第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。

配置流程其实不复杂,我拆成几步来说:

  1. 使能MPU——写MPU_CTRL寄存器,使能PRIVDEFENA(特权默认映射)
  2. 配置区域——设置基地址、大小、权限、可缓存性等
  3. 使能区域——写MPU_RNR选择区域号,写MPU_RBAR和MPU_RASR
  4. 验证——读回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的寄存器就在那里。

Cortex-M4 内存映射与MPU保护关系 Cortex-M4 内存映射与MPU保护关系 Code区 (0x0000 0000 - 0x1FFF FFFF) 程序代码 · 向量表 · 常量 · I-Code/D-Code总线 SRAM区 (0x2000 0000 - 0x3FFF FFFF) 堆 · 栈 · 全局变量 · 系统总线 外设区 (0x4000 0000 - 0x5FFF FFFF) GPIO · UART · TIM · DMA寄存器 · 系统总线 外部RAM区 (0x6000 0000 - 0x9FFF FFFF) 扩展SDRAM · NOR Flash · 外部总线 系统控制区 (0xE000 0000 - 0xE00F FFFF) NVIC · MPU · SysTick · 专用外设总线 MPU 区域配置 (Region 0-7) 基地址 · 大小 · 权限 · 可缓存性 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是真香。