4. MMU与内存管理:页表结构、TLB操作、域访问控制与缓存策略
MMU这东西,说白了就是给CPU戴上一副“虚拟眼镜”。它让每个进程都以为自己独占整个4GB地址空间,实际上物理内存可能只有512MB。我早年做手机Bootloader时,第一次打开MMU就把系统搞挂了——原因很简单,页表配置错了。从那以后,我对MMU的每一个比特都格外小心。
ARMv7的MMU比ARM9/ARM11复杂得多,但也灵活得多。咱们从最核心的页表结构开始聊。
4.1 一级页表结构
ARMv7的一级页表(L1)有4096个表项,每个表项32位。它覆盖整个4GB地址空间,每个表项管理1MB的虚拟内存块。
关键点:一级页表必须放在物理地址16KB对齐的位置。TTBR0和TTBR1寄存器指向的就是这个基地址。
一级页表项的类型决定了这1MB区域怎么映射:
| 表项类型 | 描述 | 粒度 |
|---|---|---|
| Section | 直接映射为1MB的段 | 1MB |
| Supersection | 映射16MB的大段(需要LPAE) | 16MB |
| Page Table | 指向二级页表,做细粒度映射 | 4KB/64KB |
| Fault | 未映射,访问会产生缺页异常 | — |
我个人习惯:内核空间用Section映射,因为内核代码段、数据段通常很大,1MB粒度够用,还能省掉二级页表的内存开销。用户空间则用二级页表,方便按页管理。
4.2 二级页表结构
二级页表(L2)把一级页表里标记为“Page Table”的1MB区域进一步细分。ARMv7支持两种L2表项粒度:
- Large Page(64KB):每个L2表项管理64KB,一张L2表有16个表项
- Small Page(4KB):每个L2表项管理4KB,一张L2表有256个表项
二级页表本身占用4KB,必须4KB对齐。每个L2表项包含物理地址、访问权限、域、缓存属性等信息。
我的经验:嵌入式系统里,如果内存紧张,优先用Section映射。我在一个IoT项目里,光靠Section映射就省下了32KB的页表内存——这对只有256KB SRAM的芯片来说,简直是救命。
4.3 页表遍历与TLB操作
CPU访问虚拟地址时,MMU会做页表遍历(Page Table Walk)。这个过程分两步:
- 从TTBRx拿到一级页表基址,用虚拟地址的[31:20]位索引L1表项
- 如果L1表项指向L2页表,再用虚拟地址的[19:12]位索引L2表项
每次遍历都要访问内存,太慢了。所以ARMv7内置了TLB(Translation Lookaside Buffer),缓存最近用过的地址转换结果。
注意:TLB不是自动一致的。你修改了页表之后,必须手动使失效的TLB条目。否则CPU可能还在用旧的映射——我见过有人因为这个bug,调试了整整两天。
TLB操作主要通过CP15协处理器完成:
/* 使失效所有TLB条目 */
MRC p15, 0, r0, c8, c7, 0 /* TLBIALL */
/* 使失效指定ASID的TLB条目 */
MRC p15, 0, r0, c8, c3, 0 /* TLBIMVA,r0包含虚拟地址和ASID */
/* 使失效指定MVA的TLB条目 */
MRC p15, 0, r0, c8, c1, 0 /* TLBIMVAA,r0包含虚拟地址 */
我建议:每次切换进程时,一定要刷TLB。如果用了ASID(Address Space ID),可以只刷当前ASID的条目,避免全局刷新的性能损失。
4.4 域访问控制
ARMv7的域访问控制是个很巧妙的设计。它把内存空间分成16个域(Domain),每个域可以独立设置访问权限。
域控制寄存器(DACR)有16个2位的域字段:
| 值 | 权限 | 说明 |
|---|---|---|
| 00 | No Access | 任何访问都产生Domain Fault |
| 01 | Client | 按页表里的权限检查 |
| 10 | Reserved | 不要用 |
| 11 | Manager | 不检查权限,直接通过 |
我在做安全启动时,就用域控制来隔离关键代码。把BootROM所在的域设为Manager,其他域设为Client。这样即使应用代码跑飞了,也碰不到BootROM区域。
实用技巧:把内核空间映射到Domain 0并设为Manager,用户空间映射到Domain 1并设为Client。这样内核访问自己的空间时,MMU直接放行,省掉权限检查的开销。
4.5 内存属性与缓存策略
ARMv7的内存属性由页表项里的TEX(Type Extension)、C(Cacheable)、B(Bufferable)三个位控制。这三个位组合起来,决定内存区域的缓存行为。
常用的组合:
| TEX | C | B | 内存类型 | 典型用途 |
|---|---|---|---|---|
| 000 | 0 | 0 | Strongly Ordered | 设备寄存器、共享内存 |
| 000 | 0 | 1 | Device | 外设内存 |
| 000 | 1 | 0 | Normal, Write-Through | 帧缓冲区 |
| 000 | 1 | 1 | Normal, Write-Back | 普通内存(代码、数据) |
| 001 | 1 | 1 | Normal, Write-Back, No Allocate | 流式数据 |
你想想看,为什么设备寄存器要用Strongly Ordered?因为CPU不能对设备寄存器做乱序访问或合并写操作。我曾经在一个网卡驱动里,不小心把DMA描述符所在的内存设成了Write-Back,结果数据一直不刷新到内存,网卡收不到包——调试了三天才发现是缓存策略的问题。
避坑指南:多核系统里,不同核共享的数据结构必须用Strongly Ordered或Device类型。否则一个核写的数据可能只留在自己的L1缓存里,另一个核读到的还是旧值。我曾经在AMP系统里踩过这个坑,两个核通过共享内存通信,数据总是对不上——最后发现是缓存一致性问题。
4.6 页表配置实战
下面是一个实际的一级页表初始化代码片段,我习惯在启动汇编阶段就把它配好:
/* 假设TTBR0指向0x80000000,页表在0x70000000 */
ldr r0, =0x70000000 /* 页表基址 */
ldr r1, =0x80000000 /* 物理地址起始 */
/* 映射前256MB为Section,设备内存用Strongly Ordered */
mov r2, #256
1:
/* 构建Section描述符 */
orr r3, r1, #0xC02 /* Domain=0, Section, AP=11 */
/* 设置TEX=000, C=0, B=0 -> Strongly Ordered */
bic r3, r3, #0x38 /* 清除TEX位 */
str r3, [r0], #4 /* 写入页表,地址递增 */
add r1, r1, #0x100000 /* 物理地址加1MB */
subs r2, r2, #1
bne 1b
/* 映射后256MB为Normal, Write-Back */
/* ... 类似逻辑,设置TEX=000, C=1, B=1 ... */
/* 使失效TLB */
MRC p15, 0, r0, c8, c7, 0
DSB
ISB
这段代码把前256MB物理内存映射为Strongly Ordered(适合设备寄存器),后256MB映射为Normal Write-Back(适合代码和数据)。实际项目中,你还需要根据具体外设的地址范围做调整。
4.7 知识结构总览
下面这张图总结了MMU与内存管理的核心脉络:
这张图把MMU的五个核心模块串起来了。你从中心出发,沿着箭头往下看,每个分支都对应一个关键知识点。实际调试时,遇到内存访问异常,就按这个结构排查:先看页表映射对不对,再看域权限够不够,最后检查缓存策略是否合适。
我的调试习惯:遇到MMU相关的异常,我第一件事就是读DFSR(Data Fault Status Register)和IFSR(Instruction Fault Status Register)。这两个寄存器会告诉你是什么类型的错误——是Translation Fault、Domain Fault还是Permission Fault。对症下药,比瞎猜快得多。