4. 堆栈溢出检测:MSP与PSP栈指针、栈空间计算、硬件栈溢出检测、软件栈水位监控
堆栈溢出,说白了就是程序跑飞前最后的温柔。我见过太多工程师,花了两周调一个随机死机的问题,最后发现只是栈空间少了64字节。嗯,这一章咱们就把栈这件事彻底聊透。
4.1 MSP与PSP:双栈指针的工作机制
STM32的Cortex-M内核有两个栈指针:MSP(主栈指针)和PSP(进程栈指针)。很多人刚接触时觉得这玩意儿多余,其实不然。
简单说:
MSP用于处理异常和中断,PSP用于跑任务代码。这样设计的好处是——中断嵌套再深,也不会踩到任务栈里的局部变量。
核心区别:
- MSP:复位后默认使用,处理异常/中断时自动切换
- PSP:需要软件主动设置,通常用于RTOS的任务栈
- 切换时机:进入异常时硬件自动压栈到当前栈指针,异常返回时自动恢复
我在项目中遇到过一个问题:一个FreeRTOS任务里调用了HAL_Delay(),结果系统随机死机。查了两天,发现是任务栈太小,中断嵌套时MSP和PSP互相干扰。后来把MSP空间从512字节加到1024,问题消失。
4.2 栈空间计算:别靠猜
栈空间怎么算?我见过有人拍脑袋给个512字节,也有人直接上8KB。其实有章可循。
| 场景 | 推荐栈大小 | 说明 |
|---|---|---|
| 裸机主循环 | 512 ~ 1024 字节 | 无RTOS,中断少 |
| 裸机+多中断 | 1024 ~ 2048 字节 | 考虑嵌套深度 |
| RTOS任务(简单) | 256 ~ 512 字节 | 无大局部数组 |
| RTOS任务(复杂) | 1024 ~ 2048 字节 | 含printf、协议栈 |
| 中断服务函数 | 按最坏情况算 | 压栈+局部变量 |
我个人习惯用最坏情况分析法:
- 列出所有函数调用链,找出最深路径
- 统计每个函数的局部变量大小(注意数组和结构体)
- 加上中断压栈开销(8个寄存器×4字节 = 32字节,嵌套再乘)
- 最后留30%余量
小技巧:在链接脚本里把栈放在SRAM末尾,栈溢出时会先踩到未使用的内存区域,更容易触发硬件异常。
4.3 硬件栈溢出检测:Cortex-M自带的保护机制
Cortex-M3/M4/M7内置了栈溢出检测功能,但很多人没开。说白了就是两个寄存器的事:
- MSPLIM:MSP的栈底限制寄存器
- PSPLIM:PSP的栈底限制寄存器
当栈指针低于限制寄存器时,硬件自动触发UsageFault异常。你可以在异常处理函数里打印栈信息,定位到是哪个任务出了问题。
// 使能MSP栈溢出检测
__set_MSPLIM((uint32_t)&_estack - 1024); // 预留1024字节
// 使能PSP栈溢出检测(RTOS任务栈)
__set_PSPLIM((uint32_t)task_stack_bottom);
// 在HardFault_Handler中读取栈指针
void HardFault_Handler(void) {
uint32_t msp = __get_MSP();
uint32_t psp = __get_PSP();
uint32_t msplim = __get_MSPLIM();
uint32_t psplim = __get_PSPLIM();
// 判断哪个栈溢出了
if (msp < msplim) {
// MSP溢出
}
if (psp < psplim) {
// PSP溢出
}
while(1);
}
注意:MSPLIM和PSPLIM只在Cortex-M3 r2p0及以上版本支持。老版本芯片(如早期STM32F1)没有这两个寄存器,需要用软件方法。
4.4 软件栈水位监控:给栈装个水位计
硬件检测只能告诉你「已经溢出了」,但很多时候我们想知道「快溢出了没」。这就需要软件水位监控。
原理很简单:在栈空间初始化时,用特定模式填充整个栈区(比如0xDEADBEEF或0xAAAAAAAA)。然后定期扫描,看有多少字节被覆盖了。
// 栈初始化填充
#define STACK_FILL_PATTERN 0xDEADBEEF
void Stack_Init(uint32_t *stack_start, uint32_t size) {
for (uint32_t i = 0; i < size / 4; i++) {
stack_start[i] = STACK_FILL_PATTERN;
}
}
// 获取栈使用水位
uint32_t Stack_GetUsage(uint32_t *stack_start, uint32_t size) {
uint32_t used = 0;
for (uint32_t i = 0; i < size / 4; i++) {
if (stack_start[i] != STACK_FILL_PATTERN) {
used = size - i * 4;
break;
}
}
return used;
}
// 打印栈使用率
void Stack_PrintUsage(const char *name, uint32_t *stack_start, uint32_t size) {
uint32_t used = Stack_GetUsage(stack_start, size);
printf("[%s] 使用: %d / %d 字节 (%.1f%%)\n",
name, used, size, (float)used / size * 100.0f);
}
我曾经在一个项目中用这个方法发现,某个任务的栈使用率在特定操作下从40%飙到95%。排查后发现是一个递归函数没有终止条件。嗯,这种bug用硬件检测根本抓不到,因为还没溢出,但已经很不安全了。
4.5 实战:栈溢出定位流程
当系统出现随机死机、HardFault、或者函数返回地址被篡改时,按这个流程排查:
- 确认是栈溢出:检查异常寄存器,看PC是否指向栈空间
- 启用硬件检测:设置MSPLIM/PSPLIM,让溢出直接触发异常
- 加软件水位:在关键任务入口/出口打印栈使用率
- 分析调用链:找出最深路径,确认是否有大局部变量或递归
- 调整栈大小:按最坏情况+30%余量重新分配
避坑指南:
- 不要相信编译器的栈分析工具,它算的是静态路径,动态调用(函数指针、回调)算不准
- 中断嵌套深度按最坏情况算,别假设「不会同时发生」
- RTOS的任务栈和系统栈(MSP)要分开监控
最后说一句:栈溢出是嵌入式开发中最常见的「软故障」之一。它不像语法错误那样编译时就报出来,而是跑着跑着突然崩溃。你想想看,一个函数调用链深了20层,中间再插个中断,栈就踩到隔壁任务的数据了。这种问题,靠肉眼读代码基本读不出来。
我个人习惯在项目初期就把栈检测框架搭好。虽然多花半小时,但后面省下的调试时间可能是几十倍。嗯,这账怎么算都划算。