4. 页表格式与层级结构
页表这东西,说白了就是一张“地址翻译地图”。CPU看到的是虚拟地址,内存颗粒只认物理地址,中间全靠页表来搭桥。SMMU的页表和CPU的页表结构很像,但又有自己的脾气。今天咱们就把4级页表、3级页表、页表粒度和映射方式彻底聊透。
4.1 为什么需要多级页表?
你想想看,如果只用一级页表,一个4KB页、48位地址空间,需要多少页表项?2^36个,每个8字节,光页表就要512GB。这谁受得了?
多级页表的思路很简单:按需分配,逐级索引。就像查字典,先查部首,再查笔画,最后翻页码。不需要一次性把整本字典的索引都建好。
核心思想:多级页表用“树形结构”把线性页表拆成多段,每一级只保存下一级的物理地址。只有真正用到的页表级才会分配内存。
我在项目中遇到过一个大坑:某次IO设备访问的地址范围很稀疏,但用了4级页表后,页表内存反而比一级页表还大。后来一查,是页表分配器没做“按需分配”,直接把所有级都预分配了。嗯,这属于实现上的失误。
4.2 4级页表结构详解
ARMv8-A架构下,典型的4级页表长这样:
虚拟地址(48位)分解:
[63:48] 符号扩展
[47:39] L0索引 (9位) → 512个L0页表项
[38:30] L1索引 (9位) → 512个L1页表项
[29:21] L2索引 (9位) → 512个L2页表项
[20:12] L3索引 (9位) → 512个L3页表项
[11:0] 页内偏移 (12位) → 4KB页
每一级页表项(PTE)的大小是8字节。所以每级页表正好占一个4KB页(512 × 8 = 4096)。
| 页表级 | 索引位数 | 页表项数 | 覆盖地址范围 | 典型用途 |
|---|---|---|---|---|
| L0 | 9 | 512 | 512GB / 项 | 顶级目录,指向L1 |
| L1 | 9 | 512 | 1GB / 项 | 中间目录,指向L2 |
| L2 | 9 | 512 | 2MB / 项 | 可映射大页(2MB) |
| L3 | 9 | 512 | 4KB / 项 | 叶子页表,映射4KB页 |
个人习惯:我一般把L0叫“根页表”,L3叫“叶子页表”。调试时这么说,团队里沟通效率高很多。
4.3 3级页表与页表粒度
不是所有场景都需要4级。如果地址空间只有39位,或者使用大页,可以缩减到3级甚至2级。
3级页表(39位地址):
[38:30] L1索引 (9位)
[29:21] L2索引 (9位)
[20:12] L3索引 (9位)
[11:0] 页内偏移
少了L0级,直接从L1开始。覆盖512GB地址空间,对大多数IO设备来说够用了。
页表粒度:
- 4KB页:最细粒度,L3叶子项映射。适合随机小IO,但页表开销大。
- 2MB页:L2项直接映射(块描述符),跳过L3。TLB覆盖范围扩大512倍。
- 1GB页:L1项直接映射,跳过L2和L3。适合大块连续内存,比如GPU显存映射。
我曾经踩过的坑:在某个AI加速器项目中,我图省事把所有IO映射都用4KB页。结果TLB miss率飙到30%,性能惨不忍睹。后来换成2MB大页,TLB miss直接降到2%以下。记住:大页不是选项,是刚需。
4.4 页表项格式与属性
每个页表项(PTE)除了存下一级地址,还带了一堆属性位。SMMU的PTE和CPU的PTE基本兼容,但多了几个SMMU特有的字段。
典型的L3页表项(64位):
[63] PXN — 特权不可执行
[62] XN — 不可执行
[61:60] 保留
[59:56] 安全状态位
[55:52] 共享属性
[51:48] 访问权限
[47:12] 物理地址[47:12] ← 4KB对齐的物理页基址
[11:9] 保留
[8] AF — 访问标志
[7:6] 保留
[5] NS — 非安全
[4:3] AP[2:1] — 访问权限
[2] SH[1] — 可共享性
[1] PXNTable — 表级PXN
[0] Valid — 有效位
关键属性解读:
- Valid位:为0表示该页表项无效,访问会触发缺页异常。调试时经常遇到“页表配了但Valid没置1”的乌龙。
- AF位:访问标志位。硬件在第一次访问时会置1,软件可以定期扫描做页表回收。
- AP位:控制读写权限。SMMU里还要结合Stream的权限一起判断。
- SH位:可共享性。IO设备之间的缓存一致性全靠这个位控制。
4.5 映射流程与TLB刷新
SMMU做地址翻译的流程,我习惯用“三级流水”来理解:
- Walk阶段:从根页表(L0/L1)开始,逐级索引,直到找到叶子PTE。
- 检查阶段:验证权限、安全状态、可共享性等属性。
- 转换阶段:取出物理地址,加上偏移,生成最终物理地址。
每次修改页表后,必须做TLB刷新。SMMU的TLB刷新命令和CPU不太一样:
// 全局TLB刷新(所有Stream)
SMMU_TLB_INVAL.write(0x1);
// 按ASID刷新(指定地址空间)
SMMU_TLB_INVAL.write(ASID << 32 | 0x1);
// 按VA刷新(指定虚拟地址)
SMMU_TLB_INVAL.write(VA & 0xFFFFFFFFFFFFF000 | 0x3);
我建议:批量修改页表时,先改完所有PTE,最后统一发一次TLB刷新。别改一个刷一次,性能会崩。我在一个NVMe驱动里见过这种写法,改一个4KB页就刷一次全局TLB,IOPS直接腰斩。
4.6 多级页表性能调优要点
实战中,页表配置对性能的影响非常大。我总结了几条经验:
| 调优方向 | 做法 | 效果 | 适用场景 |
|---|---|---|---|
| 页表级数 | 能用3级不用4级 | 减少一次内存访问 | 地址空间≤512GB |
| 页粒度 | 优先用2MB/1GB大页 | TLB覆盖范围扩大 | 大块连续DMA |
| 页表缓存 | 开启PTW缓存 | 减少页表Walk延迟 | 页表频繁访问 |
| TLB刷新 | 按ASID/VA刷新 | 避免全局刷新风暴 | 多Stream场景 |
注意:大页不是万能的。如果IO访问模式是随机4KB小IO,用2MB大页反而浪费内存(内部碎片)。我见过一个存储控制器,映射了2MB大页但实际只用了头4KB,剩下的2MB-4KB全浪费了。这种场景下,混合使用4KB和2MB页才是正解。
4.7 页表结构图
下面这张图展示了4级页表的完整Walk路径。我画图时习惯把“根”放在上面,叶子在下面,这样符合阅读习惯。
4.8 实战建议
最后,给几个我在项目中反复验证过的建议:
- 页表级数选型:先算地址空间大小。≤512GB用3级,>512GB用4级。别为了“统一”而浪费性能。
- 大页策略:IO缓冲区≥2MB的,一律用2MB页。1GB页留给GPU或AI加速器。
- 页表内存:放在NUMA离SMMU最近的节点上。页表Walk延迟能降30%。
- 调试工具:用SMMU的页表Walk调试寄存器,可以单步跟踪翻译过程。我调试缺页异常时全靠它。
一句话总结:多级页表的核心是“用时间换空间”,但通过合理选择级数和粒度,可以把时间代价降到最低。页表配置不是一次性的,需要根据IO负载动态调整。