폴트 해독 — HardFault 에서 원인을 찾아내기
임베디드에서 가장 막막한 순간이 HardFault_Handler 에 멈춰 있는 것이다. 콜 스택도 없고, 어디서 왔는지도 모르고, 변수는 다 쓰레기다.
그런데 Cortex-M 은 폴트가 났을 때 상당한 증거를 남긴다. 읽는 법만 알면 "어느 주소에 접근하다 어느 명령에서 터졌나" 까지 알 수 있다.
폴트는 네 종류다
UsageFault 잘못된 명령·정렬 위반·0으로 나누기
BusFault 없는 주소 접근·버스 오류
MemManage MPU 보호 위반
HardFault ★ 위 셋을 처리할 수 없을 때 승격된 것
대부분의 HardFault 는 원래 다른 폴트였다. 해당 핸들러가 비활성이거나, 폴트 처리 중에 또 폴트가 나면 HardFault 로 올라간다. 그래서 "원래 무엇이었나" 를 알아내는 것이 첫 단계다.
// 개별 폴트 핸들러를 켜 두면 원인이 바로 나뉜다
SCB->SHCSR |= SCB_SHCSR_USGFAULTENA_Msk
| SCB_SHCSR_BUSFAULTENA_Msk
| SCB_SHCSR_MEMFAULTENA_Msk;
이걸 켜는 것만으로 진단이 훨씬 쉬워진다. UsageFault 로 잡히면 정렬·나눗셈 문제이고, BusFault 면 잘못된 포인터다.
증거 레지스터
CFSR 0xE000ED28 설정 가능 폴트 상태 — UFSR(31:16) + BFSR(15:8) + MMFSR(7:0)
HFSR 0xE000ED2C HardFault 상태
BFAR 0xE000ED38 BusFault 를 일으킨 주소
MMFAR 0xE000ED34 MemManage 를 일으킨 주소
CFSR 비트
MMFSR (0~7) MemManage
0 IACCVIOL 명령 접근 위반
1 DACCVIOL 데이터 접근 위반
3 MSTKERR 예외 진입 시 스택 저장 실패
4 MUNSTKERR 예외 복귀 시 스택 복원 실패
5 MLSPERR FPU 지연 저장 오류
7 MMARVALID ★ MMFAR 이 유효하다
BFSR (8~15) BusFault
8 IBUSERR 명령을 가져오다 버스 오류
9 PRECISERR ★ 정확한 데이터 버스 오류 — 원인 명령을 특정할 수 있다
10 IMPRECISERR 부정확한 오류 — 쓰기 버퍼 때문에 원인을 특정 못 한다
11 UNSTKERR 복귀 시 스택 복원 실패
12 STKERR ★ 진입 시 스택 저장 실패 → 스택 오버플로의 강력한 신호
13 LSPERR FPU 지연 저장 오류
15 BFARVALID ★ BFAR 이 유효하다
UFSR (16~31) UsageFault
16 UNDEFINSTR 정의되지 않은 명령 ★ 함수 포인터 손상·Thumb 비트 누락
17 INVSTATE 잘못된 실행 상태 ★ 주소 LSB 가 0 (Thumb 아님)
18 INVPC 잘못된 PC 로드
19 NOCP 코프로세서 없음 ★ FPU 를 안 켜고 float 를 썼다
24 UNALIGNED 정렬되지 않은 접근
25 DIVBYZERO 0 으로 나누기
HFSR 비트
1 VECTTBL 벡터 테이블을 읽다 실패
30 FORCED ★ 다른 폴트가 승격된 것 — 대부분 여기가 1 이다
31 DEBUGEVT 디버그 이벤트
FORCED=1 이면 CFSR 을 봐야 한다. 진짜 원인이 거기 있다.
스택 프레임 — 어디서 터졌나
예외가 나면 하드웨어가 8개 레지스터를 스택에 밀어 넣는다.
낮은 주소 높은 주소
[ R0 ][ R1 ][ R2 ][ R3 ][ R12 ][ LR ][ PC ][ xPSR ]
+0 +4 +8 +12 +16 +20 +24 +28
^
★ 이것이 터진 명령의 주소다
PC 가 폴트를 일으킨 명령의 주소이고, LR 은 그 함수를 부른 곳이다. 이 둘이면 위치를 특정할 수 있다.
어느 스택인가 — MSP 냐 PSP 냐
예외 진입 시 LR(EXC_RETURN) 의 비트 2
0 → MSP 를 쓰고 있었다
1 → PSP 를 쓰고 있었다 (RTOS 태스크에서 터진 경우)
RTOS 를 쓰면 태스크는 PSP 를 쓰므로, 잘못된 스택을 읽으면 엉뚱한 값이 나온다.
실전 핸들러 — 증거를 뽑아낸다
// 어셈블리로 스택 포인터를 골라 C 함수에 넘긴다
__attribute__((naked)) void HardFault_Handler(void) {
__asm volatile (
"tst lr, #4 \n" // EXC_RETURN 비트 2 를 검사
"ite eq \n"
"mrseq r0, msp \n" // 0 이면 MSP
"mrsne r0, psp \n" // 1 이면 PSP
"mov r1, lr \n"
"b hardfault_report\n"
);
}
typedef struct {
uint32_t r0, r1, r2, r3, r12, lr, pc, psr;
} Frame;
void hardfault_report(Frame *f, uint32_t exc_return) {
uint32_t cfsr = SCB->CFSR, hfsr = SCB->HFSR;
printf("\r\n===== HARD FAULT =====\r\n");
printf("PC = 0x%08lX <- 여기서 터졌다\r\n", f->pc);
printf("LR = 0x%08lX <- 이 함수가 불렀다\r\n", f->lr);
printf("PSR = 0x%08lX\r\n", f->psr);
printf("CFSR = 0x%08lX HFSR = 0x%08lX\r\n", cfsr, hfsr);
printf("stack= %s\r\n", (exc_return & 4) ? "PSP(태스크)" : "MSP");
if (hfsr & (1u << 30)) printf(" FORCED — 다른 폴트가 승격됐다\r\n");
if (hfsr & (1u << 1)) printf(" VECTTBL — 벡터 테이블 읽기 실패\r\n");
// UsageFault
if (cfsr & (1u << 16)) printf(" UNDEFINSTR — 정의되지 않은 명령 (함수 포인터 손상?)\r\n");
if (cfsr & (1u << 17)) printf(" INVSTATE — Thumb 비트 누락 (주소 LSB=0?)\r\n");
if (cfsr & (1u << 19)) printf(" NOCP — FPU 를 안 켜고 float 를 썼다\r\n");
if (cfsr & (1u << 24)) printf(" UNALIGNED — 정렬 위반\r\n");
if (cfsr & (1u << 25)) printf(" DIVBYZERO — 0 으로 나눴다\r\n");
// BusFault
if (cfsr & (1u << 8)) printf(" IBUSERR — 명령 fetch 실패\r\n");
if (cfsr & (1u << 9)) printf(" PRECISERR — 정확한 데이터 오류\r\n");
if (cfsr & (1u << 10)) printf(" IMPRECISERR — 부정확 (쓰기 버퍼 지연)\r\n");
if (cfsr & (1u << 12)) printf(" STKERR — 스택 저장 실패 → 스택 오버플로 의심\r\n");
if (cfsr & (1u << 15)) printf(" BFAR = 0x%08lX <- 이 주소가 문제다\r\n", SCB->BFAR);
// MemManage
if (cfsr & (1u << 1)) printf(" DACCVIOL — MPU 데이터 접근 위반\r\n");
if (cfsr & (1u << 7)) printf(" MMFAR = 0x%08lX\r\n", SCB->MMFAR);
while (1) { }
}
__attribute__((naked)) 가 중요하다. 컴파일러가 프롤로그를 넣으면 스택 포인터가 바뀌어 프레임 위치가 어긋난다.
PC 로 소스 위치를 찾는다
# PC 값이 0x08001A2E 였다면
arm-none-eabi-addr2line -e firmware.elf -f -C 0x08001A2E
# process_frame
# /home/dev/src/dsp.c:142 ← 정확한 줄이 나온다
# 그 주변 어셈블리를 본다
arm-none-eabi-objdump -d --start-address=0x08001A20 --stop-address=0x08001A40 firmware.elf
addr2line 이 폴트 진단의 핵심 도구다. PC 하나만 있어도 파일·함수·줄 번호가 나온다. 디버그 정보(-g)를 켜고 빌드해야 하지만, 그것은 ELF 에만 들어가고 굽는 바이너리 크기에는 영향이 없다.
진단 순서를 한 장으로
폴트가 터졌을 때 무엇부터 보는지가 정해져 있다. 아래 순서대로만 내려가면 원인이 좁혀진다.
왜 이 순서인가 — HardFault 는 자기 원인을 안 갖는 경우가 많다. UsageFault·BusFault· MemManage 가 비활성이거나 우선순위 때문에 처리 못 되면 HardFault 로 승격(escalation) 되는데, 이때
HFSR.FORCED비트가 선다. 그래서 HFSR 로 "내 폴트인가 남의 폴트인가"를 먼저 가르고, 남의 것이면 CFSR 로 내려가는 것이다.개발 중에는 하위 폴트를 미리 켜 두면(
SHCSR의 USGFAULTENA·BUSFAULTENA·MEMFAULTENA) 승격 없이 각자의 핸들러로 잡혀 원인이 훨씬 선명해진다.
흔한 원인별 대응
| 증거 | 원인 |
|---|---|
INVSTATE | 함수 포인터의 LSB 가 0 — Thumb 비트 누락 |
UNDEFINSTR | 함수 포인터가 데이터 영역을 가리킨다. 배열 오버런으로 손상 |
NOCP | FPU 를 켜지 않고 float 를 썼다 |
STKERR + BFAR 이 SRAM 하단 | 스택 오버플로 |
PRECISERR + BFAR 이 0 근처 | 널 포인터 역참조 |
PRECISERR + BFAR 이 존재하지 않는 영역 | 초기화 안 된 포인터 |
IMPRECISERR | 쓰기 버퍼 때문에 원인 명령을 못 찾는다 → 아래 참고 |
UNALIGNED | packed 구조체 멤버의 주소를 넘겼다 |
FPU 를 켜는 것을 잊었을 때
// startup 이나 SystemInit 에서
SCB->CPACR |= (0xF << 20); // CP10·CP11 full access
__DSB(); __ISB();
CONTROL 이 아니라 CPACR 이다. 이걸 안 켜고 float 연산을 하면 NOCP 로 터진다.
IMPRECISERR — 원인을 못 찾는 경우
// 쓰기 버퍼가 있어 str 명령이 지나간 뒤에 폴트가 보고된다
// → PC 가 실제 원인 명령을 가리키지 않는다
// 임시로 버퍼를 끄면 정확해진다 (느려지지만 진단용)
SCnSCB->ACTLR |= SCnSCB_ACTLR_DISDEFWBUF_Msk;
진단 중에만 켜는 옵션이다. 이걸로 PRECISERR 로 바뀌면 PC 를 신뢰할 수 있다.
스택 오버플로를 미리 잡는다
// ① 링커에 경계 심볼을 두고 카나리를 심는다
extern uint32_t _sstack;
#define CANARY 0xDEADBEEFu
void stack_guard_init(void) {
volatile uint32_t *p = &_sstack;
for (int i = 0; i < 16; i++) p[i] = CANARY; // 스택 '바닥' 에 표식
}
bool stack_intact(void) {
volatile uint32_t *p = &_sstack;
for (int i = 0; i < 16; i++) if (p[i] != CANARY) return false;
return true;
}
// ② MPU 로 스택 바닥에 접근 금지 영역을 만든다 — 넘치는 순간 MemManage 가 난다
// 카나리보다 확실하다. 덮어쓴 뒤에 아는 게 아니라 덮어쓰려는 순간 잡는다
②가 훨씬 낫다. 카나리는 "이미 망가진 뒤에" 알려 주지만 MPU 는 그 순간 잡는다. 다만 MPU 설정이 번거롭고 영역 정렬 제약이 있다.
폴트 기록을 남긴다 — 현장에서 터졌을 때
디버거가 없는 현장에서 리셋된 경우, 다음 부팅에서 원인을 알 수 있어야 한다.
// 리셋에도 지워지지 않는 영역에 기록한다 (백업 SRAM 또는 지정 섹션)
__attribute__((section(".noinit"))) volatile struct {
uint32_t magic, pc, lr, cfsr, hfsr, bfar;
uint32_t count;
} fault_log;
void hardfault_report(Frame *f, uint32_t exc) {
fault_log.magic = 0xFA017CAF;
fault_log.pc = f->pc; fault_log.lr = f->lr;
fault_log.cfsr = SCB->CFSR; fault_log.hfsr = SCB->HFSR;
fault_log.bfar = SCB->BFAR;
fault_log.count++;
NVIC_SystemReset(); // 기록하고 재부팅한다
}
// 부팅 시 확인
void check_last_fault(void) {
if (fault_log.magic == 0xFA017CAF) {
printf("이전 폴트: PC=0x%08lX CFSR=0x%08lX (총 %lu회)\r\n",
fault_log.pc, fault_log.cfsr, fault_log.count);
fault_log.magic = 0;
}
}
.noinit 섹션은 startup 이 0 으로 지우지 않는다. 그래서 리셋을 넘어 값이 남는다. 링커 스크립트에 이 섹션을 두고 .bss 초기화 대상에서 빼야 한다.
제품에서는 폴트 시 무한 루프가 아니라 리셋이 맞다. 멈춰 있으면 워치독이 어차피 리셋하는데, 그때는 기록할 기회를 잃는다. 기록하고 스스로 리셋하는 편이 낫다.
한눈에 정리
- 대부분의 HardFault 는 승격된 것 —
HFSR.FORCED=1이면CFSR에 진짜 원인이 있다 - 개별 폴트 핸들러를 켜면(
SHCSR) 원인이 바로 나뉘어 진단이 쉬워진다 - 하드웨어가 8개 레지스터를 스택에 남긴다 —
+24의PC가 터진 명령,+20의LR이 호출자 - EXC_RETURN 비트 2 로 MSP·PSP 를 골라야 한다. RTOS 태스크는 PSP
- 핸들러는
naked여야 프레임 위치가 안 어긋난다 addr2line로 PC → 파일·줄 을 바로 찾는다.-g는 ELF 만 커지고 굽는 크기엔 영향 없다STKERR+ BFAR 이 SRAM 하단 = 스택 오버플로,PRECISERR+ BFAR 0 근처 = 널 포인터NOCP는 FPU 미설정 —CPACR이지CONTROL이 아니다IMPRECISERR는 쓰기 버퍼 탓 —DISDEFWBUF로 진단 중에만 정확하게 만든다- 스택 오버플로는 MPU 로 그 순간 잡는다 — 카나리는 이미 망가진 뒤에 안다
- 제품에서는 폴트를
.noinit에 기록하고 리셋 — 멈춰 있으면 기록할 기회를 잃는다
꼬리질문 대비
출처 — Arm, Cortex-M4 Devices Generic User Guide(DUI 0553) §4.3 SCB(CFSR·HFSR·BFAR·MMFAR·SHCSR·CPACR) · Keil, AN209 Using Cortex-M3/M4/M7 Fault Exceptions · Memfault, How to debug a HardFault on an ARM Cortex-M MCU — 스택 프레임·EXC_RETURN · Arm, Cortex-M4 Technical Reference Manual(DDI0439) — ACTLR·쓰기 버퍼