页表

分页虚拟内存与虚拟内存的概念一起于 1962 年在 Ferranti Atlas 计算机上发明,这是第一台具有分页虚拟内存的计算机。随着时间的推移,该功能迁移到更新的计算机,并成为所有类 Unix 系统的实际功能。 1985 年,该功能包含在 Intel 80386 中,这是 Linux 1.0 开发所用的 CPU。

页表将 CPU 看到的虚拟地址映射到外部内存总线上看到的物理地址。

Linux 将页表定义为一个层次结构,目前高度为五层。 然后,每个受支持架构的架构代码都会将其映射到硬件的限制。

对应于虚拟地址的物理地址通常由底层物理页帧引用。 **页帧号**或**pfn**是页的物理地址(在外部内存总线上看到的)除以 PAGE_SIZE

物理内存地址 0 将为 *pfn 0*,最高的 pfn 将是 CPU 的外部地址总线可以寻址的物理内存的最后一页。

当页面粒度为 4KB 且地址范围为 32 位时,pfn 0 位于地址 0x00000000,pfn 1 位于地址 0x00001000,pfn 2 位于 0x00002000,依此类推,直到我们到达 0xfffff 处的 pfn 0xfffff000。 对于 16KB 页面,pfs 位于 0x00004000、0x00008000 ... 0xffffc000,并且 pfn 从 0 变为 0x3ffff。

正如您所看到的,对于 4KB 页面,页面基本地址使用地址的位 12-31,这就是为什么在这种情况下 PAGE_SHIFT 定义为 12,并且 PAGE_SIZE 通常根据页面移位定义为 (1 << PAGE_SHIFT)

随着时间的推移,为了应对不断增长的内存大小,开发了更深的层次结构。 当 Linux 创建时,使用了 4KB 页面和一个名为 swapper_pg_dir 的单页表,其中包含 1024 个条目,覆盖 4MB,这与 Torvald 的第一台计算机拥有 4MB 物理内存的事实相吻合。 这个单表中的条目被称为 *PTE*:s - 页表条目。

软件页表层次结构反映了页表硬件已变为分层的事实,这样做是为了节省页表内存并加快映射速度。

当然,人们可以想象一个单一的线性页表,其中包含大量的条目,将整个内存分解为单个页面。 这样的页表将非常稀疏,因为虚拟内存的很大一部分通常保持未使用状态。 通过使用分层页表,虚拟地址空间中的大孔不会浪费宝贵的页表内存,因为它足以在页表层次结构中的更高级别将大区域标记为未映射。

此外,在现代 CPU 上,更高级别的页表条目可以直接指向物理内存范围,这允许在单个高级别页表条目中映射几个兆字节甚至千兆字节的连续范围,从而在将虚拟内存映射到物理内存时采取捷径:当您找到像这样的大型映射范围时,无需在层次结构中进一步遍历。

页表层次结构现在已发展成这样

+-----+
| PGD |
+-----+
   |
   |   +-----+
   +-->| P4D |
       +-----+
          |
          |   +-----+
          +-->| PUD |
              +-----+
                 |
                 |   +-----+
                 +-->| PMD |
                     +-----+
                        |
                        |   +-----+
                        +-->| PTE |
                            +-----+

页表层次结构的不同级别上的符号具有以下含义,从底部开始

  • **pte**,pte_tpteval_t = **页表条目** - 前面提到过。 *pte* 是 pteval_t 类型的 PTRS_PER_PTE 元素的数组,每个元素都将单个虚拟内存页面映射到单个物理内存页面。 架构定义 pteval_t 的大小和内容。

    一个典型的例子是 pteval_t 是一个 32 位或 64 位的值,其高位是 **pfn**(页帧号),低位是一些特定于架构的位,例如内存保护。

    名称的**entry**部分有点令人困惑,因为虽然在 Linux 1.0 中,这确实指的是单个顶级页表中的单个页表条目,但当首次引入两级页表时,它被追溯修改为映射元素的数组,因此 *pte* 是最底层的页 *表*,而不是页表 *条目*。

  • **pmd**,pmd_tpmdval_t = **页面中间目录**,*pte* 之上的层次结构,具有对 *pte*:s 的 PTRS_PER_PMD 引用。

  • **pud**,pud_tpudval_t = **页面上层目录**是在其他级别之后引入的,用于处理 4 级页表。 它可能未使用,或者如我们稍后将讨论的那样 *折叠*。

  • **p4d**,p4d_tp4dval_t = **页面第 4 级目录**是在 *pud* 之后引入的,用于处理 5 级页表。 现在很明显,我们需要用一个指示目录级别的数字来替换 *pgd*、*pmd*、*pud* 等,并且我们不能再使用临时名称了。 这仅在实际具有 5 级页表的系统上使用,否则会被折叠。

  • **pgd**,pgd_tpgdval_t = **页面全局目录** - Linux 内核处理内核内存的 PGD 的主页表仍然可以在 swapper_pg_dir 中找到,但系统中的每个用户空间进程也都有自己的内存上下文,因此也有自己的 *pgd*,可以在 struct mm_struct 中找到,而 struct mm_struct 又在每个 struct task_struct 中引用。 因此,任务具有以 struct mm_struct 形式的内存上下文,而这又具有指向相应页面全局目录的 struct pgt_t *pgd 指针。

重复一遍:页表层次结构中的每个级别都是 *指针数组*,因此 **pgd** 包含指向下一级别下方的 PTRS_PER_PGD 指针,**p4d** 包含指向 **pud** 项目的 PTRS_PER_P4D 指针,依此类推。 每个级别上的指针数量由架构定义。

      PMD
--> +-----+           PTE
    | ptr |-------> +-----+
    | ptr |-        | ptr |-------> PAGE
    | ptr | \       | ptr |
    | ptr |  \        ...
    | ... |   \
    | ptr |    \         PTE
    +-----+     +----> +-----+
                       | ptr |-------> PAGE
                       | ptr |
                         ...

页表折叠

如果架构未使用所有页表级别,则可以 *折叠* 它们,这意味着跳过它们,并且对页表执行的所有操作都将在编译时进行扩充,以便在访问下一个较低级别时仅跳过一个级别。

希望保持架构中立的页表处理代码(例如虚拟内存管理器)需要编写为遍历当前所有五个级别。 这种风格也应该优先用于特定于架构的代码,以便对未来的更改具有鲁棒性。

MMU、TLB 和页面错误

内存管理单元 (MMU) 是一个硬件组件,用于处理虚拟地址到物理地址的转换。 它可能会使用硬件中相对较小的缓存,称为 转换后备缓冲区 (TLB)页面遍历缓存 来加速这些转换。

当 CPU 访问内存位置时,它会向 MMU 提供一个虚拟地址,MMU 会检查 TLB 或页面遍历缓存中是否存在现有转换(在支持它们的架构上)。 如果未找到转换,MMU 会使用页面遍历来确定物理地址并创建映射。

当页面被写入时,页面的脏位会被设置(即打开)。 内存的每个页面都有相关的权限和脏位。 后者表示自页面加载到内存中以来,页面已被修改。

如果没有任何阻止它的情况,最终可以访问物理内存,并且对物理帧执行所请求的操作。

MMU 找不到某些转换有几个原因。 可能是因为 CPU 试图访问当前任务不允许访问的内存,或者是因为数据不存在于物理内存中。

当这些情况发生时,MMU 会触发页面错误,这是一种异常,它会向 CPU 发出信号,暂停当前执行并运行一个特殊函数来处理上述异常。

页面错误有常见和预期的原因。 这些是由进程管理优化技术(称为“惰性分配”和“写时复制”)触发的。 当帧已交换到持久存储(交换分区或文件)并从其物理位置逐出时,也可能发生页面错误。

这些技术提高了内存效率,减少了延迟,并最大限度地减少了空间占用。 本文档不会深入探讨“惰性分配”和“写时复制”的细节,因为这些主题超出了范围,因为它们属于进程地址管理。

交换与其他提到的技术不同,因为它是不受欢迎的,因为它是在内存压力很大的情况下执行的。

交换不能用于内核逻辑地址映射的内存。 这些是内核虚拟空间的一个子集,它直接映射一个连续的物理内存范围。 给定任何逻辑地址,其物理地址都是通过对偏移量进行简单的算术运算来确定的。 访问逻辑地址的速度很快,因为它们避免了对复杂页表查找的需求,但代价是帧不可逐出和分页。

如果内核无法为必须存在于物理帧中的数据腾出空间,内核会调用内存不足 (OOM) 杀手来腾出空间,方法是终止优先级较低的进程,直到压力降至安全阈值以下。

此外,页面错误也可能由代码错误或 CPU 指示访问的恶意制作的地址引起。 进程的线程可以使用指令来寻址不属于其自身地址空间的(非共享)内存,或者可以尝试执行想要写入只读位置的指令。

如果上述条件发生在用户空间中,内核会向当前线程发送 段错误 (SIGSEGV) 信号。 该信号通常会导致线程及其所属进程的终止。

本文档将简化并展示 Linux 内核如何处理这些页面错误、创建表和表条目、检查内存是否存在,如果不存在,则请求从持久存储或其他设备加载数据,并更新 MMU 及其缓存的高层视图。

第一步取决于架构。 大多数架构跳转到 do_page_fault(),而 x86 中断处理程序由调用 handle_page_fault()DEFINE_IDTENTRY_RAW_ERRORCODE() 宏定义。

无论采用何种路线,所有架构最终都会调用 handle_mm_fault(),而 handle_mm_fault() 又(可能)最终调用 __handle_mm_fault() 来执行分配页表的实际工作。

无法调用 __handle_mm_fault() 的不幸情况意味着虚拟地址指向不允许访问的物理内存区域(至少从当前上下文中)。 这种情况会使内核向进程发送上述 SIGSEGV 信号,并导致已经解释过的后果。

__handle_mm_fault() 通过调用多个函数来查找页表上层的条目偏移量并分配可能需要的表来执行其工作。

查找偏移量的函数具有像 *_offset() 这样的名称,其中“*”代表 pgd、p4d、pud、pmd、pte;相反,用于逐层分配相应表的函数被称为 *_alloc,使用上述约定在层次结构中以相应的表类型命名它们。

页表遍历可能在中间层或上层(PMD、PUD)之一结束。

Linux 支持比通常的 4KB 更大的页面大小(即所谓的 巨页)。 当使用这些类型的较大页面时,更高级别的页面可以直接映射它们,而无需使用较低级别的页面条目 (PTE)。 巨页包含通常从 2MB 到 1GB 的大型连续物理区域。 它们分别由 PMD 和 PUD 页面条目映射。

巨页带来了几个好处,例如减少 TLB 压力、减少页表开销、提高内存分配效率以及提高某些工作负载的性能。 但是,这些好处也伴随着权衡,例如浪费内存和分配挑战。

在分配的遍历的最后,如果它没有返回错误,__handle_mm_fault() 最终会调用 handle_pte_fault(),而 handle_pte_fault() 又通过 do_fault() 执行 do_read_fault()do_cow_fault()do_shared_fault() 中的一个。“read”、“cow”、“shared”给出了关于它正在处理的错误的原因和类型的提示。

工作流的实际实现非常复杂。 它的设计允许 Linux 以一种根据每个架构的特定特征量身定制的方式处理页面错误,同时仍然共享一个共同的总体结构。

为了结束对 Linux 如何处理页面错误的高层视图,让我们补充一点,可以使用 pagefault_disable()pagefault_enable() 分别禁用和启用页面错误处理程序。

几个代码路径使用了后两个函数,因为它们需要禁用陷入页面错误处理程序的陷阱,主要是为了防止死锁。