C 정의되지 않은 동작: -O0과 -O2 결과가 다른 이유
C 강좌 5강. 부호 있는 오버플로, 사라지는 루프, strict aliasing, 초기화 안 된 변수, 지워진 NULL 검사를 -O0·-O2로 실행하고 UBSan 보고와 비용까지 실측.
시리즈: C 강좌 5/6 (고급). C를 쓰고 디버깅할 줄 알고, 디버그 빌드와 릴리스 빌드에서 프로그램이 다르게 동작하는 걸 겪어 본 분을 위한 글입니다. 다 읽고 나면 흔한 정의되지 않은 동작을 알아보고, 최적화기가 그걸로 무엇을 했는지 설명하고, UBSan으로 찾아내고, 증상을 가리는 플래그가 아니라 "정의된 C"로 고칠 수 있습니다.
백준이나 프로그래머스에서 C로 문제를 풀다 보면 "내 컴퓨터에서는 예제가 다 맞는데 제출하면 틀렸습니다"를 한 번쯤 겪습니다. 채점 서버는 보통 -O2로 컴파일하는데, 로컬에서는 최적화 없이 돌리고 있었다면 원인은 컴파일러 버그가 아니라 정의되지 않은 동작(Undefined Behavior, UB)일 가능성이 높습니다. 실무에서는 "디버그 빌드에서는 재현이 안 되고 릴리스에서만 숫자가 틀린다"는 형태로 나타납니다. 이 글은 작은 UB 예제 다섯 개를 -O0과 -O2로 실행하고, 최적화기가 만든 기계어와 새니타이저의 보고를 그대로 보여 드립니다.
실행 환경: Apple clang 21.0.0(clang -std=c17 -Wall -Wextra), Apple Silicon Mac(M1 Pro), macOS, arm64. 출력은 모두 실제 실행에서 복사했고, 디스어셈블은 objdump -d --no-show-raw-insn으로 얻었습니다. 채점 서버의 gcc는 이 맥에서 돌려 보지 않았습니다. 전체 학습 순서는 프로그래밍 학습 로드맵에 있습니다.
"정의되지 않았다"는 말의 뜻, 그리고 최적화기
C 표준은 이식성 없는 동작을 세 칸으로 나눕니다.
- 구현 정의(implementation-defined): 컴파일러가 하나를 골라 문서로 밝혀야 합니다. 예:
sizeof(long), 음수의 오른쪽 시프트 결과. - 미지정(unspecified): 몇 가지 중 하나가 일어나지만 어느 쪽인지 알려 줄 필요는 없습니다. 예:
f() + g()에서 두 함수를 부르는 순서. - 정의되지 않음(undefined): 표준이 프로그램 실행 전체에 대해 아무 요구도 하지 않습니다. 예: 부호 있는 정수 오버플로, 초기화하지 않은 지역 변수 읽기, 배열 범위 밖 접근, NULL 역참조, 호환되지 않는 타입의 포인터로 객체 접근.
문제는 마지막 칸이고, 하드웨어가 이상한 짓을 해서가 아닙니다. arm64 레지스터에서 2147483647에 1을 더하면 매번 -2147483648이 나옵니다. 놀라움은 최적화기에서 옵니다. 최적화기는 UB가 절대 일어나지 않는다고 가정하고 거기서부터 추론해도 됩니다. "이 덧셈은 넘칠 수 없다" → "그러니 x + 1 > x는 항상 참" → "그러니 1을 반환". 단계 하나하나는 합법인데, 결과는 컴파일러가 내 코드를 무시한 것처럼 보입니다.
함수 세 개, 답 두 개
파일 하나에 작은 함수 세 개. noinline은 각 함수가 실제 호출로 남아서 기계어를 읽을 수 있게 하려고 붙였습니다.
/* ub.c: -O 수준에 따라 달라지는 UB 세 가지.
clang -std=c17 -Wall -Wextra -O0 ub.c -o ub0 && clang -std=c17 -Wall -Wextra -O2 ub.c -o ub2 */
#include <stdio.h>
#include <limits.h>
/* 1. 부호 있는 오버플로: x+1이 x보다 큰가? */
__attribute__((noinline)) int plus_one_is_bigger(int x) { return x + 1 > x; }
/* 2. strict aliasing: int*와 float*로 같은 곳에 쓰기 */
__attribute__((noinline)) int alias(int *i, float *f) {
*i = 1;
*f = 0.0f; /* f가 같은 바이트를 가리키면 *i는 이제 0 */
return *i;
}
/* 3. 카운터가 넘치는 루프 */
__attribute__((noinline)) int count_steps(int start) {
int steps = 0;
for (int i = start; i > 0; i += 100000000) steps++; /* "음수로 넘어갈 때까지" */
return steps;
}
int main(void) {
printf("plus_one_is_bigger(INT_MAX) = %d\n", plus_one_is_bigger(INT_MAX));
int x = 0;
printf("alias(&x, (float*)&x) = %d\n", alias(&x, (float *)&x));
printf("count_steps(1) = %d\n", count_steps(1));
return 0;
}
두 수준 모두 경고가 하나도 없습니다. 결과는:
-- O0
plus_one_is_bigger(INT_MAX) = 0
alias(&x, (float*)&x) = 0
count_steps(1) = 22
-- O2
plus_one_is_bigger(INT_MAX) = 1
alias(&x, (float*)&x) = 1
count_steps(1) = 0
세 줄이 전부 바뀌었습니다. -O2 바이너리에 실제로 들어 있는 기계어(주석은 우리가 붙임):
<_plus_one_is_bigger>:
mov w0, #0x1 ; return 1
ret
<_alias>:
mov w8, #0x1
str w8, [x0] ; *i = 1
str wzr, [x1] ; *f = 0
mov w0, #0x1 ; *i를 다시 읽지 않고 return 1
ret
<_count_steps>:
mov w0, #0x0 ; return 0: 루프가 사라졌다
ret
1. 부호 있는 오버플로. -O0에서는 CPU가 더하고 넘쳤고, -2147483648 > 2147483647은 거짓이라 0. -O2에서 clang은 x + 1은 넘칠 수 없으니 항상 x보다 크다고 보고 함수를 return 1로 바꿨습니다. 부호 있는 오버플로를 "넘쳐서 돈다"로 정의해 주는 -fwrapv를 붙이면 -O2도 다시 0을 출력했습니다.
2. Strict aliasing. C는 int 객체를 float 좌변값으로 접근하면 안 된다고 정합니다(C17 6.5p7). 그래서 clang은 *f = 0.0f가 *i를 바꿀 수 없다고 가정하고 방금 저장한 1을 그대로 반환합니다. -fno-strict-aliasing을 붙이면 -O2도 0. 리눅스 커널 같은 큰 C 코드베이스가 이 플래그로 빌드하는 이유입니다.
3. 사라진 루프. for (int i = 1; i > 0; i += 100000000)을 빠져나가는 유일한 길은 오버플로입니다. -O0에서는 22번 만에 넘쳐서 끝났습니다. -O2에서는 오버플로가 없다면 루프는 끝날 수 없고, 부수 효과 없는 이런 루프는 끝난다고 가정해도 되므로(C11 6.8.5p6) 두 가정을 합치면 실행할 것이 남지 않습니다. clang은 return 0을 만들었습니다. 22도 아니고 무한 루프도 아닌 0. -fwrapv를 붙이면 22로 돌아옵니다.
초기화 안 된 변수: 실행할 때마다 다른 숫자
#include <stdio.h>
__attribute__((noinline)) int grade(int score) {
int bonus; /* "= 0"을 빠뜨림 */
if (score >= 90) bonus = 10;
return score + bonus;
}
__attribute__((noinline)) void scribble(void) { /* 스택에 쓰레기를 남긴다 */
volatile int junk[16];
for (int i = 0; i < 16; i++) junk[i] = 7777;
}
int main(void) {
printf("grade(95) = %d\n", grade(95));
scribble();
printf("grade(50) = %d\n", grade(50));
return 0;
}
이번에는 clang이 경고하고, 노트에 고치는 방법까지 보여 줍니다.
uninit.c:5:9: warning: variable 'bonus' is used uninitialized whenever 'if' condition is false [-Wsometimes-uninitialized]
5 | if (score >= 90) bonus = 10;
| ^~~~~~~~~~~
uninit.c:6:20: note: uninitialized use occurs here
uninit.c:4:14: note: initialize the variable 'bonus' to silence this warning
4 | int bonus; /* forgot "= 0" */
| ^
| = 0
무시하면 실제로 이렇게 됩니다.
-- O0, 세 번 실행
grade(50) = 76989834
grade(50) = 47941002
grade(50) = 43156874
-- O2
grade(95) = 105
grade(50) = 60
-O0에서 bonus는 그 스택 칸에 남아 있던 값이고, 주소 공간 무작위화(ASLR) 때문에 실행할 때마다 다른 숫자였습니다. -O2에서는 답이 안정적인데, 새로운 방식으로 틀립니다. 기계어가 add w0, w0, #0xa 한 줄입니다. 초기화 안 된 bonus를 읽는 건 UB이므로 컴파일러는 if가 항상 실행된다고 보고 무조건 10을 더합니다. 50점인 학생이 매번 60점을 받으니, 메모리 버그가 아니라 로직 버그처럼 보입니다. 채점 서버에서만 틀리는 전형적인 모습이 바로 이것입니다.
-ftrivial-auto-var-init=zero(clang 21에서 받아들임)는 이런 지역 변수를 0으로 채워서 grade(50) = 50을 출력했습니다. 릴리스 빌드의 방어용으로는 쓸 만하지만 보이던 버그를 조용한 가정으로 바꿉니다. 진짜 해결은 int bonus = 0;이고, 컴파일러가 강제하게 하려면 -Werror=sometimes-uninitialized(또는 전부 -Werror):
uninit.c:5:9: error: variable 'bonus' is used uninitialized whenever 'if' condition is false [-Werror,-Wsometimes-uninitialized]
흔한 실수: 컴파일러가 지운 NULL 검사
실수: 먼저 역참조하고 나중에 검사하기.
struct cfg { int retries; };
__attribute__((noinline)) int get_retries(struct cfg *c) {
int r = c->retries; /* 먼저 역참조 ... */
if (c == NULL) return -1; /* ... 나중에 검사: 컴파일러가 지울 수 있다 */
return r;
}
-O2에서 실제로 일어나는 일:
<_get_retries>:
ldr w0, [x0]
ret
검사가 없어졌습니다. c->retries가 이미 c를 역참조했으니 c는 NULL일 수 없고, 따라서 c == NULL은 거짓, 그 분기는 죽은 코드라는 결론입니다. -O0에는 비교가 남아 있습니다. 이 함수가 NULL을 받으면 -O2 빌드는 로드에서 바로 죽고, 소스에 보이는 "방어 코드"는 한 번도 실행되지 않습니다. 해결: 첫 사용 전에 검사합니다. 2009년 리눅스 커널 취약점이 바로 이 패턴이었고, 커널은 지금 -fno-delete-null-pointer-checks로 빌드합니다(여기서는 실행 안 함).
UBSan: UB를 시끄럽게 만들기
-fsanitize=undefined(UndefinedBehaviorSanitizer)는 UB가 될 수 있는 연산 앞마다 검사를 넣고, 걸리면 한 줄을 출력합니다. ub.c를 -O0으로:
ub.c:7:68: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ub.c:7:68
ub.c:19:34: runtime error: signed integer overflow: 2100000001 + 100000000 cannot be represented in type 'int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ub.c:19:34
plus_one_is_bigger(INT_MAX) = 0
alias(&x, (float*)&x) = 0
count_steps(1) = 22
exit=0
두 가지를 보세요. 오버플로 두 곳은 정확한 값과 함께 찾았지만 aliasing 위반은 못 찾았습니다. UBSan에는 그 검사가 없습니다. 그리고 보고만 하고 계속 실행했습니다(exit 0). CI에서는 실패해야 하니 -fno-sanitize-recover=undefined를 붙이세요. 같은 파일이 첫 보고에서 멈추고 exit 134로 끝났습니다.
다른 파일에서 세 가지 검사를 더(인자 없이 실행):
int n = argc > 1 ? atoi(argv[1]) : 32;
int table[4] = {10, 20, 30, 40};
int idx = argc > 2 ? atoi(argv[2]) : 4;
printf("1 << %d = %d\n", n, 1 << n); /* 비트 폭 이상 시프트 */
printf("table[%d] = %d\n", idx, table[idx]); /* 하나 넘침 */
int d = argc - 1; /* 인자 없으면 0 */
printf("100 / %d = %d\n", d, 100 / d);
more_ub.c:8:35: runtime error: shift exponent 32 is too large for 32-bit type 'int'
more_ub.c:9:37: runtime error: index 4 out of bounds for type 'int[4]'
more_ub.c:11:38: runtime error: division by zero
1 << 32 = 1
table[4] = -16
100 / 0 = 0
마지막 줄이 중요합니다. 새니타이저 없이 -O0과 -O2 모두에서도 이 맥은 100 / 0 = 0을 출력하고 정상 종료했습니다. arm64의 나눗셈 명령은 0으로 나누면 트랩 대신 0을 돌려줍니다. x86-64에서는 같은 코드가 SIGFPE("Floating point exception", 정수 나눗셈인데도 이 이름)로 죽습니다(여기서는 실행 안 함). 애플 실리콘 맥에서만 "테스트한" 프로그램이 처음 만나는 리눅스 서버나 채점 서버에서 죽을 수 있다는 뜻입니다. table[4]는 빌드 세 가지에서 -16, -178814720, 1010573361로 매번 달랐습니다.
실측: 최적화 수준과 새니타이저의 비용
같은 2,000만 원소 루프(히스토그램 + 가중합, 고정 시드 xorshift로 만든 데이터)를 플래그만 바꿔 실행했습니다. 7회 중 최솟값, Apple Silicon Mac(M1 Pro), 한 바퀴 더 돌린 결과도 5% 안에서 같았습니다. 체크섬이 모든 빌드에서 같으니 일을 건너뛴 빌드는 없습니다.
| 빌드 | 시간(ms) | -O2 대비 |
|---|---|---|
-O0 |
30.22 | 3.1배 |
-O1 |
9.77 | 1.0배 |
-O2 |
9.63 | 1.0배 |
-O3 |
6.97 | 0.72배 |
-O2 -fwrapv |
10.03 | 1.04배 |
-O2 -fsanitize=undefined |
25.22 | 2.6배 |
-O2 -fsanitize=address |
17.25 | 1.8배 |
-O0 -fsanitize=address,undefined |
241.33 | 25배 |
읽을거리 세 가지. -fwrapv는 이 루프에서 거의 공짜였습니다. "오버플로를 정의하면 느려서 안 된다"는 말은 내 핫 루프에서 직접 재 보고 믿으세요(인덱스 확장 같은 일부 루프에서는 차이가 날 수 있지만 여기서는 보여 주지 않았습니다). -O2의 새니타이저는 테스트 전체를 돌려도 될 만큼 쌉니다. 그리고 평소 쓰는 디버그 + 새니타이저 빌드는 릴리스의 25배 느리니, 테스트에는 좋지만 성능 측정에 쓰면 안 됩니다. 측정 프로그램 전체(bench5.c)는 영어판에 있습니다.
결정표: 증상, 도구, 정의된 해결
| 증상 | 먼저 쓸 도구 | 정의된 해결 |
|---|---|---|
| -O0과 -O2 결과가 다름, 채점 서버에서만 틀림 | -fsanitize=undefined, CI에서는 -fno-sanitize-recover |
UB를 없앤다. -fwrapv 같은 플래그는 임시방편 |
| 넘칠 수 있는 산술 | UBSan signed integer overflow |
먼저 검사하거나 C23 <stdckdint.h>의 ckd_add/ckd_mul |
| float 비트 보기, 패킷 파싱 | (잡는 새니타이저 없음) | memcpy로 다른 타입에 복사, 또는 unsigned char로 접근 |
| 실행할 때마다 바뀌는 쓰레기 값 | -Wall (-Wsometimes-uninitialized) |
선언할 때 초기화, 그 경고는 -Werror |
| NULL·범위 검사가 "안 먹힘" | -O2 디스어셈블 읽기 |
첫 역참조 전에 검사 |
| 다른 OS·CPU에서만 크래시 | 내 기계에서 UBSan | 한 플랫폼의 운 좋은 결과를 믿지 않는다(arm64 x / 0은 0) |
정의된 대체 코드 두 개. -O0과 -O2에서 출력이 같았습니다.
#include <stdckdint.h> /* C23 */
static uint32_t float_bits(float f) { /* 바이트를 보는 정의된 방법 */
uint32_t u;
memcpy(&u, &f, sizeof u);
return u;
}
/* ... */
int sum;
if (ckd_add(&sum, INT_MAX, 1)) puts("ckd_add: overflow detected");
if (!ckd_add(&sum, 40, 2)) printf("ckd_add: 40 + 2 = %d\n", sum);
printf("bits of 1.0f = 0x%08X\n", float_bits(1.0f));
ckd_add: overflow detected
ckd_add: 40 + 2 = 42
bits of 1.0f = 0x3F800000
memcpy는 -O2에서 레지스터 이동 명령 하나(fmov w0, s0)로 컴파일됩니다. 비용이 없습니다.
한계와 버전 메모
- 결과는 모두 arm64의 Apple clang 21.0.0입니다. 다른 컴파일러(GCC, 채점 서버)나 다른 버전은 같은 UB를 다르게 최적화하거나 전혀 안 할 수도 있습니다. 그게 UB의 본질입니다. 위
-O0/-O2출력은 이 컴파일러가 한 일이지 C가 보장하는 것이 아닙니다. - C23은 부호 있는 정수를 2의 보수로 표현하도록 정했지만 부호 있는 오버플로는 C23에서도 여전히 UB입니다. "2의 보수"를 "넘치면 돈다"로 읽으면 안 됩니다.
<stdckdint.h>와ckd_add는 C23입니다. clang 21은-std=c17 -Wpedantic에서도 경고 없이 받아들였지만 다른 컴파일러는 아닐 수 있으니 쓸 때는-std=c23으로 컴파일하세요.- UBSan은 strict aliasing 위반을 보고하지 않았습니다.
-fsanitize=type(TypeSanitizer, LLVM의 새 기능)은 드라이버가 받아들였지만libclang_rt.tysan_osx_dynamic.dylib가 Apple clang 21에 들어 있지 않아 링크가 실패했습니다. 초기화 안 된 읽기를 잡는 MemorySanitizer는 macOS에서 쓸 수 없습니다(실행 안 함). - C11 6.8.5p6(루프는 끝난다고 가정 가능)은 C11·C17에 있습니다. 2009년 커널 NULL 검사 버그와 x86 SIGFPE는 문서를 바탕으로 설명했고 실행하지 않았습니다.
- 시간은 한 대에서 두 바퀴, 7회 최솟값입니다. 절대값보다 비율이 중요합니다.
연습 문제
int same_after_double(int x) { return (x * 2) / 2 == x; }에서same_after_double(21)과same_after_double(INT_MAX)가-O0과-O2에서 무엇을 출력할지 예측하고, UBSan이 무엇을 보고할지 말해 보세요.- 위
grade함수를 코드를 고치지 않고 경고 대신 빌드가 실패하게 만들어 보세요.
정답
-O0은1 0:INT_MAX * 2가 -2로 넘치고 -2 / 2는 -1.-O2는1 1: 곱셈이 "넘칠 수 없으니" 컴파일러가(x * 2) / 2를x로 줄였습니다. UBSan:ex5.c:3:68: runtime error: signed integer overflow: 2147483647 * 2 cannot be represented in type 'int'.-Werror=sometimes-uninitialized(또는-Werror)를 붙입니다. clang이error: variable 'bonus' is used uninitialized whenever 'if' condition is false [-Werror,-Wsometimes-uninitialized]를 출력하고 바이너리를 만들지 않았습니다.
메모리 오류로 나타나는 UB(범위 밖 쓰기, 해제 후 사용)는 3강과 4강의 ASan 보고를 보세요.
이전: 4강: 문자열·구조체·버퍼 오버플로 · 다음: 6강: 캐시 친화 코드·make·lldb
자주 묻는 질문
내 컴퓨터에서는 맞는데 채점 서버나 릴리스 빌드(-O2)에서만 틀리는 이유는?
대부분 -O0 빌드에서는 우연히 넘어간 정의되지 않은 동작(UB)이 있기 때문입니다. 최적화기는 UB가 일어나지 않는다고 가정하고 코드를 단순화해도 됩니다. 실제로 INT_MAX에 대한 x + 1 > x가 -O0에서 0, -O2에서 1이었고, 오버플로에 기대던 루프는 -O0에서 22번 돌고 -O2에서는 통째로 사라졌습니다. -fsanitize=undefined로 줄 번호를 찾으세요.
-fsanitize=undefined가 모든 UB를 잡나요?
아닙니다. 부호 있는 오버플로, 너무 큰 시프트, 지역 배열 범위 밖 인덱스, 0으로 나누기는 잡았지만 strict aliasing 위반은 못 잡았고, 초기화 안 된 변수 읽기도 대상이 아닙니다. -fsanitize=type은 Apple clang 21에 런타임 라이브러리가 없어 링크가 실패했습니다.
새니타이저를 켜면 얼마나 느려지나요?
2,000만 원소 루프에서 -O2는 9.63ms, -O2 + UBSan은 25.22ms(2.6배), -O2 + ASan은 17.25ms(1.8배)였습니다. -O0에 두 새니타이저를 함께 켜면 241ms로 -O2의 25배. 테스트와 CI에서 켜고 릴리스 빌드에서는 끄세요.