내가 학생인 스터디 기록

추상 상태로 살펴보는 오버플로우와 래핑

rnaster 2026. 4. 5. 23:06

모던 C (옌스 구스테트)를 읽으며 정리하는 학습 노트

 

들어가며

5장 값과 데이터 파트를 읽으며 좀처럼 내 환경에서는 찾아볼 수 없던 !! 문법을 발견했다. 이건 좀 더 디테일하게 branchless 패턴이라고 하던데 좀 더 깊은 탐구가 필요하여 다른 글에서 잇도록 하겠습니다.

 

5장은 추상 상태 기계를 이해하기 위한 내용을 시작합니다. 현대의 컴파일러란 놀라울 정도의 최적화로 아름다운 성능을 가진 오브젝트를 만들어내곤 합니다. abstract를 추상으로 번역할 수 밖에 없는 상황이 안타까울 따름입니다. 유독 이번에 표현이 얼마나 모호한지 절실히 느꼈습니다. 

 

추상상태란, C 표준이 프로그램의 실행을 정의하기 위해 상정하는 가상의 실행 모델입니다. 정식 용어로는 추상 기계(abstract machine) 라고 합니다. 실제 하드웨어가 아니라 표준이 규정하는 "이상적인 컴퓨터"이며, 컴파일러의 의무는 이 추상 기계의 관찰 가능한 동작(observable behavior)을 보존하는 것입니다. 관찰 가능한 동작이 동일하다면 내부 구현을 얼마든지 변경할 수 있습니다. 

 

이 개념이 왜 중요한지는, 정수 타입의 동작을 살펴보면 체감할 수 있습니다. 특히 unsigned와 signed가 범위를 넘었을 때 왜 완전히 다르게 처리되는지를 이해하는 열쇠가 됩니다.

 

부호있는 정수는 한계가 있다. 

아무래도 이 책의 대상과 목적은 기초지식을 위하지는 않습니다. 부호가 있는 정수타입은 한계를 넘어선 연산을 시도할 때 미정의 동작 (undefined behavior, UB) 이라고 합니다. (다행스럽게도 이건 업무 중에도 들어봤습니다.)

int b = INT_MAX + 1;             // UB: 결과를 보장할 수 없음

 

흔히들 발생하는 문제 상황이지만, 컴파일러는 이 상황을 야기한 개발자를 제정신이 아닌 것쯤으로 취급하고 이런 상황을 가정하지 않습니다. (멋진 최적화의 근원입니다.) 추상 기계의 핵심이 이 지점에서 동작합니다. 

이를 as-if 규칙이라고 합니다. 프로그램은 추상 기계를 따르는 것으로 실행됩니다. 

 

이 논리를 정리하면 이렇습니다.

  1. signed type의 overflow 는 UB다. 
  2. 올바른 프로그램은 UB가 발생하지 않는다. 
  3. signed signed시작됩니다 type 의 연산 결과는 항상 표현 범위 내에 존재한다. 
  4. 이 전제를 따르도록 과감하게 코드를 단순화 한다. 

아래의 코드를 살펴보겠습니다. 

int x;
// ...
if (x + 1 > x) {
    do_something();
}

 

 

... 사이에 x라는 값을 지지고 볶는다고 하더라도

컴파일러에게는,

 

  • x + 1이 overflow하는 경우? 그건 UB니까 "발생하지 않는다"고 가정
  • overflow가 없다면 x + 1 > x는 수학적으로 항상 참
  • 그러면 조건문 자체가 필요 없으니 do_something()을 무조건 실행

결과적으로

do_something();

 

한줄로 바뀝니다.

또 다른 예시로는 반복문도 대표적입니다. 

for (int i = 0; i < N; i++) {
    a[i] = i * 4;
}

 

 

컴파일러는 i*4가 overflow하지 않는다고 확신하기 때문에 반복문에서 곱셈대신 덧셈으로 변경합니다. (그 편이 더 저렴하니까.)

int *p = a;
int *end = a + N;
int val = 0;
while (p < end) {
    *p++ = val;
    val += 4;
}

 

하지만 unsigned 는 상황이 바뀝니다.

부호없는 정수는 끝없이 순환한다.

부호없는 정수는 하드웨어 단위의 메모리 공간을 최대한 사용하기 위해 비트 그대로 사용합니다. 8비트 레지스터는 물리적으로 8개의 비트만 저장할 수 있습니다. 255 + 1을 계산하면 이런 일이 벌어집니다.

 

  11111111      (255)
+ 00000001      (  1)
----------
 100000000      (256, 9비트)
  00000000      (  0, 상위 비트 잘림)

 

9번째 비트는 레지스터에 담을 수 없으니 그냥 잘려나갑니다. 이 "비트 잘림"이 수학적으로는 2^N으로 나눈 나머지를 취하는 것과 정확히 같은 결과를 만들어냅니다. C 표준은 이 하드웨어의 자연스러운 동작을 그대로 언어 명세로 보장한 겁니다.

뺄셈도 동일합니다. 

  00000000      (  0)
- 00000001      (  1)
----------
  11111111      (255)

 

2의 보수 체계에서 -1의 비트 패턴이 11111111인데, 이걸 unsigned로 해석하면 255가 됩니다.

하지만 이건 오버플로우라고 부르지 않습니다. 의도된 동작이기 때문입니다. 

 

때문에 unsigned는 다르게 동작합니다. 

unsigned int y;
if (y + 1 > y) { ... }

 

y == UINT_MAX이면 y + 1은 0으로 래핑되니까 조건이 거짓이 됩니다. 이 경우 컴파일러는 조건을 함부로 제거할 수 없습니다.

overflow(= UB)라면 컴파일러가 "그런 일은 일어나지 않는다"고 가정할 수 있고, 래핑(= 정의된 동작)이라면 가정할 수 없습니다. 

 

마치며

  • 추상 기계 (abstract machine): C 표준이 정의하는 실행 모델. 실제 CPU가 아니라 컴파일러가 준수해야 하는 스펙입니다.
  • as-if rule: 관찰 가능한 동작만 보존하면 추상 기계와 다르게 구현해도 된다는 원칙. 컴파일러 최적화의 근거입니다.
  • 추상 해석 (abstract interpretation): 컴파일러가 내부적으로 사용하는 정적 분석 기법. 변수의 가능한 값 범위를 추적하여 최적화에 활용합니다.

 

참고 문헌

  • Gustedt, J. (2019). Modern C. Manning Publications.