
去年做一版OTA升级量产前最后两天测试同事拿样机刷了一版新固件结果设备直接变砖看门狗开了也没救回来。排查到最后问题根本不在网络、不在分包、不在校验而是升级代码自己“住”在Flash里却动手擦写了当前正在执行的Flash扇区——CPU从Flash取指的那一刻Flash控制器正忙着擦除指令总线永远等不到下一个字节。那段时间我把“将应用程序代码重定向到系统栈stack上运行”这个思路翻来覆去研究了个透现在把原理、链接脚本写法、启动时序和实测中踩过的坑整理成文希望能帮同样做嵌入式MCU软件开发的朋友少走几天弯路。1. 一次OTA升级变砖才逼我认真研究“代码重定向到RAM”1.1 事故回放升级代码也住在要擦掉的Flash里先还原一下现场。设备用的是Cortex-M4内核MCU内部Flash共512KB分两个Bank。固件分成bootloader和app两个区平时app从0x08008000启动。OTA流程是先通过无线把新固件下载到外部SPI Flash然后一个“升级任务”开始擦写内部Flash。升级任务本身是app的一部分编译后被链接器放进了Flash也就是它自己也被安排在0x080xxxxx这个地址范围。擦写内部Flash时按照数据手册流程必须先擦除目标扇区。问题就出在这升级任务擦除的扇区里恰好包含了它自己所在的代码区域。Flash控制器进入擦除流程后整个Flash阵列都在高电压操作下无法同时响应来自CPU的取指请求。CPU在那一刻正等着Flash返回下一条指令结果一等就是几百毫秒期间程序计数器停在那里一动不动。等擦写完成Flash内容已经变了原先的指令被擦掉复位后从新向量表开始跑结果向量表指向的代码也不完整于是看门狗复位、再跑、再卡死彻底变砖。这不是偶发bug而是结构性问题只要你让住在Flash里的代码去擦写它所在的Flash区域就一定存在这个风险。除非MCU支持“边擦写边读”的RWW特性否则在擦写期间CPU从Flash取指就是会被卡死这是Flash物理特性决定的。1.2 根因拆解Flash擦写期间取指总线被卡住Flash的读写差异很大。读Flash通常几十ns搞定但擦除/编程需要泵浦电路把电压升到高压操作一个扇区往往要几十到几百毫秒。这段时间内Flash阵列处于“工作状态”没有余力响应读请求。MCU内部的指令总线I-Bus和数据总线如果都挂在Flash控制器上那么擦写期间任何对Flash的读操作都会悬停等待。很多人在写升级流程时只关注“擦除函数能不能正确操作Flash寄存器”却忽略了CPU本身也是一台“从Flash读指令的机器”。你不是简简单单调一个erase_sector()等返回就行——你这条函数调用的代码本身、函数里的每条指令、中断向量、字符串常量全都放在Flash里。擦写开始后CPU每次取指都需要Flash控制器响应而Flash控制器正忙着擦除于是CPU就只能干等。这里有个关键细节即使你要擦除的是另一个扇区只要Flash控制器在同一时刻只能处理一件事取指就会被阻塞。所以哪怕你的升级代码不在被擦的扇区里擦写期间CPU照样会卡。这就是为什么强烈建议凡是擦写Flash期间可能被CPU执行到的函数全部放到内部SRAM里跑。1.3 “系统栈”辨析标题里的stack到底指什么先说清楚一个容易混淆的概念。Cortex-M内核有两个堆栈指针MSP主堆栈指针和PSP进程堆栈指针。“系统栈”这个词在嵌入式语境里通常指MSP所指向的那块主堆栈区它位于内部SRAM中。但代码重定向的目标严格来说不是“栈区”而是“内部SRAM中的一段可执行区域”。栈区地址是动态变化的函数每压栈一次SP就往下挪一点返回时SP又往上弹。如果把程序代码真的放到SP当前指向的地址上函数调用时返回地址压栈立刻就把代码覆盖了等于自杀。所以标题里的“重定向到系统栈上运行”更准确的理解是把代码的运行地址VMA重定向到内部SRAM这片物理RAM区域中而这片RAM区域通常同时容纳栈、堆、数据段和代码段。代码和栈各占各的地盘互不侵犯。我做这个方案时链接脚本里是把代码段固定放在SRAM的一个独立区域内栈放在SRAM末尾靠链接器的地址检查保证两者不重叠。如果你直接拿栈顶地址充当代码起始地址那属于自己给自己埋雷。1.4 除了OTA还有哪些场景必须考虑重定向Flash擦写自保护bootloader升级app、app升级bootloader、保存用户配置参数到Flash等场景。Flash编程算法下载很多IDE下载程序时会把烧录算法临时拷贝到RAM执行同一个道理。关键数据区的擦除/写入比如在运行中修改EEPROM模拟区、掉电存储区如果存储区就在代码所在的Flash芯片上同样有风险。部分安全启动和校验场景需要在Flash写保护切换前后保持代码继续执行。2. 链接脚本是幕后导演用GCC .ld把函数安排进SRAM2.1 VMA和LMA“代码存哪”和“代码从哪执行”是两码事很多朋友对链接脚本不熟一看到LMA、VMA就头大。其实用大白话讲VMA是程序运行时某个段所在的地址LMA是这个段在Flash里存的地址。比如普通.data段初始值存在Flash里LMA在Flash上电后C运行库把它拷贝到SRAMVMA在SRAM。程序运行时访问的是SRAM里的副本。代码段.text平时是VMALMA都在Flash所以CPU直接从Flash取指。我们要做代码重定向本质就是改代码段的VMA让它指向SRAM而LMA仍然放在Flash保存一份初始代码镜像。运行时先把镜像从Flash拷贝到SRAM再跳到SRAM执行。这个“Flash里存镜像、SRAM里跑代码”的思路理解透了对后面所有操作都有帮助。2.2 GCC链接脚本实操.ramtext段这样定义以GCC工具链为例典型链接脚本片段长这样MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 512K SRAM (rwx) : ORIGIN 0x20000000, LENGTH 128K } SECTIONS { /* 默认代码段仍留在Flash */ .text : { KEEP(*(.isr_vector)) *(.text*) *(.rodata*) . ALIGN(4); _etext .; } FLASH /* 重定向段运行地址在SRAM加载地址在Flash */ .ramtext : { . ALIGN(4); __ramtext_start .; KEEP(*(.ramtext)); *(.ramtext*); __ramtext_end .; } SRAM AT FLASH /* 常规数据段 */ .data : { . ALIGN(4); _sdata .; *(.data*) _edata .; } SRAM AT FLASH .bss : { . ALIGN(4); _sbss .; *(.bss*) *(COMMON) _ebss .; } SRAM }关键点在这句.ramtext段的VMA被放进了SRAM而AT FLASH指定它的LMA仍在Flash。这样链接器会生成两套地址信息运行地址是SRAM里的比如0x20001000加载地址是Flash里的比如0x08020000。程序启动时没人帮你自动拷贝因为这不是标准段需要你自己写拷贝代码。链接器会自动生成一些边界符号你也可以自己声明变量来拿地址extern uint8_t __ramtext_start[]; extern uint8_t __ramtext_end[]; extern uint8_t __ramtext_load[];这里__ramtext_load指向该段在Flash中的LMA。用数组名而不是变量名能避免取地址时产生误导。函数想放进.ramtext段只要加一句声明__attribute__((section(.ramtext))) void flash_page_program(uint32_t addr, const uint8_t *data) { // 擦写Flash的核心代码 }2.3 MDK/armclang分散加载ARM工程怎么搞在Keil MDK里方法类似但要换成分散加载文件。老AC5编译器可以直接用__ramfunc关键字编译器会把函数放到RAM并自动生成拷贝初始化确实省事。但到了AC6armclang时代__ramfunc的行为在不同版本有差异我建议直接显式用__attribute__((section(ram_code)))可控性更强。分散加载文件相应增加一个执行域LR_IROM1 0x08000000 0x00080000 { ER_IROM1 0x08000000 0x00080000 { *(.isr_vector) *(InRoot$$Sections) .ANY (RO) } RW_IRAM1 0x20000000 0x00020000 { .ANY (RW ZI) } RAM_CODE 0x20020000 { *(ram_code) } }注意RAM_CODE的执行域不要和数据段RW_IRAM1重叠实际项目中我会专门给RAM_CODE预留一块区域并设置长度上限防止代码段膨胀后越界。拷贝代码同样要自己写AC6的__main只会处理标准RW段不会自动处理自定义section。2.4 只搬关键函数别把整个应用都塞RAM有朋友一上来就想把整个.text段搬到RAM以为这样最省事。实际工程里强烈不建议RAM容量通常比Flash小一个数量级Flash里几十KB的代码塞进去RAM直接爆炸成本、功耗全变差。合理的做法是把“擦写Flash期间会被执行的函数”挑出来按函数粒度重定向。按目标文件粒度选择也可以.ramtext : { . ALIGN(4); __ramtext_start .; KEEP(*(.ramtext)); *upgrade_main.o(.text*) *flash_hal.o(.text*) __ramtext_end .; } SRAM AT FLASH这样只要把升级相关代码放在特定C文件里链接时整个目标文件的代码就会自动进.ramtext。比一个个函数加section属性省心也不容易漏。3. 搬完代码只是开始跳转时序、向量表和SP切换3.1 只重定向个别函数时直接调用就够了如果你的场景只是在升级过程里调用几个RAM函数普通函数调用完全够用。Cortex-M的BL指令寻址范围是±32MBFlash到SRAM的地址差通常远小于这个范围编译器会用普通BL指令完成跳转。RAM函数里也可以再调用其他RAM函数链接器都会处理好。我常用的做法是写一个execute_upgrade()函数放在RAM里里面串联“擦除扇区、写数据、校验、跳转”整个流程。Flash里的app只负责在某个时机调用它extern void execute_upgrade(void *param);链接器会把这个符号解析为SRAM中的地址。函数执行完后可以正常返回Flash也可以用软复位或直接跳转新固件。这种局部重定向实现简单、风险可控适合绝大多数项目。3.2 整个应用都要在RAM里跑时先改VTOR如果需求是整个应用代码彻底搬进RAM跑事情就复杂了。首先Cortex-M的向量表默认放在Flash起始地址CPU复位后从固定地址取初始SP和Reset_Handler。应用全部搬到RAM后Flash里的向量表已经名存实亡必须把向量表搬到RAM并重定位。Cortex-M3/M4等内核有VTOR寄存器SystemControl Block里可以指定向量表基地址。RAM里的向量表需要按对齐要求摆放通常是向量表大小对齐比如256B或512B。设置代码很简单SCB-VTOR (uint32_t)_ram_vector_table;但有一点要提醒Cortex-M0/M0部分型号没有VTOR或者需要额外的启动模式配置才能从RAM启动。拿到MCU第一步先查参考手册的“System control block”章节确认内核是否支持别想当然。3.3 从bootloader跳转的完整代码和顺序假设bootloader在Flash新固件镜像已经下载到内部RAM的某区域跳转代码大致是这样typedef void (*AppEntry)(void); void jump_to_ram_app(void) { uint32_t app_sp; uint32_t app_pc; __disable_irq(); SysTick-CTRL 0; DWT-CYCCNT 0; app_sp *(volatile uint32_t *)RAM_APP_BASE; app_pc *(volatile uint32_t *)(RAM_APP_BASE 4); __set_MSP(app_sp); __ASM volatile (dmb); __ASM volatile (isb); ((AppEntry)app_pc)(); }顺序很讲究先关中断再读取新固件的第一条向量初始SP和第二条向量Reset_Handler然后切换到新固件的栈指针最后跳转。读向量表前要确认RAM里的镜像已经完整拷贝跳转前要做DMB/ISB内存屏障确保之前写RAM的数据已经落定。有个容易忽略的点跳转前要把SysTick和其他外设中断全部关干净否则新固件还没初始化完旧中断就跑进来了。3.4 拷贝RAM代码的三条铁律拷贝动作必须在Flash里完成。因为拷贝代码本身要执行你在擦写开始后再拷贝就晚了。拷贝完成后才允许执行擦写操作。拷贝期间不能有任何中断。中断一触发向量表取指、ISR执行都会访问Flash如果Flash正在擦写一样卡死。建议拷贝前__disable_irq()擦写完成后重新初始化中断。拷贝后做一次完整性校验。把Flash里的镜像和RAM里的镜像逐字节比较一次发现不一致就报警绝不要带着残缺代码跳过去。这三条是我血的教训。第一次做RAM重定向时拷贝完就直接跳到RAM执行结果偶尔启动不稳定最后才发现是中断在拷贝过程中偷跑把RAM代码区当数据盖了一部分。4. 在RAM里执行代码的实战陷阱清单4.1 坑一代码和栈抢地盘跑着跑着被SP踩掉RAM区域里既有代码段又有数据段还有栈。栈从RAM末尾往下增长如果代码段放得太靠近栈底某个深层函数调用一压栈就可能踩到代码区程序立刻乱飞。链接脚本里不要偷懒把RAM_CODE显式放在低地址或固定区域栈放在高地址两者之间留出足够的空洞。有条件的话给RAM_CODE段所在的区域开MPU设成只读可执行栈溢出时先触发MPU异常而不是神秘HardFault。4.2 坑二字符串常量还在Flash擦写期间一样卡死把函数搬进RAM只是第一步函数里引用的数据也要检查。C编译器默认把字符串常量和const变量放在.rodata里而.rodata通常跟着.text留在Flash。你的RAM擦写函数一旦调用日志打印printf(erase sector %d\n, sector)就会去Flash读字符串擦写期间照样卡死。解决办法升级模块里尽量不用字符串或者把这些常量也放进RAM段。我一般会把升级模块的.rodata*一并塞进.ramtext段.ramtext : { ... *upgrade_main.o(.rodata*) *flash_hal.o(.rodata*) ... } SRAM AT FLASH这样函数和它要用的常量数据都在RAM里擦写期间想读就读不受Flash状态影响。4.3 坑三链接器垃圾回收把函数“搬丢了”开了-ffunction-sections和--gc-sections的工程链接器会删除“看起来没被引用”的段。你的RAM函数如果只通过函数指针或section属性被引用很容易被误删。链接后跑出来的现象是跳转时HardFault查map文件才发现目标地址根本不存在。在链接脚本里用KEEP包裹关键段是一个有效手段.ramtext : { KEEP(*(.ramtext)); ... }同时建议编译后打开map文件搜索你的RAM函数名确认它确实被链接到了SRAM地址而不是被丢弃或者被放到了Flash。这一步看起来多余但能帮你省下半天排查时间。4.4 坑四中断不是局外人ISR也得住进RAM很多人只顾把主流程函数搬进RAM却忘了中断。SysTick、UART RX、按键轮询定时器这些中断如果在擦写Flash期间触发处理器会从向量表里取ISR地址然后跳到Flash执行ISR。和普通代码一样ISR取指照样被Flash擦写卡住。解决思路有三个层次最简单擦写期间__disable_irq()连中断一起屏蔽。中立把可能触发的中断ISR也放到RAM段。极致整个升级状态机包括所有中断处理全部放进RAM运行。我做批量化OTA时选的第三层因为升级过程中还要保持通信超时判断、喂狗、看进度条全关中断不现实。把这些ISR也通过section属性放进RAM后问题才彻底消停。4.5 坑五调试下载时RAM函数没被初始化一调用就HardFault调试器下载程序时通常只会往Flash烧写并不会自动执行你的RAM拷贝函数。你如果在调试器里直接调用RAM里的升级函数大概率得到HardFault因为RAM里那块区域还是随机值。这不是代码逻辑错了是调试流程没匹配上。调试时可以这么做在main函数早期调用升级模块初始化函数先完成.ramtext从Flash到RAM的拷贝或者调试时给拷贝函数设个断点单步执行完拷贝再继续。总之RAM函数不是下载完就能用的必须确保拷贝动作执行过。5. SRAM执行到底有多香以及值得照抄的工程架构5.1 Flash等待周期与SRAM的实测差异很多人问代码在RAM里跑是不是真的更快答案是“分情况”。Flash在高速主频下需要插入等待周期比如某M4内核在72MHz下Flash需要2个等待周期理论上有意义的代码可能要多花2倍时间但现代MCU的Flash控制器基本都有预取缓冲和缓存顺序执行的代码预取命中率很高实际差异没有理论那么悬殊。SRAM零等待但走总线也可能有固定开销。我实际测试过CRC32计算这类循环密集的任务SRAM执行时间大约比Flash执行快20%到40%而一次memcpy这种顺序流两者差距很小。所以如果你单纯为了性能把全部代码搬进RAM收益未必划算但如果你是为了Flash擦写自保护那这个开销是必须付的性能提升只能算“附赠品”。5.2 可复制的两阶段升级架构我在OTA项目里最终采用的架构供参考阶段一下载app接收新固件写入外部SPI Flash或RAM缓冲过程中一切正常运行。阶段二提交调用存放在RAM的升级模块按扇区擦写内部Flash。擦写前关中断、关看门狗或喂RAM代码里的软狗擦写完成后重新开中断。阶段三校验跳转校验新固件CRC全部通过后软复位启动新固件不通过则保留旧固件回滚。升级模块编译时被放在.ramtext段启动早期由bootloader或app初始化时拷贝到SRAM。这套架构最核心的好处即使升级中途断电、Flash写一半旧固件和新固件都还有完整备份设备永远有办法通过bootloader恢复。方案适用场景优点缺点单函数重定向只在擦写流程中调用改动小、风险低需要手动标注每个函数目标文件重定向升级模块集中管理不易漏、结构清晰对代码组织要求高全量代码重定向安全启动、自更新bootloader彻底解决Flash访问冲突RAM占用大、启动流程复杂5.3 成本与选型全量重定向 vs 局部重定向全量重定向听上去干净但RAM成本不低。128KB SRAM的MCUFlash里固件60KB搬一半过去就要占30KB加上数据段、栈、堆几乎把RAM挤爆。而且全量重定向意味着整个启动流程、中断体系全部重设计Debug难度直线上升。我的经验是默认先用局部重定向只挪升级相关的十几个函数和ISR。等到出现“升级过程中还得维护通信、文件系统、按键交互”这类复杂的业务需求时再考虑把升级状态机整体放RAM但依然不碰普通业务代码。能少搬就少搬搬得越少坑越少。最后再说一点实践体会代码重定向到RAM这套东西原理不复杂复杂的是执行时机和边界条件。链接脚本敲对只是第一步真正考验人的是中断屏蔽时机、拷贝完整性校验、栈与代码的地址规划。我建议新手先做一个小工程在RAM里放一个点灯函数Flash main里调起来跑通再逐步扩展到Flash擦写场景。一次成功之后你再看那些所谓“升级变砖”的问题就会觉得不过如此。