ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Cortex-M代码重定向到系统栈运行:原理、实现与避坑

Cortex-M代码重定向到系统栈运行:原理、实现与避坑 说实话第一次看到“把应用程序代码重定向到系统栈上运行”这个说法时我也愣了一下。平时嵌入式MCU开发代码不都老老实实躺在Flash里跑吗为什么要把代码搬到栈上栈不是用来放局部变量和函数调用现场的吗代码放栈上跑不怕栈溢出把指令给冲了这个操作确实偏门但并不是炫技。我在做几款Cortex-M内核的MCU项目时确实遇到过必须把某段关键代码放到RAM里执行的场景比如Flash擦写期间不能从Flash取指、低功耗模式下要关掉Flash供电、或者某些芯片的Flash时序不稳导致跑飞。而栈恰好是一块天然存在、又总被忽略的高速RAM。把代码重定向到系统栈上运行本质上就是一个链路——链接脚本定义运行地址启动代码完成搬运然后让PC指针跳进去执行。这篇文章就把这几步的原理、实现和坑一次讲透。1. 代码重定向到栈上运行的底层原理1.1 CPU取指不看“硬盘”只看地址线很多人对“代码在Flash里跑”有误解以为Flash和代码执行有某种绑定关系。其实对于Cortex-M内核的MCU来说CPU根本不关心指令物理上存在哪个存储介质里它只认两样东西地址和总线。Cortex-M3/M4内核有两条主要取指通道I-Code总线负责从代码区取指令D-Bus总线负责访问数据。两条总线在芯片内部经过总线矩阵最终接到Flash控制器和SRAM控制器上。地址空间是统一编址的Flash的地址可能是0x08000000SRAM的地址可能是0x20000000它们都在同一个4GB的地址映射表里。所以从CPU的视角看只要某段内存地址上放的是有效指令而且这段地址所在的总线支持取指操作那PC指针跳过去就能执行。Flash做的事SRAM也能做栈所在的那块RAM当然也能做。这就是代码重定向的底层基础把一段可执行代码放到RAM的某个地址上然后跳转过去。1.2 哈佛结构带来的取指限制要说清楚重定向还得提一嘴“哈佛结构”。Cortex-M内核是改进型哈佛结构指令总线和数据总线物理上分开但地址空间统一。这意味着代码区和数据区在软件层面没有严格边界只要硬件上允许从某段内存取指它就“可以是代码区”。大部分MCU的SRAM是允许取指的因为SRAM控制器本身没有“禁止取指”的保护逻辑不像某些MPU系统会通过MMU把数据区标记为不可执行。不过同一条D-Bus既要给栈读写用又要给指令取指用如果代码和栈共用一片RAM频繁的栈操作会跟取指竞争总线带宽。这也是为什么很多人把代码放在“栈”上的时候会选择CCM RAM或紧耦合RAM——这类RAM挂着独立总线不经过AHB总线矩阵取指时不会跟普通数据访问抢带宽。1.3 重定向的“两头”加载地址和运行地址代码重定向必须搞清楚两个概念一个是加载地址Load Memory AddressLMA一个是运行地址Virtual Memory AddressVMA。平时编译的代码LMA和VMA是一样的都在Flash里。但重定向到栈上之后VMA就变成了RAM栈区的一个地址而LMA还在Flash里。链接器干了两件事先把代码的绝对地址引用全部按VMA来算也就是函数指针、跳转指令、常量池都指向RAM地址再把代码复制到RAM的搬运请求编译进镜像文件启动时由启动代码把Flash里的代码原样拷到VMA位置。这就是为什么重定向听起来高大上实际做起来只需要改链接脚本和启动代码。2. 为什么要专门选择“栈”而不是其他RAM2.1 栈到底特殊在哪坦白讲把代码放在系统栈上运行并不是最常规的做法。一般要RAM执行代码我们会直接在链接脚本里划一块独立的RAM段取个名字叫“ram_code”然后把函数放进去这是最稳妥的。但栈不一样它有三个其他RAM区域给不了的好处。第一栈是“私有”的。系统栈通常由寄存器SP指向是唯一的执行现场不会被其他业务模块随意占用。这意味着放在栈里的代码区域承受的外部干扰最小。第二栈的地址是“天然存在的”。启动代码里早就把SP设置到了RAM的某个地址栈空间不需要额外规划内存布局只要在栈顶往下的位置留一小块做代码区就行。第三栈所在的内存往往被优化过。很多MCU把栈默认放在RAM的高端而高端RAM或CCM RAM往往有更小的访问延迟。STM32F4系列就把CCM RAM挂在D-Bus的独立端口上主频跑到168MHz时CCM RAM能做到零等待。2.2 什么样的场景才值得把代码放栈上不是所有代码都适合挪到栈上。我整理了几类真正需要这样做的场景Flash正在执行擦除或编程操作时不能从同一片Flash取指否则MCU会卡死这时擦写算法代码必须放到RAM里。低功耗模式下如果要把Flash供电关掉来省电唤醒代码必须在Flash断电前就跑到RAM里。在调试某些疑难问题时怀疑Flash读取受时钟配置或电压影响可以用栈上的代码做个隔离实验把问题缩小到存储介质层面。某些高安全要求的场景不希望关键代码在Flash里被静态分析运行时才会把代码搬运到RAM中执行栈区域私密性比通用RAM更好。我实际遇到最多的是第一种。做IAP升级时需要在应用里直接调用Flash擦写函数这段函数如果不放到RAM里擦写Flash的过程中CPU去取下一跳指令时读的就是无效数据直接HardFault。3. 链接脚本和启动代码的完整实现3.1 GCC下的链接脚本改造我用GCC工具链比较多先拿它开刀。要让代码段定位到栈上需要在链接脚本里定义一个新的输出段并把它放到栈区域内。假设系统栈的初始SP是0x20010000RAM末地址栈向下增长那我在栈的低地址区域划出一小块放代码。链接脚本大概长这样MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 512K RAM (xrw) : ORIGIN 0x20000000, LENGTH 128K } _estack 0x20010000; /* 栈顶 */ SECTIONS { /* 其他段省略 */ .stack_code 0x2000F000 : { . ALIGN(4); __stack_code_start .; *(.stack_code) . ALIGN(4); __stack_code_end .; } RAM AT FLASH /* 运行在RAM内容存储在Flash */ }注意0x2000F000这个地址是栈顶0x20010000往下4KB位置。也就是说栈顶保留了16KB空间实际可用栈空间是0x2000F000到0x20010000这一段而0x2000F000往下是代码区。这样安排后只要不出现极端深度的递归或者超大局部变量数组栈指针向下增长时不会够到代码区。但是这里有个隐患编译器如果见到某些函数没有被调用可能会在垃圾回收时把整个.stack_code段丢掉。所以还需要配合KEEP指令把段强制保留.stack_code 0x2000F000 : { . ALIGN(4); KEEP(*(.stack_code)) __stack_code_end .; } RAM AT FLASH3.2 启动代码里完成搬运链接脚本只决定“代码该放到哪”真正把数据从Flash搬运到RAM的还是启动代码。在Reset_Handler里进入main之前要加上拷贝逻辑。用纯汇编写最稳ldr r0, __etext /* Flash中.stack_code段的加载地址 */ ldr r1, __stack_code_start ldr r2, __stack_code_end subs r2, r2, r1 beq .Lstack_code_done .Lstack_code_loop: ldr r3, [r0], #4 str r3, [r1], #4 subs r2, r2, #4 bne .Lstack_code_loop .Lstack_code_done:这里有个细节我吃了不少亏GCC生成的符号名在不同版本里不太一样。__etext是Flash中所有加载区结束后的地址但如果你有多个RAM段比如.data、.stack_code都要从Flash拷到RAM就不能简单用__etext去拿.stack_code的加载地址。更稳妥的做法是引用__load_start__stack_code和__load_stop__stack_code这两个链接器自动生成的符号GCC的ARM工具链对每个输出段都会生成这两个符号。所以拷贝代码改成ldr r0, __load_start__stack_code ldr r1, __stack_code_start ldr r2, __load_stop__stack_code subs r2, r2, r0用加载地址差作为拷贝长度避免运行时地址和加载地址不一致导致的长度计算错误。3.3 Keil MDK环境下怎么搞Keil MDK用ARMCC/ARMClang时事情稍微有点不同但思路完全一致。分散加载文件.sct里定义执行域和加载域。假设我用的芯片RAM是64KB栈顶在0x20010000我把代码放在栈内低地址区0x2000F000LR_EROM1 0x08000000 0x00080000 { ER_ROM1 0x08000000 0x00080000 { *(.text) *(.rodata) } RW_IRAM1 0x20000000 0x00010000 { *(.data) *(.bss) } STACK_CODE 0x2000F000 UNINIT { *(.stack_code) } }函数声明用__attribute__((section(.stack_code)))放到指定段。启动文件里Image$$STACK_CODE$$Base、Image$$STACK_CODE$$Length这些符号就是搬运用的关键参数。ARMClang下更推荐用__attribute__((section(.stack_code), noinline))防止编译器自作主张把函数内联掉。3.4 调用重定向后的函数代码放在栈上后调用方式和普通函数没有区别声明extern函数原型然后直接调用即可。但有一个点需要注意如果函数的地址是编译期决定的那链接器已经按RAM地址修正了所有跳转关系如果你用函数指针动态调用也要保证跳转地址确实是RAM地址。调试时看到PC指针跳进0x2000Fxxx段就说明重定向生效了。4. 实操验证和栈安全边界估算4.1 先跑一个最小的Demo我建议第一次做实验的时候不要直接把业务代码扔进.stack_code段先写一个简单的“空转翻转引脚”函数把它放到段里然后反复调用看系统是否稳定。示例__attribute__((section(.stack_code), noinline)) void stack_run_test(void) { volatile uint32_t cnt 0; for (uint32_t i 0; i 0x1000; i) { cnt; } GPIO_TogglePin(GPIOA, GPIO_PIN_0); }在main函数里跑这个函数用示波器看引脚翻转频率是否正常。如果翻转正常说明代码确实在RAM里跑而且栈区域没有被破坏。然后逐步加递归深度、加大局部变量数组观察什么时候栈会压到代码区。这个“临界点”就是你的栈安全边界。4.2 怎么确认PC真的跳到了栈上调试器是最直接的“照妖镜”。连接SWD接口后在stack_run_test函数入口设一个断点跑起来让程序停在断点上然后查看寄存器窗口里的PC值。如果PC显示为0x2000Fxxx那说明代码确实在栈上跑如果PC还停在0x08xxxxxx说明section配置没生效或者启动代码拷贝前PC已经跳过去了。另外可以看编译生成的.map文件。找到.stack_code段确认它的地址范围落在栈区域内。如果地址范围还是Flash地址说明链接脚本里指定的地址没生效检查是不是有别的段占了那个地址。反汇编窗口更直观。把PC停在栈上时反汇编窗口应该显示RAM地址处的机器码。如果RAM地址处的字节全是FFFF或0x00说明拷贝没成功这时候先检查拷贝循环有没有跑再检查加载地址对不对。我遇到过一种情况链接器生成了.stack_code段但启动代码里没有执行拷贝程序跳进RAM后执行了一堆00 00 00 00最终掉进HardFault。4.3 栈容量预算到底怎么算把代码放栈上最大的代价是“偷走”了一部分栈空间。栈原本是给函数调用、局部变量、中断压栈用的现在还要留出一块给代码。这里给一个我常用的估算方法先量出正常业务逻辑下的栈水位。方法是把栈区域全部填充0xAA跑完典型业务流后查看栈高水位剩余多少。调试器内存窗口扫一遍0xAA没被覆盖的边界就能知道最大栈使用深度一般用__attribute__((noinline))递归函数强行压榨栈空间测出最大值。在最大栈深度基础上加两个安全因子一个给中断嵌套一个给编译器生成的库函数调用。我通常额外加30%到50%。代码段至少留4KB。如果你的函数有很多内联字符串、浮点常量池代码段可能放大到8KB。宁可多给空间也不要出现栈增长到代码区把指令覆盖掉的惨剧。我实际遇到的情况是系统栈原本32KB我只用其中2KB放代码栈空间损失很小但业务逻辑不能出现超深递归否则压到代码区域。如果芯片RAM比较小就要重新考虑这个方案是否划算。5. 高频踩坑和问题排查实录5.1 栈增长把正在执行的代码覆盖了这是最典型的故障。代码在栈区跑SP也在栈区如果函数调用层级太深SP增长到代码段地址接下来的压栈操作直接抹掉当前指令流。此时表现为程序从正常执行突然跳到随机地址或者产生HardFaultHardFault处理函数本身可能也是被破坏的导致调试器连寄存器都抓不准。解决思路有三个方向第一是预算就是我上面说的栈水位测试把栈安全边界标定出来。第二是配置MPU。如果芯片带MPU可以把栈代码区设置成只读权限这样栈指针一旦越界写入会立刻触发MemManage Fault从被动损坏变成主动告警。第三是在代码段末尾放一个栈警戒区填充固定字节每轮主循环检查一次发现被改写立刻报警。这个方式不依赖硬件异常适合没有MPU的低端MCU。5.2 链接器优化掉整个代码段写好了section编译却提示找不到符号或者.map文件里根本没有.stack_code段这多半是函数被优化掉了。因为如果代码没有被调用链接器不会为它分配空间。对策就是上面的KEEP()指令。另外函数本身要加noinline否则可能被内联到调用处再从Flash执行等于重定向了个寂寞。还有种情况是编译器的LTO开启后把section属性给折叠了。我建议重定向的代码文件单独关LTO或者用__attribute__((used))强制保留。这是个不起眼但很容易坑人的细节。5.3 拷贝时中断抢跑导致执行垃圾指令启动代码在搬运.stack_code段时如果此时中断已经使能一个中断触发后中断向量表指向的函数如果也在未拷贝完成的区域那中断函数可能执行到一半的数据。所以搬运动作必须在进main之前、开中断之前完成这点和.data段拷贝的逻辑一样。但要特别注意如果重定向代码放在某个外设初始化之后那拷贝前必须先关中断拷贝完再恢复中断状态。我一般会把拷贝动作放在Reset_Handler最前面紧跟SP初始化之后SystemInit之前。这样不管是FPU、时钟还是中断全都还没来得及配置不会发生混乱。5.4 Cache和流水线导致的诡异问题Cortex-M7或者部分M55内核带I-Cache时从Flash取指过的地址会被缓存。代码运行在RAM里通常不会涉及I-Cache但如果代码从Flash拷贝到RAM的过程中CPU预取了旧地址的指令缓存里可能会残留Flash中的旧内容。这种情况下需要把I-Cache清理一遍确保后续取指访问RAM时不会命中过期的Flash地址。Cortex-M3/M4没有I-Cache可以跳过这一步。但M4有一个更隐蔽的问题代码重定向到RAM后D-Bus既要访存又要取指总线矩阵的仲裁可能造成执行效率下降。如果代码段放到了普通的SRAM区域建议通过MCU的AXI/AHB总线配置把RAM端口优先级调高或者直接改放CCM RAM区域隔离总线竞争。5.5 快速故障定位速查表故障现场可能原因排查切入点PC跳到RAM地址后立即HardFault拷贝未完成/数据被破坏检查启动代码拷贝循环、比较Flash和RAM地址的字节函数运行结果正常但性能更差代码段位于普通SRAM总线竞争换CCM RAM或用D-Bus优先级配置程序偶发跑飞严重时死机栈越界覆盖代码段栈水位测试、MPU只读保护、警戒区检查链接报错section地址重叠栈区地址设置和RAM变量段冲突检查链接脚本RAM布局不要把代码段放在.data区域重定向函数里用了浮点运算死机FPU未使能或栈断言未初始化在拷贝后显式使能FPU或减小浮点运算断电重启后RAM里内容混乱代码段没有在每次启动时刷新确认拷贝循环一定执行不能用条件跳过6. 几个值得继续深挖的变体和特殊玩法前面讲的是直接把代码段硬编码到栈区实际项目里还有两种变体很实用。一种是“动态重定向”。运行时从外部Flash、网络、SD卡里加载一段校验过的代码把它放到栈区里跑。这种玩法适合一些临时性的现场诊断任务比如远程升级后如果系统起不来可以在栈上跑一段诊断代码把Flash里的日志捞出来。动态重定向的关键是做好RAM散列校验防止跳转后才发现代码损坏。另一种是“函数级重定向”把同一个函数的两个版本分别放在Flash和栈区。平时跑Flash版本特殊模式比如产线自检、安全模式切到栈版本。两者的函数地址不同切换时必须保证当前调用栈上没有指向旧函数的帧否则返回地址还是旧函数的Flash地址。我最开始做这个切换时忽略了返回地址的问题切过去之后函数返回时PC跳回Flash旧函数出现了两份代码同时“活着”的灵异现象。解决办法是切换前将调用路径彻底退出或者用一层薄的内存函数指针做接口。还有一点栈重定向和RTOS一起用时要格外小心。如果系统跑了FreeRTOS任务栈是各自独立的系统栈MSP只在启动和中断时用。如果你把代码放到MSP所在的系统栈里中断里能正常执行但切到任务栈后这段代码可能不可见。更常见的做法是把关键代码放到任务栈里配合任务级临界区调用但这也意味着不同任务的栈都要预留代码空间内存开销成倍增加。用RTOS时我反而更倾向于用独立RAM段不要再折腾栈了。就我个人经验而言代码重定向到系统栈这套方案最大的价值不是节省内存而是提供了一个“关键时刻必须能跑”的执行环境。嵌入式系统里最难查的往往不是逻辑错误而是存储介质在特定环境下失效的问题。在栈上留一小块可执行代码相当于给自己留了一个永不失效的应急通道。前提是头脑清醒把栈边界和代码边界算得一清二楚别让救命通道被自己的栈给埋了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进