说起 STM32 里的内存保护单元(MPU),我以前也一直觉得它有点“高配”的感觉——M3/M4 内核手册里那一大章寄存器,看着就劝退,总想着 MCU 上就那点 RAM,还用得着保护?直到一次做伺服驱动器,被一个栈溢出 bug 折磨了两周,才老老实实把 MPU 这块硬骨头啃下来。那次故障现象特别诡异:PID 参数跑着跑着随机被改掉,逻辑分析仪抓不到,在线调试也复现不了,最后把程序拆开看,才发现是一个任务栈越界,把另一块全局参数数组给踩了。这种问题,没有 MPU 基本就是大海捞针,有了 MPU,一次 MemManage 异常就能把元凶抓出来。
这篇文章我想从工程角度把 STM32 的 MPU 讲透,主线是 Cortex-M3/M4/M7 这类 ARMv7-M 内核,M33 这类 ARMv8-M 的差异也会单独说。内容包括寄存器级别的原理、手写配置和 HAL 库两种写法、用 MPU 做栈溢出保护的完整实操,还有我实际调试中踩过的一堆坑。不管你是刚接触 STM32 的新手,还是被内存问题折磨过的老手,照着这篇文章做一遍,基本就能把 MPU 用起来了。
1. 先搞清楚:MPU 到底在保护谁
1.1 从一次 DMA 把参数区踩烂说起
先说一个最能体现 MPU 价值的真实场景。我之前做数据采集设备,用定时器触发 DMA 搬运 ADC 数据,DMA 目标缓冲区就放在内部 SRAM 里。有一次改代码,把 DMA 传输长度多写了 4 个字节,就 4 个字节,结果 DMA 每完成一次传输,都会把缓冲区后面紧跟的一个状态标志位覆盖掉。更坑的是,这个标志位的初始值刚好是 0,被 DMA 写的 0 覆盖后,程序还能正常运行一段时间,直到状态机切到某个特定分支才出错。
这类问题的共性是什么?是“内存越界写”这件事本身很难被实时感知。CPU 按照正常流程取指、执行,DMA 也在按规则搬数据,谁都没犯错,但合在一起,内存里的数据就错了。如果你在 DMA 缓冲区所在区域配置了一个 MPU 区域,并且把缓冲区末尾那 4 个字节排除在保护区之外,那么当 CPU 或外设访问越界时,处理器会立刻触发 MemManage Fault。虽然 DMA 本身的访问不走 MPU(这个后面详细说),但至少 CPU 侧任何越界操作都会被第一时间抓住,这就是 MPU 最核心的价值——它给内存访问加了一道“硬件红绿灯”。
1.2 硬件层的“内存监票员”
MPU 不是 ST 公司特有的外设,它是 ARM Cortex-M 内核自带的一个保护模块,只不过 STM32 把它继承了下来。它的工作位置很特殊:位于 CPU 内核和总线矩阵之间,CPU 发出的每一个内存访问请求(取指令、读数据、写数据),都会先经过 MPU 的检查,检查通过才真正发到总线上去,检查不通过就直接触发异常。
你可以把内存空间划分成若干个区域,每个区域可以独立配置四项东西:基地址、大小、访问权限(特权/非特权、读/写/只读)、内存属性(是否可缓存、可缓冲、可共享)。这有点像小区门禁系统,每个区域规定哪些人可以进、能看不能动。CPU 每次访问内存,MPU 就拿这次访问的地址去跟所有区域比对,命中哪个区域就按那个区域的规则执行,一个区域都匹配不上、且默认映射也被关闭时,就产生 MemManage Fault。
这里有个容易误解的点:MPU 管的是“访问权限”,不是“内存完整性”。它不能防止硬件故障、不能防止 ECC 错误,也不能防止 DMA 写错地址。它的作用是:当 CPU 侧发生非法访问时,第一时间把系统拉停,让你在调试器里看到案发现场。
1.3 MPU 管不了什么
说清楚 MPU 管什么,同样重要的是说清楚它不管什么。否则很多人会在 MPU 上寄托不切实际的期待,遇到问题反而找不到原因。
第一个管不了的就是 DMA 访问。DMA 控制器、以太网 MAC、USB 控制器这些都是总线主设备(bus master),它们发起的内存访问不经过 CPU 内核,自然也就不会被 MPU 检查。所以如果你的 DMA 配置错误,把数据写到了保护区,MPU 是完全不知情的。想给 DMA 加保护,得靠 DMA 自己的边界校验、描述符校验或者软硬件配合的地址检查。
第二个管不了的是外设内部的非法操作。比如你往一个 USART 的数据寄存器写了个非法值,或者 SPI 的波特率配置超出了范围,这些属于外设自身的功能逻辑,跟内存访问权限无关。
第三个要明确的是,MPU 只是“检测器”,不是“修复器”。它能在故障发生那一刻给你一个异常中断,但不会帮你恢复数据、回滚状态。在真正的安全关键系统里,你要做的是利用这个异常做故障记录、紧急停机、状态恢复,而不是指望 MPU 自己把问题解决掉。
这些边界想清楚了,你对 MPU 的预期就会准确很多。接下来进入寄存器层面,看看它到底是怎么工作的。
2. 寄存器视角:8 个 MPU 区域怎么用
2.1 需要认识的寄存器
STM32 的 ARMv7-M 内核(M3/M4/M7)里,MPU 相关寄存器就那么几个,都挂在系统控制空间(SCS)里。第一次看的时候觉得多,拆开其实非常规律:
| 寄存器 | 作用 | 地址(M3/M4 典型值) |
|---|---|---|
| MPU_TYPE | 只读,查询 MPU 支持的区域数量 | 0xE000ED90 |
| MPU_CTRL | MPU 总开关,控制默认映射、NMI/HardFault 行为 | 0xE000ED94 |
| MPU_RNR | 区域编号,当前操作的是第几个区域 | 0xE000ED98 |
| MPU_RBAR | 区域基地址 + VALID 有效位 | 0xE000ED9C |
| MPU_RASR | 区域大小、权限、属性、子区域配置、使能 | 0xE000EDA0 |
MPU_CTRL 的三个位最常用:ENABLE 是总开关;PRIVDEFENA 决定是否启用“默认内存映射”(也就是芯片参考手册里那个从 0x00000000 到 0xFFFFFFFF 的完整地址映射),如果使能了,则特权模式下 CPU 可以访问没有配置区域的内存;HFNMIENA 控制 MPU 在 HardFault 和 NMI 处理器中是否继续生效,默认建议关闭,否则一旦这两个异常里访问了被保护内存,系统会直接锁死。
MPU_RASR 是整个 MPU 配置里信息最大、也最容易写错的寄存器。它里面包含 ENABLE(区域使能)、SIZE(区域大小编码)、AP(访问权限)、SRD(子区域禁用)、TEX/C/B/S(内存属性)、XN(禁止取指令执行)等字段。ARMv7-M 里 SIZE 字段的编码规则是:区域大小 = 2^(SIZE+1)。也就是说,你需要 4KB 的区域(4096 字节 = 2^12),SIZE 就填 11(因为 11+1=12);需要 256 字节的最小区域,SIZE 就填 7。区域基地址必须对齐到区域大小,比如 4KB 区域,基地址低 12 位必须是 0。
2.2 子区域:一个区域当多个用
ARMv7-M 的 MPU 每个区域都可以进一步划分为 8 个等大的子区域(Subregion)。这个功能非常实用,但很多应用笔记把它一笔带过了。SRD 字段是一个 8 位的位图,每一位对应一个子区域,置 1 表示禁用该子区域。
举个例子:我配置一个 4KB 的区域,基地址 0x20000000,那么 8 个子区域按顺序分别是 0x20000000~0x200001FF、0x20000200~0x200003FF……直到 0x20000E00~0x20000FFF,每个子区域 512 字节。如果我把 SRD 设为 0x80,也就是最高位为 1,那么最后一个 512 字节子区域被禁用,CPU 访问 0x20000E00 到 0x20000FFF 这个地址范围就会触发 MemManage Fault,而访问前面的 3.5KB 一切正常。这个特性用来做“守卫页”再合适不过了,后面实战章节我会给出完整做法。
还有一点区域之间的优先级:如果两个已配置的区域地址有重叠,ARMv7-M 的规则是“区域编号越大,优先级越高”。也就是说 Region 7 的配置会覆盖 Region 3 的配置。实际工程里,我会刻意用这个特性做“精细覆盖”:先给一大片 RAM 设置宽松权限,然后在关键数据区叠加一个编号更大、权限更严格的区域。
2.3 ARMv8-M(M33)的差异
如果你用的是 STM32U5、STM32H5、STM32L5 这类基于 Cortex-M33 的芯片,MPU 的硬件设计跟 M3/M4/M7 差别很大,代码不能直接照搬。ARMv8-M 的 MPU 改了寄存器体系:用 MPU_RBAR 配基地址,用 MPU_RLAR 配限制地址和区域使能,内存属性不再存放在 RASR 的 TEX/C/B/S 位里,而是通过 MPU_MAIR0/MPU_MAIR1 配置属性,RBAR/RLAR 里只放一个属性索引字段。区域数量也不固定 8 个,有些实现支持到 16 个。
M33 上还有一个 M3/M4 没有的概念叫“背景区域”,你可以通过 MPU_CTRL 的 PRIVDEFENA 配合 MAIR 来给整个系统配置一个默认属性。所以如果你拿到的项目是 M33 内核,建议直接以 ST 官方 HAL 例程为参考,不要像我一样把 M4 上的寄存器写法搬到 M33 上,结果调试了整整一天发现地址根本不匹配。
理解寄存器和区域规则之后,下一步就是把配置代码写出来。我个人推荐先用寄存器手写一遍,理解每个位的含义,再用 HAL 封装替换,这样出了底层问题不会慌。
3. 从零配置 MPU:手写寄存器到 HAL 库
3.1 完整的寄存器配置流程
MPU 配置的标准顺序,我用精简的一句话总结为“关总闸、设区域、开总闸、刷流水线”。详细步骤是这样的:
- 清零 MPU_CTRL 的 ENABLE 位,关闭 MPU。
- 把需要配置的区域编号写入 MPU_RNR。
- 写 MPU_RBAR,设置区域基地址,并把 VALID 位置 1。
- 写 MPU_RASR,设置大小、权限、属性、子区域,并把 ENABLE 位置 1。
- 配置好所有需要的区域后,按需设置 MPU_CTRL 的 PRIVDEFENA。
- 把 MPU_CTRL 的 ENABLE 位置 1。
- 执行 __DSB() 和 __ISB()。
为什么最后必须加这两条屏障指令?因为 MPU 是内存映射寄存器,写入后需要确保它真正生效,DSB 保证前面的配置写入已经完成,ISB 保证在配置 MPU 前后预取的指令不会因为旧配置而出错。我见过不少人省掉这两条指令,短时间没问题,一旦代码分支发生变动,就会出现“偶尔配置不生效”的玄学问题。
下面是一段基于 CMSIS 的寄存器配置代码,保护 0x20000000 起始的一个 4KB SRAM 区域,特权读写、非特权禁止访问:
void MPU_Config(void) { // 1. 关闭 MPU MPU->CTRL = 0; // 2/3/4. 配置区域0 MPU->RNR = 0; // 选择区域0 MPU->RBAR = 0x20000000 | (1U << 0); // 基地址 + VALID MPU->RASR = (0U << 28) /* XN = 0,允许取指令 */ \ | (0b001U << 24) /* AP: 特权读写,非特权禁止 */ \ | (0x00U << 20) /* SRD: 所有子区域启用 */ \ | (0U << 18) /* S = 0 非共享 */ \ | (0U << 17) /* C = 0 不可缓存 */ \ | (0U << 16) /* B = 0 不可缓冲 */ \ | (11U << 1) /* SIZE = 4KB */ \ | (1U << 0); /* ENABLE */ // 5/6. 使能 MPU,并允许特权模式访问默认映射 MPU->CTRL = (1U << 0) /* ENABLE */ | (1U << 2) /* PRIVDEFENA */; // 7. 屏障 __DSB(); __ISB(); }这段代码里 AP 字段我用了0b001,它对应“特权模式可读写,非特权模式不可访问”。这是做任务间隔离时的常用配置。如果你一开始只是想让 MPU 跑起来不碍事,可以先用0b011(完全访问),等通了之后再收紧权限。
3.2 用 HAL 库的等价写法
如果项目是用 STM32CubeMX 生成的工程,通常都会用 HAL 的HAL_MPU_ConfigRegion,代码会简单很多,代价是一些细节被封装掉了。同样是配置 4KB 区域,HAL 的写法是这样的:
void MPU_Config(void) { MPU_Region_InitTypeDef MPU_InitStruct = {0}; HAL_MPU_Disable(); MPU_InitStruct.Enable = MPU_REGION_ENABLE; MPU_InitStruct.Number = MPU_REGION_NUMBER0; MPU_InitStruct.BaseAddress = 0x20000000; MPU_InitStruct.Size = MPU_REGION_SIZE_4KB; MPU_InitStruct.SubRegionDisable = 0x00; MPU_InitStruct.TypeExtField = MPU_TEX_LEVEL0; MPU_InitStruct.AccessPermission = MPU_REGION_PRIV_RW; MPU_InitStruct.DisableExec = MPU_INSTRUCTION_ACCESS_ENABLE; MPU_InitStruct.IsShareable = MPU_ACCESS_NOT_SHAREABLE; MPU_InitStruct.IsCacheable = MPU_ACCESS_NOT_CACHEABLE; MPU_InitStruct.IsBufferable = MPU_ACCESS_NOT_BUFFERABLE; HAL_MPU_ConfigRegion(&MPU_InitStruct); HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT); __DSB(); __ISB(); }注意HAL_MPU_Enable的参数:MPU_PRIVILEGED_DEFAULT对应 PRIVDEFENA=1,它会保留默认内存映射;MPU_HARDFAULT_NMI对应 HFNMIENA=1;MPU_HFNMI_PRIVDEF则两者都开。我建议调试阶段先不要开 HFNMIENA,否则在 HardFault 里触发 MPU 访问异常,系统会直接 lockup,调试器都连不上,排查问题会非常痛苦。
3.3 配置时机与屏障指令的讲究
MPU 的配置时机,我推荐放在 main 函数的最开始,越早越好。因为在启动文件里,__main(C 库初始化)会进行 .data 段和 .bss 段的复制清零,如果这个过程的地址落在你后配的保护区里、而你又在 main 里开启 MPU,可能在你还没配置时,某些库函数就已经访问了这些内存。
更稳妥的顺序是:在系统时钟初始化之后、外设初始化之前调用MPU_Config()。如果你用的是 FreeRTOS,需要特别注意:有一部分版本的 FreeRTOS 在启动时会创建任务、初始化堆,这些操作发生在 main 里你调用vTaskStartScheduler()之前,所以主函数开头配置 MPU 不会影响系统的启动流程。但如果你在 RTOS 运行后再动态配置 MPU,并且改动了一个正在内存中的任务区域,那后果就只能用“惊喜”来形容了。
关于屏障指令,我再说一个容易被忽略的点:__DSB()和__ISB()是 CMSIS 提供的编译器内置函数,在 ARMCC(Keil)、GCC(VS Code + arm-none-eabi-gcc、STM32CubeIDE)里都直接用,不需要额外包含头文件。配置完 MPU 后,我习惯再读一次 MPU->RASR 确认配置写入成功,这在老旧芯片版本上偶尔能救你一命。
4. 实战:用 MPU 给任务栈加“守卫墙”
4.1 思路:栈越界为什么会成为玄学问题
栈溢出在嵌入式里之所以难查,是因为它不像数组越界那样立刻报错,而是先悄悄覆盖相邻变量。RTOS 环境下,每个任务有自己的栈,栈顶栈底紧挨着别的任务控制块或全局数组。一旦某次函数调用嵌套过深,栈指针就越过了边界,写坏别人的数据。这种问题往往要跑好几个小时甚至随机触发,你根本来不及用调试器抓现场。
MPU 解决这个问题的思路特别朴素:在栈区的边界上留出一小块“守卫页”(Guard Page),这块内存不允许任何人访问。一旦栈真的溢出了,第一件触发的就是看守卫页的非法访问,于是立即触发 MemManage Fault,系统在故障发生瞬间被停下。你可以通过异常现场看到栈指针疯涨到了哪里,以及是哪层调用导致的。
在裸机环境下,最简单的做法是把主栈放在一个独立段里,让它占满一个 MPU 区域(比如 2KB),然后把其中最底部的一个子区域设为守卫。RTOS 下,每个任务栈需要单独一个区域,区域数量不够时可以只保护最关键的任务栈。
4.2 用子区域禁用做 512B Guard 的计算过程
前面提到 ARMv7-M 的 MPU 区域最小是 256 字节,8 个子区域。为了做守卫页,我选择一个 4KB 区域,把它分成 8 个 512B 子区域。我需要保护的栈数据占用前 7 个子区域,最后一个子区域设为禁用,作为 512B 的守卫。
配置时的计算如下:
- 区域基地址:如果栈区从 0x20001000 开始,那基地址就是 0x20001000,低 12 位为 0,满足 4KB 对齐。
- SIZE 字段:4KB = 2^12,所以 SIZE = 12 - 1 = 11。
- SRD 字段:第 7 个子区域(最高位)禁用,二进制是 1000 0000,即 0x80。
- AP 字段:设为全访问(0b011)或特权访问(0b001)都可以,一般调试阶段用全访问,避免栈保护功能干扰正常的用户代码。
- 需要确保链接脚本里栈段的起始地址与 4KB 边界对齐,栈空间至少 3584 字节,这样最后一个子区域才能作为守卫。
代码配置如下:
void MPU_Config_StackGuard(void) { MPU->CTRL = 0; // 关闭 MPU MPU->RNR = 0; MPU->RBAR = 0x20001000 | (1U << 0); // 基地址 0x20001000,VALID MPU->RASR = (0U << 28) // XN = 0 | (0b011U << 24) // AP: 特权/非特权都允许 | (0x80U << 20) // SRD: 禁用最高子区域 | (0U << 18) // S = 0 | (0U << 17) // C = 0 | (0U << 16) // B = 0 | (11U << 1) // SIZE = 4KB | (1U << 0); // ENABLE MPU->CTRL = (1U << 0) | (1U << 2); // 使能 MPU,保留默认映射 __DSB(); __ISB(); }有人会问,为什么不单独用一个区域做守卫,而是用子区域禁用?原因很简单:MPU 区域数量有限(M3/M4 只有 8 个),单独一个守卫区域就要占掉一个名额,而且区域之间可能互相重叠影响。子区域禁用不需要额外区域号,对区域数量非常友好。
4.3 测试:故意让栈溢出一次
配好之后,最好的验证方式就是故意写一个越界函数。比如写一个递归函数,每次递归消耗大量局部数组,这样很快就能触发守卫访问,从而进入 MemManage_Handler:
__attribute__((noreturn)) void StackOverflowTest(void) { volatile uint8_t dummy[1024]; dummy[0] = 0xAA; StackOverflowTest(); // 递归深度增加,栈不断下压 }注意:这种递归方式在现代编译器里可能被优化成死循环,或者产生警告。稳妥一点的做法是把dummy数组写成 volatile 数组,并且给递归函数加__attribute__((noinline))。如果你不想为了测试破坏工程,也可以直接用一个指针往栈底地址写值,比如:
uint32_t *p = (uint32_t *)0x20001000; // 守卫区域开始的地址 *p = 0xDEADBEEF; // 应该触发 MemManage Fault如果一切正常,调试器会停在 MemManage_Handler 里。如果它直接进 HardFault,说明 MemManage 异常没有使能,需要检查 SCB->SHCSR 的 MEMFAULTENA 位。CubeMX 生成的工程里默认会开启 MemManage 异常,但如果从寄存器层面配置过,这个位可能是 0。
4.4 在 MemManage_Handler 里看什么
进入了 MemManage_Handler 之后,第一件事不是打印日志,而是把故障现场保存下来。因为栈可能已经破坏了,此时任何复杂操作都有可能二次触发异常。我的习惯是在 SRAM 里固定放一个结构体,专门记录故障信息:
typedef struct { uint32_t mmfsr; uint32_t mmfar; uint32_t pc; uint32_t lr; uint32_t psp; uint32_t msp; } FaultInfo_t; volatile FaultInfo_t g_faultInfo; void MemManage_Handler(void) { // 读取故障状态寄存器 g_faultInfo.mmfsr = SCB->MMFSR; g_faultInfo.mmfar = SCB->MMFAR; // 从栈帧中提取 PC/LR uint32_t *stack_ptr = (uint32_t *)__get_PSP(); if ((SCB->ICSR & SCB_ICSR_VECTACTIVE_Msk) != 0) { // 异常向量正在执行,通常使用 MSP stack_ptr = (uint32_t *)__get_MSP(); } g_faultInfo.pc = stack_ptr[6]; g_faultInfo.lr = stack_ptr[5]; g_faultInfo.psp = __get_PSP(); g_faultInfo.msp = __get_MSP(); while (1) { // 等待调试器,或在这里翻转一个 IO 指示故障 } }读 SCB->MMFSR 前,要看它里面的 MMARVALID 位(bit 7)。如果该位为 1,说明 MMFAR 里的地址有效;如果为 0,说明这次 MemManage 不涉及数据访问地址(例如是栈错误),MMFAR 里面的值不可信。很多新手在这里吃了亏:明明想看是谁踩了栈,结果 MMFAR 里是个无关地址,反而被带偏。
__get_PSP()和__get_MSP()在 CMSIS 里都有。如果你是裸机单线程程序,栈帧一般用 MSP;如果是带 RTOS 的任务,则需要看触发异常时处理器处于线程模式还是处理模式。调试器里直接看 R14 (LR) 的 bit 2 也能判断用的是 PSP 还是 MSP,不过用代码记录更稳妥。
5. 调试实录:开启 MPU 后最常见的坑
5.1 症状-原因速查表
MPU 配置错误导致的故障,排障思路高度依赖于“症状”。我把实际调试中遇到过的、以及在社区里反复出现的问题整理成表,方便你排查时快速定位。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 一开 MPU 就进 HardFault | 没有使能 MemManage 异常,MPU 违规直接升级为 HardFault | 检查 SCB->SHCSR 的 MEMFAULTENA,保证例外已使能 |
| 默认外设访问(0x40000000 附近)故障 | 外设地址没有匹配任何区域,且 PRIVDEFENA 被关闭 | 配置 MPU 时设置 PRIVDEFENA=1,建议保留默认映射 |
| 访问某个变量时进入 Fault,但编译器说它合法 | 变量所在地址没有配置区域,或者区域基地址没对齐 | 检查该变量地址是否落在区域范围内,检查 RBAR 低 12 位 |
| MPU 配置写了但感觉没生效 | 缺少 DSB/ISB,配置在流水线中未生效 | 配置后必须执行 __DSB() 和 __ISB() |
| DMA 写坏了 RAM,但 MPU 没反应 | DMA 访问属于总线主设备访问,不受 MPU 保护 | 用 DMA 的传输完中断 + 校验、边界检查,或用 SCB 的 ARMv7-M 守护方式 |
| 开启 MPU 后系统运行明显变慢 | 内存属性配置不当,导致 M7 的 cache 不可用 | 检查 TEX/C/B/S 位,确保 RAM 区域设置了 cacheable 属性 |
| 中断里访问被保护区域触发 fault,但主程序没事 | 中断上下文访问了未匹配地址,可能被更高优先级抢占 | 检查中断函数中访问的地址,确认区域配置覆盖完整 |
如果你在 CubeMX 里生成工程,MPU 相关配置代码会生成在main()函数前部。很多人会用__disable_irq()包裹配置过程,其实不必要。MPU 的配置和中断的开关没有直接关系,真正需要注意的是:不要在中断服务函数里动态修改 MPU 区域,尤其是 RTOS 的 tick 中断,否则极大概率触发 HardFault。
5.2 三个实际踩过的坑
第一个坑:把整个 SRAM 配置成“特权访问”,结果用户任务直接崩。FreeRTOS 支持 MPU 版本,任务可以运行在非特权模式。非特权模式下,如果 SRAM 区域只允许特权访问,那么任何用户任务的变量读写都会触发 MemManage Fault,表面上看起来就是任务一启动就死。解决方法是给用户任务可访问的区域单独配置 AP=0b011,或者干脆让所有任务栈区域都用 AP=0b011,只把关键数据区用 AP=0b001 保护。
第二个坑:在 main 最开始调用 MPU_Config,但启动文件里 C 库初始化先用了内存。某些编译器启动库会在__main阶段完成 .data 段复制,而我的 MPU 配置却把这块 SRAM 设成了只读,导致程序一启动就死在 startup 的 memcpy 里。后来我把 MPU 配置挪到了系统时钟初始化之后、但明确要在 C 库初始化之后的位置,或者干脆配置为“允许所有访问”再启动,才解决。
第三个坑:M7(比如 STM32H7)上配置了 cacheable 属性,结果 DMA 拿到的数据和 CPU 看到的不一致。这个不是 MPU 配置错误,而是内存属性配置问题,但出现的形式是“MPU 一开,DMA 数据不对”。解决办法是把 DMA 缓冲区所在区域配置为 non-cacheable(TEX=0、C=0、B=0),或者在 DMA 传输前后执行 cache clean/invalidate。很多 H7 参考手册的例程里会把大块外部 SRAM 配置为 cacheable,但唯独 DMA 缓冲区特殊处理,就是这个原因。
5.3 性能影响:MPU 会不会拖慢 MCU
经常有同事问:MPU 开启后是不是 CPU 每个访问都要比对一次,程序会变慢?根据我在 M4 和 M7 上的实测,正常情况下 MPU 对性能的影响几乎测不出来。原因在于 MPU 的检查逻辑在 Cortex-M 内核里是并行执行的,和地址译码、数据读取发生在同一个流水线阶段,不会额外插入等待周期。
真正会影响性能的,是你给内存配置的属性。比如在 M7 上,如果 RAM 区域被配置成 non-cacheable,CPU 每次读写都会真正打到 SRAM,而 SRAM 的访问延迟比 cache 高不少,那些频繁访问该区域的代码会明显变慢。反过来,如果你把一块有 DMA 读写内容的区域配置成 cacheable,CPU 和 DMA 的数据一致性又会出问题。所以 MPU 配置本质上是在“性能”和“一致性/保护”之间做取舍,没有银弹。
我个人的建议是:按功能域划分 RAM,热路径数据(实时控制变量、ADC 缓冲)用 non-cacheable 或配置正确属性并配合 DMA;冷路径数据(配置信息、日志缓冲区、Web 页面)可以 cacheable;关键标志位、任务控制块这类需要跨 CPU/DMA 共享的区域,要么 non-cacheable,要么做显式 cache