GDB 실전 디버깅 (멀티스레드·시그널)
NOTE
기본 명령어부터 멀티스레드 디버깅(
info threads/thread apply all bt로 원인 스레드 찾기), 블로킹 함수를n으로 넘길 때의 함정, gdb가 SIGINT를 가로채서 Ctrl+C 테스트가 안 먹히는 문제까지 정리한다.
1. 기본 명령어
| 명령어 | 사용법 | 설명 | 비고 |
|---|---|---|---|
gdb [실행파일] | gdb ./xxx.out | 바이너리를 붙여서 디버깅 시작 | 코어 덤프 분석은 gdb ./xxx.out core |
r | r | 프로그램 시작(run) | |
b [위치] | b main, b file.c:42 | 브레이크포인트 설정 | |
n | n | 다음 줄 실행(next) — 함수 호출은 안으로 안 들어가고 통째로 실행 | 아래 3번 항목 주의 |
s | s | 다음 줄 실행(step) — 함수 호출이면 그 안으로 들어감 | |
c | c | 브레이크포인트/정지 지점부터 계속 실행(continue) | |
until | until | 반복문 끝까지 한 번에 실행 | |
finish | finish | 현재 함수가 리턴할 때까지 실행 | |
p / print | p 변수명 | 변수/표현식 값 확인 | |
bt | bt | 현재 스레드의 콜스택(backtrace) 확인 | |
up / down | up, down 2 | 콜스택 프레임 이동 | 다른 함수의 지역변수 보려면 그 프레임으로 가야 함 |
frame [N] | frame 2 | 콜스택의 N번 프레임으로 직접 이동 | |
q | q | gdb 종료 | |
x | x/32x 주소, x/s 문자열주소 | 메모리를 직접 덤프 (x/<개수><포맷><단위>) | 포맷: x(16진), d, s(문자열), i(명령어) / 단위: b,h,w,g. 포인터 변수면 x/32x ptr |
Ctrl+X, A | Ctrl+X 누른 뒤 A | TUI 모드 토글 — 소스 코드 창을 같이 띄워서 보며 디버깅 | 한 번 더 누르면 원래 CLI로 복귀. 화면이 깨지면 Ctrl+L |
보조 — TUI 모드와 메모리 확인
- TUI 모드:
Ctrl+X, A로 켜면 위쪽에 소스 창, 아래쪽에 gdb 프롬프트가 나뉘어 현재 실행 줄을 눈으로 따라가며n/s로 진행할 수 있다. svsn: 디버그 심볼이 없는 라이브러리 API는s로 들어가도 소스가 안 보이고 어셈블리로 들어간다 — 이럴 땐finish로 빠져나온다.- 메모리 확인:
x/32x buf로 버퍼를 16진으로 확인한다. 가변 길이 바이너리(length+ 가변 바이트 구조)처럼 끝을 알 수 없는 데이터는p로 문자열처럼 보지 말고length필드만큼x/<length>xb로 덤프하는 게 정확하다.
2. 멀티스레드 프로그램 디버깅
스레드가 여러 개인 프로그램은 “지금 실행 중인 스레드”만 보이는 게 아니라, 전체 스레드 목록과 각 스레드의 상태를 따로 다뤄야 한다.
| 명령어 | 설명 |
|---|---|
info threads | 전체 스레드 목록 + 각 스레드가 지금 멈춰있는 위치 |
thread N | N번 스레드로 전환 (gdb 내부 번호. OS의 LWP 번호와 다름) |
thread apply all bt | 모든 스레드의 콜스택을 한 번에 출력 — 어떤 스레드가 어디서 멈춰있는지 전체 그림을 볼 때 제일 먼저 쓰게 되는 명령 |
실전 흐름 — “종료 플래그를 세웠는데 프로세스가 안 끝난다”
멀티스레드 데몬에서 종료 플래그(running 같은 변수)를 0으로 만들었는데도 프로세스가 안 끝날 때, thread apply all bt로 전체 스레드를 먼저 훑어보면 원인이 대략 세 부류로 나뉜다.
Thread 4: nanosleep() ... worker_thread() <- 주기적으로 자다가 깨서 플래그 재확인하는 정상 패턴
Thread 3: pthread_join() ... main() <- 다른 스레드가 끝나길 기다리는 중 (메인)
Thread 2: poll() ... (라이브러리 내부 스레드) <- 우리 코드가 만든 스레드가 아닐 수 있음
sleep()/nanosleep()안에 있는 스레드: 주기적으로 깨서 플래그를 다시 확인하는 정상 루프 구조라면, 그 주기(예: 3초)만큼만 기다리면 스스로 빠져나온다. 버그가 아니라 타이밍 문제일 뿐.- 소켓/IPC 통신 함수(
send,recv, 라이브러리의 통신 API 등) 안에 있는 스레드: 루프 조건을 맨 위에서만 확인하는 구조라면, 통신 함수 자체가 응답을 못 받아 오래 블로킹되는 동안은 플래그를 아무리 바꿔도 그 루프가 재확인할 기회가 없다 — 이건 진짜 구조적 문제(타임아웃 처리가 없는 블로킹 호출)일 가능성이 높다. - 우리가 만들지 않은 스레드(라이브러리/프레임워크가 내부적으로 띄운 스레드):
join하는 대상이 아니면 신경 쓸 필요 없다.main()이 리턴/exit()하면 프로세스 종료 시 다른 스레드는 상태와 무관하게 정리된다.
같은 변수를 다른 스레드/프레임에서 값이 다르게 보이는 것 같으면, 대부분은 이름이 같은 서로 다른 변수를 보고 있는 경우다. 예를 들어 여러 함수가 각자 지역변수로 ctx라는 이름을 따로 쓰고 있으면, 프레임을 안 맞추고 print ctx를 하면 완전히 다른 타입/다른 스레드의 값을 보게 된다. 헷갈리면:
frame 2 /* 확인하고 싶은 변수가 선언된 함수의 프레임으로 이동 */
print 변수명
으로 프레임을 먼저 맞추고 확인하는 습관이 필요하다.
3. 블로킹 함수를 n으로 넘길 때의 함정
n(next)은 “그 줄에 있는 함수 호출을 안으로 들어가지 않고 통째로 실행”하는 명령인데, 그 함수가 블로킹 함수(예: 다른 스레드를 기다리는 join, 표준입력을 기다리는 함수 등)라면 그 호출이 끝날 때까지 gdb 프롬프트가 안 돌아온다.
이 상태에서는 터미널 입력이 gdb 명령이 아니라 디버깅 대상 프로그램의 입력으로 그대로 들어간다. 그래서 이 타이밍에 뭔가 쳤는데 gdb 명령으로 인식 안 되고 프로그램이 “그런 명령 없음” 식으로 반응한다면, gdb가 안 먹은 게 아니라 입력이 프로그램 쪽으로 가고 있는 것이다. gdb 명령을 다시 치려면, 블로킹이 실제로 풀리길 기다리거나(그 함수가 리턴할 조건을 채워주거나) Ctrl+C로 강제로 끊고 들어가야 한다.
4. 시그널과 gdb — Ctrl+C가 프로그램에 안 갈 수 있다
Ctrl+C(SIGINT)로 프로그램 자체의 종료 처리를 테스트하려는 경우, gdb 안에서 하면 원하는 대로 안 될 수 있다.
gdb는 기본적으로 SIGINT를 “디버거를 멈추는 용도”로 가로채고, 프로그램한테는 전달하지 않는다 (Stop=Yes, Pass=No). 그래서 c(continue)로 실행 중에 Ctrl+C를 누르면:
Program received signal SIGINT, Interrupt.
라는 메시지와 함께 gdb 프롬프트로 돌아오는데, 이건 gdb가 멈춘 것이지 프로그램의 시그널 핸들러가 실행된 게 아니다. 이 상태에서 아무리 여러 번 Ctrl+C를 눌러도 프로그램이 등록한 시그널 핸들러(예: 종료 플래그를 0으로 만드는 핸들러)는 한 번도 호출되지 않는다.
실제 시그널 처리를 테스트하는 방법 두 가지:
- gdb 밖에서 바이너리를 직접 실행하고 Ctrl+C — 가장 확실하고 간단함
- gdb 안에서 하고 싶다면,
continue하기 전에 아래 설정으로 gdb가 시그널을 가로채지 않고 그대로 넘기도록 바꿔줘야 함:
이후handle SIGINT nostop pass noprintc로 실행하고 Ctrl+C를 누르면, 이번엔 gdb가 멈추지 않고 시그널이 프로그램에 실제로 전달돼서 핸들러가 동작하는지 확인할 수 있다.