임베디드 학습 노트 목차

폴트 해독 — 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함수 포인터가 데이터 영역을 가리킨다. 배열 오버런으로 손상
NOCPFPU 를 켜지 않고 float 를 썼다
STKERR + BFAR 이 SRAM 하단스택 오버플로
PRECISERR + BFAR 이 0 근처널 포인터 역참조
PRECISERR + BFAR 이 존재하지 않는 영역초기화 안 된 포인터
IMPRECISERR쓰기 버퍼 때문에 원인 명령을 못 찾는다 → 아래 참고
UNALIGNEDpacked 구조체 멤버의 주소를 넘겼다

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개 레지스터를 스택에 남긴다+24PC 가 터진 명령, +20LR 이 호출자
  • 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 에 기록하고 리셋 — 멈춰 있으면 기록할 기회를 잃는다

꼬리질문 대비

  • "HardFault 에서 가장 먼저 볼 레지스터는?" → HFSR. FORCED 가 1 이면 하위 폴트가 승격된 것이라 CFSR 로 내려간다
  • "PRECISERR 에 BFAR 이 0 근처면?" → 널 포인터 역참조. SRAM 하단이면 스택 오버플로를 의심한다
  • "IMPRECISERR 는 왜 원인 명령을 못 찾나?" → 쓰기 버퍼 때문에 폴트가 늦게 보고된다 — __DSB 를 넣어 정밀화한 뒤 재현
  • "스택 프레임에서 무엇을 꺼내 쓰나?" → 스택된 PC 로 소스 위치를, LR 로 어느 스택(MSP/PSP)·모드였는지를 판별

출처 — 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·쓰기 버퍼

디버거 — SWD 로 칩 안을 들여다보기트레이스와 프로파일링 — 멈추지 않고 관찰하기