printf 디버깅에서 벗어나기: GDB·LLDB 브레이크포인트, 워치포인트, 단계 실행 기초
들어가며: printf 디버깅의 한계
std::cout을 곳곳에 넣어 버그 위치를 좁히는 방식은 단순하지만, 출력을 추가할 때마다 다시 빌드하고 실행해야 하고, 무엇을 출력할지 미리 알아야 합니다. 디버거는 프로그램을 원하는 지점에서 멈추고, 그 순간의 변수 값·호출 스택·메모리를 직접 보게 해 줍니다. 브레이크포인트(실행을 멈출 지점), 워치포인트(특정 메모리가 바뀔 때 자동으로 멈춤), 조건부 중단, 백트레이스(호출 스택)만 익혀도 버그 위치를 훨씬 빨리 좁힐 수 있습니다.
printf 디버깅이 크래시 앞에서 특히 약한 이유는 출력 버퍼링입니다. 표준 출력이 터미널이면 보통 줄 단위로 내보내지만, 파일이나 파이프로 리다이렉트하면 블록 단위로 모았다가 내보냅니다. 프로세스가 SIGSEGV로 죽으면 버퍼에 남아 있던 마지막 출력이 사라지므로, 로그에는 “3”까지만 찍혔어도 실제로는 훨씬 뒤에서 죽었을 수 있습니다. 버퍼링하지 않는 std::cerr가 임시 추적에는 낫지만, 매번 코드를 고치고 다시 빌드해야 한다는 근본 문제는 그대로입니다.
void processData(int* arr, int size) {
std::cout << "1\n";
for (int i = 0; i < size; ++i) {
std::cout << "2: i=" << i << "\n";
arr[i] *= 2;
std::cout << "3\n";
}
std::cout << "4\n";
}
// 출력을 리다이렉트한 상태에서 크래시하면 마지막 몇 줄이 사라질 수 있음
같은 문제를 디버거로 보면, 크래시 순간에 멈춘 상태에서 스택과 변수를 바로 읽을 수 있습니다.
$ gdb ./myapp
(gdb) run
Program received signal SIGSEGV, Segmentation fault.
(gdb) backtrace
#0 processData (arr=0x..., size=...) at main.cpp:15
#1 main () at main.cpp:30
(gdb) print i
(gdb) print size # size가 실제 배열 크기보다 크게 전달됐다면 호출부를 확인
Linux·WSL에서는 GDB(apt install gdb), macOS에서는 LLDB(xcode-select --install)를 주로 씁니다. 명령 이름은 다르지만 멈추고, 한 줄씩 진행하고, 스택과 변수를 보는 흐름은 같습니다. 이 글은 GDB 기준으로 설명하고 LLDB 대응 명령을 함께 적었습니다.
증상별로 먼저 손이 가는 디버거 기능:
flowchart TD
A[버그 발생] --> B{유형?}
B -->|크래시| C[디버거로 실행 → backtrace]
B -->|특정 반복·입력에서만| D[조건부 브레이크포인트]
B -->|값이 어디선가 바뀜| E[워치포인트]
B -->|멈춘 것처럼 보임| F[Ctrl+C → backtrace]
B -->|멀티스레드 정지| G[thread apply all bt]
B -->|운영 환경 크래시| H[core dump 분석]
디버그 빌드와 GDB·LLDB 시작
디버그 빌드
-g를 주면 실행 파일에 디버그 정보(소스 줄 번호, 변수 이름과 위치, 타입)가 들어갑니다. -O0로 최적화를 끄면 변수가 사라지거나 코드 순서가 바뀌는 일이 없어 디버깅이 가장 편합니다.
# 디버그 정보 포함 (-g), 최적화 끄기 (-O0)
g++ -g -O0 main.cpp -o myapp
# CMake
cmake -DCMAKE_BUILD_TYPE=Debug ..
make
# 최적화 + 디버그 정보 (릴리스에서만 나는 버그 재현용)
cmake -DCMAKE_BUILD_TYPE=RelWithDebInfo ..
-g와 -O0는 역할이 다릅니다. -g는 “이 기계어가 소스 몇 번째 줄인지, 이 레지스터가 어떤 변수인지”를 담은 정보를 추가할 뿐 실행 코드를 바꾸지 않고, -O0는 코드 자체를 최적화하지 않게 합니다. 그래서 -O2 -g 빌드도 디버깅은 가능하지만, 변수가 <optimized out>으로 나오거나 next가 소스 줄을 앞뒤로 건너뛰는 일이 생깁니다. GCC의 -Og는 디버깅 경험을 해치지 않는 최적화만 켜는 절충안입니다.
GDB 시작
gdb ./myapp
(gdb) run # 실행
(gdb) run arg1 arg2 # 인자와 함께 실행
(gdb) set env LD_LIBRARY_PATH=/path/to/libs # 환경 변수 설정 후
(gdb) run
(gdb) quit
GDB는 기본으로 디버깅 대상 프로세스의 ASLR(주소 무작위화)을 끈 채 실행합니다(set disable-randomization on). 그래서 디버거 밖에서는 크래시가 나는데 GDB 안에서는 재현되지 않는 주소 의존 버그가 있다면 set disable-randomization off로 바꾼 뒤 다시 실행해 봅니다.
LLDB 시작 (macOS)
lldb ./myapp
(lldb) run
(lldb) run arg1 arg2
(lldb) quit
브레이크포인트와 조건부 브레이크포인트
브레이크포인트가 동작하는 방식
브레이크포인트는 실행을 특정 위치에서 멈추게 하는 지점입니다. Linux의 GDB는 ptrace 시스템 콜로 대상 프로세스를 제어하며, 소프트웨어 브레이크포인트는 대략 다음 순서로 동작합니다.
break main.cpp:20을 받으면 디버그 정보에서 해당 줄의 기계어 주소를 찾습니다.- 그 주소의 첫 바이트를 저장해 두고, x86에서는
int3명령(0xCC, 1바이트)으로 덮어씁니다(PTRACE_POKETEXT). - CPU가 그 주소에 도달해
int3를 실행하면 커널이 프로세스를 SIGTRAP으로 멈추고 디버거에 알립니다. - 디버거는 원래 바이트를 되돌리고, 명령 포인터를
int3다음에서 브레이크포인트 주소로 1바이트 되돌린 뒤 프롬프트를 띄웁니다. continue하면 원래 명령을 한 단계 실행(PTRACE_SINGLESTEP)하고 다시int3를 써 넣은 뒤 실행을 재개합니다(PTRACE_CONT).
하드웨어 브레이크포인트(hbreak)는 코드를 바꾸지 않고 CPU의 디버그 레지스터(x86의 DR0~DR3에 주소, DR7에 활성화 비트)를 씁니다. 읽기 전용 메모리나 ROM의 코드에도 걸 수 있지만, x86에서는 하드웨어 워치포인트와 합쳐 동시에 4개까지만 쓸 수 있습니다.
조건부 브레이크포인트는 내부적으로 일반 브레이크포인트와 같습니다. 도달할 때마다 프로세스가 멈추고, 디버거가 조건식을 평가해 거짓이면 조용히 실행을 재개합니다. 그래서 수백만 번 도는 루프 안에 조건부 브레이크포인트를 걸면 매번 커널과 디버거 사이를 오가느라 프로그램이 눈에 띄게 느려집니다. 그럴 때는 조건을 만족하는 코드 경로에 임시로 if 문을 넣고 거기에 브레이크포인트를 거는 편이 빠릅니다.
GDB 브레이크포인트 명령어
(gdb) break main # 함수에
(gdb) b main.cpp:15 # 파일:줄에
(gdb) break main.cpp:20 if i == 50 # 조건부
(gdb) break main.cpp:25 if ptr == 0 # 포인터가 null일 때만
(gdb) info breakpoints # 목록 (i b)
(gdb) delete 1 # 번호로 삭제
(gdb) delete # 모두 삭제
(gdb) clear main.cpp:15 # 위치로 삭제
(gdb) disable 1 # 비활성화
(gdb) enable 1
(gdb) ignore 1 99 # 1번을 99번 건너뛰고 100번째 도달 때 멈춤
(gdb) tbreak main.cpp:40 # 한 번 멈추면 사라지는 임시 브레이크포인트
LLDB 브레이크포인트 명령어
(lldb) breakpoint set --name main
(lldb) b main
(lldb) breakpoint set --file main.cpp --line 15
(lldb) b main.cpp:15
(lldb) breakpoint set -f main.cpp -l 20 -c 'i == 50'
(lldb) breakpoint set -f main.cpp -l 25 -c 'ptr == nullptr'
(lldb) breakpoint list
(lldb) breakpoint delete 1
(lldb) breakpoint disable 1
(lldb) breakpoint enable 1
조건부 브레이크포인트 활용
// 1000번 중 500번째에서만 잘못된 동작
for (int i = 0; i < 1000; ++i) {
process(i); // main.cpp 10번 줄
}
# GDB: i가 500일 때만 10번 줄에서 멈춤
(gdb) break main.cpp:10 if i == 500
(gdb) run
# LLDB
(lldb) breakpoint set -f main.cpp -l 10 -c 'i == 500'
(lldb) run
이미 만든 브레이크포인트에 나중에 조건을 붙이거나, 조건식 대신 도달 횟수만 쓸 수도 있습니다.
# C 문자열 비교
(gdb) break main.cpp:30 if strcmp(name, "buggy") == 0
# 이미 만든 1번 브레이크포인트에 조건 추가/변경
(gdb) condition 1 ptr == 0
# i가 0부터 시작한다면 i == 500은 501번째 도달이므로 500번 건너뜀
(gdb) ignore 1 500
ignore는 조건식을 평가하지 않고 횟수만 세므로 조건식보다 가볍습니다. 조건식에서 함수를 호출하면 브레이크포인트에 도달할 때마다 대상 프로세스 안에서 그 함수가 실행됩니다. strcmp처럼 부작용이 없는 함수는 괜찮지만, 상태를 바꾸는 함수를 조건에 넣으면 디버깅이 프로그램 동작을 바꿔 버립니다. 또 std::string의 ==처럼 인라인되어 바이너리에 실체가 없는 함수는 조건식에서 호출이 실패할 수 있습니다.
워치포인트로 메모리 추적
워치포인트는 특정 변수나 메모리 주소의 값이 바뀌는 순간 실행을 멈추는 기능입니다. “이 값이 도대체 어디서 바뀌는가”를 찾을 때 씁니다. x86에서는 디버그 레지스터를 써서 하드웨어가 쓰기를 감지하므로, 감시하는 동안에도 프로그램이 거의 제 속도로 돕니다.
GDB 워치포인트
(gdb) watch variable_name # 쓰기 시 멈춤
(gdb) watch *ptr # 포인터가 가리키는 값
(gdb) rwatch variable_name # 읽기 시 멈춤
(gdb) awatch variable_name # 읽기 또는 쓰기 시 멈춤
(gdb) watch -l arr[5] # 식이 아니라 현재 주소를 감시
지역 변수는 프로그램이 해당 스코프에 들어간 뒤에만 감시할 수 있으므로, 먼저 브레이크포인트로 멈춘 다음 워치포인트를 겁니다.
LLDB 워치포인트
(lldb) watchpoint set variable variable_name
(lldb) w s v variable_name # 단축
(lldb) watchpoint set expression -- ptr # ptr 값을 주소로 보고 그 메모리를 감시
(lldb) watchpoint set expression -w write -s 4 -- 0x7fff1234 # 주소 직접 지정
(lldb) watchpoint list
(lldb) watchpoint delete 1
LLDB의 watchpoint set expression은 식을 평가한 결과를 감시할 주소로 씁니다. 그래서 주소를 직접 줄 때는 *(int*)0x...처럼 역참조하지 않고 주소 값을 그대로 넘기고, -s로 감시할 바이트 수를 지정합니다.
워치포인트 실전 예: 메모리 오염 찾기
// memory_corruption.cpp
#include <iostream>
struct State {
int buffer[10];
int counter; // buffer 바로 뒤에 배치됨
};
State g_state{};
void suspiciousFunction() {
// 버퍼 오버런: buffer[10]은 존재하지 않는 원소 → 바로 뒤 counter를 덮어씀 (UB)
for (int i = 0; i <= 10; ++i) {
g_state.buffer[i] = i; // i=10일 때 범위 초과
}
}
int main() {
std::cout << "Before: " << g_state.counter << "\n";
suspiciousFunction();
std::cout << "After: " << g_state.counter << "\n"; // 10이 찍힘
return 0;
}
$ g++ -g -O0 -o corrupt memory_corruption.cpp
$ gdb ./corrupt
(gdb) break main
(gdb) run
(gdb) watch g_state.counter
(gdb) continue
Hardware watchpoint 2: g_state.counter
Old value = 0
New value = 10
suspiciousFunction () at memory_corruption.cpp:13
예제가 구조체를 쓰는 이유가 있습니다. 흔히 “지역 배열이 넘치면 전역 변수를 덮는다”고 설명하지만, 지역 배열은 스택에, 전역 변수는 데이터 영역에 있어 서로 멀리 떨어져 있으므로 실제로는 스택의 다른 지역 변수나 반환 주소가 망가집니다. 구조체 멤버는 선언 순서대로 배치되므로 buffer[10]이 counter 자리를 덮는 상황이 (여전히 미정의 동작이지만) 재현 가능하게 드러납니다.
워치포인트의 핵심은 “누가 썼는지”를 쓰는 순간에 잡아 준다는 점입니다. 오염이 발견된 시점에 backtrace를 보면 범인 함수는 이미 반환한 뒤라서, 워치포인트 없이는 추적이 훨씬 어렵습니다.
지역 변수에 워치포인트를 걸면 그 변수의 스코프를 벗어날 때 GDB가 “Watchpoint deleted because the program has left the block”이라며 워치포인트를 지웁니다. 함수가 여러 번 호출되는 경우라면 매번 다시 걸거나, watch -l var처럼 주소를 기준으로 감시해야 합니다.
하드웨어 워치포인트는 x86에서 디버그 레지스터 수만큼(4개, 감시 크기에 따라 더 적을 수 있음)만 걸 수 있습니다. 개수를 넘기면 실행을 재개할 때 “Could not insert hardware watchpoint” 에러가 나므로 필요 없는 것을 지웁니다. set can-use-hw-watchpoints 0으로 소프트웨어 워치포인트를 강제할 수도 있지만, 이 방식은 한 명령씩 실행하며 값을 비교하므로 매우 느립니다.
next, step, finish로 단계별 실행
| 명령 | GDB | LLDB | 동작 |
|---|---|---|---|
| 다음 줄 | next / n | next / n | 현재 줄을 실행하고 다음 줄로. 함수 호출은 안으로 들어가지 않고 한 번에 실행 |
| 함수 진입 | step / s | step / s | 현재 줄의 함수 호출 안으로 들어감 |
| 함수 끝까지 | finish | finish | 현재 함수가 반환될 때까지 실행하고 반환값 표시 |
| 계속 | continue / c | continue / c | 다음 브레이크포인트까지 실행 |
| 특정 줄까지 | until 42 / advance 42 | thread until 42 | 지정한 줄까지 실행 |
(gdb) next 5 # next를 5번
(gdb) step 3
(gdb) list # 현재 위치 주변 소스 (l)
step은 std::vector::operator[] 같은 표준 라이브러리 함수 안으로도 들어가 버려 번거롭습니다. GDB에서는 skip -gfi /usr/include/c++/*/*처럼 특정 파일이나 함수를 step 대상에서 빼 둘 수 있고, 이미 들어갔다면 finish로 빠져나옵니다.
print, ptype, x, backtrace로 상태 검사
변수 출력
# GDB
(gdb) print x
(gdb) print *ptr
(gdb) print arr[0]@10 # arr[0]부터 원소 10개
(gdb) print person
(gdb) print person.name
(gdb) print vec # libstdc++ pretty printer가 있으면 원소까지 표시
(gdb) print vec.size() # 인라인된 함수라면 호출이 실패할 수 있음
# LLDB
(lldb) frame variable # 현재 프레임의 모든 지역 변수 (fr v)
(lldb) frame variable x
(lldb) p x
(lldb) expression ptr->member
(lldb) expr vec.size()
print vec가 내부 포인터만 잔뜩 보여 준다면 pretty printer가 로드되지 않은 것입니다. 대부분의 배포판은 GCC와 함께 libstdc++ pretty printer를 설치하고 GDB가 자동으로 불러오지만, 직접 빌드한 툴체인에서는 빠져 있을 수 있습니다.
타입 확인 (ptype)
값만 봐서는 원인이 안 보일 때, 변수의 실제 타입이나 구조체 정의를 확인하면 “signed/unsigned 비교였다”, “생각한 구조체가 아니었다” 같은 단서가 나옵니다.
(gdb) ptype x # 변수 타입
(gdb) ptype Person # 구조체 정의 (멤버 목록)
(gdb) whatis expr # 표현식의 타입만 한 줄로
(lldb) type lookup Person
메모리 검사 (x 명령)
# 형식: x/[개수][형식][크기] 주소
(gdb) x/10xw ptr # 4바이트 단위 16진수 10개
(gdb) x/10dw ptr # 4바이트 단위 10진수 10개
(gdb) x/s ptr # C 문자열 하나
(gdb) x/20xb ptr # 1바이트 단위 16진수 20개
(gdb) x/5i $pc # 현재 위치부터 기계어 명령 5개
# 형식: x(16진), d(10진), s(문자열), i(명령어)
# 크기: b(1바이트), h(2바이트), w(4바이트), g(8바이트)
스택 추적 (백트레이스)
# GDB
(gdb) backtrace # bt
(gdb) bt full # 모든 프레임의 지역 변수 포함
(gdb) frame 2 # 2번 프레임으로 이동 (f 2)
(gdb) up / down # 한 프레임씩 이동
(gdb) info frame
(gdb) info locals # 현재 프레임의 지역 변수
(gdb) info args # 현재 프레임의 함수 인자
# LLDB
(lldb) thread backtrace # bt
(lldb) frame select 2 # f 2
(lldb) frame variable
프레임 0이 현재 실행 중인 함수이고, 번호가 커질수록 호출한 쪽입니다. 크래시가 표준 라이브러리나 libc 안(프레임 0~2)에서 났더라도 원인은 대개 그 위의 내 코드 프레임에서 잘못된 인자를 넘긴 데 있으므로, frame N으로 처음 나오는 내 코드 프레임에 가서 인자를 확인합니다.
세그폴트, 무한 루프, 데드락을 GDB로 추적하는 예제
배열 범위 초과
// buggy_array.cpp - g++ -g -O0 -o buggy buggy_array.cpp
#include <iostream>
void fill(int* arr, int size) {
for (int i = 0; i < size; ++i) {
arr[i] = i * 2;
}
}
int main() {
int arr[10];
fill(arr, 10000000); // 버그: 실제 크기보다 훨씬 큰 size 전달
std::cout << "done\n";
return 0;
}
$ gdb ./buggy
(gdb) run
Program received signal SIGSEGV, Segmentation fault.
(gdb) backtrace
#0 fill (arr=0x7fffffffe2a0, size=10000000) at buggy_array.cpp:6
#1 main () at buggy_array.cpp:11
(gdb) print i # 스택 끝을 넘어선 지점의 인덱스 (환경마다 다름)
(gdb) frame 1
(gdb) print sizeof(arr) / sizeof(arr[0])
$2 = 10
size가 배열 크기 10보다 훨씬 크게 넘어온 것이 바로 보입니다. 반면 i <= size 같은 한 칸짜리 범위 초과는 대개 세그폴트가 나지 않습니다. 배열 바로 뒤의 스택 메모리도 접근 가능한 영역이라 조용히 다른 값을 덮어쓰고, 스택 보호(-fstack-protector, 많은 배포판의 기본값)가 켜져 있으면 함수가 반환할 때 “stack smashing detected”로 중단되는 정도입니다. 이런 버그는 디버거보다 AddressSanitizer가 접근 순간에 정확히 잡아 줍니다.
null 포인터 역참조
// null_ptr.cpp
struct Node { int value; Node* next; };
int secondValue(Node* head) {
return head->next->value; // 노드가 하나뿐이면 head->next가 nullptr
}
(gdb) run
Program received signal SIGSEGV, Segmentation fault.
0x... in secondValue (head=0x4052a0) at null_ptr.cpp:5
(gdb) print head->next
$1 = (Node *) 0x0
(gdb) bt full
한 줄에 포인터 접근이 여러 번 있으면 어느 쪽이 null인지 줄 번호만으로는 알 수 없으므로, 각 부분식을 print로 확인합니다.
무한 루프
void processData() {
int i = 0;
while (i < 100) {
process(i);
// i++ 누락
}
}
(gdb) run
^C
Program received signal SIGINT, Interrupt.
(gdb) backtrace
#0 processData () at main.cpp:5
(gdb) print i
$1 = 0
(gdb) next
(gdb) next
(gdb) print i
$2 = 0 # 반복해도 변하지 않음 → 증가 누락
Ctrl+C로 멈춘 위치가 process() 내부일 수도 있으므로, finish로 루프 본문까지 올라온 뒤 몇 번 next하며 루프 변수가 변하는지 봅니다.
STL vector 범위 초과
std::vector<int> vec = {1, 2, 3};
for (size_t i = 0; i <= vec.size(); ++i) { // <= 버그
std::cout << vec[i] << "\n";
}
vec[3]은 대개 크래시하지 않고 힙에 있던 쓰레기 값을 출력합니다. operator[]는 범위를 검사하지 않고, 바로 뒤의 힙 메모리도 접근 가능하기 때문입니다. 이런 버그를 디버거에서 잡으려면 -D_GLIBCXX_ASSERTIONS(libstdc++)로 빌드해 범위 초과 시 즉시 중단되게 하거나, 의심 지점을 vec.at(i)로 바꿔 std::out_of_range 예외가 던져지는 순간 catch throw로 멈추게 합니다.
(gdb) catch throw # 예외가 던져지는 순간 멈춤
(gdb) run
(gdb) bt # 예외를 던진 위치
멀티스레드 데드락
std::mutex m1, m2;
void thread1() { std::lock_guard a(m1); /* ... */ std::lock_guard b(m2); }
void thread2() { std::lock_guard a(m2); /* ... */ std::lock_guard b(m1); }
# 멈춘 것처럼 보이면 Ctrl+C 후
(gdb) thread apply all backtrace
Thread 3 (...):
#0 futex_wait (...)
#1 ... pthread_mutex_lock ...
#3 thread2 () at deadlock.cpp:3
Thread 2 (...):
#0 futex_wait (...)
#1 ... pthread_mutex_lock ...
#3 thread1 () at deadlock.cpp:2
스택 맨 위의 함수 이름은 glibc 버전에 따라 __lll_lock_wait, futex_wait 등으로 다르지만, 두 스레드가 모두 pthread_mutex_lock에서 기다리고 있고 각자 상대가 쥔 뮤텍스를 요청하고 있다는 것이 보입니다. 해결은 잠금 순서를 통일하거나 std::scoped_lock(m1, m2)로 두 뮤텍스를 한 번에 잡는 것입니다.
무한 재귀와 스택 오버플로
int factorial(int n) {
if (n == 0) return 1; // 음수가 들어오면 종료 조건에 도달하지 못함
return n * factorial(n - 1);
}
int main() {
return factorial(-5);
}
(gdb) run
Program received signal SIGSEGV, Segmentation fault.
(gdb) bt 3 # 안쪽 3개 프레임
#0 factorial (n=...) at stack_overflow.cpp:2
#1 factorial (n=...) at stack_overflow.cpp:3
#2 factorial (n=...) at stack_overflow.cpp:3
(gdb) bt -3 # 바깥쪽 3개 프레임
#... factorial (n=-6) ...
#... factorial (n=-5) ...
#... main () at stack_overflow.cpp:7
스택 오버플로의 backtrace는 수십만 줄이 될 수 있으므로 bt N(안쪽 N개), bt -N(바깥쪽 N개)으로 양 끝만 봅니다. 프레임 깊이는 스택 크기(ulimit -s)와 프레임 크기에 따라 달라집니다. 핵심은 바깥쪽 끝에서 “처음 잘못된 인자가 들어온 호출”을 찾는 것이고, 이 경우 main이 음수를 넘긴 것이 원인입니다.
지역 변수 주소 반환 (dangling pointer)
#include <iostream>
int* createValue() {
int x = 42;
return &x; // 함수가 끝나면 x의 스택 공간은 재사용됨
}
int main() {
int* p = createValue();
std::cout << *p << "\n"; // 42가 나올 수도, 쓰레기 값이 나올 수도 있음
}
이 버그는 디버거로 잡기 까다롭습니다. 크래시가 나지 않고 값만 틀리는 경우가 많고, -O0에서는 우연히 42가 그대로 남아 있어 멀쩡해 보이기도 합니다. GCC는 이 코드에 기본으로 -Wreturn-local-addr 경고를 내고, Clang은 -Wreturn-stack-address로 알려 줍니다. 실행 중 탐지는 AddressSanitizer가 확실한데, GCC에서는 ASAN_OPTIONS=detect_stack_use_after_return=1을 줘야 이 유형을 잡습니다. 자세한 사용법은 Sanitizers 글에서 다룹니다.
포인터 산술 오류
void processBuffer(char* buf, int len) {
char* end = buf + len;
while (buf < end) {
*buf = toupper(static_cast<unsigned char>(*buf));
buf += sizeof(int); // char*인데 4바이트씩 건너뜀
}
}
결과 문자열이 “네 글자마다 한 글자만 대문자”처럼 이상하다면 증가량 실수를 의심할 만합니다. 디버거에서 루프 안에 브레이크포인트를 걸고 print buf - start(원래 시작 주소를 따로 저장해 둔 경우)나 x/8xb buf로 실제 가리키는 위치를 보면, 포인터가 한 번에 4씩 움직이는 것이 바로 보입니다.
No symbol table, optimized out 같은 디버거 에러
“No debugging symbols found”
-g 없이 빌드했거나, 빌드 후 strip으로 디버그 정보를 지운 경우입니다.
g++ -g -O0 main.cpp -o myapp
file myapp # "with debug_info, not stripped"가 보이면 디버그 정보 있음
”Cannot access memory at address 0x0”
null 포인터를 역참조했거나 디버거에서 null 포인터를 출력하려 한 경우입니다. backtrace로 위치를 찾고, info args와 print로 어느 포인터가 0인지 확인합니다.
”<optimized out>”
최적화 빌드에서 변수가 레지스터에만 잠깐 있었거나 계산 과정에서 없어진 경우입니다. -O0(또는 -Og)로 다시 빌드하는 것이 가장 확실합니다. 최적화를 유지해야 한다면 특정 변수만 volatile로 선언해 메모리에 남기는 임시방편이 있고, info registers와 x/i $pc로 기계어 수준에서 값을 추적할 수도 있습니다.
run 직후 정상 종료처럼 보이는데 버그가 재현되지 않음
버그가 fork()한 자식 프로세스에서 난다면, GDB는 기본으로 부모만 추적하므로 자식의 크래시를 보지 못합니다.
(gdb) set follow-fork-mode child # fork 후 자식을 추적
(gdb) set detach-on-fork off # 부모·자식 모두 붙잡아 두기
(gdb) run
LLDB “variable not found”
최적화로 변수가 제거되었거나 현재 스코프 밖인 경우입니다. frame variable로 지금 보이는 변수 목록을 먼저 확인하고, 필요하면 주소를 직접 읽습니다(expr *(int*)0x7fff...).
디버깅 환경 설정
.gdbinit
# ~/.gdbinit
set pagination off
set print pretty on
set print array on
handle SIGPIPE nostop noprint pass
서버 프로그램을 GDB로 돌리면 클라이언트가 연결을 끊을 때마다 SIGPIPE로 멈춰서 정작 보려던 크래시까지 가기 어렵습니다. handle로 관심 없는 시그널을 넘기도록 해 두면 흐름이 끊기지 않습니다. 프로젝트 폴더의 .gdbinit은 보안상 기본으로 로드되지 않으므로, ~/.gdbinit에 add-auto-load-safe-path /path/to/project를 추가해야 읽힙니다.
로그 저장
(gdb) set logging file debug.log
(gdb) set logging enabled on # GDB 12 이전 버전은 set logging on
(gdb) run
(gdb) set logging enabled off
core dump, gdbserver, 디버그 심볼 분리
Core Dump 분석
운영 환경에서 크래시가 나면, 그 순간의 메모리 상태를 core 파일로 남겨 나중에 분석합니다.
# core dump 크기 제한 해제 (현재 셸)
ulimit -c unlimited
# 저장 위치 지정
echo /tmp/core.%e.%p | sudo tee /proc/sys/kernel/core_pattern
# 크래시 후
gdb ./myapp /tmp/core.myapp.12345
(gdb) bt full
systemd를 쓰는 배포판은 기본으로 core_pattern이 systemd-coredump로 연결되어 있어, coredumpctl list로 목록을 보고 coredumpctl gdb <PID>로 바로 GDB를 열 수 있습니다. 어느 쪽이든 core 파일은 그것을 만든 바이너리와 정확히 같은 빌드로 분석해야 합니다. 다시 빌드한 바이너리는 주소가 달라 backtrace가 엉뚱하게 나옵니다.
원격 디버깅 (gdbserver)
# 대상 머신 (서버·임베디드 장비)
gdbserver :1234 ./myapp
# 이미 실행 중인 프로세스에 붙기
gdbserver --attach :1234 <PID>
# 개발 머신
gdb ./myapp
(gdb) target remote 192.168.1.100:1234
(gdb) continue
gdbserver로 붙는 순간 대상 프로세스가 멈추므로, 운영 중인 서비스라면 트래픽을 다른 인스턴스로 돌린 뒤 사용합니다.
디버그 심볼 분리
릴리스 바이너리는 작게 배포하고, 디버그 정보는 별도 파일로 보관해 두었다가 core 분석에 씁니다.
objcopy --only-keep-debug myapp myapp.debug
strip -g myapp
objcopy --add-gnu-debuglink=myapp.debug myapp
# 분석 시 (myapp.debug가 같은 폴더에 있으면 자동으로 찾음)
gdb -s myapp.debug -e myapp -c core.12345
최적화 빌드의 core는 변수가 <optimized out>이거나 줄 번호가 어긋날 수 있으므로, 운영 빌드를 -O2 -g(RelWithDebInfo)로 만들고 심볼만 분리해 두는 구성이 흔합니다.
디버그 빌드에서는 멀쩡한데 릴리스에서만 버그가 날 때
대부분 미정의 동작(UB)이 최적화 때문에 드러난 경우입니다. 가장 흔한 것은 초기화하지 않은 변수입니다. 디버그 빌드는 우연히 다른 값이 들어 있거나(MSVC 디버그 빌드는 스택을 0xCC, 새로 할당한 힙을 0xCD 패턴으로 채움), 릴리스에선 그 자리에 남아 있던 아무 값이나 읽힙니다. 범위를 벗어난 배열 접근, 부호 있는 정수 오버플로, 값을 반환하지 않는 함수 경로도 최적화기가 “일어나지 않는다”고 가정하고 코드를 바꿔 버려 릴리스에서만 증상이 납니다.
순서는 이렇게 합니다. 먼저 -Wall -Wextra(MSVC /W4) 경고를 모두 없앱니다. 다음으로 -O2 -g나 CMake RelWithDebInfo처럼 최적화를 켠 채 디버그 정보를 넣은 빌드로 재현합니다. 마지막으로 그 빌드에 ASan·UBSan을 붙여 실행합니다. 디버그 빌드로 돌아가서 찾으려 하면 증상이 사라져 오래 걸립니다.
GDB, LLDB, Visual Studio 명령어 대응표
| 기능 | GDB | LLDB |
|---|---|---|
| 실행 | run | run |
| 브레이크포인트 | break main | breakpoint set -n main (b main) |
| 조건부 브레이크 | break f.cpp:10 if i==5 | breakpoint set -f f.cpp -l 10 -c 'i==5' |
| 다음 줄 | next | next |
| 함수 진입 | step | step |
| 함수 끝까지 | finish | finish |
| 변수 출력 | print x | frame variable x / p x |
| 스택 | backtrace | thread backtrace (bt) |
| 모든 스레드 스택 | thread apply all bt | thread backtrace all |
| 워치포인트 | watch var | watchpoint set variable var |
| 종료 | quit | quit |
Windows에서 MSVC로 개발한다면 Visual Studio 디버거를 씁니다. 개념은 같고 조작만 다릅니다.
| 작업 | 단축키 / 방법 |
|---|---|
| 브레이크포인트 설정/해제 | F9 |
| 디버깅 시작 / 계속 | F5 |
| Step Over / Step Into / Step Out | F10 / F11 / Shift+F11 |
| 조건부 브레이크포인트 | 브레이크포인트 우클릭 → 조건 (예: i == 500) |
| 데이터 브레이크포인트(워치포인트) | 디버깅 중 변수 우클릭 → “값이 변경되면 중단” |
| 크래시 덤프 분석 | .dmp 파일을 Visual Studio로 열고 “네이티브로 디버그” |