
在现代计算中,程序所看到的内存是一种精心构造的幻象。这个被称为虚拟内存的概念,为每个应用程序提供了各自私有的、广阔的地址空间,与其他所有程序隔离开来,并且不受物理 RAM 的限制。正是这一基础性的抽象,使得我们的设备能够无缝地执行多任务、保护数据,并运行那些需要比物理可用内存更多内存的软件。实现这一幻象的关键在于地址转换,即系统将程序的虚拟地址转换为实际物理位置的复杂过程。本文将揭开这一关键过程的神秘面纱。
我们将首先在“原理与机制”一节中剖析其核心机制,解释硬件和操作系统如何利用页表和专用缓存协同工作,以安全高效地执行这种转换。之后,“应用与跨学科联系”一节将揭示为何这种机制如此强大,探讨它如何促成操作系统的基本功能、提升性能,并为虚拟化等复杂技术奠定基础。
现代计算的核心在于一个深刻而优雅的“骗局”:你的程序所看到的内存并非计算机中真实存在的物理内存。实际上,每个程序都生活在自己的私有宇宙中,即一个虚拟地址空间。这是计算机科学中最强大的思想之一,如同一场魔法,让你的笔记本电脑可以同时运行数十个程序而不会相互冲突,可以使用比物理可用内存更多的内存,并保护你的数据免遭窥探。我们的任务就是去理解这个宏伟的幻象是如何构建和维护的。这是一个关于间接性、巧妙的数据结构以及硬件与软件之间紧密协作的故事。
想象一下,要指引一个朋友去一个巨大图书馆里找一本特定的书。你不会给他这本书在地球上的绝对 GPS 坐标。相反,你会说:“去第 42 排,架子上的第 119 本书。”这正是虚拟内存所采用的策略。内存不被视为一长串无差别的字节序列,而是被划分为固定大小的块,称为页面(page)。如今,一个典型的页面大小是 KiB( 字节)。
一个虚拟地址,在你的程序看来是一个单一的大数,但硬件会秘密地将其解释为一个坐标:由一个页号(page number)和一个在该页内的偏移量(offset)组成的数对。
这个方案的美妙之处在于其数学上的简洁性。如果你有一个虚拟地址 和一个页面大小 ,硬件只需用你在小学学过的整数除法就能找出页号和偏移量。页号 是地址除以页面大小的商。偏移量 则是余数。
例如,在使用 字节页面大小的情况下,虚拟地址 会被转换为页号 和偏移量 。因此,地址 就是第 页上的第 个字节。这种转换是完全可逆的;原始地址可以通过简单的公式 重建。这不是一个近似值,而是一个数学上精确的双射,确保在转换中不会丢失任何信息。这种简单的算术是整个虚拟内存大厦的基石。
那么,硬件知道了你的程序想要虚拟页 10 上的第 1191 个字节。但是虚拟页 10 在计算机的实际物理 RAM 中位于何处?答案存于一个由操作系统维护的特殊数据结构中,称为页表(page table)。页表就是将虚拟地址转换为物理地址的地图或“电话簿”。在最简单的形式下,页表只是一个大数组。页号被用作该数组的索引,在那里找到的条目,即页表项(Page Table Entry, PTE),包含了该页面在内存中实际位置的物理地址——这个物理页面通常被称为物理帧(physical frame)。
这个间接层是所有魔法的源泉。操作系统完全控制着这张地图。它可以将一个进程的页面放置在物理内存的任何位置,从而创造出连续地址空间的假象,即使物理帧是分散的。
但 PTE 的真正威力远不止于简单的地址转换。每个条目都附带一组权限位,硬件的内存管理单元(Memory Management Unit, MMU)在每次内存访问时都会检查这些权限位。
这些位是硬件的哨兵。它们是一个行为不端的程序无法篡改另一个程序内存的原因。想象一下,进程 A 试图访问一个虚拟地址,比如 ,这个地址恰好在进程 B 的地址空间中是有效的。这是一个常见的数字巧合。然而,在进程 A 的上下文中执行的 MMU 会查询进程 A 的页表。在那个索引处,它很可能会找到一个存在位为关()的 PTE,因为进程 A 从未请求过那块内存。这会立即触发一个错误。即使那个地址碰巧在进程 A 中被映射了,它也可能是一个属于内核的页面,在这种情况下, 位会被设置为仅超级用户可访问,同样会触发一个错误。这种隔离是绝对的,在硬件最根本的层面上强制执行。
历史上,一些架构如 Intel IA-32 在分页之前使用了一个更复杂的、分层的系统,涉及分段(segmentation)。一个逻辑地址在被转换为线性地址(然后进行分页)之前,会先根据段限制进行检查。即使底层的页面是完全有效的,访问也可能因为段违规而被捕获,增加了另一层检查。现代 64 位系统明智地简化了这一点,几乎完全依赖于更简洁、更强大的分页机制来管理和保护内存。
简单的页表模型有一个显而易见的问题:大小。一个 32 位地址空间,使用 KiB 的页面,包含 (约一百万)个虚拟页。如果每个 PTE 是 字节,那么单个进程的页表就将是 MiB!对于一个 64 位地址空间,这样一个“扁平”页表的大小将是天文数字,远大于任何物理内存。
解决方案是一个经典的计算机科学技巧:增加另一个间接层。我们使用多级页表(multilevel page tables)。我们不再使用一个巨大的表,而是创建一棵树。顶层的虚拟地址位索引一个“页目录”,它指向的不是一个物理帧,而是一个二级页表。下一组虚拟地址位索引这个二级页表,该表最终包含了物理帧地址。
这种层级结构对于程序实际使用内存的方式来说非常高效。大多数程序的地址空间是稀疏的;它们使用一小块区域存放代码,另一块存放数据,以及一块不断增长的区域用于栈,但中间巨大的虚拟鸿沟是空的。通过多级页表,操作系统只需要为那些实际在使用的区域创建二级页表。所有未使用虚拟空间的页目录条目都可以被标记为不存在,不消耗额外的内存。
考虑一个深度运行的递归函数,导致其栈在内存中向下增长。当它越过一个由单个二级页表覆盖的 MiB 区域的边界时,它会首次触及一个新的虚拟页面。这会触发一个页错误,操作系统会响应分配并填充一个全新的二级页表来覆盖这个新区域。这种“惰性分配”是操作系统和硬件协同工作以节约资源的一个绝佳例子。当然,这不是没有代价的;一个非常深的递归可能需要数百个这样的二级页表,仅仅为了这些映射本身就产生了数百 KB 的内存开销。
对于 64 位系统,即使是三级或四级页表也可能显得笨重,一些设计采用了反向页表(inverted page tables)这种激进的方法。系统中不再是每个进程一个页表(将虚拟映射到物理),而是只有一个全系统的表,用物理帧号作索引,该表存储着占用该物理帧的(进程ID,虚拟页)。这巧妙地将页表的大小固定为与物理内存成正比,而不是与庞大的虚拟空间成正比。但现在,你如何为给定的虚拟地址找到条目?你将不得不搜索整个表!解决方案是另一个优美的数据结构:覆盖一个哈希表,允许 MMU 在期望的常数时间内找到正确的条目。这是一个将一个问题换成另一个问题,并用算法的巧妙来解决新问题的典型例子。
我们已经构建了一个宏伟的系统,但我们忽略了一个可怕的性能悬崖。为了访问单个字节的内存,MMU 可能需要执行几次它自己的内存访问来遍历页表树。一个四级页表遍历意味着在你甚至可以开始你最初想要的那个内存读取之前,就要进行四次依赖性的内存读取。这会使机器的速度降低一个数量级。
救星是 CPU 内部一个小型、专用的硬件缓存,称为转译后备缓冲器(Translation Lookaside Buffer, TLB)。TLB 是一个用于翻译的缓存。它存储了少量最近使用过的虚拟到物理页的映射关系。在进行缓慢的页表遍历之前,MMU 首先检查 TLB。如果翻译结果在那里(TLB 命中),物理地址几乎可以立即获得,内存访问继续进行。如果不在那里(TLB 未命中),硬件才会执行缓慢的遍历,然后将新找到的翻译结果存入 TLB,希望它很快会再次被需要。
TLB 的影响难以言表,它受局部性原理(principle of locality)支配。程序倾向于以某种模式访问内存。当你顺序读取一个数组时,你会访问同一页面内的许多元素。对该页面的第一次访问可能会导致 TLB 未命中,但接下来对同一页面的成百上千次访问将是闪电般的 TLB 命中。
让我们把这个具体化。假设一次内存访问需要 ns,而一次 TLB 未命中的惩罚(页表遍历的时间)是 ns。
当我们考虑到现代系统的现实时,这个简单的图景变得异常复杂:多个进程在多个处理器核心上并发运行。这是最微妙和最重要的正确性问题出现的地方。
虚拟内存自然地创造了两种有趣的情况:
同名异物对 TLB 的正确性构成直接威胁。当操作系统从进程 A 切换到进程 B 时,如何阻止进程 B 使用来自进程 A 的陈旧 TLB 条目?天真的解决方案是在每次上下文切换时刷新整个 TLB,但这非常慢。所有现代 CPU 使用的优雅解决方案是为 TLB 条目打上地址空间标识符(Address Space Identifier, ASID)或进程上下文ID(Process-Context ID, PCID)的标签。TLB 的查找现在会同时匹配虚拟页和当前进程的 ASID,允许多个不同进程的翻译和平共存于缓存中。性能增益是巨大的;对于一个有频繁系统调用的工作负载,启用 PCID 可以为每次调用节省数千个处理器周期,仅仅是通过避免 TLB 刷新。
另一方面,异名同物为*数据缓存*,特别是虚拟索引、物理标签(Virtually Indexed, Physically Tagged, VIPT)的缓存,带来了一个微妙的问题。缓存可能使用虚拟地址位作为其索引。如果两个异名同物 和 具有不同的索引位,那么相同的物理数据可能最终被缓存到两个不同的地方。如果一个被更新,另一个就会变得陈旧,违反了一致性。这就是别名问题(aliasing problem)。解决方案要么是硬件约束(设计缓存,使其索引位只来自页内偏移,这对所有异名同物都是相同的),要么是一种称为页着色(page coloring)的巧妙操作系统技巧,以确保任何异名同物的映射设置都能避免这种冲突。
用户和内核之间的边界也充满了微妙之处。当一个硬件设备通过中断信号表示任务完成时,内核中的中断服务程序(ISR)可能需要访问用户的数据缓冲区。但中断可能发生在运行一个完全不相关的进程时!如果 ISR 天真地尝试使用用户缓冲区的虚拟地址,它将使用错误的页表进行转换,导致混乱。内核必须通过要么临时切换整个地址空间上下文(通过更改 CR3 寄存器),要么更高效地,在 I/O 最初发起时为用户内存创建一个稳定的内核虚拟别名来解决这个问题。这个别名是全局内核映射的一部分,并且始终有效,无论当前哪个进程正在运行。
最后,在一个多核系统中,如果操作系统更改了一个映射的权限——例如,将一个共享的可写页面变为只读——仅仅更新主页表是不够的。陈旧的、权限更宽松的翻译可能潜伏在其他核心的 TLB 中。为了保持正确性,操作系统必须执行一次TLB 击落(TLB shootdown):它向其他核心发送一个处理器间中断,指示它们从其本地 TLB 中使陈旧的条目失效。
从一个简单的除法问题到多核击落的复杂编排,地址转换是抽象的一个惊人例子。它证明了间接性的力量,将物理硬件混乱、有限且充满竞争的现实,转变为每个程序栖居的有序、广阔且私密的宇宙。它是使现代计算成为可能的沉默、不知疲倦的引擎。
在我们之前的讨论中,我们剖析了地址转换的复杂机制。我们看到了处理器和操作系统如何串通一气,利用页表和转译后备缓冲器 (TLB),将程序看到的虚拟地址转换为硬件能理解的物理地址。为了在内存中找到一个字节而经历这么多麻烦,似乎有点小题大做。为什么不直接让程序使用物理地址呢?
事实是,正如科学中常有的情况一样,真正的魔力不在于机制本身,而在于它所开启的非凡可能性。地址转换不仅仅是一种查找服务;它是为每个程序创建一个虚拟宇宙的基本工具——一个干净、私密且灵活的世界,在那里,物理内存混乱、有限且充满竞争的现实可以被忽略。现在,让我们来探索从这个单一而强大的理念中绽放出的美丽而多样的应用。
地址转换最直接的应用是保护。通过为每个进程提供自己独立的页表,操作系统为它构建了一个独立的虚拟宇宙。你的网页浏览器生活在一个宇宙里,你的文本编辑器在另一个。页表硬件确保程序只能访问操作系统明确映射到其世界中的物理内存。这是一个稳定的多任务系统的基础;一个程序中的错误不能破坏内核或其他应用程序的内存。
但操作系统可以比仅仅建墙更聪明。它能利用其对页表的控制权,以一种近乎魔法的优雅方式管理资源。例如,考虑创建一个新进程的常见操作,比如使用 [fork()](/sciencepedia/feynman/keyword/fork()|lang=zh-CN|style=Feynman) 系统调用。新进程应该是父进程的一个相同副本。一个天真的方法是物理上复制父进程内存的每一页,这可能涉及数 GB 的数据。这种做法缓慢且浪费。
取而代之的是,操作系统执行一种名为写时复制 (Copy-on-Write, COW) 的技巧。它为子进程创建一个新的虚拟地址空间,但它配置子进程的页表指向与父进程完全相同的物理页面。为了防止混乱,它在两个进程中都将这些共享页面标记为只读。现在,两个进程都运行着,共享所有物理内存,而 fork 操作几乎是瞬时的。只有当其中一个进程试图写入一个共享页面时,CPU 的内存管理单元才会检测到权限违规并触发一个陷阱到操作系统。只有在这时,操作系统才会分配一个新的物理页面,复制原始页面的内容,并更新引起错误的进程的页表,使其指向这个新的、具有写权限的私有副本。这种“惰性复制”是一种惊人的优化,它之所以成为可能,正是利用了地址转换的保护特性。这种交互非常深入,延伸到处理器推测执行引擎的核心,在那里,这种错误必须被极其小心地处理,以确保架构状态保持精确和正确。
这种“非到万不得已不做”的哲学也延伸到了内存分配本身。一个程序可以请求操作系统为其虚拟地址空间保留一个巨大的、数 GB 大小的区域。操作系统同意了,创建了虚拟映射,但并没有为其分配任何物理内存。这被称为按需分页 (demand paging)。只有当程序第一次实际触及该区域内的某个页面时,才会发生页错误,也只有在这时,操作系统才会找到一个空闲的物理帧来支持该虚拟页面。
程序和操作系统之间的这种对话可以是双向的。一个复杂的程序,比如一个管理大缓冲区的动态数组,可以使用像 madvise 这样的系统调用来通知操作系统:“我现在没有使用我缓冲区容量的上半部分。”如果操作系统采纳了这个提示,它可以回收支持那部分虚拟地址空间的物理页面,从而减少程序的内存占用,而无需破坏其虚拟地址布局。当程序再次需要那部分容量时,它只需经历几次软页错误,就能从操作系统那里获得新的物理页面。这种协作使得构建内存效率极高的数据结构成为可能。
虽然地址转换是隔离的大师,但它也是受控共享的大师。如果两个进程想要通信怎么办?它们可以请求操作系统将同一个物理内存区域映射到它们各自的私有虚拟地址空间中。现在它们有了一个共享的“沙箱”,一个进程写入的数据可以立即被另一个进程看到。这是可用的最快的进程间通信形式。
在这里,我们遇到了一个有趣的难题。一项名为地址空间布局随机化 (Address Space Layout Randomization, ASLR) 的现代安全特性,在每次程序运行时,都会故意将共享库和其他内存区域加载到不同的虚拟地址。这使得攻击者更难利用内存损坏漏洞。所以,你的进程可能将一个共享文件映射在虚拟地址 ,而我的进程将同一个文件映射在 ,其中 。如果我们的地址不同,我们怎么能共享呢?
答案在于虚拟与物理的美妙解耦。操作系统只需配置我们各自的页表,使得你进程中的虚拟页 和我进程中的虚拟页 都转换到同一个物理帧。这个抽象完美地成立:我们各自在自己的私有地址空间中看到一个连续的文件,但在底层,硬件将我们的访问都导向了同一个物理位置。这不仅仅是一个理论上的好奇心;它是你如何能够调试一个每次运行内存布局都改变的程序的基础部分。
这种将同一物理页映射到不同虚拟地址的能力,可以用于更巧妙的编程技巧。想象一下,你需要一个大小恰好为一页的环形缓冲区。通常,当你写入的数据从末尾绕回到开头时,你需要执行显式且有时很慢的模运算。相反,你可以请求操作系统创建一个两页的连续虚拟区域,我们称之为页面 和 ,但将两个虚拟页都映射到同一个物理页帧。现在,一个从虚拟页 末尾溢出的写操作,会无缝地出现在虚拟页 的开头。由于两者都映射到同一个物理页,写操作实际上已经在物理缓冲区中完成了环绕,而无需任何特殊代码。我们甚至可以利用保护位来捕获错误:通过将页面 设为只读,任何越过边界的写操作都会触发一个保护错误,立即向我们警示缓冲区溢出。
到目前为止,我们一直将地址转换视为一种抽象服务。但它是一个物理过程,需要时间。为了使其快速,CPU 使用一个专门的翻译缓存:TLB。和任何缓存一样,它的性能不是给定的;它关键地取决于程序的访问模式。
这就产生了一个深刻且常常令人惊讶的联系,介于高级软件设计和底层硬件性能之间。假设你需要存储数百万个小对象。你可以将它们紧密地打包到一个密集数组中,或者你可以为每个对象在堆上单独分配,导致一个稀疏布局,其中每个微小的对象可能都住在它自己的、大部分为空的虚拟页面上。从程序逻辑上看,两者都是有效的。但从性能上看,差异可能是灾难性的。
密集数组是“TLB 友好的”。一次顺序扫描会在跨越页面边界并需要新的翻译之前访问数千个对象。当前页面的 TLB 条目被一次又一次地重用。而稀疏布局则是一场性能灾难。每当程序从一个对象移动到下一个时,它很可能在访问一个新的虚拟页面。程序的页面工作集变得巨大,TLB 不断地因未命中而被颠簸,处理器花费更多的时间等待页表遍历,而不是做有用的工作。一个看似无辜的数据结构设计选择可能导致数量级的减速。解决方案是什么?做到“操作系统感知”。通过从由巨页(例如 而不是 )支持的大型内存池中分配对象,一个 TLB 条目可以覆盖一个大得多的内存区域,从而极大地减轻 TLB 的压力。
编译器也可以成为我们在这场斗争中的盟友。一个预先 (AOT) 编译器可以分析一个程序,并观察到某个特定函数频繁访问一个特定的常量。在标准布局中,函数的代码在 .text 段,而常量则远在 .rodata(只读数据)段,很可能在不同的页面上。一个聪明的编译器可以选择将常量与函数的代码并置,确保它们都落在同一个虚拟页面上。这个简单的改变将运行那段代码所需的 TLB 条目数量减半,这是一个虽小但不断累积的性能胜利。
地址转换的概念是如此强大,以至于它已被推广到解决远超管理单台计算机内存的原始范围的问题。
虚拟化是这一点的终极体现。你如何将一个完整的操作系统作为“客户机”在另一个“主机”操作系统内部运行?你虚拟化一切,包括内存。客户机操作系统认为它在管理物理内存和页表,但它所谓的“物理地址”,实际上只是主机视角下的另一层虚拟地址。当客户机操作系统试图访问其页表时,CPU 必须执行一次翻译的翻译。这个过程,称为嵌套分页或扩展页表 (EPT),得到了现代硬件的支持。它允许一个虚拟机监控程序为整个客户机操作系统创建完全隔离的宇宙,每个客户机都相信自己完全控制着机器。
同样的“受控宇宙”原则也可以应用于 I/O 设备。像网卡或显卡这样的现代设备可以使用直接内存访问 (DMA) 直接写入内存,绕过 CPU。一个有缺陷或恶意的设备可能会通过覆写关键的内核数据结构造成严重破坏。解决方案是一个输入输出内存管理单元 (IOMMU)。IOMMU 实际上是为设备准备的 TLB。操作系统为 IOMMU 编程,提供页表,精确指定给定设备被允许访问哪些物理页面。设备任何试图在其指定沙箱之外执行 DMA 的行为都会导致 IOMMU 错误,从而保护系统的完整性。
最后,地址转换的哲学启发了计算领域最新的前沿之一:持久性内存。这种内存像 RAM 一样,是字节可寻址且速度快,但又像磁盘一样,在断电时能保留其内容。你如何在这样的内存中构建一个持久的数据结构,比如一棵树?你不能存储传统的指针(它们是绝对的虚拟地址),因为当系统重启时,持久性内存文件可能会被映射到一个完全不同的虚拟基地址,使得所有旧的指针都失效。
解决方案是学习 ASLR 的位置无关性教训。我们不存储绝对地址,而是将所有内部引用存储为相对于持久性内存区域起始位置的相对偏移量。一个指向子节点的指针变成了“从这个区域开始的第 3200 字节处”。当程序启动时,它映射该区域,获得新的基虚拟地址,并可以通过简单的加法将任何偏移量“再水化”为一个有效的、可调用的指针。这使得数据结构可重定位且持久,这是将虚拟内存思想直接应用于必须比创建它的进程生命周期更长的数据问题的典范。
从页错误与 CPU 流水线之间的微观舞蹈,到虚拟机的宏伟架构,再到可以永存的数据结构,地址转换的原则贯穿了所有现代计算。它证明了抽象的力量——一个关于内存本质的简单而优雅的谎言,却让我们能够构建出日益复杂、强大和美丽的真理。