
该文章同步至OneChan你的系统偶尔会死机调试器抓到的 HardFault 回溯指向一个完全不相关的函数。查了三天发现是某个任务的栈悄悄溢出踩烂了另一个任务的堆栈。这种“暗箭”在运行几天后才触发最是致命。这是资深工程师压箱底的编程技巧系列第三十九篇。前面我们学会了用packed映射协议帧用 bit-band 实现原子位操作用内存屏障控制访问顺序。今天这一招作用在硬件层面上——利用 Cortex-M 处理器内置的内存保护单元为你的关键内存区域尤其是栈筑起一道物理屏障。一旦有代码试图越界处理器会在第一时间抛出异常让你立刻定位元凶而不是在数天之后面对莫名其妙的崩溃。这就是嵌入式系统中实现内存隔离的利器MPUMemory Protection Unit内存保护单元。对于跑裸机或 RTOS 的 Cortex-M3/M4/M7 芯片MPU 是提升系统鲁棒性的关键硬件。而且正确地使用它几乎不增加任何运行时开销。一、这东西到底是干什么用的简单说MPU 可以将物理内存划分为多个区域并为每个区域设置独立的访问权限。当 CPU 访问某个地址时MPU 硬件会在指令执行的同时检查该地址所属区域的权限。如果违反了规则例如写只读区、访问未授权区、执行数据区立即触发 MemManage Fault 异常系统可以在异常处理中捕获并记录错误现场。MPU 能做哪些事隔离任务栈给每个 RTOS 任务栈设置保护区域当某个任务栈溢出时会踩到 MPU 保护的红线立刻触发异常而不是继续运行直到破坏其他数据。保护关键数据将系统配置、标定参数、固件版本等关键数据区设为只读或特权访问防止野指针或缓冲区溢出破坏这些数据。防止代码在数据区执行将 RAM 标记为不可执行即使有人尝试注入代码并跳转执行也会被 MPU 拦截。这是许多安全认证如 IEC 61508、ISO 26262的基本要求。外设寄存器隔离防止用户态代码直接访问关键外设寄存器如时钟控制器、复位寄存器强制通过驱动 API 操作。MPU 与 MMU 的区别MMU 除了保护还能做虚拟地址映射页表主要在 Cortex-A 等应用处理器上运行 Linux 时使用。而我们今天聚焦的 MPU 是轻量级的在 Cortex-M 上直接操作物理地址适合 RTOS 和裸机场景配置简单延迟极低。二、上硬菜直接看怎么用Step 1配置 MPU 区域来保护主栈假设我们需要保护主栈MSP 所在的栈不被溢出破坏。栈通常位于 RAM 的末尾我们可以将栈底以下的一小段“保护区”设置为不可访问这样栈一旦溢出到保护区就会触发 MemManage Fault。以 STM32F4Cortex-M4为例MPU 区域配置代码通常放在main()开始之前或系统初始化中#includestm32f4xx.hvoidMPU_Init(void){// 禁用 MPU以便配置MPU-CTRL0;// 区域 0主栈保护区// 主栈从 0x20000000 RAM_SIZE 向下增长// 我们保留栈底 32 字节作为保护区MPU-RNR0;// 选择区域 0MPU-RBAR(0x20018000-0x200)|(00);// 保护区的起始地址假设 RAM 128KB栈顶在 0x20018000我们保护栈底之上 0x200 字节MPU-RASR(0x0024)|// 不使能默认不允许访问(0x0319)|// 保留(0x0816)|// 区域大小 2^8256 字节保护区 256 字节(0x078)|// 子区域禁用不适用全写1(0x005)|// 关闭指令访问(0x003)|// 关闭写访问(0x001)|// 关闭读访问(0x010);// 使能此区域// 更简单的做法保护区设置为不可访问属性 0x00无权限// 区域 1允许对整个 RAM 特权访问MPU-RNR1;MPU-RBAR0x20000000;MPU-RASR(0x0324)|// 特权读写(0x1219)|// 区域大小 2^18256KB覆盖全部 RAM(0x008)|// 子区域全使能(0x015)|// 开启指令访问使能执行(0x013)|// 写访问(0x011)|// 读访问(0x010);// 使能// 区域 2保护外设区仅供特权访问MPU-RNR2;MPU-RBAR0x40000000;MPU-RASR(0x0324)|// 特权读写(0x1B19)|// 区域大小 2^27128MB覆盖整个外设区(0x008)|// 子区域全使能(0x005)|// 不使能执行(0x013)|(0x011)|(0x010);// 启用 MPU使用默认内存映射作为背景区域MPU-CTRLMPU_CTRL_ENABLE_Msk|MPU_CTRL_PRIVDEFENA_Msk;}原理我们首先在栈底部划定了一个“保护区”它被配置为不可访问读、写、执行全禁止。一旦栈溢出到这个区域CPU 访问地址时就会产生 MemManage Fault。同时我们为整个 RAM 和外设区设置了允许访问的权限但外设区不使能指令执行防止代码注入执行。PRIVDEFENA位启用后任何未在 MPU 区域中显式覆盖的内存都继承默认的权限映射如 Flash 可执行RAM 可读写这简化了配置。Step 2在 MemManage Fault 中记录现场voidMemManage_Handler(void){volatileuint32_tmmfarSCB-MMFAR;// 引发故障的地址volatileuint32_tcfsrSCB-CFSR;// 包含 MMFAR 有效位// 将 mmfar 和当前任务上下文保存到 .noinit 区域然后重启或停机while(1);}当栈溢出发生时MMFAR 将记录触达保护区的那次访问地址直接指向溢出点附近的代码。这比调试几天的随机死机要高效得多。三、举一反三MPU 的这些用法让系统固若金汤1. 与 FreeRTOS 结合为每个任务分配专用保护区FreeRTOS 从 v10.3 开始引入了configMPU_WRAPPERS可以在任务切换时自动更新 MPU 区域让每个任务只能访问自己的栈和全局共享数据无法破坏其他任务的栈。这是安全关键系统的标准配置。即使不用 FreeRTOS 的封装自己实现也很简单在任务栈的底部设置保护区域当任务切换时更新该区域基址为当前任务的栈底。这样任何任务栈溢出都会被当场捕获不会殃及他人。2. 防止数组越界破坏标定数据如果你有一段保存设备标定值的 Flash 或 RAM 区域可以配置 MPU 区域将其设为只读。应用程序试图写入时直接触发 MemManage Fault而不是静默破坏。// 将 0x0800F000 开始的 4KB 设为只读禁止写入MPU-RNR3;MPU-RBAR0x0800F000;MPU-RASR(0x0624)|// 特权只读用户只读(0x0B19)|// 大小 4KB(0x008)|// 子区域全使能(0x015)|// 使能执行(0x003)|// 禁止写(0x011)|// 允许读(0x010);// 使能3. 利用子区域实现更细粒度的权限MPU 的区域大小必须是 2 的幂如 32 字节、64 字节、……但通过“子区域”可将一个区域分为 8 等份每份独立开启或禁用。如果一个 256 字节的区域你可以只用 32 字节作为真正的保护区其余作为正常访问区而不必额外占用 MPU 条目。这对于 Cortex-M 仅有的 8 个或 16 个MPU 区域来说是宝贵的资源。4. 检测栈溢出方向与保护区位置的配合栈通常从高地址向低地址增长保护区应放在栈的最小地址下方栈底再往下。如果栈是向上增长的极少见保护区则在上方。在 RTOS 任务栈中通常每个任务的栈是一个连续内存块保护区放在块的起始位置最低地址因为栈向低增长一旦越界就会访问保护区。四、留两个问题给你思考现在请你停下来推演这两个实际问题MPU 的某个区域配置错误比如覆盖范围过大导致正常的栈访问也被阻止系统会怎样如何在开发阶段避免这种误配置如果某个任务栈溢出踩到了 MPU 保护区但 MemManage Fault 的优先级很低被其他高优先级中断延迟处理会不会导致保护失效想清楚这两个问题你就能在产品中放心地开启 MPU 保护而不是担心它带来新的不稳定。五、总结与思考题回答核心总结MPU内存保护单元是 Cortex-M 硬件特性可划分内存区域并设置独立访问权限越权访问立即触发 MemManage Fault。典型应用保护任务栈溢出、关键数据只读保护、防止代码在 RAM 执行、隔离用户态与特权态。配置要点区域大小必须是 2 的幂且对齐可配合子区域实现精细控制通常启用PRIVDEFENA减少配置量。性能开销MPU 检查与 CPU 指令并行无额外延迟完全零运行时开销。思考题回答问题1区域配置错误导致正常访问也被阻止如何预防如果保护区设置过大覆盖了正常栈空间系统会在栈操作时立即 MemManage Fault很可能在启动阶段就崩溃。预防措施在开发阶段使用调试器在 MPU 配置前后检查 MPU 的 RNR、RBAR、RASR 寄存器值确保区域地址和大小正确。可以在配置完成后立即在代码中尝试访问保护区首地址用assert或手动触发异常来验证区域是否确实不可访问。使用static_assert在编译期确保地址和大小常量对齐、合法。在 CI 中加入测试用例通过模拟栈溢出如声明一个巨大的局部数组来验证 MPU 能正确捕获。问题2MemManage Fault 优先级低会不会被延迟导致保护失效不会。MemManage Fault 一旦发生CPU 会立即进入异常处理而不受中断优先级影响MemManage Fault 的优先级确实可配置但通常设为最高或次高。即使它的优先级低于某个高优先级中断当 MemManage Fault 发生时如果正在执行的那个高优先级中断中发生了越权访问CPU 依然会立即响应 MemManage Fault因为它是同步异常。如果 MemManage Fault 的优先级比当前正在执行的中断更低且该中断屏蔽了此异常则可能无法响应。因此必须将 MemManage Fault 的优先级配置得足够高通常等于或高于所有需要保护的中断优先级以确保能在越权发生时立即捕获。这在标准的安全固件配置中是基本操作。好了第 39 招我们就彻底吃透了。从今天起别再让你的栈溢出无声地破坏系统。开启 MPU给关键内存穿上铠甲让每一次越权都当场现形。如果今天的内容让你对芯片的硬件保护能力有了新的认识欢迎转发和点赞。下一篇我们继续挖使用双堆栈技术PSP/MSP隔离内核模式与用户模式代码。咱们不见不散