GDB로 C++ 디버깅하기: 브레이크포인트, 워치포인트, backtrace, Core Dump 분석
printf 디버깅의 한계
세그폴트를 std::cout으로 쫓다 보면 마지막으로 찍힌 줄이 실제 크래시 위치보다 한참 앞인 경우가 흔합니다. 프로세스가 비정상 종료되면 아직 버퍼에 남아 있던 출력이 함께 사라지기 때문입니다. GDB(GNU Debugger)는 프로그램을 멈춘 상태에서 어느 줄에 있는지, 그때 변수 값과 호출 스택이 어떤지를 직접 보여 줍니다. 브레이크포인트(실행을 멈출 지점), 워치포인트(값이 바뀌는 순간 멈추기), 백트레이스(호출 스택), print(변수 출력), step/next/continue(단계별 실행)만 익혀도 출력문을 넣고 다시 빌드하는 반복을 크게 줄일 수 있습니다.
Linux나 WSL에서 sudo apt install gdb(RHEL 계열은 sudo dnf install gdb)로 설치합니다. 디버그 정보가 있어야 하므로 -g 옵션으로 빌드합니다(CMake에서는 Debug 구성).
GDB가 도움이 되는 버그 유형
몇 가지 전형적인 상황과 그때 쓰는 GDB 기능을 먼저 짚겠습니다.
크래시(세그폴트)가 나면 GDB 안에서 실행하거나 코어 덤프를 열어 backtrace로 어느 함수의 어느 줄에서 죽었는지 보고, print로 그 시점의 포인터와 인덱스를 확인합니다. 예를 들어 다음처럼 받은 포인터가 null이면 head->value에서 SIGSEGV가 나고, 백트레이스와 print head로 null이 어디서 들어왔는지 거슬러 올라갈 수 있습니다.
struct Node { int value; Node* next; };
int sumList(Node* head) {
int sum = 0;
while (head != nullptr) {
sum += head->value;
head = head->next;
}
return sum;
}
프로그램이 멈춘 것처럼 보이면(무한 루프나 데드락) GDB에서 실행 중에 Ctrl+C로 중단하고 backtrace와 print로 어디서 돌고 있는지, 루프 변수가 변하는지 봅니다. 실행 중인 프로세스라면 gdb -p <PID>로 붙습니다.
어떤 값이 어디선가 엉뚱하게 바뀐다면 그 변수에 watch를 걸어 두고 continue합니다. 값을 쓰는 순간 멈추므로 범인을 바로 찾을 수 있습니다.
1000번 도는 루프의 500번째에서만 문제가 생긴다면 next를 500번 누르는 대신 break 파일.cpp:줄 if i == 500 같은 조건부 브레이크포인트를 겁니다.
한 가지 미리 알아 둘 점은, 배열 범위를 한 칸 넘는 쓰기 같은 메모리 오류는 대개 그 자리에서 크래시가 나지 않는다는 것입니다. 바로 옆 메모리를 조용히 덮어쓰고 넘어가거나, 함수가 반환할 때 *** stack smashing detected ***로 중단되거나, 한참 뒤 전혀 다른 곳에서 터집니다. 이런 버그는 GDB만으로 찾기보다 AddressSanitizer(-fsanitize=address)로 빌드해 잘못된 접근 순간에 멈추게 하는 편이 빠르며, 자세한 내용은 Sanitizers 글에서 다룹니다.
-g로 디버그 빌드하고 GDB 시작하기
-g를 주면 실행 파일에 디버그 정보(DWARF: 소스 줄 번호, 변수 이름과 위치, 타입)가 들어가서 GDB가 “몇 번째 줄에서 멈췄는지”와 “변수 이름으로 값 보기”를 할 수 있습니다. -O0으로 최적화를 끄면 변수가 사라지거나 코드 순서가 바뀌는 일이 없어 한 줄씩 따라가기 쉽습니다.
g++ -g -O0 main.cpp -o myapp
# CMake 사용 시
cmake -DCMAKE_BUILD_TYPE=Debug ..
make
gdb ./myapp
(gdb) run # 실행
(gdb) run arg1 arg2 # 인자와 함께 실행
(gdb) quit
sequenceDiagram
participant Dev as 개발자
participant GDB as GDB
participant App as 대상 프로그램
Dev->>GDB: gdb ./myapp
GDB->>App: 로드 (디버그 심볼)
Dev->>GDB: break main
Dev->>GDB: run
GDB->>App: 실행 시작
App->>GDB: main() 도달 → 중단
GDB->>Dev: 프롬프트 반환
Dev->>GDB: next / step / print
GDB->>Dev: 결과 출력
Dev->>GDB: continue
GDB->>App: 다음 브레이크포인트까지 실행
브레이크포인트: 원하는 줄에서 멈추기
브레이크포인트는 실행이 특정 위치에 도달하면 멈추게 하는 지점입니다. 한 번 걸어 두면 그 위치에 도달할 때마다 멈춥니다.
(gdb) break main
Breakpoint 1 at 0x401234: file main.cpp, line 5.
(gdb) break processData
Breakpoint 2 at 0x401256: file main.cpp, line 12.
(gdb) break main.cpp:15
Breakpoint 3 at 0x401278: file main.cpp, line 15.
조건을 붙이면 조건이 참일 때만 멈춥니다. 조건식은 브레이크포인트 위치에서 보이는 변수로만 쓸 수 있으므로, 루프 변수 i로 조건을 걸려면 함수 이름이 아니라 루프 안쪽 줄에 걸어야 합니다.
(gdb) break main.cpp:20 if i == 50
(gdb) break processData if ptr == nullptr # 매개변수는 함수 입구에서 보임
(gdb) break main.cpp:10 if size > 0
(gdb) info breakpoints
Num Type Disp Enb Address What
1 breakpoint keep y 0x0000000000401234 in main at main.cpp:5
2 breakpoint keep y 0x0000000000401256 in processData at main.cpp:12
(gdb) delete 1 # 1번 삭제
(gdb) delete # 모두 삭제
(gdb) disable 2 # 비활성화
(gdb) enable 2
(gdb) clear main.cpp:15 # 위치로 삭제
tbreak는 한 번 멈추면 사라지는 임시 브레이크포인트이고, ignore 2 99는 2번 브레이크포인트를 다음 99번 무시합니다. commands 2 … end로 멈출 때마다 실행할 명령(예: print i, continue)을 붙여 두면 GDB를 간단한 로깅 도구로도 쓸 수 있습니다.
브레이크포인트 동작 원리
GDB는 일반 사용자 프로그램이라 다른 프로세스를 직접 멈출 권한이 없습니다. Linux에서는 ptrace(2) 시스템 콜로 커널에 대상 프로세스의 추적자로 등록하고, 이후 메모리·레지스터 읽기/쓰기와 실행 재개를 모두 커널을 통해 합니다.
소프트웨어 브레이크포인트는 이 권한으로 대상 주소의 첫 바이트를 INT3(x86-64에서 0xCC) 명령어로 덮어쓰는 방식입니다. CPU가 그 주소에 도달하면 트랩이 발생하고, 커널이 프로세스를 멈춘 뒤 GDB에 알립니다. GDB는 보관해 둔 원래 바이트를 되돌리고, 한 명령어만 실행한 다음 다시 0xCC를 써 넣고 계속 실행합니다. 이 구조를 알면 몇 가지 현상이 이해됩니다. 브레이크포인트를 많이 걸어도 멈추지 않는 동안에는 거의 느려지지 않습니다. 코드가 바뀔 뿐 감시하는 것이 아니기 때문입니다. 반대로 조건부 브레이크포인트는 도달할 때마다 멈췄다가 GDB가 조건을 평가하고 재개하므로, 자주 실행되는 루프에 걸면 눈에 띄게 느려집니다. 이미 실행 중인 프로세스에 붙는(gdb -p) 경우에는 커널 보안 정책이 ptrace를 막을 수 있습니다(아래 에러 해결 절). 워치포인트는 코드를 고치는 방식으로는 만들 수 없어서 CPU의 디버그 레지스터를 쓰며, 그래서 개수 제한이 있습니다.
실습: 조건부 브레이크포인트로 범위 초과 찾기
// breakpoint_demo.cpp - g++ -g -O0 -o bp_demo breakpoint_demo.cpp
#include <iostream>
void fillArray(int* arr, int size) {
for (int i = 0; i <= size; ++i) { // 버그: <= 이므로 i == size까지 돔
arr[i] = i;
}
}
int main() {
int arr[5];
fillArray(arr, 5);
std::cout << "done\n";
return 0;
}
이 프로그램은 arr[5]에 범위 밖 쓰기를 하지만, 실행하면 대개 크래시 없이 done을 출력하거나 빌드 옵션에 따라 stack smashing detected로 끝납니다. 그래서 “크래시가 나면 백트레이스를 본다”는 방법이 통하지 않고, 의심되는 인덱스에서 직접 멈춰 봐야 합니다. 5번째 줄(arr[i] = i;)에 조건부 브레이크포인트를 겁니다.
$ gdb ./bp_demo
(gdb) break breakpoint_demo.cpp:5 if i == size
(gdb) run
Breakpoint 1, fillArray (arr=0x7fffffffe2c0, size=5) at breakpoint_demo.cpp:5
5 arr[i] = i;
(gdb) print i
$1 = 5
(gdb) print size
$2 = 5
i == size인 상태로 arr[i]에 쓰려는 순간에 멈췄으므로, 루프 조건이 i < size여야 한다는 것을 확인할 수 있습니다.
워치포인트: 값이 바뀌는 순간 멈추기
워치포인트는 특정 변수나 메모리 주소의 값이 바뀔 때 실행을 멈추게 합니다. 어디서 값이 바뀌는지 모를 때 가장 효과적인 도구입니다.
(gdb) watch variable_name # 쓰기(값 변경) 시 멈춤
(gdb) watch *ptr # 포인터가 가리키는 값
(gdb) watch arr[5] # 배열 요소
(gdb) rwatch x # 읽을 때 멈춤 (하드웨어 워치포인트 필요)
(gdb) awatch x # 읽거나 쓸 때 멈춤
실습: 메모리 오염 추적
// watchpoint_demo.cpp - g++ -g -O0 -o wp_demo watchpoint_demo.cpp
#include <iostream>
void corruptData(int* arr) {
arr[5] = 999; // 여기서 arr[5] 변경
}
int main() {
int arr[10] = {0};
std::cout << "arr[5] before: " << arr[5] << "\n";
corruptData(arr);
std::cout << "arr[5] after: " << arr[5] << "\n";
return 0;
}
지역 변수 arr은 main이 실행 중일 때만 존재하므로, 먼저 main에서 멈춘 뒤 워치포인트를 겁니다.
$ gdb ./wp_demo
(gdb) break main
(gdb) run
(gdb) watch arr[5]
Hardware watchpoint 2: arr[5]
(gdb) continue
Hardware watchpoint 2: arr[5]
Old value = 0
New value = 999
corruptData (arr=0x7fffffffe290) at watchpoint_demo.cpp:5
5 }
(gdb) backtrace
#0 corruptData (arr=0x7fffffffe290) at watchpoint_demo.cpp:5
#1 0x0000555555555217 in main () at watchpoint_demo.cpp:9
워치포인트는 쓰기가 끝난 직후에 멈추므로 표시되는 줄은 값을 쓴 줄(4번째 줄)의 다음 줄입니다. backtrace로 누가 corruptData를 불렀는지도 함께 확인합니다.
CPU가 지원하는 하드웨어 워치포인트는 빠르지만 개수가 제한됩니다(x86-64는 디버그 레지스터가 4개라 감시할 수 있는 주소가 몇 개뿐입니다). 한도를 넘거나 하드웨어로 감시할 수 없는 식이면 GDB가 소프트웨어 워치포인트로 바꾸는데, 한 명령어씩 실행하며 값을 비교하므로 매우 느립니다. 지역 변수에 건 워치포인트는 그 변수의 스코프를 벗어나면 자동으로 삭제됩니다.
backtrace로 크래시 지점의 호출 스택 보기
백트레이스(스택 트레이스)는 현재 위치에 도달하기까지 어떤 함수들이 호출되었는지 보여 줍니다. 크래시가 어디서 났는지, 그 함수를 누가 어떤 인자로 불렀는지 파악하는 출발점입니다.
(gdb) backtrace # 또는 bt
(gdb) backtrace full # 각 프레임의 지역 변수까지
(gdb) frame 2 # 2번 프레임으로 이동 (f 2)
(gdb) up # 호출한 쪽으로 한 프레임
(gdb) down
(gdb) info frame # 현재 프레임의 상세 정보
실습: 세그폴트에서 백트레이스 활용
// backtrace_demo.cpp - g++ -g -O0 -o bt_demo backtrace_demo.cpp
#include <iostream>
void level3(int* p) {
*p = 42; // p가 null이면 크래시
}
void level2(int* p) {
level3(p);
}
void level1(int* p) {
level2(p);
}
int main() {
int* p = nullptr;
level1(p);
return 0;
}
$ gdb ./bt_demo
(gdb) run
Program received signal SIGSEGV, Segmentation fault.
0x0000555555555139 in level3 (p=0x0) at backtrace_demo.cpp:4
4 *p = 42;
(gdb) backtrace
#0 0x0000555555555139 in level3 (p=0x0) at backtrace_demo.cpp:4
#1 0x0000555555555154 in level2 (p=0x0) at backtrace_demo.cpp:7
#2 0x000055555555516f in level1 (p=0x0) at backtrace_demo.cpp:10
#3 0x000055555555518e in main () at backtrace_demo.cpp:14
(gdb) frame 3
(gdb) print p
$1 = (int *) 0x0
0번 프레임이 크래시가 난 곳이고, 번호가 커질수록 호출한 쪽입니다. 각 프레임의 인자에 p=0x0이 그대로 보이므로, null이 main에서부터 내려왔다는 것을 바로 알 수 있습니다. 주소 값은 환경마다 다릅니다.
print·x로 변수와 메모리 검사하기
(gdb) print x # 또는 p x
(gdb) print *ptr # 포인터 역참조
(gdb) print person.name # 멤버
(gdb) print obj->value
(gdb) print arr[0]@10 # arr[0]부터 원소 10개
(gdb) print/x value # 16진수 (d: 10진, c: 문자, f: 부동소수점)
(gdb) info locals # 현재 프레임의 지역 변수 전부
(gdb) info args # 현재 함수의 인자
x 명령은 변수가 아니라 주소의 메모리를 그대로 보여 줍니다. 형식은 x/[개수][형식][크기] 주소이며, 형식은 x(16진), d(10진), s(문자열), i(명령어), 크기는 b(1바이트), h(2), w(4), g(8)입니다.
(gdb) x/10xw ptr # 4바이트 단위 10개를 16진으로
(gdb) x/10dw ptr # 같은 범위를 10진으로
(gdb) x/s str # C 문자열 하나
(gdb) x/5i $pc # 현재 위치부터 명령어 5개
실습: 함수 안에서 값 변화 보기
// print_demo.cpp - g++ -g -O0 -o print_demo print_demo.cpp
#include <iostream>
struct Point { int x, y; };
void process(Point* p) {
if (p == nullptr) return;
p->x *= 2;
p->y *= 2;
}
int main() {
Point pt = {10, 20};
process(&pt);
std::cout << pt.x << ", " << pt.y << "\n";
return 0;
}
$ gdb ./print_demo
(gdb) break process
(gdb) run
(gdb) print *p
$1 = {x = 10, y = 20}
(gdb) next # if 검사 실행 → 다음 줄(p->x *= 2)에서 멈춤
(gdb) next # p->x *= 2 실행
(gdb) print *p
$2 = {x = 20, y = 20}
next는 현재 줄을 실행하고 다음 줄에서 멈춥니다. 표시되는 줄은 “다음에 실행할 줄”이라 아직 실행되지 않았다는 점에 주의하세요.
step·next·continue로 한 줄씩 실행하기
| 명령 | 동작 |
|---|---|
next (n) | 다음 소스 줄로 이동. 함수 호출은 안으로 들어가지 않고 한 번에 실행 |
step (s) | 다음 소스 줄로 이동. 함수 호출이 있으면 그 함수 안으로 들어감 |
finish | 현재 함수가 반환될 때까지 실행하고 반환값을 출력 |
until | 루프의 끝 줄에서 쓰면 루프가 끝날 때까지 실행 |
continue (c) | 다음 브레이크포인트(또는 종료)까지 실행 |
next 5, step 3처럼 횟수를 붙일 수 있고, 빈 줄에서 Enter를 누르면 직전 명령이 반복됩니다.
// stepping_demo.cpp - g++ -g -O0 -o step_demo stepping_demo.cpp
#include <iostream>
int add(int a, int b) {
return a + b;
}
int main() {
int x = 10;
int y = 20;
int z = add(x, y);
std::cout << z << "\n";
return 0;
}
$ gdb ./step_demo
(gdb) break main
(gdb) run # 7번째 줄(int x = 10;)에서 멈춤
(gdb) next # → 8번째 줄
(gdb) next # → 9번째 줄 (int z = add(x, y);)
(gdb) step # add() 안으로 진입
add (a=10, b=20) at stepping_demo.cpp:4
4 return a + b;
(gdb) finish
Run till exit from #0 add (a=10, b=20) at stepping_demo.cpp:4
0x0000555555555190 in main () at stepping_demo.cpp:9
9 int z = add(x, y);
Value returned is $1 = 30
(gdb) next # 대입까지 마치고 10번째 줄로
(gdb) print z
$2 = 30
finish는 add가 반환된 직후, 아직 9번째 줄의 대입이 끝나기 전에 멈춥니다. 이 시점에 print z를 하면 초기화되지 않은 값이 나오므로 반환값은 Value returned로 확인하거나 next로 대입을 마친 뒤 봅니다. 9번째 줄에서 step 대신 next를 쓰면 add 안으로 들어가지 않고 바로 10번째 줄로 갑니다.
버그 하나를 처음부터 끝까지 GDB로 찾기
// full_demo.cpp
#include <iostream>
#include <vector>
void processVector(std::vector<int>& vec) {
for (size_t i = 0; i <= vec.size(); ++i) { // 버그: <=
vec[i] *= 2;
}
}
int main() {
std::vector<int> vec = {1, 2, 3};
processVector(vec);
std::cout << "done\n";
return 0;
}
vec[3]은 범위 밖이지만 std::vector::operator[]는 기본적으로 범위를 검사하지 않으므로, 그냥 빌드하면 대개 아무 일 없이 done이 출력됩니다. libstdc++의 검사 모드를 켜면 범위 밖 접근이 그 자리에서 abort로 바뀌어 GDB가 잡을 수 있습니다.
$ g++ -g -O0 -D_GLIBCXX_ASSERTIONS -o full_demo full_demo.cpp
$ gdb ./full_demo
(gdb) run
... Assertion '__n < this->size()' failed.
Program received signal SIGABRT, Aborted.
(gdb) backtrace
백트레이스의 위쪽 몇 프레임은 abort와 libstdc++ 내부 함수이고, 그 아래에 processVector와 main이 보입니다. frame N으로 processVector 프레임으로 이동한 뒤 변수를 확인합니다.
(gdb) frame 3 # 번호는 bt 출력에서 processVector가 있는 프레임
(gdb) print i
$1 = 3
(gdb) print vec.size()
$2 = 3
i가 vec.size()와 같은 상태로 접근했으므로 조건을 i < vec.size()로 고치면 됩니다. 검사 모드를 쓸 수 없는 환경이라면 앞의 실습처럼 break full_demo.cpp:6 if i == vec.size()로 해당 순간에 멈춰 확인할 수도 있습니다.
스레드·STL 출력·TUI·역방향 디버깅
멀티스레드 프로그램
(gdb) info threads # 스레드 목록, * 표시가 현재 스레드
(gdb) thread 3 # 3번 스레드로 전환 (이후 bt, print가 이 스레드 기준)
(gdb) thread apply all bt # 모든 스레드의 호출 스택 — 데드락 분석의 출발점
(gdb) set scheduler-locking step # step/next 하는 동안 다른 스레드는 멈춰 둠
멀티스레드 프로그램에서 next를 누르면 다른 스레드가 같은 브레이크포인트에 걸려 엉뚱한 스레드로 화면이 넘어가는 일이 흔합니다. 한 줄씩 따라가던 흐름이 끊기고, 방금 본 변수 값이 다른 스레드의 것이 되어 혼란스럽습니다. set scheduler-locking step을 켜면 스텝하는 동안 나머지 스레드를 멈춰 두므로 한 스레드의 흐름만 따라갈 수 있습니다. 다만 다른 스레드가 쥔 락을 기다리는 코드에서는 진행되지 않으니, 그때는 off로 되돌립니다. 데드락이 의심될 때는 스텝보다 thread apply all bt로 모든 스레드가 어디서 무엇을 기다리는지 한 번에 보는 편이 빠릅니다.
타입 확인과 STL 출력
(gdb) whatis config # 간단한 타입 이름
(gdb) ptype config # 멤버와 메서드까지 펼친 타입 정의
(gdb) set print pretty on # 구조체를 들여쓰기해 출력
(gdb) print vec # std::vector of length 3, capacity 3 = {1, 2, 3}
print vec의 결과가 {1, 2, 3} 대신 _M_impl, _M_start 같은 내부 멤버로 쏟아진다면 libstdc++ pretty printer가 로드되지 않은 것입니다. 배포판 GDB는 보통 자동으로 로드하지만, 직접 빌드한 GCC나 크로스 툴체인을 쓰면 빠지기 쉽습니다. info pretty-printer로 등록 여부를 확인하고, 없으면 해당 GCC의 share/gcc-*/python 경로를 .gdbinit에서 python 블록으로 등록합니다.
TUI 모드: 소스를 보면서 디버깅
$ gdb -tui ./myapp # 처음부터 TUI로 시작
(gdb) layout src # 소스 창 (asm, split, regs도 가능)
# Ctrl+X, A : TUI 켜기/끄기
# Ctrl+X, O : 창 사이 포커스 전환
# Ctrl+L : 화면이 깨졌을 때 다시 그리기
list를 반복하지 않아도 현재 줄이 소스 창에 표시되어 IDE 없이 원격 서버에서 디버깅할 때 편합니다. 처음 쓰면 화살표 키가 명령 기록이 아니라 소스 창 스크롤로 동작해서 당황하게 되는데, 포커스가 소스 창에 있기 때문입니다. focus cmd(또는 Ctrl+X, O)로 명령 창에 포커스를 주면 평소처럼 이전 명령을 불러올 수 있습니다. 프로그램이 표준 출력으로 글자를 찍어 화면이 깨지면 Ctrl+L로 다시 그립니다.
역방향 디버깅: 크래시 직전으로 되감기
(gdb) break processData
(gdb) run
(gdb) record # 여기서부터 실행 기록 시작
(gdb) continue # 크래시 발생
(gdb) reverse-next # 한 줄 뒤로
(gdb) reverse-step # 함수 안으로 들어가며 뒤로
(gdb) reverse-continue # 이전 브레이크포인트/워치포인트까지 되감기
(gdb) reverse-finish # 현재 함수를 호출한 지점까지
값이 언제 망가졌는지 찾을 때 강력합니다. 크래시 지점에서 망가진 변수에 watch를 걸고 reverse-continue하면 그 값을 마지막으로 쓴 곳에서 멈춥니다. 하지만 GDB 내장 record는 명령어 하나하나의 변화를 기록하므로 수십~수백 배 느려지고, 기본 기록 한도(record full insn-number-max, 200,000 명령어)가 금방 차서 기록을 계속할지 묻습니다. 또 최신 glibc의 memcpy·strlen처럼 AVX 명령어를 쓰는 코드에서 Process record does not support instruction 0xc5 ... 에러로 멈추는 경우가 많아서, 기록 구간을 문제 함수 근처로 좁혀야 실용적입니다. 긴 실행을 되감아야 하거나 가끔만 재현되는 버그라면, 실행을 한 번 녹화한 뒤 GDB 명령 그대로 몇 번이고 정·역방향 재생할 수 있는 rr이 더 현실적인 선택입니다(Linux x86-64 전용이며, 성능 카운터 접근이 필요해 일부 VM에서는 동작하지 않습니다).
No symbol table·optimized out 등 GDB 에러 해결
”No symbol table is loaded” / “(No debugging symbols found in …)”
-g 없이 빌드했거나 strip으로 디버그 정보를 지운 바이너리입니다. file myapp 출력에 with debug_info가 있는지 확인하고, 없으면 -g를 붙여 다시 빌드합니다.
”Cannot access memory at address 0x0”
null 포인터(또는 잘못된 주소)를 역참조하려 했다는 뜻입니다. backtrace로 위치를 찾고, info args와 print ptr로 어느 포인터가 0인지 확인한 뒤 위 프레임으로 올라가며 그 값이 어디서 왔는지 추적합니다.
<optimized out>
-O2, -O3 같은 최적화 빌드에서 변수가 제거되었거나 레지스터에만 잠깐 존재하는 경우입니다. -g가 만드는 DWARF 정보는 “이 변수가 이 주소 구간에서는 이 레지스터에 있다”는 식으로 위치를 기록하는데, 최적화로 변수가 아예 사라지거나 값이 다른 계산에 합쳐지면 기록할 위치가 없어서 GDB는 <optimized out>이라고 답할 수밖에 없습니다. GDB의 버그가 아닙니다.
g++ -g -O0 main.cpp -o myapp
# 또는 디버깅을 해치지 않는 최적화만 켜는 -Og
g++ -g -Og main.cpp -o myapp
최적화 빌드에서만 재현되는 버그라면 -O0으로 바꾸는 순간 버그가 사라지기도 합니다. 이럴 때는 -O2 -g를 유지한 채 info registers와 disassemble로 직접 보거나, 문제 변수만 임시로 volatile로 선언해 메모리에 남게 하는 방법을 씁니다. 다만 이런 버그는 미정의 동작인 경우가 많으므로 Sanitizer로 먼저 확인해 보는 것이 좋습니다.
run 직후 프로그램이 끝나 버림
프로그램이 정상 종료했거나, fork한 자식 프로세스에서 문제가 생긴 경우입니다. GDB는 기본적으로 부모 프로세스만 따라가므로 자식을 따라가려면 다음처럼 설정합니다.
(gdb) set follow-fork-mode child
(gdb) set detach-on-fork off # 부모와 자식 모두 붙잡아 두기
(gdb) run
”Cannot watch constant value”
watch 5처럼 메모리 위치가 없는 상수 식에 워치포인트를 걸면 납니다. 메모리에 있는 변수(지역 변수라면 그 함수가 실행 중일 때)를 대상으로 겁니다.
”ptrace: Operation not permitted”
gdb -p 12345로 이미 떠 있는 서버 프로세스에 붙으려는데, 같은 사용자 권한인데도 거부되는 경우입니다. Ubuntu 등 여러 배포판은 Yama 보안 모듈로 kernel.yama.ptrace_scope=1을 기본값으로 둡니다. 이 설정에서는 자기 자손 프로세스에만 ptrace할 수 있어서, gdb ./myapp으로 직접 띄운 프로그램은 되는데 이미 실행 중인 프로세스에는 붙지 못합니다. 컨테이너 안이라면 Docker가 기본으로 CAP_SYS_PTRACE 권한을 주지 않는 것이 원인입니다.
cat /proc/sys/kernel/yama/ptrace_scope # 1이면 자손만 허용
sudo gdb -p 12345 # 가장 간단한 방법
echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope # 임시 완화 (재부팅 시 원복)
docker run --cap-add=SYS_PTRACE ... # 컨테이너 안에서 디버깅할 때
로컬에서는 되는데 컨테이너에서만 안 된다면 대개 이 경우입니다. ptrace_scope를 0으로 바꾸면 같은 사용자의 어떤 프로세스든 다른 프로세스의 메모리를 읽을 수 있게 되므로, 공용 서버에서는 영구 설정하지 말고 sudo를 쓰는 편이 안전합니다.
Core Dump·gdbserver·배치 모드로 운영 환경 디버깅
Core Dump 분석
코어 덤프는 프로세스가 죽은 순간의 메모리와 레지스터를 파일로 남긴 것입니다. 운영 서버처럼 디버거를 붙여 재현할 수 없는 곳에서 크래시 원인을 사후에 분석할 때 씁니다.
ulimit -c unlimited # 현재 셸에서 코어 파일 크기 제한 해제
cat /proc/sys/kernel/core_pattern # 코어가 어디로 가는지 확인
# 크래시 후
gdb ./myapp /path/to/core
(gdb) backtrace full
(gdb) thread apply all bt
systemd 기반 배포판은 core_pattern이 |/usr/lib/systemd/systemd-coredump ...처럼 파이프로 설정되어 있어 코어 파일이 현재 디렉터리에 생기지 않습니다. 이 경우 coredumpctl list로 목록을 보고 coredumpctl gdb <PID>로 바로 엽니다(Ubuntu 데스크톱은 apport가 대신 받기도 합니다). 서비스로 실행되는 프로세스는 셸의 ulimit이 아니라 systemd 유닛의 LimitCORE= 설정을 따릅니다.
디버그 심볼 분리
배포 바이너리는 작게 유지하고 디버그 정보는 따로 보관하는 방식입니다.
objcopy --only-keep-debug myapp myapp.debug
strip -g myapp
objcopy --add-gnu-debuglink=myapp.debug myapp
# 분석 시
gdb -s myapp.debug -e myapp -c core.12345
코어 덤프와 분석에 쓰는 바이너리·심볼은 정확히 같은 빌드여야 합니다. 같은 커밋에서 다시 빌드해도 경로·컴파일러 버전이 다르면 주소가 어긋나 백트레이스가 엉뚱한 함수를 가리킵니다. 링커가 넣는 빌드 ID(readelf -n myapp | grep "Build ID", file myapp에도 표시)가 코어를 만든 바이너리와 같은지 먼저 확인하고, 릴리스마다 빌드 ID별로 디버그 심볼을 보관해 두는 것이 사후 분석의 전제 조건입니다. 또 코어 덤프에는 프로세스 메모리가 통째로 들어가므로 토큰·비밀번호·개인정보가 포함될 수 있어, 저장 위치 권한과 보존 기간을 정해 두어야 합니다.
원격 디버깅 (gdbserver)
# 대상 머신
gdbserver :1234 ./myapp
# 개발 머신 (같은 바이너리와 심볼을 가지고)
gdb ./myapp
(gdb) target remote 192.168.1.100:1234
(gdb) continue
gdbserver로 붙어 브레이크포인트에서 멈추면 그동안 그 프로세스는 요청을 처리하지 못합니다. 운영 서버에서는 코어 덤프를 받아 오프라인으로 분석하는 쪽을 우선하고, 직접 붙어야 한다면 트래픽에서 빠진 인스턴스를 대상으로 합니다.
배치 모드로 자동 분석
# analyze.gdb
set pagination off
run
backtrace full
quit
gdb -batch -x analyze.gdb ./myapp
CI에서 테스트가 크래시했을 때 백트레이스를 로그로 남기는 용도로 쓸 수 있습니다. 대화형 세션의 출력을 파일로 남기려면 set logging file debug.log 후 set logging enabled on을 씁니다(GDB 12 이전 버전은 set logging on).
GDB 명령어 치트시트
| 기능 | 명령 |
|---|---|
| 실행 | run, run arg1 arg2, kill |
| 브레이크포인트 | break, tbreak, info breakpoints, delete 1, ignore 1 N |
| 워치포인트 | watch, rwatch, awatch |
| 실행 제어 | next(n), step(s), finish, until, continue(c) |
| 검사 | print(p), x, backtrace(bt), backtrace full, frame N, info locals, info args |
| 타입 | whatis, ptype |
| 스레드 | info threads, thread N, thread apply all bt |
| 되감기 | record, reverse-next, reverse-continue |
| 화면 | gdb -tui, layout src, Ctrl+X A |
| 기타 | list(l), quit(q) |
.gdbinit에 set pagination off, set print pretty on을 넣어 두면 매번 입력하지 않아도 됩니다. macOS에서는 LLDB가 기본 디버거이며 명령 체계가 비슷하므로 LLDB 글을 이어서 보면 됩니다.
이전 글: CMake 입문