JTAG/SWD 디버깅
JTAG과 SWD는 마이크로컨트롤러를 외부에서 디버깅하는 인터페이스다. printf로 해결할 수 없는 문제 — 크래시, 메모리 corruption, 타이밍 버그 — 를 해결하는 도구다.
JTAG vs SWD
| JTAG | SWD | |
|---|---|---|
| 핀 수 | 4 (TCK, TMS, TDI, TDO) | 2 (SWCLK, SWDIO) |
| 트레이스 | X | SWO 핀으로 ITM 출력 |
| 데이지 체인 | O | X |
| 바운더리 스캔 | O (BGA 납땜 검사) | X |
| 지원 범위 | ARM, MIPS, RISC-V, FPGA | ARM Cortex만 |
ARM Cortex MCU는 SWJ-DP를 탑재해서 같은 핀(PA13/PA14)에서 JTAG과 SWD를 전환할 수 있다. ARM Cortex만 디버깅하면 SWD가 낫다 — 핀이 적고, SWO 트레이스를 쓸 수 있다.
printf로 안 되는 상황
1. HardFault — 크래시 원인 분석
코어가 HardFault에 빠지면 printf가 실행되지 않는다. SWD로 정지시키고 fault 레지스터를 읽어야 한다.
GDB로 분석하는 순서:
(gdb) target extended-remote :3333
(gdb) monitor halt
# 1. 어디서 죽었는가
(gdb) bt # 백트레이스
(gdb) info reg pc lr # PC = 죽은 위치, LR = 호출자
# 2. 왜 죽었는가 — fault 상태 레지스터
(gdb) x/xw 0xE000ED28 # CFSR (Configurable Fault Status)
(gdb) x/xw 0xE000ED38 # BFAR (Bus Fault Address)
(gdb) x/xw 0xE000ED34 # MMFAR (MemManage Fault Address)
CFSR 해석:
| 비트 | 이름 | 의미 |
|---|---|---|
| [0] | IACCVIOL | 실행 불가 영역에서 명령어 fetch |
| [1] | DACCVIOL | 접근 불가 영역 읽기/쓰기 |
| [8] | IBUSERR | 명령어 fetch 중 버스 에러 |
| [9] | PRECISERR | 데이터 접근 버스 에러 (BFAR 유효) |
| [15] | BFARVALID | BFAR에 유효한 주소가 있음 |
| [16] | UNDEFINSTR | 정의되지 않은 명령어 |
| [17] | INVSTATE | 잘못된 실행 상태 (ARM↔Thumb 전환 오류) |
| [18] | INVPC | 잘못된 PC 로드 (EXC_RETURN 오류) |
| [24] | UNALIGNED | 비정렬 메모리 접근 |
| [25] | DIVBYZERO | 0으로 나눗셈 |
흔한 원인과 CFSR 패턴:
CFSR = 0x00000100 (IBUSERR)
→ 함수 포인터가 잘못된 주소를 가리킴. 콜백 테이블 확인
CFSR = 0x00008200 (PRECISERR + BFARVALID)
→ BFAR의 주소를 역참조하려다 실패. NULL 포인터 또는 해제된 메모리
CFSR = 0x00010000 (UNDEFINSTR)
→ PC가 코드가 아닌 영역을 가리킴. 스택 오버플로로 리턴 주소 파괴
CFSR = 0x00020000 (INVSTATE)
→ BX/BLX에서 Thumb 비트(bit 0)가 0. 함수 포인터 캐스팅 오류
자동 분석 핸들러:
void HardFault_Handler(void) {
__asm volatile (
"tst lr, #4 \n" /* EXC_RETURN bit 2 확인 */
"ite eq \n"
"mrseq r0, msp \n" /* MSP 사용 중이면 MSP */
"mrsne r0, psp \n" /* PSP 사용 중이면 PSP */
"b hard_fault_dump \n"
);
}
void hard_fault_dump(uint32_t *stack) {
volatile uint32_t r0 = stack[0];
volatile uint32_t r1 = stack[1];
volatile uint32_t r2 = stack[2];
volatile uint32_t r3 = stack[3];
volatile uint32_t r12 = stack[4];
volatile uint32_t lr = stack[5]; /* 호출자 */
volatile uint32_t pc = stack[6]; /* 죽은 위치 */
volatile uint32_t psr = stack[7];
volatile uint32_t cfsr = SCB->CFSR;
volatile uint32_t bfar = SCB->BFAR;
volatile uint32_t mmfar = SCB->MMFAR;
__BKPT(0); /* SWD 연결 시 여기서 정지 → 변수 검사 */
while (1);
}
SWD를 연결하면 __BKPT(0)에서 멈추고, pc, lr, cfsr을 바로 확인할 수 있다.
2. 메모리 Corruption — 워치포인트
“이 변수가 언제, 어디서 바뀌는가”는 printf로 추적할 수 없다. DWT 워치포인트를 설정하면 해당 주소에 쓰기가 발생하는 순간 코어가 멈춘다.
GDB:
(gdb) watch my_variable # 쓰기 감지
(gdb) rwatch my_variable # 읽기 감지
(gdb) awatch my_variable # 읽기+쓰기
(gdb) watch *(uint32_t *)0x20000100 # 주소 직접 지정
(gdb) continue
# 트리거되면:
(gdb) bt # 누가 썼는가
(gdb) info locals
OpenOCD 텔넷:
> halt
> wp 0x20000100 4 w # 주소 0x20000100, 4바이트, 쓰기 감지
> resume
# 트리거되면 자동 정지
> reg pc # 어디서 썼는가
> mdw 0x20000100 1 # 현재 값 확인
실전 사례:
- 글로벌 변수가 간헐적으로 0으로 초기화됨 → 워치포인트로 추적 → 다른 태스크의
memset이 버퍼 범위를 초과 - 링크드 리스트 노드가 깨짐 →
next포인터에 워치포인트 → ISR에서 보호 없이 접근 - 스택 오버플로 → 스택 끝 주소에 워치포인트 → 특정 함수의 로컬 배열이 과다
Cortex-M3/M4는 워치포인트 비교기가 4개다. 감시 대상을 좁혀야 한다.
3. 타이밍 버그 — printf를 넣으면 사라지는 버그
printf는 UART 전송 시간(115200bps에서 1문자 ≈ 87μs)이 소요되므로 타이밍이 바뀐다. printf를 넣으면 버그가 사라지고 빼면 다시 나타나는 heisenbug가 된다.
해결 1: ITM 트레이스 (SWO)
코드 수정 최소화, 타이밍 영향 거의 없음 (레지스터 쓰기 한 번):
// 설정 (초기화 시 1회)
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
TPI->ACPR = (SystemCoreClock / 2000000) - 1; // 2Mbps SWO
TPI->SPPR = 2; // NRZ 모드
TPI->FFCR = 0x100;
ITM->LAR = 0xC5ACCE55; // 잠금 해제
ITM->TCR = 0x00010005;
ITM->TER = 0x00000001; // 채널 0
// 사용
static inline void itm_putc(char c) {
while (!(ITM->PORT[0].u32 & 1));
ITM->PORT[0].u8 = (uint8_t)c;
}
// printf 리다이렉트
int _write(int file, char *ptr, int len) {
for (int i = 0; i < len; i++) itm_putc(ptr[i]);
return len;
}
Cortex-M0/M0+에는 ITM이 없다. 이 경우 해결 2를 사용한다.
해결 2: 링 버퍼 + 나중에 읽기
타이밍에 영향을 주지 않고 이벤트를 기록하고, 나중에 SWD로 메모리를 읽는다:
#define TRACE_SIZE 256
struct trace_entry {
uint32_t timestamp; // DWT->CYCCNT
uint16_t event_id;
uint16_t value;
} trace_buf[TRACE_SIZE];
volatile uint32_t trace_idx;
static inline void trace_log(uint16_t id, uint16_t val) {
uint32_t i = trace_idx % TRACE_SIZE;
trace_buf[i].timestamp = DWT->CYCCNT;
trace_buf[i].event_id = id;
trace_buf[i].value = val;
trace_idx++;
}
// 사용
trace_log(1, adc_value); // ISR 안에서도 안전
trace_log(2, motor_state);
# GDB에서 버퍼 덤프
(gdb) print trace_idx
(gdb) print trace_buf[0]
(gdb) print trace_buf[1]
# 또는
(gdb) x/8xw &trace_buf[0] # 메모리 직접 읽기
DWT 사이클 카운터 활성화: DWT->CTRL |= 1; → 클럭 사이클 단위 타임스탬프.
해결 3: J-Link RTT
UART 대신 SWD 메모리 접근으로 양방향 로깅. 타이밍 영향 없음, 속도 수 Mbps:
#include "SEGGER_RTT.h"
SEGGER_RTT_printf(0, "CAN RX: id=0x%03X dlc=%d\n", id, dlc);
J-Link 전용이지만 printf 디버깅과 거의 같은 사용성에 타이밍 부작용이 없다.
4. 페리페럴 레지스터 확인
CAN 인터럽트가 안 들어올 때, UART 데이터가 안 나갈 때 — 원인을 printf로 알 수 없다. SWD로 레지스터를 직접 읽는다.
CAN 디버깅 예시 (STM32F4):
(gdb) # CAN1 base = 0x40006400
# 에러 상태
(gdb) x/xw 0x40006418 # CAN_ESR: 에러 카운터, 버스 상태
# bit[6:4]: LEC (Last Error Code)
# 001 = Stuff Error, 010 = Form Error, 011 = ACK Error
# 인터럽트 설정 확인
(gdb) x/xw 0x40006414 # CAN_IER: 어떤 인터럽트가 활성화되었는가
# FIFO 상태
(gdb) x/xw 0x4000640C # CAN_RF0R: FIFO0 메시지 수
# bit[1:0]: FMP0 = 대기 중 메시지 수
# 필터 설정 확인
(gdb) x/xw 0x40006600 # CAN_FMR: 필터 마스터 레지스터
UART 디버깅 예시:
(gdb) # USART1 base = 0x40011000 (STM32F4)
(gdb) x/xw 0x40011000 # USART_SR: 상태 레지스터
# bit 7: TXE (TX empty), bit 5: RXNE (RX not empty)
# bit 3: ORE (Overrun Error) ← 수신 버퍼 미처리로 데이터 유실
OpenOCD에서는 mdw로 같은 작업:
> mdw 0x40006418 1 # CAN_ESR 읽기
> mdw 0x40011000 1 # USART_SR 읽기
5. 보드 Bring-up / 플래시 프로그래밍
새 보드에 처음 펌웨어를 넣을 때 SWD가 유일한 방법이다.
# OpenOCD + ST-LINK
openocd -f interface/stlink.cfg \
-c "transport select hla_swd" \
-f target/stm32f4x.cfg \
-c "program firmware.elf verify reset exit"
# 또는 분리 명령
openocd -f board/stm32f4discovery.cfg \
-c "init" \
-c "reset halt" \
-c "flash write_image erase firmware.bin 0x08000000" \
-c "verify_image firmware.bin 0x08000000" \
-c "reset run" \
-c "shutdown"
GDB에서 플래시 + 디버깅 한 번에:
# 터미널 1
openocd -f board/stm32f4discovery.cfg
# 터미널 2
arm-none-eabi-gdb firmware.elf
(gdb) target extended-remote :3333
(gdb) monitor reset halt
(gdb) load # 플래시 프로그래밍
(gdb) break main
(gdb) continue # main에서 정지
(gdb) next # 한 줄씩 실행
(gdb) print variable # 변수 검사
(gdb) info reg # 레지스터 전체
monitor 접두어로 GDB 안에서 OpenOCD 명령어를 실행할 수 있다.
6. 부트로더 디버깅
부트로더는 UART나 USB가 초기화되기 전에 실행되므로 출력 수단이 없다.
(gdb) monitor reset halt # 리셋 벡터에서 정지
(gdb) break *0x08000000 # 부트로더 진입점
(gdb) continue
# 부트로더 → 앱 점프 시점 확인
(gdb) break *0x08008000 # 앱 시작 주소
(gdb) continue
# 점프 직전 상태 확인
(gdb) info reg sp pc # SP와 PC가 올바른가
(gdb) x/xw 0x08008000 # 앱 벡터 테이블: SP 초기값
(gdb) x/xw 0x08008004 # 앱 벡터 테이블: Reset_Handler
앱 점프가 실패하는 흔한 원인:
0x08008004의 Reset_Handler 주소의 bit 0이 0 → Thumb 비트 누락 → INVSTATE fault- 앱 영역이 erased (0xFF) → SP가
0xFFFFFFFF→ 즉시 크래시 - 부트로더가 인터럽트를 비활성화하지 않고 점프 → 앱의 벡터 테이블이 아직 설정 안 됨
7. 벽돌 복구
펌웨어가 즉시 크래시하거나, SWD 핀을 GPIO로 재설정한 경우.
방법 1: NRST 잡고 연결
# NRST를 LOW로 유지한 상태에서 OpenOCD 시작
openocd -f interface/stlink.cfg \
-c "transport select hla_swd" \
-f target/stm32f4x.cfg \
-c "reset_config srst_only"
# 연결되면 NRST 해제 → 리셋 상태에서 바로 halt
> reset halt
> flash erase_sector 0 0 last # 전체 플래시 삭제
> flash write_image firmware.bin 0x08000000
> reset run
방법 2: BOOT0으로 시스템 부트로더 진입
BOOT0 핀을 HIGH로 잡고 리셋하면 내장 ROM 부트로더로 진입한다. SWD 핀이 정상 복원된다.
# BOOT0=HIGH → 리셋 → SWD 연결 가능
# 또는 STM32CubeProgrammer / stm32flash로 UART 플래시
stm32flash -w firmware.bin -v -g 0x08000000 /dev/ttyUSB0
방법 3: connect_under_reset (OpenOCD)
> reset_config srst_only srst_nogate connect_assert_srst
리셋을 잡은 상태에서 SWD 연결을 시도한다. 펌웨어가 SWD 핀을 건드리기 전에 연결된다.
디버그 프로브
| J-Link | ST-LINK | CMSIS-DAP | |
|---|---|---|---|
| 속도 | 최대 50 MHz | V3 고속, V2 저속 | 구현체마다 다름 |
| 플래시 BP | 무제한 | X | X |
| RTT | O | X | X |
| 가격 | $60~$1000+ | $3~$35 | $4~ |
- J-Link: 플래시 브레이크포인트 무제한 + RTT 로깅. 기능이 가장 많다
- ST-LINK: Nucleo/Discovery 보드에 내장. STM32 개발이면 추가 구매 불필요
- CMSIS-DAP: RPi Pico에 DAPLink 펌웨어를 올리면 $4짜리 프로브
SWD 핀 연결
| 프로브 핀 | 시그널 | STM32 핀 |
|---|---|---|
| VTref | 3.3V (감지용, 전원 공급 아님) | VDD |
| SWDIO | 데이터 | PA13 |
| SWCLK | 클럭 | PA14 |
| SWO | 트레이스 (선택) | PB3 |
| NRST | 리셋 (선택) | NRST |
| GND | 그라운드 | GND |
브레이크포인트 종류
| 하드웨어 (FPB) | 소프트웨어 | |
|---|---|---|
| 원리 | 주소 비교기가 PC 매칭 | 명령어를 BKPT로 교체 |
| 플래시 코드 | O | X (J-Link은 재프로그래밍으로 우회) |
| 개수 | M0: 2~4, M3/M4: 6, M7: 8 | 무제한 (RAM만) |
| GDB 명령 | hbreak | break |
메모
- HardFault에서 스택이 깨진 경우
bt가 엉뚱한 주소를 보여주면 스택 오버플로 가능성이 높다info reg sp로 SP가 스택 영역 내인지 확인. 링커 스크립트의_estack과 비교- FreeRTOS 환경이면
uxTaskGetStackHighWaterMark()로 여유 공간을 미리 모니터링
- Release 빌드(-O2)에서 디버깅
- 변수가 최적화로 제거되면 GDB에서
<optimized out>표시 volatile로 선언하거나,-Og(디버그 최적화) 빌드를 별도로 준비- 하드웨어 브레이크포인트와 워치포인트는 최적화 수준에 관계없이 동작
- 변수가 최적화로 제거되면 GDB에서
- Semihosting
BKPT 0xAB으로 CPU를 정지시키고 호스트가 파일 I/O 등을 대행하는 방식- 호출마다 수 ms 소요. 타이밍이 중요한 코드에서는 사용 불가
- OpenOCD:
arm semihosting enable, J-Link:monitor semihosting enable
- STM32 RDP (Read-out Protection)
- Level 0: 보호 없음. Level 1: 디버그 읽기 차단 (해제 시 mass erase). Level 2: 영구 차단
- Level 2는 되돌릴 수 없다. 프로덕션 양산 직전에만 설정한다
- 워치포인트 정렬 제약
- 감시 영역은 크기에 맞게 자연 정렬되어야 한다. 4바이트 워치는 4바이트 정렬 주소에서만 동작
- Cortex-M3/M4는 비교기 4개. M7은 최대 4개이지만 범위 매칭이 가능해서 더 유연