页表¶
分页虚拟内存是 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,以此类推,直到我们到达 0xfffff000 的 pfn 0xfffff。对于 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_t,pteval_t = **页表条目** - 前面提到过。*pte* 是 PTRS_PER_PTE 个 pteval_t 类型元素的数组,每个元素将单个虚拟内存页面映射到单个物理内存页面。架构定义 pteval_t 的大小和内容。
一个典型的例子是 pteval_t 是一个 32 位或 64 位的值,高位是 **pfn**(页帧号),低位是一些特定于架构的位,例如内存保护。
名称的 **entry** 部分有点令人困惑,因为虽然在 Linux 1.0 中,这确实指的是单个顶级页表中的单个页表条目,但当首次引入两级页表时,它被改造为映射元素数组,因此 *pte* 是最底层的页*表*,而不是页*表条目*。
**pmd**,pmd_t,pmdval_t = **页中间目录**,位于 *pte* 之上的层次结构,包含 PTRS_PER_PMD 个对 *pte* 的引用。
**pud**,pud_t,pudval_t = **页上层目录**,在其他级别之后引入,用于处理 4 级页表。它可能未使用,或者如我们稍后将讨论的那样被 *折叠*。
**p4d**,p4d_t,p4dval_t = **第 4 级页目录**,在引入 *pud* 后引入,用于处理 5 级页表。现在很明显,我们需要用指示目录级别的数字替换 *pgd*、*pmd*、*pud* 等,而我们不能再使用临时名称了。这仅在实际具有 5 级页表的系统上使用,否则它会被折叠。
**pgd**,pgd_t,pgdval_t = **页全局目录** - Linux 内核用于处理内核内存的 PGD 的主页表仍然可以在 swapper_pg_dir 中找到,但是系统中的每个用户空间进程也有自己的内存上下文,因此它有自己的 *pgd*,可以在 struct mm_struct 中找到,而后者又在每个 struct task_struct 中被引用。因此,任务以 struct mm_struct 的形式拥有内存上下文,而这反过来又有一个指向相应页全局目录的 struct pgt_t *pgd 指针。
重复一遍:页表层次结构中的每个级别都是*指针数组*,因此 **pgd** 包含 PTRS_PER_PGD 个指向下一个较低级别的指针,**p4d** 包含 PTRS_PER_P4D 个指向 **pud** 项的指针,依此类推。每个级别的指针数量由架构定义。
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 中断处理程序由 DEFINE_IDTENTRY_RAW_ERRORCODE() 宏定义,该宏调用 handle_page_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(),后者通过 do_fault() 执行 do_read_fault()、do_cow_fault()、do_shared_fault() 中的一个。“read”、“cow”、“shared” 提供了有关它正在处理的故障的原因和类型的提示。
工作流程的实际实现非常复杂。它的设计允许 Linux 以针对每种架构的特定特性量身定制的方式来处理缺页,同时仍然共享一个通用的整体结构。
为了总结对 Linux 如何处理缺页的高层次观察,让我们补充一点,缺页处理程序可以使用 pagefault_disable() 和 pagefault_enable() 分别禁用和启用。
多个代码路径会使用后两个函数,因为它们需要禁用进入缺页处理程序的陷阱,主要是为了防止死锁。