3. MMU核心概念:虚拟地址与物理地址映射、页表结构、TLB工作原理
好,咱们进入第三章。这一章是MMU的基石,说白了就是回答三个问题:虚拟地址怎么变成物理地址?页表长什么样?TLB又是怎么加速的? 这三个概念搞不清楚,后面配置调试基本就是瞎蒙。
我记得刚入行那会儿,第一次调MMU的bug,死活找不到原因。后来发现是页表项里一个属性位配错了,导致访问权限异常。从那以后,我就养成了一个习惯——先把页表结构画出来,再动手写代码。
3.1 虚拟地址与物理地址映射
先说说最核心的概念:映射。
CPU执行指令时,拿到的地址是虚拟地址(VA)。这个地址不能直接去访问内存,得先经过MMU翻译成物理地址(PA),才能去DDR里读写数据。这个过程,就叫地址翻译。
核心公式:
物理地址 = MMU(虚拟地址)
翻译的依据,就是页表。
你想想看,如果没有MMU,所有程序都直接操作物理地址,那会是什么场景?程序A把地址0x10000写了数据,程序B也往0x10000写,直接就打架了。有了虚拟地址,每个进程都以为自己独占整个地址空间,互不干扰。
我在项目中遇到过一个问题:一个跑在Cortex-A7上的Linux系统,用户态程序访问了一个非法地址,按理说应该触发段错误。结果系统直接死机了。查了半天,发现是MMU配置里把用户空间的页表项权限位设错了,导致用户态能访问内核空间。嗯,这个坑踩得值。
3.2 页表(Translation Table)结构
页表,就是一张映射表。它告诉MMU:虚拟地址X对应物理地址Y,并且这个地址的访问权限是什么、缓存策略是什么。
ARMv7-A和ARMv8-A的页表结构略有不同,但核心思想一致:多级页表。
3.2.1 为什么需要多级页表?
假设地址空间是32位,4KB一页。如果只用一级页表,需要2^20个页表项,每个页表项4字节,那就是4MB。每个进程一张页表,10个进程就是40MB。这还不算64位地址空间,那更是天文数字。
多级页表的好处是:按需分配。大部分进程只用到地址空间的一小部分,二级页表只在需要时才创建。这样内存占用就小多了。
3.2.2 ARMv7-A 短描述符格式
ARMv7-A支持两种页表格式:短描述符(Short Descriptor)和长描述符(Long Descriptor)。短描述符是32位格式,长描述符是64位格式。咱们先看短描述符。
短描述符使用两级页表:
- 一级页表(L1):也叫页表基址寄存器(TTBR)指向的表,每个表项对应1MB地址空间
- 二级页表(L2):每个L2表项对应4KB地址空间
一个L1表项的结构如下:
位[31:20]:物理地址基址(指向L2表或1MB节)
位[19:2]:域(Domain)和属性
位[1:0]:表项类型
00:无效(Fault)
01:页表(指向L2表)
10:节(Section,1MB映射)
11:保留
一个L2表项的结构:
位[31:12]:物理页帧号(PFN)
位[11:2]:访问权限、缓存属性等
位[1:0]:页大小
00:无效
01:大页(64KB)
10:小页(4KB)
11:保留
个人习惯:我在调试页表时,喜欢先把L1表项打印出来,看看哪些地址段是有效的。如果某个地址段访问异常,先查L1表项是不是Fault,再查L2表项。这个排查顺序能省不少时间。
3.2.3 ARMv8-A 页表格式
ARMv8-A进入64位时代,页表结构也升级了。它支持4级页表(L0到L3),页大小可以是4KB、16KB或64KB。
以4KB页为例:
| 级别 | 地址范围 | 表项数 | 粒度 |
|---|---|---|---|
| L0 | 512GB | 512 | 指向L1表 |
| L1 | 1GB | 512 | 指向L2表或1GB块 |
| L2 | 2MB | 512 | 指向L3表或2MB块 |
| L3 | 4KB | 512 | 4KB页 |
每个表项64位,结构如下:
位[63:48]:物理地址高位(取决于物理地址宽度)
位[47:12]:下一级表基址或物理页帧号
位[11:2]:属性位(访问权限、XN、PXN、AF、SH、AP等)
位[1:0]:描述符类型
00:无效
01:页/块
11:指向下一级表
注意:ARMv8-A的页表项里有一个AF位(Access Flag),必须置1才能访问。我曾经在调试时忘了设这个位,结果每次访问都触发缺页异常。查了两天才发现是AF位的问题。
3.3 TLB(Translation Lookaside Buffer)工作原理
页表在内存里,每次地址翻译都要查内存,那速度太慢了。TLB就是MMU内部的一个高速缓存,专门缓存最近用过的页表项。
3.3.1 TLB的结构
TLB本质上是一个全相联或组相联的CAM(内容可寻址存储器)。它存储的是:
- 虚拟地址标签:用于匹配
- 物理地址:翻译结果
- 属性位:权限、缓存策略等
- ASID(地址空间标识符):区分不同进程
当CPU发出一个虚拟地址时,MMU先查TLB。如果命中,直接拿到物理地址,不需要查内存页表。这个过程叫TLB hit。如果没命中,叫TLB miss,MMU就得去内存里查页表,然后把结果加载到TLB里。
3.3.2 TLB miss的处理流程
- MMU收到虚拟地址
- 在TLB中查找匹配项
- 如果命中,直接返回物理地址
- 如果未命中,硬件自动遍历页表(硬件页表遍历器)
- 找到对应的页表项后,加载到TLB
- 返回物理地址
ARMv7-A和ARMv8-A都支持硬件页表遍历(Hardware Page Table Walk)。这意味着TLB miss时,不需要软件介入,硬件自己就把页表查完了。这个设计大大提升了性能。
关键点:TLB miss的代价很大。一次TLB miss可能需要几十到几百个时钟周期(取决于页表级数和内存延迟)。所以,TLB的命中率直接影响系统性能。
3.3.3 TLB的维护
TLB是缓存,缓存就有一致性问题。当你修改了页表(比如改变映射关系、修改权限),TLB里的旧数据就失效了。这时候需要无效化TLB。
ARM提供了几条指令:
- TLBIALL:无效化所有TLB项(ARMv7-A)
- TLBIMVA:无效化指定虚拟地址的TLB项
- TLBIASID:无效化指定ASID的TLB项
- DSB:确保TLB维护操作完成
- ISB:确保后续指令看到TLB维护的效果
在ARMv8-A中,指令名略有变化,但功能类似:
// 无效化所有TLB项(EL1)
DSB SY
TLBI ALLE1
DSB SY
ISB
我曾经踩过的坑:在修改页表后,只执行了TLBIALL,没有加DSB和ISB。结果后续指令还在用旧的TLB项,导致访问了错误的物理地址。从那以后,我每次修改页表都严格执行:修改页表 → DSB → TLB无效化 → DSB → ISB。这个顺序不能错。
3.4 映射示例:4KB页映射
咱们看一个实际例子。假设我们要把虚拟地址0x10000映射到物理地址0x20000,4KB页,可读写,非缓存。
在ARMv7-A短描述符格式下:
- 虚拟地址0x10000,属于L1索引0x10(因为每1MB一个L1表项)
- L1表项指向一个L2表
- 虚拟地址0x10000的L2索引是0x00(因为每4KB一个L2表项)
- L2表项内容:物理地址0x20000,AP=3(可读写),C=0,B=0(非缓存)
代码实现(伪代码):
// 假设TTBR0已经指向L1表
uint32_t *l1_table = (uint32_t *)TTBR0;
uint32_t *l2_table;
// 分配L2表
l2_table = alloc_page();
// 设置L1表项
l1_table[0x10] = (uint32_t)l2_table | 0x01; // 指向L2表,类型为页表
// 设置L2表项
l2_table[0x00] = 0x20000 | (3 << 10) | (0 << 3) | (0 << 2); // AP=3, C=0, B=0
// 维护TLB
DSB();
TLBIALL();
DSB();
ISB();
调试技巧:我习惯在设置完页表后,用调试器读一下L1和L2表项的内容,确认每个位都设对了。特别是AP位和域位,这两个最容易出错。
3.5 本章知识结构图
下面这张图展示了虚拟地址翻译的完整流程:
3.6 常见问题与避坑指南
问题1:TLB未刷新导致访问旧数据
我曾经在热插拔一个外设驱动时,修改了MMU映射,但没有刷新TLB。结果驱动访问的还是旧地址,直接导致系统崩溃。解决方案:每次修改页表后,必须执行TLB维护操作。
问题2:页表项属性位设置错误
ARM的页表项里有很多属性位:XN(不可执行)、PXN(特权不可执行)、AF(访问标志)、SH(可共享性)等。设错了就会触发各种异常。我的建议是:先对照ARM手册把每个位的含义搞清楚,再写代码。
问题3:多级页表的内存对齐
ARM要求页表必须按特定边界对齐。比如L1表必须1KB对齐,L2表必须1KB对齐。如果不对齐,MMU会忽略低几位,导致访问错误的物理地址。我习惯用__attribute__((aligned(1024)))来保证对齐。