一、为什么栈溢出难查

在FreeRTOS中,每个任务拥有独立的栈空间,由xTaskCreate的usStackDepth参数指定。栈溢出通常不会立刻触发HardFault,而是悄悄踩坏相邻内存(另一个任务的栈、TCB或堆块),表现为“随机死机”“变量莫名被改”。等复现时现场早已破坏,调试成本极高。

因此工程上需要两层防护:

  • 静态估算:根据调用深度与局部变量估算初始栈大小;
  • 运行时监测:用高水位线(High Water Mark)持续观测栈余量,动态修正。

二、高水位线原理

FreeRTOS在创建任务时会把整个栈填充为魔数0xA5A5A5A5(tskSTACK_FILL_BYTE)。任务运行过程中栈被使用,未被覆盖的区域仍是魔数。uxTaskGetStackHighWaterMark()从栈的“生长末端”向栈顶扫描,统计连续未被改写的字数,即历史最小剩余量。

关键点:

  • 返回值单位是字(StackType_t),Cortex-M上StackType_t为uint32_t,即1个单位=4字节;
  • 返回的是历史最小值,不是当前值,因此适合做“最坏情况”评估;
  • 该函数需要遍历栈,耗时与栈大小成正比,不宜在高频中断或紧循环中调用;
  • 使用INCLUDE_uxTaskGetStackHighWaterMark宏开启,默认在FreeRTOS.h中为1。

三、实测步骤

3.1 配置

/* FreeRTOSConfig.h */
#define INCLUDE_uxTaskGetStackHighWaterMark   1
#define configCHECK_FOR_STACK_OVERFLOW        2   /* 方法2:带魔数校验,推荐 */
#define configUSE_TRACE_FACILITY              1

3.2 周期采样代码

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

typedef struct {
    TaskHandle_t handle;
    const char  *name;
    uint32_t     min_free_words;   /* 历史最小剩余,单位:字 */
} stack_monitor_t;

static stack_monitor_t g_mon[] = {
    { NULL, "sensor", 0 },
    { NULL, "comm",   0 },
    { NULL, "ui",     0 },
};
#define MON_NUM (sizeof(g_mon)/sizeof(g_mon[0]))

/* 在任务创建后登记句柄 */
void stack_monitor_register(uint32_t idx, TaskHandle_t h)
{
    if (idx < MON_NUM) g_mon[idx].handle = h;
}

/* 由低优先级监控任务每 5s 调用一次 */
void stack_monitor_poll(void)
{
    for (uint32_t i = 0; i < MON_NUM; i++) {
        if (g_mon[i].handle == NULL) continue;
        UBaseType_t hw = uxTaskGetStackHighWaterMark(g_mon[i].handle);
        uint32_t bytes = (uint32_t)hw * sizeof(StackType_t);
        if (bytes < g_mon[i].min_free_words) {
            g_mon[i].min_free_words = bytes;
        }
        /* 低于安全线则告警,阈值见第四节 */
        if (bytes < 128) {
            printf("[STACK] %s low! free=%lu B\n",
                   g_mon[i].name, (unsigned long)bytes);
        }
    }
}

3.3 用钩子兜底

高水位线是“事后统计”,真正溢出瞬间需要钩子:

void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName)
{
    (void)xTask;
    taskDISABLE_INTERRUPTS();
    printf("STACK OVERFLOW: %s\n", pcTaskName);
    for (;;) { }   /* 停在此处便于调试器查看调用栈 */
}

四、阈值设定

设实测历史最小剩余为M字节,建议保留安全余量S:

  • S ≥ 最大中断嵌套开销 + 最深函数调用局部变量 + 编译器优化波动,Cortex-M上经验取 128~256 字节;
  • 若M < S,说明栈偏小,按新栈 = 原栈 + (S - M) + 20%扩容;
  • 若M > 2×S且长期稳定,可适度缩减以省RAM,但不要低于S;
  • 对含浮点、printf、递归、大数组局部变量的任务,S取上限甚至翻倍。

一个可落地的判据:告警线 = S,扩容线 = S/2。即剩余低于S时记录并观察,低于S/2时立即扩容。

五、注意事项

  • 高水位线只反映已执行路径的最坏值,测试必须覆盖所有分支(含异常分支、通信峰值、OTA等);
  • 中断使用主栈(MSP),任务栈溢出检测不覆盖中断栈,需单独核算configISR_STACK_SIZE_WORDS或启动文件中的栈大小;
  • printf/浮点格式化会显著吃栈,测量时保持与量产一致的编译选项(-O2与-O0结果差异明显);
  • 采样任务本身也要留足栈,避免“监控者溢出”;
  • 发布固件可保留周期采样但降低频率(如30s),把结果写入日志或诊断区,便于现场回溯。

六、小结

uxTaskGetStackHighWaterMark把“栈够不够”从玄学变成可量化指标。配合configCHECK_FOR_STACK_OVERFLOW=2的魔数校验与溢出钩子,形成“统计+兜底”的双层防护。实践中坚持全路径压测、按字节换算、留足安全余量,就能在量产前把栈风险收敛到可控范围。