第4章:内存映射与MPU初探——Cortex-M4内存地图与保护单元
各位同学,今天我们来聊聊Cortex-M4的内存映射和MPU。说实话,这块内容在裸机开发时经常被忽略——反正程序能跑就行,管它内存怎么排布的?但一旦你要上RTOS,或者要做安全关键系统,内存布局和访问保护就成了绕不开的坎。
我最早接触MPU是在一个工业控制项目里,客户要求两个任务绝对不能互相踩内存。当时我还在想:不就是加个边界检查吗?后来发现,软件检查总有遗漏,硬件保护才是真靠谱。嗯,从那以后,我对MPU的态度就变了。
4.1 Cortex-M4的内存地图
Cortex-M4是32位处理器,寻址空间4GB。ARM把这4GB划分成了几个固定区域。你想想看,不管哪家芯片厂,只要用M4内核,这个布局就是一样的——外设地址可以不同,但区域划分是硬规矩。
| 地址范围 | 区域名称 | 用途 | 总线接口 |
|---|---|---|---|
| 0x0000 0000 - 0x1FFF FFFF | Code区 | 程序代码、向量表、常量 | I-Code / D-Code |
| 0x2000 0000 - 0x3FFF FFFF | SRAM区 | 堆、栈、全局变量 | 系统总线 |
| 0x4000 0000 - 0x5FFF FFFF | 外设区 | GPIO、UART、TIM等寄存器 | 系统总线 |
| 0x6000 0000 - 0x9FFF FFFF | 外部RAM区 | 扩展SDRAM、NOR Flash | 外部总线 |
| 0xE000 0000 - 0xE00F FFFF | 系统控制区 | NVIC、MPU、SysTick等 | 专用外设总线 |
关键点:Code区和SRAM区在物理上可能共用同一块Flash/SRAM,但总线路径不同。Code区走I-Code总线取指令,SRAM区走系统总线读写数据——这是哈佛架构的体现。
我个人习惯在工程里把内存映射图画出来贴在工位上。别笑,真的有用。有一次排查一个DMA搬运数据的问题,就是靠这张图发现外设地址写到了SRAM区,DMA根本访问不到。
4.2 为什么RTOS需要MPU?
裸机时代,你一个人掌控全局。所有变量、堆栈、外设寄存器都在你的掌控之下。但RTOS不一样——多个任务共享CPU,每个任务都有自己的栈空间,还可能访问共享资源。
问题来了:任务A的栈溢出,把任务B的栈给踩了。或者任务C的野指针写到了任务D的全局变量。这种bug,靠软件排查非常痛苦。我曾经花了两周时间定位一个栈溢出问题,最后发现是任务栈分配少了16字节。
MPU能做什么?说白了,就是给每个任务划定一个“地盘”。任务只能在自己的地盘里活动,越界就触发异常。这样,栈溢出、野指针、非法访问,都能在第一时间被捕获。
我的建议:在RTOS移植阶段就把MPU加上,不要等出问题了再补。虽然配置MPU会多花半天时间,但后面调试省下的时间绝对不止半天。
4.3 MPU的基本配置
MPU的核心是区域(Region)。每个区域由三部分描述:基地址、大小、属性。Cortex-M4最多支持8个区域,编号0-7。
配置流程其实不复杂,我拆成几步来说:
- 使能MPU——写MPU_CTRL寄存器,使能PRIVDEFENA(特权默认映射)
- 配置区域——设置基地址、大小、权限、可缓存性等
- 使能区域——写MPU_RNR选择区域号,写MPU_RBAR和MPU_RASR
- 验证——读回MPU_RBAR和MPU_RASR确认配置生效
下面是一个典型的配置代码,我习惯把它封装成一个函数:
/* MPU区域配置结构体 */
typedef struct {
uint32_t base; /* 基地址 */
uint32_t size; /* 大小,使用MPU_SIZE_xxx宏 */
uint32_t attr; /* 属性,可缓存/可共享/权限等 */
} MPU_RegionConfig;
/* 配置一个MPU区域 */
void MPU_ConfigureRegion(uint8_t region, MPU_RegionConfig *cfg) {
/* 检查参数 */
if (region > 7) return;
/* 选择区域号 */
MPU->RNR = region;
/* 写基地址和区域号 */
MPU->RBAR = cfg->base | MPU_REGION_VALID | region;
/* 写大小和属性 */
MPU->RASR = cfg->size | cfg->attr | MPU_REGION_ENABLE;
/* 数据同步屏障,确保配置生效 */
__DSB();
__ISB();
}
注意:配置MPU后一定要加DSB和ISB指令。我见过有人忘了加,结果MPU配置没生效,程序跑飞了还找不到原因。DSB保证配置写入完成,ISB刷新指令流水线。
4.4 典型的内存分区方案
在RTOS场景下,我一般把8个区域这样分配:
| 区域号 | 地址范围 | 用途 | 权限 |
|---|---|---|---|
| 0 | 0x0000 0000 - Flash大小 | 代码区(只读) | 特权+用户:读/执行 |
| 1 | 0x2000 0000 - SRAM大小 | 系统数据区(内核变量) | 特权:读/写;用户:无 |
| 2 | 任务A栈空间 | 任务A的私有栈 | 特权+用户:读/写 |
| 3 | 任务B栈空间 | 任务B的私有栈 | 特权+用户:读/写 |
| 4 | 任务C栈空间 | 任务C的私有栈 | 特权+用户:读/写 |
| 5 | 共享内存区 | 任务间通信缓冲区 | 特权+用户:读/写 |
| 6 | 外设寄存器区 | 关键外设(如DMA) | 特权:读/写;用户:无 |
| 7 | 系统控制区 | NVIC、MPU自身 | 特权:读/写;用户:无 |
这个方案的核心思想是:任务栈隔离、内核数据保护、外设访问控制。每个任务只能看到自己的栈和共享区,不能碰别的任务的东西。
避坑指南:我曾经把任务栈区域设得太大,结果区域重叠了。MPU对重叠区域的处理是:高编号区域优先。所以如果你区域0和区域1重叠,区域1的配置会覆盖区域0。设计时一定要画图确认区域不重叠。
4.5 内存映射与MPU关系图
下面这张图展示了Cortex-M4的完整内存映射,以及MPU如何在这些区域上设置保护边界。我特意把系统控制区单独标出来——因为MPU的寄存器就在那里。
4.6 实战中的注意事项
配置MPU时,有几个坑我踩过,分享给大家:
- 区域大小必须是2的幂次——比如256B、512B、1KB、2KB……不能随便设个300B。而且基地址必须对齐到区域大小。
- 使能MPU后,默认所有访问都受控——如果你没配任何区域,默认映射(PRIVDEFENA)会生效。特权模式可以访问所有空间,用户模式只能访问已配区域。
- 中断服务函数要小心——ISR运行在特权模式,但如果你的ISR访问了未配区域,还是会触发MemManage异常。
- 调试器可能被MPU挡住——有些调试器通过SWD访问内存,如果MPU限制了对应区域,调试器就读不到数据。我遇到过好几次,最后发现是MPU把调试器挡在了门外。
小技巧:调试阶段可以先把MPU关掉,等基本功能调通了再打开MPU。或者用条件编译控制MPU的使能,方便切换。
4.7 为RTOS铺路
好了,说了这么多,MPU和RTOS到底怎么结合?
简单来说:每个任务切换时,更新MPU区域配置。任务A运行时,MPU只开放任务A的栈和共享区;切换到任务B时,MPU重新配置,只开放任务B的栈和共享区。
这个切换过程发生在PendSV异常里。具体怎么做?我会在后面的章节详细讲。现在你只需要记住:MPU是RTOS安全性的基石。没有MPU,任务隔离全靠自觉;有了MPU,硬件帮你盯着。
我个人觉得,MPU是Cortex-M系列被低估的特性之一。很多人觉得它配置麻烦、用不上。但当你真正遇到内存踩踏、栈溢出、野指针这些问题时,你就会发现——MPU是真香。