4、内存保护单元(MPU)实战:ARM Cortex-M MPU配置、特权级与非特权级隔离、内存区域属性设置

好,咱们进入MPU的实战环节。说实话,MPU这东西,光看手册会觉得挺简单——不就是配几个寄存器嘛。但真到了项目里,坑可不少。我最早接触MPU是在一个工业控制项目上,客户要求系统必须通过IEC 61508 SIL3认证。那时候我才意识到,MPU配置不是“配通就行”,而是“配对了才算数”。

4.1 ARM Cortex-M MPU硬件结构速览

先看看MPU长什么样。Cortex-M3/M4/M7/M33这些内核,内部都集成了一个MPU模块。它本质上是一组寄存器,用来定义内存区域的访问权限和属性。

MPU支持最多8个或16个区域(取决于具体内核版本)。每个区域可以独立配置:

  • 基地址:区域的起始地址
  • 大小:从32字节到4GB,必须是2的幂次
  • 权限:读/写/执行,特权级/用户级
  • 属性:可缓存、可缓冲、可共享等

嗯,这里要注意:区域之间不能重叠,除非你设置了特殊的“子区域”功能。我见过有人把两个区域配重叠了,结果系统跑起来莫名其妙地死机——查了两天才发现是MPU配置冲突。

核心要点:MPU的检查是在指令执行之前完成的。如果访问违反了MPU规则,CPU会触发MemManage Fault异常。这个异常可以让你优雅地处理非法访问,而不是让系统直接崩溃。

4.2 特权级与非特权级隔离——RTOS的基石

RTOS里,内核跑在特权级,任务跑在非特权级。这个隔离靠的就是MPU。说白了,任务代码不能随便碰内核的数据结构,也不能改自己的栈指针——这些都得靠MPU来保护。

我习惯的做法是这样的:

  1. 内核代码和数据放在特权级可访问的区域
  2. 每个任务有自己的栈和私有数据区,只允许该任务访问
  3. 任务间共享的内存区域,设置成“特权级可读写,用户级只读”
  4. 外设寄存器区域,只允许特权级访问

你想想看,如果任务A不小心写坏了任务B的栈,那后果是什么?系统崩溃、数据损坏、甚至安全漏洞。MPU就是干这个的——把每个任务关进自己的“笼子”里。

个人经验:我在一个物联网项目里,把任务的栈区域设置成“不可执行”。这样即使栈被溢出攻击,攻击者也没法执行注入的代码。这个技巧在安全要求高的场景下特别管用。

4.3 MPU区域属性设置——细节决定成败

MPU的区域属性,说白了就是告诉CPU:这块内存该怎么对待。我列个表,大家看得清楚些:

属性位 含义 典型用途
XN 不可执行 数据区、栈区
AP[2:0] 访问权限 特权/用户级读写控制
TEX[2:0] 类型扩展 缓存策略控制
S 可共享 多核/多主设备共享内存
C 可缓存 性能优化
B 可缓冲 写缓冲控制

这里有个坑:缓存属性配错了,系统可能跑得慢,也可能跑得不稳。我曾经在一个项目里,把外设寄存器的区域配成了“可缓存”。结果读寄存器的时候,读到的总是缓存里的旧值——外设状态变了,CPU却不知道。查了三天才找到原因。

避坑指南:外设寄存器区域一定要配成“不可缓存、不可缓冲、不可共享”。否则你会遇到各种诡异的“数据不一致”问题。这是ARM官方文档里反复强调的,但很多人还是会踩这个坑。

4.4 实战:配置一个RTOS任务的MPU区域

光说不练假把式。咱们直接上代码。下面是一个典型的RTOS任务MPU配置流程:

/* 定义任务的内存区域 */
#define TASK_STACK_SIZE   1024
#define TASK_PRIV_DATA_SIZE 64

/* 任务控制块 */
typedef struct {
    uint32_t stack[TASK_STACK_SIZE / 4];
    uint32_t priv_data[TASK_PRIV_DATA_SIZE / 4];
    uint32_t *sp;  /* 栈指针,只允许特权级访问 */
} task_t;

/* 配置MPU区域 */
void mpu_config_task(task_t *task) {
    /* 区域0:任务栈 - 用户级可读写,不可执行 */
    MPU->RNR  = 0;
    MPU->RBAR = (uint32_t)task->stack 
                | MPU_RBAR_VALID_Msk 
                | (0 << MPU_RBAR_REGION_Pos);
    MPU->RASR = (0x03 << MPU_RASR_AP_Pos)   /* 全权限 */
                | (0x01 << MPU_RASR_XN_Pos)  /* 不可执行 */
                | (TASK_STACK_SIZE << MPU_RASR_SIZE_Pos)
                | MPU_RASR_ENABLE_Msk;

    /* 区域1:任务私有数据 - 特权级只读,用户级不可访问 */
    MPU->RNR  = 1;
    MPU->RBAR = (uint32_t)task->priv_data 
                | MPU_RBAR_VALID_Msk 
                | (1 << MPU_RBAR_REGION_Pos);
    MPU->RASR = (0x06 << MPU_RASR_AP_Pos)   /* 特权级读写,用户级无权限 */
                | (0x01 << MPU_RASR_XN_Pos)  /* 不可执行 */
                | (TASK_PRIV_DATA_SIZE << MPU_RASR_SIZE_Pos)
                | MPU_RASR_ENABLE_Msk;

    /* 区域2:任务栈指针 - 只允许特权级访问 */
    MPU->RNR  = 2;
    MPU->RBAR = (uint32_t)&task->sp 
                | MPU_RBAR_VALID_Msk 
                | (2 << MPU_RBAR_REGION_Pos);
    MPU->RASR = (0x06 << MPU_RASR_AP_Pos)   /* 特权级读写 */
                | (0x01 << MPU_RASR_XN_Pos)  /* 不可执行 */
                | (0x04 << MPU_RASR_SIZE_Pos) /* 32字节 */
                | MPU_RASR_ENABLE_Msk;
}

这段代码做了三件事:

  • 任务栈:任务自己可以读写,但不能执行代码
  • 私有数据:只有内核能读写,任务碰都碰不到
  • 栈指针:这是任务切换的关键,必须由内核独占

为什么栈指针要单独保护?因为如果任务能改自己的栈指针,它就可以把栈指向内核的数据区——那MPU的保护就形同虚设了。

4.5 知识体系:MPU配置的核心逻辑

下面这张图,是我自己总结的MPU配置决策流程。每次做新项目,我都会照着这个思路走一遍:

MPU区域配置决策流程 确定内存区域用途 是代码区? XN = 0 (可执行) AP = 全权限 XN = 1 (不可执行) AP = 按需配置 是外设寄存器? TEX=0, S=0 C=0, B=0 TEX=1, S=0 C=1, B=1 注:AP权限需根据“特权级/用户级”隔离需求进一步细化

这个流程的核心逻辑就是:先分用途,再定属性。代码区要能执行,数据区不能执行;外设寄存器要关掉缓存,普通内存可以开缓存。每一步都有它的道理。

4.6 常见陷阱与调试技巧

最后,分享几个我踩过的坑:

  • 区域大小必须是2的幂次:如果你配了100字节,MPU会直接忽略这个区域。我刚开始就犯过这个错。
  • 基地址必须对齐到区域大小:比如区域大小是1KB,基地址必须是1KB的整数倍。不对齐的话,MPU会报错。
  • 区域优先级:区域编号越小,优先级越高。如果两个区域重叠,编号小的区域生效。
  • 使能MPU后要同步:配置完MPU后,最好执行一个DSB指令和ISB指令,确保配置生效。

调试小技巧:如果系统跑飞了,先检查MemManage Fault状态寄存器。它能告诉你:是哪个区域、什么类型的访问触发了异常。这个信息比瞎猜有用得多。

好了,MPU的实战配置就讲到这里。记住一句话:MPU不是配完就完事的,它需要和你的RTOS任务模型紧密配合。每个任务的栈、数据、共享内存,都要有对应的MPU区域来保护。这样,你的系统才能真正做到“隔离”和“安全”。


公众号:蓝海资料掘金营,微信deep3321