很多搞单片机的人应该都遇过这种场景:设备跑着跑着就卡死,看门狗咬下去后系统复位了,但问题依然复现不了,因为复位后所有变量都回到了“刚上电”的样子,你根本不知道复位发生前程序在干什么。更头疼的是,有时候软件看门狗要连续复位好几次才能把系统救回来,而每次复位都会把RAM里的现场信息抹得干干净净。这篇文章就是来解决这个问题的:软件复位后,如何保留指定RAM区域的数据。
这里要先给一个反直觉的结论:RAM其实没丢数据。真正“清空”RAM的,不是复位电路,而是C语言运行环境的启动代码。搞懂这个之后,再用链接脚本、编译器属性和启动代码配合起来,划出一块“NOINIT保留区”,就能让看门狗复位、软件复位之后关键数据依然活着。这个方案在STM32、GD32、NXP等Cortex-M平台上都能落地,也可以推广到大多数带链接脚本的嵌入式环境里。适合被死机现场逼疯的调测工程师、做OTA升级后想保留状态的朋友,以及正准备做RAM空间优化的人读。
1. 先别怪硬件:RAM内容还在,清掉它的其实是启动代码
1.1 Cortex-M复位后,RAM到底经历了什么
从硬件层面说,RAM是易失性存储器,只有掉电,也就是VDD断开,才会真的丢失内容。Cortex-M核的软件复位、看门狗复位、外部引脚复位这些“内部复位”,并不会切断RAM供电。复位信号只是把内核、外设寄存器恢复到默认状态,SRAM本身的内容会原封不动地保留下来。
那为什么你会看到所有全局变量都回到初值?因为程序从Reset_Handler里启动后,链接器生成的启动代码会做两件事:把.data段从Flash复制到RAM,用来恢复有非零初值的变量;把.bss段清零,用来把无初值的全局变量变成0。这是C标准对运行环境的要求,否则局部变量和全局变量的初始值语义就不成立。
所以,你对“软件复位后变量保留”的需求,本质上是希望在启动代码的这段搬运和清零工作中“漏掉”某一块RAM。谁来决定漏掉哪块RAM?链接脚本。谁把变量放进那块RAM?编译器属性。谁保证启动代码不去碰它?链接脚本里的NOLOAD标记,以及启动文件里对复制和清零范围的严格控制。这三者缺一不可。
1.2 同一块RAM,在四种复位下命运不一样
按照复位类型,可以把RAM内容的“可信度”分个级:
- 上电复位(POR/BOR):电源曾经断开或电压跌到阈值以下,RAM里的数据失去意义,必须从头初始化。
- 软件复位(
NVIC_SystemReset()):内核主动复位,RAM供电从未中断,内容是上一次运行留下的“遗物”。 - 看门狗复位(IWDG/WWDG):电源还在,RAM内容完整保留了死机前的现场。
- 调试器复位:工具通过调试接口触发,RAM内容通常也保留,但下载算法可能初始化RAM,需要单独分辨。
这个分级很重要。如果程序对所有这些复位一视同仁,启动时把RAM全清一遍,看门狗复位就被白白浪费了。你真正要做的是在复位后对自己想保留的区域做校验,而不是无脑清空。
2. 把指定RAM区域划成“禁区”:.noinit段的原理与配置
2.1 链接脚本是真正的“地盘划分者”
在GCC工具链中,变量被放到哪个段,由链接脚本的SECTIONS决定。默认情况下:有非零初值的变量进.data,启动代码会把初值从Flash复制过来;无初值或零初值的变量进.bss,启动代码会清零。
如果你想让某块RAM“复位后原地不动”,就要让链接器把它放到一个既不属于.data也不属于.bss的段里,并且这个段不被启动代码处理。最常用的做法是定义一个.noinit段,并标记为NOLOAD。
典型的GCC链接脚本片段如下:
.noinit (NOLOAD) : { . = ALIGN(4); KEEP(*(.noinit*)) . = ALIGN(4); } > RAMNOLOAD的意思是:这个段没有加载地址,不占用Flash空间,加载器不需要往里写内容。KEEP是为了防止链接器垃圾回收把这个段里的变量当垃圾丢掉。你可以把这一段放在.bss段之后、堆栈段之前。具体位置不是关键,关键是要确认启动代码的搬运和清零循环不会覆盖它。
怎么确认?编译完成之后打开.map文件,搜索.noinit,同时搜一下_sbss、_ebss、__bss_start__、__bss_end__这些符号,看看地址区间有没有重叠。这一步很多人容易忽略:链接脚本写得很漂亮,但启动文件里的清零循环范围设置不对,照样会把保留区清掉。想省事的话,就在链接脚本里把.noinit放在所有会初始化段的最末尾,调试时能少很多麻烦。
2.2 用编译器属性把变量丢进.noinit段
链接脚本只决定“段存在”,要把变量放进去,还得靠编译器属性。GCC/Clang环境下最常用的写法:
__attribute__((section(".noinit"), used, aligned(4))) volatile uint32_t g_reboot_count; __attribute__((section(".noinit"), used)) volatile struct { uint32_t magic; uint32_t version; uint32_t reset_cause; uint32_t reset_count; } g_boot_info;这里有几个关键点值得展开:
第一,section(".noinit")把变量放进自定义段。第二,used告诉编译器,这个变量可能在没有直接引用的情况下仍然需要保留,防止被优化掉。第三,volatile防止编译器因为“没看到写入”而做错误优化。第四,aligned(4)或aligned(8)保证结构体对齐,避免某些Cortex-M型号对非对齐访问直接触发HardFault。
还有一个最容易踩的坑:不要在声明时给非零初值。比如写成__attribute__((section(".noinit"))) uint32_t x = 1;,编译器仍然会生成一个初始值记录,把它当.data处理。你要的“保留”必须让变量的初值由复位前的运行状态自己决定,而不是由编译期常量决定。
在Keil MDK(armclang)环境下,写法会稍有不同:
__attribute__((section(".noinit"), zero_init)) volatile uint32_t g_reboot_count;同时需要确认分散加载文件(scatter file)中对应的RAM区域带有UNINIT属性,例如:
RW_IRAM1 0x20000000 UNINIT 0x00020000 { * (noinit) }UNINIT保证启动和C库初始化时不碰这块区域。IAR环境则更简单,直接用__no_init volatile uint32_t g_reboot_count;,编译器会自动把它放到内部保留段。
如果是跨编译器工程,建议封装成宏,避免到处写平台相关代码:
#if defined(__ARMCC_VERSION) || defined(__CC_ARM) #define RESET_RETAIN __attribute__((section(".noinit"), zero_init)) #elif defined(__GNUC__) || defined(__clang__) #define RESET_RETAIN __attribute__((section(".noinit"), used)) #else #define RESET_RETAIN __no_init #endif RESET_RETAIN volatile uint32_t g_boot_count;这样一个头文件就能搞定GCC、Keil、IAR三种环境,换IDE时不用到处改。
2.3 启动代码里“不碰它”比“清理它”更重要
很多人担心:链接脚本加了.noinit段,启动文件要不要也改?这里分两种情况。
第一种,你的工具链已经认识.noinit段。比如STM32CubeIDE生成的GCC工程里,链接脚本默认就有类似的.noinit (NOLOAD)段,启动文件的Reset_Handler在复制.data和清零.bss时,使用的是_sdata/_edata/_sbss/_ebss这些符号,只要链接器把.noinit放在这些范围之外,启动代码天然不会碰它。
第二种,你的startup文件比较老,或者链接脚本里把某段RAM又手动初始化了一遍。这时你需要在Reset_Handler里检查:_sbss和_ebss之间不能包含.noinit,__bss_start__和__bss_end__也一样。如果发现清零循环会覆盖保留区,优先改链接脚本,而不是在汇编里加复杂判断。硬要在启动文件里绕来绕去,既容易错,也不方便维护。我之前见过有人在Reset_Handler里写了十几行条件跳转,只为跳过一段RAM,结果换个编译优化等级就开始出问题。能用链接脚本解决的事,不要用汇编硬扛。
3. 实战:软件复位/看门狗复位后恢复现场
3.1 先分辨复位原因:从复位状态寄存器读证据
保留RAM只是第一步,更重要的问题是:你怎么知道这次复位是软件复位、看门狗复位还是上电复位?几乎所有主流MCU都有一组复位状态寄存器。以STM32F1为例,RCC->CSR里的标志位会记录最近的复位来源:
typedef enum { RESET_CAUSE_POWER_ON = 0, RESET_CAUSE_PIN_RESET, RESET_CAUSE_SOFTWARE, RESET_CAUSE_IWDG, RESET_CAUSE_WWDG, RESET_CAUSE_LOW_POWER, RESET_CAUSE_UNKNOWN } ResetCause; uint32_t GetResetCause(void) { uint32_t csr = RCC->CSR; uint32_t cause = RESET_CAUSE_UNKNOWN; if (csr & RCC_CSR_PORRSTF) cause = RESET_CAUSE_POWER_ON; else if (csr & RCC_CSR_SFTRSTF) cause = RESET_CAUSE_SOFTWARE; else if (csr & RCC_CSR_IWDGRSTF) cause = RESET_CAUSE_IWDG; else if (csr & RCC_CSR_WWDGRSTF) cause = RESET_CAUSE_WWDG; else if (csr & RCC_CSR_LPWRRSTF) cause = RESET_CAUSE_LOW_POWER; else if (csr & RCC_CSR_PINRSTF) cause = RESET_CAUSE_PIN_RESET; RCC->CSR |= RCC_CSR_RMVF; // 清复位标志,避免下次误判 return cause; }这里有几个注意事项。
不同型号的寄存器名和位名不一样。STM32F0、F4、H7等系列可能是RCC->RSR,相关标志位宏前缀也可能不同,写代码前一定要翻一下参考手册的Reset and Clock Control章节。某些系列在复位后,多个复位标志会同时置位,比如上电复位标志和软件复位标志同时为1,你需要按自己的业务优先级判断,而不是无脑按if-else顺序取第一个。
另一个关键点是:复位原因寄存器最好在main函数早期读取,并且要在HAL库或标准库初始化之前处理。很多启动库例程会清除复位标志,你晚一步就读不到原始信息了。我习惯的做法是:把读取函数放到BootRecord_Init里,在进入任何外设初始化之前就执行。
复位原因和保留RAM变量放在一起才有意义。程序启动后先调用GetResetCause,把原因写进保留区的reset_cause字段。这样即使系统再次复位,你也能知道上一次是因为什么倒下的。
3.2 设计一个“复位现场记录区”
实际操作中,我不建议零零散散声明一堆noinit变量,而是定义一个结构体,把复位相关的信息统一放进去。这样既方便管理,也方便整体校验。
#define BOOT_MAGIC 0xA5A5F00DUL #define BOOT_VERSION 1 typedef struct { uint32_t magic; // 标记这块RAM是否已有有效数据 uint32_t version; // 记录当前数据布局版本,软件升级后作废 uint32_t reset_cause; // 本次复位原因 uint32_t reset_count; // 复位计数器,看门狗多次复位时很有用 uint32_t pc; // 死机时PC(HardFault里写入) uint32_t lr; // 死机时LR uint32_t sp; // 死机时SP uint8_t app_state[32]; // 业务层自定义状态快照 } BootRecord; RESET_RETAIN BootRecord g_boot; void BootRecord_Init(void) { if (g_boot.magic != BOOT_MAGIC || g_boot.version != BOOT_VERSION) { memset(&g_boot, 0, sizeof(g_boot)); g_boot.magic = BOOT_MAGIC; g_boot.version = BOOT_VERSION; } g_boot.reset_cause = GetResetCause(); g_boot.reset_count++; if (g_boot.reset_count == 0) { g_boot.reset_count = 1; // 防止回绕 } }这段代码的逻辑很清晰:首次上电时RAM内容是随机的,magic不匹配,就手动初始化整个结构体;软件复位或看门狗复位后,magic匹配,就不动原有内容,只更新复位原因和计数。
这里要特别说一下version字段的用途。固件升级之后,代码里BootRecord结构体布局可能变了,但RAM里还是旧版本数据,如果按新布局解析就全乱套。所以每次改动结构体,记得把version加1,初始化逻辑检测到版本不匹配就会主动清空重建。
HardFault_Handler里可以进一步把现场写进去:
void HardFault_Handler(void) { g_boot.pc = __get_PC(); g_boot.lr = __get_LR(); g_boot.sp = __get_MSP(); NVIC_SystemReset(); }这样看门狗复位前,保留区里已经有了崩溃路径上的关键寄存器值。设备重启后,只需要在串口里把g_boot结构体打出来,就能大概判断死机发生在哪个函数附近。这在排查“看门狗多次复位”问题的时候,几乎是救命稻草。
3.3 看门狗多次复位场景怎么破
再看文章开头说的场景:设备死机后,看门狗需要多次复位才能救回来。这其实是典型的恶性循环:第一次看门狗复位后,启动代码把RAM清零,业务模块重新初始化,但外部环境刚好还是异常状态,初始化又触发死机,看门狗再次复位……如此反复,直到外部异常消失,或者系统彻底无法启动。
有了复位现场记录区,你可以这样处理:连续复位次数达到N次时,不再走正常初始化,而是进入安全恢复模式,比如不初始化出问题的外设,只保留喂狗和串口日志,等待人工干预。
void main(void) { HAL_Init(); SystemClock_Config(); BootRecord_Init(); if (g_boot.reset_count >= 3) { SafetyRecovery_Run(); // 安全恢复模式 } App_Init(); while (1) { App_Task(); IWDG_Refresh(); } }注意,安全恢复模式下一定要继续喂看门狗,否则系统又会复位。如果你的独立看门狗已经启动且无法关闭,就把喂狗放在一个定时器中断里,主循环只做状态打印和日志保存。用保留RAM里的reset_count做门限,是一个很实用的设计。它让系统有了“记住自己反复重启”的能力,而不是每次复位都像个失忆的新机器。
4. 这些坑会让人以为“RAM又被清了一次”
4.1 调试器“全片擦除”和下载带来的假象
很多人在IDE里一边调试一边验证.noinit是否生效,点了下载之后发现保留区还是被清了,于是怀疑方案不可行。先冷静一下:IDE的下载按钮不只是写Flash,它通常还会执行擦除操作,有些调试器的下载算法会初始化整个SRAM。这并不代表目标板真实复位时数据就不能保留。
验证时更可靠的方式是:把程序烧录进Flash后,拔掉调试器,目标板独立上电,通过串口观察数据;然后用按键触发软件复位或看门狗复位,再观察串口输出。如果此时保留数据还在,说明方案可行。调试器只能作为辅助工具,不能作为唯一结论来源。
另外,很多调试器的Reset按钮触发的是内核复位,RAM内容会保留,但如果你在IDE里配置了“Reset and halt”或“下载后复位”,行为可能不同。想专门调试复位逻辑时,建议用Attach模式挂到目标上,不让调试器主动复位。
4.2 编译器优化把保留变量“优化没了”
保留变量最怕两件事:链接器垃圾回收把它丢了,编译优化把它当成普通变量处理。解决办法就是我前面说的used、volatile、KEEP三层防护。GCC下如果发现.map文件里找不到.noinit段,先检查链接脚本有没有KEEP;如果变量被优化掉,检查有没有加volatile和used。
还有一种隐蔽情况:你把保留区定义成了局部变量,那它当然不会保留。noinit只对全局或静态变量的生命周期有效。另外,如果变量声明为static,且只在某个文件里定义却没有被任何函数访问,优化器也可能直接去掉。我在代码评审中一般推荐用全局结构体g_boot,虽然命名不算优雅,但可以明确告诉链接器“这个符号必须保留”。
4.3 堆栈和DMA把保留区打得稀碎
即使启动代码不碰.noinit,运行时的栈和DMA也可能把这块区域覆盖。最常见的是栈溢出问题:Cortex-M的栈是向下增长的,如果链接脚本把栈放在RAM最高地址,而.noinit段紧挨着栈底,栈一深就压进保留区。调试时会看到g_boot的前几个字段突然变成随机值。
对策有几种:
- 在链接脚本里给栈和.noinit之间留足余量,或者把.noinit放到单独的RAM区。很多MCU有多块SRAM,比如SRAM1和SRAM2,把保留区放在独立SRAM2是更稳的做法。
- 开启MPU,把保留区设为只读或不可执行,防止越界访问。
- 用调试器对保留区设置数据访问断点,谁写它马上就能抓到。
DMA的问题更隐蔽。如果你初始化了一个DMA通道,目标地址正好落在.noinit所在内存范围,DMA一旦搬运,保留区数据就被覆盖了。排查方法很简单,把所有DMA描述符、缓冲区的地址和.map文件里.noinit段的地址对齐比较一下。我在一个跑飞问题里就遇到过类似情况:看门狗复位本想保留计数器,结果一个UART DMA缓冲区和它重叠,程序一重启计数就变成巨大值,费了好大劲才定位到是DMA描述符初始化时把保留区覆盖了。
4.4 不要把低功耗唤醒和复位混为一谈
还有一种常见误解:从Stop模式唤醒后发现保留变量丢了,于是怀疑方案有问题。其实Stop模式唤醒根本不是复位路径,程序是从唤醒中断继续执行的,所有SRAM内容都应该保留。真正要小心的是Standby模式,它会把大部分SRAM内容丢掉,保留区数据能否存活要看MCU是否提供了独立供电的后备SRAM。
所以代码逻辑应该清晰区分:
- 软件复位、看门狗复位:.noinit保留区可用。
- Stop模式唤醒:普通变量也是活的,不涉及初始化。
- Standby唤醒:大概率要走上电复位一样的初始化流程,除非用后备SRAM或后备寄存器。
把这几条路径写进代码注释里,能避免不少低级错误。
5. 当“保留RAM”不够用:后备RAM与Flash的组合方案
5.1 备份SRAM/备份寄存器:数据“幸存”的另一种方式
.noinit数据在软件复位和看门狗复位下很可靠,但一掉电就没了。如果你的需求是“系统彻底掉电后,还要记住上一次的关键标志”,就需要看MCU有没有备份域资源。以STM32常见系列为例,很多芯片带有备份SRAM和备份寄存器,它们由VBAT供电,主电源断了也能保持。
备份SRAM容量通常不大,1KB到4KB左右,适合存放复位计数、启动原因、设备状态、校准数据这类小体积信息。使用备份SRAM时,一般需要先使能备份访问,不同系列使能方式不同,典型代码如下:
__HAL_RCC_PWR_CLK_ENABLE(); HAL_PWR_EnableBkUpAccess(); __HAL_RCC_BKPSRAM_CLK_ENABLE();之后就可以把变量贴在备份SRAM对应的内存地址上了,或者通过地址映射直接访问。相比.noinit,它多了一层“主电源掉电不丢”的能力,但要注意备份域访问解锁和VBAT的接线。备份寄存器则更简单,通常是RTC里的一组32位寄存器,适合保存复位计数和事件标志,但读写也要在备份域使能后操作。
个人经验:不要把所有东西都塞进备份SRAM。容量有限,调试时备份域访问还经常被PWR配置锁住。我一般只在备份SRAM里放一个64字节的管理头,真正的上下文快照还是放.noinit,两边用同一个version字段关联。这样既有掉电保存能力,又不会把备份域挤爆。
5.2 有必要时写Flash:但要考虑寿命和时机
如果设备要记录“看门狗复位了多少次”,而且要求掉电也保存,写Flash是最直接的办法,但比想象中更坑。Nor Flash的擦写寿命通常在1万到10万次之间,如果每次看门狗复位都去擦写一次,设备可能几小时就废了。正确做法是:noinit里的reset_count只做RAM内计数,达到某个阈值或特定事件时,才把现场打包写入Flash。而且最好采用两个存储槽交替写,配合CRC校验,防止写一半掉电导致整块数据损坏。
写Flash的时机也要讲究。不要在HardFault_Handler里直接擦写整个扇区,擦除时间可能长达几十毫秒,期间系统行为不可控。更稳妥的方式是:复位后进入安全模式,确认系统时钟和供电稳定,再执行Flash写入。如果存在掉电风险,至少留一个“写中标记”,下次启动时能识别并回滚。
5.3 RAM空间优化与双口RAM的一些补充
标题之外,有人搜索时还会带上RAM空间优化和双口RAM相关的热词,这里简单补几句,避免方向跑偏。
.noinit保留RAM的特点是“不清零”,不是“不占空间”。变量放在里面照样占RAM,只是省掉了初始化的时间开销和Flash里的初始值。真正的RAM空间优化,通常靠压缩数据结构、复用DMA缓冲区、把只读常量搬到Flash、用位域或定点数替代浮点运算等手段,这和noinit是两码事。做优化之前,先打开.map文件看哪块RAM被谁占了,再决定怎么省,别凭感觉下手。
至于simple dual port RAM和true dual port RAM的区别,简单说:simple dual port只有一个写端口和一个读端口,两个口分工固定;true dual port两个端口都能读写,可以同时在不同地址操作,更灵活。如果你的“保留数据”落在外部双口RAM里,要特别关注两个时钟域之间的握手和互斥,否则复位瞬间两个端口都在访问同一块地址,任何保存策略都会失效。但就Cortex-M软件复位保留数据这个场景来说,内部SRAM的.noinit已经能解决绝大部分问题,外部双口RAM多数时候是过度设计。
我在实际项目中沉淀下来的一套做法是:复位管理模块统一封装BootRecord结构体,业务代码不允许直接读写g_boot,只能通过BootRecord_GetCause()、BootRecord_GetCount()这类接口访问。这样布局想改就改,调用方不受影响,也方便以后把存储后端从.noinit换到备份SRAM或Flash。写完记得把结构体做4字节对齐,有些Cortex-M型号对非对齐访问会进HardFault。本来是用来抓死机的代码,结果自己成了死机源头,那就太冤了。