1. 从一块开发板的启动日志说起:三种"内存管家"到底谁在管谁
第一次在串口终端里看到MMU enabled这行打印的时候,我盯着屏幕愣了几秒——明明前一秒代码还在按物理地址跑,后一秒地址空间就"换了个世界"。后来做带 DMA 的外设驱动,又撞上了SMMU translation fault,日志里一串 IOVA 地址看得人头皮发麻。再往后接触实时性要求高的场景,MPU 又冒了出来,配置粒度粗、没有页表、但胜在确定性极强。
这三个词——MPU、MMU、SMMU——在 ARM 体系里经常被放在一起讨论,但它们解决的问题、所处的层级、以及"谁为谁服务"的关系,很多人其实是模糊的。我见过不少做了两三年驱动的同行,能熟练写mmap、能配iommu_map,但被问到"MPU 和 MMU 能不能同时开""SMMU 的页表是谁建的"时,回答就开始含糊。
这篇内容就是想把这三者彻底拆开讲清楚。它适合正在做 ARM 平台底层开发、驱动移植、虚拟化或者异构计算的工程师,也适合刚接触 Cortex-A/R/M 系列、被地址翻译绕晕的朋友。我会从它们各自要解决的核心问题切入,讲清楚硬件层面的工作方式,再落到实际配置和踩坑经验上。核心关键词就三个:MPU、MMU、SMMU,外加它们共同依赖的地址翻译与内存保护机制。
先把一句话结论摆在这里,方便你建立坐标系:MPU 管的是"这块内存能不能访问",MMU 管的是"这个虚拟地址映射到哪块物理内存",SMMU 管的是"外设发出的地址怎么翻译成物理地址"。三者不是替代关系,而是分别站在 CPU 核内、CPU 核内、以及系统总线上的三个不同位置,各管一摊。
2. MPU:没有页表的"粗粒度门卫",为什么实时系统偏爱它
2.1 MPU 的本质:区域描述符而非页表
MPU 全称 Memory Protection Unit,内存保护单元。它和 MMU 最大的区别在于:MPU 不做地址翻译,只做访问权限检查。CPU 发出的地址就是物理地址,MPU 只是站在旁边看一眼——这个地址落在哪个区域(region)里?当前的特权级别和访问类型(读/写/执行)允许吗?允许就放行,不允许就触发 MemManage fault。
它的配置模型是"区域描述符":每个 region 用一组寄存器描述基地址、大小、权限、是否可缓存、是否可共享等属性。ARMv7-M(比如 Cortex-M3/M4/M7)通常支持 8 个 region,ARMv8-M 支持 16 个,并且可以给每个 region 配子区域(sub-region)做更细的禁用控制。
这里有个很多人第一次配 MPU 会踩的坑:region 的大小必须是 2 的幂,且基地址必须按大小对齐。比如你想保护 0x20000000 开始的 64KB SRAM,那 base 必须是 0x20000000,size 选 64KB,没问题。但如果你想保护一段 48KB 的区域,对不起,MPU 没有这个粒度,你只能拆成 32KB + 16KB 两个 region,或者干脆用 64KB 覆盖再靠 sub-region 关掉多余部分。
2.2 为什么 RTOS 和裸机场景更愿意用 MPU
MMU 需要页表、需要 TLB、需要页表遍历(page table walk),这一套下来,地址翻译的延迟是不确定的——TLB 命中快,未命中就要走内存访问页表,最坏情况下的延迟对硬实时系统是灾难。MPU 没有这个问题:权限检查是组合逻辑,固定周期,确定性极强。
所以在 RTOS(比如常见的实时操作系统)里,MPU 常被用来做任务隔离:每个任务有自己的栈和私有数据区,MPU 配置成任务切换时只允许访问自己的区域,一旦越界立刻 fault,把"野指针踩到别人数据"这种问题在发生瞬间就抓住,而不是等到系统跑飞了再回头查。
我实际做过的一个项目里,用 MPU 把几个任务的数据区隔开,结果在一次联调中直接抓到了一个任务因为数组越界写坏了另一个任务控制块的 bug。如果没有 MPU,这个 bug 会表现为"某个不相关的任务偶尔行为异常",排查成本可能是几天;有了 MPU,fault 现场直接指向越界的那条指令,半小时定位。
2.3 MPU 配置的实操要点与常见误区
配置 MPU 的典型流程是这样的(以 ARMv7-M 为例):
- 关闭 MPU(
MPU->CTRL = 0),确保配置过程中不会触发意外 fault。 - 选择要配置的 region 编号,写入
MPU_RBAR(基地址 + region 号 + 有效位)。 - 写入
MPU_RASR(大小、权限、属性、子区域禁用位、使能位)。 - 重复以上步骤配置所有 region。
- 使能 MPU,并根据需要使能背景区域(background region)。
/* 以常见的 CMSIS 风格为例,配置一段只读可执行的 Flash 区域 */ MPU->RNR = 0; /* 选择 region 0 */ MPU->RBAR = 0x08000000 | (1UL << 4); /* 基地址,VALID 位置 1 */ MPU->RASR = (0x05UL << 1) /* size: 32KB,编码需查手册 */ | (0x06UL << 24) /* AP: 特权可读写,用户只读 */ | (0x01UL << 28); /* XN=0,允许执行 */几个必须注意的点:
- region 重叠时的优先级:高编号 region 的权限会覆盖低编号 region。这个规则很多人不知道,导致配了半天发现权限不对,其实是后面的 region 把前面的覆盖了。
- 背景区域:如果使能了 background region,那么没有被任何 region 覆盖的地址会使用默认内存映射的权限;如果不使能,未覆盖区域的访问会直接 fault。这个开关决定了你的"默认策略"是宽松还是严格。
- 栈的保护:给栈区域配一个紧邻的"守卫 region",权限设为不可访问,栈溢出时会立刻触发 fault,这是 MPU 最实用的技巧之一。
提示:MPU 的 region 数量有限,别指望用它做精细的内存管理。它的定位是"粗粒度隔离 + 确定性保护",把最关键的几段内存(栈、关键数据、外设寄存器)圈起来就够了。
3. MMU:页表驱动的地址翻译,虚拟内存的硬件基石
3.1 MMU 解决的核心问题:虚拟地址到物理地址的映射
MMU 全称 Memory Management Unit。如果说 MPU 是门卫,MMU 就是"翻译官 + 门卫"二合一。CPU 核发出的所有地址都是虚拟地址(VA),MMU 负责把它翻译成物理地址(PA),翻译的同时顺带做权限检查。
翻译的依据是页表。页表是一棵多级树,ARMv8-A 常见的是 4 级页表(Level 0 到 Level 3),每一级把地址的一部分作为索引,逐级往下找,最后找到页表项(PTE),里面存着物理页帧号(PFN)和权限属性。以 4KB 页为例,48 位虚拟地址被切成 9+9+9+9+12 的结构,前四段各索引一级页表,最后 12 位是页内偏移。
这个多级结构的好处是省内存:不需要为整个地址空间建一张大表,只在实际用到的区域建分支。代价是翻译延迟——一次未命中的翻译要访问 4 次内存(每级页表一次)。所以硬件加了TLB(Translation Lookaside Buffer)做缓存,把最近用过的 VA→PA 映射缓存起来,命中就一步到位。
3.2 页表项里到底存了什么
一个典型的 ARMv8-A 页表项(描述符)包含这些关键字段:
| 字段 | 作用 |
|---|---|
| 输出地址 | 下一级页表基址或最终物理页帧号 |
| AF(Access Flag) | 是否被访问过,供操作系统做页面回收 |
| AP(Access Permission) | 读写权限、特权/用户态区分 |
| UXN/UXN | 用户态是否可执行 |
| PXN | 特权态是否可执行 |
| AttrIndx | 指向 MAIR 寄存器,决定内存属性(Normal/Device、可缓存性) |
| Shareability | 是否可共享,多核一致性相关 |
| Valid | 描述符是否有效 |
这里面的AttrIndx + MAIR组合是很多人配 MMU 时最容易出错的地方。MAIR(Memory Attribute Indirection Register)是一张"属性表",页表项里的 AttrIndx 只是个索引,真正的内存类型(比如 Normal Write-Back、Device nGnRnE)存在 MAIR 里。如果你把设备寄存器的页配成了 Normal 可缓存,那读写外设就会出现"读到的值不对""写下去没生效"这类诡异问题——因为缓存把访问拦截了。
3.3 多级页表遍历与 TLB 维护的实操经验
配 MMU 的完整流程大致是:
- 在内存里分配页表空间,按架构要求对齐(比如 4KB 对齐)。
- 填写各级页表项,建立 VA→PA 映射,设置权限和属性。
- 配置 MAIR、TCR(Translation Control Register,定义页表级数、粒度、地址范围)。
- 把页表基址写入 TTBR0_EL1(用户空间)和 TTBR1_EL1(内核空间)。
- 配置 SCTLR_EL1 使能 MMU 和缓存。
- 执行
ISB、TLBI等指令刷新流水线和 TLB。
/* 使能 MMU 前后的关键操作(AArch64 简化示意) */ msr ttbr0_el1, x0 // 写入用户空间页表基址 msr ttbr1_el1, x1 // 写入内核空间页表基址 msr mair_el1, x2 // 配置内存属性表 msr tcr_el1, x3 // 配置翻译控制 dsb sy tlbi vmalle1 // 清空 TLB dsb sy isb mrs x4, sctlr_el1 orr x4, x4, #1 // 置位 M 位,使能 MMU msr sctlr_el1, x4 isb踩过的坑里,最典型的是改完页表忘记刷 TLB。页表改了,但 TLB 里还缓存着旧映射,CPU 继续用旧地址翻译,表现为"代码明明改了却没生效"。ARM 提供了多种 TLBI 粒度:tlbi vmalle1清全部,tlbi vae1按 VA 清,tlbi aside1按 ASID 清。性能敏感的场景要按需选择,别动不动就全清。
另一个坑是页表对齐和属性一致性。页表本身所在的内存必须是 Normal 可缓存且一致的,否则多核场景下会出现一个核改了页表、另一个核看不到的情况。这个在 SMP 系统里尤其要注意。
注意:MMU 使能的瞬间,PC 所在的代码、当前栈、以及接下来要访问的所有数据,都必须已经在页表里有正确映射。否则第一条取指就会 fault。所以通常的做法是"恒等映射"(identity mapping)一段启动代码,先让 MMU 跑起来,再逐步切换到完整的虚拟地址布局。
4. SMMU:站在总线上的翻译官,让外设也懂虚拟地址
4.1 为什么外设需要自己的 MMU
CPU 有了 MMU,进程看到的是虚拟地址,物理内存被保护得很好。但外设(DMA 控制器、网卡、GPU、加解密引擎)做 DMA 时,发出的是物理地址——它们绕过 MMU,直接怼到内存上。这就带来两个问题:一是驱动要拿到物理地址才能让外设工作,虚拟内存的隔离优势在外设这里失效;二是恶意或有 bug 的设备可以 DMA 到任意物理内存,把内核数据写坏。
SMMU(System Memory Management Unit)就是来解决这个问题的。它挂在系统总线上,拦截外设发出的地址(IOVA,I/O Virtual Address),用一套独立的页表把它翻译成物理地址,同时做权限检查。这样驱动就可以给外设分配虚拟地址,外设用 IOVA 做 DMA,SMMU 负责翻译和隔离。
在 ARM 体系里,SMMU 的规范主要有 SMMUv1/v2(对应 ARMv7/v8 早期)和 SMMUv3(对应 ARMv8,重新设计了架构,支持更高效的命令队列和事件队列)。PCIe 场景下,SMMU 还和 ATS(Address Translation Services)、PASID(Process Address Space ID)配合,实现设备侧的地址翻译缓存。
4.2 Stream Table 与页表:SMMU 怎么知道该用哪套映射
SMMU 的核心数据结构是Stream Table。每个发起 DMA 的设备(更准确说是每个 Stream ID,通常由设备 ID 派生)在 Stream Table 里对应一个表项,表项指向该设备的翻译配置——用哪套页表、什么属性、是否 bypass。
翻译流程大致是:
- 设备发出带 Stream ID 的 DMA 请求。
- SMMU 用 Stream ID 索引 Stream Table,找到对应的配置。
- 用配置里的页表基址,对 IOVA 做多级页表遍历(和 MMU 类似)。
- 得到物理地址,做权限检查,通过则访问内存,不通过则产生 translation fault 并记录到事件队列。
/* Linux 下典型的 SMMU 映射流程(简化示意) */ struct iommu_domain *domain = iommu_domain_alloc(&platform_bus_type); iommu_attach_device(domain, dev); /* 分配 IOVA 并建立映射 */ phys_addr_t pa = virt_to_phys(buffer); iommu_map(domain, iova, pa, size, IOMMU_READ | IOMMU_WRITE); /* 把 IOVA 告诉设备,设备用它做 DMA */ writel(iova, dev_base + DMA_ADDR_REG);4.3 SMMU 调试中最让人头疼的几类 fault
做 SMMU 相关开发,最常打交道的不是配置,而是 fault。日志里出现SMMU translation fault时,排查思路要清晰:
- fault 地址是 IOVA 还是 PA:先确认设备发出的是不是 IOVA。如果驱动错误地把物理地址当 IOVA 传给了设备,SMMU 会拿物理地址去查页表,大概率查不到,报 fault。
- Stream ID 对不对:设备树或 ACPI 里描述的 Stream ID 必须和硬件实际发出的一致。配错了,SMMU 会用错误的页表翻译,或者直接 bypass。
- 页表项权限够不够:设备要写,但映射只给了读权限,一样 fault。
- 映射建立和 DMA 发起的时序:映射还没建好设备就开始 DMA,必然 fault。驱动里要保证
iommu_map完成后再启动设备。
我遇到过一次很隐蔽的问题:设备做 64 位 DMA,但 SMMU 的页表只配了 32 位地址范围,高位地址被截断,翻译到了错误的物理页,数据写飞。这种问题不会立刻 fault,而是表现为"数据偶尔错乱",排查起来非常费劲。后来在 Stream Table 配置里把地址范围补全才解决。
提示:SMMU 的 bypass 模式(Stream Table 表项直接标记为 bypass)会让设备 DMA 直通物理内存,调试阶段临时用来排除 SMMU 因素很有用,但生产环境千万别留 bypass,否则隔离形同虚设。
5. 三者协同:一个 SoC 里它们如何各司其职
5.1 从 CPU 到外设的完整地址路径
把三者放到一个典型的 ARM SoC 里看,地址路径是这样的:
- CPU 核内:程序发出 VA,MMU 翻译成 PA(或直接用 PA,如果 MMU 关闭)。MMU 的权限检查失败触发 abort。
- 实时核 / 裸机核:可能用 MPU 做保护,地址不翻译,直接是 PA,MPU 只查权限。
- 系统总线上:外设发出 IOVA,SMMU 翻译成 PA,权限检查失败触发 SMMU fault。
所以一个数据从 CPU 写到外设,可能经历"MMU 翻译 + SMMU 翻译"两次地址转换(如果外设也走 SMMU)。这也是为什么在虚拟化场景下,会出现两级翻译:Guest VA → Guest PA(Stage 1,由 Guest 的 MMU 管)→ Host PA(Stage 2,由 Hypervisor 的 MMU 管)。SMMU 同样支持两级翻译,对应设备的 Stage 1 和 Stage 2。
5.2 MPU 与 MMU 能否共存
这是被问得最多的问题之一。答案是:在同一个核上,MPU 和 MMU 通常不会同时使能。Cortex-A 系列一般只有 MMU,Cortex-M 系列一般只有 MPU(部分高端 M 系列如 Cortex-M7 有可选 MPU,但没有 MMU),Cortex-R 系列则可能两者都有,但实际使用中通常二选一。
原因在于它们的设计目标不同:MMU 面向富操作系统,需要虚拟内存、按需分页、进程隔离;MPU 面向实时和裸机,需要确定性、低开销。硬要把两者叠在一起,权限检查逻辑会变得复杂,收益也不明显。所以选型时先看你的场景:要跑 Linux 这类富 OS,用 MMU;要跑 RTOS 或裸机且强调确定性,用 MPU。
5.3 虚拟化场景下的两级翻译与 SMMU 的配合
在虚拟化环境里,三者的关系会更复杂一层。Hypervisor 用 Stage 2 翻译把 Guest 的物理地址再翻译一层,MMU 和 SMMU 都要支持两级翻译。这时候 SMMU 的 Stream Table 表项里会同时指向 Stage 1 和 Stage 2 的页表配置。
实际配置时要注意:Stage 1 由 Guest OS 管,Stage 2 由 Hypervisor 管,两者的页表格式和属性要匹配。如果 Guest 把某段内存配成 Device 属性,而 Stage 2 配成 Normal 可缓存,最终生效的属性可能出乎意料。这种跨层级的属性冲突,是虚拟化平台调试中的经典难题。
| 场景 | 使用的单元 | 地址类型 | 典型用途 |
|---|---|---|---|
| 裸机 / RTOS | MPU | 物理地址 | 任务隔离、栈保护 |
| 富 OS(Linux 等) | MMU | 虚拟地址 | 进程隔离、按需分页 |
| 外设 DMA | SMMU | IOVA | DMA 隔离、虚拟化直通 |
| 虚拟化 Guest | MMU + SMMU | VA→IPA→PA | 两级翻译 |
6. 配置与调试中的实战心得
6.1 先确认"谁在翻译",再动手配
我见过太多人一上来就翻手册配寄存器,结果方向就错了。正确的顺序是:先搞清楚当前场景下地址是谁翻译的。跑 Linux 的用户态程序,地址翻译是 MMU 的事,你改 MPU 没用;写 DMA 驱动发现数据不对,先确认设备走不走 SMMU,不走的话查 cache 一致性,走的话查 IOVA 映射。
一个实用的判断方法:看 fault 的类型和来源。Data abort来自 MMU,MemManage fault来自 MPU,SMMU translation fault来自 SMMU。日志里 fault 的名字直接告诉你该查哪套机制。
6.2 内存属性配置的一致性检查清单
内存属性(可缓存性、共享性、设备属性)是三者共同的坑点。我整理了一个检查清单,配完内存映射后逐条过一遍:
- 设备寄存器区域是否配成了 Device 属性(不可缓存、强序)?
- DMA 缓冲区是否配成了 Normal Non-cacheable 或做了 cache 维护?
- 多核共享的数据是否配了正确的 Shareability?
- 页表本身所在内存是否是一致可缓存的?
- SMMU 的 Stage 1 和 Stage 2 属性是否冲突?
这几条里任何一条出问题,表现都可能是"数据偶尔不对"这种最难查的症状。
6.3 从 fault 日志反推配置错误
fault 日志是最好的老师。以 SMMU fault 为例,一条完整的 fault 记录通常包含:fault 类型(translation/permission/access)、fault 地址、Stream ID、是否读写。拿到这些信息,排查路径就清晰了:
- 用 Stream ID 找到对应设备,确认它的页表配置。
- 用 fault 地址在页表里查,看是否有映射。
- 有映射就查权限,没映射就查为什么没建。
- 确认设备发出的地址确实是 IOVA,而不是被错误传入的 PA。
MMU 的 abort 日志类似,会给出 FAR(Fault Address Register)和 ESR(Exception Syndrome Register),ESR 里的 DFSC 字段直接告诉你是什么类型的 fault(翻译 fault、权限 fault、对齐 fault 等)。学会读这些寄存器,比盲目改配置高效得多。
6.4 性能与确定性的取舍
最后说一个选型层面的经验。MMU 和 SMMU 带来灵活性的同时,也带来了 TLB miss 和页表遍历的开销。对延迟敏感的场景,可以考虑:
- 用大页(2MB 或 1GB)减少页表级数和 TLB 压力。
- 把关键代码和数据的映射锁定在 TLB 里(如果硬件支持)。
- SMMU 场景下,合理使用 ATS 让设备侧缓存翻译结果,减少每次 DMA 都走 SMMU 遍历。
而 MPU 场景下,确定性是天然优势,但 region 数量有限,要精打细算。我的做法是:把最关键的几段(栈、任务控制块、外设寄存器)用 MPU 保护起来,其余内存用默认属性,不追求全覆盖。
这套东西说到底,核心就一句话:先想清楚你的地址在哪个层级被翻译、被谁保护,再去配对应的单元。MPU、MMU、SMMU 不是三个可以随便替换的选项,而是三个位置不同、职责不同的硬件模块。把它们的边界理清楚,很多看似玄学的内存问题,其实都有明确的排查路径。