Skip to content

LV001-死机原因与故障定位 ​

一、 为什么单片机一崩就死机 ​

同一段有 bug 的代码,在 Linux 上通常只留下一句 Segmentation fault、一条崩溃日志,进程被杀掉后由 systemd 重新拉起;在单片机上则是整机停摆——串口不再输出,按键没有反应,调试器挂上去发现 PC 停在一个跳转自身的死循环里。

差别不在代码质量,在两边的运行时结构。Linux 有三道隔离层把崩溃关在单个地址空间里,单片机一道都没有。

Linux 与单片机的隔离层对比

1. 缺失的三道隔离 ​

1.1 没有 MMU:越界访问无人拦截 ​

Cortex-M 系列(M0 / M0+ / M3 / M4 / M7)没有内存管理单元,代码里的指针就是物理地址。写一个越界的数组下标,CPU 不会报错,它会老老实实把值写进那个物理地址——那里可能是另一个全局变量、某个任务的栈,也可能是一个外设的控制寄存器。

后果分两类:

  • 写到 RAM:破坏数据结构。崩溃点与出错点分离,表现为「莫名其妙在别处崩」,这是最难查的一类。

  • 写到外设寄存器:立刻改变硬件行为。比如关掉某个外设的时钟、触发一次复位、改乱串口波特率。现象是硬件「自己动了」。

MPU 只是部分替代品。M3 / M4 / M7 可选配 MPU,能划分若干区域的读写与执行权限,越权访问触发 MemManageFault。但它只有 8 ~ 16 个 region,且不做地址翻译,挡不住「越界写到另一块合法 RAM」这种最常见的情况。

1.2 没有特权分离:应用即系统 ​

Linux 的用户态代码碰不到硬件,所有硬件操作都要经系统调用陷入内核。用户态崩了,内核还是清醒的。

单片机上应用代码直接读写寄存器,整段程序通常跑在同一个特权级、同一个地址空间里。于是业务逻辑中的一个野指针,和外设驱动中的一个错误,破坏力完全相同——都能改坏整个系统,包括改坏中断向量表(仅当向量表被重定位到 RAM 时;STM32F1 默认向量表在 Flash,普通总线写无法改写)和 RTOS 的任务控制块。

1.3 没有进程抽象:无人有权重启 ​

Linux 有进程表。内核可以单独终结一个进程,systemd 可以把它重新拉起来,其他进程毫无感知。

单片机上没有「进程」这个概念,只有一份全局状态和一个 main()。不存在任何站在应用之外的实体,有权力说「这个模块坏了,我重启它」。

RTOS 的任务比进程轻量得多,也正因为轻,它提供不了隔离:所有任务共享同一个地址空间,没有独立页表,一个任务写坏内存所有任务一起遭殃。FreeRTOS 也没有「杀掉一个任务并重启它」的通用机制,vTaskDelete() 只删除任务本身,被它写坏的数据不会恢复。

2. HardFault:从错误异常到默认死循环 ​

2.1 HardFault 是什么:错误异常的统一出口 ​

先弄清这两个英文词。fault 本义是「故障、过错」,在 Cortex-M 术语里指程序执行触发的硬件级错误事件:野指针访问非法地址、取指碰到未定义指令,CPU 检测到这类错误后触发一个异常上报,这类异常统称 fault。它与中断同走一套异常机制,语义却相反——中断是外设或软件「正常的服务请求」,fault 是「出了错的硬件上报」。

HardFault 字面可译作「硬故障」,但这里的 hard 既不是「硬件」也不是「困难」,而是固定、不可禁用——与「可配置(configurable)」相对,正如 NMI(不可屏蔽中断)之于普通中断:MemManage/BusFault/UsageFault 都属于可配置 fault,能单独开关;HardFault 无法关闭,永远在线兜底。中文资料也译作「硬错误」,实际交流中一般直接说英文原词。

CPU 有一套统一的异常机制:任何需要打断正常执行流的事件(外设中断、软中断、错误)都走同一条路——查向量表、硬件压栈、跳转到对应的服务函数。HardFault 是其中专门承接「错误」的那类异常:CPU 在取指、译码或访问数据时遇到自己无法处理的问题,就放弃当前代码,跳去执行 HardFault_Handler。

下图是这套机制的全景:异常是一个大集合,外部中断 IRQ 只是其中一支,fault 是另一支里的「错误类」;两者分类不同,进入 handler 的硬件路径却完全相同。

Cortex-M 异常全景:中断与 fault 的分类及共同的硬件路径

Cortex-M 的错误异常分两层:

层级异常向量表编号默认状态典型诱因
可配置 faultMemManage4禁用(触发即升级)MPU 违例、不可执行区取指
可配置 faultBusFault5禁用(触发即升级)访问无效地址、外设时钟未开就读写
可配置 faultUsageFault6禁用(触发即升级)未定义指令、非法状态、除零
兜底 faultHardFault3永远使能上面三种的升级 + 取向量表失败等

「升级」是理解的关键:MemManage/BusFault/UsageFault 这三个细分 fault 默认是关着的,触发时不进自己的 handler,而是直接升级成 HardFault。所以实际调试中看到的崩溃十有八九都叫 HardFault——它不是某一种具体错误,而是错误异常的统一出口。想要细分类别,得在 SHCSR 里手动打开对应 fault(见第四章 2 的运行期防护)。

两条性质决定了它的行为模式:

  • 优先级 -1,仅次于复位(异常号 1,优先级 -3)和 NMI(异常号 2,优先级 -2):任何代码都拦不住它;进入 handler 前硬件自动把 R0~R3/R12/LR/PC/xPSR 八个寄存器压栈——这正是崩溃现场能被保存下来的硬件依据(第三章 3.1)。
  • handler 里再出错就是 LOCKUP:fault handler 自身优先级已是 -1,无法再嵌套,里面再触发 fault 会直接进入锁定状态,总线停摆,只能复位。所以 handler 里只允许做最简单的事(写内存、轮询串口发送),这也是 LV005 里 printf 必须走轮询、不能依赖中断的原因。

2.2 默认的 HardFault 处理就是一个死循环 ​

启动文件里,所有 fault handler 的默认实现都是跳转到自身:

armasm
; startup_stm32f103xe.s
HardFault_Handler    PROC
                     EXPORT  HardFault_Handler    [WEAK]
                     B       .
                     ENDP

B . 等价于 C 里的 while(1)。[WEAK] 表示「用户不实现就用这个」。

于是崩溃的默认结局是:CPU 卡死在这个循环里,外设保持崩溃瞬间的状态,串口停止输出;若未启用看门狗,没有任何机制会来复位。从外部看就是死机。

【注意】

这不是芯片的设计缺陷,是启动文件留给用户的空位。死机之所以难查,恰恰是因为这个默认实现把现场全部丢弃了——它既没有记录 fault 类型,也没有保存寄存器,甚至没有复位。第三章要做的就是把这块空位填上。

3. Linux 靠什么兜住崩溃 ​

3.1 用户态:从缺页异常到进程重启 ​

一次空指针解引用在 Linux 上的完整链路:

(1)CPU 访问虚拟地址 0,MMU 查页表发现无映射,触发 page fault,陷入内核。

(2)内核的 fault handler 检查该地址是否落在进程的 VMA(vm_area_struct)内。地址 0 通常不属于任何 VMA(vm.mmap_min_addr 默认保留低地址段),判定为非法访问。

(3)内核向该进程投递 SIGSEGV,默认动作是终结进程并生成 core dump。

(4)父进程收到 SIGCHLD。若由 systemd 管理,Restart=on-failure 会把它重新拉起。

整个过程内核始终清醒,其他进程完全不受影响。崩溃被限制在一个地址空间内——这就是隔离的价值。

下图把上面四步画成了时序,重点是最后那条恢复动作:终结进程的是内核,重新拉起它的是进程之外的 systemd。

Linux 兜住一次用户态崩溃的时序

3.2 内核态:panic 与 kdump ​

如果出错的是内核自己,结果和单片机并无本质区别:

  • 默认 panic_on_oops=0 时,内核打印一段 oops(含调用栈与寄存器),杀掉出错的内核线程后尝试继续运行。此时系统状态已不可信,通常表现为随后一连串诡异故障。

  • 配 panic_on_oops=1 则直接 kernel panic,整机停摆或重启。

  • 配了 kdump 时,panic 会跳转到预留内存中的 capture kernel,把崩溃瞬间的完整内存镜像写成 vmcore,供事后用 crash 工具分析。

kdump 的思路值得单片机开发者留意:崩溃时把现场搬到一块不会被破坏的地方存起来,复位后再读。这正是第 3 章要在 MCU 上手工实现的东西,只不过 MCU 的「预留内存」是一个不被启动代码清零的 RAM 段。

4. 两种系统的机制对照 ​

机制单片机(Cortex-M 裸机)Linux
地址翻译与保护无 MMU,指针即物理地址MMU + 页表,越界触发 page fault
特权分离应用直接操作寄存器用户态 / 内核态,硬件操作经系统调用
崩溃的边界整个系统单个进程的地址空间
谁发现错误CPU 的 fault 机制(若配置了)MMU + 内核 fault handler
发现后做什么默认 B . 死循环投递 SIGSEGV,终结进程
能否自动恢复只能靠看门狗复位(丢失现场)systemd 重启该进程,其他进程无感
事后取证需自己实现(NOINIT 段保存现场)core dump / kdump / syslog
内核自身出错—— 不存在这个区分oops 或 panic,与单片机同样致命

一句话总结:Linux 的「不死」是用户态崩溃被兜住,不是内核不会崩。单片机缺的是兜底的那一层,所以任何一次 fault 都直接等于整机停摆。

二、 常见崩溃成因 ​

下面五类按现场排查的频率排序。前两类是纯软件问题,后三类常常伪装成软件问题,浪费大量时间。

1. 内存类故障 ​

1.1 栈溢出 ​

最高频的原因。触发方式:

  • 深递归,尤其是递归深度由外部输入决定时;

  • 大的局部数组,比如在函数里开 uint8_t buf[2048];

  • 中断嵌套层数超出预估,每层都要压入 {R0-R3, R12, LR, PC, xPSR} 八个字;

  • RTOS 任务栈按编译期固定值分配,几百字节很常见,一次深层调用链就够了。

栈溢出之所以凶险,是因为它绝大多数情况下不触发 fault:栈指针往下走过界,写到的仍然是合法 RAM,CPU 毫无异议。等它踩坏了相邻任务的栈或堆管理结构,崩溃会在完全不同的地方爆发。(只有越界深到踩出合法 RAM,或异常入栈时 SP 已越界,才会以 MSTKERR 之类的标志位露头,见第三章 4.1。)

1.2 野指针与越界写 ​

已释放的指针、未初始化的指针、memcpy 长度算错、数组下标越界。因为无 MMU,这些写入都会成功执行,破坏在写入时静默发生,在读取时才暴露。

特别危险的目标:

  • 中断向量表(仅当被重定位到 RAM 时才可能被踩;STM32F1 默认向量表在 Flash,普通总线写改不动,改写需走 FPEC 编程时序):被改写后,下一次中断跳转到数据区,取到非法指令;

  • RTOS 的任务控制块或就绪链表:调度器行为错乱,表现为随机死锁或跑飞;

  • 外设寄存器:硬件行为突变。

1.3 堆耗尽与返回值未判 ​

malloc / pvPortMalloc 返回 NULL 后直接解引用。在 Cortex-M 上,地址 0 通常是 Flash 的别名区,读它能成功(读到向量表内容),写它才会 fault。所以空指针解引用的现象取决于读写方向:写触发 BusFault,读则静默得到一堆无意义数据,然后在更远的地方崩。

2. 异常与向量表故障 ​

2.1 向量表未重定位 ​

IAP 与 OTA 场景的高发问题。Bootloader 跳转到应用后,如果 SCB->VTOR 没有指向应用自己的向量表,中断一来 CPU 仍去 Bootloader 的向量表取地址,跳进一段已经不属于当前程序的代码。

正确做法是在应用启动早期、开中断之前完成重定位,且基地址要满足对齐要求(region 大小向上取到 2 的幂):

c
// Core/Src/main.c
SCB->VTOR = FLASH_BASE | VECT_TAB_OFFSET;
__enable_irq();

具体实现见 70-在线升级/01-IAP/LV010-IAP编程基础示例.md。

2.2 非法指令与非对齐访问 ​

  • 非法指令(UNDEFINSTR):PC 跑进了数据区或空 Flash(全 0xFF)。根因往往是函数指针被踩、返回地址被栈溢出改写、或者向量表错乱。

  • 非法状态(INVSTATE):最常见的是函数指针忘了置最低位。Cortex-M 只执行 Thumb 指令,跳转地址的 bit0 必须为 1,为 0 会触发 INVSTATE。

  • 非对齐访问(UNALIGNED):默认允许普通 load/store 非对齐,但 LDM / STM / PUSH / POP 永远要求对齐;把 UNALIGN_TRP 置位后所有非对齐访问都会 fault。

  • 除零(DIVBYZERO):默认不触发 fault,商置 0。要捕获需置位 CCR 的 DIV_0_TRP(此时若 UsageFault 未使能则升级为 HardFault)。

3. Flash 与时钟故障 ​

3.1 同一 bank 边执行边擦写 ​

STM32F1 这类单 bank 器件,在 Flash 擦写期间从同一片 Flash 取指会总线停顿甚至出错。所以擦写例程本身必须放在 RAM 里执行,或者用双 bank 器件做 A/B 切换。

这是 OTA 升级中途死机的头号原因,两种硬件形态的完整落地方案见 70-在线升级/02-OTA/LV022-MCU无BankSwap落地方案.md。

3.2 时钟配置错误 ​

  • PLL 倍频算错,实际主频远高于器件上限,Flash 等待周期又没跟着加大,取指出错;

  • HSE 晶振未起振却直接切过去,或者起振等待没有超时保护,卡死在 while(HSE not ready);

  • 外设时钟未使能就去访问它,读到的全是 0 或触发 BusFault。

第三种尤其隐蔽:RCC_APB1ENR 里没开的时钟,对应外设的寄存器读回 0、写入无效,代码会停在一个永远等不到的状态位上。

4. 电源类故障 ​

这一类的特征是复现概率不固定,且与代码逻辑无明显关联:

  • 射频模块发射瞬间、电机启动瞬间的电流冲击导致 VDD 跌落,触发欠压复位或直接让 Flash 写入出错;

  • 去耦电容不足或布局太远,高频噪声导致逻辑误动作;

  • 掉电过程中电压在复位阈值附近反复穿越,造成半死不活的状态。

判别方法:用示波器同时抓 VDD 和 NRST,看复位是否伴随电压跌落。软件层面能做的只有开启 PVD(可编程电压检测)监测欠压——F1 的 POR/PDR 掉电复位是固定硬件行为,无需也无法配置——让电压不足时干净地复位,而不是带病运行。

5. RTOS 与逻辑故障 ​

严格说这一类多数不是 fault 而是 hang,但外部现象同样是死机。

5.1 死锁与优先级反转 ​

所有任务都在等待一个永远不会释放的资源。互斥量忘记释放、两个任务循环等待、或者低优先级任务持有锁却被中等优先级任务长期抢占(优先级反转)。

FreeRTOS 的互斥量自带优先级继承,能缓解第三种;前两种只能靠 uxTaskGetSystemState() 导出任务状态表来判断。

5.2 在 ISR 中调用非 FromISR 接口 ​

中断服务程序里调用了 xQueueSend() 而不是 xQueueSendFromISR()。前者会尝试阻塞和让出,在中断上下文里做这件事会破坏调度器状态,通常直接触发 configASSERT 或者在稍后的调度中跑飞。

同类问题还有中断优先级配错:FreeRTOS 用 configMAX_SYSCALL_INTERRUPT_PRIORITY 划界,优先级高于该界限(数值更小)的中断里不允许调用任何 FreeRTOS API。

5.3 轮询一个永不置位的标志 ​

c
// Core/Src/main.c
while ((USART1->SR & USART_SR_TXE) == 0)
{
    /* 等待发送寄存器空 */
}

如果 USART1 的时钟没使能、或者外设被意外复位,这个状态位永远不会置位,代码就永远停在这里。所有裸机的忙等循环都该加超时:

c
// Core/Src/main.c
uint32_t timeout = 100000U;
while (((USART1->SR & USART_SR_TXE) == 0) && (--timeout != 0U))
{
}
if (timeout == 0U)
{
    /* 记录并走降级路径,而不是无限等待 */
}

三、 故障定位方法 ​

死机不会自己说明原因,现场必须主动去捞。整个流程的顺序很重要:先分清是死机还是复位,再判 fault 类型,最后才去查代码。跳过第一步直接看代码,很容易在一个根本无关的函数里绕上几天。

崩溃现场的定位流程

1. 先分清是死机还是复位 ​

现象都是「设备不动了」,但两者的排查方向完全相反:死机说明程序停在某处出不来,复位说明程序被重启过。

复位后读 RCC->CSR 的标志位即可判定:

c
// Core/Src/main.c
if (RCC->CSR & RCC_CSR_IWDGRSTF)  { /* 独立看门狗复位 —— 说明曾死机 */ }
if (RCC->CSR & RCC_CSR_WWDGRSTF)  { /* 窗口看门狗复位 */ }
if (RCC->CSR & RCC_CSR_SFTRSTF)   { /* 软件复位 */ }
if (RCC->CSR & RCC_CSR_PORRSTF)   { /* 上电 / 掉电复位 —— 查电源 */ }
if (RCC->CSR & RCC_CSR_PINRSTF)   { /* NRST 引脚复位 */ }

RCC->CSR |= RCC_CSR_RMVF;   /* 读完必须清除,否则下次判断会被旧标志误导 */

判读要点:

  • 看门狗复位:程序曾死机,且看门狗正常工作。要查的是死机原因,现场得靠第 3 节的 NOINIT 段捞。

  • 掉电复位:不是软件问题,转去查电源(第二章第 4 节)。

  • 既非看门狗也非掉电,且设备仍停摆:程序正卡在某个死循环里。此时最省事——调试器 attach 上去(不要用 reset,会丢现场),直接看 PC 停在哪、再读 fault 寄存器。

2. 故障状态寄存器 ​

Cortex-M 把 fault 的原因写在一组状态寄存器里,复位前一直有效。

2.1 HFSR 与 CFSR ​

HFSR(0xE000ED2C)说明 HardFault 是怎么来的:

  • bit 30 FORCED = 1:这个 HardFault 是由其他 fault 升级而来(原 fault 被禁用、或在 fault handler 里又出了 fault)。真正的原因要看 CFSR。

  • bit 1 VECTTBL = 1:取向量表时就出错了。强烈指向向量表被破坏或 VTOR 配错。

CFSR(0xE000ED28)是一个 32 位寄存器,由三段拼成:

字段位域含义
MMFSRbit 0 ~ 7存储器管理 fault 状态
BFSRbit 8 ~ 15总线 fault 状态
UFSRbit 16 ~ 31用法 fault 状态

常用位:

  • MMFSR bit 1 DACCVIOL:数据访问违反 MPU 权限;bit 4 MSTKERR:异常入栈时栈指针已越界,这是栈溢出的直接证据。

  • BFSR bit 1 PRECISERR:精确总线错误,出错地址就在 BFAR 里;bit 2 IMPRECISERR:非精确错误,地址不可信,需要靠代码写缓冲定位。

  • UFSR bit 0 UNDEFINSTR:非法指令,PC 跑飞了;bit 1 INVSTATE:非法状态,多半是跳转地址 bit0 为 0;bit 3 NOCP:访问了不存在的协处理器——典型场景是代码里带浮点指令、而目标核没有 FPU(本板 F103 为 Cortex-M3,无 FPU,出现这条通常意味着链接进了不该用的浮点库);带 FPU 的 M4 / M7 上则是 FPU 时钟未开或 CPACR 未放行。

2.2 MMFAR 与 BFAR ​

  • MMFAR(0xE000ED34):MemManage fault 的出错地址,仅当 MMFSR bit 7 MMARVALID = 1 时有效。

  • BFAR(0xE000ED38):BusFault 的出错地址,仅当 BFSR bit 7 BFARVALID = 1 时有效。

这两个寄存器给出的是被非法访问的地址,不是出错的指令地址。它的价值在于:拿这个地址去比对链接生成的 .map 文件,就能知道「谁被踩了」;再结合栈帧里的 PC,就知道「是谁踩的」。

【注意】

IMPRECISERR 时 BFAR 无效。总线错误可能延迟上报,此时要么打开精确上报(把 CCR 的相关位配成非缓冲访问),要么在可疑路径上插桩。调试期可以把 configASSERT 和内存写观察点一起用上。

2.3 典型组合速查 ​

CFSR / HFSR 组合大概率原因下一步
MSTKERR 置位栈溢出查栈水位,加大任务栈
PRECISERR + BFAR 指向外设区访问了未使能时钟的外设检查 RCC 使能顺序
PRECISERR + BFAR 指向 RAM野指针写比对 .map 找被踩的变量
UNDEFINSTRPC 跑进数据区查栈帧 PC 与 LR,怀疑返回地址被改写
INVSTATE跳转地址 bit0 为 0查函数指针赋值与回调注册
NOCPFPU 未使能却用了浮点指令检查 CPACR 与启动顺序
VECTTBL向量表取址失败查 VTOR 与向量表对齐
只有 FORCED,CFSR 全 0fault handler 内再次出错先确保 handler 自身不含函数调用

3. 在 HardFault 中保存现场 ​

默认的 B . 必须替换掉。目标不是「在 fault handler 里打印」——那需要串口驱动在中断上下文里可用,本身就不安全——而是把寄存器存进一块复位后仍然存在的 RAM,重启后慢慢分析。

3.1 需要保存哪些寄存器 ​

异常入栈时,硬件自动压入八个字,这就是最重要的现场:

偏移寄存器用途
0x00R0参数 / 中间值
0x04R1参数 / 中间值
0x08R2参数 / 中间值
0x0CR3参数 / 中间值
0x10R12临时寄存器
0x14LR出错函数的调用者(教科书语义;-O3 下常是最后一次 BL 的残留值,用前反查验证)
0x18PC出错的指令地址,最关键
0x1CxPSR含 IPSR,可看出当时正在执行哪个中断

其中 PC 直接给出出错位置;LR 在教科书语义上是调用链上一层(-O3 下常只是最后一次 BL 的残留值,用前反查验证)。这两个加上 CFSR / HFSR / BFAR / MMFAR,绝大多数 fault 都能定位。

3.2 用 NOINIT 段跨复位保留 ​

C 程序的启动流程(Reset_Handler 调用的 __main)复位后要重建运行环境:把 .data 段变量从 Flash 拷回初值、把 .bss 段变量全部清零。放在这两个段里的崩溃现场,重启那一刻就被启动代码抹掉了。NOINIT 段就是链接器划出的一块「启动时不初始化」的 RAM:既不拷初值也不清零,变量内容原样留在 SRAM 里——只要芯片不掉电,复位(含看门狗复位、软复位)后依然可读。

它的两条边界:上电冷启动后内容是随机值(所以要用魔数判断有效性);掉电即丢,不能替代 Flash 落盘。

GCC + 链接脚本:

c
// Core/Src/fault_record.c
__attribute__((section(".noinit"), used)) fault_record_t g_fault_record;
ld
/* STM32F103XE_FLASH.ld */
.noinit (NOLOAD) :
{
    . = ALIGN(4);
    *(.noinit)
    . = ALIGN(4);
} >RAM

Keil AC5 则写 __attribute__((section("noinit"), zero_init)),并在 scatter file 里给对应执行区加 UNINIT——zero_init 把变量归入 ZI 段,须与 section 属性配合才能路由进 UNINIT 区。该属性源自 AC5,早期 AC6 不认(Arm Compiler 6.14 起已支持);版本无关的写法是把段名以 .bss. 开头的 section 当作 ZI 段处理(如 __attribute__((section(".bss.noinit")))),同样要放进 UNINIT 执行区。

配合一个魔数判断本次复位前是否发生过 fault:

c
// Core/Src/fault_record.c
#define FAULT_MAGIC 0x4641554CU   /* "FAUL" */

if (g_fault_record.magic == FAULT_MAGIC)
{
    fault_report(&g_fault_record);   /* 上报后再清 magic */
    g_fault_record.magic = 0U;
}

3.3 汇编入口实现 ​

需要用汇编的原因是:进入 handler 时要先判断当时用的是 MSP 还是 PSP,才能取到正确的栈帧指针,而这件事在 C 里做已经晚了。

armasm
; startup_stm32f103xe.s
HardFault_Handler    PROC
                     EXPORT  HardFault_Handler
                     TST     LR, #4              ; bit2 = 0 用 MSP,= 1 用 PSP
                     ITE     EQ
                     MRSEQ   R0, MSP
                     MRSNE   R0, PSP
                     LDR     R1, =g_fault_record
                     STMIA   R1!, {R0, R4-R11, LR} ; 栈帧指针 + 通用寄存器
                     LDR     R2, =0xE000ED28
                     LDMIA   R2, {R2, R3}        ; CFSR 与 HFSR
                     STMIA   R1!, {R2, R3}
                     B       .
                     ENDP

保存完之后再决定是 B . 等看门狗复位,还是主动 NVIC_SystemReset()。后者恢复更快,但要确保现场已经落盘。

拿到 PC 后用工具链反查符号:

bash
arm-none-eabi-addr2line -e build/app.elf -f -C -a 0x08001a3c

前提是保留了带符号的 .elf,且与实际烧录的固件严格对应。这也是为什么发布版本应当归档每一份 elf 与 map 文件。

4. 栈溢出检测 ​

栈溢出不一定触发 fault(见第二章 1.1),所以要主动检测。但在讨论怎么检测之前,得先把一件事说清楚:第三章那两套现场方案都不是为这类故障准备的——HardFault 现场打印接不住,NOINIT 加看门狗只能兜底。

下面四节是四条互补的手段,按成本从低到高排列:编译期算栈用量、运行期水位标记、运行期哨兵、运行期 MPU 守卫。前两条回答「会不会溢出」,后两条回答「已经溢出了怎么办」。本板(STM32F103ZE)只能用前三条,第四条受型号限制,原因见 4.5。

4.1 为什么那两套现场方案都接不住栈溢出 ​

先看本工程实测的 RAM 布局(取自 .map),下面两条原因都建立在它之上:

区间内容说明
0x20000000 ~ 0x20005818.data + .bss全局变量,含 lwIP 的 8 KB 内存池
0x20005818 ~ 0x20005A18HEAP512 字节堆
0x20005A18 ~ 0x20005E18STACK1 KB 主栈,栈顶即 __initial_sp
0x2000FC00 ~ 0x20010000NOINIT 现场记录在栈顶上方约 40 KB,中间未分配

关键在第二、三行的落差:栈底下方的全部是合法 RAM——向下先踩 512 字节堆,再往下约 22 KB 全是 .data / .bss。这就是「栈溢出不产生 fault」的物理原因。

4.1.1 HardFault 现场打印接不住:它假设「越界会触发 fault」 ​

(1)多数情况下根本没有 fault 可打印。 SP 越过栈底,写到的仍是合法 SRAM,总线不报错,MPU 未使能时也谈不上越权,CPU 毫无异议。程序不会「崩」,只是把堆的分配块或某个全局变量写坏,之后在完全无关的地方跑飞、卡住——表现是「跑歪了」,不是「崩了」。

(2)真报出 fault 时也拿不到输出。 只有溢出深到踩出 SRAM 下界才会报 fault,而那意味着先穿过约 22 KB 的 .data / .bss;此时现场已经不可用——打印要用栈(printf 的格式化路径本身开销可观),SP 又在框外,handler 里会二次 fault。fault handler 优先级已是 -1,无法再嵌套,二次 fault 直接进入 LOCKUP,总线停摆,串口一个字节也发不出。这条边界在 LV005 里也写了。

(3)最像栈溢出的那个标志位,恰恰让现场失效。 异常入栈时 SP 已越界会置位 MSTKERR,看着像「栈溢出的直接证据」,但它的含义是硬件压栈失败:八个寄存器的异常帧没能完整落到内存,hardfault_find_frame() 的运行时校验只能退到兜底偏移,打印出来的 R0 ~ xPSR 是栈上的残留旧值。此时 MSTKERR 只能当「发生过栈越界」的标志位用,不能顺着它读调用链。

4.1.2 NOINIT 加看门狗只能兜底,给不出原因 ​

它是「后果记录」,不是「原因检测」,三处缺口:

  • 触发是被动的:只有 CPU 停下来不再喂狗才会留下记录。栈溢出可以先表现为静默的数据破坏,系统带病继续跑,等它在别处跑飞时,记录里的 uptime_ms 停在症状时刻,而不是越界时刻。
  • 记录里没有栈相关字段:noinit_scene_t 只有 magic / cause / uptime_ms / rcc_csr / pc_hint / check 六项(见 LV025),既没有 SP,也没有水位。复位后读回时无法把「栈溢出」与「普通死循环」区分开,cause 只能落到 NONE,靠心跳与复位源推断,信息量约等于「死过一次」。
  • 证据在复位那一刻被销毁:NOINIT 段自己不清零,但被踩坏的 .data / .bss 属于 __main 会重建的段,复位后初值回填,越界写掉的痕迹全部消失。方案设计的「复位后慢慢分析」在栈溢出场景里是断的,能留下来的只有 NOINIT 里自己写进去的那几个字段。

【注意】

NOINIT 段本身不会被栈溢出踩到——0x2000FC00 在栈顶上方 40 KB,而溢出方向朝下。所以「记录能跨复位存活」这一条是成立的,不成立的是「从记录里读出原因」。

结论:栈溢出的对策必须换方向——不能等它「崩」,而要在越界发生的那一刻把它变成可观测事件,或者干脆在编译期就算清楚它不会发生。下面四节分别展开,最后一条(MPU 守卫)在本板上不可用,但值得知道它是什么,见 4.5。检测一旦成立,把结论回写进 NOINIT 记录(新增一个 cause),就能让第三章那套跨复位上报机制重新接上这条链路。

4.2 编译期:先把栈用量算清楚 ​

运行期手段再花样,也是在下游堵漏。最省事的一步是先把「这条调用链最深用多少栈」在编译期量出来,与栈大小对比。本工程构建后链接器会输出一份静态调用图:

text
Static Call Graph for image atk-f103\atk-f103.axf
Maximum Stack Usage = 688 bytes + Unknown(Functions without stacksize, Cycles, Untraceable Function Pointers)
Call chain for Maximum Stack Depth:
__rt_entry_main -> main -> iap_service_init -> lwip_comm_init -> netif_add -> ... -> __2printf -> _printf_char_common -> __printf

对照本工程 1 KB 的主栈(Stack_Size EQU 0x400),688 字节意味着余量只剩 336 字节——而这一行末尾的 + Unknown 说明它只是下界,有两类消耗没被计入:

  • 不可追踪的函数指针:向量表里的中断入口都归在这类。也就是说中断路径完全没算进来——每层异常入站先压 8 个字,再叠加 ISR 自身的局部变量与它调用的函数;
  • 无栈信息的函数:库函数与汇编实现,如 __scatterload、__user_initial_stackheap。

判读规则:把报告值当下界,手工加上最坏中断嵌套的深度,与栈大小对比后仍要留 30% 以上余量才算安全。另外注意最深的那条链末端就是 printf——日志代码本身很吃栈。GCC / armclang 侧对应 -fstack-usage,为每个函数生成 .su 列出各自栈消耗,把调用链上的值累加同样得到这个下界。

4.3 运行期水位标记法 ​

思路都是「先把空闲的栈填成可辨识的图案,之后看图案被吃掉多少」。图案填在哪条栈上,决定了具体做法。

4.3.1 裸机:MSP 栈同样可以填 ​

在 main() 早期(外设与业务初始化之前)把「当前 SP 以下、栈底以上」这一段填成 0xA5A5A5A5,之后周期性从栈底向上扫连续 0xA5 的字节数,即历史最小剩余:

c
// Core/Src/stack_check.c
extern uint32_t Stack_Mem[];        /* startup 里 AREA STACK 的栈底符号 */
extern uint32_t __initial_sp[];     /* 栈顶 = 栈底 + Stack_Size */

void stack_paint(void)              /* main() 早期调用一次 */
{
    uint32_t sp = __get_MSP();
    uint32_t *p;
    for (p = Stack_Mem; ((uint32_t)p + 32U) < sp; p++)   /* 不覆盖当前活动帧 */
    {
        *p = 0xA5A5A5A5U;
    }
}

uint32_t stack_min_free(void)       /* 栈底起连续 0xA5 的字节数 */
{
    const uint8_t *base = (const uint8_t *)Stack_Mem;
    const uint8_t *end  = (const uint8_t *)__initial_sp;
    const uint8_t *p    = base;

    while ((p < end) && (*p == 0xA5U)) { p++; }
    return (uint32_t)(p - base);
}

调用点就放在主循环的心跳处——本工程是 noinit_tick() 旁边那一行,天然与「系统还活着」这个语义绑在一起。余量低于阈值时打印告警,或把余量一并写进 NOINIT 记录,跨复位观察趋势。

两条边界:

  • 图案只有启动之后才在:main() 之前由 C 库启动代码(__main → __scatterload)消耗的栈不会被统计,报告值偏乐观;
  • 它看的是「到过的最低点」:踩出去又收回来同样会留下痕迹(图案已被改写),但结论只能是「曾经用过这么多」。所以它是预警,不是捕获——捕获交给下一节。
4.3.2 RTOS:任务栈有现成接口 ​

FreeRTOS 在创建任务时用 0xA5 填充整个栈,uxTaskGetStackHighWaterMark() 返回从栈底起连续未被改写的字数(StackType_t 单位,32 位机上 1 字 = 4 字节,乘 4 才是字节数)——即历史最小剩余量:

c
// Core/Src/main.c
UBaseType_t remain = uxTaskGetStackHighWaterMark(NULL);   /* NULL 表示查当前任务 */
if (remain < 32U)
{
    /* 剩余不足 32 字,扩容 */
}

需要在 FreeRTOSConfig.h 里打开 INCLUDE_uxTaskGetStackHighWaterMark。遍历所有任务的做法是在最高优先级任务里循环调用 uxTaskGetSystemState() 取任务句柄,逐个查询。

判读标准:水位在长期运行、且覆盖过所有分支路径之后仍留有 30% 以上余量才算安全。只在启动后测一次没有意义。

4.4 运行期哨兵 ​

水位法回答「还剩多少」,哨兵法回答「有没有踩穿」——一个确定的布尔结论,代价也更低。

4.4.1 裸机:在栈底正下方留一块哨兵 ​

栈从 Stack_Mem 朝低地址生长,所以哨兵要放在 Stack_Mem 之前。改 startup 的 STACK 段,在栈前面插 32 字节并保证对齐:

armasm
; startup_stm32f103xe.s
Stack_Size      EQU     0x400

                AREA    STACK, NOINIT, READWRITE, ALIGN=5
GUARD_Mem       SPACE   32                    ; 栈底正下方的哨兵, 32 字节对齐
                EXPORT  GUARD_Mem
Stack_Mem       SPACE   Stack_Size
__initial_sp

启动时把 GUARD_Mem 填成 0xA5A5A5A5,之后在主循环的心跳处校验一次。一旦被改写,说明栈已经踩穿栈底,此时不要试图带病继续跑,而是把它变成一次可自报的死亡:

c
// Core/Src/stack_check.c
if (guard_broken())
{
    noinit_save(NOINIT_CAUSE_STACK, 0);   /* 死因落 NOINIT 段, 见第三章 3.2 */
    while (1) { }                          /* 停止喂狗, 等 IWDG 复位 */
}

复位后 noinit_init() 照常读回并报出 cause = stack overflow,链路与第三章那两套机制完全一致——区别只在于这次死因是自报的,不是推断的。pc_hint 在这一条上没有意义(越界瞬间的 PC 早已回不来),真正有价值的信息就是哨兵被踩穿这件事本身。

边界:它只能发现「踩穿之后还活着」的越界。踩出去又收回来,或者踩穿后立刻在别处跑飞(根本走不到心跳点),都抓不到。

4.4.2 RTOS:等价物是溢出钩子 ​

打开 configCHECK_FOR_STACK_OVERFLOW 并实现钩子:

c
// Core/Src/freertos_hooks.c
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName)
{
    fault_panic_with_task(pcTaskName);   /* 只记录,不要在这里做复杂操作 */
}
  • 设为 1:只在任务切换时检查栈指针是否越界,开销极小,但漏检率高——任务运行途中踩出去又收回来就查不到。

  • 设为 2:额外检查栈末端的 16 字节 0xA5 填充是否被改写,能抓住大部分越界,代价是每次切换多几次比较。

这两档检查的其实都是哨兵,与裸机那套同源,只是哨兵由调度器在切换时顺手看一眼。

4.5 运行期 MPU 守卫:把静默破坏变成精确 fault ​

前面三节都是「发现」,MPU 是唯一能把栈溢出的第一步就变成硬件异常的机制。

4.5.1 它是什么 ​

MPU(Memory Protection Unit,内存保护单元)是 Cortex-M 内核里的一个可选外设,挂在私有外设总线上。它与 MMU 没有关系:不做地址翻译,只做权限检查。用法是把物理地址空间划成若干 region(Cortex-M3 / M4 为 8 个,M7 为 16 个),每个 region 独立指定:

配置项含义
基址 + 大小区域范围。大小必须是 2 的幂,最小 32 字节,基址按大小对齐
AP特权 / 非特权下的读、写权限,可设成完全禁止访问
XN是否允许从该区域取指
SRD子区域开关,用来在整块区域里挖洞

每一次访问都要在进总线前过一遍 MPU,命中一个禁止访问的 region 就产生 MemManageFault(MMFSR.DACCVIOL,数据访问违例)。注意它是逐次访问检查,所以能给出违反的那条指令,而不是等出事后再推断。

4.5.2 有什么用 ​

对单片机这种「没有进程隔离、指针即物理地址」的环境(见第一章),它是最接近 MMU 的一道栅栏:把 Flash 配成只读可执行、把外设区配成仅特权可写、把每块栈的底部划一格禁止访问的守卫区。对栈溢出来说,守卫区这一步的价值在于把「静默踩坏一块合法 RAM」变成「立即停在越界那条指令上」——DACCVIOL 进来时 PC 就是那次 PUSH,配合第三章的 dump 就是完整现场。

4.5.3 怎么配 ​

以本工程的 MSP 栈为例,直接写 ARMv7-M 内核寄存器:

c
// Core/Src/mpu_guard.c
MPU->RNR  = 0U;                                    /* 用 region 0 */
MPU->RBAR = (uint32_t)GUARD_Mem;                   /* 基址须与大小对齐 */
MPU->RASR = (4U << MPU_RASR_SIZE_Pos)              /* SIZE = 4 → 2^(4+1) = 32 字节 */
          | (0U << MPU_RASR_AP_Pos)                /* AP = 000 → 特权/非特权都禁止访问 */
          | MPU_RASR_XN_Msk
          | MPU_RASR_ENABLE_Msk;

SCB->SHCSR |= SCB_SHCSR_MEMFAULTENA_Msk;           /* 让 MemManage 进自己的 handler */
MPU->CTRL  = MPU_CTRL_PRIVDEFENA_Msk | MPU_CTRL_ENABLE_Msk;

三处细节决定它能不能真正跑起来:

  • MPU_CTRL_PRIVDEFENA 必须置位。置位后背景区域(默认内存映射)对特权访问继续有效,只是额外「挖掉」守卫区那一格;不置位则所有未被 region 覆盖的地址统统 fault,程序寸步难行。
  • SHCSR.MEMFAULTENA 必须与 handler 改造同时做。 本工程现在的 MemManage_Handler 是空的 while (1):只使能 fault 而不改 handler,现场比不使能还差——故障被分流进 MemManage,既没有 dump 也不复位。正确做法是让 MemManage / BusFault / UsageFault 三个 handler 也走 hardfault_trampoline,hardfault_dump() 里那几行 DACCVIOL / MSTKERR 判断照常输出。
  • 异常入栈本身踩线时置位的是 MSTKERR 而不是 DACCVIOL。这一种情况下硬件压栈不完整,dump 出来的寄存器不可信——与 4.1 里那条边界是同一件事。

【注意】

本板用不了这一节。 ST 的 CMSIS 设备头按型号声明 MPU 有无,本工程编译时定义的是 STM32F103xE(高密度系列),对应头文件里是:

c
/* Drivers/CMSIS/Device/ST/STM32F1xx/Include/stm32f103xe.h */
#define __MPU_PRESENT             0U       /*!< Other STM32 devices does not provide an MPU  */

同目录下只有 XL-density 的 stm32f103xg.h(如 STM32F103ZG)是 1U。后果是整个 MPU 寄存器接口都不存在:

c
/* Drivers/CMSIS/Include/core_cm3.h */
#if defined (__MPU_PRESENT) && (__MPU_PRESENT == 1U)
  #define MPU_BASE          (SCS_BASE +  0x0D90UL)                    /*!< Memory Protection Unit */
  #define MPU               ((MPU_Type       *)     MPU_BASE      )   /*!< Memory Protection Unit */
#endif

也就是说上面那段代码在本工程里连编译都过不去;stm32f1xx_hal_cortex.h 的 HAL_MPU_Enable() / HAL_MPU_ConfigRegion() 同样被 #if (__MPU_PRESENT == 1U) 挡在门外。要用这一节,前提是换成带 MPU 的型号(F103xG、F4 等);本板(STM32F103ZE)只能用 4.2 ~ 4.4 三条。

4.6 没有 MPU 时的替代:把栈贴到 SRAM 底界 ​

本板没有 MPU,但还有一招能达到类似效果,且不需要任何内核外设:把主栈安排到 SRAM 的最底端,让栈底正好压在 SRAM 边界上。

现在的布局是 .data / .bss 在最前、栈在最后(见 4.1 的表),溢出要往下走 22 KB 才踩出 SRAM。把顺序倒过来——栈占 0x20000000 起的 1 KB,.data / .bss / 堆整体上移——之后 SP 只要越过 0x20000000,访问就落进 SRAM 下界的保留区,立刻产生总线错误(BFSR.PRECISERR,BFAR 给出地址),由第三章的 HardFault 现场方案接住。也就是说,用「栈底紧贴非法地址」替代了「栈底划禁区」。

代价与限制:

  • 需要动链接布局(scatter 文件或固定地址段),且 bootloader 与 flash-app 两个工程要同步,否则 RAM 划分约定不一致;
  • 拦截点在 SRAM 边界而不是栈底,栈区自己那 1 KB 依旧是可写 RAM,哨兵(4.4)仍然值得保留;
  • 「保留区访问会报总线错误」依赖器件的地址译码行为,落地前要实测一次——本工程已有的 crash 命令就是现成工具:读一个未映射地址,看是否如预期进 HardFault。

四、 预防与加固 ​

定位手段再完善也是事后的。下面三层从编译期到运行期,把大部分问题挡在发布之前。

1. 编译期防护 ​

  • 打开全部告警并当错误处理:-Wall -Wextra -Werror。数组越界、未初始化变量、隐式类型截断,相当一部分能被静态查出来。

  • 静态分析:cppcheck 能查出编译器不报的空指针解引用与越界。CI 里跑一遍成本很低。

  • 栈用量报告:GCC 加 -fstack-usage,为每个函数生成 .su 文件,列出该函数的栈消耗。把调用链的栈用量加起来,与任务栈大小对比,就能在运行前发现溢出风险。

  • 禁止动态分配:安全相关模块尽量用静态分配。堆耗尽与碎片是运行期最难复现的一类故障。

  • 保留符号文件:每次发布归档 .elf 与 .map,否则第三章的反查符号无从下手。

2. 运行期防护 ​

  • 使能 MPU(前提是型号带 MPU,本板 F103xE 不带,见第三章 4.5):至少把 Flash 配成只读可执行、把外设区配成仅特权可写、在每个任务栈底部留禁止访问的守卫区。

  • 打开 fault 上报:SHCSR 里使能 MemManage、BusFault、UsageFault。默认只有 HardFault 是开的,其余 fault 会直接升级成 HardFault,丢掉分类信息。注意使能后必须同步改造对应的 handler,否则故障会被分流进空的 while (1),现场反而更少。

  • 打开除零与非对齐陷阱:CCR 的 DIV_0_TRP 与 UNALIGN_TRP。开发期打开能把隐患暴露成明确的 fault;发布期是否保留取决于性能与兼容性权衡。

  • 所有忙等加超时:见第二章 5.3,这是把 hang 变成可恢复错误的最低成本手段。

  • 关键状态自检:定期校验配置寄存器的值是否还是期望值(外设被意外复位、电源毛刺都会改掉它们),发现不符就重新初始化。

3. 看门狗的正确用法 ​

看门狗能把死机变成自动重启,但用错了反而掩盖问题。三条原则:

(1)必须在保存现场之后才喂狗复位。看门狗复位会清掉大部分寄存器状态,如果不先把 fault 信息落到 NOINIT 段,重启之后就什么也查不到了。这是「上了看门狗反而更难查」的根本原因。看门狗本身的配置见 20-基本外设篇/20-看门狗/LV001-看门狗简介.md。

(2)喂狗点要放在能反映系统真实健康的地方。在主循环里无条件喂狗,等于没有看门狗——任务全阻塞死了,主循环可能还在跑。更好的做法是各任务定期上报存活标志,喂狗任务检查所有标志都置位后才喂一次。

(3)超时时间要大于最长正常周期。含 Flash 擦写、文件系统操作、网络重连的路径,最坏耗时可能是平时的几十倍。超时设得太短会造成正常运行中的误复位,而这种复位往往被当成偶发死机,排查方向完全跑偏。

【注意】

调试期可以在 DBGMCU 里配置看门狗随调试器停止而冻结,否则单步调试会不断触发复位。但要记住发布版本没有这个保护,超时时间必须按真实最坏情况设定。


本文档由 markdowncli 技能辅助生成