C · 기본
메모리 그림으로 배우는 C
C 자료형과 크기 - sizeof 바이트 순서 부호와 오버플로 (C 기초 2장)
sizeof와 limits.h로 자료형 크기를 재고, 정수의 바이트를 직접 찍어 엔디언을 확인하며, 부호 없는 순환과 부호 있는 오버플로를 UBSan으로 구분한다.
개발자 · 원고 갱신
이 장에서 배우는 것
1장에서 소스가 실행 파일이 되는 과정을 봤다. 이제 그 실행 파일 안에서 값 하나가 차지하는 칸을 들여다본다. C의 변수는 "이름 붙은 바이트 묶음"이다. 몇 바이트인지, 그 바이트를 부호 있는 수로 읽을지 없는 수로 읽을지가 자료형이다.
sizeof로 자료형의 크기를 확인하고, 크기가 플랫폼마다 다를 수 있음을 안다.- 정수가 메모리에 바이트 단위로 어떤 순서로 저장되는지(엔디언) 직접 찍어 본다.
- 부호 없는 정수의 순환(wrap-around)과 부호 있는 정수 오버플로(정의되지 않은 동작)를 구분한다.
- 크기가 고정된
int32_t,uint8_t같은 자료형과 출력 매크로를 쓴다.
문제 상황
센서 보드에서 받은 방문(이벤트) 수를 int에 누적하는 코드가 몇 달 잘 돌다가 어느 날 음수를 보고했다. 또 재고 수량을 unsigned int로 바꿨더니 "재고 3개에서 5개 판매" 같은 잘못된 입력이 들어오자 재고가 42억으로 표시됐다. 두 사고 모두 코드 한 줄에는 문제가 없어 보인다. 원인은 값이 담긴 칸의 크기와 해석 규칙에 있다.
메모리 그림: int 하나는 4바이트
int n = 0x12345678; (이 기계: int 는 4바이트, 리틀 엔디언)
주소 → +0 +1 +2 +3
┌─────┬─────┬─────┬─────┐
n │ 78 │ 56 │ 34 │ 12 │ 낮은 주소에 낮은 자리 바이트
└─────┴─────┴─────┴─────┘
int m = -1; 2의 보수: 모든 비트가 1
┌─────┬─────┬─────┬─────┐
m │ ff │ ff │ ff │ ff │ unsigned 로 읽으면 4294967295
└─────┴─────┴─────┴─────┘
같은 바이트 ff ff ff ff라도 int로 읽으면 -1, unsigned int로 읽으면 4294967295다. 메모리에는 비트만 있고, 뜻은 자료형이 붙인다. 엔디언은 여러 바이트짜리 값을 어떤 순서로 놓는가의 규칙이다. x86-64와 대부분의 ARM 환경은 리틀 엔디언(낮은 자리 먼저)이고, 네트워크 프로토콜은 관례적으로 빅 엔디언(높은 자리 먼저)을 쓴다.
리틀 엔디언 기계에서 0x12345678 은 78 56 34 12 순서로 놓인다. ff ff ff ff 는 int 로 -1, unsigned int 로 4294967295 다.
부호 없는 정수는 범위를 넘으면 나머지 규칙으로 0 으로 돌아가지만, 부호 있는 int 가 넘치는 것은 정의되지 않은 동작이다.
완성 코드
자료형 크기와 범위
#include <stdio.h>
#include <limits.h>
int main(void) {
printf("char %zu바이트\n", sizeof(char));
printf("short %zu바이트\n", sizeof(short));
printf("int %zu바이트\n", sizeof(int));
printf("long %zu바이트\n", sizeof(long));
printf("long long %zu바이트\n", sizeof(long long));
printf("float %zu바이트\n", sizeof(float));
printf("double %zu바이트\n", sizeof(double));
printf("int * %zu바이트\n", sizeof(int *));
printf("INT_MIN = %d\n", INT_MIN);
printf("INT_MAX = %d\n", INT_MAX);
printf("UINT_MAX = %u\n", UINT_MAX);
printf("CHAR_MIN = %d (0이면 char 는 부호 없음)\n", CHAR_MIN);
return 0;
}
값의 바이트를 직접 찍기
#include <stdio.h>
static void dump(const char *label, const void *addr, size_t size) {
const unsigned char *p = addr;
printf("%-10s", label);
for (size_t i = 0; i < size; i++) {
printf(" %02x", p[i]);
}
printf("\n");
}
int main(void) {
int n = 0x12345678;
int minus_one = -1;
unsigned int big = 4294967295u;
short s = 300;
dump("n", &n, sizeof n);
dump("-1", &minus_one, sizeof minus_one);
dump("4294967295", &big, sizeof big);
dump("short 300", &s, sizeof s);
return 0;
}
순환과 고정 폭 자료형
#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>
int main(void) {
uint8_t level = 255;
level = level + 1;
printf("uint8_t 255 + 1 = %" PRIu8 "\n", level);
unsigned int stock = 3;
unsigned int sold = 5;
printf("재고 3 - 판매 5 = %u\n", stock - sold);
int32_t visits = INT32_MAX;
printf("INT32_MAX = %" PRId32 "\n", visits);
int64_t wide = (int64_t)visits + 1;
printf("64비트로 넓혀 + 1 = %" PRId64 "\n", wide);
return 0;
}
부호 있는 정수 오버플로
#include <stdio.h>
#include <limits.h>
static int add_visit(int count) {
return count + 1;
}
int main(void) {
int count = INT_MAX - 1;
for (int i = 0; i < 2; i++) {
count = add_visit(count);
printf("방문 수: %d\n", count);
}
return 0;
}
줄별 해설
sizeof(int)— 컴파일할 때 결정되는 바이트 수다. 결과형은size_t(부호 없는 정수)이고,printf에서는%zu로 찍는다.%d로 찍으면 형식이 맞지 않아 경고가 난다.INT_MAX,UINT_MAX,CHAR_MIN—<limits.h>에 있는 이 플랫폼의 한계값이다. 숫자를 외우지 말고 이 이름을 쓴다.const unsigned char *p = addr;— 어떤 값이든 바이트 단위로 들여다볼 때는unsigned char포인터를 쓴다. C는 모든 객체를unsigned char배열로 읽는 것을 허용한다. 포인터는 7장에서 자세히 다루니 지금은 "그 주소부터 한 바이트씩 읽는다"로 이해하면 된다.%02x— 16진수 두 자리, 빈자리는 0으로 채운다.uint8_t level = 255; level = level + 1;— 부호 없는 정수는 범위를 넘으면 2의 n제곱으로 나눈 나머지가 되도록 표준이 정해 두었다. 256을 8비트에 담으면 0이다.stock - sold— 둘 다unsigned int이므로 3 - 5 = -2가 아니라 2의 32제곱 - 2 = 4294967294가 된다. 오류가 아니라 규칙대로 계산된 결과라서 더 위험하다.PRId32,PRIu8—<inttypes.h>의 출력 형식 매크로다.int32_t가 어떤 기본형의 별명인지는 플랫폼마다 다르므로, 이 매크로가 맞는 형식 문자를 대신 골라 준다.(int64_t)visits + 1— 더하기 전에 넓은 자료형으로 바꿔야 한다.(int64_t)(visits + 1)이라고 쓰면 이미 넘친 뒤에 넓히는 것이라 소용없다.count + 1(overflow.c) —int가INT_MAX를 넘는 것은 정의되지 않은 동작(undefined behavior, 이하 UB)이다. 표준은 결과를 아무것도 보장하지 않으며, 컴파일러는 "넘치지 않는다"고 가정하고 최적화해도 된다.
실제 실행 결과
네 파일 모두 경고 없이 컴파일된다(출력 없음).
$ clang -std=c17 -Wall -Wextra sizes.c -o sizes
$ ./sizes
char 1바이트
short 2바이트
int 4바이트
long 8바이트
long long 8바이트
float 4바이트
double 8바이트
int * 8바이트
INT_MIN = -2147483648
INT_MAX = 2147483647
UINT_MAX = 4294967295
CHAR_MIN = -128 (0이면 char 는 부호 없음)
| 자료형 | sizeof | 다른 플랫폼에서 |
|---|---|---|
| char | 1 | 표준상 항상 1 |
| short | 2 | |
| int | 4 | 16비트 MCU 에서는 2 인 경우가 흔함 |
| long | 8 | 64비트 Windows 는 4 (LLP64) |
| long long | 8 | |
| float | 4 | |
| double | 8 | |
| int * | 8 | 32비트 환경은 4 |
이 결과는 이 기계(64비트 macOS, arm64) 기준이다. 대부분의 64비트 리눅스·macOS는 long이 8바이트(LP64 모델)지만, 64비트 Windows는 long이 4바이트(LLP64 모델)다. 8비트·16비트 마이크로컨트롤러에서는 int가 2바이트인 경우도 흔하다. 마지막 줄의 CHAR_MIN = -128은 이 환경에서 char가 부호 있는 자료형이라는 뜻이다. 같은 ARM이라도 리눅스(aarch64)에서는 char가 보통 부호 없는 자료형이라 0이 나온다. 그래서 바이트를 다룰 때는 char 대신 unsigned char나 uint8_t를 명시한다.
$ ./bytes
n 78 56 34 12
-1 ff ff ff ff
4294967295 ff ff ff ff
short 300 2c 01
0x12345678이 78 56 34 12 순서로 저장됐다. 리틀 엔디언이다. short 300은 300 = 0x012c이므로 2c 01이다. -1과 4294967295는 바이트가 완전히 같다.
$ ./wrap
uint8_t 255 + 1 = 0
재고 3 - 판매 5 = 4294967294
INT32_MAX = 2147483647
64비트로 넓혀 + 1 = 2147483648
$ ./overflow
방문 수: 2147483647
방문 수: -2147483648
이 실행에서는 -2147483648이 나왔지만, 이것은 "그렇게 나올 수도 있다"일 뿐 보장이 아니다. 최적화 옵션을 바꾸거나 코드 모양이 달라지면 다른 결과가 나올 수 있다. 조용히 틀린 값이 퍼지는 것을 막으려면 실행 중에 UB를 잡아 주는 UndefinedBehaviorSanitizer(UBSan)를 켜서 돌린다.
$ clang -std=c17 -Wall -Wextra -g -fsanitize=undefined overflow.c -o overflow_ub && ./overflow_ub
방문 수: 2147483647
overflow.c:5:18: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior overflow.c:5:18
방문 수: -2147483648
UBSan은 문제가 생긴 파일·줄·열(overflow.c:5:18)과 어떤 값이 넘쳤는지를 알려 준다. 기본 설정에서는 보고하고 계속 실행하므로 뒤의 출력도 이어진다. 테스트 빌드에 이 옵션을 넣어 두면 몇 달 뒤에야 드러날 버그를 첫 테스트에서 잡는다. 12장에서 도구를 더 자세히 다룬다.
한눈에 보기
| 자료형 | 서식 | 예 |
|---|---|---|
int | %d | printf("%d", n) |
unsigned int | %u, %x | 16진수는 %x, 두 자리 채움 %02x |
long / long long | %ld / %lld | 크기가 달라도 서식은 자료형 이름을 따른다 |
size_t | %zu | sizeof, strlen 결과 |
int32_t / uint8_t | PRId32 / PRIu8 | <inttypes.h> 매크로 |
| 포인터 | %p | 인자는 (void *)p |
실무에서 자주 틀리는 것
- "int는 4바이트"를 전제로 코드와 파일 형식을 만든다. 통신 패킷이나 저장 파일처럼 크기가 약속인 곳에는
int32_t,uint16_t같은 고정 폭 자료형을 쓴다. 반복문 인덱스처럼 크기가 중요하지 않은 곳에는int나size_t를 써도 된다. - 음수가 될 수 없는 값이라며
unsigned를 쓴다. 수량이 음수가 되지 않아야 한다는 것은 검증 규칙이지 자료형으로 보장되지 않는다. 잘못된 뺄셈은 오류 대신 거대한 양수가 된다. 빼기 전에if (sold > stock)으로 먼저 검사한다. - 넘친 뒤에 넓힌다.
long long total = a * b;에서a,b가int면 곱셈은int로 먼저 계산돼 넘친다.(long long)a * b처럼 연산 전에 한쪽을 넓힌다. - 바이트를
char로 다룬다.char c = 0xff;가 플랫폼에 따라 -1도 되고 255도 된다. 비교나 테이블 인덱스에 쓰면 플랫폼에 따라 결과가 갈린다.
연습 문제
uint16_t port = 0x1f90;을bytes.c의dump로 찍으면 이 기계에서 어떤 두 바이트가 어떤 순서로 나오는가? 이 값을 네트워크 순서(빅 엔디언)로 보내려면 바이트 순서가 어떻게 돼야 하는가?unsigned int a = 5, b = 7;일 때if (a - b > 0)은 참인가 거짓인가? 의도대로 "a가 b보다 큰가"를 검사하도록 고쳐라.- 하루 방문 수(최대 약 30억)를 누적하는 변수의 자료형으로
int,unsigned int,int64_t중 무엇을 고르겠는가? 이유를 한 문장으로 써라.
정답
- 이 기계는 리틀 엔디언이므로
90 1f가 나온다. 네트워크 순서로는 높은 자리 바이트가 먼저인1f 90이어야 한다. POSIX 환경에서는htons(port)가 이 변환을 해 준다. 빅 엔디언 기계에서는htons가 아무것도 바꾸지 않으므로 같은 코드가 양쪽에서 맞게 동작한다. - 참이다. 부호 없는 뺄셈 5 - 7은 4294967294이고, 이는 0보다 크다. 사실 부호 없는 값은 언제나 0 이상이므로
> 0은 "0이 아니다"와 같다. 의도대로 쓰려면 빼지 말고if (a > b)로 직접 비교한다. int64_t.int는 이 기계에서 최대 약 21억이라 넘치면 UB이고,unsigned int는 약 42억까지 담지만 여러 날을 합치거나 차이를 계산할 때 순환 문제가 생긴다. 64비트 부호 있는 정수는 여유가 충분하고 뺄셈 결과도 음수로 정직하게 나온다.
다음 장에서는 값끼리 계산할 때 자료형이 어떻게 바뀌는지, 그 변환이 어디서 조용히 결과를 틀리게 만드는지 본다.
READER FEEDBACK
질문·오탈자·의견
내용에 관한 질문이나 오탈자, 더 나은 설명을 위한 의견을 남겨 주세요. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.