Skip to content

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_地址 固定地址 sectionarmlink section 命名约定Keil AC5 / AC6否
自定义 .sct + UNINIT 执行区Scatter FileKeil AC5 / AC6是
.noinit (NOLOAD) + ld 脚本GNU ldGCC是

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 变量声明 ​

c
// 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:

xml
<!-- 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 ~ 0x2000FFFFNOINIT 段(双方都不初始化,跨复位保留)

两个 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 字段 ​

三类必留、每类取两个字段,就是本文的结构体:

c
// 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 时序 ​

text
正常运行     主循环每 500ms  noinit_tick(HAL_GetTick())  只刷新心跳与校验和
卡死         noinit_save(CAUSE_DEADLOOP, 0) 写死因 -> 进死循环 -> 心跳停更
3s 后        IWDG 复位 -> SRAM 内容保持 -> __main 不清 NOINIT 段
启动         noinit_init() 读到有效现场 -> 串口报告死因 / 心跳 / 复位源 -> 清 magic

2.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 技能辅助生成