Skip to content

LV005-HardFault现场打印实战 ​

本文在 HardFault_Handler 里通过串口直接打印异常现场:栈帧的八个寄存器、CFSR/HFSR/BFAR 状态寄存器以及逐位文字解释。它是调试期定位 HardFault 最快的手段,与 LV001 的 NOINIT 落盘方案互补。

一、 方案选型:打印还是落盘 ​

LV001 第三章给的方案是把现场存进 NOINIT 段,复位后再读。那是发布版的正确形态:不依赖任何外设,看门狗复位后现场还在。

本文讲另一种形态:在 HardFault_Handler 里直接通过串口打印。它不适合发布版,但是在调试期有两个 NOINIT 方案给不了的好处:

  • 立刻能看到结果,不需要「制造崩溃 → 等复位 → 重启后读段 → 再重启」这一套流程;
  • 能打印任意长的诊断信息(CFSR 每个错误位的文字解释、栈回溯等),NOINIT 段存原始字节,解释工作留在事后。

两种方案对照:

维度NOINIT 落盘(LV001)串口打印(本文)
适用阶段发布版、现场问题回归调试期、可复现问题
依赖无(只要 RAM 不掉电)串口驱动可用、fputc 不依赖中断
信息量原始字节,事后解析完整文字解释,当场可读
栈溢出场景可用不可用(打印自身可能二次 fault)
崩溃后状态停在 handler 或主动复位停在 handler 死循环

【注意】

调试期最实用的组合是:打印版用于快速定位,定位后换成 NOINIT 版发布。本文代码全部放在 CubeMX 的 USER CODE 保护区里,重新生成代码不会丢。

二、 先制造一次「干净的」崩溃 ​

排查工具本身必须先被验证过,否则真崩溃时你分不清是工具错了还是程序错了。标准做法是写一个测试函数,故意触发一次类型已知的崩溃,看打印是否与预期完全一致。

1. 地址选择:为什么是 0x30000000 ​

测试要读一个「一定不存在」的地址。候选有三个,只有一个可靠:

地址在 STM32F103 上的实际行为能否触发 fault
0x00000000启动配置下是 Flash 的别名区读不崩,读到向量表内容
0xE0000000内部外设区(PPB)部分寄存器读得到值,不可靠
0x30000000保留区,完全未映射必崩,触发 BusFault

F103 的内存映射里,SRAM 在 0x20000000,外设在 0x40000000,FSMC 在 0x60000000 以上,0x30000000 整段没有任何映射,读它一定触发精确总线错误。

2. 测试函数与 crash 串口命令 ​

c
// Core/Src/main.c
static void fault_test_illegal_addr(void)
{
    volatile uint32_t *bad_addr = (uint32_t *)0x30000000;  /* F103 未映射地址 */
    (void)*bad_addr;                                       /* 读操作即触发, 下一行不会执行 */
}

static void cmd_crash(const char *line)        /* 串口命令 'crash' 的处理函数 */
{
    (void)line;
    printf("crash: reading unmapped address 0x30000000 ...\r\n");
    fault_test_illegal_addr();                /* 先打印提示再崩溃, 提示一定发得出去 */
}

命令在 main() 初始化序列里注册,必须排在 iap_service_init() 之前(它内部会启动串口接收,此后再注册的命令收不到):

c
// Core/Src/main.c
(void)usart1_cmd_register("crash", cmd_crash);  /* 触发 HardFault, 验证现场打印 */
iap_service_init();                             /* 业务层初始化(内部启动串口接收) */

串口命令触发(而非编译开关)的意义:同一份固件既能正常运行业务,又能随时敲一条 crash 复现崩溃,不必改宏重新编译烧录。命令识别在串口中断、执行在主循环,所以 cmd_crash 里先打印的提示一定能在崩溃前发送完毕。

三、 现场打印的实现 ​

1. 为什么需要一个汇编跳板 ​

HardFault_Handler 本体是 C 函数,直接在里面取栈帧指针行不通,有两个独立的原因:

其一,C 里取 SP 已经晚了。 异常帧的基址等于进入 handler 时的 MSP,但 C 函数的 prologue 可能压栈,__get_MSP() 读到的是被 prologue 改动过的值。更麻烦的是,handler 里 BL 调用任何函数都会覆盖 LR,而 LR 里保存的 EXC_RETURN(判断崩溃前用 MSP 还是 PSP 的唯一依据)随之丢失。(注:本板实测打印里 frame offset = -4,即打印出的 MSP(entry) 比真帧基址高 4 字节;成因与对策见 LV015,不影响「必须在汇编里第一时间取 SP」这一结论。)

其二,Keil AC5 不支持函数体内的多指令内联汇编(报错细节见 LV010)。唯一可靠的形式是文件作用域的 __asm 函数。

于是结构变成三层:C 版 HardFault_Handler(CubeMX 生成,只负责转发)→ 汇编跳板(取栈帧指针、对齐栈)→ C 版 hardfault_dump(打印)。

armasm
; Core/Src/stm32f1xx_it.c, ARM Compiler 5 分支
__asm void hardfault_trampoline(void)
{
    PRESERVE8                    /* 声明保持 8 字节栈对齐, 否则链接报 L6238E */
    IMPORT hardfault_dump        /* 显式声明外部符号, 否则汇编报 A1516E */
    MRS   r0, MSP
    ADD   r0, r0, #4
    MOV   r1, sp
    BIC   r1, r1, #7
    MOV   sp, r1
    BL    hardfault_dump
}

四条关键指令的分工:

  • MRS r0, MSP + ADD r0, r0, #4:HardFault_Handler 用 BL 调跳板时已把返回地址压进栈,MRS 读到的是入口 MSP 减 4,加回 4 才是异常入口瞬间的 MSP,传给 dump 用于定位帧基址(帧基址不在这里硬编码,原因见 LV015);
  • MOV/BIC/MOV sp, r1:把 SP 无条件垫到 8 字节对齐,满足 AAPCS 对函数入口栈对齐的要求,printf 这类库函数默认依赖它;
  • PRESERVE8 / IMPORT:两个汇编器指令,分别治链接器的对齐检查和符号解析检查(成因与解法见 LV010)。

裸机工程没有 OS,异常只可能发生在线程模式或 Handler 模式,两者都用主栈,所以这里只读 MSP。若日后移植 RTOS,需要按 TST lr, #4 判断 EXC_RETURN 的 bit2 来选择 MSP/PSP——那又要求跳板必须是向量表直接入口(不能经过 C 函数的 BL,否则 LR 已被覆盖),届时要重构成 __asm void HardFault_Handler(void) 整体替换。

2. hardfault_dump:打印什么 ​

c
// Core/Src/stm32f1xx_it.c(节选)
void hardfault_dump(uint32_t msp)
{
    int32_t off;
    uint32_t *frame = hardfault_find_frame(msp, &off);

    printf("\r\n");
    printf("================ HardFault Dump ================\r\n");
    printf("MSP(entry) = 0x%08X, frame offset = %d\r\n", (unsigned int)msp, (int)off);
    printf("R0    = 0x%08X\r\n", (unsigned int)frame[0]);
    /* ... R1 / R2 / R3 / R12 同理 ... */
    printf("LR    = 0x%08X  (return address of faulting context)\r\n", (unsigned int)frame[5]);
    printf("PC    = 0x%08X  <-- faulting instruction, locate it in disassembly\r\n", (unsigned int)frame[6]);
    printf("xPSR  = 0x%08X\r\n", (unsigned int)frame[7]);
    printf("CFSR  = 0x%08X\r\n", (unsigned int)SCB->CFSR);
    printf("HFSR  = 0x%08X\r\n", (unsigned int)SCB->HFSR);
    printf("MMFAR = 0x%08X\r\n", (unsigned int)SCB->MMFAR);
    printf("BFAR  = 0x%08X\r\n", (unsigned int)SCB->BFAR);
    /* ... CFSR / HFSR 逐位文字解释 ... */
    while (1)
    {
    }
}

打印内容分三组,各有各的用途:

组字段用途
栈帧R0~R3 / R12 / LR / PC / xPSRPC 定位出错指令;LR 期望是「调用链上一层」,但 -O3 下常是过期残留值,用前必须反查验证(见 LV020 3.3.1)
状态寄存器CFSR / HFSRfault 分类,判断是否升级而来
地址寄存器MMFAR / BFAR被非法访问的地址,拿去比对 map 找「谁被踩了」

三个实现细节:

  • 输出全英文。串口工具对中文的支持参差不齐,UTF-8 / GBK 稍不匹配就是乱码,而乱码的崩溃日志等于没有。代码注释可以写中文,打印字符串不要。
  • printf 走轮询发送。本工程 fputc 是忙等 USART1->SR 的 TXE 位,不依赖中断,所以在异常上下文里可用。若工程的 fputc 用了中断或 DMA,必须先确认它们在 fault 期间仍能工作。
  • 帧基址运行时定位。不把传入的 MSP 直接当帧基址,用 hardfault_find_frame() 校验,原因与实现见 LV015。

四、 实测输出与逐行解读 ​

串口终端敲入 crash 并回车后输出(本工程 ATK-F103,Cortex-M3,Keil AC5;下例与 LV030 的样例取自不同构建版本,地址不可互相比对):

text
crash: reading unmapped address 0x30000000 ...
================ HardFault Dump ================
MSP(entry) = 0x20005D34, frame offset = -4
R0    = 0x30000000
R1    = 0x08002CC8
R2    = 0x08002CC9
R3    = 0x08004115
R12   = 0x00000000
LR    = 0x080008ED  (return address of faulting context)
PC    = 0x08002C94  <-- faulting instruction, locate it in disassembly
xPSR  = 0x61000000
-----------------------------------------------
CFSR  = 0x00008200
HFSR  = 0x40000000
MMFAR = 0x30000000
BFAR  = 0x30000000
-----------------------------------------------
BUF: precise data bus error (PRECISERR)
BUF: BFAR valid, fault addr = 0x30000000
HFS: configurable fault escalated to HardFault (FORCED)
===============================================

第一行 crash: ... 是 cmd_crash 自己的提示,证明命令已进入处理函数;后面才是异常现场。

1. 打印字段含义速查 ​

拿到打印先对照这张表,每个字段是什么、拿到后该做什么(字段背后更完整的理论说明见 LV001 第三章 3.1 的栈帧表与第二章 2.1~2.3 的状态寄存器详解):

字段是什么拿到后怎么用
MSP(entry)进入 HardFault_Handler 瞬间的主栈指针,是定位异常帧的锚点不直接参与分析,配合 frame offset 换算帧基址
frame offset帧基址相对 MSP(entry) 的字节偏移,由运行时校验得出只用于判断可信度:-4/0/4 等小偏移说明帧定位成功,结论可用
R0~R3AAPCS 的参数与 scratch 寄存器,保存崩溃前最后一次函数调用的实参或中间值值为 Flash 地址时多为字符串常量残留;为 RAM 地址时比对 .map 看指向哪个变量
R12过程内临时寄存器(IP),编译器做中转用一般无诊断价值,多为 0 或残留值
LR崩溃现场的返回地址寄存器期望是「调用链上一层」,但 -O3 下常是过期残留值,用前必须反查验证(见 LV020 3.3.1)
PC出错指令地址,最关键字段拿去 map/addr2line 反查函数与源码行,方法见 LV020
xPSR程序状态寄存器组合(APSR + EPSR + IPSR 三合一)bit24(T 位)应为 1;IPSR = 0 说明崩在线程模式,非 0 说明崩在中断里,号数即异常号
CFSR可配置故障状态,由三段拼成:UFSR[31:16] + BFSR[15:8] + MMFSR[7:0]先看哪一段非 0 定出故障大类,再查具体置位位,组合速查见 LV001 2.3
HFSRHardFault 自身的状态bit30 FORCED = 1 说明是其他 fault 升级来的,真原因看 CFSR;bit1 VECTTBL 指向向量表问题
MMFARMemManage 的出错地址仅当 MMFSR 的 MMARVALID = 1 时有效,否则是旧值,不可信
BFARBusFault 的出错地址,即被非法访问的地址仅当 BFSR 的 BFARVALID = 1 时有效;拿去比对 .map 找「谁被踩了」
BUF: / HFS: 行固件里逐位解码好的 CFSR/HFSR 文字版结论不用手算位域,直接读结论,与上面两个寄存器互为印证

2. 实测值逐行解读 ​

  • MSP(entry) = 0x20005D34, frame offset = -4:帧基址 = MSP(entry) - 4 = 0x20005D30,硬件把异常帧压在这里;后面八个寄存器全部从这块内存读出,这一行是它们的共同前提。
  • R0 = 0x30000000:与测试函数加载进 R0 的地址一致,同时 BFAR/MMFAR 也是它。三个独立来源互相印证,说明栈帧指针完全正确——这是整套工具可信的验收标准。
  • R1 / R2:0x08002CC8 / 0x08002CC9,指向 Flash 里的字符串常量——正是 cmd_crash 崩溃前最后一次 printf 的格式串残留,与本次出错无关,解读过程见 LV020。
  • R3 = 0x08004115:反查落在 fputc 里,同样是打印路径的残留值,无诊断价值。R12 = 0 一样跳过不读。
  • PC = 0x08002C94:出错指令地址,拿去反汇编或 addr2line 即可定位到源码行(方法见 LV020)。
  • LR = 0x080008ED:奇数,是合法的 Thumb 返回地址形态。但注意它不是「出错函数调用者的返回地址」——-O3 下它只是崩溃前最后一次 BL(C 库里的 ferror)的残留值,拿它定位调用链会被带偏,实测分析见 LV020 3.3.1 节。
  • xPSR = 0x61000000:拆开是三段——bit30/29(Z/C 标志位)= 1 是上一次运算的残留,无诊断价值,不必纠结;bit24(T 位)= 1,处于 Thumb 态,正常;IPSR(低 9 位)= 0,崩溃发生在线程模式而非中断里。
  • CFSR = 0x00008200:按三段拆——高 16 位(UFSR)= 0x0000,无 UsageFault;中 8 位(BFSR)= 0x82,即 bit9 PRECISERR(精确总线错误,地址可信)+ bit15 BFARVALID(BFAR 有效);低 8 位(MMFSR)= 0x00,无 MemManage。结论:这是一次精确总线错误。
  • HFSR = 0x40000000:bit30 FORCED,这个 HardFault 是 BusFault 升级来的(BusFault 默认不使能),所以真正的错误类型要看 CFSR。
  • BUF: / HFS: 三行:固件已把上面两个寄存器解码成人话——「精确数据总线错误」「BFAR 有效,出错地址 0x30000000」「可配置故障升级为 HardFault」,与手工拆位的结果一致,交叉核对用。

【注意】

MSP(entry) 与帧基址相差 4 字节(frame offset = -4)不是错误,是本板的实测行为,分析与对策见 LV015。如果你的板子上这一行是 frame offset = 0,说明运行时校验在标准行为的内核上工作,同样正确。

3. 分析顺序:拿到 dump 后四步走 ​

上面是逐字段的「读法」,实际排查要有先后,四步串起来:

(1)先定故障类型:看 HFSR 与 CFSR。HFSR 的 FORCED = 1 说明真原因在 CFSR;按三段拆 CFSR 找到置位位。本例得到「精确总线错误 + BFAR 有效」,后续步骤的 BFAR 才可信。常见组合与对应原因速查表见 LV001 2.3。

(2)再定出错位置:反查 PC。PC 指向内部 Flash 才有意义;本例 0x08002C94 反查到 main.c:76 的非法读(方法见 LV020)。若 PC 指向数据区或乱地址,先怀疑跑飞或帧错位,转入(4)。

(3)然后找被踩对象:比对 BFAR/MMFAR。这是「谁被非法访问」的答案:指向 RAM 就比对 .map 找变量,指向外设区就查时钟是否使能,指向保留区(本例 0x30000000)就是访问了不存在的地址。配合(2)的 PC,一个回答「是谁踩的」,一个回答「踩坏了谁」。

(4)最后自检可信度:用 xPSR 与三源互证。T 位 = 1、PC 落在 Flash、R0 等参数与 BFAR 对得上,说明帧定位正确,前三步结论可用;任一不满足,先回 LV015 解决帧定位,再重新分析,否则是在错误的现场上推理。

五、 使用限制 ​

三条必须知道的边界:

(1)栈溢出引起的崩溃不要用这套。打印本身要消耗栈,栈已经溢出的情况下会在 handler 里二次 fault,直接 lockup,什么都不会输出。这类崩溃回到 LV001 第三章的 NOINIT 方案。

(2)崩溃后 CPU 停在 while(1),此时调试器 attach 上去(不要 reset,会丢现场)还能补看内存;需要自动恢复就换成 __NVIC_SystemReset(),但要确保该打印的都打印完了。

(3)看门狗会打断打印。调试期建议用 DBGMCU 把看门狗配成随调试器冻结,或者临时关狗,否则打印一半被复位,日志只剩半行。


本文档由 markdowncli 技能辅助生成