강의/system programming

[system programming] Bits, Data Types, and Operations (데이터 타입의 비트 표현법과 비트 연산)

하기싫지만어떡해해야지 2025. 3. 11. 21:37

본 게시글은

서울대학교 데이터사이언스대학원 성효진 교수님의

데이터사이언스를 위한 컴퓨팅 시스템 강의를

학습을 목적으로 재구성하였습니다


오늘은 시스템 프로그래밍 수업의 두 번째 시간

컴퓨터에서 가장 기본이 되는

데이터 타입과 0과 1만 인식하는 컴퓨터가

이러한 데이터 타입을 어떻게 표현하는지에 대한 내용을 배웠다

 

 

 

 

어떤 프로그램을 컴퓨터로 표현할 때

결국 컴퓨터 내부에서는 그 프로그램을 0과 1로 해석한다

 

이러한 0과 1의 단위를 Bits(비트)라고 하고

컴퓨터는 이러한 비트를 어떠한 방식으로 조합하고

어떠한 방식으로 표현을 하는지 약속이 되어있다

 

이번 수업은 비트 표현법의 약속에 대한 내용이고

이는 전자적으로 컴퓨터가 정보를 저장하기 위한 방법이다

 

아래 그래프를 보면 0일 때는 전압이 0.0V에서 0.2V

1일 때는 0.9V에서 1.1V인 것을 볼 수 있다

 

 

 

우리가 흔히 0과 1로 데이터를 표현한다고하면

이진법을 떠올릴 것이다

컴퓨터와 친해지기 위해서는 이진법 표현법에 익숙해야하는데

간단하게 이진법을 좀 알아보자

 

이진법은 0과 1로 수를 표현하는 방식이다

가장 큰 수가 왼쪽이고 오른쪽으로 가면서 작아진다

이미 다 알겠지만 위의 예시를 간단하게 살펴보자

 

십진수인 15213은 11101101101101로 표현할 수 있다

1.20에서 정수부인 1은 이진법으로 1이고

그 아래 0.20의 이진표현은 0011이 반복되는 순환소수이기때문에

1.0011001100110011[0011]과 같은 방식으로 표기한다

그다음 마지막으로 1.5213 * 10의 4제곱은

앞의 1.5213을 이진법으로 표현한다음

10의 4제곱을 2의 13제곱으로 표현하는데

이는 정확하게 10의 4제곱 = 10000이 2의 13제곱은 아니지만

2의 제곱수 중 가장 10000에 가까운 수이기 때문에

그냥 그렇게 표현한다고한다

 

 

우리가 컴퓨터에서 흔히 쓰는 단위인 Byte(바이트)는

8비트이다 (1 Byte = 8 Bits)

 

따라서 1바이트는 00000000부터 11111111까지

표현이 가능하며

십진수로 표현하면 0부터 255까지

16진수로 표현하면 00부터 FF까지이다

 

보통 16진수를 표현하는 단위를 word라고 부르는데

이게 컴퓨터 시스템에서 매우 중요한 개념이다

왜냐하면 이 word 단위가 프로그램을 access할 수 있는

최적의 단위이기 때문이다

 

예를 들면 보통 int는 4바이트의 크기를 갖는데

그 바이트를 word단위로 저장한다고한다

또한, c나 c++ 프로그래밍을하며

어떤 주솟값을 출력하면 0xFA1D37B와 같은 형식이

나오는 것을 확인할 수 있는데

이도 word단위로 저장되는 것이라고 한다

 

우리가 흔히 알고있는 각 데이터타입의 크기이다

위 ppt는 c언어의 데이터 타입이다

 

데이터 타입 중 크기가 가장 작은 것은 char이며 1바이트이고

range가 작은 int인 short는 2바이트라고한다

그리고 흔히들 알고있는 int는 4바이트이다

 

그 이후의 데이터타입들은

하드웨어에 따라 크기가 달라질 수도 있다고한다

 

예전과 같은 경우는 위처럼

고정된 크기를 갖는 data type을 사용하는 것이

국룰(?)이었다

 

하지만 AI 시대가 되고나서

인공지능 학습시키며 가중치 계산 할 때

가중치의 precision을 16비트로 변환하는 것을

익숙하게 볼 수 있을 것이다

 

원래 인공지능 가중치의 data type인 float은

32비트가 표준이지만

인공지능의 빠른 학습을 위해

16비트, 혹은 8비트로 정확도를 줄여서 계산을 하는 것이다

 

이는 메모리 효율을 위하여

정확도를 어느정도 trade off 하는 것인데

인공지능 학습 시에 이런 일들이 많이 발생한다

 

 

 

이제는 이러한 비트를 어떻게 조작하는지

Bit Manipulation에 대해 배워보자

 

Boolean Algebra는 한국어로 부울대수라고하며

하드웨어를 구성하는 가장 기본적인 로직이다

이를 정의한 사람이 Boole(부울)이고

그 사람의 이름을 따서 부울대수라고 부른다고한다

이 부울대수는 컴퓨터에서

0과 1의 데이터를 바꿀 수 있는 수학적인 정의이다

 

부울대수에 대해서 간단히 살펴보면

AND

둘다 1이어야 1이고

다른 경우는 다 0

A&B = A and B

 

OR

둘다 0이어야 0이고

다른 경우는 다 1

A|B = A or B

 

NOT

0이면 1로

1이면 0으로

반대로 변환해주는 것

~A = not A

 

XOR

두개가 같으면 0이고

다르면 1

A^B = A xor B

 

이 부울대수는 하드웨어 계산의 가장 최소 단위이며

이를 조합해서 다양한 하드웨어를 만든다

 

 

 

간단하게 부울대수를 활용한

비트연산에 대해 알아보자

 

이미 이산수학이나 다른 수업에서

많이 배웠을 것인데

위의 부울대수를 잘 생각하며 적용해보면 쉽다

 

각각 왼쪽부터 오른쪽 순서대로

And, Or, Not, Xor 연산이다

 

 

 

보통 이런 비트를 집합(set)으로 어떻게 표현하냐면

1인 비트의 index를 표기한다

01101001이 있으면

오른쪽부터 Index 0으로 시작해서 마지막이 7이다

위 예시에서 1인 비트가 있는 index는

0, 3, 5, 6이므로 이를

{0, 3, 5, 6}으로 표현한다


아래 operation은

위 예시의 두 비트 벡터를

각각 연산을 하였고

이를 집합으로 표현한 결과이다

 

 

 

이러한 bit-level operation을

c언어에서 한 번 알아보자

 

각 &, |, ~, ^는 c언어에서 사용이 가능한데

연산자를 앞에 붙여주면 사용이 가능하다

 

integral로 표현되는 모든 데이터타입에

적용이 가능하다

 

char 데이터로 예시를 든 위 ppt를 보면

~0x41, ~0x00, 0x69 & 0x55와 같은 식으로

사용해주면된다

 

 

 

학생들이 이러한 비트 연산자와

Logic operation(논리 연산자)를 많이 헷갈려하는데

헷갈려하면 안된다

 

가장 큰 차이는 논리 연산자는

결과값이 무조건 true 아니면 false가 나온다는 것이다(1 or 0)

 

논리 연산자는 &&, ||, !이 있다

 

보통 비트 연산자가 1개

논리 연산자가 2개씩 붙어있다고 생각하면 쉽다

 

위 예시는 논리 연산자의 예시인데

모두 결과값이 0x00 혹은 0x01이 나오는 것을 알 수 있다

 

 

 

이제 shift operation에 대해 알아보자

shift operation은 비트값을 옆으로 하나씩 미는 것이다

left shift는 왼쪽으로 미는 것이고

right shift는 오른쪽으로 미는 것이다

 

비트 단위에서 하나의 자리수는 2의 제곱으로

계속 커지기 때문에

left shift는 기존의 수에다가 2를 곱하는 것과 같고

right shift는 2를 나누는 것과 같다

 

만약 << 3과 같이 쓰면

3개의 자리만큼 left shift를 했고

나머지는 0으로 채운다

그리고 밀려나는 세개의 비트는 그냥 버린다

 

right shift에는 채워지는 값을

어떻게 처리하냐에 따라

logical shift와 arithmetic shift로 구분되는데

logical shift는 그냥 0으로 채우는 것이고

arithmetic shift(산술 shift)는 최상위비트(MSB)의 값에

맞게 채워주는 것인데 이는 숫자의 부호와 관련이 있다

 

 

 

앞에서 MSB를 얘기하며 수의 부호 얘기가 나왔는데

이제 우리가 흔히 쓰는 integer에서

signed integer와 unsigned integer에 대해 알아보자

 

쉽게 말하면 signed integer는 부호가 있는 int이고

unsigned integer는 부호가 없는 int이다

 

ppt 가장 위의 unsigned 계산을 보면

비트 수가 w개라면

2의 0제곱부터 2^w -1까지의 값을 표현한다

 

그래서 많이 사용하는 unsigned integer의 경우

4 * 8 = 32비트의 공간을 차지하므로

0부터 2^32 -1까지만큼 표현한다

 

 

반대로 signed integer의 경우 부호가 있는 숫자인데

가장 왼쪽의 값 Most Significant Bit(MSB) 값에따라

음수 혹은 양수인지 구분한다

MSB가 0이면 양수, 1이면 음수가 된다

 

위 B2T(X)식에서 시그마가 누락되어있는데

 

수식은 이렇다

 

그러면 c언어에서 15213과 -15213을

이진법으로 확인해보자

 

위 ppt를 보면

15213은 00111011 01101101

-15213은 1100100 10010011

임을 알 수 있다

 

위 논리에 따르면 MSB만 다르고

나머지 비트는 똑같아야하는 것이 아닌가?

위 예시를 보면 거의 완전 반대로

비트가 표현되어있음을 확인할 수 있다

 

왜 이렇게 되어있는 것일까?

 

이는 바로 이전의 많은 학자들이 연구하다가

MSB만 반대로하여 부호와 수를 표기하는거의

치명적인 허점을 발견했기 때문이다

 

바로 이렇게 하면 0을 표현하는 방식이

2개가 되어버린다는 점이다

 

MSB가 0으로 표현된 0과

1로 표현된 0

이렇게 2개가 생겨버리는데

이게 별거 아닌 것 같지만

이렇게 0을 표현하는 방식이 2개라는 점은

컴퓨터가 연산할 때 굉장히 치명적인 문제가 된다

 

이에 따라 Two's Complement(2의 보수)라는 방법으로

음수를 비트로 표현해주기로 한다

 

2의 보수는 원칙적으로는

2^n제곱에서 원래 수를 뺀 값이다

하지만 일반적으로

전체 비트를 반전해준다음 1을 더해주는 방식으로

편하게 계산한다

 

어떻게해서 이렇게 편하게 계산되는지는

다음 슬라이드에서 알아보도록하자

 

 

 

15213과 -15213을 각각 계산한 방법이다

앞의 내용을 잘 이해했다면

크게 어렵지 않을 것이다

 

15213은 그냥 일반 이진법 계산대로

계산하여 다 더한 것이고

-15213은 가장 왼쪽 비트가 1이기 때문에

이게 32768이 되고

원래 수에서 32678을 빼면 되는 것이다

 

 

2의 보수가

모든 비트 반전에 1을 더하면 되는 것임을 증명해보자

증명도 아닌 간단한 이해..?

A + (-A) = 0 이고

1 + (-1)도 0이 된다

 

따라서 A + (-A) = 0 = 1 + (-1)인데

이를 이항하면

-A = (-1 -A) + 1이 된다

그런데 -1-A는 ~A와 같다

 

따라서 -A = ~A + 1이 되어버리기 때문에

sign bit를 포함해서 전체를 뒤집은 다음

마지막 자리에 1을 더해준 것과 같은 결과가 나오게 된다

 

 

수의 범위에 대한 내용이다

 

W=16인 수의 unsigned, signed의 min max값이다

여기서 외워두면 좋은 점은

부호가 있을 때 모든 비트가 1이면 -1이 된다

 

UMax의 경우 MSB를 부호로 치지않기 때문에

모든 비트가 1일 경우 65535인 max값이 나오게된다

 

 

각 비트 크기 별

usigned와 signed의 max, min 값이다

 

이런 int max, long max값이 그렇게 낯설지는 않을텐데

그 이유는 우리가 코딩할 때 흔히 많이 사용하기 때문이다

 

이런 max, min 값은 c나 c++언어에서

매크로로 정의되어있으며

다들 한 번씩 해봐서 알겠지만

for loop 돌면서 최솟값, 최댓값 찾아줄 때

보통 가장 처음 값을 INT_MAX 혹은 INT_MIN

이런 식으로 선언해준다

 

이러한 max나 min값은 고정된게 아니고

하드웨어마다 달라질 수 있다

 

 

unsigned와 signed의 numeric value이다

signed는 맨 왼쪽의 비트가 1로 바뀌면서

음수로 바뀌는 것을 확인할 수 있다

 

 

 

이러한 signed와 unsigned의

type casting에 관한 내용이다

 

사실 이 내용은 어느정도 생략하려고한다

 

왜냐하면 signed와 unsigned를

casting 할 수 있지만

이는 굉장히 위험한 casting 방법이며

실제로 프로그래밍을 할 때 반드시 "지양"해야하는 것이기 때문이다

 

이런식으로 위험한 캐스팅을 계속 하다보면

내가 예상하지 않았던 값이

나도 모르는 사이에 들어갈 수 있다고한다

 

 

보통 conventional적으로

u와 같은 suffix가 없으면

signed integer로 이해한다

 

 

컴파일 도중 w bit크기의 어떤 데이터타입을

(ppt에서는 signed integer)

w+k bit크기로 확장시켜야 할 때가 있다

 

이럴 때 작은 비트에서 큰 비트로

확장시켜주는 것을 Sign Extension이라고 하는데

새로 채워지는 곳에

MSB에 있는 비트를 채워준다

 

한 마디로 양수인 경우 확장되는 곳에 0이 들어가고

음수인 경우는 1이 들어가게 된다

 

 

예시를 한 번 살펴보자

2바이트의 15213을 4바이트로 확장하면

15213은 양수기 때문에 MSB가 0이고

확장되는 앞의 2바이트는 0으로 채워진다

 

반면에 2바이트의 -15213을 4바이트로 확장할 때

-15213은 MSB가 1이기 때문에

확장되는 앞의 2바이트가 1로 채워진다

 

 

 

반대로 큰 크기의 비트를 작은 크기의 비트로 줄이는 것을

truncate라고 하는데

이는 상위 비트를 제거하고 하위 비트만 남기게 된다

 

수학적으로 이렇게 하위 비트만 남기게 되는 것이

mod operation(나머지 연산)과 동일한 결과를

갖게 되므로

truncate의 결과는 mod operation과 비슷해진다

 

다음으로는 unsigned addition이다

unsigned끼리 더하는 연산인데

w bits 크기의 두 데이터가 더해지면서

자릿수가 올라가면 w+1 bits 크기의 데이터가 나올 수 있다

 

이러한 경우에 이를 처리하는 방식이 2가지가 있는데

Standard Addition Function은

overflow가 되는 비트를 무시하는 truncate 방식이고

Modular Arithmetic 방식은

초과되는 부분을 모듈러 연산을 통해

나머지 값에 대해서만 값을 남겨놓아

비트 범위 내로 조정하는 방식이다

 

 

 

시각화를 하면 이렇게 된다고 한다

 

 

overflow가 되었을 때

modular 연산을 수행한 모습이다

 

 

 

2의 보수 더하기에 대한 내용이다

2의 보수 연산에서도

위 unsigned addition과 동일하다

overflow가 된 부분을 그냥 truncate한다

 

 

곱하기에서는 어떨까

 

곱하기는 당연히 더하기보다

overflow가 날 가능성이 증가하게된다

 

곱하기 연산을 하면 원래 크기였던 w bits보다

큰 값이 나타날 수 있게 되고

값을 정확하게 표현할 수 있는

result range보다 더 커질 수 있다

 

따라서 너무도 당연하지만

곱셈을 할 때는 데이터 타입을 생각보다 더 크게 정해야한다

 

또 c라이브러리 중에서 arbitrary precision이라는

library도 있는데

이런 비트 범위를 벗어나는 연산을

자동으로 처리해주는 라이브러리라고한다

내부적으로 result range를 벗어나면

자동으로 값을 더 큰 데이터타입으로 옮겨준다고한다

 

 

 

unsigned의 곱하기 연산이다

이도 비슷하게

overflow된 비트값들을 버리거나

아니면 mod 연산을 수행해서 나머지만 남긴다

 

 

 

아까 위에서 shift를 표현하면서

left shift는 기존 값에 2를 곱해주는 결과와

동일하다고 말을 했었다

 

위에서 multiplication 연산을 봐서 알겠지만

하드웨어적으로 multiplication 연산은

구현이 굉장히 복잡하다

 

따라서 2를 곱할 일이 있다면

multiplication 연산을 수행하는 것보다

left shift를 하는 것이 훨씬 편하다

 

따라서 우리의 컴파일러는

2를 곱하는게 확실하다면

그냥 left shift를 때려버린다

 

 

나누기도 마찬가지

 

divide 연산은 하드웨어적으로

굉장히 복잡한 연산이기 때문에

2를 나눌일이 있다면 right shift를 때려버린다

 

그렇다면 근본적인 질문

우리는 unsigned를 왜 어떨 때 사용해야하는가?

 

우선 unsigned에 대해서 정확하게 이해하지 못한다면

"절대 사용하지마라"

 

실제로 시스템 프로그래밍 수업들으면서

가장 많이 듣는 말이

"underlying에서 무슨 일이 일어나는지도 모르면서

그걸 어떻게 사용하지?" 였다..

(근데 그게 abstraction 아닌가?)

 

아무튼 unsigned integer를 사용해야하는 경우는

값이 항상 overflow 되지 않으면서

modular한 값을 계속 가질 때 의미가 있는 경우에

사용해야한다고 한다

 

signed같은 경우는

결과가 원하는 범위 내에 있을 때만 가능하다고 한다

 

 

위 예시를 보고 오늘 수업을 마무리하려고한다

array a의 index를 탐색해야하는 i를

unsigned로 정의해준다

 

index는 0부터 시작하는 양수 값이라

unsigned로 정의해도 괜찮을 것 같지만

c언어 표준에서는 unsigned addition은

나머지 연산과 동일하게 작동하기때문에

프로그래머가 의도한대로 작동하지 않을 수가 있다

 

따라서 c언어에서는 보통

데이터타입의 크기를 표현하기위해

size_t라는 unsigned value의 데이터 타입이 있고

이를 사용하는 것이 좋다

 

 

다음 시간에는

pointer, string 등이 메모리에서 어떻게 표현이 되는지

알아본다고한다

 

그럼 오늘 수업은 여기까지 ..-!