Skip to content

Lab 3:Sv39 分页式虚拟内存系统

Estimated time to read: 10 minutes

DDL

  • 代码、报告:2025-11-11 23:59
  • 验收:2025-11-18 实验课

实验简介

在前两个实验中,我们的内核一直运行在物理地址空间中。本次实验的目标,是让内核正式进入“虚拟世界”——实现 RISC-V 的 Sv39 分页式虚拟内存系统

在理论课第八、九章中,我们已经学习了虚拟内存的概念与设计思想。现在,让我们从“为什么”出发,重新理一理它的演进脉络:

  • 在早期系统中,程序员需要手动管理物理内存,容易出错,也难以实现隔离和保护。
  • 随着系统复杂度提升,内存管理经历了从单一连续分配 → 静态分段(Base and Bound) → 动态分段(Segmentation)→ 分页(Paging)的演化。每种方式都有优缺点:分段容易产生外部碎片,分页则可能带来内部碎片

    vm_history
    内存管理的发展历程
    图片来源:Linux Kernel Development (3rd)

  • 分页机制的关键思想是:用“虚拟地址”统一抽象内存,让程序认为自己独占整个地址空间

实现虚拟内存需要软硬件协作,如下图所示:

vm_system
虚拟内存的软硬件协作
图片来源:Piyush Itankar on X: "Here is how MMU (memory management unit) works!"
  • CPU 内部的 MMU(Memory Management Unit) 负责解析页表、执行地址转换和权限检查。我们的所有硬件都是 QEMU 模拟的,它也会实现 MMU 的功能。
  • 我们实现的操作系统需要负责构建与维护页表处理缺页异常

本实验要实现 RISC-V 规范中的 Sv39 多级页表机制,你将完成以下关键步骤:

  1. 理解并掌握页表结构:学习 Sv39 的三级页表格式,弄清每级索引、PTE 各字段的意义。
  2. 构建内核页表:为内核镜像的各个段(.text.rodata.data.bss)建立映射,并配置正确的访问权限;同时实现内核空间的直接映射区域。
  3. 切换至虚拟地址执行:配置 satp 寄存器,执行 sfence.vma,完成从物理地址到虚拟地址的安全过渡。

完成本实验后,你的操作系统将第一次具备完整的地址空间抽象能力,为 Lab4 实现用户态进程奠定基础。

实验要求

首页#要求和评分标准

Part 0:环境配置

更新代码

现在你位于 lab2 分支。你需要创建 lab3 分支,合并上游的代码:

git checkout -b lab3
git fetch upstream
git merge upstream/lab3

下面的合并说明供同学们解决合并冲突时参考:lab3: init (!21) · Merge requests · OS / Code · GitLab

  • 新增实验相关:
    • 虚拟内存代码 vm.hvm.c
    • 头文件中新增了一些宏定义
    • vmlinux.lds 添加虚拟内存配置
    • 需要物理地址的位置添加了 PA2VAVA2PA
  • 变更:
    • vmlinux.lds:链接器脚本修改,其中 _sbss 移动到 .bss 段最开始的位置(因为待会要用),现在不是栈底了。重新弄了俩符号 _sstack_estack 作为内核栈的边界。之前使用 _sbss 作为内核栈的同学需要修改 head.S,而在 head.S 中自行定义栈边界的同学则不受影响。
    • head.S 中 Lab1 Task3(stvec 的设置)向前移动,紧接在 Lab1 Task1(栈的设置)之后。这样能更早地捕获异常。

Part 1:从物理地址到虚拟地址

虚拟与加载地址

同学们合并 vmlinux.lds 的更改时,应该注意到其中发生了这样的变更:

diff --git a/kernel/arch/riscv/kernel/vmlinux.lds b/kernel/arch/riscv/kernel/vmlinux.lds
index 6ede8c0..24c9f07 100644
--- a/kernel/arch/riscv/kernel/vmlinux.lds
+++ b/kernel/arch/riscv/kernel/vmlinux.lds
@@ -7,12 +7,15 @@ PHY_SIZE     = (128 * 1024 * 1024);
 PHY_END      = (PHY_START + PHY_SIZE);
 PGSIZE       = 0x1000;
 OPENSBI_SIZE = (0x200000);
+VM_DIRECT_START = 0xffffffd600000000;
+VM_DIRECT_END = (VM_DIRECT_START + PHY_SIZE);

 MEMORY {
     ram  (wxa!ri): ORIGIN = PHY_START + OPENSBI_SIZE, LENGTH = PHY_SIZE - OPENSBI_SIZE
+    ramv (wxa!ri): ORIGIN = VM_DIRECT_START + OPENSBI_SIZE, LENGTH = PHY_SIZE - OPENSBI_SIZE
 }

-BASE_ADDR = PHY_START + OPENSBI_SIZE;
+BASE_ADDR = VM_DIRECT_START + OPENSBI_SIZE;

 SECTIONS
 {
@@ -28,7 +31,7 @@ SECTIONS
         *(.text .text.*)

         _etext = .;
-    } AT>ram
+    } >ramv AT>ram

     .rodata : ALIGN(0x1000) {
         _srodata = .;

请编译内核,并观察上述修改引起的变化:

  • 修改BASE_ADDR 从物理地址 0x80000000 变为虚拟地址 0xffffffd600000000

    变化:打开 System.map,看看内核符号们的地址发生了什么变化?

    所有符号的地址都从原来的 0x80000000 开头变为 0xffffffd600000000 开头。Part 2 内存布局一节会解释为什么选择这个地址。

  • 修改:各 Section 的地址发生了变化,相关的链接器脚本语法见 3.6.8.2 Output Section LMA - LD

    Every section has a virtual address (VMA) and a load address (LMA); see Basic Linker Script Concepts. The virtual address is specified by the see Output Section Address described earlier. The load address is specified by the AT or AT> keywords. Specifying a load address is optional.

    The AT keyword takes an expression as an argument. This specifies the exact load address of the section. The AT> keyword takes the name of a memory region as an argument. See MEMORY Command. The load address of the section is set to the next free address in the region, aligned to the section’s alignment requirements.

    变化:链接器在执行符号解析、重定位等链接操作时,现在会使用 VMA 地址(虚拟地址),而在生成最终的内核镜像时,仍然使用的是 LMA 地址(物理地址)。

    注意到链接器脚本中的每个段后都有 AT>ram,这保证了内核镜像仍然会被加载到 0x80200000 开始的物理内存中。

    动手做:验证内核加载的仍是物理地址

    动手调试一下刚编译的内核:

    • 0x80200000 处打断点,让 GDB 运行到断点处停下来;
    • 你会发现 GDB 现在没法解析源码位置(比如断点处停下来的时候显示 0x80200000 in ??())。这是因为 GDB 是根据符号的位置来推算当前位于哪个函数的,现在所有符号都变成了虚拟地址,它现在找不到 0x80200000 对应哪个函数;
    • 使用 GDB 或 QEMU Monitor,将从 0x80200000 开始的内存打印为指令,与 vmlinux.asm 中的反汇编结果对比,它们是否一致?

寻址方式

在刚才的动手做中,你应该发现了结果存在一些不一致。这里以 mm_init() 函数的调用为例:

vmlinux.asm 中的反汇编结果
    call mm_init
ffffffd600200018: 3f9000ef           jal ffffffd600200c10 <mm_init>
GDB 中的反汇编结果
(gdb) x/10i 0x80200018
   0x80200018:  jal     0x80200c10
(gdb) x/10xw 0x80200018
0x80200018:     0x3f9000ef

objdump 反汇编结果显示 jal 指令跳转到 0xffffffd600200c10,而 GDB 显示跳转到 0x80200c10。但是,两边的机器码都是 0x3f9000ef,说明它们实际上是同一条指令。你能解释这是为什么吗?

动手做:解释同一条指令的不同翻译结果

提示:

  • 把机器码(用你自己的机器码,和文档不一定相同)输入到 rvcodec.js · RISC-V Instruction Encoder/Decoder 中,看看解码出来是什么。
  • jal 指令的寻址方式是什么?
  • vmlinux.asm 和 GDB 中,当前指令地址(PC)分别是什么?你能算算跳转目标地址是否和翻译结果一致吗?

再看看其他指令。在 Lab1 中我们设置了栈,一般是通过 la 某个符号地址实现的,看看看它翻译成了什么:

vmlinux.asm 中的反汇编结果
    /* Lab1 Task1 */
    # need to setup sp for C function
    # s0 is callee-saved, so we can use it as a temporary register
    la sp, _estack
ffffffd600200004: 00009117           auipc sp,0x9
ffffffd600200008: ffc10113           addi sp,sp,-4 # ffffffd600209000 <kthread_create_info_cache>

通过上述探究,同学们应该能想起《计算机组成》课上学习过的 RISC-V 寻址方式:

addressing_modes
RISC-V 的寻址方式
图片来源:《计算机组成》课程 PPT

现在我们知道,链接器在解析函数、全局变量等符号时,使用的都是 PC 相对寻址。记住这一点,它为下文的重定位奠定了基础。

虚拟与物理地址的转换

需要注意的是,有些地方仍然使用物理地址。例如:

  • sbi_debug_console_write() 函数仍然接收物理地址参数
  • Buddy System 仍然使用物理页帧(PFN)管理内存
  • 下文学习的 SATP、PTE 中的 PPN 也是物理页号

这些位置使用 VA2PAPA2VA 等宏进行转换。在合并、编写代码时,请务必区分清楚虚拟地址与物理地址,避免混淆。

Part 2:构造 Sv39 页表

RISC-V Sv39 规范阅读

本节要求同学们完整阅读特权级手册中的以下两节:

不可跳过规范阅读

Sv39 是分页机制的核心,历年考试几乎必考。不读标准,你会在位宽、权限位、翻译流程上出现理解偏差。请务必认真通读并结合图表理解。

导读:

  • 阅读目标是掌握 RISC-V 的虚拟地址结构、页表层级与翻译算法。阅读 Sv32 了解机制,阅读 Sv39 明确差异
  • Sv32 是给 32 位系统设计的,它详细描述了“地址翻译的全过程”,是学习流程的模板
  • Sv39 面向 64 位系统,只在 Sv32 的基础上说明“有何不同”,例如:

    • 页表层级从 2 级变为 3 级;
    • 虚拟地址长度从 32 位变为 39 位;
    • 页表项(PTE)扩展为 8 字节;
    • 支持 2 MiB、1 GiB 的大页(megapage/gigapage)。

阅读过程中画图是一个很好的习惯。下面这些图来自 RISC-V Sv32,Sv39 を理解する,有助于同学们理解 Sv32/Sv39 的各种页类型与地址转换流程:

Sv32 地址转换流程 Sv32 2MiB 大页示意
Sv32 地址转换与 2MiB 大页
RISC-V Sv32,Sv39 を理解する
Sv39 地址转换流程 Sv39 2MiB 大页示意 Sv39 1GiB 大页示意
Sv39 地址转换与 2MiB、1GiB 大页
RISC-V Sv32,Sv39 を理解する
要点:Sv39 分页虚拟内存系统
类别 关键要点
地址结构 Sv39 虚拟地址 39 位:VPN[2:0] 各 9 位,页内偏移 12 位。
CPU 访问时检查符号扩展:bit 63–39 必须与 bit 38 相同。
页表结构 三级页表,每层 512 项、每项 8 B。
页表页大小 4 KiB;根页物理基址在 satp.PPN
页表项(PTE)格式 低 10 位含 V/R/W/X/U/G/A/D;其余为 PPN。
R=0 且 W=1 非法;A/D 由硬件在访问时更新。
树形结构 叶子表项:R/W/X 任一为 1。
中间表项:V=1 且 R/W/X=0。
权限控制 V:有效位。
R/W/X:读/写/执行许可。
U:用户可访问。
G:全局映射。
A/D:访问/修改标记。
非法组合会触发页错误。
地址转换流程 satp.PPN 逐级索引:VPN[2] → VPN[1] → VPN[0]。
遇叶子即停止;无效/越权/未对齐触发页错误。
关键寄存器 satp.MODE=8:Sv39。
satp.ASID:区分地址空间。
关键指令 sfence.vma:刷新 TLB,避免旧映射。
访存行为控制 sstatus.SUM:允许内核访问 U=1 页。
sstatus.MXR:允许从可执行页读。

内存屏障与 TLB、缓存刷新

刚才阅读的章节末尾有这么一段话:

Note that writing satp does not imply any ordering constraints between page-table updates and subsequent address translations, nor does it imply any invalidation of address-translation caches. If the new address space’s page tables have been modified, or if an ASID is reused, it may be necessary to execute an SFENCE.VMA instruction (see Section 12.2.1) after, or in some cases before, writing satp.

本节就来看看 sfence.vma。请同学们阅读特权级手册的 12.2.1. Supervisor Memory-Management Fence Instruction 一节。阅读时,你需要回忆《计算机组成》中学习的 TLB、《计算机体系结构》中学习的缓存一致性等相关知识。

导读:

  • The supervisor memory-management fence instruction SFENCE.VMA is used to synchronize updates to in-memory memory-management data structures with current execution. Instruction execution causes implicit reads and writes to these data structures; however, these implicit references are ordinarily not ordered with respect to explicit loads and stores.

    这里的 in-memory memory-management data structures 指的就是放在内存中的页表

  • The SFENCE.VMA is used to flush any local hardware caches related to address translation. It is specified as a fence rather than a TLB flush to provide cleaner semantics with respect to which instructions are affected by the flush operation and to support a wider variety of dynamic caching structures and memory-management schemes. SFENCE.VMA is also used by higher privilege levels to synchronize page table writes and the address translation hardware.

    这里解释了 sfence.vma 为什么被设计成内存屏障(fence)而不是简单的 TLB 刷新指令。《计算机体系结构》课上我们学习了 CPU 在 L1 会有 i Cache 和 d Cache,这同样涉及虚拟内存。sfence.vma 的语义更宽泛,能够涵盖所有与地址转换相关的缓存结构

  • Changes to the sstatus fields SUM and MXR take effect immediately, without the need to execute an SFENCE.VMA instruction. Changing satp.MODE from Bare to other modes and vice versa also takes effect immediately, without the need to execute an SFENCE.VMA instruction. Likewise, changes to satp.ASID take effect immediately.

    这里说明了首次启用分页机制时,不需要 sfence.vma。但是,如果页表发生变化,或者 ASID 被重用,就需要执行 sfence.vma

  • 该节剩余的部分涉及《计算机体系结构》中的内存一致性模型(RVWMO,对应体系结构课上学习的 Weak Ordering)。内存一致性在体系结构中也算比较难的内容,这里就不展开了。感兴趣的同学可以自行阅读,在报告中讲讲你读完后觉得 sfence.vma 有什么有意思的地方。这里提供一篇参考资料:细说 RVWMO:RISC-V 指令集手册 Appendix A(一) - 知乎

    memory_model
    内存一致性模型概览
    图片来源:《计算机体系结构》课程 PPT

内核内存布局

RISC-V 架构下的 Linux 内核的内存布局见 Virtual Memory Layout on RISC-V Linux — The Linux Kernel documentation。我们采取简化的模型。

  • 内核与用户空间:

    同学们已经了解到,Sv39 分页式虚拟内存系统中,64b 的虚拟地址实际有效的只有 39b,其余高位必须与 bit 38 保持符号扩展一致。这意味着整个 \(2^{64}\text{B}\) 的地址空间被划分为两个对称的区域:

    • 用户空间0x0000000000000000 ~ 0x0000003fffffffff(低地址半区)
    • 无效地址:多达 16M TB。
    • 内核空间0xffffffc000000000 ~ 0xffffffffffffffff(高地址半区)

    在本实验中,我们只实现内核空间的映射,用户空间的映射将在 Lab4 实现。

  • 内核空间:

    Linux 的内核空间布局较为复杂,包含设备 I/O、内核堆栈等区域,我们全部弃用。本实验只实现直接映射,也就是将 Lab2 中介绍的从 0x80000000 开始的物理内存全部映射到 0xffffffd600000000 开始的虚拟地址空间。

重定位

内核启动初期运行在物理地址上,它要怎么切换到虚拟地址空间呢?

最重要的问题是 PC 要从物理地址跳转到虚拟地址。回忆目前为止学习的所有 RISC-V 知识,有哪些地方可以修改 PC?

  • 跳转:jaljalrret 指令(本质上是 jalr
  • Trap:陷入、xRET 返回

Linux 选择了通过 Trap 实现,留给同学们探究。这里介绍一下通过跳转实现重定位的思路:

  • 写一个汇编函数 relocate()
  • 在其中给某些寄存器加上偏移量 PA2VA_OFFSET
  • ret 返回,PC 和某些寄存器就完成了重定位,切换到了虚拟地址
  • 因为函数、全局变量都是 PC 相对寻址的,所以整个内核镜像都完成了重定位

这里的某些寄存器请同学们自己思考。

动手做:中断实现重定位

阅读 Linux 内核源码 arch/riscv/kernel/head.Srelocate_enable_mmu() 函数,解释它是怎么实现重定位的。

完成 Task 1 后,请你分析这两种重定位方式是否都需要恒等映射?为什么?

Task 1:大页表双重映射和重定位

术语:映射

映射(mapping):将虚拟地址空间的一段连续区域对应到物理地址空间的一段连续区域的过程。

在 RISC-V 中,映射是通过页表实现的。页表将虚拟地址转换为物理地址,并提供权限控制。

所以,当我们说“建立映射”时,实际上是指在页表中创建相应的页表项(PTE),以实现虚拟地址到物理地址的转换。

setup_vm() 用于构造内核启动初期的页表 early_pg_dir。这是一个仅有 Gigapage 的 Sv39 页表,其中包含两个映射关系:

  • 映射大小:物理内存有多少,就映射多大。QEMU 默认分配 128 MiB 内存,可以通过 -m 参数调整。下文以 128 MiB 为例。
  • 恒等映射:虚拟地址 0x80000000~0x88000000 映射到物理地址 0x80000000~0x88000000
  • 内核映射:虚拟地址 0xffffffd600000000~0xffffffd608000000 映射到物理地址 0x80000000~0x88000000
early_pg_dir 的映射关系示意图

你的任务是:

  • setup_vm() 中,完成对 early_pg_dir 的初始化,建立包含上述映射关系的大页表。页表项的各个位应当根据存放的内容和标准的要求设置。

    该函数的返回值是待写入 satp 寄存器的值,也就是说它可以这么用:

    call setup_vm
    csrw satp, a0
    

    选择让 setup_vm() 返回 satp 值,是因为在 C 语言中用运算符构造 satp 的值会比写汇编更方便一些。请同学们善用头文件中提供的宏 😉。

  • head.S 中,调用 setup_vm(),然后用汇编开启 Sv39 分页虚拟内存。

  • head.S 中实现 relocate() 汇编函数,完成重定位。具体实现方式自由选择,跳转或模仿 Linux 的 Trap 方式都行。

你需要想清楚下列问题:

  • 要映射的区域大小是多少?Gigapage 大页的大小是多少?你需要创建多少个页表项?

完成条件

  • 开启虚拟内存后没有异常,内核和 Lab2 一样正常运行
  • 写入 SATP 寄存器后,QEMU Monitor 能够正确执行下面的指令:

    (qemu) gva2gpa 0x80000000
    gpa: 0x80000000
    (qemu) gva2gpa 0xffffffd600000000
    gpa: 0x80000000
    
  • 通过 Lab3 Task1 测试

Task 2:多级页表

刚才实现的页表比较粗暴:

  • 用 Gigapage 粗粒度地映射了整片空间
  • 没有完全切换到虚拟地址空间,仍能通过恒等映射访问物理地址

但通过刚才的简单页表,我们成功切换到了虚拟地址空间,现在 Buddy System 返回的内存就都是虚拟地址了。有了 Buddy System 的支持,我们就可以构造更精细的多级页表映射了。

你的任务是:

  • 补全 setup_vm_final()

    该函数构造 Sv39 三级页表 swapper_pg_dir,并返回待写入 satp 寄存器的值。

    该页表需要为 vmlinux.lds 中定义的各个段建立映射关系,页表项的各个位应当根据存放的内容和标准的要求设置。

    • .text:代码
    • .rodata:只读数据
    • .data.bss剩余的所有物理内存:读写数据

    注意最后一段映射要包括剩余的所有物理内存,它们要给 Buddy System 使用。

    要求该函数调用 create_mapping() 来完成具体的映射工作。

  • 补全 create_mapping()

    该函数负责在给定的三级页表中,建立从 pava 的映射,长度为 size 字节,权限为 perm

    该函数有非常多种实现方法:

    • 单层循环
    • 多层循环
    • 递归

    请自由发挥。

  • head.S 中,调用 setup_vm_final(),然后用汇编切换到最终的页表 swapper_pg_dir

完成条件

  • 开启虚拟内存后没有异常,内核和 Lab2 一样正常运行
  • 写入 SATP 寄存器后,QEMU Monitor 能够正确执行下面的指令:

    (qemu) gva2gpa 0xffffffd600200000
    gpa: 0x80200000
    
  • 通过 Lab3 Task2 测试。

扩展阅读:QEMU 的 TLB 实现

本节我们研究一个问题:修改 satp 寄存器和 sfence.vma 这两个操作的先后顺序应该是怎样的?

我们知道 sfence.vma 的一大作用是刷新 TLB 和缓存,以避免使用旧的地址映射,应该在写入 satp 之后执行。但是如果去看 Linux 的 arch/riscv/kernel/head.S,会发现它是在写入 satp 之前执行的:

arch/riscv/kernel/head.S
    /*
     * Load trampoline page directory, which will cause us to trap to
     * stvec if VA != PA, or simply fall through if VA == PA.  We need a
     * full fence here because setup_vm() just wrote these PTEs and we need
     * to ensure the new translations are in use.
     */
    sfence.vma
    csrw CSR_SATP, a0

这是因为 Linux 采用 Trap 的方式完成重定位。理由已经在注释中说明了,这里主要是用它的内存屏障(fence)语义,确保在写入 satp 之后硬件能立刻看到内存中的页表,从而立刻触发 Trap 跳转到重定位代码。而刷新 TLB 和缓存并不是主要目的,因为这里是第一次开启 MMU,前文引用的 RISC-V 规范已经说明了这种情况(Bare 到其他模式)不需要 sfence.vma

除去这一特殊情况,一般 sfence.vma 都会放置在 satp 写入之后。SFENCE.VMA Before or After SATP Write · Issue #226 · riscv/riscv-isa-manual 对此进行了讨论:

  • SFENCE before SATP may be necessary: The concern is, what if the mapping for the instruction immediately after SFENCE.VMA has been modified? In the Linux kernel, this mapping is fixed (regardless of address space) so the concern does not apply.
  • SFENCE after SATP write is definitely necessary, though. In general, you need to SFENCE after you’ve recycled an ASID. Since we don’t use ASIDs in the Linux kernel yet, every context switch is effectively an ASID reuse, hence the full TLB flush.

喜欢动手的同学可能还会尝试删去 sfence.vma,结果发现内核依然能正常运行。难道 QEMU 没有实现 TLB?我们可以设计一个小实验验证一下:

// create old TLB entry
asm volatile("li t0, 0x80200000");
asm volatile("ld t1, 0(t0)");
// set satp with swapper_pg_dir
asm volatile("csrw satp, %0" :: "r"(new_satp_value));
// try to hit old TLB entry
asm volatile("li t0, 0x80200000");
asm volatile("ld t1, 0(t0)");

如果 QEMU 实现了 TLB,那么第二次 ld 指令应该会命中旧的 TLB 条目。但这条指令触发了 page fault,说明没有命中 TLB。

QEMU 是开源的,所以我们可以直接读源码一探究竟。qemu tlb 实现分析 | Sherlock's blog 是一篇很好的博客(作者还写了 QEMU 的其他部分分析),虽然相应的源码版本为 v5.0,但整体思路没有太大变化。我们找到 QEMU 处理 satp 写入部分的代码:

target/riscv/csr.c
static target_ulong legalize_xatp(CPURISCVState *env, target_ulong old_xatp,
                                  target_ulong val)
{
    target_ulong mask;
    bool vm;
    if (riscv_cpu_mxl(env) == MXL_RV32) {
        vm = validate_vm(env, get_field(val, SATP32_MODE));
        mask = (val ^ old_xatp) & (SATP32_MODE | SATP32_ASID | SATP32_PPN);
    } else {
        vm = validate_vm(env, get_field(val, SATP64_MODE));
        mask = (val ^ old_xatp) & (SATP64_MODE | SATP64_ASID | SATP64_PPN);
    }

    if (vm && mask) {
        /*
         * The ISA defines SATP.MODE=Bare as "no translation", but we still
         * pass these through QEMU's TLB emulation as it improves
         * performance.  Flushing the TLB on SATP writes with paging
         * enabled avoids leaking those invalid cached mappings.
         */
        tlb_flush(env_cpu(env));
        return val;
    }
    return old_xatp;
}

QEMU 先验证待写入的值的合法性,如果合法,且和旧值不同,那么就调用 tlb_flush() 刷新 TLB。源码注释说明这是为了避免泄漏旧的映射。然而在真实的硬件实现中,并不会有这一保证,所以我们在实验中仍然需要 sfence.vma