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 串口命令
// 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() 之前(它内部会启动串口接收,此后再注册的命令收不到):
// 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(打印)。
; 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:打印什么
// 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 / xPSR | PC 定位出错指令;LR 期望是「调用链上一层」,但 -O3 下常是过期残留值,用前必须反查验证(见 LV020 3.3.1) |
| 状态寄存器 | CFSR / HFSR | fault 分类,判断是否升级而来 |
| 地址寄存器 | 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 的样例取自不同构建版本,地址不可互相比对):
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~R3 | AAPCS 的参数与 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 |
HFSR | HardFault 自身的状态 | bit30 FORCED = 1 说明是其他 fault 升级来的,真原因看 CFSR;bit1 VECTTBL 指向向量表问题 |
MMFAR | MemManage 的出错地址 | 仅当 MMFSR 的 MMARVALID = 1 时有效,否则是旧值,不可信 |
BFAR | BusFault 的出错地址,即被非法访问的地址 | 仅当 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,即 bit9PRECISERR(精确总线错误,地址可信)+ bit15BFARVALID(BFAR 有效);低 8 位(MMFSR)= 0x00,无 MemManage。结论:这是一次精确总线错误。HFSR = 0x40000000:bit30FORCED,这个 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 技能辅助生成