ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

嵌入式MCU代码重定向到栈上运行的原理与实现

嵌入式MCU代码重定向到栈上运行的原理与实现 1. 为什么要让代码在栈上运行先聊清楚场景再动手嵌入式MCU开发这个行当绝大多数人写代码默认是代码在Flash里跑数据在RAM里跑。但偶尔你会遇到一些特殊需求要求把应用程序的代码直接重定向到系统栈stack上运行。我第一次碰到这个需求是在做一个远程固件升级的bootloader项目当时要在RAM里完成整个应用程序的校验、搬运、跳转而Flash里的部分区域因为擦写策略原因暂时不可读于是把代码搬到栈上跑就成了最稳的方案。这个技术说白了就是程序代码段原本放在Flash里CPU通过指令总线去取指执行重定向到栈上运行本质上是把代码段的数据从Flash拷贝到RAM的栈空间里然后把PC指针指过去让CPU从RAM里取指执行。听起来简单但里面涉及链接脚本调整、启动代码的拷贝逻辑、栈空间预算、中断向量表重映射、编译器优化选项等一系列坑。这篇文章就把整个实现原理和操作细节完整拆一遍适合正在做bootloader、需要在RAM中执行代码的开发者也适合准备嵌入式面试时遇到代码重定向、栈上运行这类题目的人。先说清楚一个概念误区很多人以为把代码重定向到stack上运行就是把函数栈帧搞大一点、多定义几个局部变量这是完全不对的。这里说的重定向是active code segment的地址映射发生改变CPU取指令的源地址从非易失存储换成了RAM中的栈区属于链接地址和运行时地址脱钩的问题。理解到这一层后面所有操作才有意义。2. 原理拆解链接脚本、启动代码与栈的本质2.1 先看程序是怎么被记住地址的MCU工程从源码到可执行文件中间要经过编译、汇编、链接三步。链接这一步干的最核心的事就是把各个目标文件里的段section按链接脚本linker script的规则摆到指定的地址空间里。以GCC工具链下的ARM Cortex-M为例链接脚本里通常会定义这几个关键段.text存放可执行指令、只读常量一般放在Flash起始地址。.rodata只读数据通常跟在.text后面。.data已初始化的全局变量初始值存在Flash里运行时拷贝到RAM。.bss未初始化或零初始化的全局变量运行时清零。.stack栈空间通常由启动代码或链接脚本预留。默认情况下.text段的链接地址LMALoad Memory Address和运行地址VMAVirtual Memory Address都是Flash地址。比如STM32F103系列Flash从0x08000000开始链接脚本就把.text放在那里CPU的PC寄存器指到0x08000000附近取指执行这就是最常规的场景。而重定向到栈上运行就是强制把某一段可执行代码的VMA改成RAM中的栈区域。这里必须理解LMA和VMA的分离LMA是代码躺在哪里烧录时写入的位置VMA是代码跑起来时所在的地址。把VMA指向栈空间意味着链接器生成的目标地址是栈区域的地址但代码的二进制内容还是存在Flash里启动时必须先做一次自我搬运把这段二进制从Flash复制到栈空间的对应地址上。2.2 栈究竟是怎么一回事栈在MCU里就是一段RAM区域配合栈指针寄存器Cortex-M里是MSP或PSP完成压栈、弹栈操作。函数的局部变量、函数调用时的返回地址、保存的寄存器现场全部都在栈上。栈的生长方向在Cortex-M上是向下生长的也就是栈指针从高地址向低地址移动。那为什么有人会想到把代码放到栈上呢几个典型动机Flash被占用或暂时不可访问比如bootloader正在对Flash执行擦写操作此时Flash的读操作会被打断如果代码还在Flash里跑取指就会失败。需要对RAM中的代码做运行时补丁或动态加载比如一些轻量级的脚本引擎或运行时自修改技术。某些芯片Flash读取速度慢而RAM访问速度更快把热点代码搬到RAM里能提升实时性。安全场景下想防止固件被静态逆向代码在RAM中动态解密后运行栈区就是一个现成的临时战场。但必须提醒一句栈的空间本来就紧张还要塞代码段进去这对栈深度的预算提出了很高要求。后面我会详细讲怎么算这个账。2.3 重定向到系统栈与普通RAM段的区别严谨地说把代码放到普通RAM段比如单独开辟一块__attribute__((section(.sram_code)))和放到系统栈上实现路径是有区别的。放到普通RAM段时这块RAM是独立的、静态分配的不参与函数调用时的压栈弹栈而放到系统栈上代码段的二进制数据和函数运行时的栈帧数据是共用同一片物理内存的。这个区别最直接的后果是系统栈顶、栈底的定义方式不同代码段的搬入位置必须精确落在栈区域内而且要保证搬入代码后剩余栈空间依然满足最深一次函数调用的栈帧需求。很多人在这上面翻车就是因为只算了代码大小没算运行时栈帧需求结果代码搬进去正常跑起来一调用深层函数就HardFault。另外还有一点栈是对齐要求比较高的内存区域Cortex-M架构要求栈指针按4字节对齐如果在某些带FPU的芯片上还要考虑8字节对齐。代码段里如果包含浮点指令或需要LDRD/STRD这类双字访问指令地址对齐不对就直接总线错误。所以搬运地址的分配必须做对齐处理不能随手取个栈中间地址就开始放。3. 实操以Cortex-M为例的完整实现流程3.1 工程准备与工具链选择我用的是ARM GCC工具链配合一个基于STM32F407的板子做验证因为F407有充足的RAM192KB方便做各种极端测试。工程结构很简单只包含三个关键文件linker.ld链接脚本需要手工修改。startup.s启动文件需要增加搬运代码。main.c业务代码验证重定向是否成功。如果你用的是Keil MDK或者IAR实现思路完全一致区别只是链接脚本语法和启动文件的写法不同。Keil里分散加载文件.sct可以用EXEC和LOAD区域描述实现类似效果IAR的.icf文件里用place in指令也能做到。我这里用GCC语法讲因为开源工具链的语法最直观而且嵌入式Linux环境下交叉编译也常用GCC。3.2 链接脚本的修改这是整个方案的地基我在链接脚本里定义了一个独立的输出段叫.stack_code专门用来放要被搬到栈上运行的代码。核心语法如下.stack_code (NOLOAD) : { . ALIGN(8); __stack_code_start__ .; *(.stack_code) __stack_code_end__ .; } RAM_STACK这里的RAM_STACK是在MEMORY区域里预先定义的一块内存区域。关键点是NOLOAD关键字它告诉链接器这段内容不需要在烧录时初始化为具体值它的初始内容由运行时启动代码负责填充。如果不加NOLOAD链接器可能会生成期望在Flash里预先烧录数据的段这在RAM区域就会报错或产生意外行为。接着栈区域的符号定义也要改。原本的栈通常长这样_estack ORIGIN(RAM) LENGTH(RAM);这是典型的栈顶定义栈向下生长。现在我要在栈的顶部附近预留一段放代码那么栈顶符号就应该减去代码段的预估大小。注意代码段预计多大需要在修改链接脚本之前就做到心里有数。我的做法是先编译一版不修改链接脚本的工程在map文件里找到目标函数的.text段大小比如app_code函数编译后占220字节那我就预留在栈顶下方256字节处开始放代码留了36字节对齐余量。修改后的栈区定义_estack ORIGIN(RAM) LENGTH(RAM); __stack_code_size__ 0x200; __stack_start__ _estack - __stack_code_size__; __stack_code_start__ __stack_start__;不过这个定义太硬编码不够优雅。更好的做法是让链接器自动计算先在MEMORY里划分一个独立区域RAM_STACK_CODE紧挨着RAM的顶部然后把__stack_code_start__设为该区域的起始地址栈顶符号调整为该区域的起始地址之前。这样代码段大小由实际编译结果决定不需要手动改数字。MEMORY { RAM (rwx) : ORIGIN 0x20000000, LENGTH 0x1FC00 RAM_STACK_CODE (rwx) : ORIGIN 0x2001FC00, LENGTH 0x400 } . 0x2001FC00; .stack_code (NOLOAD) : { __stack_code_start__ .; *(.stack_code) __stack_code_end__ .; } RAM_STACK_CODE这段的含义是把RAM的最高1KB划给代码段栈的可用空间从0x2001FC00开始向下生长栈顶实际是0x20020000。因为栈是从高到低生长所以栈顶依然是RAM最高地址但栈的有效起始区被压低了1KB。这才是栈上运行代码的精髓——代码段和栈帧共享这片RAM代码段占据高地址端栈帧从高地址向下延伸两者互不干扰但物理上是同一块内存。3.3 标记需要重定向的函数编译属性与函数修饰仅仅在链接脚本里划好区域是不够的还得告诉编译器把哪些函数放进这个段。GCC下用__attribute__((section(.stack_code)))实现。比如__attribute__((section(.stack_code))) int critical_update(void) { // 这段代码运行时会被搬到RAM栈区执行 // 假设这里做Flash擦写后的校验操作 }这里有个细节必须提如果你要重定向的是一整段关联代码比如func_a调用了func_b那么func_b也必须放进.stack_code段否则func_b还在Flash里PC跳过去照样取不到指令。更隐蔽的是如果func_a里内联了一个不在该段的函数编译器的内联优化可能会把Flash里的指令嵌进func_b的指令序列里导致搬运后执行时访问到Flash地址。解决这个问题有两个手段对涉及到的所有函数都加上section属性包括它们内部调用的辅助函数。给这些函数统一加上__attribute__((noinline))防止编译器做跨段内联。还有一种更省事的方式在链接脚本里直接把.text段里的一段地址区间原封不动映射到RAM地址上这属于段级别重定向对源码侵入最小。但这种方式要求被重定向的代码段不能有任何对其他Flash区域绝对地址的引用否则搬运后绝对地址失效。GCC编译时可以使用-fPIC或-fPIE生成位置无关代码来绕开这个问题但在MCU上-fPIC会显著增加代码体积和运行开销我一般只在bootloader这种对空间不敏感的场景用。3.4 启动代码从Flash搬运到栈上链接脚本只是把地址空间规划好了真正的搬运动作在启动代码里完成。我写了一段汇编放在复位异常处理程序里在调用main之前执行ldr r0, __stack_code_start__ ldr r1, __stack_code_load__ ldr r2, __stack_code_end__ subs r2, r2, r0 beq skip_copy copy_loop: ldrb r3, [r1], #1 strb r3, [r0], #1 subs r2, r2, #1 bne copy_loop skip_copy:注意这里有一个新符号__stack_code_load__这是我在链接脚本里额外定义的__stack_code_load__ LOADADDR(.stack_code);LOADADDR()函数返回的是段的加载地址也就是这段二进制在Flash里存放的位置。因为没有用NOLOAD链接器会在Flash里生成一段初始内容运行时从Flash拷贝到RAM段地址。这里就体现LMA和VMA分离的意义了代码体积220字节Flash里有一份220字节的备份RAM栈顶下方也有一份220字节的运行副本启动时完成搬运。如果追求效率可以把ldrb/strb换成ldr/str一次拷贝4字节但要注意对齐和末尾剩余字节的处理。我上面写的逐字节版本虽然慢但逻辑最清晰也便于读者理解。实际工程里我会用ldmia和stmia批量拷贝性能提升明显。另外搬运完成后建议执行一次缓存清理操作特别是带有D-Cache的芯片比如Cortex-A系列或部分Cortex-M7需要执行SCB_CleanDCache和SCB_InvalidateICache防止指令缓存里还保留着Flash上的旧指令。3.5 跳转控制如何让程序真正跑到栈上搬运完成后下一步就是让PC指到栈上代码的入口地址。这里有两种做法取决于重定向发生的时间。如果是在启动阶段就做重定向那直接在启动代码里跳转即可ldr r0, __stack_code_start__ blx r0blx会同时完成跳转和LR的设置跳到栈上代码入口后代码正常运行函数返回时LR是Cortex-M启动时默认的0xFFFFFFFF或之前设置的值需要谨慎处理。更稳妥的方式是在搬运完代码后直接维护一个函数指针typedef void (*pfunc)(void); pfunc app_entry (pfunc)(uint32_t)__stack_code_start__; app_entry();如果重定向发生在运行过程中比如用户程序运行到一半要切到RAM里执行某段敏感逻辑那就不需要重启直接调用放在.stack_code段的那个函数就行。链接器已经在函数符号和地址映射上做好了处理调用方生成bl指令时目标地址自然就是RAM地址。这是最优雅的用法——对上层业务零侵入。还需要注意一种情况如果重定向的是整个应用程序而非某个函数那就涉及中断向量表的重映射。Cortex-M的中断向量表默认在0x00000000很多芯片允许通过VTOR寄存器把它重映射到RAM中。这时不仅要把代码搬到栈上还要把整个向量表也复制到RAM里并设置SCB-VTOR 新地址。向量表在RAM里的位置可以和栈区代码放一起也可以单独划分一块区域但必须保证整个表按芯片要求的对齐粒度对齐Cortex-M一般是32字节对齐有些是64字节或256字节需要查具体芯片参考手册。4. 栈空间预算与安全性这是成败的关键4.1 先算一笔账栈还能不能装下代码很多人忽略这个问题觉得RAM那么大塞个几百字节代码不是轻轻松松。实测下来这种乐观会直接导致灾难——尤其是工程里原本就有深层递归或者大局部变量数组的搬了几百字节代码进栈后原来就紧绷的栈空间直接爆掉。怎么算这笔账三步走第一步统计栈的可用总空间。如果代码段占了1KB那栈的可用空间就是RAM总量减去静态数据区.data.bss再减去这1KB。第二步编译生成map文件找出所有函数的栈使用量之和的最大值。GCC可以用-fstack-usage编译参数生成每个函数的栈帧大小再配合调用关系深度就能算出理论最大栈深。注意还要加上中断嵌套的额外开销Cortex-M的中断压栈会一次性压入8个寄存器32字节。第三步给栈预留20%~30%的安全余量然后把代码段大小、栈帧最大需求、中断开销三者相加检查是否超出可用空间。我实际做过一个测算一个中等复杂度的STM32F407工程静态数据大约占了20KB代码段要搬1.2KB栈帧最大需求按静态分析是3KB中断嵌套最坏情况额外占用0.5KB安全余量按25%算最终需要栈空间约5.9KB而F407的RAM有192KB非常宽裕。但如果换到只有8KB RAM的小芯片同一套工程塞进去就会很紧张这时候必须考虑对代码段做瘦身或者改用普通RAM段单独分配空间。4.2 危险操作一递归与栈上运行的代码段叠加这是个隐蔽的大坑。如果你放到栈上运行的代码里恰好有递归函数哪怕是间接递归那递归的每一层栈帧都会挤压到代码段所在的栈区域一旦递归深度超出预算栈指针下探到代码段的数据区域就相当于代码在执行过程中自己踩碎了自己。轻则数据错乱重则取指异常。我的建议是放进.stack_code段的代码绝对禁止递归。如果业务上确实需要递归把递归部分拆出去留在Flash里执行只把外层关键逻辑放RAM。同时给这些函数都加上--param stack-usage...编译选项检查或者用半主机模式下的栈溢出检测工具在调试阶段提前暴露问题。4.3 危险操作二中断嵌套与栈指针切换Cortex-M内核默认使用的是主栈指针MSP中断响应时自动压栈到MSP。如果代码重定向到栈上运行中断照常触发压栈依然发生在栈顶位置——但此时栈顶下方就是代码段。如果恰好压在代码段附近的数据上中断返回后栈指针恢复正常但代码段的某些字节可能已经被中断现场覆盖了。这个问题怎么解我总结了几种策略把代码段放在栈的最顶端栈顶和代码段起始地址之间留足缓冲地带比如留一个链表结构或者若干调试魔数magic number。一旦运行过程中这些魔数被改写就能立刻知道栈溢出了。使用双栈模式把线程模式栈指针切到PSP中断代码继续用MSP。这样中断压栈发生在MSP所在区域和PSP里的代码段互不干扰。切换的方法是修改CONTROL寄存器的SPSEL位同时分别初始化MSP和PSP。最简单粗暴的关闭中断。但只适合极其短暂的临界区操作不适合长时间在栈上跑业务逻辑。我的工程里采用双栈模式实测稳定性和可维护性都很好。线程模式跑重定向代码用PSP中断继续用MSP栈溢出检测代码也只需要盯住PSP一个指针就够。4.4 与嵌入式非阻塞扫描等常见开发模式的适配一个在热词里频繁出现的方向是嵌入式按键非阻塞扫描这类时间片轮询架构。这种架构下主循环是一个超大的循环定时器中断做时间片管理各个任务穿插执行。如果在这种模式下做代码重定向一定要注意不能在某个任务函数正在执行、且该任务函数自身就在栈上运行的同时发生任务切换否则线程切换压栈会把正在执行的代码段覆盖掉。非阻塞架构里通常通过标志位和事件队列切换上下文不会像RTOS那样有真正的时间片切换风险相对可控。但如果你用的RTOS比如FreeRTOS每个任务有独立的栈那系统栈这个概念就要谨慎定义了——RTOS里每个任务栈都是独立的。把代码段放到某个任务栈上跑必须保证该任务栈的深度足够而且其他任务的切换不会污染这片区域。5. 常见问题与排查技巧实录5.1 问题一跳转到栈上代码后直接HardFault这是最常见的故障现场。排查顺序我按照经验概率排第一看地址对齐。确认__stack_code_start__的地址是否4字节对齐如果芯片支持FPU且代码里有浮点指令确认是否8字节对齐。不对齐就改链接脚本里的ALIGN。第二查搬运是否完整。在跳转前加一个软件断点或串口打印核对__stack_code_start__到__stack_code_end__长度的内容是否就是从Flash里读到的原始指令。很多时候是拷贝长度算错代码搬了一半就跳。第三看PC是否正确。上调试器看进入HardFault时PC寄存器的值如果PC指向RAM地址就说明跳转成功了问题出在代码本身的跨段引用上如果PC指向Flash地址或0地址说明函数指针传递有误。第四查编译优化。-O2以上优化级别可能生成与地址相关的优化假设尝试降到-O1或-Os对比测试。5.2 问题二重定向后的代码能跑但运行一段时间后随机崩溃这种间歇性故障最折磨人。我踩过的几次坑都和缓存有关。带I-Cache的芯片比如Cortex-M7在执行RAM代码之前必须做一次指令缓存失效操作否则CPU可能从缓存里取出Flash上的旧指令新旧代码混合执行。同时D-Cache也要谨慎如果搬运数据时写缓存但没回写到RAM跳转后读到的可能是脏数据。解决办法是在搬运结束后执行SCB_InvalidateICache(); SCB_CleanDCache();顺序不能反先清I-Cache后清D-Cache。另外如果芯片支持分支预测或指令预取Cortex-M33及以上跳转时最好加一个__DSB()和__ISB()屏障防止流水线里残留旧地址的预取指令。5.3 问题三链接时报错region RAM_STACK_CODE overflowed这说明代码段预估小了。我之前手工在MEMORY里划分1KB给.stack_code结果实际编译出来函数占1.3KB链接就报overflow。解决方法是先不加NOLOAD编译一版看map文件里.stack_code段的大小再回改MEMORY区域的LENGTH或者更优雅一点直接在MEMORY长度上留足够余量配合链接器的ASSERT在超限时报错。比如ASSERT(__stack_code_end__ __stack_code_start__ 0x400, stack code overflow)5.4 混淆点怎么验证代码真的在栈上跑了这其实是很重要的一步验证。我的做法是在重定向函数里加一个自检逻辑读取自身的地址__attribute__((section(.stack_code))) void self_check(void) { uint32_t addr (uint32_t)(uintptr_t)self_check; // 判断 addr 是否在 RAM_STACK_CODE 范围内 }如果addr落在0x2001FC00~0x20020000区间说明链接器正确地生成了RAM地址。再进一步在跳转入口处用调试器查看SP寄存器和PC寄存器的差值——正常情况下两者应该在同一片RAM区域内。如果SP是0x2001F000PC却是0x08001234那说明链接阶段VMA没生效函数还是指向Flash地址。5.5 与嵌入式八股文面试的关联这个知识点在嵌入式面试里被问到的概率不低尤其是大厂校招。常见的考法是先问你知道LMA和VMA的区别吗然后延伸到怎么让代码在RAM里运行再往上就是bootloader升级过程中Flash不可访问怎么处理。回答清楚链接脚本的AT指令、NOLOAD的语义、栈指针从MSP切换PSP的做法基本上就能压住这一题。细节上面试官喜欢抠的点包括为什么重定向之后要用__ISB()因为Cortex-M三级流水线里可能已经预取了旧地址的指令为什么中断向量表要重映射因为向量表本身也定义在Flash上中断触发后内核从Flash取向量如果Flash被擦写就废了。这些都答出来基本就是满分的水平。6. 更进一步的玩法链接脚本魔法与动态加载如果你把基础版本跑通了可以再往前走一步探索两个方向。第一个方向是代码段不固定运行时才决定加载位置。做法是在RAM_STACK_CODE区域预留一个足够大的缓冲比如4KB实际的代码段不通过链接脚本映射而是把编译好的二进制作为原始数据数组嵌入工程运行时由一段通用加载器把数据拷到栈指定位置再用函数指针跳转。这种方式适合做动态补丁和插件系统代价是需要自己管理符号的解析和重定位复杂度上升一个台阶。第二个方向是栈上运行和代码分层架构结合。嵌入式代码分层的经典做法是驱动层、中间层、应用层严格隔离层与层之间通过接口调用。在这种架构里把最底层的一两个关键驱动函数比如Flash扇区擦除函数重定向到栈上运行上层应用完全无感知这就是分层架构带给你最大的好处——重定向的位置互换不影响接口语义。我在做一个OTA升级项目时就是把Flash擦写函数单独放进.stack_code段整个应用层在升级过程中照常跑在Flash上只有最底层的几个Flash操作函数在RAM里运行这样升级过程中Flash擦写和主流程并发执行也不冲突。关于位置无关代码PIC也值得提一句。传统MCU场景很少用PIC因为开销大但如果你想在栈上运行的代码是可加载模块那位置无关是基本要求。GCC针对ARM有-fpic支持Cortex-M上可以使用但要注意线程局部存储TLS和全局偏移表GOT在MCU上缺乏标准支持实际用起来很麻烦。除非确有必要否则我建议还是走链接脚本静态映射的老路稳定、可控、调试方便。从我这多次实操的经验来看把代码重定向到系统栈上运行属于那种原理不复杂细节全是坑的方向。只要你把链接脚本的LMA/VMA关系吃透把栈空间预算算清楚把缓存和中断的问题提前布局好整个过程其实很顺手。但第一次做的人往往会折在栈空间预算和缓存清理这两个点上——我自己就折过不止一次。希望这篇拆解能让你少走几段弯路。
RELATED READING

延伸阅读

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