2.1정보 저장
현대 컴퓨터의 메모리는 비트(bit) 한 알갱이 단위로 다루는 게 아니다. 메모리는 8비트짜리 묶음인 바이트(byte) 단위로 주소가 매겨져 있고, 각 바이트마다 고유한 번지(주소, address)를 가진다. 프로그램이 보는 메모리는 이 바이트들이 줄지어 늘어선 거대한 한 줄짜리 배열, 즉 가상 메모리(virtual memory) 공간이다. 운영체제는 이 가상 주소를 실제 물리 메모리 주소로 매핑해주는데, 자세한 건 9장 영역이고, 지금은 "프로그램이 보기에 메모리는 그냥 큰 바이트 배열"이라고 받아들이면 충분하다.
흥미로운 건 한 비트만 봐서는 그것이 문자의 일부인지, 정수의 일부인지, 함수 주소의 일부인지 알 수가 없다는 점이다. 비트 자체에는 의미가 없고, 우리가 그 비트를 어떻게 해석하느냐에 따라 의미가 부여된다. 같은 4바이트가 어떨 때는 정수 3,735,928,559이고, 어떨 때는 부동소수점 어떤 값이며, 또 어떨 때는 메모리의 한 주소다. 컴퓨터 입장에서 비트는 그냥 비트일 뿐이다.
2.1.116진수 표기법
1바이트 = 8비트는 00000000부터 11111111까지 256가지 패턴이다. 사람이 읽기엔 이진수가 너무 길고, 십진수는 비트 경계가 안 보여 불편하다. 그래서 등장하는 게 16진수(hexadecimal)다. 4비트가 정확히 한 자리에 대응하니, 비트 패턴을 4개씩 끊어 한 글자로 표시하면 끝.
| 10진수 | 2진수 | 16진수 | 10진수 | 2진수 | 16진수 |
| 0 | 0000 | 0 | 8 | 1000 | 8 |
| 1 | 0001 | 1 | 9 | 1001 | 9 |
| 2 | 0010 | 2 | 10 | 1010 | A |
| 3 | 0011 | 3 | 11 | 1011 | B |
| 4 | 0100 | 4 | 12 | 1100 | C |
| 5 | 0101 | 5 | 13 | 1101 | D |
| 6 | 0110 | 6 | 14 | 1110 | E |
| 7 | 0111 | 7 | 15 | 1111 | F |
C에서는 0x 또는 0X 접두사로 16진수를 표기한다. 0xFA1D37B처럼 대소문자를 섞어도 되지만, 보통 한 스타일로 통일한다. 16진수와 2진수 사이는 4비트씩 끊어 즉시 변환할 수 있어 편리하다. 예를 들어 0xA3F은 1010 0011 1111이다. 반대로 1101 0111 0010은 0xD72다. 16진수 ↔ 10진수 변환은 한 번씩 손으로 해보는 게 가장 빨리 익숙해진다.
꿀팁2의 거듭제곱은 16진수로 외워두면 디버깅이 즐거워진다. 210 = 0x400(1KB), 220 = 0x100000(1MB), 230 = 0x40000000(1GB). 메모리 매핑 주소가 어디 즈음인지 감이 바로 온다.
2.1.2데이터 크기
컴퓨터에는 그 기계의 "기본 정수 단위"라 할 만한 워드 크기(word size)가 있다. 32비트 머신은 워드가 4바이트, 64비트 머신은 8바이트다. 워드 크기는 가상 주소의 폭을 결정한다. 32비트 주소 공간은 최대 4GB(232바이트), 64비트는 사실상 무한에 가깝다(264은 1600만 TB쯤 된다). 요즘 PC가 8GB, 16GB 메모리를 달고 있는데도 32비트 시절을 그리워하지 않는 이유다.
C 표준은 정수 타입의 정확한 크기를 보장하지 않고 "최소" 크기만 정한다. 그래서 int가 어떤 시스템에서는 2바이트, 어떤 시스템에서는 4바이트일 수 있다. 이식성 있는 코드를 짜려면 <stdint.h>의 int32_t, uint64_t 같은 정확한 크기 타입을 쓰자.
| C 타입 | 32비트 | 64비트 | 고정 크기 별칭 |
char | 1 | 1 | int8_t |
short | 2 | 2 | int16_t |
int | 4 | 4 | int32_t |
long | 4 | 8 | (시스템마다 다름) |
long long | 8 | 8 | int64_t |
void * | 4 | 8 | uintptr_t |
float | 4 | 4 | — |
double | 8 | 8 | — |
주의리눅스 64비트에서 long은 8바이트지만, 윈도우 64비트(LLP64)에서는 long이 여전히 4바이트다. 같은 코드가 다른 OS에서 다른 결과를 낼 수 있다는 뜻. 멀티플랫폼이 목표라면 long을 쓰지 말고 int64_t를 명시하자.
2.1.3주소 지정과 바이트 순서 (엔디언)
여러 바이트로 구성된 값(예: 4바이트 정수)은 메모리에 어떤 순서로 깔릴까? 두 가지 관습이 있다.
- 빅 엔디언(big-endian): 가장 의미가 큰 바이트(MSB)를 가장 낮은 주소에 둔다. 사람이 숫자를 적는 순서와 동일하다.
- 리틀 엔디언(little-endian): 가장 의미가 작은 바이트(LSB)를 가장 낮은 주소에 둔다. 직관에 어긋나지만, 하드웨어 설계상 이점이 있다.
32비트 정수 0x01234567이 주소 0x100부터 저장되었다고 하자.
| 주소 | 0x100 | 0x101 | 0x102 | 0x103 |
| 빅 엔디언 | 01 | 23 | 45 | 67 |
| 리틀 엔디언 | 67 | 45 | 23 | 01 |
대부분의 데스크톱과 노트북이 쓰는 x86, x86-64 계열은 리틀 엔디언이다. ARM은 양쪽 다 지원하지만 보통 리틀 엔디언으로 동작한다. 네트워크 프로토콜(TCP/IP)은 빅 엔디언으로 약속되어 있어 "네트워크 바이트 순서(network byte order)"라고도 부른다. 그래서 htons, ntohl 같은 변환 함수가 존재한다.
엔디언이 문제 되는 순간은 (1) 다른 머신과 이진 데이터를 주고받을 때, (2) 정수의 바이트 단위 표현을 직접 들여다볼 때다. 같은 머신 안에서만 놀면 엔디언은 사실상 보이지 않는다.
#include <stdio.h>
// 임의 객체의 바이트들을 한 줄로 출력하는 함수
void show_bytes(unsigned char *start, size_t len) {
for (size_t i = 0; i < len; i++)
printf(" %.2x", start[i]);
printf("\n");
}
int main(void) {
int x = 0x01234567;
show_bytes((unsigned char *)&x, sizeof(int));
// x86-64 리눅스에서 출력: 67 45 23 01 (리틀 엔디언)
return 0;
}
메모"엔디언"이라는 이름은 조나단 스위프트의 걸리버 여행기에서 따왔다. 삶은 달걀을 큰 쪽부터 깨야 하느냐 작은 쪽부터 깨야 하느냐로 전쟁까지 벌이는 풍자였는데, 컴퓨터 세계에서도 이 논쟁은 본질적으로 비슷한 수준의 종교 전쟁이다. 어느 쪽이든 일관성만 있으면 된다.
2.1.4문자열 표현
C 문자열은 끝에 널 문자('\0', 즉 0x00)가 붙은 바이트 배열이다. 문자 자체는 ASCII로 인코딩한다. 알파벳 'A'는 0x41, 'a'는 0x61, '0'은 0x30이다. "Hello"를 메모리에 펼치면 48 65 6C 6C 6F 00이 된다.
ASCII는 영어권만 챙겨주는 7비트 인코딩이라 한글, 한자, 이모지를 표현 못 한다. 그래서 등장한 게 유니코드(Unicode)이고, 이를 바이트로 인코딩하는 방식 중 가장 널리 쓰이는 게 UTF-8이다. UTF-8은 ASCII와 호환되며, 한글 한 글자는 보통 3바이트, 이모지는 4바이트로 인코딩한다. 흥미로운 사실: ASCII 문자만으로 된 텍스트는 UTF-8과 ASCII의 바이트 표현이 완전히 똑같다. 그래서 옛날 코드도 그대로 동작한다.
여기서 좋은 점이 하나 있다. 문자열 자체는 바이트 단위로만 다루기 때문에 엔디언 문제가 발생하지 않는다. ASCII 문자열은 어떤 머신에서 출력해도 같은 모양이다.
2.1.5코드 표현
같은 C 소스 코드도 컴파일하면 머신마다 다른 기계어가 나온다. x86-64 리눅스에서 컴파일한 바이너리는 ARM 맥에서 직접 실행할 수 없다. 명령어 인코딩, 함수 호출 규약, 시스템 콜 번호 등이 모조리 다르기 때문이다. 그래서 "이식성"이라는 단어는 보통 소스 수준에서의 호환성을 의미한다.
요점: 비트 자체에는 의미가 없다. 그 비트를 들여다보는 프로그램(혹은 CPU)이 어떻게 해석하느냐가 모든 것을 결정한다.
2.1.6부울 대수 입문
부울 대수(Boolean algebra)는 19세기 영국 수학자 조지 부울이 만든 0과 1만 다루는 산술이다. 연산자는 4개:
- NOT (¬,
~): 0↔1 뒤집기.
- AND (∧,
&): 둘 다 1이면 1, 아니면 0.
- OR (∨,
|): 하나라도 1이면 1.
- XOR (⊕,
^): 정확히 하나만 1이면 1. 같으면 0.
이걸 비트 벡터(bit vector)에 자리별로 적용하면 비트 수준 연산이 된다. 부울 대수는 분배법칙(a & (b | c) == (a & b) | (a & c)), 결합법칙, 드모르간 법칙(~(a & b) == ~a | ~b) 등 일반 산술과 비슷하면서도 묘하게 다른 규칙들이 성립한다. XOR은 자기 역원이라는 멋진 성질이 있다: a ^ b ^ b == a. 이걸 이용하면 임시변수 없이 두 변수를 교환할 수도 있는데, 멋있어 보이지만 실제로는 안 쓴다(가독성도 떨어지고, 컴파일러가 더 빠른 코드를 만든다).
2.1.7C의 비트 수준 연산
C는 부울 연산을 정수에 그대로 적용한다. 자료형이 무엇이든(char, int, long) 비트 단위로 작동한다. 가장 일상적인 패턴 몇 가지를 보자.
// 비트 마스킹: 하위 8비트만 남기기
unsigned x = 0xABCD1234;
unsigned low = x & 0xFF; // 0x00000034
// 특정 비트만 1로 켜기 (set)
x = x | (1u << 5); // 5번 비트를 1로
// 특정 비트만 0으로 끄기 (clear)
x = x & ~(1u << 5); // 5번 비트를 0으로
// 특정 비트만 토글 (flip)
x = x ^ (1u << 5); // 5번 비트 0↔1
// 특정 비트가 1인지 검사
if (x & (1u << 5)) { /* 켜져 있음 */ }
// 비트 추출: 8~15번 비트만 뽑기
unsigned mid = (x >> 8) & 0xFF;
이런 비트 트릭은 임베디드 펌웨어, 네트워크 프로토콜 헤더 파싱, 권한 플래그(rwx) 처리, 컬러 픽셀 분해(RGBA) 등 곳곳에서 쓰인다. 이 패턴들이 손에 익으면 코드를 읽다가 "아, 여기서 권한 플래그 검사하는구나"라고 한눈에 보인다.
2.1.8C의 논리 연산
C에서 비트 연산자(&, |, ~)와 논리 연산자(&&, ||, !)는 별개다. 논리 연산자는 0이면 거짓, 0이 아니면 참으로 보고, 결과는 항상 0이나 1이다. 또 단축 평가(short-circuit evaluation)를 한다.
// 비트 vs 논리 비교
int a = 0x12, b = 0x34;
int v1 = a & b; // 0x10 (비트 단위 AND)
int v2 = a && b; // 1 (둘 다 0이 아니므로 참)
// 단축 평가: ptr이 NULL이면 *ptr 평가 안 함
if (ptr != NULL && *ptr > 0) { ... }
// 위를 잘못 적으면 NULL 역참조 크래시
// if (*ptr > 0 && ptr != NULL) { ... } // 위험!
함정가장 흔한 실수: if (x & 1)를 쓸 자리에 if (x && 1)를 쓴다. x && 1은 x != 0 ? 1 : 0이라 짝수/홀수 판별이 안 된다. 컴파일러는 경고도 잘 안 띄운다.
2.1.9C의 시프트 연산
왼쪽 시프트 x << k는 비트들을 왼쪽으로 k칸 밀고, 빈 오른쪽은 0으로 채운다. 결과적으로 2k를 곱한 효과(오버플로 무시).
오른쪽 시프트는 두 종류다.
- 논리 시프트(logical right shift): 빈 왼쪽 자리를 0으로 채운다.
unsigned 타입에서는 항상 이 동작.
- 산술 시프트(arithmetic right shift): 빈 왼쪽 자리를 부호 비트로 채운다. 음수는 음수로 유지된다.
signed 타입에서는 거의 항상 이 동작(C 표준상 구현 정의지만 거의 모든 컴파일러가 그렇다).
unsigned u = 0xF0000000;
int s = 0xF0000000; // 음수로 해석됨
u >> 4; // 0x0F000000 (논리: 0으로 채움)
s >> 4; // 0xFF000000 (산술: 1로 채움, 음수 유지)
함정시프트 양 k가 워드 크기보다 크거나 같으면 동작이 정의되지 않는다(undefined behavior). 32비트 정수에서 x << 32는 0이 될 거라고 기대하면 안 된다. 어떤 컴파일러는 0을, 어떤 컴파일러는 x 그대로를 반환한다. 이런 코드는 그냥 쓰지 말자.
연습 2.132비트 정수 x의 7번 바이트(0번이 가장 낮은 바이트)만 0xFF로 바꾸고 나머지는 그대로 두는 식을 작성하라. (힌트: 마스크 한 번, OR 한 번)
풀이: (x & ~(0xFFu << 24)) | (0xFFu << 24). 더 간단히 x | (0xFFu << 24)로도 7번 바이트를 모두 1로 만들 수 있다(이미 1인 비트는 영향 없음).
2.2정수 표현
정수를 비트로 표현하는 방식은 크게 두 가지: 음수 없는 부호 없는(unsigned) 표현과, 음수까지 다루는 2의 보수(two's complement) 표현이다. 후자가 거의 모든 현대 CPU의 표준이다.
2.2.1정수 데이터 타입
C는 같은 비트 폭에 대해 부호 있음(signed)과 부호 없음(unsigned) 두 종류를 제공한다. signed int의 최댓값은 보통 2,147,483,647이고, 같은 비트로 unsigned int를 만들면 4,294,967,295까지 표현된다. 후자는 음수를 포기한 대신 양수 범위가 두 배다.
| 타입 | 최솟값 | 최댓값 |
int8_t | -128 | 127 |
uint8_t | 0 | 255 |
int32_t | -2,147,483,648 | 2,147,483,647 |
uint32_t | 0 | 4,294,967,295 |
int64_t | 약 -9.2 × 1018 | 약 9.2 × 1018 |
2.2.2부호 없는 인코딩
w비트 부호 없는 정수의 값은 자릿값에 따른 단순 합이다.
B2U(x) = xw-1·2w-1 + xw-2·2w-2 + … + x1·2 + x0
표현 범위는 0부터 2w − 1까지. 이 인코딩의 큰 장점은 비트 표현과 값 사이가 일대일 대응이라는 것. 비트가 다르면 값이 다르고, 비트가 같으면 값이 같다.
2.2.32의 보수 인코딩
음수를 표현하는 가장 자연스러운 방법은 "최상위 비트(MSB)를 음수 가중치로 해석"하는 것이다.
B2T(x) = −xw-1·2w-1 + xw-2·2w-2 + … + x0
w비트 2의 보수의 표현 범위는 −2w-1부터 2w-1 − 1까지. 음수 쪽이 한 개 더 많다는 점이 비대칭의 묘미다. 예: 8비트라면 −128부터 +127까지. −128의 비트 패턴은 1000 0000인데, 그 부호를 뒤집어도(~x + 1) 다시 1000 0000이 나온다. +128은 8비트 2의 보수로 표현 불가능하다.
| 4비트 비트 패턴 | 부호 없는 값 | 2의 보수 값 |
0000 | 0 | 0 |
0001 | 1 | 1 |
0111 | 7 | 7 |
1000 | 8 | −8 |
1001 | 9 | −7 |
1110 | 14 | −2 |
1111 | 15 | −1 |
메모2의 보수가 우아한 이유: 덧셈 회로 하나로 부호 있음/없음을 모두 처리할 수 있다. 5 + (−3)이든 5u + 0xFFFFFFFD이든 비트 단위 덧셈은 똑같다. 캐리(올림)를 무시하면 정답이 나온다. 하드웨어가 단순해지고, ALU 회로 면적이 줄어든다. 부호-크기 표현이나 1의 보수 표현이 도태된 핵심 이유다.
2.2.4부호 있음/없음 변환
같은 비트 폭의 signed ↔ unsigned 캐스팅은 비트 패턴을 바꾸지 않는다. 해석만 달라진다. 8비트 예시:
int8_t s = -1; // 비트: 1111 1111
uint8_t u = (uint8_t)s; // 같은 비트 1111 1111, 값은 255
uint8_t v = 200; // 비트: 1100 1000
int8_t t = (int8_t)v; // 같은 비트, 2의 보수 해석 = -56
변환 공식이 있긴 한데, 외울 필요는 없고 "비트는 그대로, 해석만 바뀐다"라는 직관 하나면 충분하다. 단, 비트 폭이 다른 변환(예: int를 long으로)은 별개 이야기로 2.2.6에서 다룬다.
2.2.5C에서의 부호 있음 vs 없음
C의 정수 리터럴은 기본적으로 부호 있음이다. 12345는 int, 접미사 U를 붙이면(12345U) unsigned int가 된다. 식 안에서 부호 있는 값과 부호 없는 값이 만나면 부호 없는 쪽으로 자동 변환된다. 이게 종종 무시무시한 버그를 일으킨다.
int a = -1;
unsigned b = 1;
if (a < b)
printf("a가 작다\n"); // 직관적 기대
else
printf("a가 크거나 같다\n"); // 실제 출력!
// 이유: a가 unsigned로 승격되어 0xFFFFFFFF (= 4,294,967,295)이 됨.
// 그게 1보다 크니 else 가지를 탄다.
함정가장 악명 높은 패턴: for (int i = n - 1; i >= 0; i--)를 무심코 size_t i = n - 1; i >= 0; i--로 바꾸면 무한 루프. size_t는 부호 없음이라 절대 음수가 안 된다. 0에서 1을 빼면 SIZE_MAX가 되며 루프가 영원히 돈다.
2.2.6비트 표현 확장
좁은 타입을 넓은 타입으로 변환할 때, 부호 없음이라면 제로 확장(zero extension)으로 위쪽을 0으로 채운다. 부호 있음이라면 부호 확장(sign extension)으로 위쪽을 부호 비트(MSB)로 채운다. 이래야 값이 보존된다.
int8_t a = -1; // 1111 1111
int16_t b = a; // 1111 1111 1111 1111 (= -1) 부호 확장
uint8_t u = 0xFF; // 1111 1111
uint16_t v = u; // 0000 0000 1111 1111 (= 255) 제로 확장
왜 부호 확장이 값을 보존할까? 직관적으로, w비트 2의 보수에서 MSB의 가중치 −2w-1이 (w+1)비트로 확장되면 새 MSB의 가중치 −2w 와 옛 MSB 위치의 양수 가중치 2w-1의 합이 되어, 둘 다 1이면 결과적으로 원래 값과 같다. 식으로 풀어보면 깔끔하게 떨어진다.
2.2.7숫자 절단
넓은 타입을 좁은 타입으로 변환하면, 위쪽 비트는 그냥 잘려나간다(truncation). 부호 없음이라면 결과는 원래 값을 2w'로 나눈 나머지다. 부호 있음이라면 비트 절단 후 다시 2의 보수로 해석한다.
int x = 53191; // 0000 ... 1100 1111 1100 0111
short y = (short)x; // 1100 1111 1100 0111 = -12345 !
// "분명 양수였는데 음수가 됐다"는 전형적인 절단 버그.
32비트 양의 정수 53,191이 short (16비트)로 절단되면서 MSB가 1이 되어 음수로 해석됐다. 이게 실전에서 데이터 손상의 큰 원천이다.
2.2.8부호 있음/없음에 대한 조언
부호 없는 정수는 카운트나 크기처럼 음수가 의미 없는 값에만 쓰자. 그것 외에는 기본을 signed로 두는 게 안전하다. 비교에서 묘한 자동 변환이 일어나기 때문이다.
꿀팁인덱스로 size_t를 쓸 때 역순 루프가 필요하면, 값이 0에 닿기 전에 종료 조건을 점검하자: for (size_t i = n; i > 0; i--) { auto idx = i - 1; ... }. 또는 그냥 ptrdiff_t(부호 있는 인덱스 타입)를 쓰는 것도 방법.
2.3정수 산술
여기부터가 핵심이다. CPU의 ALU는 비트를 더하고 곱할 뿐인데, 우리는 그것이 부호 있음/없음 모두에서 "수학적으로 옳은 산술"을 한다고 믿는다. 그 믿음이 깨지는 경계가 바로 오버플로다.
2.3.1부호 없는 덧셈
w비트 부호 없는 정수 두 개를 더하면 결과는 최대 w+1비트가 된다. 위로 넘친 1비트(캐리)를 버리면, 결과는 (x + y) mod 2w다. 즉 모듈러 산술이다.
uint32_t a = 0xFFFFFFFFu; // 2^32 - 1
uint32_t b = 1u;
uint32_t c = a + b; // 결과는 0 (오버플로)
// 오버플로 검출: 합이 두 피연산자 중 어느 하나보다 작아졌다면 오버플로.
int overflow = (c < a); // 1
부호 없는 덧셈에서 오버플로는 "결과가 피연산자보다 작아진다"는 단순한 규칙으로 잡을 수 있다.
2.3.22의 보수 덧셈
비트 단위 덧셈 자체는 부호 없음과 똑같다. 다만 결과를 어떻게 해석하느냐에 따라 오버플로 조건이 달라진다.
- 양수 오버플로: 두 양수를 더했는데 결과가 음수로 나오면 오버플로.
- 음수 오버플로: 두 음수를 더했는데 결과가 양수로 나오면 오버플로.
- 부호가 다른 두 수의 합은 절대 오버플로하지 않는다.
int32_t a = 2000000000;
int32_t b = 1000000000;
int32_t c = a + b; // 약 30억은 INT_MAX(약 21억) 초과
// c는 음수로 나온다. 양수 오버플로!
// 안전 검사
int overflow_pos = (a > 0 && b > 0 && c < 0);
int overflow_neg = (a < 0 && b < 0 && c >= 0);
함정부호 있는 정수 오버플로는 C 표준상 정의되지 않은 동작(UB)이다. x + 1 > x는 항상 참이라 가정하고 컴파일러가 최적화해버릴 수 있다. 그래서 if (x + y < x) 같은 검사는 부호 있는 타입에서 통하지 않는다. 검사하려면 __builtin_add_overflow 같은 내장 함수를 쓰거나 부호 없는 타입으로 캐스팅하자.
2.3.32의 보수 부정
음수를 만드는 방법은 두 가지가 동등하다.
- 비트를 모두 뒤집고 1을 더한다:
-x == ~x + 1
- 모든 비트를 1에서 뺀 뒤 1을 더한다(같은 말).
예외가 있다. 가장 작은 음수 TMin = -2w-1의 부정은 자기 자신이다. 32비트라면 -INT_MIN이 다시 INT_MIN이다. 이건 표현 가능 범위 밖이라 그렇다. abs(INT_MIN)이 음수가 되는 충격적인 사실의 원인.
2.3.4부호 없는 곱셈
w비트 곱셈도 결과는 (x · y) mod 2w다. 위쪽으로 잘려나간 비트는 사라진다. 그래서 큰 부호 없는 정수의 곱은 진짜 결과의 하위 w비트에 불과하다.
2.3.52의 보수 곱셈
2의 보수 곱셈도 비트 패턴 자체는 부호 없는 곱셈과 동일하다. 다시 말해 ALU의 곱셈 회로는 하나면 충분하다. 다만 결과 해석이 달라질 뿐이다.
왜 x * y / x == y가 항상 참이 아닌가? 곱셈 도중 오버플로가 발생해 정보가 잘려나가면, 나눗셈으로 그 정보를 복원할 수 없기 때문이다.
int x = 100000;
int y = 100000;
int z = x * y; // 10,000,000,000은 INT_MAX 초과 → 오버플로
// 실제 결과: 1,410,065,408 (하위 32비트)
int w = z / x; // 14100 — y가 아니다!
2.3.6상수 곱셈
곱셈 명령은 옛날 CPU에서는 비싼 연산이었다. 그래서 컴파일러는 상수와의 곱셈을 시프트와 덧셈/뺄셈의 조합으로 바꾸는 트릭을 자주 쓴다.
x * 14
== x * (16 - 2)
== (x << 4) - (x << 1)
x * 17
== x * (16 + 1)
== (x << 4) + x
요즘 CPU의 곱셈기는 1~3 사이클이면 끝나니 큰 차이가 안 나지만, 컴파일러는 여전히 이런 변환을 해서 코드 크기를 줄이거나 의존성 사슬을 짧게 만든다.
2.3.72의 거듭제곱으로 나눗셈
나눗셈은 곱셈보다 훨씬 비싸다(10~30 사이클). 다행히 2k로 나누는 건 시프트 한 번으로 끝난다. 그런데 음수에서는 함정이 있다.
int x = -7;
int y = x >> 1; // 산술 시프트 → -4
// C에서 정수 나눗셈은 0 방향 절삭(truncation toward zero):
int z = -7 / 2; // -3 (소수점 -3.5 → 0 쪽으로 -3)
산술 우측 시프트는 음의 무한대 방향으로 내림(floor)을 한다. C의 정수 나눗셈은 0 방향 절삭. 이 차이 때문에 음수에서 시프트가 정확한 나눗셈이 아니다. 보정하려면 편향(bias)을 더해주는 트릭을 쓴다.
// x를 2^k로 나누되 0 방향으로 절삭하고 싶다면:
int divide_by_pow2(int x, int k) {
int bias = (x < 0) ? ((1 << k) - 1) : 0;
return (x + bias) >> k;
}
// 또는 분기 없는 버전 (부호 비트로 마스크 만들기)
int divide_by_pow2_branchless(int x, int k) {
int bias = (x >> 31) & ((1 << k) - 1);
return (x + bias) >> k;
}
음수일 때 2k − 1을 더한 다음 시프트하면, 0 방향 절삭과 일치한다. 컴파일러가 x / 8 같은 코드를 보고 자동으로 이런 형태로 변환해준다(-O2 켜면 확인 가능).
연습 2.2(x + (1 << k) - 1) >> k가 음수 x에 대해 0 방향 절삭 나눗셈 x / 2k와 같음을 증명하라.
풀이 스케치: x = -q · 2k + r 꼴로 쓰자(0 ≤ r < 2k). 0 방향 절삭은 r = 0이면 -q, 아니면 -q + 1이다. 한편 산술 시프트는 음의 무한대 방향 내림이라 (x) >> k = -q가 된다. 2k − 1을 더해주면 r = 0일 때만 자릿수가 안 올라가고, 그 외에는 한 자리 올라가서 결과가 -q + 1이 된다. 이게 정확히 0 방향 절삭과 일치한다.
2.3.8정수 산술 마무리
비트 다발의 산술은 모듈러 대수다. 무한 정밀도를 가정한 채 코딩하면 언젠가 오버플로 한 방에 무너진다. 안전 코딩 체크리스트:
- 외부 입력으로 들어온 크기/길이 값에 더하기 전에 오버플로를 검사하라.
- 부호 있음/없음 비교가 섞이지 않게 하라.
- 곱셈 결과는 더 넓은 타입으로 받아라(예:
int * int가 아니라 (int64_t)a * b).
- 의심스러우면
-fsanitize=undefined로 빌드해서 런타임 검사를 켜라.
2.4부동소수점 (Floating Point)
실수를 정확히 표현할 수는 없다. 비트 수가 유한하니, 표현 가능한 값은 유한하다. 그래서 부동소수점은 늘 근사다. 우리가 할 일은 그 근사가 어떤 규칙으로 일어나는지 이해하는 것.
2.4.1분수 이진수
10진수 3.14는 3 + 1/10 + 4/100이다. 같은 식으로 2진수 101.011은 4 + 1 + 1/4 + 1/8 = 5.375이다. 소수점 오른쪽으로 가면서 1/2, 1/4, 1/8, 1/16, ... 가중치가 붙는다.
중요한 사실: 2의 거듭제곱의 합으로 표현되는 분수만 유한 비트로 정확히 적을 수 있다. 0.5는 0.1로 OK, 0.75는 0.11로 OK. 그런데 0.1 (10진수)? 10진수 1/10은 2진수로 0.0001100110011... 무한 순환소수다. 정확한 표현이 불가능하다. 이게 0.1 + 0.2 != 0.3의 근본 원인이다.
2.4.2IEEE 754 부동소수점 표현
IEEE 754는 1985년 제정된 표준으로, 거의 모든 현대 CPU가 채택한다. 핵심 아이디어는 과학적 표기법(scientific notation): ±M × 2E 꼴로 적는 것이다.
| 형식 | 전체 비트 | 부호 (s) | 지수 (exp) | 유효숫자 (frac) | 지수 편향(bias) |
단정밀도 float | 32 | 1 | 8 | 23 | 127 |
배정밀도 double | 64 | 1 | 11 | 52 | 1023 |
비트 배치는 부호(MSB) — 지수 — 유효숫자 순. 값을 어떻게 해석하는지는 exp 필드에 따라 4가지 경우로 나뉜다.
- 정규화(normalized):
exp가 0도 아니고 전부 1도 아닐 때. 값은 (−1)s × 1.frac × 2exp − bias. "1.frac"의 1은 묵시적이라 비트로 저장되지 않는다(공짜 1비트 정밀도). 가장 흔히 쓰이는 케이스.
- 비정규화(denormalized):
exp == 0. 값은 (−1)s × 0.frac × 21 − bias. 0 근처의 작은 수를 표현. +0과 −0이 모두 존재한다(같다고 간주되지만 비트는 다름).
- 무한대(±∞):
exp가 전부 1이고 frac == 0. 1.0 / 0.0의 결과.
- NaN(Not-a-Number):
exp가 전부 1이고 frac != 0. 0.0 / 0.0이나 sqrt(-1) 같은 의미 불명 연산의 결과. NaN은 자기 자신과도 같지 않다(NaN == NaN은 거짓).
메모왜 지수에 편향(bias)을 더하나? 비교를 단순화하기 위해서. 부동소수점 두 수를 부호 비트 뺀 나머지를 부호 없는 정수처럼 비교해도 대소 관계가 맞아떨어지게 설계되어 있다. 정렬 인덱스에 그대로 쓸 수 있을 정도. 똑똑한 설계다.
2.4.3예제 숫자
구체적인 예로 6비트 짧은 부동소수점(s=1, exp=3, frac=2, bias=3)으로 표현 가능한 값을 깔아보자.
| 비트 패턴 | 의미 | 값 |
0 000 00 | +0 (비정규화) | 0 |
0 000 01 | 최소 비정규 | 1/16 |
0 000 11 | 최대 비정규 | 3/16 |
0 001 00 | 최소 정규 | 4/16 = 1/4 |
0 011 00 | 1.0 | 1 |
0 110 11 | 최대 정규 | 7 × 2 = 14 |
0 111 00 | +∞ | ∞ |
0 111 01 | NaN | — |
관찰: 0 부근에서는 비정규화 영역이 균등 간격으로 깔리고, 그 위로는 지수가 올라갈 때마다 간격이 두 배씩 벌어진다. 즉 큰 값일수록 표현 정밀도가 떨어진다. 이게 부동소수점의 핵심 본질이다.
2.4.4반올림
표현 가능한 격자 위에 정확히 떨어지지 않는 값은 반올림해야 한다. IEEE 754의 기본 모드는 짝수 자리 반올림(round-to-nearest-even)이다. 가장 가까운 표현 가능한 값으로 반올림하되, 정확히 중간이면 마지막 비트가 0인 쪽(짝수)을 선택.
왜 짝수로? 통계적 편향(bias)을 없애기 위해서. 만약 항상 위로 반올림하면 누적 합이 점점 커진다. 짝수 반올림은 위/아래로 균형 있게 흩어져 평균이 0에 수렴한다.
// 10진수 예시 (소수 둘째 자리 짝수 반올림)
1.255 → 1.26 (5 다음이 짝수가 되도록 6 선택? 아니다 — 이건 정수 자리)
2.5 → 2 (가장 가까운 짝수)
3.5 → 4
4.5 → 4
5.5 → 6
2.4.5부동소수점 연산
부동소수점 덧셈/곱셈은 교환법칙은 성립하지만 결합법칙은 깨진다. 정확히 말하면, 매번 반올림이 끼어들기 때문에 연산 순서에 따라 결과가 달라질 수 있다.
// 결합법칙 깨짐
double a = 1e20, b = -1e20, c = 1.0;
double r1 = (a + b) + c; // 0 + 1 = 1.0
double r2 = a + (b + c); // 1e20 + (-1e20 + 1) ≈ 1e20 + (-1e20) = 0!
// r1 != r2
큰 값과 작은 값을 더하면 작은 값이 묻혀 사라진다. 이걸 흡수 오차(absorption)라고 한다. 비슷한 두 큰 수를 빼면 유효숫자가 한꺼번에 사라지는 상쇄 오차(catastrophic cancellation)도 자주 일어난다.
또 곱셈/나눗셈에는 분배법칙도 깨진다. x * (y + z) != x*y + x*z가 흔히 일어난다. 그래서 부동소수점 코드를 컴파일러가 함부로 재배치하지 못한다(-ffast-math 같은 위험 플래그가 있긴 하지만 권장하지 않음).
함정if (a == b)로 부동소수점을 직접 비교하지 말 것. 항상 허용 오차(epsilon)와 함께: fabs(a - b) < eps. 단 eps는 절대 오차/상대 오차 중 무엇을 쓸지 상황에 맞게 선택. 큰 수에서는 절대 오차 1e-9가 사실상 무의미하다.
2.4.6C에서의 부동소수점
C는 float(32비트), double(64비트), long double(플랫폼 의존, 80~128비트)을 지원한다. 정수와 부동소수점 사이 변환에 주의할 점:
int → float: 큰 정수는 정밀도 손실 가능. float는 유효숫자가 24비트라 32비트 정수를 모두 정확히 못 담는다.
int → double: 32비트 정수는 항상 정확히 변환 가능(double의 유효숫자가 53비트).
float/double → int: 0 방향 절삭. 표현 범위를 벗어나면 결과는 정의되지 않음.
int i = (1 << 24) + 1; // 16,777,217
float f = (float)i;
int j = (int)f;
// j == 16,777,216 (1 손실!)
// 24비트가 넘는 정수부는 float에서 정확히 못 든다.
함정금융 계산은 절대 부동소수점으로 하지 말 것. 0.1 + 0.2 != 0.3 정도의 오차도 누적되면 0.01원이 어디론가 사라지거나 생기는 회계 사고가 된다. 정수 단위(원, 센트)로 환산해 정수 산술로 처리하거나, 십진 부동소수점 라이브러리를 쓰자.
연습 2.3float로 정수 16777217을 정확히 표현할 수 있는가? double은? 이유와 함께 답하라.
풀이: 16777217 = 224 + 1. float의 유효숫자는 묵시적 1비트 포함 24비트라 224+1은 정밀도 한 비트가 부족해 표현 불가, 16777216이나 16777218로 반올림된다. double의 유효숫자는 53비트라 253+1까지는 정확하므로 16777217은 문제없이 표현된다.
2.5요약
이번 장에서 얻은 가장 중요한 통찰은 단 한 줄로 요약된다: 비트 자체는 의미가 없다. 해석이 의미를 만든다. 같은 바이트가 signed int인지 unsigned int인지 float인지 ASCII 문자인지에 따라 전혀 다른 값이 된다. C의 캐스팅은 그 해석을 바꾸는 행위지, 비트를 바꾸는 행위가 아니다(예외: 정수 ↔ 부동소수점은 비트 변환이 일어난다).
- 2의 보수는 음수와 양수를 같은 덧셈 회로로 처리하는 우아한 방식. 표현 범위는 비대칭(
−2w-1~+2w-1−1).
- 정수 산술은 모듈러 산술이다. 오버플로는 항상 가능하며, 부호 있는 오버플로는 C 표준상 UB라 컴파일러가 마음대로 최적화한다.
- 비트 시프트는 빠르지만, 음수 우측 시프트의 반올림 동작이 0 방향 절삭과 다르다는 점을 기억하자.
- 부동소수점은 늘 근사다. 결합법칙·분배법칙이 깨지고, 큰 수에 작은 수를 더하면 흡수된다. 직접
== 비교 금지.
- 이식성 있는 코드를 위해
<stdint.h>의 고정 크기 타입과 명시적 캐스팅을 쓰자.
이 장의 내용은 다음 3장(기계 수준 표현)에서 즉시 활용된다. 어셈블리 명령이 부호 있음/없음 곱셈을 어떻게 구분하는지, 분기 명령이 어떤 플래그를 보는지를 이해하려면 여기 내용이 손끝에 있어야 한다. 디버거에서 메모리 덤프를 보는 능력도 결국 이번 장의 비트 ↔ 해석 사고에서 나온다.
꿀팁스스로 점검: (1) -1 > 0u의 결과를 즉시 답할 수 있는가? (2) (int)0xFFFFFFFF의 값은? (3) 0.1 + 0.2 - 0.3의 부호는? 모두 답할 수 있으면 이 장은 충분히 흡수한 것이다. 한 개라도 막히면 해당 절을 다시 펴보자.