GDB 고급 사용법 — 메모리 조사부터 원격 디버깅까지

아래 GDB 사용법 글에서 breakpoint·step·backtrace·print 같은 기본기는 이미 다뤘다. 하지만 실무에서, 특히 임베디드/크로스컴파일 환경에서 GDB를 쓰다 보면 로컬 바이너리에 breakpoint 거는 것만으로는 부족한 상황이 자주 생긴다. 타깃 보드에는 GUI도 없고 gdb 풀 버전도 못 올리는 경우가 많고, 멀티스레드 프로그램에서 특정 스레드만 봐야 하거나, “이 값이 대체 언제 바뀌는지” 소스만 봐서는 알 수 없는 버그를 잡아야 할 때도 있다. 이 글에서는 그런 상황에서 쓰는 GDB의 심화 기능 — 메모리 직접 조사, 워치포인트, 멀티스레드 디버깅, 원격/크로스 디버깅, 코어덤프 분석 — 을 정리한다.

메모리 직접 조사 — x 명령어

print이 변수·표현식 중심이라면, x(examine)는 임의의 메모리 주소를 직접 들여다볼 때 쓴다. 문법은 x/NFU addr이다.

  • N: 반복 횟수(생략 시 1)
  • F: 출력 형식 — x(16진수, 기본값), d(부호 있는 10진수), u(부호 없는 10진수), o(8진수), t(2진수), c(문자), s(문자열), i(어셈블리 명령어)
  • U: 단위 크기 — b(1바이트), h(2바이트), w(4바이트, 기본값), g(8바이트)
(gdb) x/s buf
0x555555558040 <buf>:	"Hello GDB!"
(gdb) x/16xb buf
0x555555558040 <buf>:	0x48	0x65	0x6c	0x6c	0x6f	0x20	0x47	0x44
0x555555558048 <buf+8>:	0x42	0x21	0x00	0x00	0x00	0x00	0x00	0x00
(gdb) x/dw &local
0x7fffffffea6c:	42
(gdb) x/3i $pc
=> 0x555555555177 <main+46>:	mov    -0x4(%rbp),%eax
   0x55555555517a <main+49>:	mov    %eax,%edx
   0x55555555517c <main+51>:	lea    0x2ebd(%rip),%rax        # 0x555555558040 <buf>

구조체 포인터를 print -pretty로 예쁘게 보는 것과 달리, x는 구조체 정의를 몰라도(스트립된 바이너리, 알 수 없는 메모리 영역이라도) 바이트 단위로 그냥 찍어볼 수 있다는 점이 다르다.

워치포인트 — 값이 바뀌는 순간 잡기

breakpoint는 “이 위치에 도달하면” 멈추지만, 워치포인트는 “이 값이 바뀌면” 멈춘다. 어디서 값이 바뀌는지 소스만 봐서는 특정할 수 없을 때(전역 변수를 여러 함수가 건드리는 경우 등) 유용하다.

  • watch <expr>: 표현식의 값이 쓰기로 바뀌면 정지
  • rwatch <expr>: 값을 읽기만 해도 정지
  • awatch <expr>: 읽기·쓰기 모두에 정지
  • info watchpoints: 설정된 워치포인트 목록 확인
(gdb) watch g_counter
Hardware watchpoint 2: g_counter
(gdb) continue

Hardware watchpoint 2: g_counter

Old value = 0
New value = 1
bump () at test_watch.c:9
9	}

워치포인트는 대상 하드웨어의 디버그 레지스터 개수만큼만 걸 수 있어(대부분 4개 안팎), 전역 변수 전체에 무작정 걸기보다는 의심되는 변수 몇 개로 좁혀서 쓰는 게 실전에서 낫다.

멀티스레드 디버깅

  • info threads: 현재 프로세스의 모든 스레드 목록과 각 스레드의 현재 위치 출력
  • thread <n>: n번 스레드로 컨텍스트 전환
  • thread apply all bt: 모든 스레드의 backtrace를 한 번에 출력 (교착상태/행 걸린 프로세스 분석에 유용)
  • break <위치> thread <n>: 특정 스레드에서만 동작하는 breakpoint
(gdb) info threads
  Id   Target Id                                       Frame
  1    Thread 0x7ffff7fa9740 (LWP 3680) "test_threads" 0x00007ffff7c98d71 in __futex_abstimed_wait_common64 (...) at ./nptl/futex-internal.c:57
* 2    Thread 0x7ffff7bff6c0 (LWP 3683) "test_threads" worker (arg=0x7fffffffea40) at test_threads.c:7
  3    Thread 0x7ffff73fe6c0 (LWP 3684) "test_threads" worker (arg=0x7fffffffea44) at test_threads.c:7

(gdb) thread apply all bt

Thread 3 (Thread 0x7ffff73fe6c0 (LWP 3684) "test_threads"):
#0  worker (arg=0x7fffffffea44) at test_threads.c:7
#1  0x00007ffff7c9caa4 in start_thread (arg=) at ./nptl/pthread_create.c:447
#2  0x00007ffff7d29c6c in clone3 () at ../sysdeps/unix/sysv/linux/x86_64/clone3.S:78

Thread 2 (Thread 0x7ffff7bff6c0 (LWP 3683) "test_threads"):
#0  worker (arg=0x7fffffffea40) at test_threads.c:7
#1  0x00007ffff7c9caa4 in start_thread (arg=) at ./nptl/pthread_create.c:447
#2  0x00007ffff7d29c6c in clone3 () at ../sysdeps/unix/sysv/linux/x86_64/clone3.S:78

여러 스레드가 뒤엉킨 상태에서 특정 스레드만 보고 싶은데 다른 스레드가 계속 실행되며 화면을 어지럽힌다면, set scheduler-locking on으로 현재 컨텍스트 전환한 스레드만 실행되도록 고정할 수 있다.

원격/크로스 디버깅 — gdbserver

타깃 보드(라즈베리파이, 임베디드 리눅스 등)에 풀버전 gdb를 올리기 부담스럽거나, 호스트 PC에서 크로스컴파일한 바이너리를 타깃에서 직접 디버깅하고 싶을 때는 타깃에 gdbserver만 올리고 호스트의 gdb가 원격으로 붙는 방식을 쓴다.

# 타깃 보드에서: 새 프로세스를 gdbserver로 실행
target$ gdbserver :2345 ./myapp arg1 arg2

# 이미 실행 중인 프로세스에 붙는 경우
target$ gdbserver --attach :2345 $(pidof myapp)
# 호스트 PC에서 (크로스 타깃이면 gdb-multiarch 사용)
host$ gdb-multiarch ./myapp
(gdb) target remote 192.168.0.50:2345

타깃과 호스트의 공유 라이브러리 경로가 다르면(임베디드 rootfs vs 호스트 시스템) 심볼을 못 찾는 경우가 많은데, 이때는 타깃 rootfs를 호스트에 마운트/복사해두고 set sysroot <rootfs경로>로 지정해주면 된다. 라이브러리 경로만 따로 추가하고 싶다면 set solib-search-path <경로>를 쓴다.

실제로 타깃에서 gdbserver 127.0.0.1:2345 ./test_x를 띄우고 호스트에서 붙여보면 다음과 같이 원격의 라이브러리·디버그 심볼을 자동으로 읽어오면서 연결된다.

(gdb) target remote 127.0.0.1:2345
Reading /lib64/ld-linux-x86-64.so.2 from remote target...
warning: File transfers from remote targets can be slow. Use "set sysroot" to access files locally instead.
Reading /usr/lib/debug/.build-id/94/9cd00983c0051fc31285b68e03a0bd2261a161.debug from remote target...
0x00007ffff7fe4540 in _start () from target:/lib64/ld-linux-x86-64.so.2
(gdb) break test_x.c:10
Breakpoint 1 at 0x555555555177: file test_x.c, line 10.
(gdb) continue
Reading /lib/x86_64-linux-gnu/libc.so.6 from remote target...

Breakpoint 1, main () at test_x.c:10
10		printf("%s %d\n", buf, local);
(gdb) x/s buf
0x555555558040 <buf>:	"Hello GDB!"
(gdb) detach
[Inferior 1 (process 4652) detached]

연결 직후 뜨는 “File transfers from remote targets can be slow” 경고가 바로 위에서 말한 set sysroot 안내다 — 타깃 파일시스템 전체를 매번 네트워크로 읽어오는 대신 로컬에 복사해두고 그 경로를 sysroot로 지정하면 훨씬 빨라진다.

코어덤프 분석

프로세스가 죽은 뒤에도 원인을 분석해야 한다면 코어덤프를 gdb로 열면 된다. 코어덤프 생성 설정 자체는 Ubuntu coredump 설정 글을 참고한다. 다만 stock Ubuntu에서는 /proc/sys/kernel/core_pattern이 기본적으로 apport로 잡혀 있어서, apt로 설치되지 않은 자체 빌드 바이너리가 죽으면 /var/crash에 리포트가 아예 안 남는 경우가 많다(apport가 조용히 무시한다). 이럴 때는 gdb로 프로그램을 직접 실행해 죽는 순간을 잡고, generate-core-file로 그 자리에서 코어를 뽑는 게 더 확실하다.

$ gdb -batch -ex run -ex bt -ex 'generate-core-file crash.core' ./test_segv

Program received signal SIGSEGV, Segmentation fault.
0x000055555555513d in crash () at test_segv.c:7
7		strcpy(ptr, "boom");
#0  0x000055555555513d in crash () at test_segv.c:7
#1  0x0000555555555157 in main () at test_segv.c:12
Saved corefile crash.core

이렇게 뽑은 코어 파일은 이후 별도 세션에서 시스템이 만든 core.12345와 똑같이 열어서 분석할 수 있다.

$ gdb ./test_segv crash.core
Core was generated by `/home/noble/gdb_test_tmp/test_segv'.
Program terminated with signal SIGSEGV, Segmentation fault.
(gdb) bt
#0  0x000055555555513d in crash () at test_segv.c:7
#1  0x0000555555555157 in main () at test_segv.c:12
(gdb) frame 0
#0  0x000055555555513d in crash () at test_segv.c:7
7		strcpy(ptr, "boom");
(gdb) print ptr
$1 = 0x0

코어덤프는 죽은 시점의 스냅샷이라 run/continue는 쓸 수 없고, bt·print·x 같은 “정적으로 들여다보는” 명령만 동작한다. 위 예에서도 ptr0x0이었다는 게 바로 확인되어, strcpy에 NULL 포인터가 들어간 게 죽은 원인이라는 게 바로 드러난다.

주의사항

  • 워치포인트는 하드웨어 디버그 레지스터를 쓰는 방식이라 개수 제한이 있고, 소프트웨어 워치포인트로 폴백되면 실행이 눈에 띄게 느려질 수 있다.
  • gdbserver로 원격 디버깅할 때 타깃의 gdbserver 버전과 호스트 gdb 버전 차이가 크면 프로토콜 호환 문제가 생길 수 있으니, 가능하면 타깃 배포판이 제공하는 gdbserver와 그에 맞는 gdb-multiarch를 짝지어 쓰는 게 안전하다.
  • 코어덤프 분석 시 바이너리에 -g 디버그 정보가 없으면 심볼 없이 주소만 나온다. 스트립된 릴리즈 바이너리라면 별도로 보관해둔 디버그 심볼(.debug 파일이나 stripped 전 바이너리)을 symbol-file로 따로 로드해야 한다.
  • Ubuntu에서 core_pattern이 apport로 잡혀 있는 상태로는 apt 패키지가 아닌 바이너리의 코어가 /var/crash에 안 남을 수 있다. sudo 권한으로 core_pattern을 직접 바꿀 수 없는 환경(공유 서버, 제한된 계정 등)이라면 generate-core-file로 우회하는 편이 빠르다.

마무리

x 명령어로 임의 메모리를 바이트 단위로 들여다보고, 워치포인트로 “언제 바뀌는지”를 추적하고, gdbserver로 타깃 보드까지 디버깅 범위를 넓히고 나면 GDB로 다룰 수 있는 문제의 폭이 훨씬 넓어진다. 특히 원격/크로스 디버깅은 임베디드 개발에서 거의 필수적으로 마주치게 되므로, 타깃 보드 작업이 잦다면 gdbserver와 sysroot 설정 방법 정도는 미리 익혀두는 게 좋다.

참고

답글 남기기