Devin.KR

C 디버깅 도구 - 컴파일러 경고 UBSan ASan 정적 분석 lldb gdb (C 기초 12장)

개발자 조회 1

이 장에서 배우는 것

이 책의 예제는 여러 번 "컴파일은 되지만 틀린" 코드를 보여 줬다. 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 sumi = 4, count = 4. 원소 네 개의 인덱스는 0~3인데 4를 읽으려 한다. sum이 이미 음수인 것은 또 다른 버그의 흔적이다(3단계).
  • bt(backtrace) — 호출 스택이다. frame #0이 지금 멈춘 average, frame #1이 그것을 부른 main의 13번째 줄이다. 5장의 스택 프레임 그림이 디버거 화면에 그대로 나온 것이다. values=0x...는 배열 주소로, 디버거는 보통 주소 무작위화를 끄고 실행하지만 환경에 따라 값이 다를 수 있다.

리눅스에서는 gdb를 더 많이 쓴다. 흐름은 같고 명령 이름만 다르다.

lldb 와 gdb 명령 대응 (gdb 는 이 책 환경에서 미실행)
하려는 일lldbgdb
프로그램 불러오기lldb ./avg2gdb ./avg2
조건부 중단점breakpoint set -f avg2.c -l 6 -c "i == count"break avg2.c:6 if i == count
실행runrun
지역 변수 보기frame variableinfo locals
식 계산해 보기p values[3]p values[3]
호출 스택btbt
한 줄 실행(함수 안으로 / 건너뛰기)step / nextstep / next
변수 값이 바뀌면 멈추기watchpoint set variable sumwatch sum
계속 실행 / 종료continue / quitcontinue / 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 이 나왔다.

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 보고에는 문제 접근 위치, 그 메모리를 할당한 위치, 해제한 위치가 차례로 나온다. 원인은 대개 두 번째·세 번째 스택에 있다.

연습 문제

  1. avg2.c에서 sum이 처음으로 음수가 되는 순간에 멈추려면 lldb 중단점 조건을 어떻게 걸어야 하는가? 그때 i는 얼마일까?
  2. 다음 버그는 각각 어떤 도구로 가장 싸게 잡을 수 있는가? (가) if (x = 5) (나) malloc한 10바이트 버퍼에 11바이트째 쓰기 (다) int 곱셈이 넘침
  3. 동료가 "UBSan을 켜고 테스트를 다 돌렸는데 아무것도 안 나왔으니 이 코드에는 UB가 없다"고 말한다. 무엇이 틀렸는가?

정답

  1. breakpoint set -f avg2.c -l 6 -c "sum < 0". 6번째 줄은 더하기 에 멈추므로, i = 2 회차에서 700000000을 더해 넘친 결과가 다음 회차 시작에서 보인다. 멈췄을 때 i3이다(검증 스크립트에서 lldb로 확인). 다만 넘친 뒤의 값은 UB라서 음수가 된다는 보장이 없다. 이 실행에서 음수(-1294967296)로 보인 것은 결과일 뿐 약속이 아니므로, 이런 조건은 디버깅 보조로만 쓰고 원인 확인은 UBSan에 맡긴다.
  2. (가) 컴파일러 경고(-Wparentheses, -Wall에 포함, 4장). (나) ASan(heap-buffer-overflow). UBSan의 bounds 검사는 힙 블록의 크기를 모른다. (다) UBSan(signed integer overflow). 컴파일 시점에 값을 알 수 있는 상수 계산이라면 컴파일러 경고로도 나온다.
  3. 새니타이저는 실행된 경로에서 실제로 일어난 일만 보고한다. 테스트가 지나가지 않은 분기, 테스트 데이터로는 넘치지 않는 값, UBSan이 검사하지 않는 종류의 UB(예: 해제 후 사용, 초기화 안 된 값)는 여전히 남아 있을 수 있다. 경고·정적 분석·ASan과 함께 쓰고, 경계값(0, 최댓값, 빈 입력)을 테스트 데이터에 넣어야 한다.

책을 마치며

열두 장 동안 같은 그림을 반복해서 그렸다. 값은 바이트이고, 바이트는 스택·힙·정적 영역 중 한 곳에 살며, 포인터는 그 주소를 담는 칸이다. 새로운 C 코드를 만나면 "이 값은 어디에 살고, 언제 사라지고, 누가 크기를 알고 있는가"를 먼저 물어보자. 이 질문에 답하면 C 버그의 대부분은 설명된다.

댓글 0

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

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