从裸机到 FreeRTOS 的栈溢出排查:用 PSP 限位与填充字定位任务栈峰值

1. 为什么裸机时代很少遇到栈溢出?

裸机程序通常只有一个主栈(MSP),链接脚本分配的栈空间往往远大于实际需求,加上函数调用深度有限,栈溢出很少暴露。但迁移到 FreeRTOS 后,每个任务拥有独立的栈空间,由 xTaskCreate 指定大小。若任务内局部变量过大、递归过深或调用链过长,就会悄悄踩坏相邻内存,导致 HardFault、数据错乱甚至随机死机。

2. Cortex-M 的双栈指针与 PSP 限位原理

Cortex-M 内核有两个栈指针:

  • MSP(主栈指针):用于中断和异常处理。
  • PSP(进程栈指针):用于任务上下文。FreeRTOS 在任务切换时把 PSP 指向当前任务的栈顶。

每个任务的栈是一段连续 RAM,栈从高地址向低地址增长。FreeRTOS 在创建任务时会用填充字(默认 0xA5)初始化整个栈区域,并在栈底(最低地址)放置一个已知值用于检测溢出。

PSP 限位的核心思想:在任务切换或调试时读取当前 PSP,与任务栈的最低合法地址比较。若 PSP 低于该地址,说明栈已溢出。同时,通过扫描填充字被改写的最高位置,可以计算出任务实际使用的栈峰值(High Water Mark)。

3. 配置步骤:开启栈溢出检测与填充字

在 FreeRTOSConfig.h 中配置以下宏:

#define configCHECK_FOR_STACK_OVERFLOW  2   // 方法2:检查栈末尾的填充字
#define configUSE_TRACE_FACILITY        1   // 启用任务状态查询
#define configUSE_STATS_FORMATTING_FUNCTIONS 1
  • configCHECK_FOR_STACK_OVERFLOW 设为 2 时,FreeRTOS 在任务切换时检查栈末尾的 16 字节是否仍为填充值。
  • 若被改写,会调用 vApplicationStackOverflowHook,我们可以在其中记录出错任务。

4. 完整代码示例:填充字扫描与 PSP 限位

4.1 栈溢出钩子函数

void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName)
{
    (void)xTask;
    // 打印任务名,点亮错误灯,或进入死循环等待调试
    printf("Stack overflow in task: %s\n", pcTaskName);
    for(;;);
}

4.2 手动扫描填充字,计算栈峰值

FreeRTOS 提供了 uxTaskGetStackHighWaterMark(),但它返回的是剩余最小空间(以字为单位)。为了更直观地定位峰值,我们可以直接扫描填充字。

#include "FreeRTOS.h"
#include "task.h"

#define STACK_FILL_PATTERN  0xA5A5A5A5UL

// 返回任务实际使用的栈深度(单位:字)
uint32_t GetTaskStackPeak(TaskHandle_t xTask)
{
    TCBT_t *pxTCB = (TCB_t *)xTask;
    StackType_t *pStack = pxTCB->pxStack;          // 栈底(最低地址)
    StackType_t *pTop   = pxTCB->pxTopOfStack;     // 当前栈顶
    uint32_t used = 0;

    // 从栈底向高地址扫描,直到遇到非填充字
    while (pStack < pTop && *pStack == STACK_FILL_PATTERN) {
        pStack++;
        used++;
    }
    // 返回被使用的字数(从栈底到第一个非填充字)
    return (pTop - pxTCB->pxStack) - used;
}

4.3 在任务中打印峰值

void vMonitorTask(void *pvParameters)
{
    TaskHandle_t xTarget = (TaskHandle_t)pvParameters;
    for (;;) {
        uint32_t peak = GetTaskStackPeak(xTarget);
        printf("Task stack peak: %lu words\n", peak);
        vTaskDelay(pdMS_TO_TICKS(5000));
    }
}

4.4 PSP 限位检查(调试用)

在调试器中或通过内联汇编读取 PSP,与任务栈底比较:

__attribute__((always_inline)) static inline uint32_t get_psp(void)
{
    uint32_t psp;
    __asm volatile ("MRS %0, psp" : "=r" (psp));
    return psp;
}

void CheckPSPLimit(TaskHandle_t xTask)
{
    TCB_t *pxTCB = (TCB_t *)xTask;
    uint32_t psp = get_psp();
    uint32_t stack_bottom = (uint32_t)pxTCB->pxStack;
    if (psp < stack_bottom) {
        printf("PSP below stack bottom! Overflow!\n");
    }
}

5. 注意事项与最佳实践

  • 填充字扫描需在任务运行时进行:任务被删除或挂起后栈内容可能变化。
  • configCHECK_FOR_STACK_OVERFLOW 有性能开销:方法2每次任务切换检查16字节,对实时性要求极高的场景可仅在调试版开启。
  • 栈峰值不等于安全余量:建议保留至少 20%~30% 的余量,因为中断嵌套、库函数调用可能临时增加栈使用。
  • 中断使用 MSP:若中断服务程序调用深度大,需单独检查 MSP 栈,不要与任务栈混淆。
  • 使用 uxTaskGetStackHighWaterMark 更安全:它由 FreeRTOS 官方维护,避免直接访问 TCB 内部结构。
  • 栈溢出钩子中不要调用阻塞 API:应尽快记录信息并复位或停机。

通过 PSP 限位与填充字扫描,你可以精确掌握每个任务的栈峰值,从而合理调整栈大小,彻底告别随机死机。