🐞 dbg-01 HardFault 死机排查:从寄存器反推崩溃现场

🐞 STM32 调试与排错 · 第 1 / 1 章
💬 程序卡在 HardFault_Handler 的 while(1) 里?别慌——CPU 已经把「凶手是谁」悄悄压进栈里了。
这一章在干嘛? 教你把令人绝望的「程序跑飞」变成一个可复现、可定位、可修复的具体问题。学完之后,你看到 HardFault 不再靠猜,而是能三步锁定到某一行 C 代码
这是什么故障升级链栈帧:硬件留的现场第1步 选栈第2步 挖 PC第3步 查 CFSR自动打印遗言三个真实案例预防清单

一、HardFault 就是单片机的「蓝屏」

写 PC 程序时,出错会有异常堆栈告诉你第几行错了。而单片机出错的典型表现是:

这时候大多数初学者的反应是一行行注释代码去试,非常痛苦。但实际上,Cortex-M 内核在跳进 HardFault 之前,已经自动把现场证据保存好了——包括「崩溃时正在执行哪条指令」。你要做的只是学会读它。

核心认知:HardFault 不是「程序坏了」,而是 CPU 在说「我遇到了处理不了的情况,现场我已经记录好了,请你来查」。

二、故障升级链:为什么会升级成 HardFault

Cortex-M 有三类可配置故障,它们更具体、更好定位:

故障类型触发原因(举例)异常号
MemManageMPU 区域违规、往只读区写数据、在禁止执行区取指4
BusFault访问不存在的外设地址、DMA 地址越界、未上电的外设5
UsageFault除零、非对齐访问、未定义指令、切换到 ARM 状态6

关键在于:默认情况下这三类故障都是关闭的(HAL 库初始化不会帮你打开)。所以当它们发生时,因为没有专门的 handler,就会「升级」成 HardFault。HardFault 优先级固定为 -1,高于所有可屏蔽中断,所以它一定能被进入。

① 发生具体故障 MemManageMPU 违规 BusFault总线/地址错误 UsageFault除零/未定义指令 ② 但它们默认未使能 → 被「升级」 HardFault_Handler 优先级 -1,固定最高(除 NMI) 若 HardFault 自身再出错 → Lockup(彻底锁死)
图 1 · 三类具体故障在默认关闭时,都会升级为 HardFault。这意味着 HardFault 里藏着真正的错误类型,只是需要你去 CFSR 里读。
排错第一原则:不要急着改代码。先把 CFSR 读出来——它会告诉你这到底是「除零」「总线错」还是「MPU 违规」,排查方向立刻从「大海捞针」缩小到「一条街」。

三、硬件已经帮你留下的「现场」:8 个字的栈帧

进入异常前,CPU 会自动把 8 个 32 位寄存器按顺序压入当前使用的栈。这 8 个字就是破案的关键,尤其第 7 个字——PC(程序计数器),它记录着「崩溃时正要执行的那条指令的地址」。

低地址 ── 栈增长方向 ──▶ 高地址 栈偏移 寄存器 C 数组下标 破案价值 SP+0R0stack[0]参数 1 SP+4R1stack[1]参数 2 SP+8R2stack[2]参数 3 SP+12R3stack[3]参数 4 SP+16R12stack[4]暂存 SP+20LRstack[5]调用者返回地址 SP+24PC ★stack[6]崩溃指令地址 SP+28xPSRstack[7]状态(含 Thumb 位)
图 2 · 异常栈帧布局(8 个字 / 32 字节)。绿色那行 PC 就是「凶手地址」,橙色 LR 能帮你回溯是谁调用了它。
小端序提醒:STM32 是小端模式,在 Memory 窗口里看到 34 12 00 08,实际地址是 0x08001234——字节要反着读

四、第 1 步:看 LR,判断该翻哪个栈

Cortex-M 有两个栈指针:MSP(主栈,裸机和中断默认用)和 PSP(进程栈,RTOS 的任务用)。要读栈帧,先得知道压在哪个栈里。

答案藏在 LR(R14) 里。异常发生时 LR 不是普通地址,而是一个叫 EXC_RETURN 的特殊编码:

LR 的值含义你该去看
0xFFFFFFF9异常前用主栈(裸机 / 中断里)MSP 寄存器
0xFFFFFFFD异常前用进程栈(RTOS 任务里)PSP 寄存器
0xFFFFFFF1从 Handler 模式返回(嵌套中断)MSP 寄存器
0xFFFFFFE9 / ED同上,但带浮点上下文(M4F/M33)对应栈,栈帧多 18 个字
速记口诀:看 LR 的倒数第二位——是 9 用 MSP,是 D 用 PSP。不用背完整数值。

五、第 2 步:从栈里挖出 PC,定位到那一行代码

拿到栈指针(假设 MSP = 0x20004F00)后,打开 Memory 窗口输入这个地址,往下数:

拿到 PC 值(比如 0x08001234)后,有三种办法翻译成源码位置:

方法操作适用场景
① 反汇编跳转Keil/CubeIDE 里 Show Disassembly at Address,输入 PC 值最快,IDE 会直接高亮对应 C 行
② map 文件打开编译生成的 .map,搜索最接近且小于 PC 的函数地址没有调试器时
③ addr2linearm-none-eabi-addr2line -e firmware.elf 0x08001234GCC 工具链,直接输出 main.c:128
一个常见坑:优化等级。-O2 下函数被内联、栈帧消失,回溯会不准。排查 HardFault 时,先把优化调到 -O0 复现一次,定位后再改回去。

六、第 3 步:读 CFSR,判断「到底错在哪类」

PC 告诉你在哪一行,CFSR 告诉你错的是什么类型。CFSR(地址 0xE000ED28)是三个子寄存器拼起来的 32 位:

UFSR [31:16]用法错误:除零/未定义指令 BFSR [15:8]总线错误:地址不存在 MMFSR [7:0]存储管理:MPU 违规 CFSR @ 0xE000ED28(32 位) 另需关注:bit15 BFARVALID / bit7 MMARVALID = 1 时,BFAR、MMFAR 里的地址才有效
图 3 · CFSR 的三段结构。读出来后按位查下表即可定性。

常用位速查(CFSR 位号,不是子寄存器内的位号)

bit 0 · IACCVIOL
取指访问违规
bit 1 · DACCVIOL
数据访问违规
bit 8 · IBUSERR
取指令总线错误
bit 9 · PRECISERR
精确数据总线错误
bit 10 · IMPRECISERR
非精确(DMA/写缓冲)
bit 11 · UNSTKERR
出栈时总线错
bit 12 · STKERR
入栈时总线错(栈溢出典型)
bit 16 · UNDEFINSTR
未定义指令(PC 跑飞)
bit 17 · INVSTATE
非法状态(Thumb 位错)
bit 18 · INVPC
非法 PC 装载
bit 19 · NOCP
访问了未开启的协处理器(FPU 没开就浮点运算)
bit 24 · UNALIGNED
非对齐访问
bit 25 · DIVBYZERO
除以零

配套的还有几个寄存器,一起读出来更完整:

寄存器地址看它能知道什么
HFSR0xE000ED2Cbit30 FORCED=1 表示由其他故障升级而来;bit1 VECTTBL 表示向量表读取出错
BFAR0xE000ED38出错的具体地址(仅当 CFSR bit15 = 1 时有效)
MMFAR0xE000ED34MPU 违规地址(仅当 CFSR bit7 = 1 时有效)
CFSR 是「写 1 清零」的黏滞位。读完一定要往对应位写 1 清掉:SCB->CFSR |= SCB->CFSR;。否则旧标志会和新故障混在一起,把你带偏。

七、让单片机自己「留遗言」:可粘贴的故障打印代码

每次手动翻内存太累。下面这段代码让 HardFault 发生时自动把关键寄存器打印到串口(或存起来),复位后也能看到。

① 先判断用的是 MSP 还是 PSP(一小段汇编,最值钱的三行):

/* stm32fxxx_it.c —— 替换默认的 HardFault_Handler */
__attribute__((naked)) void HardFault_Handler(void)
{
    /* naked:不要编译器生成函数头,否则会破坏 SP */
    __asm volatile(
        "TST LR, #4          \n"  /* 测 LR 的 bit2 */
        "ITE EQ              \n"  /* If-Then-Else */
        "MRSEQ R0, MSP       \n"  /* bit2=0 → 用 MSP */
        "MRSNE R0, PSP       \n"  /* bit2=1 → 用 PSP */
        "B hardfault_c       \n"  /* R0 = 栈帧首地址 */
    );
}

② 用 C 函数把现场读出来并打印:

void hardfault_c(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  = *(volatile uint32_t *)0xE000ED28;
    volatile uint32_t hfsr  = *(volatile uint32_t *)0xE000ED2C;
    volatile uint32_t bfar  = *(volatile uint32_t *)0xE000ED38;
    volatile uint32_t mmfar = *(volatile uint32_t *)0xE000ED34;

    printf("\r\n===== HardFault 现场 =====\r\n");
    printf("PC  = 0x%08lX  <- 用 addr2line 查这一行\r\n", pc);
    printf("LR  = 0x%08lX\r\n", lr);
    printf("CFSR= 0x%08lX  HFSR= 0x%08lX\r\n", cfsr, hfsr);
    printf("BFAR= 0x%08lX  MMFAR= 0x%08lX\r\n", bfar, mmfar);

    /* 清掉黏滞标志,避免影响下次判断 */
    *(volatile uint32_t *)0xE000ED28 |= cfsr;

    __asm volatile("BKPT #0");   /* 有调试器就停在这里 */
    while (1);
}
变量为什么要加 volatile?因为在 -O2 下,这些变量只写不读,编译器会认为没用而优化掉,调试器里就看不到值了。volatile 强制它们真实存在于栈上。

八、三个真实案例:从现象到根因

案例 1 · 空指针解引用 → 一眼看穿

uint32_t *p = NULL;
*p = 0x1234;      /* 往地址 0 写数据 */

现场:CFSRDACCVIOL(bit1) 置位,MMFAR = 0x00000000
结论:往零地址写数据,检查指针是否初始化、函数是否返回了 NULL。

案例 2 · 栈溢出 → 最隐蔽的那种

void task(void) {
    char buf[8192];   /* 8 KB 局部变量,栈只有 1~4 KB */
    memset(buf, 0, sizeof(buf));
}

现场:CFSRSTKERR(bit12) 或 MSTKERR(bit4) 置位;或者 PC 指向一个完全不合理的地址(因为返回地址已被踩坏)。
结论:大数组改 static 或全局 / 用 malloc;调大启动文件里的 Stack_Size

案例 3 · 访问未上电的外设 → 总线错误

/* 忘了 __HAL_RCC_GPIOA_CLK_ENABLE(); 就去读 GPIOA */
uint32_t v = GPIOA->IDR;   /* 外设时钟没开 → 总线无应答 */

现场:CFSRPRECISERR(bit9) 或 IBUSERR(bit8) 置位,BFAR 指向该外设寄存器地址。
结论:先开时钟,再用外设——这是 STM32 新手最常踩的坑之一。

九、与其排查,不如预防:一份代码评审清单

🧠 小测验
调试时看到 LR = 0xFFFFFFFD,应该去读哪个栈指针?
PSP(进程栈)。倒数第二位是 D,说明异常前运行在使用 PSP 的上下文,通常是 RTOS 任务中。
栈帧中第 7 个字(SP+24)是什么?为什么它最重要?
是 PC(程序计数器),保存着导致异常的那条指令的地址。用 addr2line 或反汇编窗口就能映射到具体 C 代码行,是定位的关键。
CFSR 的 bit 25 置位,说明出了什么事?
DIVBYZERO —— 发生了除以零。检查 SDIV/UDIV 指令对应的 C 代码,除数是否为 0。
为什么建议开启 MemManage / BusFault / UsageFault?
它们默认关闭,导致具体故障被「升级」成笼统的 HardFault。开启后可以在各自 handler 里就地抓住错误,信息更精确、定位更快。
读完 CFSR 后为什么要往对应位写 1?
CFSR 的标志位是黏滞的(write-1-to-clear)。不清零的话,下次故障发生时新旧标志会混在一起,误导判断。
← STM32 总览🏠 顶层目录数据手册精讲 →