一、为什么栈溢出是“静默杀手”

在RTOS中,每个任务拥有独立栈空间。当任务局部变量过大、递归过深或中断嵌套过多时,栈指针会越过栈底边界,覆盖相邻任务栈或全局变量。由于内存写操作通常不触发异常,系统可能继续运行,但数据已被破坏,表现为随机死机、变量值突变、任务调度异常等,调试难度极大。

传统方法仅靠“填充字”检测:在栈底填充魔数(如0xDEADBEEF),定期检查是否被改写。但填充字只能事后发现,且若溢出深度未触及填充区则漏检。MPU(内存保护单元)可提供硬件级保护:将栈底一段区域设为不可访问,一旦越界立即触发MemManage异常,实现“即时捕获”。两者结合,形成双保险。

二、双保险原理

  • MPU栈保护区:在任务栈底预留32字节(或更大)区域,配置为不可读/不可写。栈向下增长时,一旦SP进入该区域,CPU触发MemManage异常,可打印任务名、SP值等现场信息。
  • 填充字检测:在栈底保护区之上填充固定魔数。任务运行时周期性扫描魔数是否被改写,若被改写则说明栈曾越界但未触发MPU(例如MPU区域被意外修改或溢出深度未达保护区)。

两者互补:MPU提供即时硬件拦截,填充字提供软件兜底和溢出深度估算。

三、配置步骤(以STM32F4 + FreeRTOS为例)

3.1 启用MPU并配置保护区

STM32F4的MPU支持8个区域。我们为每个任务分配一个区域,基地址为栈底,大小为32字节(最小32字节,需2的幂)。注意:MPU区域大小必须为2的幂,且基地址对齐到大小。

#include "stm32f4xx_hal.h"

// 配置MPU区域保护任务栈底
void MPU_ConfigStackGuard(uint32_t stack_bottom, uint32_t size) {
    MPU_Region_InitTypeDef MPU_InitStruct = {0};
    HAL_MPU_Disable();
    MPU_InitStruct.Enable = MPU_REGION_ENABLE;
    MPU_InitStruct.Number = MPU_REGION_NUMBER0; // 每个任务用不同区域号
    MPU_InitStruct.BaseAddress = stack_bottom;
    MPU_InitStruct.Size = MPU_REGION_SIZE_32B; // 根据实际调整
    MPU_InitStruct.SubRegionDisable = 0x00;
    MPU_InitStruct.TypeExtField = MPU_TEX_LEVEL0;
    MPU_InitStruct.AccessPermission = MPU_REGION_NO_ACCESS;
    MPU_InitStruct.DisableExec = MPU_INSTRUCTION_ACCESS_DISABLE;
    MPU_InitStruct.IsShareable = MPU_ACCESS_NOT_SHAREABLE;
    MPU_InitStruct.IsCacheable = MPU_ACCESS_NOT_CACHEABLE;
    MPU_InitStruct.IsBufferable = MPU_ACCESS_NOT_BUFFERABLE;
    HAL_MPU_ConfigRegion(&MPU_InitStruct);
    HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT);
}

3.2 创建任务时设置保护区与填充字

使用FreeRTOS的xTaskCreateStatic可自定义栈空间。在栈底预留32字节作为保护区,并在保护区之上填充魔数。

#define STACK_SIZE 512
#define GUARD_SIZE 32
#define FILL_PATTERN 0xDEADBEEF

static StackType_t task_stack[STACK_SIZE + GUARD_SIZE/4]; // 单位:字
static StaticTask_t task_tcb;

void vTaskCode(void *pvParameters) {
    for(;;) {
        // 任务主体
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

void CreateProtectedTask(void) {
    // 栈底为task_stack起始,保护区占前GUARD_SIZE字节
    uint32_t *guard_start = (uint32_t*)task_stack;
    uint32_t *fill_start = guard_start + (GUARD_SIZE/4);
    // 填充魔数(从保护区之后到栈顶)
    for(uint32_t i = 0; i < (STACK_SIZE/4); i++) {
        fill_start[i] = FILL_PATTERN;
    }
    // 配置MPU保护栈底32字节
    MPU_ConfigStackGuard((uint32_t)guard_start, GUARD_SIZE);
    // 创建任务,栈顶为task_stack + STACK_SIZE/4 + GUARD_SIZE/4
    xTaskCreateStatic(vTaskCode, "Task1", STACK_SIZE/4, NULL, 2,
                      task_stack + GUARD_SIZE/4, &task_tcb);
}

3.3 填充字周期性检测

在空闲任务钩子或低优先级任务中扫描填充区,若发现非魔数则记录溢出深度。

void CheckStackFill(void) {
    uint32_t *fill_start = (uint32_t*)(task_stack + GUARD_SIZE/4);
    for(uint32_t i = 0; i < (STACK_SIZE/4); i++) {
        if(fill_start[i] != FILL_PATTERN) {
            // 计算溢出深度:从栈底到被改写位置
            uint32_t overflow_depth = (i + 1) * 4;
            printf("Stack overflow detected! Depth: %lu bytes\n", overflow_depth);
            // 可在此处触发断点或记录日志
            break;
        }
    }
}

// 在空闲任务钩子中调用
void vApplicationIdleHook(void) {
    CheckStackFill();
}

3.4 MemManage异常处理

当MPU触发异常时,在MemManage_Handler中获取出错任务信息。

void MemManage_Handler(void) {
    __asm volatile("mrs %0, psp" : "=r"(current_sp)); // 获取任务栈指针
    printf("MPU fault! SP=0x%08lx, Task=%s\n", current_sp, pcTaskGetName(NULL));
    while(1);
}

四、注意事项

  • MPU区域对齐:基地址必须对齐到区域大小(32字节),否则配置无效。
  • 区域数量限制:STM32F4仅有8个MPU区域,任务多时需复用或使用子区域禁用。
  • 填充字检测频率:过高影响性能,建议在空闲钩子或低优先级任务中执行。
  • 栈增长方向:Cortex-M栈向下增长,保护区应位于栈底(低地址)。
  • 中断栈:若使用中断嵌套,需确保主栈也足够大,或为中断单独配置MPU保护。
  • 调试时禁用MPU:调试器可能无法访问保护区,可在调试时临时关闭MPU。

五、总结

MPU栈保护区提供硬件级即时拦截,填充字提供软件级事后检测与溢出深度估算。两者结合,可有效定位RTOS任务栈溢出这一“静默杀手”。实际项目中,建议为关键任务启用双保险,并在产品测试阶段开启填充字扫描,量产时可关闭以节省开销。