Xv6 手册:页表

330 阅读24分钟

Chapter 3: Page Table

3.1 分页硬件

在 RISC-V 中,用户指令和内核指令操作的都是 虚拟地址(Virtual Address) 。程序在运行时不直接操作物理地址,而是通过虚拟地址来访问内存;与此同时,机器的 RAM 或物理内存使用 物理地址(Physical Address) 索引。

Xv6 运行在 Sv39 RISC-V 上, 64 位虚拟地址中只有低位的 39 位被使用;高位的 25 位没有被使用。

在 RISC-V 中,页表逻辑上是一个包含2272^{27}页表项(PTE) 的大数组。每个 PTE 包含一个 44 位的 物理页号(PPN) 和一些 标志位(flag) 。当 分页硬件(Paging Hardware) 需要转换地址时,它使用虚拟地址中的高 27 位来找到对应的 PTE 。然后,它将 PTE 中的 44 位物理页号与虚拟地址的低 12 位组合起来,生成最终的 56 位物理地址。

截屏2025-01-26 01.14.10.png

通过页表,操作系统可以控制虚拟地址到物理地址的转换,其颗粒度为 4096 字节的对齐的块。这样的块称为页。

RISC-V CPU 通过三步将虚拟地址转换为物理地址:

页表以“三级树”的形式存储在物理内存中。树的根是一个 4096 字节的页表页,其中包含 512 个 PTE ,这些 PTE 包含下一级树中页表页的物理地址。这些页中的每一个都包含 512 个 PTE ,用于树中的最后一级。

截屏2025-01-26 06.12.24.png

分页硬件使用 27 位中的最高 9 位来选择根页表页中的 PTE ,中间 9 位来选择下一级页表页中的 PTE ,最低 9 位来选择最终的 PTE 。

如果翻译地址所需的三个 PTE 中的任何一个不存在,分页硬件将引发一个 页面错误异常(page-fault exception) ,留给内核来处理这个异常。

页表的三级结构以高效的方式记录 PTE 。在常见情况下,当大范围的虚拟地址没有映射时,三级结构可以省略整个 页目录(Page Directories)

  • 例如,如果一个程序只使用从地址 0 开始的几个页面,那么顶级页目录的条目 1 到 511 是无效的,内核不必为这 511 个中间页目录分配页面。此外,内核也不必为这 511 个中间页目录分配底层页目录的页面。因此,三级设计节省了 511 页用于中间页目录和 511 x 512 页用于底层页目录。

尽管 CPU 在执行加载/存储指令时在硬件中遍历三级结构,其缺点是 CPU 必须从内存中加载三个 PTE 来执行指令中的虚拟地址到物理地址的转换。为了避免从物理内存中加载 PTE 的成本,RISC-V CPU 在 转换后备缓冲区(TLB) 中缓存 PTE 。

每个 PTE 包含标志位(如上图),这些标志位告诉分页硬件如何使用相关的虚拟地址:

  • PTE_V 表示 PTE 是否存在:如果未设置,则引用该页会导致异常(即不允许)。
  • PTE_R 控制是否允许指令读取该页。PTE_W 控制是否允许指令写入该页。
  • PTE_X 控制 CPU 是否可以将该页的内容解释为指令并执行它们。
  • PTE_U 控制是否允许用户模式下的指令访问该页;如果未设置,则 PTE 只能在监督模式下使用。

标志和所有其他与页相关的硬件结构在(kernel/riscv.h)中定义。

为了使 CPU 使用特定的页表, OS 将这个根页表页的物理地址写入到 satp 寄存器 中。每个 CPU 都有自己的 satp 寄存器。通过这个寄存器, CPU 知道使用哪个页表来转换程序指令中的地址。这样一来,不同 CPU 可以同时运行不同的程序,每个程序都有自己独立的地址空间,由各自的页表来描述和管理。这样就实现了不同程序之间的地址隔离,让它们互不干扰。

从内核的角度来看,页表是一种存于内存中的数据结构,用于管理程序虚拟地址到物理地址的转换。内核可以通过编程创建和修改页表项,这类似于任何树形数据结构。通过特定的代码指令,内核能够更新页表,从而调整不同进程的地址映射方式。

物理内存(Physical Memory)中的一个字节有一个地址,称为物理地址。

解引用地址的指令(如加载、存储、跳转和函数调用)只使用虚拟地址,分页硬件将这些虚拟地址转换为物理地址,然后发送到 RAM 硬件以读取或写入存储。地址空间(address space)是在给定页表中有效的一组虚拟地址。

每个 xv6 进程都有一个独立的用户地址空间, xv6 内核也有自己的地址空间。用户内存指的是进程的用户地址空间以及页表允许进程访问的物理内存。虚拟内存指的是与页表管理相关的概念和技术,使用它们可以实现隔离等目标。


3.2 内核地址空间

Xv6 为每个进程维护一个页表,描述其用户地址空间,另外有一单独页表,描述内核地址空间。

内核配置其地址空间的布局,以允许自己以可预测的虚拟地址访问物理内存和各种硬件资源。下图显示了这种布局如何将内核虚拟地址映射到物理地址。

如图,左侧是 xv6 的内核地址空间。其中不同区域 RWX 的权限设置由 PTE 中的标志位决定。右侧是 RISC-V 物理地址空间。

截屏2025-01-26 13.10.58.png

QEMU 模拟了一台计算机,其 RAM 从物理地址 0x80000000 开始,至少延伸到 0x88000000 ,这部分在 xv6 中被称为 PHYSTOP 。此外, QEMU 还模拟了如磁盘接口这样的 I/O 设备。这些设备的控制寄存器被映射为内存地址,位于物理地址 0x80000000 以下的空间内。

即内核通过读写特定的物理地址来与这些设备进行通信,而非直接与 RAM 交互,当内核访问这些特殊地址时,实际上是在与设备硬件交互。

内核使用一种称为“直接映射”的方法来访问 RAM 和内存映射的设备寄存器,即物理地址和虚拟地址是直接对应的。

  • 例如,内核自身的起始位置在虚拟地址和物理地址上都是 KERNBASE=0x80000000

这种方法简化了内核代码,使其可以直接读写物理内存而无需进行地址转换。

  • 举个例子,当fork函数为新创建的子进程分配用户空间内存时,它得到一块内存的物理地址。由于直接映射,fork可以直接将这个物理地址当作虚拟地址使用,以便将父进程的用户内存内容复制到子进程中。

有几个内核虚拟地址不是直接映射的:

  • 跳板页面(trampoline page) :它映射在虚拟地址空间的顶部;用户页表具有相同的映射。
    • 一个页表的用例:一个物理页面(包含跳板代码)在内核的虚拟地址空间中映射了两次:一次在虚拟地址空间的顶部,一次通过直接映射。
  • 内核栈页面(kernel stack pages) :每个进程都有自己的内核栈,它映射得很高,以便在它下面可以留一个未映射的 保护页面(guard page) 。保护页面的 PTE_V 未设置,这样如果内核溢出了内核栈,会引起异常,内核崩溃。但如果没有保护页面,溢出的栈会覆盖其他内核内存,导致操作错误。
    • 虽然内核通过高内存映射使用其栈,但内核栈页面也可以通过直接映射的地址访问。另一种设计只使用直接映射,并在直接映射的地址使用栈。然而,在此设计中,提供保护页面将取消映射那些本来可以直接使用物理内存的虚拟地址。

内核为跳板和内核文本(kernel text)映射了带有 PTE_R 和 PTE_X 权限的页面。内核从这些页面读取和执行指令。内核为其他页面映射了带有 PTE_R 和 PTE_W 权限的页面,以便它可以读写这些页面中的内存。

保护页面的映射是无效的。


3.3 代码:创建一个地址空间

Xv6 中大多数操作地址空间和页表的代码都在 vm.c 中。以下是几个关键概念:

  • 核心数据结构: pagetable_t 指向 RISC-V 根页表的指针。可以是内核页表或任何进程的页表。
  • 主要函数:
    1. walk :用于找到一个虚拟地址对应的 PTE 。它会遍历多级页表,直到找到对应于给定虚拟地址的PTE。
    2. mappages :用于创建新的映射,即将一系列虚拟地址映射到相应的物理地址范围。它为每个虚拟地址调用 walk 来获取 PTE ,并设置该 PTE 以包含正确的物理页号和权限信息。
    3. kvm*uvm*kvm 开头的函数处理内核页表的操作。 uvm开头的函数处理用户进程的页表操作。
    4. copyincopyoutcopyin 函数用于将数据从用户空间复制到内核空间; copyout 函数用于将数据从内核空间复制到用户空间。
  • 启动时的初始化过程:
    1. 启动初期:系统刚开始启动时, main 函数调用 kvminit 函数设置内核页表。这发生在分页机制启用之前,所有地址直接引用物理内存。
    2. 创建根页表kvminit 函数内,调用 kvmmake 函数,分配一块物理内存页面来保存根页表。然后使用 kvmmap 将内核需要访问的重要区域(如内核自身的代码、数据、PHYSTOP前的所有物理内存以及每个进程的栈)添加到这个页表中。
    3. 安装页表:调用 kvminithart ,系统将根页表的物理地址写入 satp寄存器 ,从而让 CPU 知道使用哪个页表进行地址转换。
    4. 直接映射:采用直接映射的方式,物理地址与虚拟地址直接对应,使内核可以直接通过虚拟地址访问其所需的物理内存区域。

RISC-V CPU 在 TLB 中缓存页表项,当 xv6 更改页表时,它必须告诉 CPU ,使相应的缓存 TLB 项无效。如果没有这样做,那么在某个时候 TLB 可能会使用旧的缓存映射,指向在此期间已分配给另一个进程的物理页面,结果一个进程可能能够在另一个进程的内存上乱写。

RISC-V 有一个指令 sfence.vma 可以刷新当前 CPU 的 TLB 。在重新加载 satp寄存器 后,xv6 在 kvminithart 中执行 sfence.vma ,在切换到用户页表并返回用户空间之前的跳板代码中也执行 sfence.vma

为了避免刷新整个 TLB,RISC-V CPU 可能支持 地址空间标识符(ASIDs) 。然后,内核可以只刷新特定地址空间的 TLB 条目。xv6 不使用此功能。


3.4 物理内存分配

在 xv6 中,内核需要动态管理物理内存,以供页表、用户内存、内核栈和管道缓冲区等使用。

实现:

  • 分配区域: xv6 利用内核末尾到 PHYSTOP 之间的物理内存进行内存分配。
  • 分配单位:内存分配以 4096 字节为单位进行。
  • 空闲页面跟踪:系统通过在一个页面内部构建链表的方式,来追踪哪些页面是空闲可用的。
    • 分配:当需要分配内存时,就从这个链表中移除一个空闲页面供使用。
    • 释放:当某块内存不再使用并被释放时,该页面会被重新添加回链表,标记为空闲,以便后续再次被分配。

xv6 通过链表管理内核末尾到 PHYSTOP 间的物理页面,支持按需分配和释放 4096 字节大小的内存页面,确保了对页表、用户内存、内核栈及管道缓冲区等的有效内存管理。


3.5 物理内存分配器

分配器位于 kalloc.c 。其核心数据结构是一个可用物理内存页的空闲列表,列表元素是一个 struct run ,代表一个当前可被分配的空闲页。

分配器从哪里获取内存来保存该数据结构?

  • 它将每个空闲页的结构体存储在空闲页本身中,因为那里没有存储其他内容。

空闲列表由 自旋锁(spin lock) 保护。列表和锁被封装在一个结构中,以明确表示锁保护结构中的字段。目前,忽略锁和获取/释放调用。

在 xv6 启动过程中, main 函数会调用 kinit 来初始化内存分配器:

  1. 初始化分配器kinit 函数的主要任务是初始化一个空闲列表,该列表用于管理内核末尾到 PHYSTOP 之间的所有可用物理内存页面。
  2. 物理内存假设:不同于解析硬件提供的配置信息来确定可用的物理内存总量, xv6 直接假定机器配备了 128MB 的 RAM 。
  3. 填充空闲列表kinit 通过调用 freerange 函数将从内核末尾到 PHYSTOP 之间的每个物理页面添加到空闲列表中。这一步骤具体是通过遍历这段范围,并对每一页调用 kfree 函数完成的。
  4. 地址对齐:由于 PTE 只能引用那些 4096 字节边界对齐的物理地址, freerange 使用宏 PGROUNDUP 确保只释放那些地址对齐了的物理内存页面给空闲列表。
  5. 为分配器提供内存:这些调用 kfree的 过程实质上是向分配器提供了一些初始的、可管理的内存资源。这样,当系统需要为新的请求分配内存时,分配器就有了可以从中分配的空闲页面。

即, kinit 初始化了一个用于追踪空闲物理内存页面的链表,并且通过 freerangekfree 函数将内核末尾到 PHYSTOP 之间的所有页面加入这个链表中,同时保证这些页面的地址是对齐的,以便后续能够正确地进行内存分配。这样,分配器就有了基础的内存资源来开始其管理工作。

分配器有时将地址视为整数,以便对它们执行算术运算(例如,在 freerange 中遍历所有页面);有时将地址用作指针,以读取和写入内存(例如,操作存储在每个页面中的运行结构);这种地址的双重用途是分配器代码充满 C 类型转换的主要原因。

kfree 首先将被释放内存中的每个字节设置为值 1。让在释放内存后使用该内存的代码(“悬空引用”)读取到垃圾数据,而不是旧的有效内容;理想状态下,这将导致此类代码更快地崩溃,也即更快能被觉察和修复。然后, kfreepa 转换为指向 struct run 的指针, kfree 记录当前空闲列表的起始位置到新添加的 struct run 结构体的 next 字段中(即将原来的第一个空闲页地址存入新释放页的 next ),并将空闲列表设置为 r

kalloc 移除并返回空闲列表中的第一个元素。


3.6 进程地址空间

每个进程都有自己的页表,当 xv6 在进程间切换时,它也会更改页表。如下图,显示了进程的地址空间。进程的用户内存从虚拟地址零开始,可以增长到 MAXVA ,理论上允许进程访问 256GB 的内存。

截屏2025-01-26 21.51.11.png

进程的地址空间包含 程序文本(text) 的页面( xv6 使用权限 PTE_R、PTE_X 和 PTE_U 映射这些页面)、包含程序预初始化 数据(data) 的页面、一个用于 栈(stack) 的页面和用于 堆(heap) 的页面组成。Xv6 使用权限 PTE_R、PTE_W 和 PTE_U 映射数据、栈和堆。

在用户地址空间内使用权限是一种常见的加固用户进程的技术。如果文本被映射为 PTE_W,那么进程可能会意外地修改自己的程序;

  • 例如,编程错误可能导致程序写入空指针,修改地址 0 处的指令,然后继续运行,可能会有更大的破坏。

为立即检测到此类错误, xv6 映射文本时不带 PTE_W;如果程序意外尝试存储到地址 0 ,硬件将拒绝执行存储并引发页面错误。内核会终止该进程并打印出一条信息性消息,以便开发人员追踪问题。

同样,通过不使用 PTE_X 映射数据,用户程序不能意外跳转到程序数据中的地址并在该地址执行。

在 xv6 中,当一个新程序通过 exec 函数加载和启动时,会为该程序创建一个新的栈。这个栈由单独的一个内存页面组成,并初始化为特定的初始内容,以便程序能够正确开始执行。以下是简要说明:

  1. 栈的内容布局
    • 命令行参数:位于栈顶的是包含命令行参数的字符串,以及一个指向这些字符串的指针数组(即argv)。
    • main函数参数:在这之下是调用 main 函数所需的参数值,包括 argc (命令行参数的数量)和 argv (指向命令行参数字符串的指针数组),使得程序启动时就像是直接调用了 main(argc, argv) 一样。
  2. 栈的作用
    • 这种布局确保了当程序开始执行时,它可以直接从 main 函数开始运行,而不需要额外的初始化步骤来设置参数或准备命令行输入。

exec 创建的新程序栈,预先设置了命令行参数及其指针数组,并准备好 main 函数需要的参数值,使得程序可以像刚刚调用了 main(argc, argv) 那样开始执行。这种方式简化了程序启动流程,确保了程序能顺利地根据提供的命令行参数运行。

为检测用户栈溢出分配的栈内存,xv6 在栈的正下方放置一个不可访问的保护页面(通过清除 PTE_U 标志)。如果用户栈溢出,并且进程尝试使用栈下方的地址,硬件将生成页面错误异常。

现实世界的操作系统可能会在栈溢出时自动为用户栈分配更多内存。

当进程向 xv6 请求更多用户内存时,xv6 会增加进程的堆。 Xv6 首先使用 kalloc 分配物理页面(这个函数负责从系统中的空闲物理页面池中分配一个 4096 字节大小的物理页面)。下一步是将指向这些新分配物理页面的指针(即 PTE )添加到进程的页表中。这样做的目的是为了让 CPU 知道如何将虚拟地址转换为对应的物理地址。Xv6 在这些 PTE 中设置 PTE_W、PTE_R、PTE_U 和 PTE_V 标志。大多数进程并不使用整个用户地址空间;xv6 在未使用的 PTE 中保持 PTE_V 未设置。

上图有页表使用的一些的例子。

  • 首先,不同进程的页表将用户地址转换为不同的物理内存页面,因此每个进程都有私有的用户内存。
  • 其次,每个进程都将其内存视为从零开始的连续虚拟地址,而进程的物理内存可以是非连续的。
  • 第三,内核在用户地址空间的顶部映射了一页带有跳板代码的页面(没有 PTE_U),因此一页物理内存出现在所有地址空间中,但只能由内核使用。

3.7 代码: sbrk 系统调用

sbrk 系统调用,用于让进程缩小或扩展其内存。

它由 growproc 函数实现。 growproc 根据 n 是正数还是负数,调用 uvmallocuvmdeallocuvmalloc使用 kalloc 分配物理内存,将分配的内存清零,并向用户页表添加 PTEs (使用 mappages)。 uvmdealloc 调用 uvmunmap,它使用 walk 查找 PTEs 并使用 kfree 释放它们所引用的物理内存。

Xv6 使用一个进程的页表不仅仅是为了告诉硬件如何映射用户虚拟地址,也是哪些物理内存页分配给该进程的唯一记录。这就是为什么释放用户内存(在 uvmunmap 中)需要检查用户页表的原因。

3.8 代码: exec 系统调用

exec 系统调用,用于替换当前进程的用户地址空间内容,使用从指定文件(通常为二进制或可执行文件)中读取的数据。这个过程涉及多个步骤,包括打开和验证文件格式、分配内存以及加载程序段。下面是详细的步骤说明:

  1. 打开并检查文件
    • 打开文件exec 函数首先通过 namei 函数(kernel/exec.c:36)打开指定路径下的二进制文件。 namei 的作用是解析路径名并返回对应的文件描述符。
    • 检查文件格式:接下来, exec 会读取文件开头的 ELF 头来快速检查该文件是否为有效的 ELF 格式。ELF 文件以特定的四字节“魔术数字” 0x7F 、 'E' 、 'L' 、 'F' 开头(在代码中定义为 ELF_MAGIC)。如果这些字符存在,则假定文件是一个正确的 ELF 二进制文件。
  2. 解析 ELF 头和程序段头
    • 读取 ELF 头:ELF 文件包含一个 ELF 头(struct elfhdr),它提供了关于整个文件的基本信息,如文件类型、目标机器架构等。
    • 解析程序段头:紧接着 ELF 头的是若干个程序段头(struct proghdr)。每个 proghdr 描述了一个需要加载到内存中的应用程序部分。对于 xv6 程序,通常有两个主要的段:一个是存放指令的段,另一个是存放数据的段。
  3. 分配新页表和内存
    • 创建新的页表:为了确保新程序在一个干净的环境中运行,exec 使用 proc_pagetable 为新程序分配一个新的页表。这一步骤保证了没有旧的用户映射干扰新程序的执行。
    • 为段分配内存:根据每个段头的信息,exec 调用 uvmalloc 函数为每个段分配所需的内存。这包括为程序的指令和数据段分配相应的内存区域。
  4. 加载程序段
    • 加载段到内存:对于每个段,exec 调用 loadseg 函数将段数据加载到内存中。loadseg 执行以下操作:
      • 使用 walkaddr 函数找到分配给该段的内存的物理地址。
      • 使用 readi 函数从 ELF 文件中读取数据,并写入到相应的物理地址上,从而完成段的加载。

/init 的程序段头,这是使用 exec 创建的第一个用户程序,看起来像这样:

截屏2025-01-27 13.24.10.png

我们看到 文本段(text) 应该被加载到内存中的虚拟地址 0x0(只读)从文件中偏移 0x1000 的内容。我们还看到 数据段(data) 应该被加载到地址 0x1000,这是一个页边界,并且没有可执行权限。

程序段头(program section header)的文件大小(filesz)可能小于内存大小(memsz),这表明它们之间的差距用零填充(用于 C 全局变量),而不是从文件中读取。

对于 /init,数据文件大小(filesz)是 0x10 字节,而内存大小(memsz)是 0x30 字节,因此 uvmalloc 分配足够的物理内存来容纳 0x30 字节,但只从文件 /init 中读取 0x10 字节。

exec 分配并初始化用户栈,它只分配一个栈页。 exec 一次复制一个命令行参数(字符串形式)到栈顶,并记录它们的指针到 ustack 中。它在将要传递给 mainargv 列表的末尾放置一个空指针,以标记参数列表的结束。

argcargv 传递给 mainargc 通过系统调用返回值传递,放入 a0 ;而 argv 通过进程的 trapframea1 入口传递。

exec 在栈页正下方放置一个 保护页面(Guard page) ,如此,使用超过一页的程序就会发生错误。此页面还允许 exec 处理过大的参数;在这种情况下,exec 用于将参数复制到栈的 copyout(kernel/vm.c:359)函数会注意到目标页面不可访问,并返回 -1。

在准备新的内存映像期间,如果 exec 检测到错误(如无效的程序段),它会跳转到标签 bad,释放新映像,并返回 -1。exec 必须等到确信系统调用会成功时才释放旧映像:如果旧映像已经消失,系统调用就不能返回 -1。exec 中的唯一错误情况发生在创建映像期间。一旦映像完成,exec 就可以提交到新的页表并释放旧的。

exec 从 ELF 文件加载字节到 ELF 文件指定的内存地址。用户或进程可以将任何他们想要的地址放入 ELF 文件中。因此,exec 是有风险的,因为 ELF 文件中的地址可能引用内核。对于不谨慎设计的内核,后果可能从崩溃到恶意颠覆内核的隔离机制(即安全漏洞)。

Xv6 通过执行多种检查来防止潜在的安全风险,确保程序只能访问它们被授权访问的内存区域。其中一个重要的安全检查是确保程序头 ph.vaddr + ph.memsz 的计算不会导致整数溢出。具体来说,如果一个 ELF 文件的虚拟地址 ph.vaddr 和内存大小 ph.memsz 的总和小于 ph.vaddr 自身,这意味着发生了溢出。这种情况下,Xv6 会拒绝加载该程序以避免潜在的安全漏洞。

在 Xv6 的早期版本中,用户地址空间可能与内核地址空间有重叠(尽管用户模式下对内核内存不可读写)。恶意用户可以通过构造特定的 ELF 文件,指定一个指向内核内存区域的虚拟地址 ph.vaddr 并设置足够大的内存大小 ph.memsz,试图将数据写入内核内存,从而破坏系统安全性。

然而,在 RISC-V xv6 中,这种情况得到了改善。现在内核有自己的独立页表,而用户程序的数据只能加载到自己的页表中,即 loadseg 函数只会更新进程的页表,而不是内核的页表。这意味着即使用户尝试通过上述方式攻击系统,也无法影响到内核内存,因为两者是完全隔离的。

内核开发人员很容易遗漏一个关键检查,而现实世界的内核有着长期遗漏检查的历史,这些检查的缺失可以被用户程序利用来获取内核权限。很可能 xv6 没有完全验证提供给内核的用户级数据,恶意用户程序可能能够利用这一点来绕过 xv6 的隔离机制。


以上内容引用、理解与翻译自《xv6 book》,版权归属 Russ Cox, Frans Kaashoek, 和 Robert Morris,以及 Massachusetts Institute of Technology 所有。