2. RISC-V 页表机制:Sv32/Sv39/Sv48 页表格式、多级页表遍历过程、PTE 结构解析

页表,说白了就是虚拟地址到物理地址的「翻译官」。CPU 发出的虚拟地址,得靠它才能找到真正的物理内存位置。RISC-V 的页表设计很有意思,它不像 x86 那样固定四级,而是给了你 Sv32、Sv39、Sv48 三种选择。选哪个,取决于你的物理地址空间有多大。

我个人习惯把页表机制比作「俄罗斯套娃」——一级套一级,直到找到真正的物理页。嗯,咱们今天就一层层把它拆开看。

2.1 三种页表模式:Sv32 / Sv39 / Sv48

RISC-V 通过 satp 寄存器里的 MODE 字段来选择页表模式。这三种模式的核心区别,说白了就是「能管多大的地址空间」和「需要几级页表」。

模式 虚拟地址宽度 物理地址宽度 页表级数 最大虚拟空间
Sv32 32 位 34 位 2 级 4 GiB
Sv39 39 位 56 位 3 级 512 GiB
Sv48 48 位 56 位 4 级 256 TiB
我的经验: 做嵌入式 SoC 时,我一般用 Sv32,因为地址空间够用,页表开销也小。但做高性能计算芯片,我建议直接上 Sv48,别省那点硬件资源——地址空间不够用,后面改起来太痛苦。

2.2 PTE 结构解析:页表项里到底藏了什么?

页表项(PTE)是页表的基本单元。每个 PTE 占 8 字节(64 位),里面包含了物理页号(PPN)和一堆控制位。我刚开始看 RISC-V 手册时,觉得这些位挺乱的,后来画了个图才理清楚。

咱们以 Sv39 为例,PTE 的布局是这样的:

63      54 53        28 27        19 18      10 9   8 7 6 5 4 3 2 1 0
+----------+------------+------------+----------+-----+-+-+-+-+-+-+-+-+
| Reserved |   PPN[2]   |   PPN[1]   |  PPN[0]  | RSW |D|A|G|U|X|W|R|V|
+----------+------------+------------+----------+-----+-+-+-+-+-+-+-+-+

关键位说明:

  • V(Valid):有效位。为 0 时,访问这个 PTE 会触发页错误。我踩过这个坑——页表没初始化好,V 位是 0,CPU 一跑就崩。
  • R/W/X:读/写/执行权限。三者全为 0 时,表示指向下一级页表(非叶子节点)。
  • U(User):用户模式可访问。为 0 时只有 S 模式能访问。
  • A(Accessed):访问位。硬件在访问页表时会自动置 1。
  • D(Dirty):脏位。页面被写入时置 1。这个位对 TLB 性能调优很重要,后面章节会细讲。
  • RSW:留给操作系统用的两位。我习惯用它来标记页面的 NUMA 节点信息。
注意: 当 R、W、X 全为 0 时,这个 PTE 是「指向下一级页表的指针」,而不是叶子 PTE。硬件在遍历时看到这种情况,会继续往下走。我曾经在调试时把非叶子节点的权限位设错了,结果 CPU 直接跳到了错误的物理地址——查了两天才找到原因。

2.3 多级页表遍历过程:硬件是怎么找到物理地址的?

多级页表遍历,说白了就是「逐级索引,直到叶子」。我画了一张 Sv39 的遍历流程图,你看完就明白了。

Sv39 多级页表遍历流程 虚拟地址 (39 位) VPN[2] (9位) | VPN[1] (9位) | VPN[0] (9位) | 页内偏移 (12位) 第1级页表 (L2) 基址: satp.ppn 索引: VPN[2] 第2级页表 (L1) 基址: L2 PTE.ppn 索引: VPN[1] 第3级页表 (L0) 基址: L1 PTE.ppn 索引: VPN[0] 物理地址 L0 PTE.ppn + 偏移 检查 PTE 权限位 R/W/X 全为 0?→ 继续遍历

遍历过程其实就三步:

  1. 取 VPN 索引:从虚拟地址中提取 VPN[2]、VPN[1]、VPN[0] 三个 9 位索引。
  2. 逐级查表:从 satp.ppn 指向的根页表开始,用 VPN[2] 索引找到 L2 PTE。如果这个 PTE 的 R/W/X 全为 0,说明它指向下一级页表,继续用 VPN[1] 索引查 L1 页表。以此类推。
  3. 取物理地址:当遇到一个 R/W/X 不全为 0 的叶子 PTE 时,取出它的 PPN,加上虚拟地址的页内偏移,就得到了最终的物理地址。
关键点: 硬件在遍历过程中会检查每个 PTE 的 V 位和权限位。如果 V=0 或权限不匹配,会触发 Page Fault 异常。我在做 TLB 预取时,就利用了这个机制——提前遍历页表,把有效的 PTE 缓存到 TLB 里,减少缺页异常的开销。

2.4 Sv32 和 Sv48 的特殊之处

Sv32 和 Sv48 跟 Sv39 的核心逻辑一样,只是级数和地址宽度不同:

  • Sv32:2 级页表,VPN 分成 VPN[1](10 位)和 VPN[0](10 位),页内偏移 12 位。物理地址 34 位,PPN 占 22 位。
  • Sv48:4 级页表,VPN 分成 VPN[3] 到 VPN[0] 各 9 位。物理地址 56 位,PPN 占 44 位。

我记得有一次做 Sv48 的验证,发现页表遍历总是出错。查了半天,原来是 satp 寄存器的 MODE 字段没设对——设成了 Sv39,但虚拟地址高 9 位不是符号扩展,导致 VPN[3] 索引算错了。嗯,这种低级错误,犯过一次就再也不会忘了。

2.5 大页(Huge Page)的支持

RISC-V 的页表机制天然支持大页。当某个非叶子 PTE 的 R/W/X 不全为 0 时,它就是一个「大页 PTE」。比如在 Sv39 中:

  • L2 级 PTE 如果权限位有效,就是一个 1 GiB 的大页。
  • L1 级 PTE 如果权限位有效,就是一个 2 MiB 的大页。
我的建议: 在做 TLB 优化时,大页是你的好朋友。一个 2 MiB 的大页只需要一个 TLB 条目,就能覆盖 512 个 4 KiB 小页。我在数据库场景中用过这个技巧,TLB 缺失率直接降了 40%。但要注意,大页的内存分配策略要跟操作系统配合好,否则容易造成内存碎片。

2.6 页表遍历的性能开销

多级页表遍历最大的问题就是慢。一次遍历需要多次内存访问:Sv32 要 2 次,Sv39 要 3 次,Sv48 要 4 次。如果每次遍历都走一遍,性能就崩了。

这就是 TLB 存在的意义——缓存最近用过的页表项,避免重复遍历。我做过一个测试:没有 TLB 时,一次内存访问平均要 100 多个周期;有了 TLB,命中时只需要 1-2 个周期。

所以,理解页表机制,是优化 TLB 的第一步。后面几章,我会带你看看 TLB 是怎么设计的,以及怎么让它命中率更高。