GDB 원격 디버깅 — gdbserver·OpenOCD·J-Link 통합
GDB가 실행되는 호스트와 디버깅 대상이 도는 타깃은 같은 컴퓨터가 아니어도 됩니다. 라즈베리파이, x86 서버, ARM 보드, Cortex-M MCU — 모두 한 끝에 GDB Remote Serial Protocol(이하 RSP)을 말할 줄 아는 스텁만 있으면 GDB가 마치 로컬처럼 디버깅합니다.
이 장은 RSP 프로토콜의 정체에서 출발해 두 갈래로 나아갑니다.
- OS가 있는 원격 머신 — gdbserver / lldb-server. 가벼운 협조 모델.
- 베어메탈 MCU — OpenOCD / J-Link → JTAG/SWD → ARM CoreSight 디버그 회로.
베어메탈 쪽이 더 깊어 보이지만 GDB 쪽의 표현은 같습니다. 어디까지가 GDB의 일이고 어디부터가 스텁의 일인지 구분하면 두 갈래가 한 그림으로 모입니다.
#RSP — Remote Serial Protocol
RSP는 1989년 GDB 4.x 시절 도입된 ASCII 패킷 기반 텍스트 프로토콜입니다. 단순함이 미덕 — 시리얼 9600bps에서 USB 3.0까지 같은 메시지가 통합니다.
#패킷 형식
$<payload>#<checksum>$— 시작.<payload>— 명령/응답 본문.#— 종료.<checksum>— payload 바이트의 8-bit 합 % 256, 16진 두 자리.
+로 ACK, -로 NACK. 노이즈가 많은 시리얼선에서는 패킷 재전송이 빈번해 신호 무결성이 핵심.
#자주 쓰이는 패킷
| 패킷 | 의미 | 응답 |
|---|---|---|
? | 정지 사유 | T05thread:01; (SIGTRAP, thread 1) |
g | 모든 레지스터 읽기 | 16진 인코딩된 전체 레지스터 |
G<hex> | 모든 레지스터 쓰기 | OK |
p<n> | 한 레지스터 읽기 | <hex> |
m<addr>,<len> | 메모리 읽기 | <hex bytes> |
M<addr>,<len>:<hex> | 메모리 쓰기 | OK |
c<addr>? | continue (옵션: 주소부터) | 다음 stop 패킷 |
s<addr>? | single-step | 다음 stop 패킷 |
Z0,<addr>,<kind> | software 브레이크포인트 설정 | OK |
Z1,<addr>,<kind> | hardware 브레이크포인트 | OK |
Z2,<addr>,<len> | write watchpoint | OK |
Z3 / Z4 | read / access watchpoint | OK |
z* | 위 BP/WP 제거 | OK |
qSupported | feature negotiation | 쉼표 구분 능력 목록 |
vCont;<actions> | 다중 스레드 제어 | 다음 stop |
qXfer:features:read:target.xml:... | 아키텍처 XML 전송 | XML 청크 |
k | kill | (응답 없음) |
#실제 트래픽 한 컷
break main + continue를 친 직후의 RSP 트래픽(GDB set debug remote 1로 노출).
Sending: "qSupported:multiprocess+;swbreak+;hwbreak+;..."Got: "PacketSize=3fff;qXfer:features:read+;..."Sending: "vMustReplyEmpty"Got: ""Sending: "qXfer:features:read:target.xml:0,ffb"Got: "l<target><architecture>i386:x86-64</architecture>..."Sending: "?"Got: "T05thread:p3039.3039;..."Sending: "Hg0" # 다음 g 명령을 위한 thread 컨텍스트Got: "OK"Sending: "g"Got: "0000000000000000ffffffff..." # 레지스터 덤프Sending: "Z0,401130,1" # main 진입에 sw BPGot: "OK"Sending: "vCont;c"Got: "T05swbreak:;thread:p3039.3039;"스텁이 누구든 — gdbserver든 OpenOCD든 J-Link 펌웨어든 — GDB는 이 패킷만 봅니다. 본격 디버거를 만들고 싶다면 RSP 명세를 정독하면 됩니다.
#qSupported — feature negotiation
연결 직후 첫 패킷이 qSupported. 상호 능력을 협상합니다.
GDB: qSupported:multiprocess+;swbreak+;hwbreak+;qRelocInsn+;fork-events+; vfork-events+;exec-events+;vContSupported+;QThreadEvents+;no-resumed+; memory-tagging+;xmlRegisters=i386Stub: PacketSize=2000;QPassSignals+;QProgramSignals+;QStartupWithShell+; QEnvironmentHashCheck+;QEnvironmentReset+;QEnvironmentUnset+; QEnvironmentSet+;QSetWorkingDir+;qXfer:features:read+; qXfer:libraries-svr4:read+;qXfer:auxv:read+;qXfer:exec-file:read+; qXfer:siginfo:read+;qXfer:siginfo:write+;QCatchSyscalls+; QPassSignals+;swbreak+;hwbreak+;...xxx+ = 지원, xxx- = 미지원, xxx? = 조건부. 이 한 줄로 GDB가 이 스텁에서는 무엇을 할 수 있는지 결정합니다.
#Software vs Hardware breakpoint
- SW BP (
Z0) — 스텁이 명령어 한 바이트를0xCC(x86 INT3) 또는 ARMBKPT로 갈아 끼웁니다. 원래 명령은 별도 저장.- 장점: 개수 무제한.
- 단점: 메모리가 쓰기 가능해야 함 — flash 같은 ROM에선 불가능.
- HW BP (
Z1) — 칩 안의 BP 레지스터에 주소 비교기를 설치. 명령어를 건드리지 않음.- 장점: ROM/flash에서 동작.
- 단점: 칩마다 4~6개 등 유한.
Cortex-M의 FPB(Flash Patch and Breakpoint) 유닛은 보통 6개의 HW BP를 제공합니다. 6개 다 쓰면 다음 BP는 침묵하므로 info breakpoints로 종종 확인해야 합니다.
#OS 있는 원격 — gdbserver
가장 일반적: 다른 리눅스 박스의 프로세스를 디버깅.
#타깃 측
# 새 프로세스 시작$ gdbserver :2345 ./my_program arg1 arg2Process ./my_program created; pid = 5678Listening on port 2345
# 또는 이미 도는 프로세스에 attach$ gdbserver :2345 --attach 5678
# 또는 멀티 인스턴스(여러 디버그 세션을 같은 데몬에서)$ gdbserver --multi :2345
# Unix 소켓$ gdbserver unix:/tmp/gdb.sock ./my_program
# 시리얼$ gdbserver /dev/ttyS0 ./my_programgdbserver는 GDB 소스 트리 안에 있는 가벼운 데몬입니다. 하는 일은 셋.
- ptrace로 디버기 attach (Linux).
- RSP 패킷을 받아 ptrace 호출로 변환.
- 응답을 RSP로 돌려보냄.
ptrace에 대한 자세한 동작은 Ch 1 참고. macOS는 ptrace 대신 Mach Exception API, Windows는 DebugActiveProcess를 씁니다. lldb-server는 이 모든 백엔드를 통합합니다.
#호스트 측
$ gdb ./my_program(gdb) target remote 192.168.1.20:2345Remote debugging using 192.168.1.20:23450x00007f... in __libc_start_main ()(gdb) break main(gdb) continuetarget remote로 연결한 뒤로는 로컬과 같습니다. bt, info threads, print 다 됩니다.
#target remote vs target extended-remote
remote | extended-remote | |
|---|---|---|
kill | 세션 종료 | 디버기만 종료, 연결 유지 |
run | 불가 | 가능(재시작) |
attach PID | 불가 | 가능 |
| 멀티프로세스 | 제한적 | 완전 지원 |
OpenOCD와 J-Link는 보통 extended로 연결. 시퀀스 monitor reset halt → load → monitor reset halt → continue 반복이 가능해야 하기 때문입니다.
#sysroot 지정
타깃과 호스트의 라이브러리 위치가 다르면 sysroot로 지정.
(gdb) set sysroot /opt/target-rootfs(gdb) set solib-search-path /opt/target-rootfs/usr/lib크로스 컴파일 환경에서 흔히 씁니다. ARM 타깃의 /lib/libc.so.6이 호스트에는 없으니, 타깃 rootfs를 복사해 두고 거기를 가리킵니다.
GDB 7.4+ 의 file transfer로 타깃의 라이브러리를 자동으로 끌어올 수도 있습니다.
(gdb) set sysroot remote:(gdb) remote get /lib/libc.so.6 /tmp/libc.so.6remote: sysroot는 GDB가 qXfer:libraries-svr4로 타깃에서 직접 메타정보를 가져옵니다. 가장 편하지만 트래픽이 많아짐.
#SSH 터널로 보안
gdbserver는 인증·암호화가 없습니다. 인터넷을 가로지르면 SSH로 감쌉니다.
# 호스트에서$ ssh -L 2345:localhost:2345 user@target -N &$ gdb ./my_program(gdb) target remote localhost:2345또는 stdio를 통째로 SSH로 보내는 한 줄.
(gdb) target remote | ssh user@target gdbserver - ./my_programgdbserver -는 stdin/stdout으로 RSP를 합니다.
운영 환경 권장: SSH 터널 + non-root gdbserver + 강력한 ptrace 제한(/proc/sys/kernel/yama/ptrace_scope=1).
#lldb-server
LLDB도 같은 모델. macOS / iOS / Linux 모두 지원하고, 플랫폼 모드라는 추상 레이어가 추가됩니다.
# 타깃$ lldb-server platform --listen "*:2345" --server
# 호스트$ lldb(lldb) platform select remote-linux(lldb) platform connect connect://192.168.1.20:2345(lldb) target create ./my_program(lldb) process launch플랫폼 모드는 파일 시스템 작업까지 RSP-LLDB 확장으로 처리합니다 — 호스트의 target create가 자동으로 타깃에 바이너리를 전송합니다. 가장 매끄러운 크로스 디버깅 경험.
iOS 기기 디버깅이 평소 우리가 가장 자주 만나는 lldb-server 사례입니다 (Xcode가 내부적으로 lldb-server를 갖다 씁니다).
#debugserver vs lldb-server
macOS / iOS는 historic 이유로 debugserver라는 별도 바이너리를 씁니다. Xcode 안에 묶여 있고, Apple Developer 도구로만 배포. 인터페이스는 lldb-server와 거의 같지만 코드 사이닝·entitlement가 추가로 필요합니다.
$ codesign -dvvv $(which debugserver)#베어메탈 — JTAG / SWD가 뭔가
여기서부터가 임베디드. MCU에는 OS가 없으니 gdbserver를 못 돌립니다. 대신 칩 안에 디버그 모듈이 있고, 그걸 외부에서 JTAG 또는 SWD 핀으로 두드립니다.
#JTAG (IEEE 1149.1)
1990년 표준화된 boundary-scan 프로토콜. 원래는 PCB 제조 후 핀 솔더링 검증용이었지만 디버그·플래시 프로그래밍까지 흡수.
| 핀 | 방향 | 의미 |
|---|---|---|
| TCK | 입력 | 테스트 클럭 |
| TMS | 입력 | 모드 선택 — TAP 상태 천이 결정 |
| TDI | 입력 | 데이터 입력(시프트인) |
| TDO | 출력 | 데이터 출력(시프트아웃) |
| TRST | 입력 | 비동기 리셋(옵션) |
핵심은 TAP(Test Access Port) — 16-상태 유한 상태 머신. TMS 값에 따라 천이.
이 상태 머신에서 *명령 레지스터(IR)*와 *데이터 레지스터(DR)*를 시프트해서 칩의 디버그 자원을 조작합니다. 흔히 보는 IR 명령.
| IR 코드 | 의미 |
|---|---|
EXTEST | boundary scan |
SAMPLE/PRELOAD | 핀 상태 캡처 |
IDCODE | 32-bit 칩 식별자 |
BYPASS | 1-bit 통과 |
DEBUG (ARM) | 디버그 자원 접근 (ARM7/9의 SCAN_N+INTEST 등) |
#SWD (Serial Wire Debug)
ARM이 핀 수를 줄이기 위해 만든 2핀 대체. JTAG 5핀 → SWD 2핀(SWCLK/SWDIO).
| 핀 | 의미 |
|---|---|
| SWCLK | 클럭 |
| SWDIO | 양방향 데이터(반이중) |
| SWO | (옵션) 1핀 트레이스 출력 |
JTAG의 TAP 상태 머신 대신 패킷 기반. 패킷 한 단위는.
[Start=1][APnDP][RnW][A2..A3][Parity][Stop=0][Park=1][TRN][ACK 3-bit][Data 32-bit][Parity]- APnDP — Access Port(0) or Debug Port(1)?
- RnW — read(1) or write(0)?
- A2..A3 — 4바이트 정렬 주소 비트.
Cortex-M은 거의 SWD. STM32, nRF52, ESP32-S3 등이 모두 SWD 2핀 + SWO 1핀 구성을 표준으로 씁니다.
#CoreSight — ARM 디버그 아키텍처
JTAG/SWD가 물리 인터페이스라면 그 위에 칩 안에서 실제 일을 하는 게 CoreSight입니다.
| 블록 | 역할 |
|---|---|
| DAP | 외부 ↔ 칩 내부 버스 게이트웨이 |
| FPB | HW 브레이크포인트 (보통 6개) + flash patching |
| DWT | 데이터 워치포인트 (4개), cycle counter, exception trace |
| ITM | software trace — printf 대용 SWO 출력 |
| ETM | 명령어 단위 trace (선택 옵션) |
| SCB / DCB | core 디버그 control 레지스터 |
이 블록들이 칩 안에 있고, 외부 디버거(OpenOCD/J-Link)는 DAP를 통해 메모리-mapped 레지스터를 읽고 씁니다. GDB의 break/watch/step은 결국 이 레지스터 셋업으로 변환됩니다.
#CPU halt 메커니즘 — Cortex-M
break main을 걸고 continue → main에 들어가면 정지. 이게 어떻게 일어나나?
- 디버거가 FPB에
main의 주소를 기록 + 활성화. - CPU가 fetch 시 PC가 FPB와 일치 → core가 debug state로 천이.
- 디버거가 DHCSR(Debug Halting Control and Status Register)의
S_HALT비트를 폴링하다 1이 되면 GDB에 stop 패킷 전송. - GDB가
bt/print요청 → 디버거가 DCRSR(register select)로 레지스터를 한 개씩 끌어옴. continue→ DHCSR.C_HALT = 0 → CPU 재개.
monitor halt는 외부에서 DHCSR.C_HALT = 1을 강제로 써서 CPU를 멈추는 것.
DHCSR (0xE000EDF0)[31:16] DBGKEY (0xA05F to write)[25] S_RESET_ST (reset since last read)[24] S_RETIRE_ST (instruction retired)[19] S_LOCKUP[18] S_SLEEP[17] S_HALT ← 1이면 정지[16] S_REGRDY[5:2] reserved[3] C_MASKINTS (mask interrupts in halt)[2] C_STEP[1] C_HALT ← 1로 쓰면 정지[0] C_DEBUGEN ← 1이면 디버그 가능DHCSR을 직접 들여다보면 왜 CPU가 안 멈추는지를 디버깅할 수 있습니다.
이 신호를 USB로 변환해 PC와 연결하는 프로브가 필요합니다.
- ST-Link (STMicro 보드 내장, 외부 V2/V3)
- J-Link (Segger 상용, 가장 빠르고 비쌈)
- DAPLink / CMSIS-DAP (ARM 표준, 저렴)
- Black Magic Probe (오픈 하드웨어, 자체 gdbserver 내장)
- FT2232 + OpenOCD 조합 (저렴, 범용)
이들의 차이는 드라이버·펌웨어와 최대 클럭 정도. 신호 자체는 표준입니다.
#OpenOCD — 오픈소스 gdbserver
OpenOCD(Open On-Chip Debugger)는 거의 모든 프로브 + 거의 모든 칩을 다루는 만능 도구입니다. GDB 측에서는 그냥 gdbserver로 보입니다.
내부 구조 (간단).
설정 파일이 두 갈래로 분리된 이유 — 프로브와 칩이 독립적으로 조합되기 때문입니다.
#실행
# 인터페이스(프로브) + 타깃(칩) 설정으로 실행$ openocd -f interface/stlink.cfg -f target/stm32f4x.cfgOpen On-Chip Debugger 0.12.0...Info : clock speed 2000 kHzInfo : STLINK V2J37M27 (API v2) VID:PID 0483:374BInfo : Target voltage: 3.234Info : stm32f4x.cpu: hardware has 6 breakpoints, 4 watchpointsInfo : Listening on port 3333 for gdb connectionsInfo : Listening on port 4444 for telnet connections3333 = GDB, 4444 = 사람용 telnet, 6666 = TCL.
타깃 voltage·BP 개수·WP 개수 같은 정보가 디버깅 출발 직전에 나옵니다. Target voltage: 3.234에서 칩에 전원이 제대로 들어왔는지 1차 확인.
#GDB 연결
$ arm-none-eabi-gdb firmware.elf(gdb) target extended-remote :3333Remote debugging using :3333(gdb) monitor reset halt(gdb) load # ELF의 .text / .data를 칩의 flash에 굽는다(gdb) monitor reset halt(gdb) break main(gdb) continue핵심 명령들.
| 명령 | 효과 |
|---|---|
monitor reset halt | 칩 리셋 + 즉시 정지 |
monitor reset run | 리셋 후 실행 |
monitor flash erase_sector 0 0 last | flash 일괄 erase |
load | ELF의 LMA로 flash/SRAM 프로그래밍 |
monitor halt | 외부에서 강제 정지 |
monitor mdw 0x20000000 16 | 메모리 워드 16개 덤프 |
monitor mww 0x20000100 0xdeadbeef | 메모리 워드 쓰기 |
monitor reg | 모든 코어 레지스터 |
monitor는 RSP의 일반 명령을 거치지 않고 스텁 측 인터프리터에 직접 전달. OpenOCD의 경우 모든 TCL 명령이 사용 가능합니다.
#load의 내부
(gdb) loadLoading section .isr_vector, size 0x1c0 lma 0x8000000Loading section .text, size 0x9d20 lma 0x80001c0Loading section .rodata, size 0x4c0 lma 0x8009ee0Loading section .data, size 0x140 lma 0x800a3a0Start address 0x080001b8, load size 41960Transfer rate: 22 KB/sec, 8392 bytes/write.GDB가 ELF의 각 PT_LOAD 세그먼트를 RSP M<addr>,<len>:<hex> 패킷으로 OpenOCD에 보냅니다. OpenOCD가 그 주소가 flash인지 SRAM인지 판단해 flash면 flash driver를 호출합니다.
Flash driver의 일.
- 섹터 erase (4-128 KB 단위).
- CPU SRAM에 flash loader 코드를 올림 — 칩 제조사 제공.
- CPU에 점프 → loader가 word 단위로 flash 프로그램.
- verify.
이 모든 일을 GDB는 모릅니다. RSP로는 그저 메모리 쓰기 패킷일 뿐.
#흔한 OpenOCD 설정
openocd.cfg 한 파일로 묶기.
# interfacesource [find interface/cmsis-dap.cfg]adapter speed 4000
# targetsource [find target/nrf52.cfg]
# (선택) reset 후 자동 halt$_TARGETNAME configure -event reset-init { # 외부 클럭으로 전환 mww 0x40000700 1}
# (선택) flash 자동 굽기initreset haltflash write_image erase firmware.elfreset runshutdown$ openocd -f openocd.cfg스크립트 안에 init reset flash write_image를 넣으면 굽는 작업도 한 줄로 자동화됩니다. CI에서 보드 펌웨어 자동 갱신에 유용.
#멀티 코어 타깃
Cortex-A + Cortex-M 듀얼 코어(예: STM32MP1), Cortex-M0+ + M4 듀얼(예: nRF5340, RP2040). 두 코어를 동시에 디버깅하려면 두 target를 정의.
target create $_CHIPNAME.app cortex_m -dap $_CHIPNAME.dap -ap-num 0target create $_CHIPNAME.net cortex_m -dap $_CHIPNAME.dap -ap-num 1$_CHIPNAME.app configure -rtos autoOpenOCD가 두 개의 GDB 포트(3333, 3334)를 띄우고, 각각 별 GDB 세션으로 디버깅합니다. 한 GDB가 두 코어를 동시에 보는 멀티-인페리어도 가능하지만 도구 체인이 제한적.
#J-Link — Segger 상용
J-Link는 J-Link GDB Server라는 자체 데몬이 따로 있습니다.
$ JLinkGDBServer -device STM32F407VG -if SWD -speed 4000SEGGER J-Link GDB Server V7.94Listening on TCP/IP port 2331Connected to targetWaiting for GDB connection...연결.
$ arm-none-eabi-gdb firmware.elf(gdb) target remote :2331(gdb) monitor reset(gdb) load(gdb) continue-device옵션이 필수. Segger 데이터베이스에 등록된 정확한 부품 번호. 잘못 적으면 connect fail이 아니라 load 후 동작 이상으로 나타나 디버깅이 어려움.-if SWD또는JTAG.-speed는 kHz. 4000이 안전한 기본값, 8000은 짧고 굵은 신호선만, 1000 이하는 긴 와이어용.- RTT(아래)를 쓰려면
-rtos GDBServer.so등 추가 옵션.
상용이지만 비상업·교육용 무료 (Segger EDU 라이선스, J-Link EDU mini가 저렴). 속도·안정성에서 OpenOCD보다 뛰어나, 정전기·잡음이 많은 현장에서 OpenOCD가 자꾸 끊기면 J-Link로 갑니다.
#J-Link 고급 — Unlimited Flash Breakpoints
상용 J-Link만 제공하는 기능. FPB의 HW BP 개수(6개) 제한을 넘기기 위해 flash patching으로 무한 BP를 흉내. 흐름.
- BP 7번째 설정 → J-Link가 flash 페이지 전체를 임시로 SRAM에 복사.
- 해당 위치를
BKPT명령으로 패치. - CPU가 점프 시 SRAM의 패치된 페이지를 실행 → halt.
- resume 시 원래 페이지로 복원.
비싸지만 BP 부족이 일상인 큰 펌웨어에서 매우 유용. OpenOCD는 같은 기능이 없으니 BP 6개 관리가 필수.
#ELF 파일 — 굽는 단위
GDB가 load 할 때 사용하는 그 파일. 실행 가능한 코드와 데이터, 어디에 놓일지까지 포함합니다.
$ arm-none-eabi-readelf -S firmware.elf[Nr] Name Type Addr Size[ 1] .isr_vector PROGBITS 08000000 ... # flash 시작[ 2] .text PROGBITS 080001c0 ... # 코드[ 3] .rodata PROGBITS 08010000 ... # 상수[ 4] .data PROGBITS 20000000 ... # 초기화 데이터 (VMA=SRAM)[ 5] .bss NOBITS 20001000 ....data의 LMA(Load Memory Address)는 flash, VMA(Virtual Memory Address)는 SRAM. 부팅 시 startup 코드가 flash에서 SRAM으로 복사합니다. load는 LMA를 따라 굽습니다.
$ arm-none-eabi-readelf -l firmware.elfProgram Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align LOAD 0x00010000 0x08000000 0x08000000 0x09f80 0x09f80 R E 0x10000 LOAD 0x00020000 0x20000000 0x0800a4c0 0x00140 0x00140 RW 0x10000 LOAD 0x00020140 0x20000140 0x0800a600 0x00000 0x00800 RW 0x10000PT_LOAD 세그먼트가 실제로 디스크와 메모리에 올라가는 단위. VirtAddr이 VMA, PhysAddr이 LMA. 세 번째 세그먼트가 FileSiz=0인 이유는 .bss (제로 채움이라 굽지 않음).
$ arm-none-eabi-objdump -h firmware.elf$ arm-none-eabi-size firmware.elf text data bss dec hex filename 47832 320 2048 50200 c418 firmware.elftext가 flash 차지(코드+rodata), data가 flash에 굽힌 초기화 데이터의 크기 + SRAM에 차지, bss가 SRAM만 차지. 총 flash = text + data. 총 SRAM = data + bss + stack + heap.
#MAP 파일 — 누가 메모리를 잡아먹나
링커가 -Map=output.map 옵션으로 만들어 주는 파일. 어떤 심볼이 어느 주소에 얼마나 차지하는지 다 보여 줍니다.
Linker script and memory map
Memory ConfigurationName Origin Length AttributesFLASH 0x08000000 0x00100000 xrSRAM 0x20000000 0x00020000 xrw
.text 0x080001c0 0x9d20 0x080001c0 main_init 0x080001f4 HAL_Init ...
.bss 0x20001234 0x800 0x20001234 g_buffer 0x20001a00 rx_queue
Cross Reference Table
Symbol FileHAL_Init ./build/main.o ./build/sensor.o (HAL_Init) ./build/uart.o (HAL_Init)진단에 쓰는 세 가지.
- 메모리 부족 —
.bss가 너무 크면 SRAM 한계 초과. MAP에서 큰 심볼을 찾아 줄임..text가 flash 초과하면 링크 단계에서 실패. - 알 수 없는 주소 — 콜스택에
0x08003a12만 나오면 MAP에서 그 주소가 어떤 함수 안인지 검색. - 링크 충돌 — Cross Reference Table에서 어느 파일이 어느 심볼을 정의했고 어느 파일이 사용했는지 확인. 누락된 ifdef·중복 정의 진단.
# 큰 심볼 상위 20개$ awk '/^ \.text|^ \.bss|^ \.data/ {section=$1} \ /^ 0x[0-9a-f]+ +0x[0-9a-f]+/ \ {print section, $1, strtonum($2), $3}' \ firmware.map | sort -k3 -n -r | head -20# objdump로 디스어셈블해도 같은 정보$ arm-none-eabi-objdump -d firmware.elf | less큰 펌웨어에선 nm --size-sort firmware.elf | tail -30로 코드 크기 큰 함수를 찾는 것도 자주 합니다.
#Cortex-M 콜스택 — 어떻게 풀리나
ARM의 호출 규약(AAPCS):
- 인자 4개까지 r0-r3.
- 함수 진입 시
push {r7, lr}또는push {r4-r7, lr}. - r7이 frame pointer로 자주 쓰이지만 생략 가능 (
-fomit-frame-pointer). - 리턴은
bx lr또는pop {pc}.
ISR(인터럽트 서비스 루틴) 진입 시 하드웨어가 stack에 exception frame 8개 워드를 자동 push.
[stack 위] xPSR PC LR R12 R3 R2 R1[stack 아래] R0그래서 ISR 안에서 bt하면 정확한 frame을 풀어낼 수 있습니다. LR이 EXC_RETURN 값(0xFFFFFFF9 등)이면 ISR 안. EXC_RETURN의 비트가 어떤 stack을 쓸지(MSP vs PSP), 어떤 모드로 돌아갈지를 결정.
EXC_RETURN 비트:[3] Mode — 0=Handler, 1=Thread[2] SPSEL — 0=MSP, 1=PSP[0] ES — 0=secure exit (TrustZone)콜스택 풀기가 안 풀리면 보통 r7 미보존(-fomit-frame-pointer) 또는 naked function 때문. DWARF .debug_frame이 충분하면 GDB가 풀어내지만, 빌드 옵션에 따라 깨집니다 — Ch 12에서 자세히.
#베어메탈 디버깅 흐름 (한 장 요약)
[작성] main.c → arm-none-eabi-gcc → firmware.elf + firmware.map[연결] PC → USB → 프로브(ST-Link/J-Link) → SWD/JTAG → 칩 디버그 모듈[데몬] openocd 또는 JLinkGDBServer 가동, TCP 3333/2331 리슨[GDB] arm-none-eabi-gdb firmware.elf → target extended-remote :3333[프로그래밍] (gdb) load # ELF의 .text/.data를 flash에 굽기[디버깅] break / continue / step / print — 평소 GDB와 동일#RTT — printf 없이 로그 빼기
UART도 없는 칩, 또는 ISR 안에서 printf를 쓸 수 없을 때 Segger의 RTT(Real-Time Transfer)가 강력합니다. SRAM의 링 버퍼를 디버그 프로브가 백그라운드로 읽어 갑니다. MCU 측에서는 메모리 한 번 쓰기로 끝.
#동작 원리
- MCU 펌웨어 측에
_SEGGER_RTT구조체가 SRAM의 고정 위치에 존재. SEGGER_RTT_printf가 그 안의 링 버퍼에 바이트를 씀(non-blocking).- PC측 J-Link DLL이 SWD를 통해 백그라운드로 RAM을 폴링하다가 버퍼 데이터를 읽음.
- PC측 RTT Client에 출력.
CPU는 멈추지 않습니다. 인터럽트 디스에이블 없이 1µs 이하로 끝나는 fire-and-forget 쓰기.
// firmware#include "SEGGER_RTT.h"SEGGER_RTT_printf(0, "tick=%u\n", HAL_GetTick());
// PC$ JLinkRTTClient###RTT Client: ************************************************************###RTT Client: * SEGGER Microcontroller GmbH *###RTT Client: * Solutions for real time microcontroller applications *###RTT Client: ************************************************************tick=1234tick=2456...OpenOCD도 RTT 채널을 지원합니다(0.11+).
(openocd telnet) rtt setup 0x20000000 0x10000 "SEGGER RTT"(openocd telnet) rtt server start 9090 0$ nc localhost 9090RTT 채널은 방향별로 최대 16개씩. 채널 0는 stdout, 1은 보통 키 입력. 한 채널을 binary 로깅(예: 센서 raw 데이터)에 쓰고 다른 채널을 텍스트로 분리하는 패턴이 많습니다.
#ITM — ARM 표준 trace
ITM(Instrumentation Trace Macrocell)은 RTT의 ARM 표준 대안. 32개의 stim port에 워드를 쓰면 SWO(1핀) 또는 TRACE 핀(병렬)으로 trace 패킷이 흘러나옵니다.
#define ITM_PORT(n) (*((volatile uint32_t *)(0xE0000000 + 4*(n))))
void itm_putchar(char c) { while ((ITM->PORT[0].u32 & 1) == 0); // wait FIFO ready ITM->PORT[0].u8 = c;}OpenOCD는 SWO 출력을 받아 stim port 0번을 stdout으로 풀어 줍니다.
(openocd telnet) tpiu config internal /tmp/swo.log uart off 168000000 2000000(openocd telnet) itm port 0 on168000000은 코어 클럭, 2000000은 SWO baud. 클럭 비율이 안 맞으면 garbled. 자주 만나는 함정.
RTT가 J-Link 종속이라면 ITM은 완전 표준. 하지만 SWO 한 핀이 추가로 필요합니다.
#Semihosting — 칩에서 호스트 syscall
ARM의 또 다른 디버그 통로. 펌웨어에서 BKPT 0xAB를 실행하면 디버거가 그 시점에 멈춰 SVC 번호로 호스트 측 동작을 대행합니다.
extern int _write(int fd, char *p, int n) { // semihosting SVC register int r0 asm("r0") = 0x05; // SYS_WRITE register const char *r1 asm("r1") = p; register int r2 asm("r2") = n; asm volatile("bkpt #0xAB" : "+r"(r0) : "r"(r1), "r"(r2)); return r0;}OpenOCD/J-Link 모두 활성화 가능.
(openocd) arm semihosting enableprintf가 호스트 콘솔로 직출력됩니다. 다만 매 호출마다 CPU가 정지하므로 느립니다. 디버깅 초창기 + 인터럽트 안 쓸 때만.
#ETM — 명령어 단위 trace
ETM(Embedded Trace Macrocell)이 있으면 CPU의 모든 명령어 실행이 trace됩니다. SEGGER J-Trace, Lauterbach TRACE32 같은 고가 도구가 받습니다.
쓰임:
- 비결정적 버그의 완전한 이전 시퀀스 재구성 (rr의 베어메탈 버전).
- 인터럽트 latency 측정.
- 캐시 hit/miss 통계.
Cortex-M7/M33은 ETM-M4 옵션 탑재. 모든 칩에 있는 건 아닙니다.
#DWT — Data Watchpoint and Trace
CoreSight DWT 유닛은 4개의 데이터 워치포인트 + 사이클 카운터 + 예외 trace를 제공합니다.
// 사이클 카운터로 정확한 µs 측정DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;uint32_t start = DWT->CYCCNT;do_work();uint32_t cycles = DWT->CYCCNT - start;watch *my_var 같은 GDB 명령은 DWT의 워치포인트 1개를 소모합니다. 4개를 넘으면 GDB가 조용히 추가를 거부 — Ch 5 참고.
#자주 만나는 문제
| 증상 | 원인 / 해법 |
|---|---|
Error: unable to find a matching CMSIS-DAP device | 권한 — udev 룰 필요 (SUBSYSTEM=="usb", MODE="0666") |
init mode failed (unable to connect to the target) | 리셋/전원/SWCLK 미연결, 또는 워치독이 너무 빨리 리셋 |
load만 했는데 안 돌아감 | monitor reset halt 후 continue 안 했음 |
콜스택이 0x00000000로 빠짐 | 옵션 -fno-omit-frame-pointer 없음 또는 손상된 스택 |
value optimized out | -O0 또는 -Og로 재빌드 (Ch 11 참고) |
| 브레이크포인트 침묵 | flash 영역인데 HW 브레이크포인트 모두 소진 — 사용 중인 BP 확인 |
Cortex-M0 doesn't support hardware breakpoints | M0/M0+의 FPB는 4개만 — 더 줄여 쓰기 |
| RTT 깨진 문자 | 코어 클럭 설정 (tpiu config)이 실제와 다름 |
| LowPower 모드에서 SWD 끊김 | monitor cortex_m maskisr on 또는 sleep mode 비활성 |
| Erase 후 verify 실패 | OTP/RDP 락 — 이전 보호 해제 필요 |
#보안 — RDP / readout protection
대부분의 MCU는 플래시 보호가 있습니다. STM32의 RDP, ESP32의 secure boot, nRF의 APPROTECT 등.
- Level 0 — 디버거가 자유.
- Level 1 — 디버거 부착되면 플래시 자동 erase. SRAM/레지스터는 접근 가능.
- Level 2 — 디버거 완전 차단. 영구.
L1으로 락된 칩을 디버깅하려면 erase + L0 재설정. 그 과정에서 펌웨어 완전 손실. 사전에 모든 디버거 ↔ 칩 통신이 정상인지 확인하고 lock하는 게 안전합니다.
#OS 있는 임베디드 — 둘의 절충
라즈베리파이·NVIDIA Jetson 같은 리눅스가 도는 임베디드는 결국 gdbserver 시나리오로 회귀합니다. SSH 가능, gdbserver 설치 가능, 표준 라이브러리 존재 — 모두 평범한 원격 디버깅.
진짜 베어메탈 흐름이 필요한 건 OS 없는 MCU·DSP·FPGA softcore입니다.
다만 RTOS가 도는 환경(FreeRTOS, Zephyr, CMSIS-RTOS)에서는 GDB가 RTOS task 단위로 콜스택을 풀 수 있어야 합니다.
# openocd$_TARGETNAME configure -rtos FreeRTOS또는 J-Link.
$ JLinkGDBServer -device STM32... -rtos GDBServer/RTOSPlugin_FreeRTOS활성화되면 info threads가 OS 스레드 목록을 보여 줍니다. 베어메탈에 가까운 RTOS 디버깅에서 매우 중요한 기능. 안 잡으면 한 task의 스택만 보이고 나머지는 invisible.
#정리
- RSP가 표준 프로토콜 — 스텁이 누구든 GDB는 같다.
- OS 있는 원격 →
gdbserver/lldb-server+ SSH 터널. - 베어메탈 MCU → OpenOCD(오픈) 또는 J-Link(상용) → JTAG/SWD.
- JTAG 5핀 / SWD 2핀. Cortex-M은 SWD.
- ARM CoreSight = DAP + FPB(BP) + DWT(WP) + ITM(trace) + ETM(명령 trace).
- HW BP는 유한(M3/M4=6, M0/M0+=4). 다 쓰면 침묵.
- ELF의 LMA가 flash 주소,
load로 굽는다. - MAP은 메모리 진단(공간 부족·주소 역추적)의 1차 자료.
- RTT(Segger 표준) / ITM(ARM 표준) / semihosting / ETM — trace 네 갈래.
- RTOS 디버깅은
-rtos FreeRTOS옵션 필수. arm-none-eabi-gdb+ OpenOCD = 0원 풀스택, J-Link는 속도·안정성 우위.
#다음 장 예고
Ch 9 — Python 스크립팅. 반복 작업을 명령으로, 복잡한 구조를 pretty-printer로. GDB의 진짜 확장성은 여기서 열립니다.
#관련 항목
- Ch 7: core dump 분석
- Ch 9: Python 스크립팅
- Ch 12: DWARF — 콜스택 풀기의 정체
- Modern Embedded Recipes — JTAG 안 붙을 때 — 트러블슈팅 시각
- GDB RSP 명세
- ARMv7-M Architecture Reference Manual — CoreSight 전체
- OpenOCD 공식
- Segger J-Link Wiki
GDB and LLDB · 8 of 12
- 1GDB vs LLDB 분석 — 두 디버거의 설치·차이·선택 기준
- 2GDB·LLDB 기본 명령 — break·step·next·print 동작 비교
- 3디버거로 상태 들여다보기 — 변수·메모리·레지스터·STL 추적
- 4GDB·LLDB Backtrace와 프레임 이동 — Call Stack 분석
- 5Breakpoint와 Watchpoint 분석 — Conditional·Hardware·Catchpoint
- 6멀티스레드·멀티프로세스 디버깅 — Non-Stop·Scheduler-Locking·Fork
- 7Core Dump 분석 기법 — gcore·coredumpctl·디버거 활용
- 8GDB 원격 디버깅 — gdbserver·OpenOCD·J-Link 통합
- 9GDB·LLDB Python 스크립팅 — Pretty-Printer·Custom Command
- 10GDB·LLDB TUI와 프런트엔드 — gdb-dashboard·gef·pwndbg·VS Code
- 11GDB·LLDB 실전 팁 — STL·최적화 코드·시간 역행 디버깅
- 12DWARF 디버그 정보 — 디버거가 변수와 라인을 찾는 방식
관련 글
DWARF 디버그 정보 — 디버거가 변수와 라인을 찾는 방식
DWARF 표준, DIE / abbrev / line / location, expression VM, CFI, split-DWARF.
GDB·LLDB 실전 팁 — STL·최적화 코드·시간 역행 디버깅
STL pretty-printers, -O2 디버깅, .gdbinit 추천, rr time-travel. 시리즈 마무리.
GDB·LLDB TUI와 프런트엔드 — gdb-dashboard·gef·pwndbg·VS Code
TUI 모드, cgdb, gdb-dashboard, gef/pwndbg, VSCode, nvim-dap, DAP 프로토콜.