Devin.KR

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 다.

리틀 엔디언 기계에서 0x12345678 은 78 56 34 12 순서로 놓인다. ff ff ff ff 는 int 로 -1, unsigned int 로 4294967295 다.

부호 없는 정수는 범위를 넘으면 나머지 규칙으로 0 으로 돌아가지만, 부호 있는 int 가 넘치는 것은 정의되지 않은 동작이다.

부호 없는 정수는 범위를 넘으면 나머지 규칙으로 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) — intINT_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 는 부호 없음)
sizes.c 실행 결과 (macOS arm64, Apple clang 17)
자료형sizeof다른 플랫폼에서
char1표준상 항상 1
short2
int416비트 MCU 에서는 2 인 경우가 흔함
long864비트 Windows 는 4 (LLP64)
long long8
float4
double8
int *832비트 환경은 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 charuint8_t를 명시한다.

$ ./bytes
n          78 56 34 12
-1         ff ff ff ff
4294967295 ff ff ff ff
short 300  2c 01

0x1234567878 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장에서 도구를 더 자세히 다룬다.

한눈에 보기

자료형마다 맞는 printf 서식 지정자
자료형서식
int%dprintf("%d", n)
unsigned int%u, %x16진수는 %x, 두 자리 채움 %02x
long / long long%ld / %lld크기가 달라도 서식은 자료형 이름을 따른다
size_t%zusizeof, strlen 결과
int32_t / uint8_tPRId32 / PRIu8<inttypes.h> 매크로
포인터%p인자는 (void *)p

실무에서 자주 틀리는 것

  • "int는 4바이트"를 전제로 코드와 파일 형식을 만든다. 통신 패킷이나 저장 파일처럼 크기가 약속인 곳에는 int32_t, uint16_t 같은 고정 폭 자료형을 쓴다. 반복문 인덱스처럼 크기가 중요하지 않은 곳에는 intsize_t를 써도 된다.
  • 음수가 될 수 없는 값이라며 unsigned를 쓴다. 수량이 음수가 되지 않아야 한다는 것은 검증 규칙이지 자료형으로 보장되지 않는다. 잘못된 뺄셈은 오류 대신 거대한 양수가 된다. 빼기 전에 if (sold > stock)으로 먼저 검사한다.
  • 넘친 뒤에 넓힌다. long long total = a * b;에서 a, bint면 곱셈은 int로 먼저 계산돼 넘친다. (long long)a * b처럼 연산 전에 한쪽을 넓힌다.
  • 바이트를 char로 다룬다. char c = 0xff;가 플랫폼에 따라 -1도 되고 255도 된다. 비교나 테이블 인덱스에 쓰면 플랫폼에 따라 결과가 갈린다.

연습 문제

  1. uint16_t port = 0x1f90;bytes.cdump로 찍으면 이 기계에서 어떤 두 바이트가 어떤 순서로 나오는가? 이 값을 네트워크 순서(빅 엔디언)로 보내려면 바이트 순서가 어떻게 돼야 하는가?
  2. unsigned int a = 5, b = 7;일 때 if (a - b > 0)은 참인가 거짓인가? 의도대로 "a가 b보다 큰가"를 검사하도록 고쳐라.
  3. 하루 방문 수(최대 약 30억)를 누적하는 변수의 자료형으로 int, unsigned int, int64_t 중 무엇을 고르겠는가? 이유를 한 문장으로 써라.

정답

  1. 이 기계는 리틀 엔디언이므로 90 1f가 나온다. 네트워크 순서로는 높은 자리 바이트가 먼저인 1f 90이어야 한다. POSIX 환경에서는 htons(port)가 이 변환을 해 준다. 빅 엔디언 기계에서는 htons가 아무것도 바꾸지 않으므로 같은 코드가 양쪽에서 맞게 동작한다.
  2. 이다. 부호 없는 뺄셈 5 - 7은 4294967294이고, 이는 0보다 크다. 사실 부호 없는 값은 언제나 0 이상이므로 > 0은 "0이 아니다"와 같다. 의도대로 쓰려면 빼지 말고 if (a > b)로 직접 비교한다.
  3. int64_t. int는 이 기계에서 최대 약 21억이라 넘치면 UB이고, unsigned int는 약 42억까지 담지만 여러 날을 합치거나 차이를 계산할 때 순환 문제가 생긴다. 64비트 부호 있는 정수는 여유가 충분하고 뺄셈 결과도 음수로 정직하게 나온다.

다음 장에서는 값끼리 계산할 때 자료형이 어떻게 바뀌는지, 그 변환이 어디서 조용히 결과를 틀리게 만드는지 본다.

READER FEEDBACK

질문·오탈자·의견

내용에 관한 질문이나 오탈자, 더 나은 설명을 위한 의견을 남겨 주세요. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.