嵌入式面试真题第 15 题:不可恢复异常后的通用崩溃快照、调用栈保存与离线分析架构

在这里插入图片描述

问题

在量产嵌入式设备中,系统可能在任意业务状态、任意线程、任意中断嵌套层级和任意外设工作阶段发生不可恢复异常,例如 Arm Cortex-M 的 HardFault、MemManage、BusFault、UsageFault、SecureFault,RISC-V 的同步异常或 NMI,Xtensa 的 panic,RTOS assert,栈溢出,内存破坏,非法指令,访问越界,双重释放,DMA 越界,Flash/cache 异常,锁死后的看门狗复位,以及电压跌落前的紧急复位。

发生异常时,调度器、线程栈、堆、文件系统、日志系统、Flash 驱动甚至时钟和缓存状态都可能已经不可信。设备又可能位于用户现场,无法连接调试器,研发只能在返修、远程上报或下次启动后分析有限数据。

你会如何设计一套通用的嵌入式 Crash Snapshot/Core Dump 机制,在异常入口的极短时间内,安全、确定且可验证地保存 CPU 上下文、故障寄存器、当前调用栈、关键线程状态、最近事件和固件身份,并在复位后可靠地转存、上报和离线符号化?设计时应说明:

  1. 异常入口如何取得正确的栈指针和异常栈帧。
  2. 为什么不能在异常上下文中直接做完整调用栈分析。
  3. 如何防止栈损坏、二次 Fault、Flash 正在忙、掉电或看门狗超时导致快照再次失败。
  4. Crash Record 如何实现版本兼容、完整性校验和原子提交。
  5. 如何兼容裸机、RTOS、多核、FPU、TrustZone、不同存储介质和不同 CPU 架构。
  6. 如何借鉴 CMSIS-View Fault Storage、CrashCatcher、Zephyr Coredump、ESP-IDF Core Dump、RT-Thread 异常框架、Memfault Firmware SDK 等现有方案。

回答

结论:不要把“异常处理”设计成在 Fault Handler 中打印日志、遍历线程、回溯符号或写普通文件。正确方案是把整个流程拆成三个彼此隔离的阶段:

  1. 一级捕获阶段:异常入口只执行固定上界、无动态分配、无锁、尽量不依赖外设的最小快照逻辑,把原始寄存器、异常栈帧、故障状态和受边界保护的栈窗口写入保留 RAM、Backup SRAM、.noinit 区或预擦除的 Crash Slot。
  2. 二级持久化阶段:系统复位后,在调度器、文件系统和网络尚未启动或刚恢复可信状态时,校验一级快照并转存到专用 Flash 分区、FRAM、eMMC、LittleFS 文件或远程上报队列。
  3. 离线分析阶段:PC 工具结合与故障固件严格匹配的 ELF、MAP、DWARF、Build ID 和链接地址,完成符号化、栈展开、源码定位、故障寄存器解码和同类崩溃聚类。

核心原则是:异常上下文只保存事实,不做复杂推理;复位后完成可靠存储;主机侧完成重型分析。

总体架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
flowchart LR
A[不可恢复异常\nFault / Panic / Assert / WDT Pretimeout] --> B[最小汇编入口]
B --> C[识别原始 SP\nMSP / PSP / 安全域 / 核心号]
C --> D[切换独立 Crash Stack]
D --> E[一级快照引擎]
E --> F[CPU 寄存器]
E --> G[故障状态寄存器]
E --> H[受边界保护的栈窗口]
E --> I[线程/系统最小元数据]
E --> J[最近事件 Ring Buffer]
F --> K{一级存储后端}
G --> K
H --> K
I --> K
J --> K
K --> L[Retention RAM / Backup SRAM / .noinit]
K --> M[预擦除 Crash Flash Slot]
K --> N[FRAM / MRAM / 主机直连]
L --> O[系统复位]
M --> O
N --> O
O --> P[Early Boot Crash Collector]
P --> Q[校验 magic / version / length / CRC]
Q --> R[持久化、去重、加密、上传]
R --> S[ELF + MAP + DWARF + Build ID]
S --> T[离线符号化 / GDB / 聚类分析]

这个架构的关键不是保存尽可能多的数据,而是先保证最小快照在最差故障条件下仍有较高成功率。完整性和确定性优先于信息量。一个只包含 PC、LR、SP、CFSR、HFSR 和 256 字节栈窗口但 CRC 正确、固件版本匹配的记录,通常比一个写了一半、版本不明、来源不明的“全内存 Dump”更有价值。

将问题抽象为通用 Crash Capture,而不是只处理 HardFault

HardFault 只是不可恢复异常的一种入口。通用方案应把各种异常统一映射为 crash_reason,共用记录格式、存储后端和离线工具。

异常来源 典型触发原因 一级捕获入口 需要额外保存的信息
HardFault 可配置 Fault 升级、向量表异常、非法返回 HardFault_Handler HFSR、CFSR、原始 EXC_RETURN
MemManage MPU 访问违规、栈限制越界 MemManage_Handler MMFAR、MPU 配置、MSPLIM/PSPLIM
BusFault 非法地址、总线超时、精确或非精确总线错误 BusFault_Handler BFAR、总线矩阵/外部存储状态
UsageFault 未定义指令、除零、非对齐、非法状态 UsageFault_Handler CFSR.UFSR、PC 附近指令字
SecureFault TrustZone 安全属性、非法跨域访问 SecureFault_Handler SFSR、SFAR、安全域和安全栈信息
RTOS Assert/Panic 内核一致性检查失败 assert/panic hook 文件、行号、当前线程、调度状态
栈溢出 线程栈破坏、MSP/PSP 越界 MPU Fault、RTOS hook 栈边界、水位、canary、TCB 指针
看门狗 死循环、锁死、中断关闭过久 WDT pretimeout/NMI 或复位原因 当前 PC、任务心跳、最后喂狗点
Brownout 电源跌落、瞬态复位 Brownout IRQ/NMI 或复位寄存器 电压状态、复位源、未完成事务
RISC-V Trap 访问错误、非法指令、断点 trap entry mcausemepcmtvalmstatus
Xtensa Panic IllegalInstruction、Load/StoreProhibited panic handler EPC、EXCCAUSE、EXCVADDR、任务栈

因此,接口层不应叫 hardfault_save(),而应抽象为:

1
crash_capture(reason, arch_context, platform_context)

其中 arch_context 由 CPU 架构端口提供,platform_context 由 RTOS、SoC 和产品层按需扩展。

机制与开源方案的对应关系

本文机制 可参考组件或方案 核心原理 可直接使用程度 主要限制或注意事项
最小 Fault 快照到未初始化 RAM CMSIS-View Fault Storage 从 Fault Handler 直接分支到无栈/无堆保存函数,将寄存器、故障寄存器、magic、版本和 CRC 写入未初始化 RAM Cortex-M 项目可直接集成或参考 重点是最小 Fault 信息,不等同于完整 RTOS Core Dump
独立 Crash Stack、寄存器和 RAM 区域 Dump CrashCatcher + CrashDebug 汇编入口切换专用栈,收集硬件压栈寄存器、软件保存寄存器和指定 RAM 区域,主机侧进行事后调试 Cortex-M GCC 工程可集成 需要自行实现输出后端;示例中的文件系统写入不代表任何故障环境都安全
架构块 + 内存块 + 多后端 Core Dump Zephyr Coredump Fatal Error 时输出 CPU 寄存器和选定内存,支持 logging、UDP、Flash Partition 等后端,并由自定义 GDB Server 还原目标 Zephyr 项目可直接配置 Flash 后端仍依赖具体 Flash 控制器和驱动在异常场景下可用
任务 TCB/栈快照、Flash/UART、ELF 解析 ESP-IDF Core Dump Panic Handler 保存任务快照和寄存器到专用分区或 UART,主机用 idf.py coredump-info/debug 分析 ESP-IDF 项目可直接使用 数据量与任务数、栈大小相关;Flash cache 损坏场景需要特别配置 IRAM panic 路径
RTOS 异常栈、异常钩子和 Backtrace RT-Thread Cortex-M CPU Port 利用异常栈结构、独立中断栈、异常钩子和架构 Backtrace 能力输出上下文 RT-Thread 项目可在现有异常框架上扩展 默认打印不等于持久化;需自行增加 Crash Record 和复位后转存
可配置 Coredump 区域、重启原因、远程聚类 Memfault Firmware SDK Firmware SDK 采集寄存器、任务和选定内存区域,经持久存储和分片上传后进行符号化与聚类 可参考或集成,云端服务商业化 SDK 使用 Memfault License,不应简单视为宽松许可证开源组件
掉电安全的复位后文件持久化 littlefs Copy-on-write、元数据日志、掉电回退、动态磨损均衡和有界 RAM 适合二级持久化 不应在 HardFault 最小路径中直接 mount/open/write/close

这些方案共同体现了几条成熟原则:异常入口必须尽量小;原始状态和解析工具分离;记录必须携带格式版本和固件身份;存储后端应可替换;内存 Dump 区域必须受控;真正的调用栈还原应在主机侧完成。

三级处理模型

一级:Fault Context Capture

一级阶段的任务只有四类:

  1. 取得原始异常上下文,而不是 Handler 已经修改后的上下文。
  2. 把最关键数据复制到可信目标区域。
  3. 以原子方式标记记录完成或部分完成。
  4. 在确定的时间上界内复位或停机。

禁止项包括:

  • printfsprintf、格式化日志和复杂串口驱动。
  • malloc/free、C++ new/delete、容器扩容。
  • mutex、semaphore、message queue 等可能阻塞的同步原语。
  • 普通文件系统的 mount/open/write/fsync/close 链路。
  • 依赖中断完成的 DMA、Flash、UART 驱动。
  • 遍历不可信链表、线程表、堆块或文件系统元数据。
  • 在故障处理器中执行符号解析、DWARF 解析或无限深度栈回溯。

二级:Early Boot Commit

二级阶段发生在复位后。此时可以逐步恢复更多能力,但仍应尽早执行,避免 .bss 初始化、内存清零、Bootloader 升级或新的崩溃覆盖一级记录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
sequenceDiagram
participant Fault as Fault/Panic Entry
participant RAM as Retention/.noinit
participant Reset as System Reset
participant Boot as Early Boot Collector
participant Flash as Crash Partition
participant App as Application

Fault->>RAM: 写 header,state=WRITING
Fault->>RAM: 写寄存器/栈窗口/事件
Fault->>RAM: 计算 CRC
Fault->>RAM: 最后写 state=VALID
Fault->>Reset: 复位或等待 WDT
Reset->>Boot: 启动早期检查
Boot->>RAM: 校验 magic/version/length/CRC
alt 有效记录
Boot->>Flash: 追加到预留 Crash Slot
Flash-->>Boot: 写入并读回校验
Boot->>RAM: 标记已搬运或清除 magic
else 无效或半写记录
Boot->>Boot: 记录 partial/corrupt 统计
end
Boot->>App: 正常启动或进入 Safe Mode

二级阶段可以执行:

  • 将 Retention RAM 快照搬到专用 Flash 分区。
  • 使用 LittleFS、NVS 或数据库保存索引,但前提是文件系统可正常挂载。
  • 加密、压缩、脱敏、去重和上传。
  • 根据连续崩溃次数进入 Safe Mode、回滚固件或禁用高风险功能。
  • 保留“首个故障”与“最近故障”的不同策略。

三级:Host-side Post-mortem Analysis

三级阶段由 PC 或服务器完成:

  • 依据 Build ID 找到精确 ELF、MAP、DWARF 和 ROM ELF。
  • 解码 CPU Fault Status Register。
  • 根据 PC、LR、SP、栈内容和 unwind 信息展开调用栈。
  • 映射到函数、文件和源码行。
  • 读取局部变量、线程状态和最近事件。
  • 对大量设备记录按 fault signature 聚类。

Cortex-M 异常入口必须先理解硬件自动压栈

以下实现以 Arm Cortex-M 为主要示例。其他架构应保留同样的分层思想,但用各自的 trap frame、cause register 和特权级状态替换。

Cortex-M 进入异常时,硬件会把基础异常栈帧压入异常前正在使用的栈。基础帧通常包含:

1
2
3
4
5
6
7
8
R0
R1
R2
R3
R12
LR
PC
xPSR

CMSIS-Core 的 System Control Block 还提供 CFSR、HFSR、DFSR、MMFAR、BFAR、AFSR 等故障状态寄存器。具体存在性随 Cortex-M 代际和实现变化,应通过 CMSIS 设备头文件和对应架构手册判断,而不是硬编码所有芯片都存在相同寄存器。

1
2
3
4
5
6
7
8
9
flowchart TB
A[异常前 Thread/Handler Context] --> B{原来使用哪个 SP?}
B -->|EXC_RETURN 指示 MSP| C[基础/扩展栈帧位于 MSP 指向区域]
B -->|EXC_RETURN 指示 PSP| D[基础/扩展栈帧位于 PSP 指向区域]
C --> E[R0-R3,R12,LR,PC,xPSR]
D --> E
E --> F{是否存在 FPU 扩展帧?}
F -->|是| G[S0-S15,FPSCR,保留字及基础帧]
F -->|否| H[仅基础帧]

必须注意以下细节:

  1. Handler Mode 使用的 SP 不一定等于故障线程原 SP。Fault Handler 开始执行后,处理器处于 Handler Mode;真正的异常栈帧位置应根据入口时 LR 中的 EXC_RETURN 解码。
  2. FPU 可能形成扩展栈帧。启用 FPU 和 lazy stacking 后,栈布局不能只按固定 8 个字处理。
  3. xPSR 可能指示栈对齐填充。离线恢复原始 SP 时要考虑异常入口的对齐规则。
  4. 异常可能发生在另一个异常中。此时原上下文可能本来就处于 Handler Mode,必须保留 IPSR、ICSR 和异常嵌套信息。
  5. Armv8-M TrustZone 存在安全/非安全状态和更多栈指针。安全固件必须明确哪些信息允许跨域保存和上报。
  6. 栈已经损坏时,任何基于栈的 C 函数调用都可能再次失败。因此应尽早切换到独立 Crash Stack,或者采用 CMSIS-View Fault Storage 这类不使用栈的最小保存实现。

最小汇编入口

下面代码只表达设计方法,不是所有 Cortex-M、编译器、FPU 和 TrustZone 配置下可直接复制的最终版本。正式工程应针对 GCC、Arm Compiler、IAR 以及具体 CPU 架构分别验证生成指令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/**
* @brief Cortex-M hardware-stacked basic exception frame.
*/
typedef struct
{
uint32_t r0;
uint32_t r1;
uint32_t r2;
uint32_t r3;
uint32_t r12;
uint32_t lr;
uint32_t pc;
uint32_t xpsr;
} crash_cm_basic_frame_t;

/**
* @brief Capture a fatal Cortex-M exception using the original exception frame.
*
* @param frame Pointer to the hardware-stacked exception frame.
* @param exc_return EXC_RETURN value captured from LR at handler entry.
*/
__attribute__((noreturn))
void crash_capture_cortex_m(const crash_cm_basic_frame_t *frame, uint32_t exc_return);

/**
* @brief Minimal HardFault entry. No C prologue or local stack is allowed here.
*/
__attribute__((naked)) void HardFault_Handler(void)
{
__asm volatile(
"tst lr, #4 \n"
"ite eq \n"
"mrseq r0, msp \n"
"mrsne r0, psp \n"
"mov r1, lr \n"
"b crash_capture_cortex_m \n");
}

这段入口完成两件事:根据 EXC_RETURN 选择 MSP 或 PSP,并把 EXC_RETURN 传给后续捕获函数。更稳健的量产实现还会:

  • 在汇编中保存 R4-R11。
  • 读取 MSP、PSP、CONTROL、PRIMASK、BASEPRI、FAULTMASK、IPSR。
  • 尽早切换到单独的 Crash Stack。
  • 对 FPU 扩展帧和 lazy stacking 做架构相关处理。
  • 对 Armv8-M 保存 MSPLIM、PSPLIM、安全域状态和 SecureFault 寄存器。
  • 对多核系统保存 core id,并通过原子变量争夺 Crash Owner。

为什么需要独立 Crash Stack

如果故障是线程栈溢出、返回地址破坏、错误 SP、越界写或内存踩踏引起,继续使用原栈执行 C 代码会显著提高二次 Fault 概率。CrashCatcher 的重要设计就是切换到专用 g_crashCatcherStack 后再运行收集逻辑。

1
2
3
4
5
6
flowchart LR
A[原线程栈\n可能溢出/损坏] --> B[异常硬件压栈]
B --> C[汇编入口保存原 SP]
C --> D[切换专用 Crash Stack]
D --> E[执行有界 C 捕获函数]
E --> F[写 Crash Buffer]

Crash Stack 应满足:

  1. 静态分配,链接时确定地址和大小。
  2. 与普通线程栈、堆、DMA Buffer 和 .bss 清零区域隔离。
  3. 最好带 MPU Guard 或 stack limit。
  4. 编译期和运行期测量最大使用量,保留足够余量。
  5. 不在 Crash Stack 上创建大数组;所有大数据复制到 Crash Buffer。
  6. 若处理器支持 TCM 或独立 SRAM,可放入更可靠且不依赖 cache 的 RAM。

示例链接脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
/* Retained across software reset; startup code must not clear this section. */
.noinit.crash (NOLOAD) :
{
. = ALIGN(8);
__crash_record_start__ = .;
KEEP(*(.noinit.crash_record))
__crash_record_end__ = .;

. = ALIGN(8);
__crash_stack_bottom__ = .;
KEEP(*(.noinit.crash_stack))
__crash_stack_top__ = .;
} > RETENTION_RAM

如果芯片没有真正的 Retention RAM,而 .noinit 仍位于普通 SRAM,则必须确认复位类型不会断电或由启动代码清除。Power-on Reset、Brownout Reset 和某些 Boot ROM 流程可能不保留普通 SRAM,不能把 .noinit 当作绝对可靠的非易失存储。

一级快照应该保存什么

建议按优先级分层,而不是一次性把所有数据都塞入 Handler。

P0:必须保存

  • magic、格式版本、记录长度、状态、CRC。
  • crash reason、复位原因、CPU 架构、核心号、安全域。
  • 固件 Build ID、镜像版本、镜像槽位、链接基址。
  • PC、LR、SP、xPSR/状态寄存器。
  • EXC_RETURN 或对应架构的 exception return 信息。
  • Cortex-M 的 CFSR、HFSR、DFSR、MMFAR、BFAR、AFSR;RISC-V 的 mcause/mepc/mtval/mstatus;Xtensa 的 EPC、EXCCAUSE、EXCVADDR。
  • 当前时间基准:uptime tick、RTC 时间、启动计数。
  • 至少 128~512 字节的受保护栈窗口。

P1:高价值信息

  • R0-R12、MSP、PSP、CONTROL、PRIMASK、BASEPRI、FAULTMASK、IPSR。
  • FPU 状态、FPSCR 和必要的浮点寄存器。
  • 当前线程 ID、TCB 地址、线程栈起止地址、线程名的固定长度副本。
  • 最近几十条二进制事件记录。
  • 当前中断号、临界区嵌套、调度锁嵌套、heap watermark。
  • 关键外设状态,例如 DMA channel、Flash controller busy/error、总线错误寄存器。

P2:资源允许时保存

  • 当前线程完整已用栈。
  • 所有线程的 TCB 和栈片段。
  • 全局 RAM、特定内存池、消息队列和协议状态。
  • 外部存储控制器寄存器和缓存标签。
  • 业务级诊断区,例如播放状态机、网络连接状态、最近命令。
1
2
3
4
5
6
7
8
9
flowchart TD
A[Crash Capture Budget] --> B[P0 最小记录\n必须成功]
B --> C{剩余时间和存储是否足够?}
C -->|否| D[提交 P0 并复位]
C -->|是| E[P1 当前线程和最近事件]
E --> F{仍有预算?}
F -->|否| G[提交 P0+P1]
F -->|是| H[P2 多线程/大内存区域]
H --> I[提交完整记录]

应通过配置决定层级,而不是把所有产品都编译成最大 Dump。资源很小的 MCU 可以只保存 P0;高端 MCU 或带 FRAM/eMMC 的系统可以保存 P0+P1+部分 P2。

Crash Record 设计

一个可维护的记录格式应具备:

  • 固定头部,便于 Bootloader 或 PC 工具快速识别。
  • 明确的 format_versionheader_size
  • 整体长度和各段长度,避免解析越界。
  • 架构无关头 + 架构相关块 + 平台扩展块。
  • Build ID、镜像版本和地址模型。
  • CRC32 或更强校验。
  • 写入状态和 partial flags。
  • 允许旧解析器跳过未知 TLV。

推荐使用 TLV 或 section table,而不是一个永远扩大的 C 结构体。

1
2
3
4
5
6
7
8
flowchart LR
A[Fixed Header] --> B[Arch Registers TLV]
B --> C[Fault Status TLV]
C --> D[Stack Region TLV]
D --> E[RTOS Thread TLV]
E --> F[Recent Events TLV]
F --> G[Platform Registers TLV]
G --> H[CRC / Commit Marker]

参考结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#define CRASH_RECORD_MAGIC          UINT32_C(0x43525348) /* "CRSH" */
#define CRASH_RECORD_FORMAT_VERSION UINT16_C(2)

/**
* @brief Persistent crash record state encoded as one-way Flash bit transitions.
*/
typedef enum
{
CRASH_RECORD_STATE_ERASED = 0xFFFFFFFFu,
CRASH_RECORD_STATE_WRITING = 0xFFFFFFFEu,
CRASH_RECORD_STATE_VALID = 0xFFFFFFFCu,
CRASH_RECORD_STATE_USED = 0xFFFFFFF8u,
} crash_record_state_t;

/**
* @brief Architecture-independent crash record header.
*/
typedef struct
{
uint32_t magic;
uint16_t format_version;
uint16_t header_size;
uint32_t total_size;
uint32_t state;
uint32_t flags;
uint32_t sequence;
uint32_t crash_reason;
uint32_t reset_reason;
uint32_t architecture;
uint32_t core_id;
uint64_t uptime_ticks;
uint8_t build_id[20];
uint32_t image_version;
uint32_t image_slot;
uint32_t payload_crc32;
uint32_t header_crc32;
} crash_record_header_t;

/**
* @brief Generic TLV header used to extend crash records without breaking parsers.
*/
typedef struct
{
uint16_t type;
uint16_t version;
uint32_t length;
} crash_tlv_header_t;

为什么 Build ID 是必需字段

仅记录“V1.2.3”通常不够,因为同一版本号可能存在不同编译时间、不同编译选项、不同链接布局或临时补丁。PC、LR 只有在与准确 ELF 对应时才有意义。

建议 CI 为每个镜像生成不可冲突的 Build ID,并归档:

1
2
3
4
5
6
7
8
9
firmware.bin
firmware.elf
firmware.map
firmware.sym
compiler version
linker script
Kconfig/menuconfig
source commit
submodule commit

可在 GNU 链接阶段启用或保留 ELF Build ID,也可以把 Git commit、配置哈希和镜像哈希组合成产品自己的 128/160 位标识。Crash Record 中应保存二进制值,不要只存可能被截断的字符串。

原子提交和掉电一致性

记录写入时必须假设随时会发生二次复位、看门狗到期或掉电。

推荐顺序:

  1. 选择一个已擦除 Slot。
  2. state=WRITING 或在 RAM 中清零记录区。
  3. 写固定头中除 CRC 和 VALID 外的字段。
  4. 写 TLV payload。
  5. 计算并写 payload CRC、header CRC。
  6. 执行必要的 memory barrier、cache clean 或存储同步。
  7. 最后一步把状态从 WRITING 单向编程为 VALID。
1
2
3
4
5
6
7
8
9
stateDiagram-v2
[*] --> ERASED
ERASED --> WRITING: 开始新记录
WRITING --> VALID: payload和CRC完成后最后提交
WRITING --> CORRUPT: 中途复位/掉电
VALID --> USED: 已上传或已转存
VALID --> PRESERVED: 首故障保护
USED --> ERASED: 正常运行期后台擦除
CORRUPT --> ERASED: 启动后回收

对 NOR Flash,应利用“擦除后为 1、编程只能从 1 变 0”的性质设计状态位,避免完成标记需要 0 变 1。Flash 擦除绝不能放在紧急 Fault 路径中,应在系统健康时预擦除 Crash Slot。

对 Retention RAM,可先写 payload 和 CRC,最后写 magic;启动时只有 magic、长度和 CRC 都合法才接收。若 CPU 有 data cache,必须确认写入目标是否 cacheable,并在复位前执行正确的 cache clean 和屏障,否则 RAM 或外部存储中的内容可能尚未真正落地。

存储后端如何选择

优先级通常如下:

  1. Retention RAM/Backup SRAM:写入快、依赖少,适合作为一级快照。
  2. 预擦除的专用内部 Flash Slot:掉电后仍保留,但要求异常时 Flash 控制器可用。
  3. FRAM/MRAM:字节写、低延迟、无需擦除,非常适合 Crash Record,但成本和容量受限。
  4. 外部 QSPI NOR 的专用分区:容量大,但可能与代码执行、cache、XIP 和业务写入共享控制器。
  5. UART/SWO/USB/网络直传:只适合实验室或确认链路简单可靠的场景,不应作为现场唯一方案。
  6. 普通文件系统:仅用于复位后的二级持久化。
1
2
3
4
5
6
7
8
9
10
flowchart TD
A[选择一级存储] --> B{有 Retention/Backup RAM?}
B -->|有| C[先写 RAM,复位后转存]
B -->|无| D{有 FRAM/MRAM?}
D -->|有| E[直接写固定地址记录]
D -->|无| F{Flash 有预擦除 Slot\n且故障路径可用?}
F -->|有| G[RAMFUNC 轮询写入]
F -->|无| H{有可靠调试链路?}
H -->|有| I[最小 UART/SWO Hex Dump]
H -->|无| J[至少保存复位原因和少量 Backup Register]

直接写 Flash 的前置条件

只有同时满足下列条件时,才建议在一级 Fault 路径直接写 Flash:

  • Crash 区已经擦除,不需要执行耗时擦除。
  • 写入函数、常量和必要数据位于 RAM/ROM,不依赖当前不可访问的 XIP Flash。
  • 驱动使用轮询,不依赖中断、DMA、mutex 或调度器。
  • Flash 控制器不处于擦除、编程、挂起或错误状态。
  • 写入目标与当前执行 bank 的限制已评估。
  • 电压和时钟满足编程条件。
  • 有严格写入长度和超时上界。
  • 看门狗预算允许;或者只保存 P0 后立即复位。

ESP-IDF 文档特别说明,panic handler 若位于 Flash,而故障发生时 Flash cache 被关闭或损坏,重新启用 cache 会增加风险;把关键 panic 路径放入 IRAM 可以降低对 Flash 可执行路径的依赖。这个思路对所有 XIP MCU 都适用。

安全复制栈窗口,避免二次 Fault

不能直接执行:

1
memcpy(record->stack, (void *)sp, 1024);

因为 sp 可能已经越界、未对齐、指向外设、指向不可访问安全域,或距离 SRAM 末端不足 1024 字节。

正确流程是:

  1. 从链接脚本、MPU 配置或 RTOS TCB 获得允许读取的 RAM 区间。
  2. 判断 SP 是否落入已知栈区域。
  3. 对起止地址执行防溢出的范围裁剪。
  4. 只按架构允许的访问宽度读取。
  5. 限制最大长度。
  6. 若不能证明安全,只保存寄存器,不复制栈。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/**
* @brief Copy a bounded stack window from a validated RAM range.
*
* @param dst Destination buffer in the crash record.
* @param dst_capacity Destination capacity in bytes.
* @param sp Fault-time stack pointer.
* @param stack_low Inclusive stack lower bound.
* @param stack_high Exclusive stack upper bound.
* @return Number of bytes copied.
*/
static size_t crash_copy_stack_window(uint8_t *dst,
size_t dst_capacity,
uintptr_t sp,
uintptr_t stack_low,
uintptr_t stack_high)
{
size_t available;
size_t copy_len;

if ((dst == NULL) || (stack_low >= stack_high) || (sp < stack_low) || (sp >= stack_high))
{
return 0U;
}

available = (size_t)(stack_high - sp);
copy_len = (available < dst_capacity) ? available : dst_capacity;

/* The production implementation may use guarded word reads instead of libc memcpy. */
for (size_t i = 0U; i < copy_len; ++i)
{
dst[i] = ((const volatile uint8_t *)sp)[i];
}

return copy_len;
}

这段代码仍假定整个声明的栈区可读。对于有 MPU、ECC、总线超时或安全域隔离的系统,应进一步采用平台安全读取函数,并允许在读取失败时提前结束、设置 CRASH_FLAG_PARTIAL_STACK,而不是让整个记录失败。

调用栈保存不等于在 Handler 中完成 Backtrace

“保存调用栈”通常有四个层级:

层级一:PC + LR

最小成本,能定位当前指令和直接调用者,但对内联、尾调用、异常返回和 LR 已覆盖的场景信息有限。

层级二:原始栈窗口 + 地址扫描

保存 SP 后的一段原始内存,PC 工具扫描落在可执行代码区的 Thumb 地址,再结合反汇编判断哪些值可能是返回地址。实现简单,但存在误报,不能把扫描结果当作严格调用链。

层级三:帧指针链

使用固定帧指针可以较稳定地沿 frame chain 回溯,但会增加寄存器压力和代码开销。不同编译器、优化级别和 ABI 对 R7/R11 使用不同,必须由实际工具链验证。可只对关键模块使用 -fno-omit-frame-pointer,不必全局关闭优化。

层级四:Unwind Table / DWARF / RTOS Core Dump

保存完整寄存器和足够栈内存后,由 GDB、CrashDebug、Zephyr GDB Server、ESP-IDF espcoredump 或自研解析器根据 unwind tables、函数序言和调试信息还原调用栈。这是信息最完整的方式,但 ELF 和故障固件必须严格匹配。

1
2
3
4
5
6
7
8
9
10
11
flowchart LR
A[故障 PC/LR/SP] --> B[原始栈字节]
B --> C{离线展开策略}
C --> D[帧指针链]
C --> E[ARM EHABI/.ARM.exidx]
C --> F[DWARF CFI]
C --> G[启发式返回地址扫描]
D --> H[符号化调用栈]
E --> H
F --> H
G --> I[候选调用路径]

因此,Fault Handler 中最合理的行为是保存“可供展开的原料”,而不是调用一个复杂 backtrace 函数打印几十层符号。尤其当故障本身由栈破坏引起时,在线回溯很容易再次访问非法地址。

RTOS 场景下如何保存线程信息

RTOS Core Dump 的难点不是读取当前线程,而是内核数据结构可能已经损坏,且遍历所有线程会扩大故障路径。

当前线程最小信息

优先保存:

  • 当前 TCB 指针。
  • 当前线程静态 ID 和固定长度名称副本。
  • 线程栈起止地址和当前 SP。
  • 线程优先级、状态、CPU affinity。
  • 调度锁、中断嵌套和临界区计数。

这些信息最好在系统正常运行时维护到一个只读或简单的 crash_runtime_info 中,而不是故障时遍历内核对象。

所有线程快照

若系统资源允许,可参考 Zephyr DEBUG_COREDUMP_MEMORY_DUMP_THREADS 或 ESP-IDF 的任务 TCB/Stack 快照:

  1. 先保存当前故障线程。
  2. 再按固定上限保存其他线程的 TCB 摘要。
  3. 每个线程只保存已用栈或顶部固定窗口。
  4. 对所有指针做地址范围验证。
  5. 达到时间或容量预算立即停止,并设置 partial flag。

RT-Thread 的接入思路

RT-Thread Cortex-M 端口已经具备异常栈结构、独立中断栈、异常处理和 backtrace 相关能力。量产方案可在 rt_exception_hook 或架构 Fault 入口中加入一级 Crash Capture,但不应只依赖 rt_kprintf 输出。更稳妥的做法是:

1
2
3
4
5
6
RT-Thread Fault Entry
-> 保存 exception_stack 原始内容
-> 保存 rt_thread_self() 的静态摘要
-> 写 .noinit/Backup SRAM Crash Record
-> 复位
-> board early init 校验并转存到 Flash

若当前故障发生在中断上下文,rt_thread_self() 只能表示被中断线程,不代表当前正在执行的 ISR,因此还应保存 IPSR/IRQ number 和中断嵌套层级。

最近事件 Ring Buffer 比大量文本日志更适合 Crash Capture

Fault Handler 中临时打印文本往往失败,但系统可以在正常运行时维护一个固定大小的二进制 Flight Recorder。

1
2
3
4
5
6
7
8
9
10
11
/**
* @brief Compact event stored in a lock-free diagnostic ring.
*/
typedef struct
{
uint32_t timestamp;
uint16_t event_id;
uint16_t source_id;
uint32_t arg0;
uint32_t arg1;
} crash_event_t;

事件可以包括:

  • 线程切换。
  • ISR 进入/退出。
  • Flash 擦写开始/完成。
  • DMA 启动/完成/超时。
  • 内存分配失败。
  • 协议状态切换。
  • 看门狗喂狗点。
  • 电源、电压和温度告警。
  • 业务状态机关键节点。

一级捕获只需要复制 ring 的 head、sequence 和最近 N 条固定长度记录,不进行字符串格式化。主机工具再根据 event_id 和固件版本映射为文本。这样既降低故障路径复杂度,也能还原“崩溃前发生了什么”。

多核系统的 Crash Owner 和其他核心冻结

双核或多核 MCU 可能出现多个核心同时进入 panic,若都写同一 Crash Slot 会互相破坏。

推荐设计:

  1. 使用位于共享、原子可访问 RAM 中的 crash_owner
  2. 第一个进入的核心通过 compare-and-swap 成为 owner。
  3. Owner 发送 NMI/IPI 请求其他核心进入 secondary crash handler。
  4. 每个核心把自己的寄存器写到独立 per-core 区域。
  5. Owner 负责最终 CRC、commit 和复位。
  6. 若其他核心在固定时间内未响应,设置 missing-core mask 后继续提交。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
sequenceDiagram
participant C0 as Core 0
participant O as Crash Owner State
participant C1 as Core 1
participant R as Shared Crash Record

C0->>O: CAS owner = Core0
O-->>C0: 成功
C0->>C1: 发送 NMI/IPI Freeze
C1->>R: 写 Core1 寄存器块
C1->>C0: 设置 ready bit
C0->>R: 写 Core0 和公共块
C0->>R: CRC + VALID
C0->>C0: 系统复位

不能无限等待另一个核心,否则故障核心可能因为对方锁死而无法完成最小记录。所有等待都必须有 cycle counter 或硬件 timer 上界。

看门狗、复位循环和 Safe Mode

Crash Capture 必须与 Watchdog 策略协同,而不是简单地在 Handler 中无限喂狗。

推荐策略

  • P0 快照时间必须小于最短看门狗剩余时间。
  • 如果有 Window Watchdog,不要在非法窗口喂狗。
  • 若有 pretimeout interrupt/NMI,先保存轻量 Snapshot,再让最终 WDT Reset 发生。
  • Handler 最多喂狗一次或按严格次数喂狗,防止故障代码永久卡住。
  • Early Boot 统计连续崩溃次数和相同签名。
  • 在短时间内重复崩溃超过阈值时进入 Safe Mode、回滚或关闭高风险模块。
1
2
3
4
5
6
7
8
crash_loop_count += 1
if same_build_id && boot_uptime < short_boot_threshold:
rapid_crash_count += 1
else:
rapid_crash_count = 0

if rapid_crash_count >= safe_mode_threshold:
enter_safe_mode()

Crash Loop 统计也必须掉电安全,并避免每次启动都擦写同一 Flash 字。可使用 Backup Register、FRAM、带磨损均衡的 KV 或追加式小日志。

安全、隐私和可信边界

Crash Dump 可能包含密码、密钥、Token、用户数据、音频片段、网络报文和安全域内容。量产系统不能只考虑调试价值,还要考虑数据泄露。

建议:

  1. 定义允许 Dump 的内存白名单,不默认 Dump 全 SRAM。
  2. 对密钥区、加密引擎上下文和用户隐私 Buffer 设置 exclusion region。
  3. 一级记录只做最小快照;复杂加密放到复位后的可信阶段。
  4. Crash 分区设置访问控制,量产接口需要鉴权。
  5. 上传时使用设备身份认证和传输加密。
  6. 服务器按产品、版本、权限和保留周期管理符号和 Dump。
  7. TrustZone 系统由 Secure 侧决定哪些 Secure Fault 信息可以暴露给 Non-secure 世界。
  8. 记录格式中加入 redaction_policy_version,便于审计。

不能为了调试方便而把整个安全 RAM 或完整用户数据无条件写入可直接读取的外部 Flash。

一级捕获伪代码

下面示例展示“最小记录优先、逐级扩展、最后提交”的结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/**
* @brief Capture a fatal exception into a preallocated crash record.
*
* @param reason Architecture-independent crash reason.
* @param arch_ctx Architecture-specific exception context.
* @param platform Platform runtime information maintained during normal operation.
*/
__attribute__((noreturn))
void crash_capture(uint32_t reason,
const crash_arch_context_t *arch_ctx,
const crash_platform_context_t *platform)
{
crash_record_writer_t writer;
uint32_t irq_state;

irq_state = crash_disable_maskable_interrupts();
crash_switch_to_dedicated_stack_if_required();

crash_writer_begin(&writer, &g_crash_record, sizeof(g_crash_record));
crash_writer_set_state(&writer, CRASH_RECORD_STATE_WRITING);

crash_write_fixed_header(&writer, reason, arch_ctx, platform);
crash_write_arch_registers(&writer, arch_ctx);
crash_write_fault_status(&writer, arch_ctx);
crash_write_validated_stack_window(&writer, arch_ctx, platform);

if (crash_budget_remaining())
{
crash_write_current_thread(&writer, platform);
}

if (crash_budget_remaining())
{
crash_write_recent_events(&writer, platform);
}

crash_writer_finalize_crc(&writer);
crash_storage_barrier();
crash_writer_commit_valid(&writer);
crash_storage_barrier();

(void)irq_state;
crash_platform_reset_or_halt();
}

量产实现还应有“二次 Fault 保护”。最简单的方法是设置一个全局原子标志:如果进入 Fault 时发现 crash_in_progress 已经置位,则不再执行完整路径,只保存一个极小 secondary-fault 标志,然后立即复位。

1
2
3
if crash_in_progress was already set:
write SECONDARY_FAULT marker if possible
reset immediately

Early Boot Collector 伪代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* @brief Validate and persist a retained crash record during early boot.
*/
void crash_early_boot_collect(void)
{
const crash_record_header_t *record = crash_retained_record();

if (!crash_record_header_sane(record))
{
return;
}

if (!crash_record_crc_valid(record))
{
crash_stats_note_corrupt_record();
crash_retained_record_clear();
return;
}

crash_boot_policy_update(record);

if (crash_flash_slot_available())
{
if (crash_flash_append_and_verify(record) == 0)
{
crash_retained_record_mark_used();
}
}

if (crash_boot_policy_requires_safe_mode())
{
crash_enter_safe_mode();
}
}

Early Boot Collector 的执行顺序很重要。它应位于会清除 Retention RAM 的初始化之前,也应位于可能再次触发同类故障的复杂驱动初始化之前。Bootloader 与应用必须对 Crash RAM 的所有权、布局、版本和清除策略达成一致。

离线符号化流程

1
2
3
4
5
6
7
8
9
10
11
12
13
flowchart LR
A[设备 Crash Record] --> B[解析 Header/TLV]
B --> C[读取 Build ID]
C --> D[从符号仓库定位 ELF/MAP/DWARF]
D --> E[校验架构/地址基址/镜像槽]
E --> F[解码 Fault Status]
E --> G[恢复寄存器]
E --> H[装载栈内存块]
F --> I[根因候选]
G --> J[GDB/CrashDebug/自研 Unwinder]
H --> J
J --> K[函数/文件/行号/局部变量]
K --> L[生成故障签名并聚类]

最基础的地址符号化可以使用:

1
arm-none-eabi-addr2line -e firmware.elf -f -C 0x08012345 0x08006789

但仅靠 addr2line 不能恢复完整运行时调用栈。完整分析通常需要把保存的寄存器和内存块呈现给 GDB。CrashCatcher 配套 CrashDebug、Zephyr 的 coredump_gdbserver.py、ESP-IDF 的 idf.py coredump-debug 都体现了这一模式。

故障签名

为了聚类,不能直接用完整 Dump 哈希,因为时间戳和栈变量会导致每条记录不同。可构造稳定签名:

1
2
3
4
5
6
7
8
signature = hash(
build_id,
crash_reason,
normalized_pc,
normalized_lr,
top_3_call_frames,
selected_fault_status_bits
)

同一根因的设备通常会落入同一签名组,研发可以按设备数、版本、时间和硬件批次排序处理。

记录容量和时间预算如何量化

Crash Record 容量不是越大越好,必须同时满足 RAM/Flash 容量、写入时间和看门狗时间上限。

1
2
3
4
5
6
7
8
B_record = B_header
+ B_arch_regs
+ B_fault_regs
+ B_current_stack
+ B_thread_meta
+ B_event_ring
+ B_platform_regs
+ B_crc_padding

一级捕获时间近似为:

1
2
3
4
5
6
T_capture = T_entry
+ T_register_copy
+ T_memory_copy
+ T_crc
+ T_storage_program
+ T_commit

必须满足:

1
T_capture < min(T_watchdog_remaining, T_power_hold_up, T_product_recovery_budget)

示例:

1
2
3
4
5
6
7
8
固定头和寄存器          512 B
当前栈窗口 1024 B
最近事件 64 条 1024 B
当前线程元数据 128 B
平台寄存器 256 B
对齐和 CRC 128 B
--------------------------------
总计 3072 B

3KB 的一级记录通常已经能提供较高诊断价值。若 Retention RAM 只有 1KB,可缩减为 512B 栈窗口和 16 条事件;若有 64KB Crash 分区,可以在复位后追加多个一级记录,而不是在 Fault 中保存所有任务完整栈。

Crash Slot 数量

如果返修周期或上报周期较长,应估算:

1
N_slots >= peak_crash_rate_per_device * maximum_offline_duration

但频繁重复崩溃不应无限占用 Flash。可采用:

  • First crash wins:保留首次异常,后续只累计次数。
  • Ring slots:保留最近 N 次。
  • Signature dedup:相同签名只更新计数和最后时间。
  • Priority slots:未知新签名覆盖低价值重复签名。

Flash Crash 分区布局

1
2
3
4
5
6
7
8
9
10
11
flowchart LR
A[Crash Partition] --> B[Superblock A]
A --> C[Superblock B]
A --> D[Slot 0]
A --> E[Slot 1]
A --> F[Slot 2]
A --> G[...]
D --> H[Header]
H --> I[TLV Payload]
I --> J[CRC]
J --> K[Commit Word]

建议:

  1. Slot 按 erase block 对齐,或多个小 Slot 共享一个预擦除 erase block。
  2. Superblock 采用双副本、sequence 和 CRC,或完全避免必须更新的全局索引,启动时扫描 Slot。
  3. 系统健康时后台维持至少一个 ERASED Slot。
  4. 写完后读回关键头和 CRC。
  5. 禁止 Crash 分区与 OTA image slot、配置区或文件系统元数据区重叠。
  6. Bootloader、应用和量产工具共享同一份分区定义。

LittleFS 适合在二级阶段把 Crash Record 组织成文件,并提供掉电恢复和磨损均衡;但最小 Fault 路径更适合固定 Slot 和追加式写入,因为依赖更少、时间更容易上界化。

常见失败设计及原因

失败设计 为什么危险 推荐替代方案
Handler 中直接 printf 全部寄存器 stdio 可能加锁、分配、等待 UART 中断,格式化耗时不可控 保存二进制寄存器,复位后格式化
Handler 中打开 FAT/LittleFS 文件 文件系统、块缓存、锁和 Flash 驱动可能已经不可信 先写 Retention RAM 或固定 Crash Slot
不判断 MSP/PSP,直接把当前 SP 当故障 SP 可能读取 Handler 栈而不是故障线程栈 解码 EXC_RETURN 并保存原始 frame
固定复制 4KB 栈 SP 可能越界,引发二次 Fault 依据已知栈边界裁剪并限制长度
只保存 PC,不保存 Build ID 不同固件地址含义不同 Build ID + ELF 符号仓库强绑定
先写 VALID,再写 payload 掉电后会出现“看似有效”的半记录 payload/CRC 完成后最后提交 VALID
Fault 中擦除 Flash 擦除时间长且电源/看门狗风险高 正常运行期预擦除 Slot
在线完整 Backtrace 依赖损坏栈、unwind table、复杂访问 保存原始栈,主机侧展开
每次重复崩溃都写完整 Dump 快速耗尽 Slot 和擦写寿命 签名去重、计数、Safe Mode
Dump 全 SRAM 容量大、耗时长、可能泄露密钥和用户数据 内存白名单和分级采集
异常后直接继续运行 系统状态不可证明,可能造成更严重数据破坏 提交快照后受控复位或安全停机

验证与故障注入

Crash Capture 不能只在“手工空指针”场景验证。必须建立自动化故障注入矩阵。

CPU 异常测试

  • 空指针读写。
  • 非法地址访问。
  • 未对齐访问,并分别测试 trap 开关。
  • 除零。
  • 未定义指令。
  • 非法异常返回。
  • MPU 读、写、执行违规。
  • 精确和可构造的非精确 BusFault。
  • FPU 使用与未使用状态。
  • Thread Mode 使用 PSP、MSP 两种场景。
  • Fault 发生在 ISR 中。
  • Fault 发生在嵌套中断中。

栈和内存破坏测试

  • 当前线程栈溢出。
  • MSP/Interrupt Stack 溢出。
  • 栈 canary 被破坏但 SP 仍合法。
  • SP 指向栈边界之外。
  • TCB 指针损坏。
  • Crash Buffer 前后 guard 被破坏。

存储和电源测试

  • Flash 正在 program 时触发 Fault。
  • Flash 正在 erase 时触发 Fault。
  • XIP cache 关闭时触发 Fault。
  • 一级写入任意字节位置断电。
  • VALID 标记前后断电。
  • CRC 破坏。
  • Crash 分区满。
  • 没有预擦除 Slot。
  • Retention RAM 在不同复位类型下的保持性测试。

看门狗和二次 Fault 测试

  • 一级捕获执行到每个阶段时触发 Watchdog。
  • Crash Handler 内故意访问非法地址,验证 secondary-fault 快速复位。
  • UART/Flash 后端永久 busy,验证超时上界。
  • 双核同时 panic,验证 Crash Owner。
1
2
3
4
5
6
7
8
9
flowchart TD
A[Fault Injection Test] --> B[确认设备可复位]
B --> C[确认记录 magic/version/length]
C --> D[确认 CRC]
D --> E[确认 Build ID 匹配]
E --> F[确认 PC/LR/故障寄存器]
F --> G[确认栈窗口边界]
G --> H[确认离线调用栈]
H --> I[确认重复崩溃和分区回收]

验收标准不只是“能看到日志”,而应包括:记录成功率、最坏捕获时间、掉电恢复率、错误记录拒绝率、符号化正确率和对正常业务 RAM/Flash/实时性的影响。

推荐的工程落地步骤

第一步:先做 256B~1KB 的 P0 Retention Snapshot

保存:magic、版本、Build ID、PC、LR、SP、xPSR、CFSR、HFSR、BFAR、MMFAR 和 256B 栈窗口。不要一开始就做全线程 Dump。

第二步:加入独立 Crash Stack 和边界检查

用栈溢出和错误 SP 故障注入验证二次 Fault 风险。

第三步:实现 Early Boot 转存

.noinit/Backup SRAM 记录追加到专用 Flash Slot,完成 CRC、读回和 Slot 管理。

第四步:建立符号归档和 PC 工具

CI 自动归档 ELF/MAP/Build ID;PC 工具解析 Record,并调用 addr2line、GDB 或自研 unwinder。

第五步:增加二进制 Flight Recorder

先记录调度、Flash、DMA、协议和看门狗关键事件,再根据实际故障补充业务事件。

第六步:按产品资源扩展 RTOS 多线程和平台寄存器

只增加能显著缩短定位时间的数据,所有扩展都必须有容量和时间上限。

第七步:增加安全、去重、Safe Mode 和远程上报

确保大量现场设备发生同类异常时不会反复磨损 Flash 或形成无限重启循环。

不同资源等级的推荐配置

设备等级 一级保存位置 建议记录量 二级存储 分析能力
超小 MCU,RAM < 32KB Backup Register / 128~512B .noinit PC、LR、SP、Fault Reg、少量栈 可选固定 Flash 页 addr2line + 栈候选扫描
普通 Cortex-M + RTOS Retention RAM 1~8KB 全寄存器、当前栈、线程摘要、事件 ring 预留 Flash Crash 分区 GDB/CrashDebug/自研工具
高端 MCU,多核/FPU Retention SRAM 8~64KB per-core 寄存器、多个线程栈、平台寄存器 双 bank Flash、FRAM、eMMC 完整 Core Dump 和聚类
ESP-IDF 平台 框架 panic/coredump TCB、任务栈、寄存器 Flash coredump partition 或 UART idf.py coredump-info/debug
Zephyr 平台 Zephyr coredump backend MIN/THREADS/LINKER_RAM 可配置 logging、UDP、Flash Partition coredump_gdbserver.py
联网量产设备 本地最小记录 + 上传队列 P0/P1 + Build ID + 事件 Flash/FRAM + 云端 符号化、签名聚类、版本趋势

Cortex-M 故障寄存器应该如何解码

保存寄存器只是第一步,离线工具还需要把状态位转换成可执行的诊断结论。Cortex-M3/M4/M7/M23/M33/M55 等内核的可用寄存器和位定义并不完全相同,解析器应根据 CPUID、架构编号和记录中的 feature flags 选择对应规则,不能假设所有 Cortex-M 都具备完整的 MemManage、BusFault 和 UsageFault 子寄存器。

HFSR:先判断 HardFault 是原生故障还是升级结果

HardFault 常见来源包括:

  • 可配置 Fault 没有使能,最终升级为 HardFault。
  • Fault Handler 本身又发生故障。
  • 向量表读取失败。
  • 调试事件或强制升级。

如果 HFSR 的 forced 类状态被置位,不能停留在“发生 HardFault”这个表面结论,而应继续解析 CFSR,找出最初的 MemManage、BusFault 或 UsageFault 原因。若只保存 HFSR 而不保存 CFSR,现场信息会严重不足。

CFSR:按三个子域分别解释

CFSR 逻辑上由 MemManage Fault Status、BusFault Status 和 UsageFault Status 组成。离线工具应输出原始值、有效位和人类可读解释,同时保留多个状态位并存的可能性。

子域 重点问题 典型分析方向
MemManage 指令取址违规、数据访问违规、异常入栈/出栈失败、懒浮点状态保存失败 MPU 配置、栈边界、安全域、错误函数指针
BusFault 精确数据总线错误、非精确异步错误、指令总线错误、异常栈操作失败 外部 SRAM/QSPI、DMA、总线超时、ECC、Flash 控制器
UsageFault 未定义指令、非法状态、非法 PC、未对齐、除零、协处理器错误 返回地址破坏、函数指针错误、编译选项、FPU 配置

“精确 BusFault”通常能把栈帧中的 PC 指向触发访问的指令;“非精确 BusFault”可能由缓冲写或异步总线事务延迟上报,故障 PC 未必是根因位置。此时最近事件、DMA 状态、总线矩阵寄存器和写缓冲相关配置比单纯 PC 更重要。

BFAR/MMFAR 只能在地址有效位成立时使用

BFAR 和 MMFAR 中的地址并非任何时候都有效。解析器必须先检查对应的 address-valid 状态,再显示“故障地址”。否则寄存器可能保留上一次值或无意义值,容易把研发引向错误方向。

异常入栈或出栈失败意味着原始栈帧可能不完整

如果状态寄存器表明异常入口 stacking、异常返回 unstacking 或 lazy FPU stacking 失败,硬件自动压栈的数据可能没有完整写入。此时:

  1. 不应盲目信任 frame 中所有 R0~xPSR 字段。
  2. 应提高 MSP、PSP、MSPLIM、PSPLIM、栈边界和 MPU 状态的诊断优先级。
  3. PC 工具应把记录标为 frame_may_be_incomplete
  4. 可以使用软件入口保存的 R4~R11、Handler LR、当前 MSP 等信息辅助判断。

保存原始值,不要只保存解码后的字符串

固件中的解码逻辑可能有错误,也可能在未来新增架构支持。Crash Record 应保存所有原始寄存器数值,文本解释只由复位后或 PC 工具生成。这样工具升级后可以重新分析历史记录。

FPU、Lazy Stacking 和扩展异常栈帧

带 FPU 的 Cortex-M4F、M7、M33、M55 等平台需要额外处理浮点上下文。异常发生时,处理器可能保存基础栈帧,也可能保存包含 S0~S15、FPSCR 等内容的扩展栈帧。启用 lazy stacking 后,浮点上下文是否已经真正压栈还与 FPCCR 等状态有关。

通用实现应遵循以下原则:

  1. 保存 EXC_RETURN 原始值,不能在 C 函数中重新读取 Handler LR 代替。
  2. 根据架构定义判断基础帧或扩展帧,不能固定偏移读取 PC。
  3. 保存 FPCCR、FPCAR、FPDSCR 等实现存在的浮点控制状态。
  4. 如果捕获函数自身使用浮点指令,可能触发新的 lazy stacking 或覆盖现场,因此 Crash Capture 模块应使用禁止浮点的编译选项,并避免任何浮点运算。
  5. 完整保存 S16~S31 是否必要取决于 RTOS 上下文切换策略和故障线程是否使用 FPU;资源不足时至少保存栈中硬件帧、FPSCR 和 RTOS 已保存区的地址。
  6. PC 工具要区分“寄存器未保存”“寄存器值为零”和“扩展帧读取失败”三种状态。

在测试中,应分别构造:故障前从未使用 FPU、刚使用 FPU、ISR 使用 FPU、线程切换后使用 FPU、lazy stacking 过程中发生总线错误等场景。只测试普通整数代码会掩盖扩展帧偏移错误。

TrustZone 和安全域故障

Armv8-M TrustZone 系统至少要面对 Secure/Non-secure 两套状态、不同栈指针和安全故障寄存器。通用 Crash 设计需要先确定安全策略,再决定技术实现。

安全侧统一捕获

由 Secure Firmware 提供最终 Crash Owner,可以读取并保存允许的 Secure 和 Non-secure 上下文。优点是信息完整、可信度高;风险是 Crash Dump 本身可能包含高敏感密钥和安全服务状态。

两侧分别捕获

Secure 和 Non-secure 各自维护独立记录区。Non-secure 记录不能读取 Secure RAM,Secure 记录由安全服务控制导出。优点是边界清晰;缺点是跨域调用故障的关联分析更复杂。

生产建议

  • Secure Dump 默认只保存故障类型、PC 范围、SFSR/SFAR 和经过审核的栈窗口。
  • 密钥、随机数种子、认证上下文和安全引擎寄存器加入永久排除列表。
  • Non-secure 侧只能获取经过脱敏的安全故障摘要。
  • 记录中明确保存 capture_security_statefault_security_state
  • 安全侧负责校验 Non-secure 提供的指针,不能直接信任其栈边界或 TCB 地址。

跨 CPU 架构的端口层设计

通用 Crash 框架不应把 Cortex-M 的寄存器直接写进公共头。推荐把公共层、架构层、RTOS 层和存储层分开。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
crash/core
record writer
TLV encoder
CRC
budget controller

crash/arch/cortex_m
exception entry
EXC_RETURN decode
SCB fault registers

crash/arch/riscv
trap entry
mcause/mepc/mtval/mstatus

crash/arch/xtensa
panic frame
EPC/EXCCAUSE/EXCVADDR

crash/rtos
baremetal
RT-Thread
FreeRTOS
Zephyr adapter

crash/storage
retention RAM
internal flash slot
FRAM
UART/SWO

公共层只理解 architecturereason 和 TLV,不解析具体寄存器。PC 工具根据 architecture TLV version 加载对应解析器。这样增加 RISC-V 或新 Cortex-M 代际时,不需要修改旧记录格式。

RISC-V 端口要点

RISC-V 同步异常通常至少保存:

  • mepc:异常时程序计数器。
  • mcause:中断/异常及原因编号。
  • mtval:故障地址、非法指令值或架构定义信息。
  • mstatus:中断使能、前一特权级等状态。
  • 通用寄存器和原始 SP。
  • 若运行 S/U Mode,还需保存对应级别的 cause、epc、tval 和 status。

与 Cortex-M 一样,trap entry 应使用最小汇编保存原始寄存器,再切换到可信异常栈。不能在 C prologue 之后才尝试恢复已经被覆盖的临时寄存器。

Xtensa/ESP 平台要点

Xtensa 需要按 ABI 和 windowed/call0 模式处理寄存器窗口。ESP-IDF 已经提供成熟 panic 和 coredump 流程,优先使用框架能力,而不是在其上叠加第二套相互冲突的异常入口。

解析器必须按不可信输入设计

Crash Record 可能因掉电、内存破坏、传输错误或旧固件 Bug 而损坏。PC 解析器不能假定来自设备的数据可信。

必须检查:

  1. header_sizetotal_size 是否小于实际文件长度。
  2. 加法和对齐计算是否整数溢出。
  3. TLV 长度是否越界、是否导致死循环。
  4. TLV 数量是否超过合理上限。
  5. 地址范围和内存块是否重叠或超出目标位宽。
  6. Build ID 长度和编码是否合法。
  7. 压缩数据解压后大小是否受限。
  8. 架构版本未知时是否可以安全跳过。
  9. CRC 不匹配时是否仍允许以“部分记录”模式提取固定头,而不是完全崩溃。
  10. 符号文件和 Dump 的 Build ID 是否一致,不一致时必须拒绝给出确定源码行。

解析器应输出置信度。例如:

1
2
3
4
5
6
7
PC symbolization: exact
LR symbolization: exact
stack frame #1: unwind table
stack frame #2: frame pointer
stack frame #3: heuristic candidate
fault address: invalid/not-present
record integrity: partial, payload CRC failed

这样研发能区分客观现场、可靠推导和启发式候选,避免把扫描到的一个代码地址误认为真实调用链。

量产运行指标

Crash 系统本身也需要可观测性。建议长期统计:

  • 一级快照成功次数。
  • 一级快照 CRC 失败次数。
  • Retention RAM 丢失次数。
  • Flash 转存成功/失败次数。
  • 无可用预擦除 Slot 次数。
  • secondary fault 次数。
  • 平均和最大捕获时间。
  • 平均记录大小和 partial record 比例。
  • 相同 Build ID 下各 Crash Signature 数量。
  • 快速重启循环和 Safe Mode 进入次数。
  • 上传成功率、积压量和最旧记录年龄。

这些指标可以发现“Crash 功能看似存在,但现场记录经常写坏”这类系统性问题。特别是捕获时间和 Flash 后端失败率,应在不同温度、电压、Flash 老化程度和最高业务负载下测量。

开源方案的组合建议

不同平台不必从零实现全部功能,可以按场景组合:

裸机 Cortex-M,小资源

  • 用 CMSIS-View Fault Storage 作为 P0 最小寄存器记录。
  • 增加产品自己的 Build ID、栈窗口和 Early Boot Flash Slot。
  • 主机用 addr2line 或简单 GDB 脚本分析。

Cortex-M + 自研 RTOS/RT-Thread

  • 借鉴 CrashCatcher 的独立 Crash Stack、内存区域回调和 CrashDebug 思路。
  • 复用 RTOS 已有异常栈结构和线程栈边界。
  • 自研 TLV Record、Retention RAM 和 Flash 分区管理。

Zephyr

  • 优先启用 Zephyr Coredump,选择 MIN、THREADS 或 LINKER_RAM 模式。
  • 根据产品可靠性评估 logging、UDP、Flash Partition 或自定义 backend。
  • 保留 Zephyr ELF,并使用官方 GDB Server 脚本。

ESP-IDF

  • 使用 ESP-IDF 自带 Core Dump 到 Flash 或 UART。
  • 根据 Flash cache 故障风险评估 panic handler IRAM 配置。
  • 配置合理的最大任务数和 coredump partition 容量。
  • 使用 idf.py coredump-infoidf.py coredump-debug 和 ROM ELF。

大规模联网设备

  • 本地采用最小可靠记录和分级内存区域。
  • 可评估 Memfault Firmware SDK 或自建类似的 Build ID、分片上传、符号化和聚类平台。
  • 无论使用商业服务还是自研服务,都应保留原始 Record 和精确符号归档,避免平台迁移后历史数据无法重放。

面试回答时的组织方式

回答这类题时,可以按以下顺序展开:

  1. 先讲核心原则:Fault Handler 只做最小二进制快照,不做打印、文件系统和在线分析。
  2. 再讲三阶段架构:一级 RAM/预擦除 Slot,二级启动后持久化,三级 PC 离线分析。
  3. 说明 Cortex-M 入口:根据 EXC_RETURN 选择 MSP/PSP,保存硬件异常栈帧和 SCB Fault Registers。
  4. 说明可靠性:独立 Crash Stack、地址白名单、有界栈复制、二次 Fault 标记、看门狗时间上限。
  5. 说明记录格式:magic、版本、长度、Build ID、TLV、CRC、最后写 VALID。
  6. 说明 Flash:正常期预擦除 Crash Slot;Fault 中只允许 RAMFUNC 轮询编程;更推荐先写 Retention RAM。
  7. 说明调用栈:保存原始栈和寄存器,复位后或 PC 工具结合 ELF/DWARF 展开。
  8. 说明 RTOS、多核、FPU、TrustZone、安全和隐私扩展。
  9. 最后列出现成方案:CMSIS-View Fault Storage、CrashCatcher、Zephyr Coredump、ESP-IDF Core Dump、RT-Thread、Memfault。

一句话概括:把不可恢复异常处理设计成一个有时间上界、无动态依赖、可原子提交的“飞行数据记录器”;Fault 中只保存不可再现的现场,复位后再持久化,主机侧再完成符号化和根因分析。

参考链接