引言

在嵌入式实时系统(RTOS)中,每个任务拥有独立的栈空间,用于保存局部变量、函数调用上下文等。一旦任务栈溢出,可能覆盖相邻内存区域,导致数据损坏、系统崩溃,且故障表现随机、难以排查。传统的软件检测方法(如栈填充模式检查)存在滞后性,无法在溢出瞬间捕获。借助 Cortex-M7 的 MPU(Memory Protection Unit),我们可以在硬件层面实时监控栈访问,一旦越界立即触发异常,实现精准、及时的溢出检测。

MPU 与栈溢出检测原理

1. Cortex-M7 MPU 概述

Cortex-M7 的 MPU 支持最多 16 个可编程区域,每个区域可配置基地址、大小、访问权限(读/写/执行)和属性(如缓存策略)。当 CPU 访问某个地址时,MPU 会检查该地址是否落在已配置的区域内,若违反权限则触发 MemManage Fault。

2. 检测栈溢出的核心思想

每个任务栈通常分配在 RAM 中,我们可以在栈的底部(即栈增长方向的末端)设置一个 MPU 保护区。该区域设置为不可读、不可写,当任务栈指针(SP)越界访问该区域时,MPU 立即触发 MemManage Fault,从而在溢出发生的第一时间捕获。

注意:Cortex-M7 栈向下增长,栈底是低地址,栈顶是高地址。因此保护区应设置在栈的最低地址处,通常称为“栈守护区”。

3. 为什么选择硬件检测?

  • 实时性:硬件检测在访问瞬间触发,无需软件轮询。
  • 确定性:软件检测依赖任务调度,可能错过溢出时机。
  • 低开销:MPU 检查由硬件完成,不消耗 CPU 周期。

实战配置步骤

1. 硬件环境

  • MCU: STM32H743(Cortex-M7)
  • RTOS: FreeRTOS(或其他支持 MPU 的 RTOS,如 RT-Thread)
  • 开发环境: STM32CubeIDE + GCC

2. 配置 MPU 区域

我们需要为每个任务栈配置一个 MPU 区域,但 MPU 区域数量有限(16个),实际应用中可只保护关键任务或动态配置。以下以保护单个任务栈为例。

2.1 定义栈和保护区

// 任务栈大小(字节),注意对齐到 32 字节
#define TASK_STACK_SIZE  1024

// 任务栈缓冲区,需对齐到 32 字节(MPU 区域大小要求)
__attribute__((aligned(32))) uint8_t task_stack[TASK_STACK_SIZE];

// 保护区大小(通常为 32 或 64 字节)
#define GUARD_SIZE  32

2.2 初始化 MPU 区域

在系统启动时,配置 MPU 区域。以下代码将任务栈的最低 GUARD_SIZE 字节设置为不可访问区域。

void MPU_Config(void)
{
    // 确保 MPU 已禁用
    MPU->CTRL = 0;

    // 配置区域 0:栈保护区
    // 基地址 = 任务栈起始地址
    uint32_t base = (uint32_t)task_stack;
    
    // 区域大小:2^ (SIZE+1) 字节,SIZE=4 表示 32 字节
    // 设置 TEX=0, C=0, B=0, S=0(普通内存,不缓存)
    // AP=0b000(无访问权限)
    MPU->RBAR = base | MPU_RBAR_VALID_Msk | (0 << MPU_RBAR_REGION_Pos);
    MPU->RASR = (0 << MPU_RASR_ENABLE_Pos) |  // 区域使能
                (4 << MPU_RASR_SIZE_Pos) |    // 大小 32 字节
                (0 << MPU_RASR_AP_Pos) |      // 无权限
                (0 << MPU_RASR_TEX_Pos) |     // 普通内存
                (0 << MPU_RASR_C_Pos) |
                (0 << MPU_RASR_B_Pos) |
                (0 << MPU_RASR_S_Pos) |
                (0 << MPU_RASR_XN_Pos);       // 允许执行(可选)

    // 使能 MPU,并启用默认内存映射(背景区域)
    MPU->CTRL = MPU_CTRL_ENABLE_Msk | MPU_CTRL_PRIVDEFENA_Msk;

    // 使能 MemManage Fault
    SCB->SHCSR |= SCB_SHCSR_MEMFAULTENA_Msk;
}

2.3 在 RTOS 中集成

在 FreeRTOS 中,任务创建时需指定栈指针。我们可以将任务栈指向上述缓冲区,并确保栈底(低地址)位于保护区之上。

void TaskFunction(void *arg)
{
    // 任务代码
}

void CreateTask(void)
{
    // 注意:FreeRTOS 的栈是向下增长的,栈底是低地址
    // 因此将栈指针指向缓冲区末尾,但保护区在起始处
    // 需确保任务栈的起始地址(低地址)在保护区之后
    // 这里我们直接使用 task_stack 作为栈底,但保护区占用了前 32 字节
    // 因此实际可用栈大小为 TASK_STACK_SIZE - GUARD_SIZE
    // 创建任务时,栈大小需减去保护区大小
    xTaskCreate(TaskFunction, "Task", (TASK_STACK_SIZE - GUARD_SIZE) / 4, NULL, 1, NULL);
}

注意:FreeRTOS 的栈大小以字(4字节)为单位,且栈起始地址需对齐。上述代码中,任务栈的实际起始地址为 task_stack + GUARD_SIZE,但 FreeRTOS 内部会从栈顶(高地址)向下使用,因此我们需要确保栈顶不越过缓冲区末尾,且栈底不进入保护区。

更稳妥的做法是:将保护区放在栈顶(高地址),因为栈向下增长,溢出时 SP 会向低地址移动,但通常栈溢出是向低地址(栈底)方向,所以保护区应放在低地址。但某些情况下(如函数调用过深)也可能向高地址溢出,因此可考虑双向保护,但 MPU 区域有限,一般只保护低地址。

3. 异常处理

当发生栈溢出时,MemManage Fault 会触发。我们需要在 Fault 处理函数中记录现场并定位任务。

void MemManage_Handler(void)
{
    // 读取故障状态寄存器
    uint32_t cfsr = SCB->CFSR;
    uint32_t mmfar = SCB->MMFAR;

    // 打印或记录信息
    printf("MemManage Fault! CFSR=0x%08X, MMFAR=0x%08X\n", cfsr, mmfar);

    // 可在此处获取当前任务句柄,并打印任务名
    // 例如:vTaskGetRunTimeStats() 或获取任务名称

    // 进入死循环,等待调试
    while(1);
}

完整代码示例

以下是一个完整的 FreeRTOS 示例,演示如何配置 MPU 并创建受保护的任务。

#include "FreeRTOS.h"
#include "task.h"
#include "stm32h7xx.h"
#include <stdio.h>

// 任务栈缓冲区,对齐到 32 字节
__attribute__((aligned(32))) uint8_t task_stack[1024];
#define GUARD_SIZE 32

// MPU 初始化
void MPU_Config(void)
{
    MPU->CTRL = 0;
    uint32_t base = (uint32_t)task_stack;
    MPU->RBAR = base | MPU_RBAR_VALID_Msk | (0 << MPU_RBAR_REGION_Pos);
    MPU->RASR = (0 << MPU_RASR_ENABLE_Pos) |
                (4 << MPU_RASR_SIZE_Pos) |   // 32 字节
                (0 << MPU_RASR_AP_Pos) |     // 无权限
                (0 << MPU_RASR_TEX_Pos) |
                (0 << MPU_RASR_C_Pos) |
                (0 << MPU_RASR_B_Pos) |
                (0 << MPU_RASR_S_Pos) |
                (0 << MPU_RASR_XN_Pos);
    MPU->CTRL = MPU_CTRL_ENABLE_Msk | MPU_CTRL_PRIVDEFENA_Msk;
    SCB->SHCSR |= SCB_SHCSR_MEMFAULTENA_Msk;
}

// 任务函数
void Task(void *arg)
{
    // 故意制造栈溢出:递归调用
    volatile uint8_t buffer[128];
    (void)buffer;
    // 递归调用自身,但这里简单演示:访问保护区地址
    // 注意:实际中不要这样做,这里仅用于测试
    // 访问 task_stack[0] 会触发 MPU
    // 但任务栈实际起始地址是 task_stack+GUARD_SIZE,所以访问 task_stack[0] 是越界
    // 但任务代码中不会直接访问,这里模拟溢出:
    // 通过指针递减来模拟栈指针下移
    // 更真实的方法是递归调用,但为了简洁,我们直接写一个越界访问
    // 注意:这行代码会触发 MemManage Fault
    // 实际上,任务栈的栈底在 task_stack+GUARD_SIZE,所以访问 task_stack[0] 是保护区
    // 但任务代码通常不会直接访问,这里为了演示,我们故意访问
    // 但编译器可能优化,所以用 volatile
    volatile uint8_t *p = (uint8_t *)task_stack;
    *p = 0; // 触发 MPU 故障

    while(1);
}

int main(void)
{
    // 初始化硬件(时钟、串口等)
    // ...

    // 配置 MPU
    MPU_Config();

    // 创建任务,栈大小需减去保护区
    xTaskCreate(Task, "Task", (sizeof(task_stack) - GUARD_SIZE) / 4, NULL, 1, NULL);

    // 启动调度器
    vTaskStartScheduler();

    while(1);
}

// 异常处理
void MemManage_Handler(void)
{
    printf("Stack overflow detected!\n");
    // 打印当前任务信息
    TaskHandle_t current = xTaskGetCurrentTaskHandle();
    if (current != NULL) {
        printf("Task: %s\n", pcTaskGetName(current));
    }
    while(1);
}

注意事项

  • MPU 区域对齐:MPU 区域大小必须是 2 的幂次,且基地址必须对齐到区域大小。例如,32 字节区域要求基地址 32 字节对齐。
  • 栈大小计算:创建任务时,栈大小需减去保护区大小,否则实际可用栈空间不足,导致任务栈溢出(但会被 MPU 捕获)。
  • 保护区位置:通常放在栈底(低地址),因为栈向下增长。但若任务使用向上增长的栈(如某些架构),需调整。Cortex-M 栈向下,所以低地址是栈底。
  • 多任务保护:MPU 区域数量有限,可只保护关键任务,或动态重配置 MPU 区域(在任务切换时更新)。但动态配置会增加上下文切换开销。
  • 与 RTOS 的配合:FreeRTOS 的 xTaskCreate 使用静态或动态内存,需确保栈缓冲区正确对齐。建议使用 xTaskCreateStatic 并手动指定栈缓冲区。
  • 调试技巧:在 MemManage_Handler 中,可读取 SCB->MMFAR 获取触发故障的地址,帮助定位溢出点。
  • 性能影响:MPU 检查在每次访问时进行,但硬件加速,通常不影响性能。但需注意,配置 MPU 区域后,所有访问都会检查,因此要确保保护区设置正确,避免误触发。

总结

通过 MPU 实现硬件级栈溢出检测,能够显著提高嵌入式系统的健壮性。本文以 Cortex-M7 为例,详细介绍了配置步骤和代码实现。实际项目中,可根据需要扩展为多任务保护,并结合 RTOS 的调试功能,快速定位问题。希望本文能帮助开发者构建更可靠的嵌入式系统。