LV025-NOINIT段简介
看门狗复位能自动救活死机的单片机,但复位本身会把现场一起抹掉——崩溃时的运行状态、死因、复位来源全部丢失,重启之后只剩一脸茫然。解决办法是把现场存进 NOINIT 段:一块启动代码不碰的 RAM,让数据穿越复位保留下来。
LV001 第 3.2 节已给出 NOINIT 的原理与 GNU ld 形态(.noinit (NOLOAD)),LV005 讲了调试期的串口打印形态。本文补上第三种工程现实:Keil AC5 自动分散加载下怎么实现 NOINIT,以及看门狗场景下"保存—复位—读取—消费"的完整闭环。示例来自一个 bootloader + APP 双固件工程(F103ZE,64KB SRAM)。
一、原理:复位不清 SRAM,抹掉现场的是启动代码
1. 硬件侧:热复位不擦 SRAM
STM32F1 的 SRAM 是易失存储器,只有掉电才丢内容。而热复位源——NRST 引脚、软件 SystemReset、IWDG、WWDG、低功耗管理复位——按 (STM32F101xx, STM32F102xx, STM32F103xx, STM32F105xx and STM32F107xx advanced Arm®-based 32-bit MCUs - Reference manual)(RM0008)的描述,复位的是内核状态与外设寄存器,SRAM 里的数据原样保留。
这是 NOINIT 可行的硬件基础:看门狗把芯片咬复位之后,死前写进 RAM 的数据还在原地。
1.1 手册出处:RCC 章的 Reset 小节
上述行为在 RM0008 中没有集中的"一句话"说明,出处是第 7 章 "Low-, medium-, high- and XL-density reset and clock control (RCC)" 的 §7.1 Reset(适用 F101/102/103;互联型 F105/107 对应第 8 章 §8.1;更老的 Rev 5~11 版本手册中 RCC 是第 6 章——章节号随版本漂移,按 "Reset and Clock Control (RCC)" 标题检索即可)。§7.1 把复位分成三类:
| 复位类型 | 触发源(§7.1 原文归类) | SRAM 内容 |
|---|---|---|
| System reset(系统复位) | NRST 引脚低电平、WWDG、IWDG、软件复位、低功耗管理复位(§7.1.1) | 原样保留 |
| Power reset(电源复位) | POR/PDR、退出 Standby 模式(§7.1.2) | 丢失(随机值) |
| Backup domain reset(备份域复位) | 软件置位 BDRST、VDD 与 VBAT 均曾断电后重新上电(§7.1.3) | 不受影响(只复位备份域) |
§7.1.1 对系统复位作用域的完整描述只有一句:
A system reset sets all registers to their reset values except the reset flags in the clock controller CSR register and the registers in the Backup domain.
注意:手册从头到尾没有直接写"复位后 SRAM 保留",这一结论是三处内容拼出的推论:
§7.1.1 与 §7.1.2 的复位作用域只提 "registers"(寄存器),SRAM 不在其中;
第 3 章 Memory and bus architecture 的 Embedded SRAM 小节表明 SRAM 是独立的存储阵列,根本不属于寄存器域;
反证:第 5 章 PWR 的 §5.3.5 Standby mode 明确写了 1.8 V 域断电时 "SRAM and register contents are lost except for registers in the Backup domain and Standby circuitry"——只有 1.8 V 域断电的场景,手册才声明 SRAM 丢失。
1.2 POR/PDR 不能列入"保留 SRAM"的复位源
POR/PDR 不属于热复位:按 §7.1.2 的归类,它与"退出 Standby"同属 Power reset(电源复位)。POR/PDR 触发即意味着 VDD 跌破阈值,SRAM 供电一度消失,重新上电后内容随机;退出 Standby 时 1.8 V 域已断电,SRAM 同样必丢。真正 SRAM 原样保留的只有 §7.1.1 列出的五类 System reset,共同点是复位前后供电从未断过——这与 3 小节"冷启动后内容是随机值"的边界自洽。
2. 软件侧:__main 才是凶手
复位后 CPU 从向量表偏移 0 处取初值装入 SP、从偏移 4 处取 Reset_Handler 地址装入 PC,Reset_Handler 随即调用 C 库的 __main(scatter-load 运行时)重建运行环境,动作只有两件:
| 段 | 内容 | __main 的动作 |
|---|---|---|
.data | 有初值的全局/静态变量 | 从 Flash 的初始化表重新拷贝进 RAM |
.bss | 零初始化的全局/静态变量 | 全部清零 |
于是出现一个反直觉的现象:硬件保留了现场,软件却把它还原了。死前改过的普通全局变量,重启那一刻被拷贝回初值或清零,等于自动毁尸灭迹。
3. NOINIT 的本质
NOINIT 段就是一段既不进 .data 也不进 .bss 的 RAM:链接器的分散加载表里没有它,__main 的初始化动作自然落不到它头上,内容原样穿越复位。
它继承两条边界(与 LV001 的结论一致):
冷启动后内容是随机值——刚上电时 SRAM 从未被本程序写过,读出来是什么全凭运气,所以必须用魔数判断"这份内容是不是我们写的";
掉电即丢——NOINIT 保留的是"复位前"的现场,不是"上次运行"的现场,不能替代 Flash 落盘。
二、实现方式与工程落地
1. 三种实现方式对比
| 方式 | 载体 | 工具链 | 需要自定义分散加载文件 |
|---|---|---|---|
.ARM.__at_地址 固定地址 section | armlink section 命名约定 | Keil AC5 / AC6 | 否 |
自定义 .sct + UNINIT 执行区 | Scatter File | Keil AC5 / AC6 | 是 |
.noinit (NOLOAD) + ld 脚本 | GNU ld | GCC | 是 |
LV001 用的是第三种。本文选第一种,理由:
(1)项目两个工程都用 Keil 自动分散加载(uvprojx 的 <ScatterFile /> 为空),引入自定义 .sct 意味着每次 CubeMX 重新生成、Keil 升级都要手动同步,纯维护负担;
(2).ARM.__at_地址 是 armlink 的 section 命名约定,AC5 / AC6 通吃;
(3)地址钉死,行为完全可控,不依赖任何自动分配的运气。
代价是必须保证该地址不在 RAM 分配范围内(见 2.2 小节),以及它是 ARM 编译器专有语法——换 GCC 就要回到 ld NOLOAD。
2. Keil AC5 实战:固定地址 section
2.1 变量声明
// fault_scene.c
/* 现场结构体定义见本文 3.1 小节 */
__attribute__((section(".ARM.__at_0x2000FC00"))) noinit_scene_t g_scene;armlink 对名字形如 .ARM.__at_<地址> 的 section 有特殊处理:直接钉到指定绝对地址,不参与任何执行区的 .ANY (+RO) / .ANY (+RW +ZI) 匹配。分散加载表里查无此段,__main 自然不清零、不拷贝。
2.2 必须同步的一件事:RAM 划分让位
F103ZE 有 64KB SRAM,uvprojx 里声明 IRAM 0x20000000 / Size 0x10000(全量)。Keil 自动生成的分散加载按这个 Size 划 RW/ZI 区,变量分配可以蔓延到 SRAM 最后一个字节。此时把 NOINIT 钉在 0x2000FC00,就存在被 ZI 分配撞上的风险——链接器对这种重叠不一定报错,可能静默共用一段 RAM。
所以要把两个工程(bootloader 与 APP)的 RAM 声明各让出 1KB:
<!-- atk-f103.uvprojx / flash-app.uvprojx 的 OnChipMemories 段 -->
<IRAM>
<Type>0</Type>
<StartAddress>0x20000000</StartAddress>
<Size>0xFC00</Size> <!-- 原 0x10000, 让出尾部 1KB 给 NOINIT -->
</IRAM>效果:两边的 RW/ZI 都只分配到 0x2000FBFF,0x2000FC00 起的 1KB 双方都不初始化、专用于跨复位现场。现场结构体只有 24 字节,占用头部,剩余空间留作后续字段扩充。
2.3 与自定义 .sct 的取舍
自定义 sct 配 UNINIT 执行区在语义上更规范(多个变量可顺序排布、无需手算地址),适合变量多、段属性复杂的场景;单块小场景用 .ARM.__at_ 更轻——不动工程文件,不改变 CubeMX / Keil 的生成链路。本文场景(一块 24 字节的现场记录)属于后者。
3. 双工程防互踩
bootloader 与 APP 各自独立链接、都覆盖全量 RAM。如果只改一边,另一边的 ZI 分配可能伸进 NOINIT 区,把现场抹掉——有 check 兜底不会误报,但现场丢失,保留机制形同虚设。
地址布局约定:
| 地址范围 | 用途 |
|---|---|
0x20000000 ~ 0x2000FBFF | 双方链接的 RW/ZI(各 63KB,实际用量远小于此) |
0x2000FC00 ~ 0x2000FFFF | NOINIT 段(双方都不初始化,跨复位保留) |
两个 uvprojx 的 <IRAM><Size> 同步从 0x10000 改为 0xFC00,RAM 布局就一致了。这一步是纯 XML 数值改动,不影响任何代码,但是整套机制可靠性的前提。
三、现场数据结构与看门狗闭环
1. 现场数据:先盘全景,再挑关键字段
1.1 全景:有哪些数据可以写
NOINIT 段本质是一块"重启后还能读到的内存",里面可以放任何想留给下一次启动的数据。设计结构体的第一步,是知道有哪些可选:
| 类别 | 典型数据 | 回答的问题 |
|---|---|---|
| 死因与复位源 | cause 死因枚举、RCC->CSR 复位源快照、复位累计计数 | 怎么死的、谁复位的 |
| 异常栈帧 | R0~R3 / R12 / LR / PC / xPSR、SCB->CFSR / HFSR、MMFAR / BFAR、栈顶快照 | 崩溃发生在哪条指令、访问了什么非法地址(仅崩溃时可取到) |
| 运行状态 | 心跳 uptime_ms、状态机阶段号、位置埋点编号、当前任务句柄(RTOS) | 死前跑到哪一步、卡在哪个任务 |
| 通信与外设 | 错误码、CRC / 重传计数、总线错误计数(如 CAN / I2C 卡死状态) | 是不是通信异常诱发的 |
| 环境与版本 | 固件版本号或 commit hash、RTC 时间戳、电池电压、设备序列号 | 哪个版本、哪台设备、什么时候死的 |
| 完整性管理 | magic 魔数、check 校验和、结构体版本号 | 这份记录本身可不可信 |
前四类描述"案发现场",后两类保证"现场可信"。
这些数据不需要全写,挑选标准只有一条:读回来之后,能不能帮你定位或修复问题。按这条标准,三类必留:
能定死因的(
cause、rcc_csr):不知道怎么死的,后面一切都是瞎猜;能定位置的(
uptime_ms、pc_hint):不知道死在哪,知道了死因也修不了;能判可信的(
magic、check):冷启动后 SRAM 是随机值,没有它们会把垃圾数据当现场。
其余字段按工程实际增删:RTOS 工程补当前任务句柄与任务名;多版本并存的量产固件补版本号,防止升级后字段含义对不上;怀疑通信诱发的补错误计数。容量上也不允许全塞——本文 NOINIT 区只有 1KB,现场数据是给"下一次启动分析"用的,要全量回放该走 LV001 的 Flash 落盘。
1.2 本文的选择:最小必要 6 字段
三类必留、每类取两个字段,就是本文的结构体:
// fault_scene.h
typedef struct {
uint32_t magic; /* 魔数: 内容是否有效 */
uint32_t cause; /* 死因: 看门狗超时 / deadloop / HardFault */
uint32_t uptime_ms; /* 死前心跳(HAL_GetTick) */
uint32_t rcc_csr; /* RCC->CSR 快照: 区分复位源 */
uint32_t pc_hint; /* 死前 PC(HardFault 时有值) */
uint32_t check; /* 校验和: 前 5 项之和, 防半写 */
} noinit_scene_t;六个字段各管一件事:magic 判断内容是不是自己写的,cause 记死因,uptime_ms 记死前活到哪一刻,rcc_csr 记这次重启是谁复位的,pc_hint 记死前 PC,check 防半写。
其中 magic / check / uptime_ms 是机制性字段——它们不描述死机本身,缺了任何一个,读到的现场都不可信:
| 机制 | 防什么问题 |
|---|---|
magic 魔数 | 冷启动 SRAM 是随机值,没有魔数会把随机数据当现场误报 |
check 校验和 | 写现场写到一半被狗咬——cause 已新、check 仍旧,和不齐则判无效 |
uptime_ms 心跳 | 只知道"死过"没用,要知道"死前跑到哪一步、卡了多久" |
两个设计细节:
校验和用简单求和即可。目标不是防恶意篡改,是防"写一半被复位"的不一致状态;
消费即失效。启动时读完现场、报告完毕,把
magic写 0。否则同一次死亡会在后续每次启动被反复报告,刷屏且失去意义。
1.3 位置信息:崩溃有精确坐标,卡死只有最后足迹
六个字段里,pc_hint 号称能指出死机位置,但它的注释写着"HardFault 时有值"——卡死时它没有值。原因在于一个容易误解的事实:
看门狗复位本身不携带任何位置信息。 IWDG 只是个倒计时器,超时就复位,它不知道程序死在哪;而复位一发生,内核寄存器(包括 PC)全部归零。也就是说,死机瞬间的程序位置,硬件上不会留下任何痕迹,想要它,只能靠程序死前自己写进 SRAM。
"死前写"这个机会,两种死法完全不同。
崩溃(HardFault / 总线错误)——有精确坐标。 崩溃时异常 handler 还在执行,现场就压在栈上:栈帧里的 PC 就是出错指令地址,SCB->CFSR 说明错误类型,MMFAR / BFAR 给出非法访问的地址。handler 第一时间把这些写进 NOINIT,再等狗咬。本文 pc_hint 的值就来自这里;栈帧八件套(R0~R3 / R12 / LR / PC / xPSR)的全量采集与解读见 LV005,需要更细的出错原因时再补 SCB->CFSR 错误码。
卡死(死循环 / 关中断死等)——只有最后足迹。 卡死意味着代码不再执行,死前不会有任何语句替你写现场。位置只能靠运行中持续刷新的记录反推,三个工具:
位置埋点:在关键路径的各节点放一条"记录当前位置"的语句,每经过一次就覆盖刷新(位置编号可用
__LINE__宏自动生成)。重启后,最后写入的那个编号就是卡点附近——相当于软件版的 PC;双心跳:两个计数器分开更新,一个在 SysTick 中断里累加,一个在主循环里刷新。重启后对比:中断计数还在涨、主循环心跳停了 = 主循环死了但中断活着;两个都停 = 中断被关或时钟出了问题;
心跳差值:主循环心跳的停更时刻与狗咬时刻之差就是卡死持续时长(2.3 小节),结合 IWDG 超时配置可反推卡点出现的早晚。
如果一定要卡死瞬间的真实 PC,只有一条软件路:换 WWDG 并启用 EWI(早期唤醒中断)——它在复位前一拍触发中断,在 EWI 里抓取被打断处的 PC / LR 写入 NOINIT。但前提苛刻:卡死时全局中断没被关、时钟还在跑;且 IWDG 没有对应机制。硬件级的 PC 记录(ETM / Trace)需要调试器在线,离线的看门狗重启场景用不上。
一句话总结:崩溃是"死时写",卡死是"生前写"——全景表里"异常栈帧"一类只在崩溃时可得,卡死场景依赖的是"运行状态"一类。
2. 看门狗场景的完整闭环
2.1 时序
正常运行 主循环每 500ms noinit_tick(HAL_GetTick()) 只刷新心跳与校验和
卡死 noinit_save(CAUSE_DEADLOOP, 0) 写死因 -> 进死循环 -> 心跳停更
3s 后 IWDG 复位 -> SRAM 内容保持 -> __main 不清 NOINIT 段
启动 noinit_init() 读到有效现场 -> 串口报告死因 / 心跳 / 复位源 -> 清 magic2.2 复位源判读:RCC->CSR
noinit_init() 第一步读 RCC->CSR 存快照,用标志位回答"是不是狗咬的":IWDGRSTF(独立看门狗)、WWDGRSTF(窗口看门狗)、SFTRSTF(软复位)、PORRSTF / PINRSTF(上电 / 引脚)、LPWRRSTF(低功耗管理)。判读标准与 LV001 第三章第 1 节的复位源判读一致。
读完必须调 __HAL_RCC_CLEAR_RESET_FLAGS() 清除——这些标志位复位后不清零,会一直累积,不处理下次就分不清是哪次复位留下的。
2.3 心跳的语义
uptime_ms 不是"运行总时间",而是"最后一次确认活着的时刻"。卡死瞬间心跳自然停在最后有效值,配合本次启动时间,两者的差就是"从卡死到被狗咬"持续的时长——结合 IWDG 的超时配置(本文示例为 3s),可以反推出死循环发生的大致位置。
工程上的配套约束:喂狗点与心跳点放在同一处。心跳停了就等于喂狗停了,两个信号同源,事后分析不会互相矛盾。
2.4 死前显式保存
deadloop 测试命令、HardFault handler 这类"已知要死"的路径,在进死循环前调 noinit_save() 写入 cause 与 pc_hint,死因就从"推断"变成"自报"。不写也能靠心跳 + 复位源兜底推断,写了原因更明确——LV005 的 HardFault 打印版同样适合在这里插一个保存点,打印与落盘二选一或兼得。
四、边界与小结
1. 边界与局限
只对热复位有效。看门狗复位、软复位后 SRAM 保持,现场可读;拔电重插后 SRAM 随机,
magic不匹配自动丢弃——安全,但没有现场;不替代 Flash 落盘。掉电即丢是硬件特性,需要"跨上电周期"的场景仍然要写 Flash(LV001 的两条边界在这里依然成立);
半写风险靠
check兜底,靠的是"校验失败即无效"的保守策略,而不是原子性——SRAM 写入没有原子保证;.ARM.__at_是 ARM 编译器专有语法,换 GCC 工具链要改用 ld 脚本NOLOAD(LV001 的形态)。
2. 小结
三种形态各管一段:LV005 的串口打印胜在当场可读,适合调试期;LV001 的 Flash 落盘胜在跨上电周期,适合必须溯源的发布版;本文的 NOINIT 热复位保留胜在零外设依赖、零延迟,是看门狗自动重启场景的标配——狗咬之后重启的那一刻,现场已经躺在 SRAM 里等着被读出来了。
组合用法:调试期用打印版快速定位,定位后换 NOINIT 版发布;现场问题回归时,靠 NOINIT 心跳 + 复位标志判断"是否狗咬、死前到哪一步",再按需补 Flash 落盘。
本文档由 markdowncli 技能辅助生成