이 통찰은 16진수 변환, 비트 연산, 2의 보수, signed/unsigned 변환, 그리고 오버플로우까지 — 이 포스트의 모든 주제를 관통합니다. 각 섹션에서 이 원리가 어떻게 드러나는지 주의 깊게 살펴보세요.
1. 수 체계 — 16진수가 존재하는 이유
16 = 2⁴, 그래서 한 자리가 정확히 4비트
컴퓨터는 전압의 두 상태(높음/낮음)를 이용하여 모든 데이터를 이진수(binary)로 처리합니다. 하지만 10100011처럼 긴 비트열을 사람이 읽기는 어렵습니다.
이 문제를 해결하는 것이 16진수(hexadecimal)입니다. 핵심 원리는 단순합니다: 16 = 2⁴이기 때문에, 16진수 한 자리는 정확히 4비트와 1:1 대응됩니다. 거듭제곱 계산 없이, 각 자리를 독립적으로 변환하면 됩니다. 반대로 이진수를 오른쪽부터 4비트씩 끊으면 바로 16진수가 됩니다.
Q1
0xA3을 이진수로 변환하려면 어떻게 해야 할까요?
각 자리를 독립적으로 변환합니다. A→1010, 3→0011, 이어 붙이면 10100011.
16 = 2⁴이기 때문에 이 단순한 방법이 작동합니다. 자릿값 계산(A × 2⁴ + 3)이 내부적으로 하는 일이 결국 "4비트 그룹을 이어 붙이는 것"과 동일하기 때문입니다.
변환 방향:
A
3
1010
0011
16진수 한 자리 = 정확히 4비트 (16 = 2⁴)
2. 비트 연산의 직관
AND, OR, XOR, NOT — 비트를 다루는 네 가지 도구
비트 연산은 각 비트 위치에서 독립적으로 작동하는 도구입니다. 각각의 역할을 "도구"로 이해하면 직관적입니다:
AND = 마스킹 — 특정 비트만 추출
OR = 합치기 — 특정 비트를 켬
XOR = 토글 — 특정 비트를 뒤집음
NOT = 전체 반전
XOR의 특별한 성질: x ^ x = 0, x ^ 0 = x. 자기 자신에 대한 역원이 존재하기 때문에, XOR swap이나 암호화에 활용됩니다.
주의
비트 연산 vs 논리 연산: &와 &&는 근본적으로 다릅니다. 비트 연산은 각 비트 위치에서 독립적으로 연산하고, 논리 연산은 피연산자 전체를 "0이냐 아니냐"로만 판단합니다.
위험한 예: x = 0x01, y = 0x02일 때 x & y = 0(거짓!), 하지만 x && y = 1(참).
Q1
8비트 값에서 하위 4비트만 추출하려면 어떤 비트 연산을 사용해야 할까요?
x & 0x0F로 마스킹합니다. 0x0F(= 00001111)에서 1인 위치의 비트만 통과하고, 0인 위치는 무조건 0이 됩니다. 이것이 AND의 "마스킹" 역할입니다.
Q2
0x66 & 0x93과 0x66 && 0x93의 결과가 왜 다를까요?
&는 비트 단위로 연산합니다: 0110 0110 & 1001 0011 = 0000 0010 (= 2).
&&는 "0x66은 0이 아니니 참, 0x93도 0이 아니니 참, 참 && 참 = 1"로 판단합니다. 비트 패턴을 전혀 보지 않고 0인지 아닌지만 봅니다.
x= 0x66
y= 0x93
&
r= 0x02
마스킹 — 1인 위치만 원래 값이 통과합니다
3. 2의 보수 — 음수를 표현하는 가장 영리한 방법
하드웨어를 단순하게 만드는 가장 영리한 선택
이진수로 음수를 어떻게 표현할까요? 부호-크기(sign-magnitude) 방식은 MSB를 부호 비트로 사용하지만, 덧셈 시 부호 비교, 절댓값 비교, 뺄셈 로직이 필요하고, 0이 두 개(+0, -0) 존재하는 문제가 있습니다.
2의 보수(two's complement)는 이 모든 문제를 해결합니다. 동일한 덧셈 회로 하나로 모든 부호 조합의 연산을 처리하며, 0의 표현이 하나뿐입니다.
핵심 원리
4비트에서 -2 + 2 = 1110 + 0010 = 10000. 캐리(넘침)가 자연스럽게 버려져 0000이 됩니다. 별도의 뺄셈 회로가 필요 없습니다!
비대칭 구조: n비트 2의 보수의 범위는 -2^(n-1) ~ 2^(n-1)-1. 음수가 하나 더 많습니다 (TMin = -TMax - 1). -0이 없으므로 그 자리에 음수를 하나 더 표현하기 때문입니다.
TMin의 특수성: -TMin = TMin. 부정해도 자기 자신이 됩니다. 이것이 abs(INT_MIN)이 음수를 반환하는 실제 버그의 원인입니다.
Q1
부호-크기 방식 대신 2의 보수가 채택된 근본적인 이유는 무엇일까요?
하드웨어 단순성입니다. 부호-크기에서는 부호 비교 → 절댓값 비교 → 뺄셈 or 덧셈 → 결과 부호 결정이라는 복잡한 과정이 필요합니다. 2의 보수에서는 부호에 관계없이 동일한 덧셈기 하나로 모든 연산을 처리합니다.
Q2
4비트에서 TMin(1000)을 부정(비트 반전 + 1)하면 어떤 일이 일어날까요?
1000 → 0111 → 1000. 자기 자신으로 돌아옵니다. +8은 4비트 2의 보수(범위 -8~+7)로 표현 불가능하기 때문입니다. 이것은 실제로 abs(INT_MIN)이 여전히 음수를 반환하는 버그의 원인이 됩니다.
-8
+4
+2
+1
MSB
가중치 계산:
(1 × -8) + (0 × 4) + (1 × 2) + (1 × 1) = -5
Signed (2의 보수)
-5
Unsigned
11
핵심 통찰
동일한 비트 패턴 1011이 signed로는 -5, unsigned로는 11로 해석됩니다. 비트는 같지만 해석이 다릅니다!
TMin
-8
1000
TMax
7
0111
4. Signed ↔ Unsigned, 보이지 않는 함정
비트 패턴은 그대로, 해석 방식만 변경
C에서 signed와 unsigned를 같은 표현식에 섞으면, signed가 unsigned로 암묵적 변환됩니다. 규칙은 단순합니다: 비트 패턴은 그대로, 해석 방식만 변경.
Q1
-1 < 0u의 결과가 참일까요, 거짓일까요? 왜 그런지 비트 패턴을 추적해 보세요.
거짓입니다. -1은 모든 비트가 1인 패턴(0xFFFFFFFF)이고, unsigned로 재해석되면 그 타입의 최댓값이 됩니다. 비교가 4294967295 < 0이 되어 거짓이 되죠. 비트 패턴은 그대로인데 해석만 바뀌어 전혀 다른 값이 된 것입니다.
실전 함정
unsigned int len = strlen(input); // len = 5
int diff = len - 10; // unsigned 연산! → 매우 큰 양수
if (diff < 0) { ... } // 의도대로 동작하지 않음
부호 확장(Sign Extension): 작은 타입 → 큰 타입으로 변환할 때, 부호 비트와 같은 값으로 상위 비트를 채웁니다. 새로 추가된 비트들의 가중치 합이 원래 MSB의 가중치와 동일해지기 때문에 값이 보존됩니다.
절삭(Truncation): 큰 타입 → 작은 타입. 상위 비트를 잘라냅니다. 본질은 모듈러 연산: 결과 = 원래값 mod 2^n.
오버플로우(Overflow)도 같은 원리입니다. Unsigned 오버플로우는 예측 가능하지만(C 표준 정의), signed 오버플로우는 부호가 뒤집힐 수 있고, C에서 undefined behavior입니다. 컴파일러가 signed 오버플로우가 없다고 가정하고 최적화하여 보안 검사를 무력화하는 사례도 있습니다.
Q1
4비트 2의 보수 값 1011(-5)을 8비트로 확장하려면 상위 비트를 무엇으로 채워야 할까요? 왜?
부호 비트인 1로 채웁니다. 결과 11111011을 부정하면 00000100 + 1 = 00000101 = 5이므로, 원래 값 -5가 보존됩니다.
원리적으로는 새로 추가된 1들의 가중치 합(-128+64+32+16+8 = -8)이 원래 4비트 MSB의 가중치(-8)와 정확히 같아지기 때문입니다.
Q2
4비트 signed에서 5 + 4를 계산하면 어떤 일이 일어날까요?
0101 + 0100 = 1001이고, 이를 signed로 해석하면 -7입니다. 양수 + 양수가 음수가 되어버렸습니다. 결과가 범위(-8~+7)를 넘어서 9 - 16 = -7로 감싸진 것이며, 이것이 signed 오버플로우의 위험성입니다.
4비트 입력 (클릭하여 토글):
= -5 (signed)
↓ 부호 확장 ↓
8비트 결과:
= -5 (signed)
■ 부호 확장 비트 (부호 비트 1로 채움)■ 원래 비트
✓ 값이 보존됨: -5 → -5
오버플로우 계산기 (4비트)
+
수학적 정확 결과
18
실제 저장값 (4비트)
2(0010)
결과 = 18 mod 16 = 2
⚠ 오버플로우! 18은 4비트 unsigned 범위(0~15)를 벗어남
6. Data Lab — 이론에서 실전으로
제한된 연산자로 함수를 구현하는 퍼즐
Data Lab은 CS:APP의 과제로, ! ~ & ^ | + << >> 만으로 함수를 구현하는 퍼즐입니다. 이 포스트에서 다룬 개념들이 어떻게 실전 테크닉으로 연결되는지 살펴봅시다.
면접 포인트
"비트 연산만으로 조건문을 구현하는 방법을 설명해 보세요."
핵심은 비트마스크 생성 + 선택입니다. 먼저 ~(!!x) + 1로 조건에 따른 전체 비트마스크(0x00000000 또는 0xFFFFFFFF)를 만들고,(mask & a) | (~mask & b)로 값을 선택합니다. 이것은 조건문 없이도 branchless하게 동작하며, 파이프라인 예측 실패를 방지하여 성능에도 유리합니다.
마무리
동일한 비트 패턴이라도 해석 방식에 따라 전혀 다른 값이 됩니다.
16진수와 이진수의 1:1 대응, 비트 연산의 독립적 처리, 2의 보수에서의 signed/unsigned 해석 차이, 그리고 확장/절삭/오버플로우의 모듈러 산술까지 — 이 모든 것의 근본에는 "비트 패턴은 하나지만 해석은 여러 가지"라는 통찰이 있습니다. Data Lab은 이 통찰을 제한된 도구만으로 실전에 적용하는 훈련입니다.