try ai
科普
编辑
分享
反馈
  • 页表

页表

SciencePedia玻尔百科
核心要点
  • 页表是至关重要的数据结构,它将程序广阔、连续的虚拟地址空间转换为计算机有限、不连续的物理内存。
  • 分层分页通过创建一个多级、树状的映射表,解决了简单页表巨大的空间开销问题。该映射表只为已使用的内存区域进行填充。
  • 翻译后备缓冲器(TLB)是一个关键的硬件缓存,用于存储最近的地址翻译,从而在多级查找带来开销的情况下,仍然能使地址翻译过程变得迅速。
  • 除了简单的翻译功能,页表在实现操作系统的核心特性(如内存保护、安全隔离和写时复制(COW)等效率技巧)方面也发挥着重要作用。

引言

在现代计算机中,程序运行在它们各自私有的内存世界里,这是一个广阔而连续的领域,称为虚拟地址空间。这种理想化的视图与机器物理内存(RAM)的现实截然不同,后者是有限且碎片化的资源。任何操作系统面临的关键挑战都是弥合这一差距,为每个进程管理着充裕、私有内存的假象。这正是发明页表要解决的根本问题。页表作为主目录,将程序的虚拟地址转换为数据实际所在的具体物理位置。

本文深入探讨了页表这个优雅而复杂的世界,探索了定义现代内存管理的工程权衡。它旨在解决一个核心的知识空白:操作系统如何能在映射表本身不变得异常庞大的情况下,管理一个万亿字节级别的虚拟空间?我们将揭示在计算机科学几十年的创新中发展起来的精妙解决方案。

我们的探索始于“原理与机制”一章,其中我们将剖析页表的结构。我们将从一个简单但有致命缺陷的线性设计开始,了解其失败的原因,这会引导我们走向分层分页这一巧妙的递归解决方案。我们还将探讨诸如反向页表等替代设计,并讨论这些选择的关键性能影响,重点关注翻译后备缓冲器(TLB)的角色。随后,“应用与跨学科联系”一章将揭示这个核心数据结构如何不仅仅是一个翻译器,更是一个多功能的工具,它支撑着现代计算的安全性、效率和诸多奇妙特性,从进程隔离、快速进程创建到令人费解的虚拟化复杂性。

原理与机制

想象一下,你是一个城市的邮政局长,这个城市有天文数字般的潜在地址——远超实际房屋的数量。这正是现代操作系统面临的挑战。程序对内存的看法,即其​​虚拟地址空间​​,是广阔而连续的,而计算机的实际物理内存(RAM)则是一个有限的、不连续的存储单元集合。​​页表​​的工作就是充当这张地图,这个邮政目录,将程序理想化的虚拟地址转换成数据实际存放的具体物理位置。但是,你如何为一个拥有数万亿潜在地址的城市建立一个目录,而目录本身又不会比城市还大呢?这正是内存管理真正精妙之处的展现。

朴素的映射及其难以承受之重

让我们从最直接的方法开始。我们可以创建一个巨大的数组,一个线性列表,为地址空间中的每一个虚拟页都设置一个条目。这个条目,即​​页表项(PTE)​​,会告诉我们该虚拟页对应哪个物理帧。一个PTE必须包含哪些信息?其核心是需要​​物理帧号(PFN)​​。如果我们的计算机有 MMM 字节的物理内存,每个页(和帧)的大小是 SSS 字节,那么就有 Nf=M/SN_f = M/SNf​=M/S 个可能的物理帧。为了唯一标识这些帧中的任何一个,我们至少需要 ⌈log⁡2(Nf)⌉\lceil \log_2(N_f) \rceil⌈log2​(Nf​)⌉ 位。

但这还不是全部。如果一个虚拟页还没有被分配物理地址怎么办?我们需要一种方法来标记一个条目是否合法。这就是不可或缺的​​有效-无效位​​的作用。如果该位是“有效”,则翻译可以进行。如果它是“无效”,任何访问该页的尝试都会触发一个警报(即页错误),交由操作系统处理。因此,我们最小的PTE需要包含PFN的位数,再加上一个有效标志位。由于内存是按字节寻址的,我们必须将总位数向上取整到最接近的整数字节数。

对于单个进程,其页表所需的总内存将是一个PTE的大小乘以其地址空间中的虚拟页数。现在,让我们考虑一下规模。一个现代的64位架构提供了 2642^{64}264 字节的虚拟地址空间。使用常见的 444 KiB(2122^{12}212 字节)页面大小,这意味着有 2522^{52}252 个虚拟页!即使一个PTE仅为8字节,单个进程的页表也需要 8×2528 \times 2^{52}8×252 字节的内存。这相当于32 PB(petabytes)——比任何普通计算机拥有的内存多出数千倍!这种暴力方法是行不通的。地图会比它所要描述的领土大得超乎想象。显然,我们需要一个更聪明的策略。问题不仅仅在于存储我们拥有的映射,还在于避免存储我们没有的映射所带来的成本。

递归解决方案:分层分页之美

线性表的致命缺陷在于,大多数进程对其广阔的地址空间的使用都非常稀疏。一个程序可能只需要零星的几兆字节内存,留下巨大的未使用虚拟地址空洞。为一个只有几座小房子的区域配备一个PB大小的地图是荒谬的。解决方案在于一个简单而深刻的认识:我们只需要为那些真正有房屋存在的社区创建地图部分。

如果我们的页表本身太大,无法容纳于单个页面中怎么办?我们就需要将其切分成页面大小的块,并将这些块存储在物理内存的某个地方。但接下来,我们如何找到这些块呢?我们就需要另一个表——一个“为页表服务的页表”。这种递归创建“表的表”的思想,正是​​分层分页​​(或多级分页)的精髓。

想象一部百科全书。它没有一个列出所有主题的庞大索引,而是有一个顶级索引,指引你找到正确的卷(例如,“A-C”、“D-F”)。在该卷内部,另一个索引可能会指引你到正确的章节,依此类推,直到你找到那一页。分层页表的工作方式与此相同。虚拟地址被分成几个部分。第一部分用作顶级表(我们称之为第4级)的索引。在那里找到的PTE并不指向数据页,而是指向下一级(第3级)的另一个页表。这个过程一直持续到最后一级(第1级),那里的PTE最终指向包含程序数据的物理帧。

这种方案的奇妙之处在于,如果虚拟地址空间中有一大片区域未使用,那么顶级PTE中对应于该区域的条目就可以简单地被标记为无效。这样就无需为整个区域分配任何低级别的页表。用于地图的内存仅在需要时才分配。

让我们看看这有多么强大。考虑一个拥有48位虚拟地址空间的系统,这个空间是巨大的。一个进程只分配了区区 646464 MiB 的内存。采用4级页表结构,该进程不需要为整个256TB的空间都准备一张地图。它只需要少数几个页表来导航到它那小小的活动区域。在一种可能的情景下,这只需要一个顶级表、一个三级表、一个二级表和32个叶级表。页表的总内存开销仅为微不足道的 140140140 KiB——这是管理巨大地址空间所付出的微小代价。内存成本与已使用的内存成比例,而不是理论上的最大值。这个层次结构本质上是一棵稀疏树,只有在实际种植了数据的地方,分支才会生长。

层次深度的代价:性能权衡

分层分页优雅地解决了空间问题,但时间问题又如何呢?每当处理器需要访问内存——无论是取指令还是读变量——它都必须首先翻译虚拟地址。如果每次访问都需要从内存中读取三到四个PTE,我们快如闪电的处理器将会陷入停顿,不断地等待内存系统。

为了避免这场灾难,硬件中包含一个特殊的、极其快速的缓存,称为​​翻译后备缓冲器(TLB)​​。TLB是一个小型的片上存储器,用于存放最近使用的虚拟地址到物理地址的翻译。当CPU需要翻译一个地址时,它首先检查TLB。如果翻译结果在那里(即​​TLB命中​​),它几乎可以立即获得,内存访问便能全速进行。由于TLB的存在,分页在绝大多数时间里是快速的。

但是,当发生​​TLB未命中​​时会怎样?硬件必须执行一次​​页表遍历(page walk)​​,这是一个手动遍历页表层次结构的过程。它从内存中读取第4级PTE,然后是第3级PTE,依此类推,每一级进行一次内存访问,直到找到最终的物理地址。这一系列相互依赖的内存读取是缓慢的。层次结构中的级别数量直接决定了这次遍历的长度,从而决定了TLB未命中的代价。

这揭示了一个基本的设计权衡。更深的层次结构可以支持更大的虚拟地址空间,但它们也增加了页表遍历的成本。即使是一个看似微小的细节也可能产生显著影响。想象两种PTE设计:一种是8字节,另一种是16字节以容纳额外的元数据。在4096字节的页面大小下,8字节的PTE允许一个页表节点有512个条目,而16字节的PTE只允许256个。为了映射相同数量的总页面,PTE较大的设计可能被迫为其层次结构增加整整一个额外的级别(例如,从3级变为4级)。每次页表遍历增加的这一次内存访问,虽然微小,但会乘以TLB未命中率。对于一个未命中率为 0.0010.0010.001 的系统,这可能会使平均内存访问时间增加一个可观的量,也许是 0.0620.0620.062 ns。在高性能计算的世界里,每一皮秒都很重要。

另辟蹊径:反向页表

到目前为止,我们地图的大小,即使使用了层次结构,也仍然与虚拟地址空间的大小相关。让我们尝试一种完全不同的哲学。如果我们构建一个大小与物理内存量挂钩的地图会怎么样?这就是​​反向页表​​背后的思想。

系统不再为每个进程维护一个私有目录,而是维护一个单一的全局目录,其中为RAM的每一个物理帧都恰好有一个条目。该表中的每个条目都表明:“我所代表的物理帧当前正持有来自进程 PPP 的虚拟页 VVV。”

其权衡利弊是显而易见的。内存占用现在是固定的,并且与物理内存量成正比,即 O(N)O(N)O(N),而不是与任何单个进程的虚拟内存使用量相关。对于一个拥有大量小型进程的系统来说,相比于数千个独立分层页表累积的开销,这可能是一个巨大的优势。一个进程在这个全局表中所“分摊”的内存成本实际上是恒定的,即 O(1)O(1)O(1)。

但是我们如何进行翻译呢?对于分层页表,虚拟地址本身就引导着查找过程。而对于反向页表,我们必须搜索与我们的(进程ID,虚拟页号)对相对应的条目。对一个有数百万条目的表进行线性扫描会慢得不可思议。解决方案是使用​​哈希表​​。将(PID, VPN)对哈希到哈希表中的一个索引,该索引再指向反向页表中的正确条目。有了一个好的哈希函数,预期的查找时间是常数时间,即 O(1)O(1)O(1),这是非常快的。在某些设计中,哈希也被用于每个进程的结构内部,但全局反向页表是分层模型的经典替代方案。

没有哪种方法是普遍优越的。反向页表前期有较大的固定内存成本,而分层页表的成本则随着进程数量和使用量的增加而增长。一项定量分析可能会显示,对于一个进程数少于(比如说)24个的系统,分层方法在内存效率上更高,但超过这个点,单一的全局反向页表就会胜出。这是摊销的全局成本和聚合的个体成本之间的经典工程权衡。

操作系统的视角:管理映射表

这些页表不是静态的。操作系统必须不断地更新它们——当进程分配新内存时,当页面被换出到磁盘时,或者当权限被更改时。但是,操作系统如何编辑一个它同时正在用来导航的地图呢?

最优雅的解决方案之一是​​自引用页表技巧​​。操作系统在顶级页表中保留一个条目,使其指向顶级页表自身。通过这种递归映射,当前进程的整个页表层次结构在内核自身的虚拟地址空间内,表现为一个连续的区域。然后,内核只需计算系统中任何PTE的虚拟地址,并使用标准的加载/存储指令,就可以读取或写入该PTE,无需任何繁琐的临时映射。这是一个优美而简洁的自引用解决方案。

然而,这种简洁性背后隐藏着一个危险的微妙之处。当操作系统向内存中的PTE写入数据时,它改变了地图。但是硬件的TLB,我们快速的翻译缓存,对这次内存写入一无所知。它可能仍然持有旧的、现在已不正确的翻译。如果这个​​陈旧的TLB条目​​被使用,处理器可能会访问错误的内存位置或违反安全权限。

因此,在对PTE进行任何更改后,操作系统有一项至关重要的责任:它必须明确指示CPU使TLB中相应的条目失效。在多核处理器上,这个问题被放大了。一个CPU可能更改了一个PTE,但所有其他CPU上的TLB现在都已过时。操作系统必须执行一次​​TLB击落(TLB shootdown)​​,向所有其他核心发送处理器间中断,迫使它们从本地TLB中清除陈旧的条目。这种协调对于在整个系统中维持一致的内存视图至关重要。

为了减少频繁TLB刷新(尤其是在上下文切换期间)的性能影响,硬件提供了辅助功能。许多现代架构支持​​地址空间标识符(ASID)​​。TLB用所属进程的ASID来标记每个条目。在上下文切换时,操作系统只需告诉CPU新进程的ASID。然后,硬件将只使用与当前ASID匹配的TLB条目,从而有效地忽略来自所有其他进程的条目,而无需进行昂贵的刷新。

从朴素线性映射的难以承受之重,到层次结构的递归优雅,再到反向页表的固定成本逻辑,以及TLB一致性的微妙舞蹈,页表的设计是一场穿越层层精妙问题与更精妙解决方案的旅程。它是操作系统设计的一个完美缩影:在抽象和缓存的基本原则之上,不断寻求空间、时间和复杂性之间的恰当平衡。

应用与跨学科联系

如果说我们之前的旅程是为了理解页表的“是什么”和“如何工作”,那么本章将探讨“为什么”。为什么这个看似官僚化的数据结构在计算中如此核心?你可能会认为页表就像一本简单的电话簿,将一个名字(虚拟地址)翻译成一个号码(物理地址)。但这就像把一把万能钥匙称为一块普通的金属。实际上,页表是一个深刻而多功能的工具,一个沉默的建筑师,它实现了我们在现代计算机中习以为常的许多奇妙功能、安全性和高效性。它是一个美丽的范例,展示了一个简单的抽象如何能成为一个充满复杂而强大特性的世界的基础。现在,让我们来探索这个世界。

城堡的守护者:安全与保护

页表的第一个也是最基础的角色是守护者。在一个多任务操作系统的混乱世界里,无数程序并肩运行,页表强制执行秩序与安全。它在每个进程周围竖起无形且不可逾越的墙壁,为每个进程创造一个隔离的虚拟世界。

想象一个调皮的程序试图制造混乱。它可能会试图窥探另一个进程的内存,比如你的网络浏览器,以窃取密码。或者,更胆大妄为地,它可能会试图覆写操作系统内核的关键部分。它会失败。为什么?因为在其虚拟地址空间内,它根本无法命名其世界之外的地址。定义了其世界的页表,不包含对此类地址的翻译。

但如果程序更聪明呢?如果它试图修改自己的页表以授予自己访问禁区的权限呢?硬件和操作系统早已预料到这一点。操作系统将页表存储在内存中,并在映射这些页表的更高级别表中将其标记为“仅限监管者”。用户模式进程任何写入这些页面的尝试都会触发保护错误,立即阻止攻击。页表保护了它自己!那如果进程试图告诉CPU使用它自己精心设计的另一套恶意页表呢?这同样是被禁止的。更改页表基址寄存器(如x86-64上的CR3)的指令是一条特权指令,只能由操作系统内核执行。用户进程尝试这样做会导致陷入操作系统,操作系统会立即终止这个无礼的程序。

这座堡垒的防护范围超出了CPU。现代系统充满了强大的设备——网卡、GPU、存储控制器——它们可以直接写入内存,这个功能称为直接内存访问(DMA)。一个不受约束的设备可能成为特洛伊木马,完全绕过CPU的保护。这时,输入输出内存管理单元(IOMMU)就派上用场了。你可以将IOMMU看作是为I/O设备专设的页表。操作系统对IOMMU进行编程,为每个设备提供其自己隔离的“I/O虚拟地址”空间,确保一个USB驱动器,即便是恶意的,也只能访问为其传输任务明确分配的特定内存缓冲区,而不能访问其他任何内容。这可以防止DMA攻击,并确保整个系统的完整性。

幻象大师:效率与操作系统魔法

除了作为一名严格的守护者,页表也是一位技艺高超的幻术师,使操作系统能够施展看似违背物理定律的魔法。

其中最著名的是[fork()](/sciencepedia/feynman/keyword/fork()|lang=zh-CN|style=Feynman)系统调用,它能创建一个新进程。在旧系统中,创建进程意味着费力地复制父进程内存的每一个字节,这可能多达数GB。这是一个缓慢而笨重的过程。而现代系统在瞬间就能完成。诀窍是什么?写时复制(COW),由页表精心策划。当调用[fork()](/sciencepedia/feynman/keyword/fork()|lang=zh-CN|style=Feynman)时,操作系统只是为新的子进程复制父进程的页表。两套页表最初都指向相同的物理内存页。为防止混乱,操作系统巧妙地在两个进程的页表中将所有这些共享页面标记为只读。只要进程们只进行读取操作,它们就会愉快地共享内存。一旦其中一个试图写入,就会发生保护错误。操作系统处理程序被唤醒,分配一个全新的内存页,复制原始页面的内容,并更新写入进程的页表以指向这个新的、现在标记为可写的私有副本。另一个进程不受影响。这种“懒惰复制”意味着内存仅在真正需要时才被复制,使进程创建快得惊人。

同样的原理也让进程能够在它们隔离的世界之间架起桥梁。对于进程间通信(IPC),操作系统可以将同一个物理页帧映射到两个或多个进程的虚拟地址空间中。它们可能在完全不同的虚拟地址上看到这个页面,但它们看到的是相同的物理数据。得益于硬件缓存一致性的魔力,如果一个进程写入共享页面,这些更改会自动且几乎立即对运行在不同CPU核心上的其他进程可见。页表创建了共享空间;硬件维护其一致性。这是软件和硬件之间完美的交响乐。

写时复制的幻象也可以用来捕捉一个转瞬即逝的瞬间。想象一下,需要为一个庞大的、正在运行的数据库服务器创建一个“快照”或检查点,用于备份或迁移,而无需关闭它。页表使这成为可能。操作系统可以镜像服务器的整个页表结构,从而在特定瞬间创建其内存的冻结“视图”。然后,它将所有活动服务器的数据页标记为只读。随着服务器继续运行并修改数据,COW错误确保所有更改都指向新的物理页面,而原始数据——即快照——则保持原始、未被触动,供备份进程从容读取 [@problem_-id:3623064]。

虚拟化艺术:世界中的世界

页表最令人费解的应用可能是在虚拟化中——即将整个操作系统作为另一个系统内部的一个普通进程来运行的艺术。一个“访客”操作系统,自以为完全控制着机器的内存,如何能被安全地容纳?

最早也是最聪明的软件技术之一被称为​​影子分页(shadow paging)​​。虚拟机监控程序(主机操作系统)创建一套“影子”页表,将访客的虚拟地址直接映射到机器的真实物理地址。访客操作系统被允许拥有自己的页表,但硬件实际上在使用的是影子页表。诀窍在于:虚拟机监控程序在影子页表中将访客的页表页面标记为只读。每当访客操作系统试图修改自己的页表(这对操作系统来说是正常操作)时,都会触发一个页错误,陷入到虚拟机监控程序中。然后,虚拟机监控程序可以检查访客的意图更改,相应地更新其影子页表,并恢复访客的运行。这是一场优美但复杂的、关于陷入和模拟内存管理的舞蹈。

然而,这种软件舞蹈可能很慢。单个访客TLB未命中可能涉及多次昂贵的陷入操作。这催生了一项硬件创新:​​嵌套分页(nested paging)​​(在Intel上称为EPT,在AMD上称为NPT)。在这里,CPU本身能够感知两个层次的翻译:从访客虚拟地址到访客“物理”地址,以及从访客“物理”地址到主机物理地址。这消除了在每次页表修改时都需要陷入的必要。但它引入了一个新的性能问题。单个TLB未命中现在会触发一个二维的页表遍历。为了找到访客的数据,硬件可能首先需要遍历主机的页表,仅仅是为了找到访客的页表页面所在的位置!在最坏的情况下,遍历一个 $d$ 级的访客页表需要在每一步都遍历 $d$ 级的主机页表,导致性能成本可能呈二次方增长,即 $d^2$。这展示了软件和硬件之间奇妙而持续的对话,其中一方的巧妙解决方案成为另一方的性能挑战。

前沿技术:性能、安全与并行

随着系统变得越来越复杂,我们使用——以及滥用——页表的方式也变得越来越复杂。

页表遍历,特别是嵌套遍历,是缓慢的。翻译后备缓冲器(TLB)是我们的第一道防线,它缓存最近的翻译。但是TLB很小。提高其效率的一种方法是使用​​大页(huge pages)​​。我们可以配置单个页表项来映射一个更大的区域,如 $2\,\mathrm{MiB}$ 甚至 $1\,\mathrm{GiB}$,而不是使用标准的 $4\,\mathrm{KiB}$ 页面。单个TLB条目现在覆盖了更大范围的内存,极大地增加了“TLB覆盖范围”,并减少了昂贵的页表遍历的频率。现代系统通常混合使用不同大小的页面,对大型、稳定的结构(如应用程序代码或数据库)使用大页,对更动态的内存使用标准页面。

操作系统和硬件之间的这种紧密耦合也可能产生意想不到的副作用。例如,创建别名(多个虚拟地址指向同一个物理地址)是常见的操作系统实践。然而,在某些缓存设计上,如虚拟索引、物理标记(VIPT)缓存,这可能导致“同义词”问题,即相同的物理数据可能最终出现在两个不同的缓存位置,导致一致性问题。为防止这种情况,架构师和操作系统设计者必须遵守缓存大小、页面大小和关联性之间的严格数学关系,确保缓存索引位仅来自页面偏移量,而页面偏移量在不同别名之间是不变的。

即使是为加速页表遍历而设计的硬件也可能成为安全漏洞。许多CPU都有一个​​页表遍历缓存(PWC)​​来缓存中间的页表项。由于这个缓存在同一核心上运行的进程之间共享,它可能被用于​​侧信道攻击​​。攻击者可以通过访问与受害者使用相同上级页表的内存(例如,通过共享库)来“预热”PWC,然后“探测”自己访问的时间,以查看受害者是否已驱逐了他们的条目。这可能会泄露有关受害者内存访问模式的信息。

最后,在我们的多核世界里,即使是一个简单的权限更改也变成了一个复杂的并行问题。考虑一个即时(JIT)编译器,它动态地生成机器代码。为了安全(一种称为W^X的策略,即写异或执行),它将代码写入一个标记为可写但不可执行的页面。然后它请求操作系统翻转PTE中的权限,使其变为只读和可执行。但是,如果同一进程的另一个线程正在不同的CPU核心上运行呢?该核心的TLB可能有一个陈旧的、带有旧的不可执行权限的条目。为确保正确性,操作系统必须执行一次​​TLB击落​​:向所有其他相关核心发送处理器间中断,指示它们使陈旧的TLB条目失效。只有这样,代码才能安全地执行。

从内存隔离的基本保证到多核一致性和微架构攻击的微妙复杂性,不起眼的页表始终处于行动的中心。它不仅仅是一个数据结构;它是硬件和软件之间一个强大、动态的接口,是构成现代计算的优雅、分层抽象的明证。