22. 页表与虚拟内存
至此,我们学会了写汇编程序、处理异常和中断、与 C 代码互操作。但所有这些操作中使用的"地址"——la a0, label 中的 label、ld t0, 0(sp) 中的 0(sp)——究竟是物理 RAM 中的真实地址,还是被 CPU 悄悄翻译过的虚拟地址?答案是:在有操作系统的环境中,所有地址都是虚拟地址。本章剖析 RISC-V 的虚拟内存机制:页表如何工作、TLB 如何让翻译变快、以及这一切对汇编程序员意味着什么。
虚拟内存的作用
三个核心目标
虚拟内存并非"让程序员省心"的便利设施——它是现代操作系统安全性和可靠性的基石。三个核心目标:
1. 隔离(Isolation)
每个用户进程拥有独立的虚拟地址空间。进程 A 的 0x10000 和进程 B 的 0x10000 映射到完全不同的物理页帧。用户程序无法(也不应该)访问属于其他进程或内核的物理内存。页表是硬件强制隔离的机制——没有合法 PTE 映射,任何 load/store 都将在硬件层面触发页错误异常。
方案 1:Mermaid 流程图
方案 2:Markdown 表格
| 进程 | 虚拟地址 | → | 物理内存 |
|---|---|---|---|
| A | 0x10000 | → | 物理页帧 #42(A 的数据) |
| A | 0x11000 | → | 物理页帧 #17(A 的代码) |
| B | 0x10000 | → | 物理页帧 #99(B 的数据) |
| — | — | → | 物理页帧 #3(B 的栈) |
| — | — | → | 物理页帧 #91(A 的栈) |
方案 3:ASCII art(纯英文)
Process A VA: Physical Memory:
0x10000 ────────┐ ┌──────────────────────┐
0x11000 ──────┐ │ ┌──→│ Frame #42 (A data) │
│ │ │ ├──────────────────────┤
│ └───│─→│ Frame #17 (A code) │
│ │ ├──────────────────────┤
Process B VA: │ │ │ Frame #99 (B data) │
0x10000 ──────┐│ │ ├──────────────────────┤
││ └──→│ Frame #3 (B stack) │
└─────────→│ Frame #91 (A stack) │
└──────────────────────┘2. 扩展(Extension)
虚拟地址空间可以远大于物理内存。在 Sv39 下,每个进程有 512 GiB 的用户地址空间(64 位地址的低 256 TiB 的一半)。操作系统通过按需分页(demand paging)和交换(swapping),让物理内存作为"热数据"的缓存,不活跃的页被移到磁盘。程序不需要知道当前有多少空闲 RAM——它看到的始终是那个"平坦、连续"的虚拟空间。
3. 保护(Protection)
页表项(PTE)中的 R/W/X 位允许操作系统精确控制每个页面的访问权限:
- 代码段:R + X(可读可执行,不可写)
- 数据段:R + W(可读可写,不可执行)
- 只读常量:R(不可写不可执行)
违反权限的访问被硬件截获为页错误异常——这是 W^X(Write XOR Execute)安全策略和栈溢出保护的硬件基础。
satp:地址翻译的开关
satp CSR 字段
satp(Supervisor Address Translation and Protection)是 S 模式下的 CSR,控制整个虚拟内存的开关和翻译方式。在 RV64 中,satp 的位布局如下:
位 [63:60] MODE: 0=Bare(无翻译), 8=Sv39, 9=Sv48, 10=Sv57
位 [59:44] ASID: 地址空间 ID(16 位,最多 65535 个地址空间)
位 [43:0] PPN: 根页表的物理页号(44 位物理地址)支持的翻译模式:
| MODE 值 | 名称 | 虚拟地址宽度 | 页表级数 | 虚拟地址空间 |
|---|---|---|---|---|
| 0 | Bare | 无翻译 | 0 | =物理地址 |
| 8 | Sv39 | 39 位 | 3 级 | 512 GiB |
| 9 | Sv48 | 48 位 | 4 级 | 256 TiB |
| 10 | Sv57 | 57 位 | 5 级 | 128 PiB |
Sv39(39 位,3 级页表)是目前 RISC-V Linux 最常用的模式。Sv48 和 Sv57 是为大内存服务器和数据中心准备的——对汇编学习而言,完全掌握 Sv39 就足够理解虚拟内存的本质。
Bare 模式
当 satp.MODE = 0(Bare 模式),地址翻译被完全绕过——虚拟地址 = 物理地址。bare-metal 程序和 M 模式代码通常运行在 Bare 模式下。嵌入式系统如果不需要虚拟内存,根本不需要实现 satp。
# 启用 Bare 模式(关掉地址翻译)
csrw satp, x0 # 写 0 到 satp: MODE=0, ASID=0, PPN=0切换 satp:sfence.vma
写入 satp 不会立即影响正在执行的指令流——但下一条指令开始,所有后续取指和访存都将使用新的页表。由于 TLB 中可能缓存了旧翻译,必须在写 satp 后执行 TLB 刷新:
# 切换到新的页表
la t0, root_page_table
srli t0, t0, 12 # 页表物理地址 >> 12 = PPN
li t1, (8 << 60) # MODE = Sv39
or t0, t0, t1
csrw satp, t0 # 写入新的根页表
sfence.vma # 刷新所有 TLB 条目
# 之后的所有访存走新页表sfence.vma 的完整形式允许清除特定虚拟地址或特定 ASID 的 TLB 条目。不加参数时,刷新所有 hart 上、所有 ASID、所有虚拟地址的 TLB 条目——最彻底的刷新,也是上下文切换中的标准做法。
Sv39 页表结构
虚拟地址分解
Sv39 将 39 位虚拟地址划分为四个部分:
虚拟地址 (39 位):
┌──────────┬──────────┬──────────┬─────────────┐
│ VPN[2] │ VPN[1] │ VPN[0] │ offset │
│ 9 bits │ 9 bits │ 9 bits │ 12 bits │
│ [38:30] │ [29:21] │ [20:12] │ [11:0] │
└──────────┴──────────┴──────────┴─────────────┘
↓ ↓ ↓ ↓
L2 页表 L1 页表 L0 页表 页内偏移
(根表) (中间表) (叶表) (4KB 页面)每一级 VPN 是 9 位——恰好索引一张 512 个条目的页表(2^9 = 512,每条 PTE 8 字节,511 × 8 + 8 = 4096 字节 = 恰好一页)。这是页表设计中的经典"自相似"模式:每一级页表都恰好占一页物理内存。
三级翻译流程
Virtual Address
┌────────────────────────────┐
│ VPN[2] │ VPN[1] │ VPN[0] │ off │
└───┬────┴───┬────┴───┬────┴──┬──┘
│ │ │ │
satp.PPN ─┤ │ │ │
┌─────────▼──┐ │ │ │
│ L2 页表 │ │ │ │
│ (根表) │ │ │ │
│ VPN[2]→PPN │ │ │ │
│ ┌─────────┐│ │ │ │
│ │PTE[VPN2]├┼─────▼──┐ │ │
│ └─────────┘│ │ L1 页表 │ │ │
└────────────┘ │ VPN[1]→PPN│ │ │
│ ┌─────────┐│ │ │
│ │PTE[VPN1]├┼────▼──┐ │
│ └─────────┘│ │L0 页表│ │
└────────────┘ │VPN[0]→PPN│ │
│┌─────────┐│ │
││PTE[VPN0]├┼──▼─────┐
│└─────────┘││Physical│
└────────────┘│ Page │
│ +offset│
└────────┘每一步:用 VPN[i](9 位)× 8 字节 = 偏移,在当前页表基址处查找 PTE,读出 PPN + Flags。
PTE 格式
一个 PTE(Page Table Entry)长度为 64 位,分为 PPN 和 Flags 两大部分:
位 [63:54] 保留(未来扩展)
位 [53:10] PPN[2:0] (44 位物理页号,每个 PPN 字段 26 位高 + 9 位中 + 9 位低)
位 [9:0] Flags (10 位标志)10 个标志位的详细定义:
| 位 | 名称 | 描述 |
|---|---|---|
| 0 | V | Valid——该 PTE 是否有效。0=无效(访问触发页错误) |
| 1 | R | Readable——可读 |
| 2 | W | Writable——可写 |
| 3 | X | Executable——可执行 |
| 4 | U | User——U 模式可访问。0=仅 S/M 模式可访问 |
| 5 | G | Global——全局映射,所有 ASID 共享(如内核页) |
| 6 | A | Accessed——已被读过/执行过(硬件自动置位或软件模拟) |
| 7 | D | Dirty——已被写过(硬件自动置位或软件模拟) |
| 8-9 | RSW | Reserved for Supervisor Software——OS 自由使用 |
R/W/X 的组合定义了标准的权限语义:
| R | W | X | 语义 |
|---|---|---|---|
| 0 | 0 | 0 | 非叶节点(指向下一级页表) |
| 1 | 0 | 0 | 只读数据页 |
| 0 | 1 | 0 | 保留(非法组合) |
| 1 | 1 | 0 | 可读写数据页 |
| 0 | 0 | 1 | 只执行(纯代码页) |
| 1 | 0 | 1 | 可读可执行(典型代码段) |
| 1 | 1 | 1 | 可读可写可执行(不推荐,W^X 违规) |
注意 W=1, R=0 是非法组合——可写但不可读的页面在 RISC-V 架构中没有意义。
大页(Superpage)
Sv39 支持两种大页——当翻译提前终止于 L2 或 L1 层时:
| 终止于 | 页面大小 | 虚拟地址覆盖 |
|---|---|---|
| L0(叶) | 4 KiB | off=12 bit |
| L1(中间) | 2 MiB | VPN[0]+off=21 bit(512×4KB) |
| L2(根) | 1 GiB | VPN[1]+VPN[0]+off=30 bit(512×2MB) |
大页的存在是出于性能考量——TLB 条目有限,一条覆盖 2 MiB 的条目比 512 条各自覆盖 4 KiB 的条目更高效。但大页的对齐要求很高:2 MiB 大页的物理基址必须 2 MiB 对齐,1 GiB 巨页必须 1 GiB 对齐。
操作系统内核代码(如 Linux 的 __vmlinux 映射)和大型数据库的缓冲池是典型的大页应用场景。对汇编程序员来说,大页通常是透明的——你不会直接控制是否使用大页。
TLB:地址翻译缓存
为什么需要 TLB
走完一次完整的三级页表翻译需要访存 3 次(读 L2 → 读 L1 → 读 L0),再加上目标数据的访问,一共 4 次内存操作——这是不可接受的性能损失。TLB(Translation Lookaside Buffer)是在 MMU 内部的硬件缓存,缓存最近使用的 VA→PA 翻译。
没有 TLB 缓存时:
ld t0, 0(a0)
→ 读 L2 PTE(内存访问 1)
→ 读 L1 PTE(内存访问 2)
→ 读 L0 PTE(内存访问 3)
→ 读目标数据(内存访问 4)
总延迟:4 × 内存延迟 ≈ 200-400 个周期
有 TLB 命中时:
ld t0, 0(a0)
→ TLB 命中 → 直接得到 PA
→ 读目标数据(内存访问 1)
总延迟:1 × 内存延迟 ≈ 50-100 个周期实际程序中 TLB 命中率通常 > 99%——因为内存访问具有极强的时空局部性(相邻的指令和数据页被反复访问)。即使是 32 条 TLB 条目的小容量,对大多数程序也能维持 > 95% 的命中率。
sfence.vma:TLB 维护指令
TLB 是硬件缓存,不会自动感知页表的修改。当软件修改页表(添加/删除映射、修改权限)后,必须显式刷新相关的 TLB 条目:
sfence.vma x0, x0 # 刷新当前 hart 的所有 TLB 条目
sfence.vma a0, x0 # 刷新虚拟地址 a0 对应的 TLB 条目
sfence.vma x0, a1 # 刷新 ASID=a1 的所有 TLB 条目
sfence.vma a0, a1 # 刷新指定地址+指定 ASID 的 TLB 条目常见使用场景:
- 进程切换:切换
satp.ASID并执行sfence.vma - 页表修改:
munmap后刷新被释放范围的 TLB - mprotect:修改页面权限后刷新 TLB(否则旧权限可能残留在缓存中)
ASID:避免全量 TLB 刷新
如果不使用 ASID,每次进程切换都要用 sfence.vma 清空整个 TLB——新进程的代码和数据会被全部冷启动,性能代价显著。ASID(Address Space ID)为每个进程分配一个唯一标识,TLB 条目标记所属 ASID,这样进程 A 和进程 B 的翻译可以同时存在于 TLB 中。
# 进程切换时:只需切换 ASID,不需要 sfence.vma
li t0, (8 << 60) # MODE = Sv39
or t0, t0, a0 # a0 = 新进程的 ASID(移位到 [59:44])
or t0, t0, a1 # a1 = 新进程的根页表 PPN
csrw satp, t0
# 不执行 sfence.vma——TLB 中的旧 ASID 条目不会与新 ASID 匹配16 位 ASID 支持最多 65535 个并发地址空间。实际 Linux 中 ASID 的分配更加复杂——当 ASID 耗尽时需要回收(刷掉原本持有者),但框架本身已足够容纳绝大多数场景。
对汇编程序员的意义
你看到的总是虚拟地址
在所有用户态汇编程序中,la、ld、sd、j 使用的地址全部是虚拟地址。CPU 的 MMU 在每条访存指令背后默默地将它们翻译为物理地址。你不需要手动参与翻译过程。
la a0, buffer # a0 = 虚拟地址(不是物理地址)
ld t0, 0(a0) # 访问的是虚拟地址,MMU 翻译到物理 RAM页错误是"正常"异常
当你尝试访问一个"合法但尚未映射"的虚拟地址时,CPU 触发页错误异常(mcause=12/13/15)。这不是 bug——它是虚拟内存正常运作的一部分。
经典的惰性分配(Lazy Allocation)场景:
// C 代码
int *p = malloc(1024 * 1024); // 返回虚拟地址,未分配物理页
p[0] = 42; // 第一次访问 → 触发页错误
// OS 分配物理页 → 建立 PTE → 返回重试
// 第二次访问 → TLB 命中 → 直接写入从汇编视角看,malloc 之后的 sd t0, 0(a0) 可能触发一次无形的页错误处理——你的代码看起来是"一次写入",实际上 OS 和硬件协作完成了"缺页 → 分配 → 映射 → 重试"的过程。
连续虚拟地址不等于连续物理地址
这是理解虚拟内存后最重要的直观改变:
# 用户态程序视角:两块"相邻"的数据
la a0, array_a # a0 = 0x10000
la a1, array_b # a1 = 0x11000 (看起来"挨着")
# 但在物理内存中,array_a 可能在物理页帧 #42,
# array_b 可能在物理页帧 #781——完全不相邻物理内存的碎片化和离散分配被页表"拼接"成用户程序眼中连续平坦的地址空间。
对性能调优的实际影响
理解虚拟内存在以下场景中有直接好处:
- 避免跨页访问:频繁跨越 4 KiB 边界的访存模式会加倍 TLB 压力
- 理解 TLB 抖动:访问模式过于分散(超过 TLB 容量)会导致频繁的页表遍历——
perf stat -e dtlb_load_misses可以观测 - 大页的价值:处理超大数组时,使用 2 MiB 大页可将 TLB 覆盖范围扩大 512 倍
- 理解
wfi的行为:在虚拟内存使能的环境中,wfi可能因等待 TLB shootdown(其他 hart 发起的sfence.vma广播)而延迟醒来
构建一个最小页表(概念示例)
以下示例展示如何在 Bare 模式下为 M 模式 bare-metal 程序构建一个 Sv39 页表。这不是通常的操作方式(M 模式一般直接用 Bare),但能直观展示页表的结构:
.section .data
.align 12 # 4 KiB 对齐
root_page_table:
# L2 页表:只有一个条目,映射 VPN[2]=0 到下一级
.dword (l1_page_table >> 2) | 0x01 # PPN | V=1, R/W/X=0(非叶)
.align 12
l1_page_table:
# L1 页表:映射 VPN[1]=0 到下一级(最终为 4KB 页面)
# 这里使用 2 MiB 大页直接映射
.dword (0x80000000 >> 2) | 0x0F # PPN | V=1, R=1, W=1, X=1
# 其余 511 个条目全部为 0(无效)
.fill 511, 8, 0
# 启用这个页表(M 模式需先切换到 S 模式或通过特殊配置)
# 注意:此例仅为概念演示,实际在 U/S 模式下使用实际上物理地址 >> 12 才得到 PPN,PTE 中的 PPN 是物理地址的高位部分(>> 2 是因为 PTE 中 PPN 从 bit 10 开始)。这里的 >> 2 是简化——真实代码中需要精确定位 PPN 在 PTE 中的位置。
本章要点
- 虚拟内存通过页表实现 VA→PA 翻译,提供隔离(独立地址空间)、扩展(按需分页)和保护(R/W/X 权限)三大能力
- Sv39 是 RISC-V Linux 的主流页表格式:39 位 VA 经三级页表(L2→L1→L0)翻译,支持 4 KiB / 2 MiB / 1 GiB 三种页面大小
satpCSR 控制翻译的开关(MODE)和根页表的物理位置(PPN);写入satp后必须执行sfence.vma刷新 TLB- TLB 缓存 VA→PA 翻译,命中率 >99%,是虚拟内存体系实用化的关键——
sfence.vma维护 TLB 一致性,ASID 避免进程切换时全量刷新 - 对汇编程序员而言,地址翻译是透明的——但你应理解"连续虚拟地址不等于连续物理地址",以及页错误作为正常异常在惰性分配中的作用