Skip to content
Published at:

22. 页表与虚拟内存

至此,我们学会了写汇编程序、处理异常和中断、与 C 代码互操作。但所有这些操作中使用的"地址"——la a0, label 中的 labelld t0, 0(sp) 中的 0(sp)——究竟是物理 RAM 中的真实地址,还是被 CPU 悄悄翻译过的虚拟地址?答案是:在有操作系统的环境中,所有地址都是虚拟地址。本章剖析 RISC-V 的虚拟内存机制:页表如何工作、TLB 如何让翻译变快、以及这一切对汇编程序员意味着什么。

虚拟内存的作用

三个核心目标

虚拟内存并非"让程序员省心"的便利设施——它是现代操作系统安全性和可靠性的基石。三个核心目标:

1. 隔离(Isolation)

每个用户进程拥有独立的虚拟地址空间。进程 A 的 0x10000 和进程 B 的 0x10000 映射到完全不同的物理页帧。用户程序无法(也不应该)访问属于其他进程或内核的物理内存。页表是硬件强制隔离的机制——没有合法 PTE 映射,任何 load/store 都将在硬件层面触发页错误异常。

方案 1:Mermaid 流程图

flowchart LR subgraph VA_A["进程 A 虚拟地址"] A0x10000["0x10000"] A0x11000["0x11000"] end subgraph VA_B["进程 B 虚拟地址"] B0x10000["0x10000"] end subgraph PA["物理内存"] direction TB PA42["物理页帧 #42 (A 的数据)"] PA17["物理页帧 #17 (A 的代码)"] PA99["物理页帧 #99 (B 的数据)"] PA3["物理页帧 #3 (B 的栈)"] PA91["物理页帧 #91 (A 的栈)"] end A0x10000 --> PA42 A0x11000 --> PA17 B0x10000 --> PA99

方案 2:Markdown 表格

进程虚拟地址物理内存
A0x10000物理页帧 #42(A 的数据)
A0x11000物理页帧 #17(A 的代码)
B0x10000物理页帧 #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 值名称虚拟地址宽度页表级数虚拟地址空间
0Bare无翻译0=物理地址
8Sv3939 位3 级512 GiB
9Sv4848 位4 级256 TiB
10Sv5757 位5 级128 PiB

Sv39(39 位,3 级页表)是目前 RISC-V Linux 最常用的模式。Sv48 和 Sv57 是为大内存服务器和数据中心准备的——对汇编学习而言,完全掌握 Sv39 就足够理解虚拟内存的本质。

Bare 模式

satp.MODE = 0(Bare 模式),地址翻译被完全绕过——虚拟地址 = 物理地址。bare-metal 程序和 M 模式代码通常运行在 Bare 模式下。嵌入式系统如果不需要虚拟内存,根本不需要实现 satp。

asm
# 启用 Bare 模式(关掉地址翻译)
    csrw    satp, x0             # 写 0 到 satp: MODE=0, ASID=0, PPN=0

切换 satp:sfence.vma

写入 satp 不会立即影响正在执行的指令流——但下一条指令开始,所有后续取指和访存都将使用新的页表。由于 TLB 中可能缓存了旧翻译,必须在写 satp 后执行 TLB 刷新:

asm
    # 切换到新的页表
    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 个标志位的详细定义:

名称描述
0VValid——该 PTE 是否有效。0=无效(访问触发页错误)
1RReadable——可读
2WWritable——可写
3XExecutable——可执行
4UUser——U 模式可访问。0=仅 S/M 模式可访问
5GGlobal——全局映射,所有 ASID 共享(如内核页)
6AAccessed——已被读过/执行过(硬件自动置位或软件模拟)
7DDirty——已被写过(硬件自动置位或软件模拟)
8-9RSWReserved for Supervisor Software——OS 自由使用

R/W/X 的组合定义了标准的权限语义:

RWX语义
000非叶节点(指向下一级页表)
100只读数据页
010保留(非法组合)
110可读写数据页
001只执行(纯代码页)
101可读可执行(典型代码段)
111可读可写可执行(不推荐,W^X 违规)

注意 W=1, R=0 是非法组合——可写但不可读的页面在 RISC-V 架构中没有意义。

大页(Superpage)

Sv39 支持两种大页——当翻译提前终止于 L2 或 L1 层时:

终止于页面大小虚拟地址覆盖
L0(叶)4 KiBoff=12 bit
L1(中间)2 MiBVPN[0]+off=21 bit(512×4KB)
L2(根)1 GiBVPN[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 条目:

asm
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 条目

常见使用场景:

  1. 进程切换:切换 satp.ASID 并执行 sfence.vma
  2. 页表修改munmap 后刷新被释放范围的 TLB
  3. mprotect:修改页面权限后刷新 TLB(否则旧权限可能残留在缓存中)

ASID:避免全量 TLB 刷新

如果不使用 ASID,每次进程切换都要用 sfence.vma 清空整个 TLB——新进程的代码和数据会被全部冷启动,性能代价显著。ASID(Address Space ID)为每个进程分配一个唯一标识,TLB 条目标记所属 ASID,这样进程 A 和进程 B 的翻译可以同时存在于 TLB 中。

asm
# 进程切换时:只需切换 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 耗尽时需要回收(刷掉原本持有者),但框架本身已足够容纳绝大多数场景。

对汇编程序员的意义

你看到的总是虚拟地址

在所有用户态汇编程序中,laldsdj 使用的地址全部是虚拟地址。CPU 的 MMU 在每条访存指令背后默默地将它们翻译为物理地址。你不需要手动参与翻译过程。

asm
    la      a0, buffer           # a0 = 虚拟地址(不是物理地址)
    ld      t0, 0(a0)            # 访问的是虚拟地址,MMU 翻译到物理 RAM

页错误是"正常"异常

当你尝试访问一个"合法但尚未映射"的虚拟地址时,CPU 触发页错误异常(mcause=12/13/15)。这不是 bug——它是虚拟内存正常运作的一部分。

经典的惰性分配(Lazy Allocation)场景:

c
// C 代码
int *p = malloc(1024 * 1024);  // 返回虚拟地址,未分配物理页
p[0] = 42;                     // 第一次访问 → 触发页错误
                                // OS 分配物理页 → 建立 PTE → 返回重试
                                // 第二次访问 → TLB 命中 → 直接写入

从汇编视角看,malloc 之后的 sd t0, 0(a0) 可能触发一次无形的页错误处理——你的代码看起来是"一次写入",实际上 OS 和硬件协作完成了"缺页 → 分配 → 映射 → 重试"的过程。

连续虚拟地址不等于连续物理地址

这是理解虚拟内存后最重要的直观改变:

asm
# 用户态程序视角:两块"相邻"的数据
    la      a0, array_a          # a0 = 0x10000
    la      a1, array_b          # a1 = 0x11000  (看起来"挨着")
    # 但在物理内存中,array_a 可能在物理页帧 #42,
    # array_b 可能在物理页帧 #781——完全不相邻

物理内存的碎片化和离散分配被页表"拼接"成用户程序眼中连续平坦的地址空间。

对性能调优的实际影响

理解虚拟内存在以下场景中有直接好处:

  1. 避免跨页访问:频繁跨越 4 KiB 边界的访存模式会加倍 TLB 压力
  2. 理解 TLB 抖动:访问模式过于分散(超过 TLB 容量)会导致频繁的页表遍历——perf stat -e dtlb_load_misses 可以观测
  3. 大页的价值:处理超大数组时,使用 2 MiB 大页可将 TLB 覆盖范围扩大 512 倍
  4. 理解 wfi 的行为:在虚拟内存使能的环境中,wfi 可能因等待 TLB shootdown(其他 hart 发起的 sfence.vma 广播)而延迟醒来

构建一个最小页表(概念示例)

以下示例展示如何在 Bare 模式下为 M 模式 bare-metal 程序构建一个 Sv39 页表。这不是通常的操作方式(M 模式一般直接用 Bare),但能直观展示页表的结构:

asm
    .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 三种页面大小
  • satp CSR 控制翻译的开关(MODE)和根页表的物理位置(PPN);写入 satp 后必须执行 sfence.vma 刷新 TLB
  • TLB 缓存 VA→PA 翻译,命中率 >99%,是虚拟内存体系实用化的关键——sfence.vma 维护 TLB 一致性,ASID 避免进程切换时全量刷新
  • 对汇编程序员而言,地址翻译是透明的——但你应理解"连续虚拟地址不等于连续物理地址",以及页错误作为正常异常在惰性分配中的作用