ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

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

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

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减4(32位内核),然后把要保存的数据写入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, ALIGN=3 Stack_Mem SPACE Stack_Size __initial_sp
  • Stack_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 函数调用与自动局部变量:最基础的消耗

每次进入一个函数,会往栈里压三类东西:

  1. 函数返回地址(LR的值),函数结束时恢复到PC;
  2. 被调用者保存寄存器,按AAPCS调用约定,函数体内如果用到了R4-R11这些寄存器,必须在使用前压栈保护,退出前恢复;
  3. 局部变量和临时变量,在函数作用域内占用栈空间。

局部变量是栈消耗的大头,尤其是大数组和结构体变量。一个临界区里的函数定义了uint8_t buf[256],这一个局部数组就吃掉256字节。如果这个函数又被中断打断,中断处理函数里又有自己的局部数组,栈空间瞬间叠加。

一个需要特别留意的小知识:虽然函数参数通常通过R0-R3传递,但是当参数个数超过4个,或者参数类型是结构体这类大于32位对齐需求的数据时,剩余参数会放到栈上传递。同时,函数内部如果编译器优化级别不高,某些中间计算结果也可能被临时压栈。

3.2 中断嵌套:栈空间的最强“突发刺客”

中断对栈的影响,可以从两个层次理解。

第一层是硬件自动压栈:每次中断触发,硬件压入8个寄存器(32字节),如果频繁进中断,这部分是固定开销。

第二层是中断服务函数自身的栈消耗:ISR代码里定义的局部变量、调用的子函数,都会压在栈上。如果是可嵌套中断,高优先级中断可以打断低优先级中断,此时栈消耗是一层套一层的。

举个例子来说明叠加效应:假设主程序里调用了函数A,A调用B,B内部有个局部数组占100字节,这时栈深度可能在200字节左右。此时来了一个优先级稍高的中断,硬件压栈32字节,ISR内部又调用了一个函数C,C有个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字节),则额外消耗 = 32+50+32+64 = 178字节。

于是栈的最低需求 ≈ 312 + 178 ≈ 490字节。考虑一定的余量系数(一般乘1.5到2),2KB栈对这个程序已经相当稳妥,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-usage=512,某个函数一旦栈消耗超过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字节的数据已经被改写了。而设计时栈大小配置为2KB(0x800),理论上还有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 把“栈检查”固化到开发流程中

设置合理的栈空间,不应是发布前的一次性动作,而应该变成开发流程的一环。个人实践下来,有这么几条经验值得参考:

  1. 每个里程碑版本都跑一次栈水位检测:功能一多,调用链和中断组合就会变化,上个月的栈深度不代表这个月还够用。
  2. 测试用例里专门设计“最坏中断组合”场景:让所有可嵌套中断在同一个时间窗口内连续触发,人为制造最大压栈深度。这种方式比随机运行更高效,能更快暴露问题。
  3. 局部数组超过256字节时主动审查:大数组是栈消耗的主要来源,一旦出现在可变调用链上,就要重新评估栈配置。能用静态/全局数组或动态内存替代的场景优先替代。
  4. 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的低优先级任务里即可,开销很小。

我一直觉得,“防止栈溢出”和“合理设置栈空间”这两件事,本质上是一件事的两个阶段:设置之前要算清楚分布,设置之后要靠水位监测验证。真正稳定的固件不是靠一次估算定终身的,而是靠持续的监测和约束养成的习惯。如果你正在设计新项目,建议从第一天就把栈水位检测代码加进去,它不会花你太多时间,但会在某个深夜帮你省掉好几个小时的痛苦排查。

RELATED READING

延伸阅读

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