第三章 页表
页表是操作系统为每个进程提供其私有地址空间和内存的最常用机制。页表决定了内存地址的含义,以及可以访问物理内存的哪些部分。它们使 xv6 能够隔离不同进程的地址空间,并将这些地址空间复用到单一的物理内存之上。页表提供了一层间接性,使操作系统能够实现许多有用的技巧。xv6 实现了其中的一些:在多个地址空间中映射同一段内存(一个跳板页),通过一个未映射的页来保护内核栈和用户栈,以及按需惰性地分配用户堆内存。本章其余部分将解释 RISC-V 硬件所提供的页表,以及 xv6 是如何使用它们的。
一、分页硬件
作为提醒,RISC-V 指令(无论是用户态还是内核态)都操作虚拟地址。机器的 RAM,即物理内存,则使用物理地址进行索引。RISC-V 的页表硬件通过将每个虚拟地址映射到一个物理地址,把这两种地址连接起来。
xv6 使用 RISC-V 的 Sv39 模式,这意味着在 64 位虚拟地址中只使用最低的 39 位;最高的 25 位不使用。在这种 Sv39 配置下,一个 RISC-V 页表在逻辑上是一个包含 2^27(134,217,728)个页表项(PTE)的数组。每个 PTE 包含一个 44 位的物理页号(PPN)以及一些标志位。分页硬件通过使用这 39 位中的高 27 位作为索引,在页表中找到一个 PTE,然后构造一个 56 位的物理地址:其高 44 位来自 PTE 中的 PPN,低 12 位则直接拷贝自原始虚拟地址。图 3.1 以将页表逻辑地看作一个简单的 PTE 数组的方式展示了这一过程(实际上 RISC-V 的页表是一个树结构;更完整的说明见图 3.2)。页表使操作系统能够以 4096(2^12)字节对齐的块为粒度来控制虚拟地址到物理地址的转换。这样的一个块称为一页。
RISC-V 的设计为虚拟地址和物理地址的扩展都预留了空间。如果需要更大的虚拟地址空间,RISC-V 支持 Sv48 模式,使用 48 位虚拟地址。物理地址同样具有增长空间:PTE 格式中为物理页号预留了再增长 10 位的空间。RISC-V 的设计者基于对技术发展的预测选择了这些地址大小。2^48 字节等于 262,144 GB,这是一个远大于当今任何应用程序可能使用的用户虚拟地址空间。2^56 字节的物理地址空间等于 65,536 TB,这比当前任何计算机所能配备的 RAM 都要多得多。

图 3.1:将虚拟地址映射到物理地址的平坦页表的抽象视图。
如图 3.2 所示,RISC-V CPU 的页表以三级树的形式存储在物理内存中。树的根是一个 4096 字节的页表页,其中包含 512 个 PTE,这些 PTE 保存了指向下一层页表页的物理地址。下一层中的每个页表页也包含 512 个 PTE,用于树的最终一层。分页硬件使用这 27 位中的最高 9 位来选择根页表页中的一个 PTE,用中间的 9 位来选择下一层页表页中的一个 PTE,用最低的 9 位来选择最终的 PTE。(在 Sv48 的 RISC-V 中,页表有四层,虚拟地址的第 39 到 47 位用于索引顶层。)
如果将一个地址翻译所需的三个 PTE 中的任何一个不存在,分页硬件就会触发一次缺页异常,并由内核负责处理该缺页(见第 4 章和第 5 章)。
与图 3.1 中的单级设计相比,图 3.2 所示的三级结构提供了一种更加节省内存的方式来记录 PTE。在常见情况下,大范围的虚拟地址并没有映射,三级结构可以省略整个页目录。例如,如果一个应用程序只使用从地址零开始的少量页面,那么顶层页目录中第 1 到第 511 项都是无效的,内核就不需要为这 511 个中间页目录分配页面。此外,内核也不需要为这 511 个中间页目录对应的底层页目录分配页面。因此,在这个例子中,三级设计节省了 511 个中间页目录页面,以及 511 × 512 个底层页目录页面。
尽管 CPU 在执行加载或存储指令时会在硬件中遍历这三级结构,但三级结构的一个潜在缺点是:CPU 必须从内存中加载三个 PTE,才能将加载/存储指令中的虚拟地址翻译为物理地址。为了避免从物理内存加载 PTE 的开销,RISC-V CPU 会将页表项缓存在一个称为转换后备缓冲区(TLB)的结构中。

图 3.2:RISC-V 地址转换的详细信息。
每个 PTE 都包含一些标志位,用来告诉分页硬件相关的虚拟地址允许如何使用。PTE_V 表示该 PTE 是否存在:如果未设置,对该页的访问会导致缺页异常(即不被允许)。PTE_R 控制是否允许指令读取该页。PTE_W 控制是否允许指令写入该页。PTE_X 控制 CPU 是否可以将该页的内容解释为指令并执行。PTE_U 控制用户态指令是否允许访问该页;如果 PTE_U 未设置,则该 PTE 只能在监督者模式下使用。图 3.2 展示了这些标志位在 PTE 中的位置。标志位以及所有其他与分页硬件相关的结构都定义在(0500)中。
为了让 CPU 使用某个页表,内核必须将根页表页的物理地址写入 satp 寄存器。CPU 随后执行的所有指令所生成的地址,都会使用 satp 指向的页表进行转换。每个 CPU 都有自己独立的 satp,这样不同的 CPU 就可以运行不同的进程,而每个进程都拥有由其各自页表描述的私有地址空间。
从内核的角度来看,页表是存储在内存中的数据,内核使用的创建和修改页表的代码,与操作任何树形数据结构的代码非常相似。下面对本书中使用的一些术语作说明。物理内存指的是 RAM 中的存储单元。物理内存中的一个字节都有一个地址,称为物理地址。对地址进行解引用的指令(例如加载、存储、跳转和函数调用)只使用虚拟地址,分页硬件会将其翻译为物理地址,然后交给 RAM 硬件进行读写。地址空间是指在给定页表中有效的虚拟地址集合;每个 xv6 进程都有独立的用户地址空间,xv6 内核也有自己的地址空间。用户内存指的是某个进程的用户地址空间,以及该页表允许该进程访问的物理内存。虚拟内存则是指与管理页表以及利用页表实现诸如隔离等目标相关的一系列思想和技术。

图 3.3:左侧是 xv6 的内核虚拟地址空间。RWX 表示 PTE 的读、写和执行权限。右侧是 xv6 期望看到的 RISC-V 物理地址空间。
二、内核地址空间
在启动时,xv6 会创建一个描述内核地址空间的单一页表。内核对其地址空间的布局进行配置,使其能够在可预测的虚拟地址处访问物理内存和各种硬件资源。图 3.3 展示了这种布局如何将内核虚拟地址映射到物理地址。文件(0200)声明了 xv6 内核内存布局中使用的常量。
QEMU 模拟了一台计算机,其 RAM(物理内存)从物理地址 0x80000000 开始,至少一直延伸到 0x88000000,xv6 将其称为 PHYSTOP。QEMU 的模拟还包括诸如磁盘接口之类的 I/O 设备。QEMU 通过内存映射的控制寄存器将设备接口暴露给软件,这些寄存器位于物理地址空间中 0x80000000 以下的位置。内核可以通过读取或写入这些特殊的物理地址与设备交互;此类读写并不是与 RAM 通信,而是与设备硬件通信。第 4 章将解释 xv6 如何与设备交互。
内核将所有物理 RAM 和设备寄存器映射到与其物理地址相同的虚拟地址。这被称为“直接映射”,它使内核只需对虚拟地址 x 执行加载或存储操作,就可以读写物理地址 x。内核代码本身在虚拟地址空间和物理内存中都位于 KERNBASE=0x80000000。当 kfork(2373)为子进程分配用户内存时,分配器返回该内存的物理地址;kfork 在将父进程的用户内存拷贝到子进程时,直接将该地址作为虚拟地址使用。
有几个内核虚拟地址并不是直接映射的:
- 跳板页(trampoline page)。它被映射在虚拟地址空间的顶部;用户页表中也有相同的映射。第 4 章将讨论跳板页的作用,但在这里我们可以看到页表的一个有趣用法:一个物理页(保存跳板代码)在内核的虚拟地址空间中被映射了两次——一次在虚拟地址空间的顶部,另一次是通过直接映射。
- 内核栈页。每个进程都有自己的内核栈,它被映射在较高的内核虚拟地址处,这样 xv6 就可以在其下方留出一个未映射的保护页。保护页的 PTE 是无效的(即未设置 PTE_V),因此如果内核发生内核栈溢出,很可能会触发缺页异常并导致内核
panic。若没有保护页,溢出的栈将覆盖其他内核内存,从而导致错误的行为。相比之下,panic崩溃是更可取的结果。
当内核通过高地址映射来使用这些栈时,每个栈也可以通过直接映射的地址被内核访问。另一种设计方案是只保留直接映射,并在直接映射地址处使用这些栈。然而,在那种安排下,提供保护页将涉及取消映射原本指向物理内存的虚拟地址,这样会使这些物理内存变得难以使用。
内核为跳板页和内核代码页设置的权限是 PTE_R 和 PTE_X,而不设置 PTE_W。内核为其他页面设置的权限是 PTE_R 和 PTE_W,而不设置 PTE_X。保护页的映射是无效的。这些受限权限的目的是帮助捕获以内核以意外方式访问页面为特征的错误,例如内核代码意外地试图覆盖内核指令。
内核创建了一个单一的内核页表,当 CPU 在内核态执行时,所有 CPU 都使用该页表。xv6 在最初创建内核页表之后,不再对其进行修改。
三、代码:创建地址空间
在继续之前,请阅读 kernel/vm.c 中直到 mappages() 结束的代码。
xv6 中用于操作地址空间和页表的大部分代码都位于 vm.c(1400)。核心数据结构是 pagetable_t,它实际上是指向一个 RISC-V 根页表页的指针;一个 pagetable_t 既可以是内核页表,也可以是某个进程的页表。核心函数是 walk,它用于查找某个虚拟地址对应的 PTE,以及 mappages,它用于为新的映射安装 PTE。以 kvm 开头的函数用于操作内核页表;以 uvm 开头的函数用于操作用户页表;其他函数则可同时用于两者。copyout 和 copyin 用于在系统调用参数中指定的用户虚拟地址与内核之间拷贝数据;它们位于 vm.c 中,因为它们需要显式地翻译这些地址,以找到对应的物理内存。
在启动序列的早期,main 调用 kvminit(1465),通过 kvmmake(1421)来创建内核页表。这个调用发生在 xv6 尚未在 RISC-V 上启用分页之前,因此此时的地址直接指向物理内存。kvmmake 首先分配一页物理内存,用于存放根页表页。随后它调用 kvmmap 来安装内核所需的地址转换。这些映射包括内核的指令和数据、直到 PHYSTOP 的物理内存,以及实际上对应于设备的内存范围。proc_mapstacks(2132)为每个进程分配一个内核栈。它调用 kvmmap,将每个栈映射到由 KSTACK 生成的虚拟地址处,从而为无效的栈保护页留出空间。
kvmmap(1457)调用 mappages(1556),后者在页表中为一段虚拟地址范围安装到相应物理地址范围的映射。它以页为单位,对该范围内的每一个虚拟地址分别进行处理。对于每一个要映射的虚拟地址,mappages 调用 walk 来找到该地址对应的 PTE 的地址。然后,它将 PTE 初始化为包含相关的物理页号、所需的权限(PTE_W、PTE_X 和/或 PTE_R),以及用于标记该 PTE 有效的 PTE_V(1577)。
walk(1497)模拟了 RISC-V 分页硬件在查找某个虚拟地址对应的 PTE 时的行为(见图 3.2)。walk 逐级向下遍历页表树,在每一层使用虚拟地址中的 9 位来索引相应的页目录页。在每一层,它要么找到下一层页目录页的 PTE,要么找到最终页的 PTE(1503)。如果第一级或第二级页目录页中的某个 PTE 无效,那么说明所需的页目录页尚未被分配;如果 alloc 参数被设置,walk 就会分配一个新的页表页,并将其物理地址写入该 PTE。walk 返回的是树中最低层的那个 PTE 的地址(1513)。
上述代码依赖于物理内存被直接映射到内核虚拟地址空间这一事实。例如,当 walk 向下遍历页表的各个层级时,它从某个 PTE 中取出下一层页表的(物理)地址(1505),然后将该地址当作虚拟地址来使用,以便获取下一层的 PTE(1503)。
在每个 CPU 上,main 会调用 kvminithart(1473)来安装内核页表,将根页表页的物理地址写入该 CPU 的 satp 寄存器。在此之后,CPU 就会使用内核页表来翻译地址。由于内核页表是直接映射的,因此在这一变化前后,地址仍然指向 RAM 中的相同位置,内核得以继续正确执行。
每个 RISC-V CPU 都会在转换后备缓冲区(TLB)中缓存页表项。当 xv6 修改页表时,必须通知 CPU 使相应的 TLB 缓存项失效。否则,在之后的某个时刻,TLB 可能会使用旧的缓存映射,而该映射指向的物理页可能已经被分配给了另一个进程,结果就可能导致一个进程修改另一个进程的内存。RISC-V 提供了一条 sfence.vma 指令,用于刷新当前 CPU 的 TLB。xv6 在 kvminithart 中重新加载 satp 寄存器之后执行 sfence.vma,并且在 trampoline 代码中的 uservec 和 userret 中也会执行该指令。
在更改 satp 之前同样有必要执行 sfence.vma,以等待所有尚未完成的加载和存储操作结束。这个等待可以确保之前对页表的更新已经完成,并确保之前的加载和存储操作使用的是旧的页表,而不是新的页表。
四、物理内存分配
内核必须在运行时为页表、用户内存、内核栈以及管道缓冲区分配和释放物理内存。
xv6 使用从内核结束位置到 PHYSTOP 之间的物理内存进行运行时分配。它一次只分配和释放完整的 4096 字节页面。xv6 通过在页面自身中穿插一个链表来跟踪哪些页面是空闲的。分配操作就是从该链表中移除一个页面;释放操作则是将被释放的页面加入链表。
请阅读 kernel/kalloc.c。
五、代码:物理内存分配器
分配器位于 kalloc.c(2950)。分配器的数据结构是一个空闲列表,其中包含可用于分配的物理内存页面。每个空闲页面的 next 指针存放在一个 struct run(2966)中。分配器将每个空闲页面的 run 结构存储在该空闲页面本身中,因为当页面处于空闲状态时,其中不会存放其他任何内容。空闲列表由一个自旋锁保护(2970–2973)。该列表和锁被封装在一个结构体中,以清楚地表明该锁保护的是该结构体中的字段。暂时可以忽略这个锁以及 acquire 和 release 的调用;第 7 章将详细讨论锁。
main 函数调用 kinit 来初始化分配器(2976)。kinit 将空闲列表初始化为包含从内核结束位置到 PHYSTOP 之间的每一页物理 RAM。xv6 本应通过解析硬件提供的配置信息来确定可用的物理内存大小,但实际上 xv6 假定机器具有 128 MB 的 RAM。kinit 调用 freerange,通过对每一页调用 kfree,将内存加入空闲列表。由于 PTE 只能引用按 4096 字节边界对齐的物理地址(即 4096 的倍数),freerange 使用 PGROUNDUP 来确保只释放对齐的物理地址。分配器一开始并不管理任何内存;正是这些对 kfree 的调用为它提供了可管理的内存。
分配器有时将地址当作整数来使用,以便对其进行算术运算(例如在 freerange 中遍历所有页面);有时又将地址当作指针来使用,以便读写内存(例如操作存储在每个页面中的 run 结构)。这种对地址的双重用途,是分配器代码中大量出现 C 类型转换的主要原因。
函数 kfree(3005)首先将被释放内存中的每一个字节都设置为值 1。这样,如果代码在内存被释放后仍然继续使用它(即使用“悬空引用”),读到的将是垃圾数据而不是之前的有效内容;这有助于让这类错误更快暴露出来。随后,kfree 将该页面插入空闲列表的表头:它将 pa 强制转换为指向 struct run 的指针,把原先空闲列表的起始位置记录到 r->next 中,并将空闲列表的头指针设置为 r。kalloc 则从空闲列表中移除并返回第一个元素。
六、进程地址空间
每个进程都有自己的页表,当 xv6 在进程之间切换时,也会同时切换页表。图 3.4 比图 2.3 更详细地展示了一个进程的地址空间。进程的用户地址空间从零开始,原则上一直到 MAXVA(0x4000000000)(0896),尽管在实际中,只有其中很小的一部分被映射到了物理内存。
一个进程的地址空间由多种页面组成:包含程序文本的页面(xv6 使用 PTE_R、PTE_X 和 PTE_U 权限进行映射)、包含程序已初始化数据的页面、一个用于栈的页面,以及用于堆的页面。xv6 使用 PTE_R、PTE_W 和 PTE_U 权限来映射数据、栈和堆。
在用户地址空间内使用权限是一种常见的进程加固技术。如果文本段被映射为带有 PTE_W 权限,那么进程就可能意外地修改自己的程序;例如,一个编程错误可能导致程序向空指针写入,从而修改地址 0 处的指令,然后继续运行,造成更大的破坏。为了立即检测这类错误,xv6 在映射文本段时不设置 PTE_W;如果程序意外地尝试向地址 0 执行存储操作,硬件会拒绝该操作并触发缺页异常(见第 4 章)。随后内核会终止该进程,并打印一条有助于开发者定位问题的信息。
类似地,通过在映射数据段时不设置 PTE_X,用户程序就无法意外地跳转到程序的数据区并从那里开始执行。
在现实世界中,通过仔细设置权限来加固进程,也有助于防御安全攻击。攻击者可能向程序(例如 Web 服务器)提供精心构造的输入,从而触发程序中的漏洞,并希望将该漏洞转化为一次利用。仔细设置权限以及其他技术(例如随机化用户地址空间的布局)都会使这类攻击更加困难。
栈只有一个页面,图中展示的是由 exec 系统调用创建的初始内容。包含命令行参数的字符串以及指向这些字符串的指针数组位于栈的最顶部。紧接在其下的是一些值,使得程序能够像刚刚调用了 main(argc, argv) 函数那样从 main 开始执行。
为了检测用户栈是否溢出其已分配的栈内存,xv6 在栈的正下方放置了一个不可访问的保护页,方法是清除 PTE_U 标志。如果用户栈发生溢出,进程试图使用栈下方的地址,由于该保护页在用户态下不可访问,硬件将生成一次缺页异常。现实世界中的操作系统可能会在用户栈溢出时,自动为其分配更多内存。
在这里我们可以看到页表使用的一些很好的例子。首先,不同进程的页表将用户地址翻译到不同的物理内存页面,因此每个进程都拥有私有的用户内存。其次,每个进程都将自己的内存视为从零开始的连续虚拟地址,而进程的物理内存则可以是不连续的。第三,内核在用户地址空间的顶部映射了一个包含跳板代码的页面(不设置 PTE_U),因此同一个物理内存页面会出现在所有地址空间中,但只能由内核使用。

图 3.4:一个进程的用户地址空间及其初始栈。
七、代码:exec
请阅读 kernel/exec.c 以及从 uvmcreate() 开始的 kernel/vm.c。
exec 是一个系统调用,用于用从文件中读取的数据替换某个进程的用户地址空间,该文件称为二进制文件或可执行文件。二进制文件通常是编译器和链接器的输出,包含机器指令和程序数据。exec 在内核中的内部实现是 kexec(6426)。kexec 使用 namei(6440)打开指定路径的二进制文件,该函数将在第 10 章中解释。随后,它读取 ELF 头部。xv6 的二进制文件采用广泛使用的 ELF 格式,其定义见(0950)。一个 ELF 二进制文件由一个 ELF 头(struct elfhdr,0955)以及随后的一系列程序段头(struct proghdr,0974)组成。每个 proghdr 描述了应用程序中必须加载到内存中的一个段;xv6 程序有两个程序段头:一个用于指令,一个用于数据。
第一步是快速检查该文件是否很可能是一个 ELF 二进制文件。ELF 二进制文件以四字节的“魔数”开始:0x7F、‘E’、‘L’、‘F’,即 ELF_MAGIC(0952)。如果 ELF 头具有正确的魔数,kexec 就假定该二进制文件格式正确。
kexec 通过 proc_pagetable(6454)分配一个不包含任何用户映射的新页表,通过 uvmalloc(6470)为每个 ELF 段分配内存,并通过 loadseg(6409)将每个段加载到内存中。loadseg 使用 walkaddr 找到要写入 ELF 段每一页数据的已分配内存的物理地址,并使用 readi 从文件中读取数据。
第一个通过 exec 创建的用户程序 /init 的程序段头如下所示:
# objdump -p user/_init
user/_init: file format elf64-little
Program Header:
0x70000003 off 0x0000000000006bb0 vaddr 0x0000000000000000
paddr 0x0000000000000000 align 2**0
filesz 0x000000000000004a memsz 0x0000000000000000 flags r--
LOAD off 0x0000000000001000 vaddr 0x0000000000000000
paddr 0x0000000000000000 align 2**12
filesz 0x0000000000001000 memsz 0x0000000000001000 flags r-x
LOAD off 0x0000000000002000 vaddr 0x0000000000001000
paddr 0x0000000000001000 align 2**12
filesz 0x0000000000000010 memsz 0x0000000000000030 flags rw
STACK off 0x0000000000000000 vaddr 0x0000000000000000
paddr 0x0000000000000000 align 2**4
filesz 0x0000000000000000 memsz 0x0000000000000000 flags rw
我们可以看到,文本段应当从文件中偏移 0x1000 的内容加载到虚拟地址 0x0 处(且不具有写权限)。我们还可以看到,数据段应当加载到地址 0x1000 处,该地址位于页边界上,并且不具有可执行权限。
一个程序段头中的 filesz 可能小于 memsz,这表明两者之间的间隙应当用零填充(用于 C 语言的全局变量),而不是从文件中读取。对于 /init,数据段的 filesz 是 0x10 字节,而 memsz 是 0x30 字节,因此 uvmalloc 会分配足够的物理内存来容纳 0x30 字节,但只会从文件 /init 中读取 0x10 字节。
接下来,kexec 分配并初始化用户栈。它只分配一个栈页。kexec 将参数字符串逐个复制到栈的顶部,并在 ustack 中记录指向这些字符串的指针。它在将要传递给 main 的 argv 列表末尾放置一个空指针。argc 和 argv 的值通过系统调用返回路径传递给 main:argc 通过系统调用返回值传递,存放在 a0 中,而 argv 通过进程 trapframe 中的 a1 传递。
kexec 在栈页的正下方放置了一个不可访问的页面,这样试图使用超过一页栈空间的程序就会发生异常。这个不可访问的页面还使 kexec 能够处理参数过大的情况;在这种情况下,kexec 用来将参数复制到栈中的 copyout(1754)函数会注意到目标页面不可访问,并返回 -1。
在准备新的内存映像过程中,如果 kexec 检测到诸如无效程序段之类的错误,它会跳转到标号 bad,释放新的映像,并返回 -1。kexec 必须等到确信系统调用将成功之后,才能释放旧的映像:如果旧映像已经消失,系统调用就无法向其返回 -1。kexec 中唯一的错误情况都发生在创建新映像的过程中。一旦映像创建完成,kexec 就可以提交新的页表(6531),并释放旧的页表(6535)。
exec 系统调用会按照 ELF 文件中指定的地址,将字节从 ELF 文件加载到内存中。用户或进程可以在 ELF 文件中放入任意他们想要的地址。因此,exec 是有风险的,因为 ELF 文件中的地址可能会意外地或故意地指向内核。对于一个不谨慎的内核而言,其后果可能从系统崩溃到对内核隔离机制的恶意破坏(即安全漏洞)不等。xv6 执行了多项检查来避免这些风险。例如,if (ph.vaddr + ph.memsz < ph.vaddr) 用于检查该求和是否发生了 64 位整数溢出。危险之处在于,用户可以构造一个 ELF 二进制文件,使 ph.vaddr 指向一个用户选择的地址,而 ph.memsz 足够大,从而使得求和溢出到 0x1000,看起来像一个有效的值。在较旧版本的 xv6 中,用户地址空间中也包含内核(但在用户态下不可读/写),用户就可以选择一个对应于内核内存的地址,从而把 ELF 二进制文件中的数据拷贝到内核中。在 RISC-V 版本的 xv6 中,这种情况不会发生,因为内核拥有自己独立的页表;loadseg 加载的是进程的页表,而不是内核的页表。
内核开发者很容易遗漏关键性的检查,而现实世界中的内核长期以来都存在由于缺少检查而被用户程序利用以获取内核权限的历史。很可能 xv6 并没有完全验证由用户层提供给内核的数据,恶意的用户程序可能会利用这一点来绕过 xv6 的隔离机制。
八、现实世界
和大多数操作系统一样,xv6 使用分页硬件来实现内存保护和映射。大多数操作系统通过将分页与缺页异常相结合,比 xv6 更加复杂和精细地使用分页机制,这一点我们将在第 4 章中讨论。
xv6 之所以较为简化,是因为内核使用了虚拟地址与物理地址之间的直接映射,并且假定物理 RAM 位于地址 0x80000000,内核也被加载到该地址处。这在 QEMU 中是可行的,但在真实硬件上却是一个糟糕的想法;真实硬件会将 RAM 和设备放置在不可预测的物理地址上,因此(例如)在 xv6 期望存放内核的 0x80000000 处,可能根本没有 RAM。更为成熟的内核设计会利用页表,将任意的硬件物理内存布局转换为可预测的内核虚拟地址布局。
RISC-V 支持在物理地址级别进行保护,但 xv6 并未使用这一特性。在拥有大量内存的机器上,使用 RISC-V 对“超级页”的支持可能是有意义的。当物理内存较小时,小页面是合理的选择,因为它们允许以较细的粒度进行分配和换页到磁盘。例如,如果一个程序只使用了 8 KB 的内存,却为它分配一个 4 MB 的超级页物理内存,就会造成浪费。在拥有大量 RAM 的机器上,较大的页面更有意义,并且可以减少页表操作的开销。
为了避免在切换页表时刷新整个 TLB,RISC-V CPU 可能支持地址空间标识符(ASID)。这样内核就只需要刷新某个特定地址空间对应的 TLB 项。xv6 并未使用这一特性。
xv6 内核缺乏一种类似 malloc 的分配器,无法为小对象提供内存,这限制了内核使用需要动态分配的复杂数据结构。一个更复杂的内核可能会分配多种不同大小的小块内存,而不是像 xv6 那样只分配 4096 字节的块;真正的内核分配器既需要处理小规模分配,也需要处理大规模分配。
内存分配一直是一个长期的热点话题,其基本问题在于如何高效地利用有限的内存,以及如何为未知的未来请求做好准备。在当今,人们往往更关注速度,而不是空间效率。