引言

在嵌入式 RTOS 环境中,任务栈溢出是隐蔽而危险的故障源。传统软件检查(如栈填充模式)存在检测延迟和开销问题。Cortex-M7 内置的 MPU 提供了硬件级保护能力,能在栈溢出发生的瞬间触发异常,实现零延迟检测。本文将基于 FreeRTOS 和 STM32H7 系列,深入讲解 MPU 栈保护实现及误报抑制技巧。

MPU 栈保护原理

硬件机制

Cortex-M7 的 MPU 允许将内存区域划分为多个保护域,每个域可配置起始地址、大小、访问权限和属性。当 CPU 访问违反区域规则的地址时,会触发 MemManage 异常。

对于任务栈保护,我们为每个任务分配一个独立的 MPU 区域,区域大小等于栈大小,并设置禁止写权限。当任务栈指针(SP)因溢出进入该区域时,写操作立即触发 MemManage 异常,从而在数据被破坏前捕获错误。

为何选择 MPU 而非软件检查

  • 零运行时开销:MPU 检查由硬件完成,不消耗 CPU 周期
  • 即时检测:溢出发生瞬间即触发异常,而非等到下次调度
  • 防止静默破坏:避免溢出数据覆盖相邻内存(如堆或全局变量)

实现步骤

1. 内存布局设计

假设我们使用 FreeRTOS,任务栈由 pvPortMalloc 分配。为了 MPU 对齐,需要确保栈起始地址按区域大小对齐。Cortex-M7 要求 MPU 区域大小必须是 2 的幂,且起始地址对齐到区域大小。

// 定义栈大小(2的幂,且大于实际需求)
#define TASK_STACK_SIZE 1024  // 1KB
#define MPU_REGION_SIZE  1024 // 必须与栈大小一致

2. 初始化 MPU 并配置保护区域

在系统启动时,初始化 MPU 并设置默认内存映射,然后为每个任务动态配置保护区域。

// MPU 初始化函数
void MPU_Init(void) {
    // 禁用 MPU 进行配置
    MPU->CTRL = 0;
    
    // 配置背景区域(默认内存映射)
    MPU->RBAR = (0x00000000UL | MPU_REGION_VALID | (0 << MPU_REGION_NUMBER_Pos));
    MPU->RASR = (MPU_AP_FULL_ACCESS | MPU_REGION_SIZE_4GB | MPU_REGION_ENABLE);
    
    // 使能 MPU,使用默认内存映射作为背景
    MPU->CTRL = MPU_CTRL_ENABLE_Msk | MPU_CTRL_PRIVDEFENA_Msk;
    __DSB();
    __ISB();
}

// 为任务配置栈保护区域
void MPU_ConfigTaskStack(uint32_t stack_addr, uint32_t size, uint8_t region_num) {
    // 确保地址对齐
    stack_addr &= ~(size - 1);
    
    // 配置区域:禁止写,允许读(用于调试)
    MPU->RBAR = (stack_addr | MPU_REGION_VALID | (region_num << MPU_REGION_NUMBER_Pos));
    MPU->RASR = (MPU_AP_READONLY | MPU_REGION_SIZE_1KB | MPU_REGION_ENABLE);
    __DSB();
    __ISB();
}

3. 集成到 RTOS 任务创建流程

在 FreeRTOS 中,我们可以在任务创建后,通过钩子函数或直接调用配置函数来设置保护区域。

// 任务创建包装函数
BaseType_t xTaskCreateWithMPU(TaskFunction_t task, const char *name, uint32_t stack_size,
                              void *param, UBaseType_t prio, TaskHandle_t *handle,
                              uint8_t region_num) {
    BaseType_t ret = xTaskCreate(task, name, stack_size, param, prio, handle);
    if (ret == pdPASS) {
        // 获取任务栈地址(FreeRTOS 内部结构)
        TCB_t *tcb = (TCB_t *)*handle;
        uint32_t stack_addr = (uint32_t)tcb->pxStack;
        MPU_ConfigTaskStack(stack_addr, stack_size * sizeof(StackType_t), region_num);
    }
    return ret;
}

4. 异常处理与诊断

当栈溢出触发 MemManage 异常时,我们需要在异常处理函数中提取信息并恢复系统。

// MemManage 异常处理
void HardFault_Handler(void) {
    // 读取异常状态寄存器
    uint32_t cfsr = SCB->CFSR;
    if (cfsr & (1 << 0)) { // MMARVALID
        uint32_t fault_addr = SCB->MMFAR;
        // 记录故障地址,可用于定位溢出任务
        fault_log(fault_addr);
    }
    // 进入安全状态:停止任务或重启系统
    while(1);
}

误报抑制策略

误报来源分析

  1. 栈指针未对齐:任务切换时,SP 可能指向栈顶附近,但 MPU 区域边界检查基于地址,若栈顶恰好在区域边界,正常操作可能触发异常。
  2. 中断嵌套:中断使用主栈(MSP),若任务栈区域与主栈区域重叠,中断处理可能误入保护区域。
  3. 编译器优化:某些编译器会生成超出栈范围的临时数据访问(如大数组)。

抑制方法

1. 预留安全边界

在每个任务栈的顶部(低地址端)预留 16~32 字节的“哨兵”区域,该区域不启用 MPU 保护,允许正常访问。

#define STACK_GUARD_SIZE 32
#define TASK_STACK_SIZE (1024 + STACK_GUARD_SIZE)
// 配置 MPU 区域时,只保护实际栈区域,排除哨兵
MPU_ConfigTaskStack(stack_addr + STACK_GUARD_SIZE, 1024, region_num);

2. 动态禁用/启用区域

在任务切换时,仅对当前运行任务启用 MPU 区域,其他任务禁用。这需要修改 FreeRTOS 的上下文切换钩子。

// 在 vTaskSwitchContext 中调用
void vTaskSwitchContextHook(void) {
    TaskHandle_t current = xTaskGetCurrentTaskHandle();
    // 禁用所有区域
    MPU->CTRL &= ~MPU_CTRL_ENABLE_Msk;
    // 启用当前任务的区域
    MPU_ConfigTaskStack(current->pxStack, TASK_STACK_SIZE, 0);
    MPU->CTRL |= MPU_CTRL_ENABLE_Msk;
}

3. 使用 MPU 的子区域(Sub-region)

Cortex-M7 支持将区域划分为 8 个子区域,可以单独禁用某些子区域。利用这一特性,可以精确控制保护范围。

// 配置区域时,禁用第一个子区域作为哨兵
MPU->RASR = (MPU_AP_READONLY | MPU_REGION_SIZE_1KB | (0x1 << 8) | MPU_REGION_ENABLE);
// 0x1 << 8 表示禁用子区域 0(最低地址的 1/8)

完整示例代码

以下是一个基于 FreeRTOS 的完整示例,演示如何创建带 MPU 保护的任务。

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

// MPU 区域编号
#define MPU_REGION_TASK1 0
#define MPU_REGION_TASK2 1

// 任务栈大小(含哨兵)
#define TASK1_STACK_SIZE 1024
#define TASK2_STACK_SIZE 1024

// 任务函数
void vTask1(void *param) {
    while(1) {
        // 任务代码
        vTaskDelay(100);
    }
}

void vTask2(void *param) {
    while(1) {
        // 任务代码
        vTaskDelay(200);
    }
}

// 创建任务并配置 MPU
void CreateTasksWithMPU(void) {
    TaskHandle_t handle1, handle2;
    
    // 创建任务(使用标准 API)
    xTaskCreate(vTask1, "Task1", TASK1_STACK_SIZE, NULL, 1, &handle1);
    xTaskCreate(vTask2, "Task2", TASK2_STACK_SIZE, NULL, 1, &handle2);
    
    // 配置 MPU 区域(假设栈地址已由 FreeRTOS 分配)
    TCB_t *tcb1 = (TCB_t *)handle1;
    TCB_t *tcb2 = (TCB_t *)handle2;
    
    // 配置区域:保护栈的 1024 字节,但跳过前 32 字节哨兵
    MPU_ConfigTaskStack((uint32_t)tcb1->pxStack + 32, 1024, MPU_REGION_TASK1);
    MPU_ConfigTaskStack((uint32_t)tcb2->pxStack + 32, 1024, MPU_REGION_TASK2);
}

int main(void) {
    // 初始化硬件
    MPU_Init();
    // 创建任务
    CreateTasksWithMPU();
    // 启动调度器
    vTaskStartScheduler();
    while(1);
}

注意事项

  • 区域数量限制:Cortex-M7 通常有 8 或 16 个 MPU 区域,需合理规划,避免耗尽。
  • 上下文切换开销:若在切换时动态配置 MPU,会增加少量开销,需评估实时性影响。
  • 调试支持:建议在开发阶段保留读权限,以便使用调试器查看栈内容。
  • 与 DMA 交互:DMA 访问不经过 MPU,需确保 DMA 不会写入受保护区域。
  • 测试覆盖:使用压力测试(如递归调用)验证保护机制的有效性。

总结

基于 MPU 的任务栈溢出检测为 RTOS 提供了硬件级的防护,能够即时捕获溢出事件,避免系统静默崩溃。通过合理配置区域、预留安全边界和动态切换策略,可以有效抑制误报,提升系统可靠性。开发者应结合具体 MCU 和 RTOS 版本,灵活调整实现细节,打造健壮的嵌入式系统。