내용 요약
현상: MCU 구동 중 시스템이 멈추거나 HardFault_Handler 무한 루프에 빠져 Lock-up 되는 현상.
원인: NULL 포인터 참조, 잘못된 메모리 주소 접근, 데이터 오정렬 등으로 레지스터 및 콜 스택(Call Stack) 데이터 유실되는 문제.
해결: 리셋하지 않고 OpenOCD의 no_reset 또는 halt 옵션으로 타깃 접속 후, GDB를 Attach하여 SP(Stack Pointer), PC(Program Counter) 레지스터 추적.
펌웨어 크래시 및 하드폴트 무한 루프 발생 증상 (Firmware Crash & HardFault_Handler Lock-up Symptoms)
임베디드 시스템 필드 테스트나 장기 신뢰성 시험 중, MCU가 아무런 응답을 하지 않고 죽어버리는 현상이 발생하기도 합니다. 디버거가 연결되지 않은 독립 구동 상태에서 이 이슈가 발생하면 하드웨어 외관상으로는 단순 먹통 상태로 보이지만, 내부적으로는 코어가 HardFault_Handler 혹은 UsageFault_Handler 등의 예외 벡터에 갇혀 무한 루프를 돌고 있는 경우가 대부분입니다.
해외 포럼이나 Google에 글로벌 개발자들이 주로 검색하는 Target is halted, BusFault, Memory Management Fault, 또는 Dumping Core Registers와 같은 상황이 이에 해당합니다. 이때 원인을 파악하고자 단순히 디버거를 연결하고 보드를 재부팅(System Reset)하면, 메모리와 레지스터에 남아있던 결정적 단서(Crash Context)가 모두 초기화되어 간헐적으로 발생하는 버그의 원인을 찾을 수 없게 됩니다.
리셋 없는 GDB 어태치 및 코어 레지스터 역추적의 근본적인 원인 분석 (Why Post-Mortem Debugging via GDB Attach Matters)
보드가 죽은 시점의 상태를 그대로 유지한 채 디버거를 연결해야 하는 이유는 ARM Cortex-M 코어의 하드웨어 예외 처리 메커니즘 때문입니다. 크래시가 발생하는 순간 코어는 현재 실행 중이던 컨텍스트(PC, LR, R0-R3, R12, xPSR)를 현재 활성화된 스택(MSP 또는 PSP)에 하드웨어적으로 푸시(Push)합니다.
그 후 시스템 제어 블록(SCB, System Control Block) 내의 HFSR(HardFault Status Register) 및 CFSR(Configurable Fault Status Register) 레지스터에 상세 원인 비트를 셋(Set)합니다.
- BFAR (Bus Fault Address Register): 버스 에러 발생 시 잘못된 접근을 시도한 메모리 주소를 저장.
- MMFAR (MemManage Fault Address Register): MPU 위반 시 해당 메모리 주소를 기록.
만약 디버깅 세션을 시작할 때 타깃 리셋 신호(NRST)를 트리거하면, 이러한 SCB 레지스터 값들과 SRAM의 스택 포인터가 가리키던 크래시 컨텍스트가 모두 지워집니다. 따라서 OpenOCD의 설정에서 하드웨어 리셋 핀 제어를 배제하고, running 상태의 코어에 그대로 halt 명령을 인젝션하여 PC(Program Counter)와 SP(Stack Pointer)를 확보하는 'Non-intrusive Attach' 기법이 필수적입니다.
메모리 오염 및 하드폴트를 유발하는 잘못된 C 코드 예시 (Bad Case)
아래 코드는 유효하지 않은 메모리 영역에 접근하거나 널 포인터를 참조하여 시스템을 즉시 HardFault로 빠뜨리는 전형적인 불량 예시입니다.
#include <stdint.h>
#include <stdlib.h>
void trigger_system_crash(void) {
// Bad: Accessing an invalid/reserved memory address directly
volatile uint32_t *invalid_ptr = (volatile uint32_t *)0xFFFFFFFF;
*invalid_ptr = 0xDEADBEEF; // This triggers a severe BusFault/HardFault immediately
// Bad: Null pointer dereference after failing to check allocation
uint8_t *buffer = NULL;
for (int i = 0; i < 10; i++) {
buffer[i] = i; // Will crash due to MemManageFault or Null Pointer Dereference
}
}
int main(void) {
// Hardware initialization routines...
trigger_system_crash();
while (1) {
// Main loop (unreachable due to crash)
}
}
크래시 분석을 돕는 방어적 예외 핸들러 및 안전한 C 코드 (Good Case)
하드폴트가 발생했을 때 단순히 무한 루프만 돌지 않고, 레지스터 정보를 스택에서 추출하여 디버거 유저가 쉽게 식별할 수 있도록 유도하는 방어적 코드 구조입니다.
#include <stdint.h>
// Prototype for the detailed C handler called from assembly
void hard_fault_handler_c(uint32_t *stack_frame);
/**
* @brief Enhanced HardFault Handler in Assembly to extract Stack Pointer
*/
__attribute__((naked)) void HardFault_Handler(void) {
__asm volatile (
"tst lr, #4\n" // Check EXC_RETURN bit 2 to determine used stack
"ite eq\n"
"mrseq r0, msp\n" // If 0, Main Stack Pointer (MSP) was used
"mrsne r0, psp\n" // If 1, Process Stack Pointer (PSP) was used
"ldr r1, [r0, #24]\n" // Load saved PC (Program Counter) from stack frame
"b hard_fault_handler_c\n" // Jump to C handler with stack frame pointer in R0
);
}
/**
* @brief C implementation for logging/analyzing crashed context
*/
void hard_fault_handler_c(uint32_t *stack_frame) {
// Registers packed onto the stack during exception entry
volatile uint32_t r0 = stack_frame[0];
volatile uint32_t r1 = stack_frame[1];
volatile uint32_t r2 = stack_frame[2];
volatile uint32_t r3 = stack_frame[3];
volatile uint32_t r12 = stack_frame[4];
volatile uint32_t lr = stack_frame[5]; // Link Register (Return Address)
volatile uint32_t pc = stack_frame[6]; // Program Counter (Address that caused fault)
volatile uint32_t psr = stack_frame[7]; // Program Status Register
// Fault Status Registers in System Control Block (SCB)
volatile uint32_t *cfsr = (volatile uint32_t *)0xE000ED28;
volatile uint32_t *hfsr = (volatile uint32_t *)0xE000ED2C;
volatile uint32_t *bfar = (volatile uint32_t *)0xE000ED38;
// Hook for OpenOCD/GDB attach: Breakpoint or infinite loop here
while (1) {
__asm("bkpt #0"); // Force hardware breakpoint for active debuggers
}
}
핵심 수정 포인트 (Key Implementation Details)
- Naked Assembly Wrapper: 예외 발생 시 코어가 자동으로 저장한 스택 프레임 위치(MSP/PSP)를 왜곡 없이 그대로 R0 레지스터에 복사하여 C 함수 인자로 넘겨줍니다.
- Context Preservation: 크래시 시점의 PC(Program Counter)와 LR(Link Register)을 변수 명시화하여 하드웨어 변경 없이 GDB 어태치만으로 문제 코드를 즉시 검출할 수 있게 합니다.
- Inline Breakpoint (bkpt #0): 디버거가 연동되어 있을 경우 자동으로 타깃을 그 자리에 멈추게(Halt) 만듭니다.
엔지니어를 위한 OpenOCD/GDB 실무 트러블슈팅 가이드 (Debugging Tips)
죽어있는 보드에 리셋 없이 GDB를 연결하여 원인을 찾는 실무 단계별 파이프라인입니다.
- OpenOCD 리셋 옵션 비활성화 실행: 보드를 리셋하지 않고 연결하려면 OpenOCD 실행 시 reset_config none을 명시하거나 타깃 스크립트 로드 후 바로 halt를 걸어야 합니다.
openocd -f interface/stlink.cfg -c "transport select hla_swd" -f target/stm32f4x.cfg -c "reset_config none" -c "init" -c "halt"
- GDB Target Attach 및 ELF 심볼 로드: GDB를 켜고 이미 죽어있는 타깃 포트(기본 3333)에 접속한 뒤, 빌드 시 생성된 펌웨어 이미지(.elf)의 심볼 테이블을 매핑하여 어셈블리가 아닌 C 소스 레벨로 추적합니다.
(gdb) target remote localhost:3333
(gdb) file your_project_output.elf
(gdb) bt
#0 hard_fault_handler_c (stack_frame=0x2000abc0) at src/main.c:30
#1 0x08001234 in trigger_system_crash () at src/main.c:8
bt (Backtrace) 명령어를 입력하면 크래시를 유발한 함수(trigger_system_crash) 위치로 역산이 완료됩니다.
- SCB 레지스터 직접 조회를 통한 원인 교차 검증: GDB 프롬프트에서 하드웨어 결함 레지스터 주소를 직접 덤프하여 컴파일러 최적화(Compiler Optimization)로 스택이 깨진 경우에도 에러 실체를 규명할 수 있습니다.
(gdb) x/1xw 0xE000ED28 <-- Read CFSR (Configurable Fault Status Register)
(gdb) x/1xw 0xE000ED38 <-- Read BFAR (Bus Fault Address Register)'Advanced Debugging & Observability' 카테고리의 다른 글
| eBPF 기반 리눅스 커널 메모리 누수(Memory Leak) 및 좀비 프로세스(Zombie Process) 추적 가이드 (1) | 2026.07.03 |
|---|---|
| eBPF 기반 파일 I/O 및 네트워크 지연(Latency) 추적기 구현 가이드 (bcc / bpftrace 실습) (0) | 2026.07.02 |
| Linux eBPF 커널 트레이싱 가이드: ftrace 및 perf 대비 오버헤드와 패러다임 비교 (0) | 2026.07.01 |
| 코어 덤프 분석 및 레지스터 스택 프레임 복원 방법: ARM Cortex-M 하드폴트 역추적 가이드 (0) | 2026.06.30 |
| JTAG과 SWD의 동작 원리: TAP 스테이트 머신과 ARM CoreSight 디버그 아키텍처 분석 (0) | 2026.06.28 |