C 디버깅 도구 - 컴파일러 경고 UBSan ASan 정적 분석 lldb gdb (C 기초 12장)
이 장에서 배우는 것
이 책의 예제는 여러 번 "컴파일은 되지만 틀린" 코드를 보여 줬다. C는 실수를 막아 주는 언어가 아니다. 대신 실수를 찾아 주는 도구가 잘 갖춰져 있다. 마지막 장에서는 버그 세 개가 숨은 평균 계산 함수 하나를 도구로 하나씩 잡아 나간다. 도구마다 잡는 버그의 종류가 다르다는 것이 핵심이다.
- 컴파일러 경고(
-Wall -Wextra, 필요하면-Werror)로 초기화하지 않은 변수를 잡는다. - 디버거(lldb, gdb)로 멈춰 세우고 변수를 들여다보는 기본 흐름을 익힌다.
- UBSan으로 실행 중의 부호 있는 정수 오버플로를 잡는다.
- 정적 분석기, AddressSanitizer의 역할과 한계를 정리하고 개발용 빌드 옵션 세트를 만든다.
문제 상황
센서 네 개의 원시 값을 평균 내는 함수가 실행할 때마다 다른 값을, 그것도 음수를 돌려준다. 코드는 10줄이 안 된다. 눈으로 봐서는 모르겠다. 이럴 때 "printf를 여기저기 넣어 보자"부터 시작하면 시간이 오래 걸린다. 싼 도구부터 순서대로 쓴다.
비용이 싼 순서
① 컴파일러 경고 빌드할 때마다 공짜. 한 파일 안의 명백한 실수
② 정적 분석기 빌드 한 번 더. 실행 안 해도 경로를 따라가며 찾음
③ 새니타이저(UBSan·ASan) 테스트 실행 시. 실제로 일어난 UB·메모리 오류를 줄 번호로
④ 디버거 사람이 직접. 특정 순간의 스택과 변수를 들여다봄
컴파일러 경고, 정적 분석기, 새니타이저, 디버거 순으로 비용이 커지며 도구마다 잡는 버그의 종류가 다르다.
1단계: 경고 읽기
#include <stdio.h>
static int average(const int *values, int count) {
int sum;
for (int i = 0; i <= count; i++) {
sum += values[i];
}
return sum / count;
}
int main(void) {
int readings[4] = {900000000, 800000000, 700000000, 600000000};
printf("평균 = %d\n", average(readings, 4));
return 0;
}
$ clang -std=c17 -Wall -Wextra -g avg1.c -o avg1
avg1.c:6:9: warning: variable 'sum' is uninitialized when used here [-Wuninitialized]
6 | sum += values[i];
| ^~~
avg1.c:4:12: note: initialize the variable 'sum' to silence this warning
4 | int sum;
| ^
| = 0
1 warning generated.
5장에서 봤듯 지역 변수는 스택의 빈 칸이고, 초기화하지 않으면 그 칸에 전에 있던 값이 남아 있다. 그래서 실행할 때마다 결과가 달랐다. note가 알려 준 대로 int sum = 0;으로 고친다. 이 경고를 빌드 실패로 만들고 싶다면 -Werror를 더한다. 팀 프로젝트에서는 새 코드가 경고를 늘리지 못하게 막는 가장 싼 방법이다.
정적 분석기도 같은 버그를 다른 말로 짚는다.
$ clang --analyze -std=c17 -o /dev/null avg1.c
avg1.c:6:13: warning: The left expression of the compound assignment is an uninitialized value. The computed value will also be garbage [core.uninitialized.Assign]
6 | sum += values[i];
| ~~~ ^
1 warning generated.
2단계: 디버거로 멈춰 세우기
초기화를 고친 avg2.c는 경고 없이 컴파일된다. 그런데도 평균이 이상하다.
#include <stdio.h>
static int average(const int *values, int count) {
int sum = 0;
for (int i = 0; i <= count; i++) {
sum += values[i];
}
return sum / count;
}
int main(void) {
int readings[4] = {900000000, 800000000, 700000000, 600000000};
printf("평균 = %d\n", average(readings, 4));
return 0;
}
$ clang -std=c17 -Wall -Wextra -g avg2.c -o avg2
-g는 실행 파일에 소스 줄 번호와 변수 이름 정보(디버그 정보)를 넣는다. 디버거는 이 정보로 기계어 위치를 소스 줄로 되돌려 보여 준다. 이번에는 "반복문이 몇 번 도는가"가 의심스럽다. 6번째 줄(sum += values[i];)에 조건부 중단점을 걸어 i == count일 때, 즉 배열 밖을 읽으려는 순간에만 멈추게 한다. 대화형으로 하나씩 입력해도 되지만, 여기서는 기록을 남기려고 --batch로 명령을 한 번에 넘겼다.
$ lldb --batch -o 'breakpoint set -f avg2.c -l 6 -c "i == count"' -o run -o 'frame variable i count sum' -o bt -o 'kill' ./avg2
(lldb) target create "./avg2"
Current executable set to '~/c-basics/12/avg2' (arm64).
(lldb) breakpoint set -f avg2.c -l 6 -c "i == count"
Breakpoint 1: where = avg2`average + 44 at avg2.c:6:16, address = 0x0000000100000560
(lldb) run
Process <pid> launched: '~/c-basics/12/avg2' (arm64)
Process <pid> stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
frame #0: 0x0000000100000560 avg2`average(values=0x000000016fdff310, count=4) at avg2.c:6:16
3 static int average(const int *values, int count) {
4 int sum = 0;
5 for (int i = 0; i <= count; i++) {
-> 6 sum += values[i];
^
7 }
8 return sum / count;
9 }
Target 0: (avg2) stopped.
(lldb) frame variable i count sum
(int) i = 4
(int) count = 4
(int) sum = -1294967296
(lldb) bt
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
* frame #0: 0x0000000100000560 avg2`average(values=0x000000016fdff310, count=4) at avg2.c:6:16
frame #1: 0x00000001000004ec avg2`main at avg2.c:13:29
frame #2: 0x0000000184a404e4 dyld`start + 6992
(lldb) kill
Process <pid> exited with status = 9 (0x00000009) killed
읽는 법은 다음과 같다(경로는 ~/c-basics/12로, 실행마다 바뀌는 프로세스 번호는 <pid>로 바꿔 적었다).
Breakpoint 1: where = avg2`average + 44 at avg2.c:6:16— 중단점이 걸린 함수와 소스 위치다.stop reason = breakpoint 1.1— 조건i == count가 참이 되어 멈췄다. 조건이 한 번이라도 참이 됐다는 사실만으로 반복문이 배열 크기만큼 한 번 더 돈다는 것이 증명된다.frame variable i count sum—i = 4,count = 4. 원소 네 개의 인덱스는 0~3인데 4를 읽으려 한다.sum이 이미 음수인 것은 또 다른 버그의 흔적이다(3단계).bt(backtrace) — 호출 스택이다.frame #0이 지금 멈춘average,frame #1이 그것을 부른main의 13번째 줄이다. 5장의 스택 프레임 그림이 디버거 화면에 그대로 나온 것이다.values=0x...는 배열 주소로, 디버거는 보통 주소 무작위화를 끄고 실행하지만 환경에 따라 값이 다를 수 있다.
리눅스에서는 gdb를 더 많이 쓴다. 흐름은 같고 명령 이름만 다르다.
| 하려는 일 | lldb | gdb |
|---|---|---|
| 프로그램 불러오기 | lldb ./avg2 | gdb ./avg2 |
| 조건부 중단점 | breakpoint set -f avg2.c -l 6 -c "i == count" | break avg2.c:6 if i == count |
| 실행 | run | run |
| 지역 변수 보기 | frame variable | info locals |
| 식 계산해 보기 | p values[3] | p values[3] |
| 호출 스택 | bt | bt |
| 한 줄 실행(함수 안으로 / 건너뛰기) | step / next | step / next |
| 변수 값이 바뀌면 멈추기 | watchpoint set variable sum | watch sum |
| 계속 실행 / 종료 | continue / quit | continue / quit |
이 표의 gdb 명령은 이 책의 검증 환경에 gdb가 없어 직접 실행하지 않았다. lldb 명령만 실제로 실행했다. 조건을 <로 고친 것이 avg3.c다.
3단계: UBSan으로 오버플로 잡기
#include <stdio.h>
static int average(const int *values, int count) {
int sum = 0;
for (int i = 0; i < count; i++) {
sum += values[i];
}
return sum / count;
}
int main(void) {
int readings[4] = {900000000, 800000000, 700000000, 600000000};
printf("평균 = %d\n", average(readings, 4));
return 0;
}
$ clang -std=c17 -Wall -Wextra -g -fsanitize=undefined avg3.c -o avg3 && ./avg3
avg3.c:6:13: runtime error: signed integer overflow: 1700000000 + 700000000 cannot be represented in type 'int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior avg3.c:6:13
평균 = -323741824
9억 + 8억 + 7억은 int의 최댓값(약 21억)을 넘는다. UBSan이 넘친 순간의 두 값(1700000000 + 700000000)과 줄 번호를 알려 줬다. 2장에서 본 부호 있는 정수 오버플로다. 합계는 넓은 자료형으로 모은다.
#include <stdio.h>
static long long average(const int *values, int count) {
long long sum = 0;
for (int i = 0; i < count; i++) {
sum += values[i];
}
return sum / count;
}
int main(void) {
int readings[4] = {900000000, 800000000, 700000000, 600000000};
printf("평균 = %lld\n", average(readings, 4));
return 0;
}
$ ./avg4
평균 = 750000000
(900000000 + 800000000 + 700000000 + 600000000) / 4 = 750000000. 드디어 맞다. 버그 세 개를 잡은 도구가 각각 달랐다는 점을 기억하자. 경고는 초기화를, 디버거는 경계를, UBSan은 오버플로를 잡았다.
avg1 의 초기화 누락은 경고가, avg2 의 경계 초과는 lldb 조건부 중단점이, avg3 의 오버플로는 UBSan 이 잡아 avg4 에서 750000000 이 나왔다.
4단계: AddressSanitizer와 도구 정리
2단계의 배열 밖 읽기는 AddressSanitizer(ASan)를 켜면 디버거 없이도 잡힌다. clang -g -fsanitize=address,undefined avg2.c로 빌드해 실행하면, 스택 배열 readings의 바로 뒤를 읽는 순간 stack-buffer-overflow 보고와 함께 문제 줄에서 멈춘다. 6장의 UBSan bounds 검사는 크기를 아는 배열에만 통하지만, ASan은 배열 앞뒤에 "접근 금지 구역"을 두는 방식이라 포인터로 넘어간 배열, 힙 블록, 해제된 블록까지 잡는다. 대신 메모리를 더 쓰고 실행이 느려지므로(일반적으로 2배 안팎) 테스트·개발 빌드에서만 켠다.
검증 한계: 9장에서 밝혔듯 이 책의 검증 환경(macOS 26.6, Apple clang 17, arm64)에서는 ASan 실행 파일이 시작 단계에서 멈춰 ASan 보고를 실제로 받지 못했다. 이 절의 ASan 설명에는 실행 결과를 붙이지 않았다. 리눅스 개발 환경이나 CI에서 위 명령을 돌려 직접 확인하기를 권한다.
| 도구 | 켜는 법 | 잘 잡는 것 | 못 잡는 것 |
|---|---|---|---|
| 컴파일러 경고 | -Wall -Wextra (+ -Wconversion, -Wimplicit-fallthrough, -Werror) | 초기화 누락, 대입/비교 혼동, 부호 비교, 형식 문자열 불일치 | 실행 중 값에 달린 버그 |
| 정적 분석기 | clang --analyze | 누수, 해제 후 사용, NULL 역참조 경로 | 복잡한 경로, 실행 데이터에 달린 문제 |
| UBSan | -fsanitize=undefined | 정수 오버플로, 시프트 범위, 크기 아는 배열 인덱스, NULL 읽기 | 힙 범위 초과, 해제 후 사용 |
| ASan | -fsanitize=address | 스택·힙·전역 버퍼 넘침, 해제 후 사용, 이중 해제, (리눅스) 누수 | 초기화 안 된 값 읽기, 실행하지 않은 경로 |
| 디버거 | -g로 빌드 후 lldb/gdb | 특정 순간의 상태, 호출 경로, 값이 바뀌는 순간 | "어디가 이상한지" 모를 때의 탐색은 느림 |
개발 중에 쓸 빌드 명령을 하나 만들어 두면 좋다. 예를 들면 clang -std=c17 -Wall -Wextra -Werror -g -O0 -fsanitize=address,undefined이다. 배포용 빌드는 새니타이저를 빼고 -O2로 따로 만든다. 새니타이저가 켜진 빌드로 테스트를 돌려 통과한 코드만 배포 빌드로 넘긴다.
실무에서 자주 틀리는 것
- 최적화 빌드(
-O2)로 디버깅한다. 변수가 레지스터에만 있거나 사라져 디버거가<optimized out>을 보여 주고, 줄 순서가 뒤섞인다. 디버깅은-O0 -g로 한다. 반대로 "최적화하면 달라지는 버그"는 UB의 신호이니 새니타이저를 돌린다. - 경고가 수백 개인 코드베이스에 익숙해진다. 새 경고가 묻힌다. 경고를 0으로 만들고
-Werror로 지키거나, 적어도 새로 추가된 파일부터 0으로 유지한다. - 재현이 안 된다며 넘어간다. 실행마다 결과가 다른 버그는 대부분 초기화 누락, 범위 밖 접근, 해제 후 사용처럼 "메모리에 우연히 남은 값"에 의존하는 버그다. 이 책의 도구 셋이 정확히 그 부류를 겨냥한다.
- 새니타이저 보고의 첫 줄만 본다. ASan 보고에는 문제 접근 위치, 그 메모리를 할당한 위치, 해제한 위치가 차례로 나온다. 원인은 대개 두 번째·세 번째 스택에 있다.
연습 문제
avg2.c에서sum이 처음으로 음수가 되는 순간에 멈추려면 lldb 중단점 조건을 어떻게 걸어야 하는가? 그때i는 얼마일까?- 다음 버그는 각각 어떤 도구로 가장 싸게 잡을 수 있는가? (가)
if (x = 5)(나)malloc한 10바이트 버퍼에 11바이트째 쓰기 (다)int곱셈이 넘침 - 동료가 "UBSan을 켜고 테스트를 다 돌렸는데 아무것도 안 나왔으니 이 코드에는 UB가 없다"고 말한다. 무엇이 틀렸는가?
정답
breakpoint set -f avg2.c -l 6 -c "sum < 0". 6번째 줄은 더하기 전에 멈추므로,i = 2회차에서 700000000을 더해 넘친 결과가 다음 회차 시작에서 보인다. 멈췄을 때i는 3이다(검증 스크립트에서 lldb로 확인). 다만 넘친 뒤의 값은 UB라서 음수가 된다는 보장이 없다. 이 실행에서 음수(-1294967296)로 보인 것은 결과일 뿐 약속이 아니므로, 이런 조건은 디버깅 보조로만 쓰고 원인 확인은 UBSan에 맡긴다.- (가) 컴파일러 경고(
-Wparentheses,-Wall에 포함, 4장). (나) ASan(heap-buffer-overflow). UBSan의 bounds 검사는 힙 블록의 크기를 모른다. (다) UBSan(signed integer overflow). 컴파일 시점에 값을 알 수 있는 상수 계산이라면 컴파일러 경고로도 나온다. - 새니타이저는 실행된 경로에서 실제로 일어난 일만 보고한다. 테스트가 지나가지 않은 분기, 테스트 데이터로는 넘치지 않는 값, UBSan이 검사하지 않는 종류의 UB(예: 해제 후 사용, 초기화 안 된 값)는 여전히 남아 있을 수 있다. 경고·정적 분석·ASan과 함께 쓰고, 경계값(0, 최댓값, 빈 입력)을 테스트 데이터에 넣어야 한다.
책을 마치며
열두 장 동안 같은 그림을 반복해서 그렸다. 값은 바이트이고, 바이트는 스택·힙·정적 영역 중 한 곳에 살며, 포인터는 그 주소를 담는 칸이다. 새로운 C 코드를 만나면 "이 값은 어디에 살고, 언제 사라지고, 누가 크기를 알고 있는가"를 먼저 물어보자. 이 질문에 답하면 C 버그의 대부분은 설명된다.