{"code":1,"msg":"成功","time":"1791681122","data":{"title":"MCU HardFault问题现场","content":"# 从 HardFault 里把现场捞出来：Cortex-M 崩溃定位（上）\r\n\r\n## 一句话结论\r\n\r\nCortex-M 崩了不会自动告诉你任何事，但它已经在异常入口处把最关键的 8 个寄存器压进了栈，同时把错误原因写进了 SCB 里的几个状态寄存器。你要做的只是在 HardFault 里把它们读出来、打印出来——这一步做到位，MCU 就能像 PC 程序一样告诉你\"挂在哪一行\"。\r\n\r\n> 本文是系列上篇，只解决\"打印现场\"，不还原完整调用链。下篇再讲如何从栈里把调用链扒出来。\r\n\r\n【MCU HardFault问题现场】 https:\/\/www.bilibili.com\/video\/BV1JcHD6cEec\/?share_source=copy_web&vd_source=79b2c8de19b1377f95a5ada384ced7f2\r\n\r\n## 为什么 MCU 崩了像人间蒸发\r\n\r\nLinux 程序崩了，系统会生成 core dump，直接告知执行到哪一行、谁调用了谁。Windows 上 Visual Studio 直接给你一个调用栈窗口，点一下就跳到出错源码。\r\n\r\nMCU 固件运行时出了问题——访问了非法地址、执行了未定义指令、栈溢出了——它不会自动打印任何东西，不会生成 dump 文件，也不会帮你记录现场。CPU 只会做一件事：**跳进 HardFault 异常**。这是 Cortex-M 核的\"最后兜底\"，所有软件层面兜不住的错误，最终都会到这里来。\r\n\r\n问题是，HardFault 不会自动回溯调用链。我们能拿到的只是一组寄存器——SP、PC、LR，以及 SCB 里几个状态寄存器。没有 OS 帮忙记录现场，也没有调试器自动展开栈帧。我们得从这几个寄存器里手工恢复出调用栈和错误原因。\r\n\r\n最终目的其实和 PC 程序一样：**找到运行出错的位置，再判断为什么出错。**\r\n\r\n## 一、HardFault 是怎么被触发的\r\n\r\n可以从 HardFault 状态寄存器（HFSR）的说明来看。HardFault 一共有三种可能的触发原因。\r\n\r\n### 第一种：取向量表时发生总线错误\r\n\r\nCPU 想从向量表里读一个异常入口地址，结果读的时候出错了，连\"跳去哪\"都不知道，只能直接进 HardFault。比如 VTOR 寄存器配错了，或者向量表被踩了，就会出现这种情况。\r\n\r\n### 第二种：可配置 fault 被禁用或优先级不够，升级上来的\r\n\r\n可配置 fault 指的是 MemManageFault、BusFault、UsageFault。它们可以配置异常优先级，也可以被关掉。关掉的时候、或者抢不进来的时候，就会升级成 HardFault。\r\n\r\n为什么抢不进来？举个例子：假设在优先级为 2 的定时器中断里发生了 BusFault，但 BusFault 处理函数的优先级是 3。3 低于 2，抢不进来。同步 fault 又不能等，CPU 必须马上处理——这就成了死局，直接升级成 HardFault。\r\n\r\n还有一种典型情况：BusFault 里面又发生了 BusFault。同一种异常无法被自身抢占，也会升级成 HardFault。\r\n\r\n### 第三种：调试器相关事件触发的\r\n\r\n三种里面，第一种和第三种相对明确。**实际项目里最常见的是第二种**——因为 configurable fault 的升级条件很多，很难穷举。\r\n\r\n我们要做的不是应对所有情况，而是在 HardFault 发生时，尽量把现场信息保存下来。\r\n\r\n## 二、现场信息从哪来\r\n\r\n这依赖 Cortex-M 的异常机制。ARMv7-M 架构手册对异常进入行为有详细说明：进入异常时，**硬件会自动把一组上下文信息压到栈上**，具体就是这 8 个寄存器：\r\n\r\n```\r\n高地址\r\n+------------------+\r\n|      xPSR        |  frameptr + 0x1C\r\n+------------------+\r\n|  ReturnAddress   |  frameptr + 0x18  ← 出事的那条指令\r\n+------------------+\r\n|       LR         |  frameptr + 0x14  ← 是谁调用过来的\r\n+------------------+\r\n|       R12        |  frameptr + 0x10\r\n+------------------+\r\n|       R3         |  frameptr + 0x0C\r\n+------------------+\r\n|       R2         |  frameptr + 0x08\r\n+------------------+\r\n|       R1         |  frameptr + 0x04\r\n+------------------+\r\n|       R0         |  frameptr + 0x00  ← 进入异常后的 SP\r\n+------------------+\r\n低地址\r\n```\r\n\r\n如果开了浮点扩展，还会额外保存浮点寄存器——这个暂时不展开。这 8 个寄存器就是硬件留给我们的\"案发现场\"。\r\n\r\n要读懂它们，才能反推问题所在位置。\r\n\r\n### R0–R3：函数传参\r\n\r\nAAPCS 调用标准规定，函数默认通过 R0–R3 按顺序传递参数。例如这样一个简单的求和函数：\r\n\r\n```c\r\nint sum(int a, int b) {\r\n    return a + b;\r\n}\r\n```\r\n\r\n反汇编大致是：\r\n\r\n```asm\r\nsum:\r\n    ADD  r0, r0, r1\r\n    BX   lr\r\n```\r\n\r\n调用方把实参放进 r0 和 r1，然后跳转到 sum。这里只有两个参数，所以 R2、R3 是调用者之前残留的，不需要关注。\r\n\r\n对问题回溯来说，参数信息价值不高——很难仅凭参数值推断出问题在哪。\r\n\r\n### R12：临时寄存器\r\n\r\nR12 是函数调用的临时寄存器，基本是编译器自己用的，对调试回溯基本没用。\r\n\r\n### LR（R14）：函数返回地址\r\n\r\n还是以 sum 为例。调用 sum 时，进入 sum 执行前，LR 会被设置为 sum 执行完后的返回地址——也就是调用 sum 之后下一条指令的位置。\r\n\r\n当函数执行过程中发生异常，LR 值就被压到栈上。它记录的是\"异常发生前，函数是从哪里调用的\"，对回溯有帮助。\r\n\r\n当然，这是正常函数调用的情况。等异常真正发生时，旧的 LR 被压入栈后，硬件会把 LR 寄存器覆盖成一个特殊值——后面再细说。\r\n\r\n### ReturnAddress：异常返回地址\r\n\r\n作用和 LR 类似，但它记录的是异常返回地址。如果异常发生在 sum 里的那条指令上，ReturnAddress 的值就是那条指令的地址。这是**最精准的定位信息**，能直接告诉你程序死在哪一行。\r\n\r\n不过要注意：**如果是非同步错误触发的异常，异常进入的位置不一定就是错误发生的位置。**\r\n\r\n比如你往一个 write buffer 里写了个非法地址，CPU 不会马上 fault，等 buffer 刷出去的时候才报错——这时候 ReturnAddress 指向的是 buffer 刷新时的代码，而不是写非法地址的那行代码。\r\n\r\n但 HardFault 大部分情况来自同步错误，所以 ReturnAddress 通常就直接指向\"凶手\"。\r\n\r\n### PSR：程序状态寄存器\r\n\r\n这里面有几个位域很有用：\r\n\r\n- **exception number**：表明异常发生前是处于 thread mode，还是正在执行哪个异常。在 RTOS 环境里，这能区分是任务出错，还是内核或中断出错。\r\n- **条件标志位**：比如零标志 Z、进位标志 C，能反映最后一条运算指令的结果，有时候能判断是不是某个边界条件没处理到。\r\n\r\n到这里，栈上这 8 个寄存器的作用就清楚了。但光看它们，只能知道\"程序停在了哪里\"，没法知道\"为什么停在这里\"。\r\n\r\n## 三、错误原因：HFSR 与 CFSR\r\n\r\n前面讲 HardFault 触发原因时已经接触了 HardFault 状态寄存器。它能指示具体错误原因：是向量表取指出错，还是某个 Configurable Fault 因为优先级不够或者没使能，被升级成了 HardFault。\r\n\r\n如果是升级上来的，通过 HFSR 只知道\"是被升级上来的\"，具体是什么问题，还得进一步查可配置 fault 状态寄存器（CFSR）。\r\n\r\nCFSR 是 32 位寄存器，按三种可配置 fault 划分为三个域：\r\n\r\n| 位域 | 名称 | 常见错误 |\r\n|------|------|----------|\r\n| [31:16] | UsageFault（UFSR） | 未定义指令、除零、未对齐访问、非法 EXC_RETURN |\r\n| [15:8] | BusFault（BFSR） | 取指\/加载\/存储时总线返回错误响应 |\r\n| [7:0] | MemManage（MMFSR） | MPU 越权、访问受保护内存 |\r\n\r\n具体位定义：\r\n\r\n**MMFSR [7:0]**\r\n- `IACCVIOL`：取指越权（XN 区域执行）\r\n- `DACCVIOL`：数据访问越权，MMFAR 给出非法地址\r\n- `MSTKERR` \/ `MUNSTKERR`：异常入栈\/出栈时又发生 MemManage fault\r\n- `MMARVALID`：MMFAR 里是有效地址\r\n\r\n**BFSR [15:8]**\r\n- `IBUSERR`：指令预取总线错误\r\n- `PRECISERR`：精确的总线错误，BFAR 里是出错地址\r\n- `IMPRECISERR`：不精确的总线错误，BFAR 无效，ReturnAddress 不可信\r\n- `STKERR` \/ `UNSTKERR`：入栈\/出栈时又发生 BusFault（**栈溢出往往表现为这个**）\r\n- `BFARVALID`：BFAR 里是有效地址\r\n\r\n**UFSR [31:16]**\r\n- `UNDEFINSTR`：执行了未定义指令\r\n- `INVSTATE`：指令执行时 EPSR.T 或 IT 位非法\r\n- `INVPC`：EXC_RETURN 完整性检查失败（LR 里是个非法值）\r\n- `NOCP`：访问了不存在或未使能的协处理器\r\n- `UNALIGNED`：未对齐访问（需 CCR.UNALIGN_TRP 使能）\r\n- `DIVBYZERO`：除零（需 CCR.DIV_0_TRP 使能）\r\n\r\n**HFSR [31:0]**\r\n- `VECTTBL`：异常入口取向量表时总线错误\r\n- `FORCED`：有可配置优先级的 fault 因优先级或禁用原因升级为 HardFault\r\n- `DEBUGEVT`：调试事件触发\r\n\r\n需要强调的是：**这几个子 fault 是可配置优先级的，甚至可以禁掉。** 当子 fault 触发时，如果没开对应的子 fault，或者优先级不够导致无法抢占，就会升级进 HardFault。\r\n\r\n不过即使升级成 HardFault，CFSR 里对应的错误位照样会被置 1。所以进 HardFault 之后一定要**同时读 HFSR 和 CFSR**，才能完整定位问题。\r\n\r\n## 四、代码实现\r\n\r\n理论说完，看具体代码。这里基于 STM32F103 的 CubeMX 基础工程来改。\r\n\r\nCubeMX 生成的工程里有一个 `stm32f1xx_it.c` 文件，定义了各种异常处理函数。默认实现都是 `while(1)` 死循环——触发 HardFault 就卡死，没有任何反馈。\r\n\r\n这种默认处理对严谨的项目来说肯定不行。我们改一下。\r\n\r\n### 1. 用汇编写入口，选出正确的栈指针\r\n\r\n```asm\r\nHardFault_Handler:\r\n    TST    LR, #4\r\n    ITE    EQ\r\n    MRSEQ  R0, MSP\r\n    MRSNE  R0, PSP\r\n    B      fault_dump\r\n```\r\n\r\n为什么要判断用哪个栈？这和 LR 有关。\r\n\r\n前面讲过，LR 是函数返回地址，但进入异常时 LR 的用途不同：CPU 把旧的 LR（进入异常前的函数返回地址）压栈后，会给 LR 赋一个特殊值——`EXC_RETURN`。这个值的 bit 2 代表\"异常结束后使用哪个栈指针\"：0 表示 MSP，1 表示 PSP。所以 `TST LR, #4` 就是测这个位——结果为 0 时 Z 标志置 1，`ITE EQ` 走 EQ 分支读 MSP，否则读 PSP。\r\n\r\n裸机程序全程只用 MSP，这里这样写是为了兼容 RTOS 的情况。\r\n\r\n确定栈指针后，就跳转到 `fault_dump`。\r\n\r\n### 2. fault_dump：读出并打印现场\r\n\r\n```c\r\nvoid fault_dump(uint32_t *sp, uint32_t fault_id) {\r\n    uint32_t r0  = sp[0];\r\n    uint32_t r1  = sp[1];\r\n    uint32_t r2  = sp[2];\r\n    uint32_t r3  = sp[3];\r\n    uint32_t r12 = sp[4];\r\n    uint32_t lr  = sp[5];\r\n    uint32_t pc  = sp[6];\r\n    uint32_t psr = sp[7];\r\n\r\n    uint32_t hfsr  = SCB->HFSR;\r\n    uint32_t cfsr  = SCB->CFSR;\r\n    uint32_t mmfar = SCB->MMFAR;\r\n    uint32_t bfar  = SCB->BFAR;\r\n\r\n    \/* 打印 sp、r0-r3、r12、lr、pc、psr *\/\r\n    \/* 打印 hfsr 的 VECTTBL \/ FORCED \/ DEBUGEVT *\/\r\n    \/* 打印 cfsr 的 UFSR \/ BFSR \/ MMFSR 各关键位 *\/\r\n    \/* 若 BFARVALID 置位，打印 BFAR；若 MMARVALID 置位，打印 MMFAR *\/\r\n}\r\n```\r\n\r\n做的事情就是：从 R0 拿到栈指针，把栈上 8 个寄存器读出来，再读 SCB 里的几个状态寄存器，最后通过串口打印出来。状态寄存器的关键位都做了解码，直接输出人话——这样后面调试的时候就不用去翻手册。\r\n\r\n### 3. 测试：主动触发一个 BusFault\r\n\r\n```c\r\nvoid trigger_busfault(void) {\r\n    volatile uint32_t *p = (uint32_t *)0xDEADBEEC;\r\n    (void)*p;\r\n}\r\n```\r\n\r\n在 `main.c` 主循环第三次打印 `running` 时调用它。Cortex-M 核默认不启用 BusFault，所以它会升级为 HardFault，进入 dump 函数打印现场信息。\r\n\r\n编译、烧录、打开串口，复位开发板，可以看到：第三次 `running` 后程序终止，并打印了相关信息。\r\n\r\nPC 指向出错的指令，BFAR 指示访问了地址 `0xDEADBEEC`——和代码里非法访问的地址一致。\r\n\r\n拿到 PC 值后，可以通过 `addr2line` 直接定位到 PC 值所在的代码行：\r\n\r\n```bash\r\narm-none-eabi-addr2line -e build\/firmware.elf 0x08000E52\r\n```\r\n\r\n输出就是导致 BusFault 的那行代码。\r\n\r\n> 一个坑：工程使用自定义链接脚本时，为了消除孤儿段警告，把 debug 相关 section 显式安排了。这种做法会影响 `addr2line` 的解析结果，可能输出一堆 `??:?`。目前没有两全其美的方法，我更倾向于保留链接脚本的做法——虽然影响解析，但调试工具的结果还是能正确输出，仿真调试也没问题。\r\n\r\n### 4. 测试 UsageFault\r\n\r\n```c\r\nvoid trigger_usagefault(void) {\r\n    int a = 1;\r\n    int b = 0;\r\n    int c = a \/ b;   \/\/ 除零\r\n    (void)c;\r\n}\r\n```\r\n\r\n第一次测试，程序正常运行，并没有进入 HardFault。这是因为除零操作的结果在 ARM 架构里是未定义的，但默认并不会触发 fault，需要先开启 `CCR.DIV_0_TRP`。\r\n\r\n未对齐访问也是类似的——一般情况下都要开启对应的控制位，这样代码里出现有风险的操作时才能及时预防。\r\n\r\n添加开启代码：\r\n\r\n```c\r\n\/* 在初始化阶段调用一次 *\/\r\nSCB->CCR |= SCB_CCR_DIV_0_TRP_Msk;      \/* 除零触发 UsageFault *\/\r\n\/* SCB->CCR |= SCB_CCR_UNALIGN_TRP_Msk; *\/  \/* 未对齐访问，按需开启 *\/\r\n\r\n\/* 启用三个子 fault 的独立处理 *\/\r\nSCB->SHCSR |= SCB_SHCSR_USGFAULTENA_Msk\r\n            | SCB_SHCSR_BUSFAULTENA_Msk\r\n            | SCB_SHCSR_MEMFAULTENA_Msk;\r\n```\r\n\r\n重新编译运行，这次就进入 HardFault 了，而且提示是除零操作导致的。同样可以拿 PC 值定位代码位置。\r\n\r\n### 5. 区分 fault 类型\r\n\r\nSTM32F103C8T6 没有 MPU，所以无法测试 MemManageFault。而由于默认没有启用 BusFault 和 UsageFault，所以无论什么类型 fault，都会进入 HardFault。\r\n\r\n如果对这些 fault 的处理方式一致，统一进 HardFault 也没问题——因为 HardFault 里会打印 CFSR 的值，这个状态寄存器能看出具体是哪种 fault 导致的，最终也能定位到准确原因。\r\n\r\n如果想更明确地处理不同类型 fault，那可以启用这些子 fault，然后在每种 fault 异常处理里都调用同一个 dump 函数，并传入 fault id，dump 根据 id 打印 fault 类型：\r\n\r\n```asm\r\nBusFault_Handler:\r\n    MOV   R1, #1\r\n    B     fault_dump_common\r\n\r\nUsageFault_Handler:\r\n    MOV   R1, #2\r\n    B     fault_dump_common\r\n```\r\n\r\n这样是哪种 fault 导致的就一目了然了。\r\n\r\n## 五、一点实操经验\r\n\r\n- **栈帧大小要留够**。每个异常至少消耗 8 个字（32 字节）。发生中断嵌套时，每个被抢占的异常都有自己的栈帧。更危险的是，某些 Fault 处理程序本身也可能因为栈问题无法正确入栈。在预估线程所需栈空间的基础上，额外预留（最大中断嵌套层数 × 32）字节，并留出至少 20% 的余量。\r\n- **栈溢出的典型特征**是 BFSR 的 `STKERR` 或 `UNSTKERR` 置位，或者压栈过程中 MSP 被写到非法地址——这时打出来的栈帧内容可能本身就是乱的，需要结合 map 文件看栈的实际落点。\r\n- **状态寄存器是 sticky 的**。HFSR、CFSR 都是\"读看、写 1 清\"。dump 时建议先读出全部值、再统一清位，避免在读取过程中被新的 fault 改动。\r\n- **生产环境可以把现场写进非易失存储**。如果设备现场无法接串口，可以把栈帧、HFSR、CFSR、BFAR\/MMFAR 写进 Flash 或 EEPROM，下次上电上报。这就构成了真正的 post-mortem crash log。\r\n\r\n## 六、小结\r\n\r\n到这里，我们已经做到了一件事：**MCU 跑挂之后，它能告知\"挂在哪一行代码\"。**\r\n\r\n这看起来简单，但对比一开始的\"只有死循环什么都不知道\"，已经是质的飞跃了。\r\n\r\n但现在只知道\"挂在哪一行\"，不知道\"谁调用的\"。如果是一个嵌套了七八层的调用链，怎么知道是从哪条路径走到这里的？\r\n\r\n**下一期就讲怎么从栈里把完整调用链还原出来。**\r\n\r\n## 参考\r\n\r\n- ARMv7-M Architecture Reference Manual, Exception entry behavior \/ PushStack() 伪代码\r\n- ARM Developer, HardFault Status Register (HFSR)\r\n- ARM Developer, Configurable Fault Status Register (CFSR)\r\n- STM32F1 Programming Manual (PM0253), Exception model, Stack frame layout\r\n- ARMv7-M Exception entry behavior：硬件在异常入口自动保存 xPSR、ReturnAddress、LR、R12、R3–R0 共 8 个 32 位字到当前栈指针指向的栈上，SCB 位于 `0xE000E000` 起，HFSR 位于 `0xE000ED2C`，CFSR 位于 `0xE000ED28`[citation:1][citation:3][citation:5]\r\n- HFSR 定义：VECTTBL 表示异常入口取向量表时总线错误；FORCED 表示有可配置优先级的 fault 因优先级或禁用原因升级为 HardFault；DEBUGEVT 为调试事件。HFSR 和 CFSR 均为读看、写 1 清的 sticky 寄存器[citation:1][citation:16]\r\n- CFSR 三个子域：UFSR [31:16]、BFSR [15:8]、MMFSR [7:0]；BFARVALID 置位时 BFAR 保存非法访问地址，MMARVALID 置位时 MMFAR 保存非法访问地址[citation:5][citation:11]\r\n- EXC_RETURN 的 bit 2 指示异常返回后使用的栈指针（0=MSP，1=PSP）；进入异常后硬件会自动 8 字节对齐栈帧[citation:12]\r\n- 默认三个可配置 fault 均为禁用状态，需在初始化阶段通过 SHCSR 的 MEMFAULTENA \/ BUSFAULTENA \/ USGFAULTENA 位分别使能[citation:2][citation:7]","updatetime":"2026-10-10 09:43:25","author":"大目熊"}}