Page Tables (页表) (翻译 by chatgpt)

发布时间 2023-12-07 23:15:04作者: 摩斯电码

原文:https://www.kernel.org/doc/html/latest/mm/page_tables.html

分页虚拟内存是在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,依此类推,直到PFN 0xfffff位于0xfffff000。使用16KB的页面,PFS位于0x00004000、0x00008000...0xffffc000,PFN从0到0x3fffff。

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

随着内存大小的增加,页面表的层次结构变得更深。在创建Linux时,使用了4KB的页面和一个名为swapper_pg_dir的单个页面表,其中包含1024个条目,覆盖了4MB的内存,这与Torvald的第一台计算机具有4MB的物理内存相吻合。在这个单个表中的条目被称为PTE(页面表条目)。

软件页面表层次结构反映了页面表硬件的层次结构化,并且这样做是为了节省页面表内存并加快映射速度。

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

此外,在现代CPU上,较高级别的页面表条目可以直接指向物理内存范围,这允许在单个高级页面表条目中映射连续的几兆字节甚至几十亿字节的范围,从而在将虚拟内存映射到物理内存时节省遍历更深层次的开销:当找到这样的大映射范围时,无需继续遍历更深层次。

页面表层次结构现在发展成为以下形式:

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

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

  • pte、pte_t、pteval_t = 页面表项 (Page Table Entry) - 如前所述。pte是pteval_t类型的PTRS_PER_PTE元素数组,每个元素将虚拟内存的单个页面映射到物理内存的单个页面。架构定义了pteval_t的大小和内容。

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

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

  • pmd、pmd_t、pmdval_t = 页面中间目录 (Page Middle Directory),位于pte上方的层次结构,具有PTRS_PER_PMD个指向pte的引用。

  • pud、pud_t、pudval_t = 页面上层目录 (Page Upper Directory)是在其他级别之后引入的,用于处理4级页面表。它可能未使用,或者如后面将讨论的那样被折叠。

  • p4d、p4d_t、p4dval_t = 页面级别4目录 (Page Level 4 Directory)是在引入pud之后处理5级页面表的。现在清楚地表明,我们需要用表示目录级别的数字来替换pgd、pmd、pud等,而且我们不能再继续使用临时名称。这仅在实际具有5级页面表的系统上使用,否则会被折叠。

  • pgd、pgd_t、pgdval_t = 页面全局目录 (Page Global Directory) - 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包含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)杀手,通过终止优先级较低的进程来腾出空间,直到压力降到安全阈值以下。

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

如果上述条件发生在用户空间,内核会向当前线程发送一个分段错误(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() 来禁用和启用。

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