第2章:MPU与内存保护——给Cortex-M内核装上“门禁系统”
MPU(Memory Protection Unit),说白了就是给芯片内存装上一套门禁系统。没有它,任何代码都能随意读写任何地址——用户程序不小心就能把系统核心数据踩个稀烂。我在项目中见过太多次这种“踩内存”导致的诡异死机,查起来真是欲哭无泪。
嗯,这一章我们就来彻底搞懂MPU怎么用。从原理到实战,再到那些容易踩的坑,我一个一个给你讲透。
2.1 MPU工作原理——它到底在保护什么?
MPU的核心逻辑其实很简单:它把物理内存或外设地址空间划分成若干个区域(Region),每个区域可以独立配置访问权限。CPU每次访问内存时,MPU都会检查这次访问是否合法。不合法?直接触发MemManage Fault,系统当场给你颜色看。
我个人习惯把MPU理解成一个“地址过滤器”。它检查三个东西:
- 地址范围:访问的地址落在哪个Region里?
- 访问类型:是读、写还是执行?
- 当前特权级:CPU当前是特权模式还是用户模式?
只有三项都匹配,访问才被放行。否则——砰,异常。
关键点:MPU不区分“任务A”和“任务B”,它只认地址和特权级。任务隔离要靠操作系统在切换任务时动态重配MPU。
STM32的Cortex-M7和M33内核都带了MPU。M7有8个Region,M33有16个。每个Region的大小必须是2的幂次方,最小32字节,最大4GB。对齐要求也很严格——Region的起始地址必须能被它的大小整除。举个例子,一个64KB的Region,起始地址必须是0x...0000或0x...10000这种。
2.2 MPU区域配置实战——手把手教你配
配置MPU其实就几步。我习惯用寄存器操作,因为HAL库的MPU封装有时候不够灵活。来看一个典型配置:把SRAM1的前64KB设为只读,给用户程序用。
/* 关闭MPU,配置前必须关 */
MPU->CTRL = 0;
/* 配置Region 0:SRAM1起始0x30000000,大小64KB */
MPU->RNR = 0; // 选Region 0
MPU->RBAR = (0x30000000) | // 基地址
MPU_RBAR_VALID_Msk | // 使能这个Region
(0 << 4); // Region编号
/* Region大小和属性 */
MPU->RASR = (0b011 << 1) | // 大小:64KB (2^(1+16) = 131072? 不对,查表)
MPU_RASR_ENABLE_Msk |
MPU_RASR_AP_RW_RO; // 特权级读写,用户级只读
/* 使能MPU,使用默认内存映射作为背景区域 */
MPU->CTRL = MPU_CTRL_PRIVDEFENA_Msk | MPU_CTRL_ENABLE_Msk;
/* 同步屏障 */
__DSB();
__ISB();
等等,上面那个大小编码我故意写了个坑。RASR寄存器的SIZE字段编码规则是:size = log2(region_size) - 1。64KB = 65536 = 2^16,所以SIZE = 16 - 1 = 15 = 0b1111。上面代码里写的0b011是错的——这是我早期踩过的坑,现在写出来给你提个醒。
| Region大小 | 2的幂 | SIZE编码 | 对齐要求 |
|---|---|---|---|
| 32字节 | 2^5 | 4 (5-1) | 32字节对齐 |
| 1KB | 2^10 | 9 (10-1) | 1KB对齐 |
| 64KB | 2^16 | 15 (16-1) | 64KB对齐 |
| 1MB | 2^20 | 19 (20-1) | 1MB对齐 |
小技巧:我写代码时习惯用宏定义来算SIZE,比如 #define MPU_REGION_SIZE(n) ((n)-1),然后传 MPU_REGION_SIZE(16) 表示64KB。这样不容易算错。
2.3 特权级与用户级隔离——两个世界的分界线
Cortex-M内核有两种运行模式:特权模式和用户模式(也叫非特权模式)。复位后默认在特权模式,可以访问所有资源。用户模式则受限——不能访问系统控制寄存器、不能执行WFI/WFE等指令、不能修改MPU配置。
MPU配合这个机制,就能实现“内核高权限、任务低权限”的隔离架构。我做过一个项目,把RTOS内核放在特权级,所有用户任务放在用户级。每个任务只能访问自己的堆栈和专属内存区。效果立竿见影——一个任务跑飞了,最多把自己崩掉,系统其他部分纹丝不动。
配置用户模式的方法很简单:在任务切换时,把CONTROL寄存器的bit0置1即可。
/* 切换到用户模式 */
__set_CONTROL(__get_CONTROL() | 1);
__ISB(); // 指令同步屏障,必须
但要注意:一旦切到用户模式,你就没法再通过软件切回特权模式了。唯一的方式是触发异常(比如SVC),在异常处理函数里切回来。这是硬件设计的安全机制——用户代码不能自己给自己“越狱”。
警告:我曾经在一个项目里忘了在SVC处理函数末尾加ISB,结果切回用户模式后MPU配置没生效,用户任务直接写穿了内核数据。排查了整整两天才发现是少了一条同步指令。所以——MPU配置、模式切换之后,务必加__DSB()和__ISB()。
2.4 常见配置错误——我踩过的坑,你别再踩
做MPU配置,错误率其实挺高的。我总结了几类高频问题:
- Region重叠:两个Region覆盖了同一段地址,MPU会优先匹配编号大的Region。很多人不知道这个优先级规则,配出来行为完全不符合预期。
- 对齐错误:Region起始地址没按大小对齐。比如64KB Region从0x30010000开始——64KB=0x10000,起始地址必须是0x30000000或0x30010000?不对,0x30010000不能被0x10000整除?等等,0x30010000 / 0x10000 = 0x3001,能整除啊。嗯,这个例子举错了。正确的反例是:64KB Region从0x30008000开始,0x30008000 / 0x10000 = 0x3000.8,不能整除。MPU会直接忽略这个Region配置。
- 背景区域误用:使能PRIVDEFENA后,特权级可以访问所有未配置Region的地址。很多人以为配了MPU就万事大吉,结果用户代码通过特权级漏洞绕过了保护。
- Cache一致性:MPU的缓存属性配置(TEX、C、B位)会影响CPU和DMA的数据一致性。我见过一个案例,MPU把SRAM配成了write-back,DMA写完后CPU读到的全是旧数据。
| 错误类型 | 现象 | 排查方向 |
|---|---|---|
| Region重叠 | 访问某些地址时权限异常 | 检查所有Region的地址范围,确认优先级 |
| 对齐错误 | Region配置不生效 | 验证起始地址 % 大小 == 0 |
| 背景区域误用 | 用户代码访问了不该访问的区域 | 关闭PRIVDEFENA,显式配置所有Region |
| Cache属性错误 | DMA和CPU数据不一致 | 共享内存配成write-through或non-cacheable |
嗯,说到排查,我一般用两种方法:一是写一个测试函数,故意触发MemManage Fault,在Fault处理函数里打印出出错的地址和访问类型;二是用调试器直接读MPU的寄存器,确认配置值写进去了没有。有时候问题就是寄存器没写进去——总线时钟没开,或者写操作被优化掉了。
核心原则:MPU配置完成后,一定要用__DSB()和__ISB()确保配置生效。这是血的教训换来的。
这张图把MPU的工作流程画得很清楚了。CPU每次访问内存,MPU都会做一次“安检”。通过就放行,拒绝就触发Fault。下方的Region配置表展示了典型的划分方式——Flash只读可执行,SRAM读写,外设区域读写,再加一个保护区域防止越界。