嵌入式死机第一案:Cortex-M HardFault 定位与 SWD 实战
适用人群:能用 CubeMX/HAL 或 ESP-IDF 点亮灯、跑通串口,但设备一"卡死"就只会疯狂注释代码二分法找问题的同学(也就是一年前的我)。
读完你能得到:① 搞懂 HardFault 到底是谁、从哪来;② 会读硬件自动压栈的"案发现场"(8 个寄存器);③ 一套五步定位法:从 CFSR/HFSR 判类型 → 从栈帧取 PC → 反查到源码某一行;④ 一份可以直接抄进工程的HardFault_Handlerdump 代码(M3/M4 版 + M0 版);⑤ OpenOCD / J-Link / STM32CubeIDE 三种 SWD 挖现场的实操命令;⑥ ESP32(Xtensa / RISC-V)的 panic 输出怎么读;⑦ 8 个我真踩过的坑。
一、场景:设备跑着跑着就"死"了
先说一个我大三做课设时的真事。
一个 STM32F407 的温湿度采集板,串口每秒打一行数据。平时跑一整晚都没事,但只要我把采样间隔调快到 100ms,跑个十几分钟,串口就突然不打印了。灯不闪、按键没反应、也不复位——就是死在那儿。
我当时的操作是这样的:
- 注释掉 DMA,跑一晚上,没死 → “哦是 DMA 的锅”
- 恢复 DMA,注释掉 OLED 刷新,又跑一晚上,还是死 → “不对啊……”
- 反复第 1、2 步,一周过去了,问题还在
这就是没有调试方法论的下场:把二分法当成唯一武器,一次实验花掉一晚上。
后来我才知道,芯片其实早就把死因写在寄存器里了,只是我没去读。我把 J-Link 接上、点一下"暂停",调用栈直接停在一个叫 HardFault_Handler 的函数里——它就在启动文件里,长这样:
/* startup_stm32f407xx.s 里默认的写法(等价 C 表示) */
void HardFault_Handler(void)
{
while (1) { } /* 死循环,什么都不告诉你 */
}
"设备死了"90% 的情况就是死在这个 while(1) 里。 这篇要干的事,就是把这个空壳变成一个会说话的"法医",再教你用 SWD 把现场挖出来。
二、先搞懂:HardFault 到底是什么
2.1 它是"兜底异常",不是"某一种错"
Cortex-M(M3/M4/M7 这些 ARMv7-M 内核)其实有 4 种故障异常:
| 异常 | 管什么 | 典型触发 |
|---|---|---|
| MemManage Fault | MPU 权限违规 | 访问了 MPU 禁止的区域、往只读区写 |
| BusFault | 总线层面出错 | 访问不存在的地址、外设时钟没开就读寄存器 |
| UsageFault | 指令/状态层面出错 | 非法指令、非对齐访问、除零(需先开 DIV_0_TRP,见 3.3)、FPU 没使能就用 float |
| HardFault | 上面三个的兜底 | 上面三个"处理不了"的时候,统统升级成它 |
关键点:前三个默认是关闭的。SCB->SHCSR(地址 0xE000ED24)里的 MEMFAULTENA/BUSFAULTENA/USGFAULTENA 复位后全是 0,所以:
UsageFault ┐
BusFault ├── 没使能 / 优先级不够 / 在 fault 里又出 fault ──▶ HardFault
MemManage ┘ (HFSR.FORCED = 1)
这个过程叫升级(escalation)。所以你看到的 HardFault,绝大多数时候只是个"快递员",真正的死因写在 CFSR 里——这也是为什么第三节要先读 CFSR 而不是瞎猜。
⚠️ 新手最容易在这里放弃:以为 HardFault 是个黑盒。其实它是最容易查的死法,因为硬件把现场保存得很完整。真正难查的是"看门狗一直复位"和"跑飞但没 fault"(后者往往是踩了栈还侥幸没崩)。
2.2 硬件已经替你保存了现场:自动压栈的 8 个寄存器
Cortex-M 进任何异常(包括 HardFault)之前,硬件会自动把 8 个寄存器压到栈上,这叫异常栈帧(exception stack frame)。这是 ARMv7-M 架构手册里 PushStack() 伪代码规定的行为,不是编译器干的,也不需要你写汇编保存。
高地址 ↑
+----------------------+
| ...(进异常前的数据) |
+----------------------+
| (可能的 4 字节对齐填充) | ← 是否存在看 xPSR 的 bit9
+----------------------+
| xPSR | frameptr + 0x1C
+----------------------+
| PC (ReturnAddress) | frameptr + 0x18 ★ 最重要:出事的指令在哪
+----------------------+
| LR (R14) | frameptr + 0x14 ★ 次重要:是谁调用过来的
+----------------------+
| R12 | frameptr + 0x10
+----------------------+
| R3 | frameptr + 0x0C
+----------------------+
| R2 | frameptr + 0x08
+----------------------+
| R1 | frameptr + 0x04
+----------------------+
| R0 | frameptr + 0x00 ← frameptr(进异常后的 SP)
+----------------------+
低地址 ↓
- 基本帧(basic frame)= 0x20 字节,就是上面这 8 个字。
- 带 FPU 的扩展帧(extended frame)= 0x68 字节:在 xPSR 上面还多压了 S0~S15、FPSCR 和一个保留字(8 + 16 + 1 + 1 = 26 个字 = 104 = 0x68)。M4F/M7 用了浮点才会出现。
- 那个"对齐填充"是因为 ARM 要求异常入口时栈 8 字节对齐(由
CCR.STKALIGN控制,ARMv7-M 上复位默认就是开的)。如果压栈前 SP 不是 8 的倍数,硬件会多塞 4 字节,并把这件事记在压进去的那份 xPSR 的 bit9里。你手算栈地址时如果不知道这条,会以为自己算错了。
只要拿到 frameptr,PC 就在 frameptr[6]。 这就是整个定位过程的钥匙。
2.3 现场压在 MSP 还是 PSP?看 LR
Cortex-M 有两个栈指针:MSP(主栈,中断/裸机默认用它)和 PSP(进程栈,跑 RTOS 时任务用它)。所以栈帧可能压在两个地方之一,你得先知道是哪个。
判断依据是进入异常时 LR 里的那个魔数,它不是返回地址,而是 EXC_RETURN:
| 位 | 值 | 含义 |
|---|---|---|
| bit2 | 1 | 异常返回后用 PSP(现场压在 PSP 上) |
| bit2 | 0 | 异常返回后用 MSP(现场压在 MSP 上) |
| bit3 | 1 | 返回到 Thread 模式(0 = Handler 模式,说明是"异常里又出异常") |
| bit4 | 0 | 用的是扩展帧(有 FPU 现场,帧长 0x68) |
| bit4 | 1 | 用的是基本帧(帧长 0x20) |
常见取值,背下来定位很快:
| EXC_RETURN | 二进制低 5 位 | 解读 |
|---|---|---|
0xFFFFFFF1 | 1_0001 | Handler 模式 + MSP + 基本帧 → 异常嵌套里炸的 |
0xFFFFFFF9 | 1_1001 | Thread 模式 + MSP + 基本帧 → 裸机 main 里炸的 |
0xFFFFFFFD | 1_1101 | Thread 模式 + PSP + 基本帧 → RTOS 任务里炸的 |
0xFFFFFFED | 0_1101 | Thread + PSP + 扩展帧 → RTOS 任务,且用了浮点 |
⚠️ 这里有个坑,我查资料时被绕了半天:网上(包括 SEGGER wiki 的某些页面)对 EXC_RETURN bit2 的描述和 ARM 官方伪代码是矛盾的。以 ARM v7-M 架构手册(DDI 0403)为准:bit2 = 1 → PSP,bit2 = 0 → MSP。 拿不准就自己验证:在 RTOS 任务里故意写个 NULL 解引用,看 LR 是不是
0xFFFFFFFD。
2.4 顺便认识两个"关中断"寄存器
查 fault 时经常撞见它们,先混个脸熟:
| 寄存器 | 作用 | 谁能拦住 |
|---|---|---|
PRIMASK | 置 1 相当于把当前优先级抬到 0 | 屏蔽除 NMI、HardFault 外的所有中断。__disable_irq() 就是它 |
FAULTMASK | 置 1 相当于把当前优先级抬到 −1 | 连 HardFault 也屏蔽,只剩 NMI。异常返回时会被硬件自动清零 |
记住 FAULTMASK 那条"自动清零":它意味着 FAULTMASK 只在当前这段异常处理里有效,不像 PRIMASK 会一直挂着。第六节的踩坑表里有个真实案例就栽在 PRIMASK 上。
三、定位五步法:把死因从寄存器里读出来
流程先给你,后面逐步展开:
死机
│
├─① 抓栈帧 从 LR 判 MSP/PSP,拿到 frameptr
│
├─② 读 HFSR FORCED=1 ? → 真凶在 CFSR ; VECTTBL=1 ? → 向量表/VTOR 出问题
│
├─③ 读 CFSR 定位到具体子类型(越界/非对齐/非法指令/除零/总线…)
│ 并读 MMFAR / BFAR 拿到"出事的那个地址"
│
├─④ 取 PC frameptr[6] → 反查 .map / addr2line → 源码第几行
│
└─⑤ 特殊处理 imprecise BusFault 关写缓冲,让它变精确再复现
3.1 第一步:抓栈帧(把空 Handler 改成会说话的法医)
思路:用一段极短的汇编判断该读 MSP 还是 PSP,把栈帧指针放进 r0、EXC_RETURN 放进 r1,然后跳到 C 函数里慢慢分析。
Cortex-M3 / M4 / M7(ARMv7-M,GCC / arm-none-eabi-gcc):
/* 适用:STM32F1/F4/F7/H7 等 ARMv7-M 内核,GCC 编译器 */
#include <stdint.h>
void hardfault_report(uint32_t *frame, uint32_t exc_return); /* 见 3.4 */
__attribute__((naked)) void HardFault_Handler(void)
{
__asm volatile (
".syntax unified \n"
"tst lr, #4 \n" /* 测 EXC_RETURN 的 bit2 */
"ite eq \n"
"mrseq r0, msp \n" /* bit2 == 0 → 现场在 MSP */
"mrsne r0, psp \n" /* bit2 == 1 → 现场在 PSP */
"mov r1, lr \n" /* 把 EXC_RETURN 当第二个参数传下去 */
"b hardfault_report \n" /* 尾跳转,r0/r1 就是 C 函数的两个入参 */
);
}
逐行讲一下(这段汇编值得看懂,面试也爱问):
naked告诉编译器别生成函数头尾(不要压栈、不要动 SP)。因为 SP 现在正指着案发现场,编译器一压栈就把frameptr冲跑了。tst lr, #4做的是lr & 4,结果只影响标志位。等于 0 说明 bit2 = 0。ite eq是 Thumb-2 的 if-then-else 块:紧跟的mrseq在"相等"时执行,mrsne在"不等"时执行。M0 没有这条指令,所以下面要单独写一份。b(不是bl)是尾跳转,不产生新的返回地址,r0/r1按 AAPCS 调用约定正好落成 C 函数的第 1、2 个参数。- 如果链接后报"branch out of range",把最后一行换成
ldr r2, =hardfault_report+bx r2。
Cortex-M0 / M0+(ARMv6-M,指令集没有 ite,也不支持 tst 立即数):
/* 适用:STM32F0/G0/L0、RP2040 等 ARMv6-M 内核,GCC 编译器 */
__attribute__((naked)) void HardFault_Handler(void)
{
__asm volatile (
".syntax unified \n"
"movs r0, #4 \n"
"mov r1, lr \n"
"tst r0, r1 \n" /* ARMv6-M 的 tst 只支持寄存器操作数 */
"beq 1f \n"
"mrs r0, psp \n"
"b 2f \n"
"1: \n"
"mrs r0, msp \n"
"2: \n"
"mov r1, lr \n"
"ldr r2, =hardfault_report \n" /* M0 分支范围小,用寄存器间接跳 */
"bx r2 \n"
".ltorg \n" /* 让汇编器把上面那个字面量池就地放下 */
);
}
💡 用 Keil AC6 / IAR 的同学:内联汇编语法不同(IAR 要用
__asm段或单独.s文件),但思路完全一样——保住 SP、判 MSP/PSP、把指针传给 C。别直接抄 GCC 语法。
3.2 第二步:读 HFSR,问它"你是不是替别人挨的刀"
HFSR(HardFault Status Register,地址 0xE000ED2C)只有三个位有用:
| 位 | 名称 | 含义 | 你该干嘛 |
|---|---|---|---|
| bit1 | VECTTBL | 读取向量表时就出错了 | 查 VTOR 设置、Bootloader 跳转、向量表有没有被搬到 RAM 又忘了改 VTOR |
| bit30 | FORCED | 由 Bus/Usage/MemManage 升级而来 | 90% 的情况是这个 → 立刻去读 CFSR |
| bit31 | DEBUGEVT | 调试事件引起 | 一般是你自己下了断点 / BKPT 指令,没接调试器却执行了 BKPT 也会 |
写代码就一句:
uint32_t hfsr = SCB->HFSR;
if (hfsr & SCB_HFSR_FORCED_Msk) {
/* 真凶在 CFSR 里,往下走 */
}
3.3 第三步:读 CFSR,锁定真正的死因
CFSR(Configurable Fault Status Register,地址 0xE000ED28)是一个 32 位寄存器,按字节切成三段:
31 16 15 8 7 0
+------------------------+---------------+---------------+
| UFSR | BFSR | MMFSR |
| (UsageFault 状态) | (BusFault状态) | (MemManage状态)|
+------------------------+---------------+---------------+
MMFSR(bit0~7):MPU / 存储器管理
| 位 | 名称 | 人话 |
|---|---|---|
| 0 | IACCVIOL | 到 MPU 禁止执行的区域去取指令了 |
| 1 | DACCVIOL | 读/写了 MPU 不允许的地址 |
| 3 | MUNSTKERR | 异常返回出栈时违规(多半是栈指针已经被踩烂了) |
| 4 | MSTKERR | 异常入口压栈时违规(同上,典型的栈溢出) |
| 7 | MMARVALID | MMFAR(0xE000ED34)里的地址有效 ← 只有它为 1,MMFAR 才可信 |
BFSR(bit8~15):总线
| 位 | 名称 | 人话 |
|---|---|---|
| 8 | IBUSERR | 取指令时总线出错(PC 跑飞到不存在的地址) |
| 9 | PRECISERR | 精确总线错误 → 栈帧里的 PC 就是闯祸那条指令 |
| 10 | IMPRECISERR | 不精确总线错误 → PC 不可信(见 3.5) |
| 11 | UNSTKERR | 出栈时总线错误 |
| 12 | STKERR | 压栈时总线错误(栈指针指到了非法区域) |
| 13 | LSPERR | 浮点惰性压栈(lazy stacking)时出错 |
| 15 | BFARVALID | BFAR(0xE000ED38)里的地址有效 ← 同理,为 0 时 BFAR 是垃圾值 |
UFSR(bit16~31):指令/用法
| 位 | 名称 | 人话 | 新手命中率 |
|---|---|---|---|
| 16 | UNDEFINSTR | 执行了未定义指令 | ★★★ 函数指针为野指针、PC 跳进数据区 |
| 17 | INVSTATE | 试图切到 ARM 状态(Thumb 位丢了) | ★★★ 函数指针最低位没置 1、返回地址被踩 |
| 18 | INVPC | 非法的 EXC_RETURN / PC 加载 | ★★ 中断里乱改 LR、栈被踩 |
| 19 | NOCP | 访问了没使能的协处理器 | ★★★ FPU 没打开就用 float |
| 24 | UNALIGNED | 非对齐访问 | ★★★ *(uint32_t*)(buf + 1) |
| 25 | DIVBYZERO | 除零 | ★★ 见下面的注意事项 |
⚠️ 三条容易记错的规则,别背反了:
- 除零默认不会 fault。只有把
CCR.DIV_0_TRP(CCR = 0xE000ED14的 bit4)置 1,整数除零才会触发 UsageFault;默认情况下x / 0的结果直接是 0,静悄悄地把错误算法往下传。CCR.UNALIGN_TRP(bit3)只管单个 word/halfword 的非对齐访问。而多字访问(LDM/STM/LDRD/STRD)以及 word/halfword 的独占访问(LDREX/STREX),无论 UNALIGN_TRP 怎么设,非对齐一律 fault(ARMv7-M / Cortex-M 除 M0 系列外)。MMFAR/BFAR必须先看 valid 位。MMARVALID/BFARVALID为 0 时里面是上一次的残留值,你照着它去查地址会被带到沟里。另外 CFSR 是"写 1 清零",各个位可以叠加,所以分析完记得SCB->CFSR = SCB->CFSR;清掉,免得下次误判。
3.4 第四步:把现场存下来 + 从 PC 反查到源码行
现在把前三步拼成一个能直接抄进工程的 C 函数:
/* 适用:STM32F4(CMSIS 提供 SCB / SCnSCB / CoreDebug 定义),其他 ARMv7-M 同理 */
#include <stdint.h>
#include "stm32f4xx.h"
typedef struct {
/* 硬件自动压栈的 8 个字 */
uint32_t r0, r1, r2, r3, r12, lr, pc, xpsr;
/* 我们自己补的上下文 */
uint32_t exc_return;
uint32_t cfsr, hfsr, mmfar, bfar;
uint32_t mmfar_valid, bfar_valid;
} fault_record_t;
volatile fault_record_t g_fault; /* 调试期:普通全局变量,暂停后直接在 Watch 里看 */
void hardfault_report(uint32_t *frame, uint32_t exc_return)
{
g_fault.r0 = frame[0];
g_fault.r1 = frame[1];
g_fault.r2 = frame[2];
g_fault.r3 = frame[3];
g_fault.r12 = frame[4];
g_fault.lr = frame[5];
g_fault.pc = frame[6]; /* ★ 出事的指令地址 */
g_fault.xpsr = frame[7];
g_fault.exc_return = exc_return;
g_fault.cfsr = SCB->CFSR;
g_fault.hfsr = SCB->HFSR;
g_fault.mmfar_valid = (g_fault.cfsr & SCB_CFSR_MMARVALID_Msk) ? 1u : 0u;
g_fault.bfar_valid = (g_fault.cfsr & SCB_CFSR_BFARVALID_Msk) ? 1u : 0u;
g_fault.mmfar = g_fault.mmfar_valid ? SCB->MMFAR : 0xDEADBEEFu;
g_fault.bfar = g_fault.bfar_valid ? SCB->BFAR : 0xDEADBEEFu;
/* 只有真的接了调试器才执行 BKPT。没接调试器时执行 BKPT
会触发 HFSR.DEBUGEVT,直接把你送进第二次 HardFault。 */
if (CoreDebug->DHCSR & CoreDebug_DHCSR_C_DEBUGEN_Msk) {
__asm volatile ("bkpt #0");
}
while (1) { } /* 量产版请换成:存 flash/备份寄存器 → NVIC_SystemReset() */
}
几个必须说清楚的点:
- CMSIS 宏的版本差异:
SCB_CFSR_MMARVALID_Msk/SCB_CFSR_BFARVALID_Msk在 CMSIS 5 的core_cm3.h/core_cm4.h里都有;万一你的版本没有,直接用(1UL << 7)和(1UL << 15)代替。CoreDebug在 CMSIS 6 里改名成了DCB(DCB->DHCSR),报"未定义"就换一下。 - 别在 fault handler 里
printf。printf可能走 HAL 串口 + 中断 + malloc,而此刻中断状态、栈都可能已经坏了,最常见的结果是"卡在 fault handler 里连死机信息都打不出来"。要打日志请用阻塞式的轮询发送(F1/F4 直接写USART->DR,F7/G0/H7/L4 是USART->TDR;或者HAL_UART_Transmit()带超时),或者用 SEGGER RTT / ITM。 - 量产设备不能
while(1)死等。正确姿势是:把g_fault写进一块复位不清零的 RAM(GCC 里给它一个.noinit段,并确认启动代码不会清这个段)或备份寄存器(RTC->BKPxR),然后调NVIC_SystemReset()复位,下次开机再把这条记录通过网络/串口上报。同时务必配上独立看门狗(IWDG),防止死在 handler 里连复位都做不到。 0xDEADBEEF是我故意填的哨兵值,看到它就知道"这个地址不可信",比留个 0 更不容易误判。
拿到 PC 之后,怎么变成源码第几行? 两个办法:
# 办法一:addr2line(推荐,GCC 工具链自带)。注意用带调试信息的 .elf,不是 .bin/.hex
arm-none-eabi-addr2line -e build/myproject.elf -f -C -i 0x080012a4
# 输出示例:
# sensor_parse
# /home/cai/proj/Core/Src/sensor.c:87
# 办法二:翻 .map 文件,找 PC 落在哪个函数的地址区间里(粗一点,但不用工具链)
grep -n "0x080012" build/myproject.map
⚠️ 用
addr2line时,PC 的最低位可能是 1(Thumb 位)。如果查出来是??:0,把地址减 1 再试一次。另外-O2下函数会内联,-i参数能把内联链一起打出来,很有用。
3.5 第五步:碰上 imprecise BusFault 怎么办
这是最气人的一种:BFSR.IMPRECISERR = 1 时,栈帧里的 PC 不是闯祸的那条指令。
原因是 Cortex-M3/M4 内部有写缓冲(write buffer):CPU 把"往某地址写数据"这件事丢给总线就继续执行下一条指令了,等总线真报错时,PC 早就往前跑了几条。所以:
| 情况 | PC 可信吗 | 结论 |
|---|---|---|
PRECISERR = 1(精确) | ✅ 可信 | frame[6] 就是闯祸指令,直接 addr2line |
IMPRECISERR = 1(不精确) | ❌ 不可信 | 只能说"大概在这附近的前面几条指令" |
破解办法:把写缓冲关掉,让不精确变精确,然后重新复现一次。
/* 仅 Cortex-M3 / M4:ACTLR 的 bit1 = DISDEFWBUF(关闭默认写缓冲)
注意 ACTLR 是"实现定义"寄存器,不同内核含义不同,M0/M7 不要照抄 */
SCnSCB->ACTLR |= SCnSCB_ACTLR_DISDEFWBUF_Msk;
代价是性能会下降(每次写都要等总线应答),所以只在调试期开、发布前关掉。开了之后再复现,IMPRECISERR 通常就变成 PRECISERR,PC 立刻变得可信。
💡 我第一次遇到 imprecise 是在"外设时钟没开就写它的寄存器"。这种错在 STM32 上特别典型:忘了
__HAL_RCC_GPIOx_CLK_ENABLE()就配 GPIO,总线返回错误,但 fault 时的 PC 指向后面十几条指令的位置,怎么看都觉得那行代码没毛病。
四、SWD 实战:把现场从芯片里挖出来
上面那套代码是"设备自己招供"。但更常见的场景是:设备已经死在现场了,代码还是老版本没有 dump 逻辑。这时候就靠 SWD 直接进去翻。
4.1 SWD 到底连几根线
SWD(Serial Wire Debug)是 ARM 的两线调试接口,比 JTAG 省引脚:
调试器(ST-Link/J-Link/DAPLink) 目标板 MCU
┌───────────────┐ ┌─────────────┐
│ SWDIO ─────┼─────────────────────┤ PA13 (SWDIO)│ 数据(双向)
│ SWCLK ─────┼─────────────────────┤ PA14 (SWCLK)│ 时钟
│ GND ─────┼─────────────────────┤ GND │ 共地(必接!)
│ VREF/3V3 ────┼─ - - - - - - - - - -┤ 3V3 │ 电平参考,强烈建议接
│ NRST ─────┼─ - - - - - - - - - -┤ NRST │ 可选,用于"复位下连接"
└───────────────┘ └─────────────┘
- 最少三根:SWDIO、SWCLK、GND。少接 GND 是新手最常见的"连不上"原因。
- VREF 建议接:调试器靠它判断目标板电平(3.3V / 1.8V)。J-Link 不接 VREF 常报 “Could not connect to target”。
- NRST 建议接:如果程序一上电就死在 fault 里,或者进了低功耗睡死,普通连接会失败,需要
connect under reset(CubeProgrammer/CubeIDE 里可选,OpenOCD 用reset_config srst_only配合-c "reset halt")。
⚠️ PA13/PA14 千万别在代码里配成普通 GPIO。我见过(也犯过):CubeMX 里手滑把 SWD 引脚配成输出,烧进去之后再也连不上了,只能用"connect under reset"或者拉 BOOT0 进系统 Bootloader 才救得回来。
4.2 OpenOCD + ST-Link:命令行挖现场
启动 OpenOCD(一个终端):
# ST-Link + STM32F4 为例;换芯片改 target 那个 cfg
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg
另开一个终端连它的 telnet 命令口(默认 4444):
telnet localhost 4444
然后就是这几条命令(语法出自 OpenOCD User’s Guide “General Commands”):
> halt # 暂停 CPU,之后才能读核内寄存器
> reg # 列出所有寄存器:r0~r15、sp、lr、pc、xPSR、msp、psp...
> reg msp # 单独读某个寄存器
> reg psp
> mdw 0xE000ED2C 1 # HFSR:先看 FORCED / VECTTBL
> mdw 0xE000ED28 1 # CFSR:定位子类型
> mdw 0xE000ED34 2 # 一次读 2 个字 = MMFAR(0xED34) + BFAR(0xED38)
> mdw 0x2001FF80 8 # ★ 从栈帧起始地址读 8 个字 = 完整基本栈帧
# 第 7 个字(索引 6)就是 PC
> resume # 分析完放它继续跑
mdw 的语法是 mdw [phys] addr [count],count 是十进制的字数。读栈帧那行的地址怎么来?就是 reg msp 或 reg psp 读到的值(用哪个由 lr 的 bit2 决定,回看 2.3)。
如果你更习惯 GDB:
arm-none-eabi-gdb build/myproject.elf
(gdb) target extended-remote localhost:3333 # OpenOCD 的 GDB 口
(gdb) monitor halt
(gdb) info registers # 看 lr,判断 MSP/PSP
(gdb) x/8xw $psp # 直接把栈帧打出来(8 个字)
(gdb) p/x *(uint32_t *)0xE000ED28 # CFSR
(gdb) bt # 有调试信息时能直接给调用栈
4.3 J-Link Commander:不装 IDE 也能查
JLinkExe -device STM32F407VG -if SWD -speed 4000 -autoconnect 1
进去之后:
J-Link> h # halt,暂停 CPU
J-Link> regs # 打印所有核心寄存器(含 PC、LR、MSP、PSP、xPSR)
J-Link> mem32 0xE000ED28, 1 # CFSR
J-Link> mem32 0xE000ED2C, 1 # HFSR
J-Link> mem32 0x2001FF80, 8 # 从栈帧地址读 8 个字
J-Link> g # go,继续运行
⚠️ J-Link Commander 的
mem32 <Addr>, <NumItems>里数量是十六进制。读 8 个字写8没问题,但你要读 16 个字得写10而不是16——我在这上面愣过神。
4.4 IDE 里的图形化捷径
- STM32CubeIDE:
Window → Show View → Fault Analyzer。它会把 CFSR/HFSR 自动翻译成人话(比如直接告诉你 “Usage fault: Unaligned access”),还能列出异常栈帧并一键跳到出事那行源码。debug 停在 HardFault 时打开它,前面三步全省了。 - Keil MDK:调试状态下
Peripherals → Core Peripherals → System Control Block(或 System Viewer),能直接看到 CFSR/HFSR 各个位的名字和当前值。 - 共同前提:编译必须带调试信息(GCC 的
-g3,Release 也要留),否则你只有一个裸地址,反查不到源码行。
4.5 完整走一遍:从"死了"到"第 87 行"
假设设备死了,你接上 SWD 敲了这么几条:
> halt
> reg lr
lr (/32): 0xFFFFFFFD ← bit2=1 → PSP;bit3=1 → Thread;bit4=1 → 基本帧
> reg psp
psp (/32): 0x2001FF80 ← 栈帧就在这儿
> mdw 0x2001FF80 8
0x2001ff80: 20000144 00000000 0000000a 40020000 00000000 08000f31 08001a2c 61000000
R0 R1 R2 R3 R12 LR PC xPSR
> mdw 0xE000ED2C 1
0xe000ed2c: 40000000 ← HFSR bit30 = FORCED,果然是升级上来的
> mdw 0xE000ED28 1
0xe000ed28: 01000000 ← CFSR bit24 = UNALIGNED(UsageFault 非对齐访问)
解读过程:
LR = 0xFFFFFFFD→ 现场在 PSP,是任务代码里炸的(不是中断里)。HFSR = 0x4000_0000→FORCED = 1,HardFault 是替人挨刀,真凶看 CFSR。CFSR = 0x0100_0000→ bit24UNALIGNED→ 非对齐访问。- 栈帧第 7 个字
PC = 0x08001A2C,是 UsageFault(精确异常),所以这就是出事那条指令。 - 顺手看一眼
R1 = 0x00000000、R3 = 0x40020000,能帮你猜出当时的变量值。
最后一步:
$ arm-none-eabi-addr2line -e build/myproject.elf -f -C -i 0x08001a2c
sensor_parse
/home/cai/proj/Core/Src/sensor.c:87
翻到 sensor.c:87:
uint32_t ts = *(uint32_t *)(rx_buf + 1); // ← 就是它,rx_buf+1 不是 4 字节对齐
从"死机"到"这一行",不到两分钟。 对比我当年花一周做二分法……这就是为什么我说这篇是"调试与排错"专栏必须放第一篇的内容。
五、平台差异:不是所有芯片都这么玩
上面整套流程建立在 ARMv7-M(Cortex-M3/M4/M7) 上。换个核,剧本就变了。
5.1 Cortex-M0 / M0+(ARMv6-M):现场少得可怜
用 STM32F0/G0/L0、RP2040 的同学注意,ARMv6-M 是精简版:
| 项目 | Cortex-M3/M4 | Cortex-M0/M0+ |
|---|---|---|
| 故障异常种类 | HardFault + Bus/Usage/MemManage | 只有 HardFault |
CFSR / HFSR | 有 | 没有(这两个寄存器压根不存在) |
MMFAR / BFAR | 有 | 没有 |
| 非对齐访问 | 单 word/halfword 由 UNALIGN_TRP 控制 | 任何非对齐 load/store 一律 HardFault |
| 屏蔽寄存器 | PRIMASK/FAULTMASK/BASEPRI | 只有 PRIMASK |
所以在 M0 上,你能拿到的只有异常栈帧 + EXC_RETURN。定位方法退化成:
- 抓栈帧(用 3.1 的 M0 版汇编);
- 取
PC,addr2line反查; - 剩下的靠看代码推断类型——M0 上最常见的就是非对齐访问和空/野指针。
⚠️ 这条差异会咬人:同一份解析协议的代码,在 F4 上跑得好好的(因为 F4 默认
UNALIGN_TRP = 0,单字非对齐访问硬件自动拆分完成),移植到 G0 上一跑就 HardFault。别怀疑人生,就是内核不一样。老老实实用memcpy取值。
5.2 ESP32(Xtensa):Guru Meditation,不叫 HardFault
ESP32 / ESP32-S3 用的是 Xtensa 内核,跟 Cortex-M 完全是两套东西。它出事时串口会打这么一坨:
Guru Meditation Error: Core 0 panic'ed (LoadProhibited). Exception was unhandled.
Core 0 register dump:
PC : 0x400e14ed PS : 0x00060030 A0 : 0x800d0805 A1 : 0x3ffb5030
A2 : 0x00000000 A3 : 0x00000001 A4 : 0x00000001 A5 : 0x3ffb50dc
...
SAR : 0x00000014 EXCCAUSE: 0x0000001c EXCVADDR: 0x00000000
...
Backtrace: 0x400e14ed:0x3ffb5030 0x400d0802:0x3ffb5050
对照着看就懂了:
| Cortex-M | ESP32 (Xtensa) | 说明 |
|---|---|---|
栈帧里的 PC | PC | 出事指令地址 |
CFSR 子类型 | 括号里的原因 + EXCCAUSE | 如 LoadProhibited(读非法地址,典型 NULL 解引用)、StoreProhibited、IllegalInstruction |
BFAR/MMFAR | EXCVADDR | 出事的那个数据地址 |
| R0~R3、R12 | A0~A15 | Xtensa 的通用寄存器窗口 |
| 手动 addr2line | Backtrace: PC:SP PC:SP ... | 一串"程序计数器:栈指针"对 |
关键工具是 IDF Monitor:用 idf.py monitor 看串口,它会自动把 PC 翻译成函数名 + 文件 + 行号贴在下面,你不用手动 addr2line。ESP32-C3 这类 RISC-V 芯片,IDF Monitor 也能根据 panic 里的栈 dump 重建出可读的 backtrace。
行为还能配:menuconfig 里 CONFIG_ESP_SYSTEM_PANIC 可以选"打印寄存器后重启(默认)/ 打印后halt / 静默重启 / 启动 GDB Stub"。调试期我一般选 halt,免得刷屏刷没了。
5.3 ESP32-C3 / C6(RISC-V):又是另一套寄存器名
Core 0 register dump:
MEPC : 0x420048b4 RA : 0x420048b4 SP : 0x3fc8f2f0 GP : 0x3fc8a600
...
MSTATUS : 0x00001881 MTVEC : 0x40380001 MCAUSE : 0x00000007 MTVAL : 0x00000000
MHARTID : 0x00000000
| 看什么 | RISC-V 寄存器 | 对应 Cortex-M 的谁 |
|---|---|---|
| 出事指令地址 | MEPC | 栈帧里的 PC |
| 出错原因编码 | MCAUSE | CFSR 的角色 |
| 出错的数据地址 | MTVAL | BFAR / MMFAR |
| 返回地址 | RA | LR |
⚠️ 别去调用
esp_cpu_dump这种名字看着很像的函数——它不是 ESP-IDF 的公开 API,不同版本可能改名或消失,编译不过是轻的,跟着版本漂移才麻烦。要拿现场就用官方机制:panic handler 自动打印 + IDF Monitor 解析,需要更完整的现场就开 Core Dump(menuconfig里选存 Flash 或走 UART,再用idf.py coredump-info/idf.py coredump-debug解析)。
六、8 个我真踩过的坑
| # | 坑 | 现象 / 后果 | 正确做法 |
|---|---|---|---|
| 1 | 局部数组越界踩栈 char buf[16]; sprintf(buf, ...) 写了 20 字节 | 函数返回时才炸,PC 跳到一个鬼地址;CFSR 常见 INVSTATE(bit17) 或 UNDEFINSTR(bit16)。最坑的是"炸的地方离肇事代码十万八千里" | 用 snprintf 带长度;数组大小和写入长度都做检查;开 -Wall -Wextra;有 MPU 的话给栈底设个不可写区当"金丝雀" |
| 2 | 非对齐强转 *(uint32_t *)(rx_buf + 1) | M0/M0+ 直接 HardFault;M3/M4 默认不报错(硬件拆分)但性能差,一旦开了 UNALIGN_TRP 或换成 LDRD/LDM 就炸 → UFSR.UNALIGNED(bit24) | 一律用 memcpy(&val, rx_buf + 1, 4),编译器会优化成最优指令;或者协议设计时就做 4 字节对齐 |
| 3 | NULL / 野指针解引用(malloc 没查返回值、结构体指针没初始化) | 在 STM32 上 读 0 地址往往不报错(0x0 被别名到 Flash 或系统存储器,能读出数据),写 0 地址才炸 → 于是 bug 潜伏很久才发作 | 指针定义时初始化为 NULL,用之前 if (p == NULL) return;;malloc/pvPortMalloc 的返回值必须检查 |
| 4 | ISR 里调阻塞 API:HAL_Delay()、printf()、xQueueSend() | HAL_Delay 靠 SysTick 中断计数,如果 ISR 优先级比 SysTick 高就永久卡死;FreeRTOS 非 FromISR 版本会触发 configASSERT,最终常表现为 HardFault | ISR 里只置标志/发数据,重活交给主循环或任务;FreeRTOS 一律用 xQueueSendFromISR() 等 FromISR 版本 |
| 5 | __disable_irq() / __enable_irq() 没配对(中间 return 提前跑了) | 中断被永久关闭:串口不响应、SysTick 不走、HAL_Delay 死等 → 看门狗复位或彻底假死。注意 PRIMASK 不会自动恢复(只有 FAULTMASK 会在异常返回时被硬件清零) | 临界区用"保存-恢复"写法:uint32_t p = __get_PRIMASK(); __disable_irq(); ... __set_PRIMASK(p);;FreeRTOS 里用 taskENTER_CRITICAL()/taskEXIT_CRITICAL() 配对 |
| 6 | FPU 没使能就用 float | UFSR.NOCP(bit19) → 升级成 HardFault。手写工程或从 F1 改过来的工程最容易中招 | 确认启动流程里打开了 CP10/CP11(CubeMX 生成的 SystemInit() 会做);编译选项 -mfpu=fpv4-sp-d16 -mfloat-abi=hard 要和芯片匹配 |
| 7 | 改了向量表位置忘了改 VTOR(Bootloader 跳 App、把向量表搬到 RAM) | 一进中断就死,HFSR.VECTTBL(bit1) = 1 | 跳转前设好 SCB->VTOR = APP_BASE_ADDR;,改完加 __DSB(); __ISB();;App 的链接脚本起始地址也要同步改 |
| 8 | 在 HardFault_Handler 里 printf / 没接调试器却执行 BKPT | 二次 fault,连死机信息都打不出来;BKPT 在无调试器时会置 HFSR.DEBUGEVT 又进一次 fault | handler 里只做"存变量 + 轮询发送";BKPT 前先判 CoreDebug->DHCSR & C_DEBUGEN(见 3.4 的代码) |
💡 这 8 个里,第 1、3、5 条是我自己真栽过的。第 5 条那次最惨:临界区里加了个"参数非法就 return"的保护,结果参数真非法了一次,中断从此再没开过,设备表现为"随机死机",查了两天。
七、动手练:故意把板子搞死,再自己救回来
这一节必须真烧到板子上跑,光看不练留不下印象。准备:任意一块 STM32(F1/F4/G0 都行)+ ST-Link 或 J-Link。
第 1 步:把会说话的 Handler 装上
把 3.1 的汇编(按你的内核选 M3/M4 版还是 M0 版)和 3.4 的 hardfault_report() 复制进工程。注意删掉启动文件里原来的弱定义 HardFault_Handler——GCC 的启动文件里它是 .weak,你重新定义同名函数就会自动覆盖,一般不用手动删;如果链接报 “multiple definition”,说明你的启动文件没写 .weak,把里面那个删掉即可。编译烧录,确认程序还能正常跑。
第 2 步:制造一次 NULL 写入(BusFault)
void crash_null(void)
{
volatile uint32_t *p = (uint32_t *)0x00000000;
*p = 0x12345678; /* 往 0 地址写 —— 在 STM32 上会炸 */
}
在按键中断或者 main 循环里调它。死了之后:
- 用 SWD 暂停,在 Watch 窗口看
g_fault; - 检查
cfsr:应该看到 BusFault 段有值(PRECISERRbit9 或IMPRECISERRbit10)。写操作因为有写缓冲,很可能报的是 imprecise——这正好是你练 3.5 那招的机会:加上DISDEFWBUF再跑一次,看它变不变成PRECISERR; - 若
BFARVALID为 1,bfar应该是0x00000000(imprecise 时 BFAR 通常无效,别硬信); - 用
arm-none-eabi-addr2line查g_fault.pc,看是不是正好落在crash_null那一行。
思考题:把 *p = 0x12345678; 改成 uint32_t v = *p;(改成读),再跑一次。大概率不会死——想想为什么?(提示:回看踩坑表第 3 条,0 地址在 STM32 上被别名到哪里了。)
第 3 步:制造一次非对齐访问(UsageFault)
uint8_t g_buf[16] = {0};
void crash_unaligned(void)
{
/* 打开非对齐陷阱(M0/M0+ 没有 CCR 这个位,跳过本行,它本来就一律 fault) */
SCB->CCR |= SCB_CCR_UNALIGN_TRP_Msk; /* CCR bit3 */
__DSB(); __ISB();
/* ⚠️ 下面这行是"故意写的未定义行为",只用于教学复现,正式代码请用 memcpy */
volatile uint32_t v = *(uint32_t *)(g_buf + 1); /* 地址 +1,必定非对齐 */
(void)v;
}
预期:CFSR 的 bit24 UNALIGNED = 1,HFSR 的 bit30 FORCED = 1。
对比实验:把 SCB->CCR |= SCB_CCR_UNALIGN_TRP_Msk; 那行注释掉,在 F4 上再跑——不会死(硬件自动拆分)。同样的代码烧到 G0/F0(Cortex-M0+)上——必死。这个对比能让你彻底记住 5.1 那张表。
第 4 步:手工解码一遍(不许用 Fault Analyzer)
死机后,用 OpenOCD 或 J-Link Commander,只用命令行走一遍 4.2 / 4.3 的流程:halt → reg lr 判 MSP/PSP → 读栈帧 8 个字 → 读 CFSR/HFSR → addr2line。全部做完再打开 STM32CubeIDE 的 Fault Analyzer 对答案。手工能跑通,你以后换任何芯片、任何 IDE 都不虚。
第 5 步(进阶):让不精确变精确
在初始化里加上 SCnSCB->ACTLR |= SCnSCB_ACTLR_DISDEFWBUF_Msk;(仅 M3/M4),再复现一次"往未使能时钟的外设寄存器写数据":
/* 故意不调用 __HAL_RCC_GPIOE_CLK_ENABLE() 就写 GPIOE 寄存器 */
GPIOE->MODER = 0x5555;
对比开启前后 CFSR 里是 IMPRECISERR 还是 PRECISERR,以及 PC 指的位置差了几条指令。
⚠️ “访问未使能时钟的外设会不会报总线错误"是芯片实现相关的:STM32F4 上很典型(会报),个别系列可能只是读回 0 而不 fault。跑不出来别怀疑自己,换成"往一个不存在的地址写”(比如
*(volatile uint32_t *)0x60000000 = 1;,前提是你的板子没接 FSMC/FMC 外部存储)同样能复现。
八、小结与速查卡
一句话版本:HardFault 不是黑盒,它是芯片给你留的一份完整案发笔录,你只是一直没去读。
死机了 → 接 SWD → halt
│
├─ reg lr ── bit2=1 用 PSP,=0 用 MSP
├─ reg msp/psp ─ 得到 frameptr
├─ mdw frameptr 8 ── 第 7 个字 = PC
├─ mdw 0xE000ED2C 1 ── HFSR:FORCED? VECTTBL?
├─ mdw 0xE000ED28 1 ── CFSR:UFSR|BFSR|MMFSR
└─ addr2line -e app.elf <PC> ── 源码第几行
关键地址背下来,能省很多翻手册的时间:
| 寄存器 | 地址 | 干嘛的 |
|---|---|---|
CCR | 0xE000ED14 | DIV_0_TRP(bit4) / UNALIGN_TRP(bit3) / STKALIGN(bit9) |
SHCSR | 0xE000ED24 | 使能 Mem/Bus/Usage Fault(默认全关) |
CFSR | 0xE000ED28 | 死因主表(写 1 清零) |
HFSR | 0xE000ED2C | FORCED(bit30) / VECTTBL(bit1) / DEBUGEVT(bit31) |
MMFAR | 0xE000ED34 | MemManage 出错地址(看 MMARVALID) |
BFAR | 0xE000ED38 | BusFault 出错地址(看 BFARVALID) |
为什么秋招要会这个? 嵌入式面试里"设备死机了你怎么定位"几乎是必问题,而且是少数能一眼看出你有没有真上过手的问题。只会说"打断点、加打印"和能说清"先看 LR 的 bit2 判 MSP/PSP,读栈帧第 7 个字拿 PC,再查 CFSR 分类型,imprecise 就关写缓冲",给面试官的印象完全是两个层级。这套流程我建议你照着第七节真跑一遍,跑过之后讲出来是有细节的,背出来的没有。
最后提醒一句:别等出事了才想起来加 dump 代码。我现在建工程的第一件事,就是把 HardFault_Handler 那段模板贴进去——成本 5 分钟,能省你一个通宵。
文中涉及的寄存器、栈帧布局都对照了 ARM 架构手册和 ST / SEGGER / 乐鑫的官方文档,想深挖的按关键词去翻对应手册即可。
下一篇:调试排错_02_逻辑分析仪实战从波形看通信错
更多推荐
所有评论(0)