
现代计算建立在一个根本的错觉之上:每个程序都拥有自己广阔、私有的内存空间。实际上,是操作系统巧妙地调度着有限的物理 RAM,才创造出这个虚拟世界。但随着虚拟地址空间增长到巨大的太字节规模,简单的“字典式”内存翻译方法已变得极其低效,其映射所需的内存甚至超过了系统所拥有的内存。本文旨在探讨这一关键挑战,深入研究分级页表(hierarchical paging)这一支撑着几乎所有现代操作系统的精妙解决方案。在接下来的章节中,我们将首先解构分级页表的核心原理与机制,揭示它如何在解决空间问题的同时引入了新的性能考量。随后,我们将探索其变革性的应用与跨学科联系,从驱动云计算虚拟化到赋能下一代计算机安全,展示这一概念如何塑造了整个数字世界。
现代计算的核心在于一个深远的错觉:你运行的每一个程序都相信自己独占了整台计算机。它看到的是一片广阔、私有且纯净的内存空间——一个虚拟地址空间——延伸达数TB,所有内容都组织得整齐而连续。当然,这是一个美丽的谎言。现实是一堆混乱、共享且有限的物理 RAM 芯片。操作系统的艺术就在于维持这一错觉,扮演一位伟大的魔术师,将程序理想化的虚拟世界翻译成混乱的物理世界。实现这一宏伟骗局的主要工具是分页(paging),而其最优雅的形式便是分级页表。
让我们从一个最直接的想法开始。如果我们的目标是将虚拟地址翻译成物理地址,为什么不使用一个巨大的字典呢?我们可以将虚拟地址空间切分成固定大小的块,称为页(pages)。对于每个虚拟页,我们都在一个巨大的表——页表(page table)——中为其设一个条目,告诉我们对应的物理内存块,即页框(page frame),位于何处。一个虚拟地址于是由两部分组成:一个虚拟页号 (VPN),用作我们字典的索引;以及一个页内偏移(page offset),告诉我们如何在该页内找到特定的字节。
这似乎很简单。但让我们考虑一下现代计算机的规模。一个典型的64位处理器可能使用48位的虚拟地址。这不仅仅是一个大数字,它代表了惊人的 字节,即 256 TB 的可寻址空间。如果我们选择一个标准的页大小,比如 8 KiB( 字节),那么页内偏移的位数就是 。这就给虚拟页号留下了 位。
这意味着我们的字典,即页表,必须为每个可能的 VPN 准备一个条目。条目的数量将是 ——超过 340 亿。如果每个存储翻译信息的页表项 (PTE) 长 8 字节,那么仅仅一个程序的这个单一、朴素的页表的总大小将是 字节。那是 256 GB!。
这是第一个危机。这完全不切实际。我们仅仅为了管理一个程序的内存,就需要比大多数高端服务器所拥有的内存还要多的内存。一个简单的线性页表,尽管其概念清晰,却被它试图管理的庞大虚拟地址空间压垮了。我们需要一种更聪明的方法。
解决方案源于一个简单但强大的观察:程序的行为方式具有深刻的稀疏性(sparse)。一个拥有 256 TB 虚拟地址空间的程序实际上并不使用 256 TB 的内存。它可能用几兆字节存放代码,几兆字节存放数据和栈,或许再用几吉字节处理一个大文件。地址空间的其余部分由广阔、空旷的未使用地址沙漠组成。
单级页表之所以灾难性地低效,是因为它为每一个可能的页面分配了一个 PTE,包括那些沙漠中的数十亿个页面。分级页表的绝妙之处在于:不要浪费空间去描述空无一物。
想象一下,你想绘制一张世界上所有房屋的地图。你不会用一张行星大小的纸。你会使用一个层次结构:一张世界地图,指向各大洲的地图,再指向各国的地图,然后是城市,最后是街道。如果整个大洲都无人居住,你根本就不会为它绘制国家和城市的地图。
分级页表正是这样做的。它将虚拟页号分解成多个部分,每个部分都作为页表层次结构中不同级别的索引。对于一个四级方案,VPN 可能被分成四个索引,比如 。地址翻译过程,被称为页表遍历(page walk),就变成了一次穿越这个层次结构的旅程:
当一大片地址空间未被使用时,奇迹就发生了。假设一个程序在对应于索引 的地址上映射了一个单独的页面。为此,操作系统分配了一系列页表:一个三级页表、一个二级页表和一个一级页表。现在,考虑另一个完全未使用的地址,其索引为 ,其中只有顶层索引不同。根表中索引为 的条目将被简单地标记为“无效”或“不存在”。当硬件试图翻译第二个地址时,它的遍历在第一级就立即停止了。它发现了一个“无效”条目,并知道地址空间树的整个分支都是空的。关键是,操作系统从未需要为这个分支分配三级、二级或一级页表。通过仅为虚拟地址空间的“已填充”区域创建页表,这种分级方法将内存开销从不可能的 256 GiB 减少到典型程序的几千字节或几兆字节。
然而,重要的是要认识到,这种空间节省仅适用于稀疏的地址空间。如果一个程序要使用其全部虚拟地址空间,分级方案实际上会比单级页表更差。它不仅需要所有末级 PTE(在一个例子中是 个),还需要存储所有中间目录级别的 PTE。总内存将是所有级别所有表的总和,这个数量严格大于单级页表的大小。分级页表是对稀疏性的一次赌博,而对于现代软件来说,这次赌博几乎总是赢。
这个解决空间问题的优雅方案并非没有代价。“没有免费的午餐”这一计算机科学原理再次应验。虽然单级页表只需一次内存访问即可完成翻译,但在转译后备缓冲器 (TLB)(一种用于翻译的高速专用缓存)未命中的情况下,一个 级层次结构需要 次内存访问才能完成一次地址翻译。这种多次访问的页表遍历可能成为一个显著的性能瓶颈。
当硬件页表遍历器穿越层次结构时,它可能会遇到两种主要麻烦,两者都会导致页错误(page fault)——一种将控制权从硬件转移到操作系统的陷阱。
缺页错误 (Not-Present Fault): 在遍历的任何一级,它需要读取的 PTE 的存在位(present bit)可能被设置为 0。这意味着所需的页面——无论是下一级的页表还是最终的数据页——当前不在物理内存中。硬件无法继续。它向操作系统发出一个错误信号,操作系统必须执行从磁盘找到该页、将其加载到空的物理页框中、更新 PTE 以标记其为存在、然后恢复程序执行的艰巨任务。这就是按需分页(demand paging)的核心机制,即页面仅在首次被访问时才从磁盘加载。
保护错误 (Protection Fault): 页面可能存在(),但程序可能试图执行非法操作。例如,试图写入一个被标记为只读的页面。每个 PTE 都包含权限位(例如,读、写、执行),硬件在页表遍历期间会检查这些位。如果权限检查失败,硬件会引发一个保护错误,操作系统通常会终止违规的程序。
这揭示了页表的美妙二元性:它不仅是地址翻译的机制,也是一种强大的内存保护工具。
页表的层次结构也催生了同样强大的分级保护模型。在更高级别 PTE 中设置的权限会被其下的整个地址树分支继承,并能对其进行约束。例如,如果一个映射了 2 GB 地址空间区域的一级 PTE 的写入位被设置为 0,那么在该 2 GB 区域内的任何页面都不能被写入,即使其中某个特定 4 KiB 页面的末级 PTE 的写入位被设置为 1。
一次访问的有效权限是页表遍历中每一级权限的逻辑与(AND)的结果。要允许一次写入,L1 PTE 中的写入位必须为 1,并且 L2 PTE 中的也为 1,并且 L3 PTE 中的也为 1,并且 L4 PTE 中的也为 1。任何一级的一个 '0' 都起到绝对否决的作用。这使得操作系统能够高效地实施粗粒度的安全策略,例如,在层次结构的很高层级将所有内核代码页标记为只读且对用户程序不可执行。
虽然分级页表占主导地位,但它并非唯一的解决方案。另一种方法是反向页表(inverted page table)。反向页表不是为每个进程维护一个大小与虚拟地址空间相关的页表,而是为机器中的每个物理页框设置一个全局条目。每个条目存储当前占用该页框的是哪个进程和哪个虚拟页。这个表的大小与物理 RAM 的数量成正比,而不是与虚拟地址空间的大小成正比。使用哈希进行查找的预期成本是快速的 ,但表的全局性使得共享和实现变得复杂。这两种方案之间的选择代表了一个根本性的权衡:你的瓶颈是虚拟地址空间的复杂性(有利于反向页表)还是物理内存的大小(有利于分级页表)?对于当今拥有广阔、稀疏虚拟空间的系统而言,分级方法已被证明是更具可扩展性的设计。
分页系统的设计是一个充满工程权衡的丰富领域。页表层次结构的深度本身就是一个关键参数。更深的表(更大的 )可以用相同数量的每表条目来寻址更大的虚拟空间,但由于页表遍历更长( 次内存访问),每次 TLB 未命中的成本也更高。由于平均内存访问时间 (AMAT) 随 增加,最优设计通常采用能够覆盖所需内存足迹的最浅层次结构。
为了对抗深层页表遍历的延迟,架构师引入了大页(huge pages)。与其总是映射到小的 4-KiB 页面,如果一个更高级别表(比如二级)中的条目可以直接指向一个大的、连续的 2-MiB 物理页框呢?这正是大页所允许的。页表遍历会提前终止,跳过层次结构的较低级别。这极大地减少了 TLB 未命中的惩罚,并且还允许单个 TLB 条目覆盖更大的内存区域,从而提高了 TLB 的效率。在一个混合使用基本页和大页的系统中,AMAT 的计算清晰地显示了这种优化带来的性能优势。
最后,让我们看一个纯粹的操作系统艺术品:自引用页表(self-referencing page table)。一个棘手的问题可能是:操作系统内核本身如何访问页表以修改它们?它可以煞费苦心地将页表的物理页框逐一映射到其地址空间中,但这很笨拙。技巧在于将一个顶层 PTE 专用于指向顶层页表本身的物理页框。这就创建了一个递归的虚拟映射,使得整个页表层次结构在内核自己的虚拟地址空间内看起来像一个单一的、连续的数据结构。现在,内核可以计算出一个虚拟地址来读写任何进程的页表中的任何 PTE,就像它是一个简单的数组成员一样。
这个技巧提供了强大的便利性,但它并没有改变基本的硬件规则。当一个内核在一个 CPU 核心上运行时修改了一个 PTE,这个变动被写入内存。然而,另一个 CPU 核心可能在其本地 TLB 中仍然缓存着旧的、过时的翻译。硬件的内存一致性机制并不延伸到 TLB。因此,操作系统必须显式地向其他核心发送一个“处理器间中断”,告诉它们从自己的 TLB 中使该过时条目失效——这个过程被称为 TLB 击落(TLB shootdown)。这突显了硬件与软件之间错综复杂的协作:硬件提供了翻译和保护的机制,但正是操作系统在编排它们,赋予它们生命,并将它们转变为支撑所有现代计算的无缝、强大而美丽的虚拟内存错觉。
在上一章中,我们深入探索了分级页表的内部工作原理。我们视其为一个巧妙的解决方案,用以解决一个难题:如何为广阔、蔓延的虚拟内存景观创建一幅地图,而地图本身不会比它所描述的领域更大。我们发现了优雅的“地图的地图”原则,一个指针的层次结构,它允许操作系统以惊人的技巧管理巨大的地址空间。
但要真正领会这个想法的精妙之处,我们现在必须提出一个不同的问题:这个机制为我们做了什么?为什么它不仅仅是一个巧妙的技巧,而是所有现代计算的基础支柱之一?其应用的故事本身就是一段旅程,它将我们从单台计算机的核心效率带到遍布全球的虚拟机云,甚至延伸到网络安全的前沿。我们将看到这一个美丽的概念如何在每个转折点解锁令人惊讶的新能力。
分级页表解决的第一个也是最根本的问题是*稀疏性*。一个现代的64位程序被赋予了256 TB的虚拟地址空间。这是一个近乎滑稽的广阔空间,远大于任何已建成的物理内存。然而,一个程序就像一片广阔、空旷沙漠中的一栋孤零零的房子;它只占据了这片巨大领土上一些微小、分散的地块。一个简单的线性页表——就像一本列出所有可能电话号码的电话簿——将会大得惊人且极其浪费。
分级页表优雅地回避了这一点。页表结构的大小不与虚拟地址空间的大小成正比,而是与实际在用的内存量成正比。对于一个可能使用,比如说,64 MB 内存的典型应用程序,其页表消耗的总空间可能只有几百千字节。我们不再需要数TB的内存来存放地图,而只需要少数几个地图页来标记那几个有人居住的区域。正是这种卓越的效率使得大型虚拟地址空间在实践中成为可能。
当然,天下没有免费的午餐。这种空间效率的代价是时间上潜在的性能损失。为了找到一个物理地址,处理器必须执行一次“页表遍历”,从页表的一级跳到下一级。对于典型的四级层次结构,这可能意味着仅为找到地址就需要四次独立的内存查找,然后第五次查找才能最终获取数据本身。在纳秒的世界里,这简直是永恒。
这时,一个绝妙的优化应运而生:大页(huge pages,或超级页)。硬件设计师注意到,程序经常分配大块连续的内存——用于数据库缓冲区、高分辨率图像或视频帧。既然可以用一个大的页表项来映射这样一个区域,为什么还要用数千个微小的4KB页表项呢?一个大页,可能是2MB甚至1GB大小,可以通过页表层次结构中较早级别的一个条目来映射。
效果是显著的。通过使用一个2MB的大页,我们可以用一个条目映射一个内存区域,而这个区域原本需要512个独立的4KB页面条目。对于一个256MB的内存段,从标准页切换到大页,可以将页表内存开销从几百KB减少到区区十几KB,而且同样重要的是,它缩短了页表遍历,为每次访问该内存节省了宝贵的处理器周期。这是一个经典的工程权衡:为一个广阔、均匀的区域使用更粗略的地图来加速导航。
也许分级页表最深远的应用是在虚拟化领域——驱动云计算的技术。挑战是巨大的:你如何将一个完整的操作系统(一个“客户机”)当作一个普通应用程序来运行在一个控制程序(“虚拟机监控器”)内部?客户机操作系统相信自己完全控制着硬件,包括它自己的用于管理其虚拟内存的页表。
早期的解决方案涉及一种复杂的软件障眼法,称为“影子页表”。虚拟机监控器会创建一个秘密的、“影子”页表,直接将客户机的虚拟地址映射到主机的物理地址。然后,它必须煞费苦心地监控并同步这些影子页表与客户机操作系统试图对其自己(现在是假的)页表所做的任何更改。
然而,现代处理器提供了一种更为优雅的解决方案,它直接建立在分级页表的思想之上。这种技术被称为嵌套分页(nested paging,或Intel的EPT和AMD的NPT),它增加了由虚拟机监控器控制的第二层完整的页表。客户机操作系统管理自己的页表,将客户机虚拟地址(GVA)转换为客户机物理地址(GPA)。但这个“客户机物理地址”从虚拟机监控器的角度看本身就是虚拟的。然后,硬件会自动执行第二次页表遍历,穿过虚拟机监控器的嵌套页表,将该GPA转换为最终的主机物理地址(HPA)。
这就产生了通常所说的“二维”页表遍历。想象一下TLB未命中时可怜的处理器。要翻译一个GVA,它必须首先遍历客户机的页表。假设这是一个四级页表。第一步是获取顶级的客户机页表项。但那个条目在哪里?它驻留在一个GPA上。要找到它,硬件必须首先对虚拟机监控器的嵌套页表进行一次完整的四级遍历。只有这样,它才知道客户机顶级条目的HPA。它获取该条目,然后继续进行客户机遍历的第二级。这第二个客户机条目也位于一个GPA上,需要另一次完整的四级嵌套遍历。这个过程在客户机遍历的每一级都会重复!
性能上的影响是惊人的。一个简单的加法模型可能会得出 次内存访问的成本,其中 和 分别是客户机和主机表的深度。但现实是乘法关系。遍历所需的总内存引用次数更接近于 。对于两个四级表,这可能意味着仅解析一个地址翻译就需要超过二十次内存访问。这种巨大的开销是硬件辅助虚拟化的根本性能挑战。
我们如何使之变得实用?答案在于一系列优化的协同作用。TLB中的积极缓存是第一道防线。但我们也可以请回我们的老朋友——大页。在虚拟化世界中,大页甚至更为关键。如果客户机用一个大页映射一个大型应用程序缓冲区,它就缩短了遍历的客户机部分。如果虚拟机监控器用一个大页映射客户机内存的大片区域,它就缩短了嵌套遍历。这些效应是累积的,它们的协同作用可以极大地降低二维遍历的成本,使虚拟化快到足以应对要求最苛刻的应用程序。
分级页表的影响远远超出了其直接职责。它所提供的机制已被重新用于解决那些乍看起来完全不相关的领域中的问题。
一个惊人的例子是虚拟机实时迁移,现代云数据中心的基石。如何将在一个城市的服务器上运行的虚拟机,迁移到全国另一端的另一台服务器上,而只产生短暂的停机?答案再一次是嵌套分页。在迁移期间,虚拟机监控器开始在后台将虚拟机的内存复制到目标主机。为了跟踪虚拟机在此过程中修改了哪些页面,虚拟机监控器可以使用一个聪明的技巧:它在嵌套页表中将虚拟机的所有页面标记为“只读”。当运行中的虚拟机不可避免地试图写入一个页面时,它会触发一个陷阱到虚拟机监控器的错误。虚拟机监控器只需记下该页面现在是“脏”的,将其重新标记为可写,然后恢复虚拟机。客户机操作系统完全不知道这次拦截。这使得虚拟机监控器可以在大块复制发生时完美地跟踪变化,并只在短暂的暂停期间传输最后那一小组脏页。嵌套分页提供了执行这种技术魔法所必需的间接和控制层。
分级页表的性能特征也对应用程序开发者有直接影响,特别是那些使用Java、Go或C#等托管语言的开发者。这些语言依赖垃圾收集器(GC)来自动管理内存。垃圾收集中的一个常见阶段是“标记-清除”,其中GC必须扫描整个应用程序堆,其大小可能达到数GB。从内存系统的角度来看,这是最坏的情况:对内存进行长长的线性扫描,每4KB就接触一个新页面,引发一场TLB未命中风暴。当在虚拟机内运行时,每一次这样的未命中都要付出嵌套页表遍历的昂贵代价。每次未命中的微小开销,乘以数百万次,可能累积成一个显著的、用户可见的延迟,延长应用程序的“stop-the-world”暂停时间。仅仅因为虚拟化导致每次TLB未命中增加1100个周期,就很容易使一个8GB堆上的GC运行增加近一秒的暂停时间。
最后,在一个引人入胜的转折中,分级地址翻译机制本身现在正被扩展,用于为计算机安全构建堡垒。在可信执行环境(TEE)中,目标是保护一个安全的“enclave”中的代码和数据,使其免受恶意操作系统或虚拟机监控器的攻击。这如何做到?通过为我们的“地图的地图”添加又一层。处理器本身可以强制执行由安全硬件控制的第三级页表,该页表将“主机物理地址”转换为最终的“机器物理地址”。这有效地增加了enclave内存嵌套遍历的深度,为每次访问都增加了性能开销。对于一个拥有4级客户机和4级嵌套页表的系统,仅仅增加一个安全级别就会使TLB未命中时的内存访问次数增加五步。然而,这种成本换来的是非凡的安全保证:虚拟机监控器再也无法读取或修改enclave的内存,因为它不再控制翻译的最后一步。
从一个简单的内存效率工具,分级页表已经演变为构建我们数字世界的多层基底。它赋予我们运行大规模应用程序的规模,构建整个虚拟计算机的灵活性,在它们运行时将其跨越全球移动的能力,甚至是在内存中构建安全保险库的刚性。这是一个单一、美丽思想力量的证明——一个既优雅又必不可少的无形脚手架。