资讯动态

STM32栈溢出排查与栈空间估算:从原理到防护

发布时间:2026/10/17 9:41:47 来源:尧图企业网站定制
1. 为什么说栈溢出是嵌入式开发里最阴险的Bug搞了几年单片机开发的人大概率都遇到过这样的场景设备运行几个小时一切正常然后某一天突然无规律复位或者一个函数看着逻辑没问题传进去的参数却莫名其妙变成脏数据再或者程序一旦开启某个中断整个系统就像喝了假酒一样行为失控。第一反应往往是怀疑硬件电路、晶振稳定性、电源纹波一顿排查下来毫无收获。最后实在没辙了打开调试器看反汇编才发现函数返回地址都被覆盖了——栈空间不够程序把栈用穿写到了别的地方去。栈溢出之所以难查是因为它几乎没有固定的“症状”。它不像空指针那样一崩就死不像死循环那样能卡住看门狗它更像一种慢性病数据被悄悄篡改现场被悄悄破坏直到某个临界点才爆发一次HardFault。这个特征决定了靠“出问题再查”的思路去对付栈溢出效率极低。真正靠谱的做法是在设计阶段就把栈空间的分布算清楚把防御机制提前埋进去。这篇文章要聊的就是这件事STM32以及同类Cortex-M内核芯片的栈空间到底是怎么分布的什么东西在消耗栈怎么估算一个合理的栈大小以及栈溢出的检测和预防手段。适合被栈溢出折磨过、想系统搞懂栈原理、或者正在设计新项目的开发者。尤其是从裸机转向RTOS的开发者栈空间的理解深度直接决定你能不能写出长期稳定运行的程序。2. ARM Cortex-M系列内核的栈机制到底是怎么运作的2.1 从一句“满递减栈”说起的底层逻辑Cortex-M内核的栈是满递减栈Full Descending Stack这句话如果只是背下来没什么用。拆开看才有意义“满”的意思是栈指针SP始终指向最后压入的那个数据项也就是说SP指向的位置是“已经有人住”的。对应的还有“空栈”SP指向的是下一个空闲位置Cortex-M不玩这种。“递减”的意思是压栈时SP向低地址方向移动出栈时SP向高地址方向回退。所以一次压栈的完整过程是先把SP减432位内核然后把要保存的数据写入SP现在指向的地址。在Cortex-M里压栈指令相当于STR到[SP, #-4]!出栈对应LDR [SP], #4。很多初学者在这里会有一个误区以为栈在RAM的最高地址SP指向RAM顶端。这个说对了一半。栈的“顶端”确实是RAM区域的结束地址但那是因为启动文件把栈顶地址初始化为RAM末尾栈向下生长。如果RAM被分成了不同段栈可能在某个段的末尾不会跑到其他段的末尾去。与x86栈的一个关键区别x86的栈也是满递减但x86的栈空间由操作系统管理每个线程有独立栈溢出会触发段错误或者守护程序保护。而STM32裸机环境下整个系统就一个栈用MSP所有中断、所有函数调用共享这一块空间。没有“栈溢出自动报错”这种机制溢出了就是静默式数据破坏。2.2 MSP与PSP两套栈指针的分工与切换Cortex-M内核物理上有两个栈指针MSP主栈指针和PSP进程栈指针但同一时刻只有一个栈指针生效通过CONTROL寄存器的bit[1]来选择。在裸机程序中全程使用MSP所有中断和所有线程代码都靠同一块栈区。在RTOS中每个任务有自己独立的栈任务运行时用的是PSP一旦触发中断或异常硬件自动切回MSP中断处理函数使用主栈也叫系统栈或中断栈。这个“自动切换”是硬件行为不用软件干预。但这也引出一个很多RTOS新手踩过的坑系统栈MSP使用的栈必须预留足够空间容纳最坏情况下的中断嵌套现场。如果只算任务栈把系统栈设得特别小——比如任务栈给2KB系统栈给256字节——一旦压栈深度大一点的中断嵌套同时发生主栈照样溢出表现比任务栈溢出还要诡异因为线程现场在PSP里还好好的崩溃的是中断现场。顺带说一嘴Cortex-M3/M4的中断压栈是由硬件完成的进入中断时硬件自动把xPSR、PC、LR、R12、R3、R2、R1、R0这8个寄存器压入当前栈。如果是MSP和PSP切换的场景压栈压的是PSP被中断的任务栈而进入中断处理函数后使用的栈是MSP。8个寄存器就是32字节看起来不多但中断嵌套一层再加上中断函数自身的局部变量累积起来就很可观了。2.3 启动文件与链接脚本里的栈定义位置先看一个典型的启动汇编片段以Cortex-M3/M4为例具体文件名因工程而异Stack_Size EQU 0x00000800 AREA STACK, NOINIT, READWRITE, ALIGN3 Stack_Mem SPACE Stack_Size __initial_spStack_Size EQU 0x00000800就是栈大小这里等于2KB。想调整裸机栈大小最直接的就是改这个数字。Stack_Mem SPACE Stack_Size在RAM区域划出这么一块连续空间。__initial_sp是这个栈区的结束地址启动代码里会把它赋给SP寄存器。再看链接脚本GCC工程里的.ld文件中与栈相关的部分通常是这样的结构_estack ORIGIN(RAM) LENGTH(RAM); /* 栈顶指向RAM末尾 */ /* 内存布局 */ MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 512K RAM (xrw) : ORIGIN 0x20000000, LENGTH 128K } /* 栈符号 */ .stack : { . ALIGN(8); __StackLimit .; . . 0x800; /* 栈大小2KB */ __StackTop .; } RAM这两套方案背后是同一个物理事实栈就是RAM里预留的一块地址连续的区域从上往下长。启动时SP初始化指向栈区最高地址程序运行后SP一路往下压。栈区与其它RAM数据区全局变量、堆之间没有任何硬件隔离栈一旦穿透底界就直接写进了相邻的变量区域。3. 算清楚谁在消耗栈空间四类主要来源3.1 函数调用与自动局部变量最基础的消耗每次进入一个函数会往栈里压三类东西函数返回地址LR的值函数结束时恢复到PC被调用者保存寄存器按AAPCS调用约定函数体内如果用到了R4-R11这些寄存器必须在使用前压栈保护退出前恢复局部变量和临时变量在函数作用域内占用栈空间。局部变量是栈消耗的大头尤其是大数组和结构体变量。一个临界区里的函数定义了uint8_t buf[256]这一个局部数组就吃掉256字节。如果这个函数又被中断打断中断处理函数里又有自己的局部数组栈空间瞬间叠加。一个需要特别留意的小知识虽然函数参数通常通过R0-R3传递但是当参数个数超过4个或者参数类型是结构体这类大于32位对齐需求的数据时剩余参数会放到栈上传递。同时函数内部如果编译器优化级别不高某些中间计算结果也可能被临时压栈。3.2 中断嵌套栈空间的最强“突发刺客”中断对栈的影响可以从两个层次理解。第一层是硬件自动压栈每次中断触发硬件压入8个寄存器32字节如果频繁进中断这部分是固定开销。第二层是中断服务函数自身的栈消耗ISR代码里定义的局部变量、调用的子函数都会压在栈上。如果是可嵌套中断高优先级中断可以打断低优先级中断此时栈消耗是一层套一层的。举个例子来说明叠加效应假设主程序里调用了函数AA调用BB内部有个局部数组占100字节这时栈深度可能在200字节左右。此时来了一个优先级稍高的中断硬件压栈32字节ISR内部又调用了一个函数CC有个80字节的局部数组。那么在B执行期间被中断打断的那一刻栈消耗总量就是“主程序的200字节”加上“中断现场32字节”加上“ISR自身函数C的100字节”合计超过330字节。如果再发生一次嵌套数字会继续翻倍。这种突发性正是“栈大小拍脑袋定”风险的根本来源。平时看过去程序跑得好好的是因为没有出现最坏情况的中断组合。哪天两个中断嵌套加一个最深函数调用路径同时出现栈就穿了。3.3 异常与HardFault处理现场最后一根稻草除了普通中断系统异常如HardFault、UsageFault也会压栈并切换到异常处理流程。如果错误处理函数里用了较多栈空间或者进入了错误恢复流程而栈空间已经濒临极限异常处理反而会成为压垮栈的最后一次操作。很多人会在HardFault_Handler里做错误诊断比如把栈上的寄存器现场打印出来。这个思路没错但要注意在栈已经极浅的时候HardFault_Handler本身的函数调用和局部缓冲区可能会再次触发栈越界导致诊断代码都跑不完。所以HardFault_Handler里尽量避免大局部数组能用全局变量就用全局变量。3.4 RTOS场景下的双栈结构任务栈与系统栈必须分开算如果用了FreeRTOS、RT-Thread这类操作系统栈消耗结构会变成每个任务栈使用PSP包含任务自身的函数调用深度、自动变量、以及被中断打断时硬件压栈的那32字节在抢占式内核中任务被中断打断时现场压到任务栈上。系统栈使用MSP包含所有中断嵌套的现场、中断服务函数及其中调用的子函数消耗。RTOS里任务栈大小的估算最常用的方法是先粗后精先按任务中最大调用链路径估算一个偏大的值跑起来后用栈水位检测后面会讲看看真实用量再逐步收敛。系统栈则可以参考“最高级中断ISR的最大消耗 嵌套层数 × 每次硬件压栈和ISR额外消耗”来算。有一个现象值得注意有些RTOS任务栈溢出时表现不是HardFault而是任务静默崩溃——因为任务的返回地址被破坏后调度器切换上下文时会把一个畸形现场加载到寄存器里轻则跑飞重则触发MemManage Fault。排查起来比裸机还要痛苦。4. 三种栈空间估算方法从拍脑袋到心中有数4.1 静态调用链分析法Excel手算的最高深度最原始但也最直观的方法是把所有可能发生调用的路径列出来找出最深的调用链逐级累加栈消耗。考虑一个简单的裸机示例main() 调用 process_data()process_data() 内部定义一个uint8_t temp[128]并调用 compute()compute() 内部定义一个结构体Config cfg约40字节然后调用 find_max()find_max() 内部定义两个局部变量和一个临时数组约64字节。同时存在两个中断TIM2中断服务函数调用timer_callback()内部有个32字节局部数组UART中断服务函数本身有48字节局部数组其中UART中断优先级比TIM2高可嵌套。主程序最深路径估算层级函数本层栈消耗含局部变量、返回地址、寄存器保护等估约第1层main 调用 process_data约16字节第2层process_data局部数组128 调用compute的参数与返回地址等约16 144字节第3层compute结构体40 局部变量约12 调用find_max开销约16 68字节第4层find_max数组64 临时变量约20 84字节合计312字节再考虑中断嵌套的最坏情况假设最坏发生在第4层执行期间先被TIM2打断现场32字节ISR内部约50字节TIM2执行期间又被UART打断现场32字节ISR内部约64字节则额外消耗 32503264 178字节。于是栈的最低需求 ≈ 312 178 ≈ 490字节。考虑一定的余量系数一般乘1.5到22KB栈对这个程序已经相当稳妥1KB也勉强够用。但如果把某个局部数组改成512字节这个估算就会迅速逼近崩溃值——这就是“合理设置栈空间”的意义所在。这种方法的优点是零工具依赖缺点是容易漏算比如编译器临时变量、库函数内部栈消耗、浮点上下文保存等所以最终建议在估算结果上乘以1.5~2的安全系数。4.2 Map文件与编译器报告让数据替你算账比纯手算更靠谱的是让编译器把栈用量直接报告出来。GCC/arm-none-eabi-gcc 系编译时加-fstack-usage每个汇编对应的源文件旁边会生成.su文件里面记录该函数的最大栈消耗单位字节。例如process_data.c:12 process_data 168 static compute.c:5 compute 76 static然后你把调用链上各函数的栈消耗加起来得到路径总消耗。配合-Wstack-usage512某个函数一旦栈消耗超过512字节编译器直接警告。Keil MDK 系在Options for Target → Listing 里勾选“Map file”相关选项编译后生成的.map文件中会显示每个函数的栈使用情况需要勾选“Stack usage”输出。IAR EWARM 系需要在项目选项里开启Stack usage analysis链接器报告里会列出函数栈深度信息。用工具报告还有一个好处能直接观察到编译器优化级别对栈的影响。常见的情况是开-O2优化后某些函数的栈消耗比-O0小一半以上因为很多中间变量被塞进寄存器而不是压栈了。这也是“同一个工程换个编译优化级别栈需求就变了”的原因。4.3 动态水位测量法跑出来的真实栈深度静态估算再精细也不如实际测一次来得可信。动态测量的核心思路初始化时把整个栈区域填成固定图案比如0xDEADBEEF或0xA5A5A5A5程序跑一段时间后从栈底向栈顶扫描找到最后一个图案未被破坏的位置就能算出历史最大栈深度。一个极简实现如下适用于裸机场景#define STACK_PATTERN 0xA5A5A5A5U void stack_fill(uint32_t start_addr, uint32_t end_addr) { uint32_t *p (uint32_t *)start_addr; while ((uint32_t)p end_addr) { *p STACK_PATTERN; } } uint32_t stack_usage(uint32_t start_addr, uint32_t end_addr) { uint32_t *p (uint32_t *)start_addr; while ((uint32_t)p end_addr *p STACK_PATTERN) { p; } return (uint32_t)p - start_addr; }调用方式系统初始化时用stack_fill(__StackLimit, __StackTop)填充整个栈区程序长时间运行后定期调用stack_usage(__StackLimit, __StackTop)得到的结果就是从栈底起“已被破坏”的字节数也就是历史上曾经到达过的最大栈深度。这个值再乘以1.5或2作为安全余量就是一份有实测数据支撑的栈大小配置。注意一个细节要保证程序在“长时间运行 尽量多的中断组合”之后读取水位才有代表性。只跑个上电初始化就去读数得出的一定是乐观值。在RTOS环境下FreeRTOS的uxTaskGetStackHighWaterMark就是干这个的它返回任务栈自创建以来的最小剩余字节数用了同样的填充图案检测原理。RT-Thread也有类似的rt_thread_control(thread, RT_THREAD_CTRL_STACK_GET_MAX_USAGE)。5. 一次真实排查设备运行几小时后随机复位背后是2KB栈的水位穿底5.1 症状与初步排查越混乱的现场越指向栈某次项目调试中出现这样的现象设备在上电后的前两个小时一切正常之后开始随机复位。抓日志发现复位原因是HardFault但HardFault发生的位置每次都不一样有时在串口中断里有时在数据处理函数里有时甚至在看门狗喂狗函数里。这组症状有两个关键特征一是HardFault位置飘忽不定二是崩溃前的运行时间有随机性。正常的逻辑漏洞通常集中在固定位置而栈溢出导致的破坏位置取决于那一刻的栈深度峰值当然会到处乱跑。分析思路分两步先确认是不是栈溢出再定位哪个环节的栈消耗异常偏大。5.2 用填充图案定位水位线把栈从底到顶全部填入0xDEADBEEF然后让设备完整跑一遍“两小时正常一小时随机崩溃”的流程。在HardFault_Handler里加一段现场扫描代码把从栈底开始连续一段未被破坏的地址范围记录到静态变量里复位后通过调试器读出来。结果很直观栈顶指针指向栈区的较高位置没变但从栈底向上大约有1400字节的数据已经被改写了。而设计时栈大小配置为2KB0x800理论上还有600多字节的空余——但这只是理论的“最大深度”假象因为动态测量显示最深时已经穿到接近栈底位置。也就是说最坏情况下栈深度已经超过了2KB或者至少逼近了临界值。进一步用.su文件查函数栈消耗发现一个关键嫌疑某个被中断调用的处理函数里定义了一个uint8_t rx_buf[512]的局部数组。这个数组在初始化时只填了不到一半但声明就占了512字节。这个函数恰好由UART中断触发而UART中断又是可嵌套的。当主程序正好跑在另一个深层调用链约800字节上UART中断一来512字节局部数组叠加稍加嵌套就突破2KB。5.3 修复与复盘让数据说话比猜更有效修复方案并不复杂把那个局部数组从512字节降到128字节数据分批处理而不是一次性接收完再处理把中断嵌套的开关策略调整了一下UART中断里关闭低优先级中断嵌套减少突发叠加栈大小从2KB增加到4KB作为多出来的安全余量。改完后同样跑了一周水位线稳定在约2.2KB没有再发生复位。这个案例最有价值的结论是仅凭“看似够用”的栈大小和“看起来正常”的运行状态判断栈是否够用是不可靠的。没有水位检测的栈永远像一个不知道什么时候会漏水的池子。6. 栈溢出的多层防线从硬件保护到代码规范6.1 Cortex-M内核自带的花式保护机制你用了几个MPU内存保护单元很多开发者把MPU当成“高级功能”实际它是防栈溢出最硬的一道防线。可以为栈区域配置一个MPU子区域或独立区域属性设为“不可写”并在栈操作时切换MPU上下文。当栈指针越过界限访问到保护区立即触发MemManage Fault。相比等数据被破坏后暴露问题MPU能在越界的第一瞬间就抓住它。代价是配置稍有复杂度而且在RTOS多任务场景下每个任务的栈区域都要动态切换MPU设置实现成本偏高。Cortex-M23/M33的栈限制寄存器较新的Cortex-M内核增加了MSPLIM和PSPLIM寄存器硬件直接监控栈指针是否越界越界即触发异常。这类内核天然带防线设置时只需要把限制地址写入对应寄存器。如果项目选型尚未定且对可靠性要求高优先考虑带栈限制寄存器的新内核。编译器的栈保护选项GCC的-fstack-protector-strong会在函数入口放置canary值在函数退出前校验是否被篡改。一旦发现栈被改立即调用__stack_chk_fail。代价是代码体积和性能稍有增加但对排查阶段帮助很大常用于测试固件而非最终发布版本。6.2 把“栈检查”固化到开发流程中设置合理的栈空间不应是发布前的一次性动作而应该变成开发流程的一环。个人实践下来有这么几条经验值得参考每个里程碑版本都跑一次栈水位检测功能一多调用链和中断组合就会变化上个月的栈深度不代表这个月还够用。测试用例里专门设计“最坏中断组合”场景让所有可嵌套中断在同一个时间窗口内连续触发人为制造最大压栈深度。这种方式比随机运行更高效能更快暴露问题。局部数组超过256字节时主动审查大数组是栈消耗的主要来源一旦出现在可变调用链上就要重新评估栈配置。能用静态/全局数组或动态内存替代的场景优先替代。HardFault处理函数保持极简只记录现场、保存关键寄存器到全局变量、然后复位不大动干戈做复杂处理避免在栈已濒危时雪上加霜。6.3 一个可以立刻用起来的栈检查代码骨架最后给一个配合水位检测使用的裸机定时检查骨架可以作为正式项目里“栈健康守护”的起点/* 每1秒检查一次栈水位超过阈值则记录并告警 */ #define STACK_WARN_THRESHOLD (STACK_SIZE * 80 / 100) /* 80%告警线 */ uint32_t stack_used 0; void stack_monitor_task(void) { stack_used stack_usage((uint32_t)__StackLimit, (uint32_t)__StackTop); if (stack_used STACK_WARN_THRESHOLD) { log_error(Stack warning: used%u, limit%u, stack_used, STACK_WARN_THRESHOLD); /* 可以在这里采集调用栈信息或者做进一步诊断 */ } }在栈填充函数里用__StackLimit和__StackTop这两个链接器自动生成的符号来界定范围比自己写死地址更稳妥因为栈位置随链接脚本变化时无需改代码。这个骨架跑在定时器或者RTOS的低优先级任务里即可开销很小。我一直觉得“防止栈溢出”和“合理设置栈空间”这两件事本质上是一件事的两个阶段设置之前要算清楚分布设置之后要靠水位监测验证。真正稳定的固件不是靠一次估算定终身的而是靠持续的监测和约束养成的习惯。如果你正在设计新项目建议从第一天就把栈水位检测代码加进去它不会花你太多时间但会在某个深夜帮你省掉好几个小时的痛苦排查。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑