网关固件里 Modbus、MQTT、OTA 挤在一个地址空间,一个野指针就能改写协议栈缓冲,而软件长度检查只能降低概率、划不出边界。Cortex-M3 的 MPU 用 8 个区域给内核访问画硬边界,本文讲清 RASR 的 SIZE、AP、XN、SRD 位定义与 MemManage 处理,附寄存器级配置代码与七步清单。
物联网网关的固件越写越大,Modbus、MQTT、OTA、日志四拨代码挤在一个地址空间里,一个野指针就能把协议栈的缓冲改写掉。结论先给:Cortex-M3 的 MPU 能把地址空间切成最多 8 个区域,用 RASR 的 AP、XN、SIZE 位分别控制"能不能读写、能不能取指、覆盖多大",越界访问会在指令执行的当下触发 MemManage 故障,比事后校验缓冲区可靠得多。前提是芯片真的集成了 MPU——先读 MPU_TYPE 的 DREGION 位确认。
场景很常见:一台 4G 网关同时跑采集任务和 MQTT 任务,OTA 升级后偶发死机。日志显示 MQTT 任务的栈被写花,但代码里每个缓冲区都有长度检查。问题在于,长度检查是"写之前"的软件判断,野指针、数组下标算错、结构体成员偏移变化都可能绕过检查;更麻烦的是中断或 DMA 在检查之后、写入之前插进来。软件检查只能降低概率,不能划出边界。
MPU 的思路不一样:它在总线上给每段地址配一张"通行证",内核每次取指、读写都先比对区域属性,不合规就当场产生 MemManage 故障并记录故障地址。代价是要提前规划内存布局,而且 MPU 只管内核访问,DMA 不受它约束。
| 寄存器 | 地址 | 作用 | 关键位 |
|---|---|---|---|
| MPU_TYPE | 0xE000ED90 | 类型寄存器,复位值 0x00000800 | DREGION[15:8]=0x08 表示 8 个区域;为 0 表示未集成 MPU |
| MPU_CTRL | 0xE000ED94 | 总开关与背景区控制 | ENABLE[0]、HFNMIENA[1]、PRIVDEFENA[2] |
| MPU_RNR | 0xE000ED98 | 区域号选择 | REGION[7:0] |
| MPU_RBAR | 0xE000ED9C | 区域基址 | ADDR[31:N]、VALID[4]、REGION[3:0] |
| MPU_RASR | 0xE000EDA0 | 区域属性与大小 | XN[28]、AP[26:24]、SRD[15:8]、SIZE[5:1]、ENABLE[0] |
MPU_CTRL 的三个位各有分工:ENABLE 是总开关;PRIVDEFENA 决定特权模式下未被区域覆盖的部分是否按默认存储器映射放行;HFNMIENA 决定 HardFault、NMI 与 FAULTMASK 升级期间 MPU 是否继续生效。复位值全为 0,也就是上电默认关闭、无背景区、故障处理中不启用 MPU。
| SIZE 编码 | 区域大小 | 典型用途 |
|---|---|---|
| b00100 | 32B | 单个寄存器块、标志位 |
| b00111 | 256B | 小型 DMA 描述符 |
| b01001 | 1KB | 通信帧缓冲 |
| b01011 | 4KB | 任务栈 |
| b01101 | 16KB | 日志区 |
| b01111 | 64KB | SRAM 数据区 |
| b10001 | 256KB | 程序区(小容量型号) |
| b10011 | 1MB | 程序区(大容量型号) |
| b10101 | 4MB | 外部 QSPI Flash 映射 |
| b11111 | 4GB | 整片地址空间背景区 |
区域大小不是直接写字节数,而是写 5 位编码,实际大小等于 2(SIZE+1)。最小 32B(b00100),最大 4GB(b11111),中间每加 1 翻一倍。基址必须按区域大小对齐:64KB 区域只能落在 64KB 的整数倍地址上,否则地址低位无效,配置行为不可预测。
| AP[2:0] | 特权许可 | 用户许可 | 典型用途 |
|---|---|---|---|
| b000 | 不可访问 | 不可访问 | 彻底封死的保留区 |
| b001 | 读/写 | 不可访问 | 外设寄存器,只允许内核特权访问 |
| b010 | 读/写 | 只读 | 共享参数区,任务只读、系统可写 |
| b011 | 读/写 | 读/写 | 普通 SRAM 数据区 |
| b101 | 只读 | 不可访问 | 特权只读区 |
| b110 | 只读 | 只读 | 程序区、校准表 |
| b111 | 只读 | 只读 | 只读区(与 b110 等效) |
XN=1 禁止从该区域取指,这一位在防跑飞上价值最大——数据缓冲区、栈、外设区都应该设 XN。这样函数指针被改写后跳过去,触发的是取指故障,而不是执行一段随机数据。
外设区还要注意 TEX、C、B 三个属性位:它们决定存储器类型与缓存策略,寄存器访问通常配成"器件/强序",避免总线重排把读写顺序打乱。TEX=000、C=0、B=1 对应共享器件属性,是外设区常用的组合。
SRD[15:8] 把区域等分成 8 个子区域,某位置 1 就禁能对应的 1/8,最低位对应第一段。它适合处理"大区域里挖一个小洞"的情况:比如 512KB 的 SRAM 区域,底部 64KB 要留给另一块属性不同的缓冲区,把 SRD 设成 b11111110 即可。注意 32B、64B、128B 三种小区域不支持子区域,设置了结果不可预测。
PRIVDEFENA=1 时,特权访问没有被任何区域覆盖的部分,按默认存储器映射放行;等于 0 时没有区域覆盖就拒绝访问。很多人只配了两三个区域就把 ENABLE 置 1,结果中断向量、系统区、未规划的 SRAM 全部触发 MemManage,系统立刻死。稳妥的顺序是:先把 Flash、SRAM、外设、栈区都配好,再打开 ENABLE;调试阶段可以先置 PRIVDEFENA=1 做兜底,量产再收紧。
还有一个细节:使能 MPU 后,系统分区和向量表始终可访问,这是硬件保证的;但用户模式代码如果没有对应区域,连自己的代码都取不了。RTOS 里切到用户任务前,必须先把任务代码区、数据区、栈区配全。
SHCSR(0xE000ED24)的 MEMFAULTENA(bit16)必须置 1,否则 MPU 违例不会被 MemManage 接管,而是升级为 HardFault,HFSR 的 FORCED 位置 1,故障地址还在 MMAR 里,但处理入口变了。MemManage 处理函数里读 MMFSR:IACCVIOL 表示取指违例,DACCVIOL 表示数据访问违例,MMARVALID=1 时 MMAR(0xE000ED34)给出触发地址。
产品上的处理策略不建议"记录后继续跑":越界已经发生,数据可能已被改写。更稳妥的做法是记录 PC 与 MMAR、把当前任务标记为异常、停止输出并进入安全状态,由看门狗复位。
| 步骤 | 动作 | 要点 |
|---|---|---|
| 1 | 读 MPU_TYPE | DREGION=8 才继续;为 0 则该芯片无 MPU |
| 2 | 规划区域 | 列出每段的基址、大小、读写与取指权限 |
| 3 | 对齐基址 | 基址必须是区域大小的整数倍 |
| 4 | 写 RNR/RBAR/RASR | 先选区号,再写基址与属性 |
| 5 | 开 MEMFAULTENA | SHCSR bit16 置 1 |
| 6 | 置 PRIVDEFENA 后 ENABLE | 先兜底再收紧,避免一开就死 |
| 7 | 故意越界验证 | 写只读区、从数据区取指,确认能进 MemManage |
| 区号 | 地址范围 | 大小 | SIZE | AP | XN | 用途 |
|---|---|---|---|---|---|---|
| 0 | 0x08000000~0x0807FFFF | 512KB | b10010 | b110 | 0 | 程序区,只读可执行 |
| 1 | 0x20000000~0x2000FFFF | 64KB | b01111 | b011 | 1 | SRAM 数据区,可读写不可取指 |
| 2 | 0x20010000~0x20010FFF | 4KB | b01011 | b011 | 1 | 主栈,独立区域便于发现溢出 |
| 3 | 0x40000000~0x4001FFFF | 128KB | b10000 | b001 | 1 | 外设区,仅特权访问 |
| 4 | 0x1FFFF000~0x1FFFF7FF | 2KB | b01010 | b110 | 1 | 系统存储与 UID,只读 |
#define MPU_CTRL (*(volatile uint32_t *)0xE000ED94UL)
#define MPU_RNR (*(volatile uint32_t *)0xE000ED98UL)
#define MPU_RBAR (*(volatile uint32_t *)0xE000ED9CUL)
#define MPU_RASR (*(volatile uint32_t *)0xE000EDA0UL)
/* size_code:5 位大小编码;ap:3 位许可;xn:1 禁止取指 */
static void mpu_region(uint32_t no, uint32_t base,
uint32_t size_code, uint32_t ap, uint32_t xn)
{
MPU_RNR = no;
MPU_RBAR = base & 0xFFFFFFE0UL; /* 清掉 VALID 与 REGION */
MPU_RASR = (xn << 28) | (ap << 24) |
(size_code << 1) | 1UL; /* bit0 区域使能 */
}
void mpu_init(void)
{
mpu_region(0, 0x08000000UL, 0x12, 0x6, 0); /* Flash 512KB */
mpu_region(1, 0x20000000UL, 0x0F, 0x3, 1); /* SRAM 64KB */
mpu_region(2, 0x20010000UL, 0x0B, 0x3, 1); /* 栈 4KB */
mpu_region(3, 0x40000000UL, 0x10, 0x1, 1); /* 外设 128KB */
SCB->SHCSR |= (1UL << 16); /* 使能 MemManage */
MPU_CTRL = (1UL << 2) | 1UL; /* PRIVDEFENA + ENABLE */
}
基址没对齐。区域大小 64KB,基址却写成 0x20008000,低 15 位无法被硬件忽略,配置结果不可预测。规划时先把大小定下来,再把基址向下取整到大小边界。
区域重叠的优先级。MPU 允许区域重叠,编号大的区域优先级高,会覆盖低编号区域。利用这一点可以把"小洞"放在高编号区域,而不必拆掉大区域。
把栈区配成只读。异常压栈会写栈,只读权限会直接产生 MSTKERR,表现成"一进中断就死"。栈区必须是可读写。
以为 DMA 也受 MPU 管。MPU 只约束内核发起的访问,DMA 控制器直接访问存储器,绕过 MPU。指望 MPU 防住 DMA 越界,方向就错了——DMA 要靠描述符校验和源/目的地址检查。
一句话总结:MPU 不是"更严格的编译器检查",而是给内核访问画硬边界:8 个区域、SIZE 编码、AP 与 XN 三个维度,配好之后越界会在当场触发 MemManage 并留下地址。先把区域规划表和基址对齐做对,再谈权限收紧。
本文基于《Cortex-M3 技术参考手册》第 9 章(存储器保护单元)与第 8 章(故障状态寄存器)整理优化。
沧州艾诺威电子 — 国家高新技术企业,20+项国家专利
嵌入式系统开发 · 物联网方案 · AI智能硬件 · 一站式交付
电话:13930711029 | 邮箱:tech@czinv.com | 24小时内响应