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、协议栈
中断服务函数 按最坏情况算 压栈+局部变量

我个人习惯用最坏情况分析法:

  1. 列出所有函数调用链,找出最深路径
  2. 统计每个函数的局部变量大小(注意数组和结构体)
  3. 加上中断压栈开销(8个寄存器×4字节 = 32字节,嵌套再乘)
  4. 最后留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、或者函数返回地址被篡改时,按这个流程排查:

  1. 确认是栈溢出:检查异常寄存器,看PC是否指向栈空间
  2. 启用硬件检测:设置MSPLIM/PSPLIM,让溢出直接触发异常
  3. 加软件水位:在关键任务入口/出口打印栈使用率
  4. 分析调用链:找出最深路径,确认是否有大局部变量或递归
  5. 调整栈大小:按最坏情况+30%余量重新分配

避坑指南:

  • 不要相信编译器的栈分析工具,它算的是静态路径,动态调用(函数指针、回调)算不准
  • 中断嵌套深度按最坏情况算,别假设「不会同时发生」
  • RTOS的任务栈和系统栈(MSP)要分开监控
堆栈溢出检测知识体系 堆栈溢出检测 MSP / PSP 双栈 栈空间计算 硬件栈溢出检测 软件水位监控 中断用MSP 任务用PSP 最坏路径法 30%余量 MSPLIM PSPLIM 填充模式 实战建议 1. 裸机项目:优先用硬件检测(MSPLIM),配合栈填充 2. RTOS项目:每个任务独立监控水位,系统栈用硬件检测 3. 调试阶段:打开所有检测,发布时至少保留硬件检测 4. 遇到HardFault:先查栈,再查指针,最后查外设

最后说一句:栈溢出是嵌入式开发中最常见的「软故障」之一。它不像语法错误那样编译时就报出来,而是跑着跑着突然崩溃。你想想看,一个函数调用链深了20层,中间再插个中断,栈就踩到隔壁任务的数据了。这种问题,靠肉眼读代码基本读不出来。

我个人习惯在项目初期就把栈检测框架搭好。虽然多花半小时,但后面省下的调试时间可能是几十倍。嗯,这账怎么算都划算。