适用人群:能用 CubeMX/HAL 或 ESP-IDF 点亮灯、跑通串口,但设备一"卡死"就只会疯狂注释代码二分法找问题的同学(也就是一年前的我)。
读完你能得到:① 搞懂 HardFault 到底是谁、从哪来;② 会读硬件自动压栈的"案发现场"(8 个寄存器);③ 一套五步定位法:从 CFSR/HFSR 判类型 → 从栈帧取 PC → 反查到源码某一行;④ 一份可以直接抄进工程的 HardFault_Handler dump 代码(M3/M4 版 + M0 版);⑤ OpenOCD / J-Link / STM32CubeIDE 三种 SWD 挖现场的实操命令;⑥ ESP32(Xtensa / RISC-V)的 panic 输出怎么读;⑦ 8 个我真踩过的坑。


一、场景:设备跑着跑着就"死"了

先说一个我大三做课设时的真事。

一个 STM32F407 的温湿度采集板,串口每秒打一行数据。平时跑一整晚都没事,但只要我把采样间隔调快到 100ms,跑个十几分钟,串口就突然不打印了。灯不闪、按键没反应、也不复位——就是死在那儿。

我当时的操作是这样的:

  1. 注释掉 DMA,跑一晚上,没死 → “哦是 DMA 的锅”
  2. 恢复 DMA,注释掉 OLED 刷新,又跑一晚上,还是死 → “不对啊……”
  3. 反复第 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 FaultMPU 权限违规访问了 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

含义
bit21异常返回后用 PSP(现场压在 PSP 上)
bit20异常返回后用 MSP(现场压在 MSP 上)
bit31返回到 Thread 模式(0 = Handler 模式,说明是"异常里又出异常")
bit40用的是扩展帧(有 FPU 现场,帧长 0x68)
bit41用的是基本帧(帧长 0x20)

常见取值,背下来定位很快:

EXC_RETURN二进制低 5 位解读
0xFFFFFFF11_0001Handler 模式 + MSP + 基本帧 → 异常嵌套里炸的
0xFFFFFFF91_1001Thread 模式 + MSP + 基本帧 → 裸机 main 里炸的
0xFFFFFFFD1_1101Thread 模式 + PSP + 基本帧 → RTOS 任务里炸的
0xFFFFFFED0_1101Thread + 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)只有三个位有用:

名称含义你该干嘛
bit1VECTTBL读取向量表时就出错了VTOR 设置、Bootloader 跳转、向量表有没有被搬到 RAM 又忘了改 VTOR
bit30FORCED由 Bus/Usage/MemManage 升级而来90% 的情况是这个 → 立刻去读 CFSR
bit31DEBUGEVT调试事件引起一般是你自己下了断点 / 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 / 存储器管理

名称人话
0IACCVIOL到 MPU 禁止执行的区域去取指令了
1DACCVIOL读/写了 MPU 不允许的地址
3MUNSTKERR异常返回出栈时违规(多半是栈指针已经被踩烂了)
4MSTKERR异常入口压栈时违规(同上,典型的栈溢出)
7MMARVALIDMMFAR(0xE000ED34)里的地址有效 ← 只有它为 1,MMFAR 才可信

BFSR(bit8~15):总线

名称人话
8IBUSERR取指令时总线出错(PC 跑飞到不存在的地址)
9PRECISERR精确总线错误 → 栈帧里的 PC 就是闯祸那条指令
10IMPRECISERR不精确总线错误 → PC 不可信(见 3.5)
11UNSTKERR出栈时总线错误
12STKERR压栈时总线错误(栈指针指到了非法区域)
13LSPERR浮点惰性压栈(lazy stacking)时出错
15BFARVALIDBFAR(0xE000ED38)里的地址有效 ← 同理,为 0 时 BFAR 是垃圾值

UFSR(bit16~31):指令/用法

名称人话新手命中率
16UNDEFINSTR执行了未定义指令★★★ 函数指针为野指针、PC 跳进数据区
17INVSTATE试图切到 ARM 状态(Thumb 位丢了)★★★ 函数指针最低位没置 1、返回地址被踩
18INVPC非法的 EXC_RETURN / PC 加载★★ 中断里乱改 LR、栈被踩
19NOCP访问了没使能的协处理器★★★ FPU 没打开就用 float
24UNALIGNED非对齐访问★★★ *(uint32_t*)(buf + 1)
25DIVBYZERO除零★★ 见下面的注意事项

⚠️ 三条容易记错的规则,别背反了

  1. 除零默认不会 fault。只有把 CCR.DIV_0_TRPCCR = 0xE000ED14 的 bit4)置 1,整数除零才会触发 UsageFault;默认情况下 x / 0 的结果直接是 0,静悄悄地把错误算法往下传。
  2. CCR.UNALIGN_TRP(bit3)只管单个 word/halfword 的非对齐访问。而多字访问(LDM/STM/LDRD/STRD)以及 word/halfword 的独占访问(LDREX/STREX),无论 UNALIGN_TRP 怎么设,非对齐一律 fault(ARMv7-M / Cortex-M 除 M0 系列外)。
  3. 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 里改名成了 DCBDCB->DHCSR),报"未定义"就换一下。
  • 别在 fault handler 里 printfprintf 可能走 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 mspreg 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 里的图形化捷径

  • STM32CubeIDEWindow → 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 非对齐访问)

解读过程:

  1. LR = 0xFFFFFFFD → 现场在 PSP,是任务代码里炸的(不是中断里)。
  2. HFSR = 0x4000_0000FORCED = 1,HardFault 是替人挨刀,真凶看 CFSR。
  3. CFSR = 0x0100_0000 → bit24 UNALIGNED非对齐访问
  4. 栈帧第 7 个字 PC = 0x08001A2C,是 UsageFault(精确异常),所以这就是出事那条指令
  5. 顺手看一眼 R1 = 0x00000000R3 = 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/M4Cortex-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。定位方法退化成:

  1. 抓栈帧(用 3.1 的 M0 版汇编);
  2. PCaddr2line 反查;
  3. 剩下的靠看代码推断类型——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-MESP32 (Xtensa)说明
栈帧里的 PCPC出事指令地址
CFSR 子类型括号里的原因 + EXCCAUSELoadProhibited(读非法地址,典型 NULL 解引用)、StoreProhibitedIllegalInstruction
BFAR/MMFAREXCVADDR出事的那个数据地址
R0~R3、R12A0~A15Xtensa 的通用寄存器窗口
手动 addr2lineBacktrace: PC:SP PC:SP ...一串"程序计数器:栈指针"对

关键工具是 IDF Monitor:用 idf.py monitor 看串口,它会自动把 PC 翻译成函数名 + 文件 + 行号贴在下面,你不用手动 addr2line。ESP32-C3 这类 RISC-V 芯片,IDF Monitor 也能根据 panic 里的栈 dump 重建出可读的 backtrace。

行为还能配:menuconfigCONFIG_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
出错原因编码MCAUSECFSR 的角色
出错的数据地址MTVALBFAR / MMFAR
返回地址RALR

⚠️ 别去调用 esp_cpu_dump 这种名字看着很像的函数——它不是 ESP-IDF 的公开 API,不同版本可能改名或消失,编译不过是轻的,跟着版本漂移才麻烦。要拿现场就用官方机制:panic handler 自动打印 + IDF Monitor 解析,需要更完整的现场就开 Core Dumpmenuconfig 里选存 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 字节对齐
3NULL / 野指针解引用malloc 没查返回值、结构体指针没初始化)在 STM32 上 读 0 地址往往不报错(0x0 被别名到 Flash 或系统存储器,能读出数据),写 0 地址才炸 → 于是 bug 潜伏很久才发作指针定义时初始化为 NULL,用之前 if (p == NULL) return;malloc/pvPortMalloc 的返回值必须检查
4ISR 里调阻塞 APIHAL_Delay()printf()xQueueSend()HAL_Delay 靠 SysTick 中断计数,如果 ISR 优先级比 SysTick 高就永久卡死;FreeRTOS 非 FromISR 版本会触发 configASSERT,最终常表现为 HardFaultISR 里只置标志/发数据,重活交给主循环或任务;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() 配对
6FPU 没使能就用 floatUFSR.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 的链接脚本起始地址也要同步改
8HardFault_Handlerprintf / 没接调试器却执行 BKPT二次 fault,连死机信息都打不出来;BKPT 在无调试器时会置 HFSR.DEBUGEVT 又进一次 faulthandler 里只做"存变量 + 轮询发送";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 段有值(PRECISERR bit9 或 IMPRECISERR bit10)。写操作因为有写缓冲,很可能报的是 imprecise——这正好是你练 3.5 那招的机会:加上 DISDEFWBUF 再跑一次,看它变不变成 PRECISERR
  • BFARVALID 为 1,bfar 应该是 0x00000000(imprecise 时 BFAR 通常无效,别硬信);
  • arm-none-eabi-addr2lineg_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 的流程:haltreg 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> ── 源码第几行

关键地址背下来,能省很多翻手册的时间:

寄存器地址干嘛的
CCR0xE000ED14DIV_0_TRP(bit4) / UNALIGN_TRP(bit3) / STKALIGN(bit9)
SHCSR0xE000ED24使能 Mem/Bus/Usage Fault(默认全关)
CFSR0xE000ED28死因主表(写 1 清零)
HFSR0xE000ED2CFORCED(bit30) / VECTTBL(bit1) / DEBUGEVT(bit31)
MMFAR0xE000ED34MemManage 出错地址(看 MMARVALID
BFAR0xE000ED38BusFault 出错地址(看 BFARVALID

为什么秋招要会这个? 嵌入式面试里"设备死机了你怎么定位"几乎是必问题,而且是少数能一眼看出你有没有真上过手的问题。只会说"打断点、加打印"和能说清"先看 LR 的 bit2 判 MSP/PSP,读栈帧第 7 个字拿 PC,再查 CFSR 分类型,imprecise 就关写缓冲",给面试官的印象完全是两个层级。这套流程我建议你照着第七节真跑一遍,跑过之后讲出来是有细节的,背出来的没有。

最后提醒一句:别等出事了才想起来加 dump 代码。我现在建工程的第一件事,就是把 HardFault_Handler 那段模板贴进去——成本 5 分钟,能省你一个通宵。

文中涉及的寄存器、栈帧布局都对照了 ARM 架构手册和 ST / SEGGER / 乐鑫的官方文档,想深挖的按关键词去翻对应手册即可。

下一篇:调试排错_02_逻辑分析仪实战从波形看通信错

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐