引言

在嵌入式开发中,任务栈溢出往往以随机死机、数据错乱等隐蔽形式出现,传统软件检测(如栈填充模式检查)存在滞后性和误判风险。借助 Cortex-M 内核的 MPU,我们可以在硬件层面实时捕获越界访问,并触发异常处理,进而实现任务级恢复。本文以 FreeRTOS 为例,设计一套基于 MPU 的用户任务栈溢出检测与恢复机制。

MPU 工作原理与栈保护策略

1. MPU 核心概念

  • MPU 将内存划分为多个区域(Region),每个区域可独立配置基地址、大小、访问权限和缓存属性。
  • 当 CPU 访问违反区域权限时,触发 MemManage Fault(内存管理异常),可配置为硬 fault 或可恢复的异常。
  • 在 RTOS 中,每个任务拥有独立的栈空间,我们可为每个任务栈的末尾(低地址方向)设置一个不可读写的保护区域。

2. 栈溢出检测策略

  • 栈向下增长:Cortex-M 栈指针 SP 从高地址向低地址增长,因此将保护区域放置在栈底(最低地址)附近。
  • 保护区域大小:通常设为 32 或 64 字节,足够捕获常见溢出,同时避免浪费内存。
  • 触发条件:当任务写入保护区域时,MPU 立即产生 MemManage Fault,中断当前执行流。

系统设计

1. 整体架构

  • 每个任务控制块(TCB)中增加 MPU 配置信息:栈起始地址、栈大小、保护区域基地址。
  • RTOS 调度器在任务切换时,动态更新 MPU 区域,确保当前任务拥有正确的栈保护。
  • 在 MemManage Fault 处理函数中,识别故障来源,若为栈溢出则执行恢复流程(如重启任务或记录日志)。

2. 恢复机制设计

  • 任务级恢复:当检测到栈溢出,可终止当前任务,释放其资源,并重新创建该任务(或切换到备份任务)。
  • 系统级恢复:若溢出严重,可触发系统软复位,但需先保存现场日志。
  • 本文采用任务级恢复:在异常处理中,调用 RTOS API 删除当前任务,并重新初始化。

基于 FreeRTOS 的实现步骤

1. 配置 MPU 硬件

首先,在系统初始化时使能 MPU,并设置默认内存映射。以下代码基于 Cortex-M4(STM32F4 系列)。

#include "core_cm4.h"

void MPU_Config(void) {
    // 禁用 MPU 进行配置
    MPU->CTRL = 0;
    
    // 配置背景区域(默认全内存可访问,但特权模式)
    MPU->RBAR = 0x00000000 | MPU_REGION_VALID | (0 << MPU_REGION_NUMBER_Pos);
    MPU->RASR = (0x03 << MPU_AP_Pos) |   // 全权限(特权+用户)
                (0x01 << MPU_TEX_Pos) |  // 正常内存
                (0x00 << MPU_CACHEABLE_Pos) |
                (0x00 << MPU_BUFFERABLE_Pos) |
                (0x00 << MPU_SHAREABLE_Pos) |
                (0x00 << MPU_REGION_SIZE_Pos) |  // 4GB 区域(实际由背景区域覆盖)
                MPU_RASR_ENABLE;
    
    // 使能 MPU,并启用默认内存映射(背景区域)
    MPU->CTRL = MPU_CTRL_ENABLE_Msk | MPU_CTRL_PRIVDEFENA_Msk;
    
    // 使能 MemManage 异常
    SCB->SHCSR |= SCB_SHCSR_MEMFAULTENA_Msk;
}

2. 定义任务栈与保护区域

在 FreeRTOS 中,任务栈通常由静态数组或堆分配。我们为每个任务定义栈时,额外预留保护区域。

#define TASK_STACK_SIZE  1024  // 单位:字(4字节)
#define GUARD_SIZE       16     // 保护区域大小(字)

// 任务栈结构体
typedef struct {
    uint32_t stack_base[TASK_STACK_SIZE];  // 实际栈空间
    uint32_t guard[GUARD_SIZE];            // 保护区域(不可访问)
} TaskStack_t;

// 示例任务栈实例
TaskStack_t task1_stack;

注意:保护区域应放在栈底(低地址),因此将 guard 数组放在结构体开头,确保其地址低于 stack_base。

3. 配置 MPU 区域保护任务栈

在任务创建时,为每个任务配置独立的 MPU 区域。由于 MPU 区域数量有限(通常 8 个),我们仅配置当前运行任务的保护区域。

void MPU_SetupTaskStack(TaskStack_t *stack, uint32_t region_num) {
    // 计算保护区域基地址(guard 数组地址)
    uint32_t guard_addr = (uint32_t)stack->guard;
    
    // 配置区域:禁止读写,触发 fault
    MPU->RBAR = guard_addr | MPU_REGION_VALID | (region_num << MPU_REGION_NUMBER_Pos);
    MPU->RASR = (0x00 << MPU_AP_Pos) |   // 无权限(任何访问都 fault)
                (0x00 << MPU_TEX_Pos) |  // 非缓存
                (0x00 << MPU_CACHEABLE_Pos) |
                (0x00 << MPU_BUFFERABLE_Pos) |
                (0x00 << MPU_SHAREABLE_Pos) |
                (log2(GUARD_SIZE*4) << MPU_REGION_SIZE_Pos) |  // 区域大小(字节)
                MPU_RASR_ENABLE;
}

注意:区域大小需为 2 的幂次,且最小 32 字节。这里 GUARD_SIZE*4 = 64 字节,满足要求。

4. 任务切换时更新 MPU

在 FreeRTOS 的 vTaskSwitchContext 或 PendSV 处理中,切换任务后调用 MPU_SetupTaskStack 配置当前任务的保护区域。

void vApplicationPendSVHook(void) {
    // 获取当前任务 TCB
    TCB_t *pxCurrentTCB = xTaskGetCurrentTaskHandle();
    TaskStack_t *stack = (TaskStack_t *)pxCurrentTCB->pxStack;  // 假设栈指针指向结构体
    
    // 配置 MPU 区域(使用固定区域号,如 1)
    MPU_SetupTaskStack(stack, 1);
}

5. 异常处理与恢复

MemManage_Handler 中,判断是否为栈溢出,并执行恢复。

void MemManage_Handler(void) {
    // 读取 fault 状态寄存器
    uint32_t cfsr = SCB->CFSR;
    if (cfsr & (1 << 0)) {  // MMARVALID 位,表示有有效地址
        uint32_t fault_addr = SCB->MMFAR;
        // 判断是否在保护区域内(可比较地址范围)
        // 这里简化:直接认为是栈溢出
        
        // 获取当前任务句柄
        TaskHandle_t task = xTaskGetCurrentTaskHandle();
        
        // 记录日志(可选)
        printf("Stack overflow in task %s\n", pcTaskGetName(task));
        
        // 删除当前任务
        vTaskDelete(task);
        
        // 重新创建任务(需外部定义创建函数)
        xTaskCreate(TaskFunction, "TaskName", TASK_STACK_SIZE, NULL, 1, NULL);
        
        // 清 fault 标志
        SCB->CFSR |= SCB_CFSR_MMARVALID_Msk;
    }
    
    // 其他 fault 处理...
}

注意:在异常处理中调用 RTOS API 需要谨慎,因为当前上下文可能不安全。建议在异常中设置标志,然后在更高优先级任务中处理恢复。

完整代码示例

以下是一个简化但完整的示例,演示了如何集成上述机制。

// main.c
#include "FreeRTOS.h"
#include "task.h"
#include "core_cm4.h"

// 任务栈结构体定义(如前)
// ...

// 任务函数
void vTask1(void *pvParameters) {
    volatile uint32_t *p = (uint32_t *)0x20000000; // 故意访问保护区域
    while(1) {
        *p = 0xDEADBEEF;  // 触发溢出
        vTaskDelay(100);
    }
}

int main(void) {
    // 初始化 MPU
    MPU_Config();
    
    // 创建任务
    xTaskCreate(vTask1, "Task1", TASK_STACK_SIZE, NULL, 1, NULL);
    
    // 启动调度器
    vTaskStartScheduler();
    
    while(1);
}

注意事项与优化

  • MPU 区域数量限制:Cortex-M 通常有 8 个区域,需合理规划。可仅用 1 个区域动态配置,或使用多个区域同时保护多个任务栈(但需在切换时更新)。
  • 异常处理中的 RTOS API:在 fault handler 中调用 vTaskDelete 可能引起调度问题,建议采用“延迟删除”机制,或通过信号量通知守护任务。
  • 保护区域大小:过小可能漏检,过大浪费内存。建议根据任务栈使用情况动态调整。
  • 性能影响:MPU 配置在任务切换时执行,会增加少量开销,需评估实时性要求。
  • 调试辅助:在恢复前,可保存任务栈快照到非易失存储,便于事后分析。

总结

基于 MPU 的栈溢出检测机制,将检测从软件轮询提升到硬件实时响应,极大增强了系统可靠性。结合 RTOS 的任务管理,可以实现自动恢复,减少人工干预。本文提供的方案可直接应用于 FreeRTOS 项目,也可移植到其他 RTOS。开发者应根据实际硬件资源,优化 MPU 配置和恢复策略,构建更健壮的嵌入式系统。