
在计算机内部创建一个完整的虚拟世界——一个能够在其操作系统毫不知情的情况下运行该系统的世界——提出了一个根本性的挑战:如何将一个主宰的监督者降级为一个被管理的对象?解决此问题的经典且最根本的方案是一种称为陷入与仿真(trap-and-emulate)的技术,这是硬件与一个名为虚拟机监控程序(hypervisor)或虚拟机监视器(Virtual Machine Monitor, VMM)的控制软件层之间的一场优雅共舞。通过拦截和模拟特权操作,hypervisor 创造出一种无懈可击的控制错觉,构成了现代虚拟化的基石。
本文将深入探讨现代计算的这一基石。第一章“原理与机制”将剖析这一复杂过程,从 CPU 的保护环和关键的 Popek 和 Goldberg 条件,到陷入与仿真周期的性能影响。随后的“应用与跨学科关联”一章将探讨这一强大机制如何支撑着我们日常使用的云计算基础设施、高级网络安全策略,以及复杂的数据中心实时迁移编排等一切。
要在计算机内部构建一个虚拟世界,一个真实到让其居民——客户机操作系统——从未怀疑自己生活在模拟之中的世界,我们需要的不仅仅是巧妙的编程。我们需要成为幻象大师,按我们的意志扭转处理器的法则。实现这一宏大骗局的经典技术被称为陷入与仿真(trap-and-emulate),这是硬件与一种特殊的软件——虚拟机监控程序(hypervisor)或虚拟机监视器(Virtual Machine Monitor, VMM)——之间一场优美的双人舞。让我们揭开帷幕,看看这个魔术是如何上演的。
想象一下,计算机的中央处理器(CPU)不是一块单一的硅片,而是一个治理严谨的王国。这个王国有严格的权力等级。在最高层,至高无上地统治着的是监督者——操作系统内核。它拥有绝对的权威;它可以与硬件对话,为所有程序管理内存,甚至可以叫停整个王国。所有其他程序都只是臣民,即用户。它们生活在一个受保护的空间里,权力受到严格限制。这种分离由 CPU 硬件通过不同的特权级别来强制执行,通常被形象地描绘成同心的保护环。内核生活在最特权的内圈(环 0),而用户应用程序则居住在外围的、特权较低的环(例如,环 3)中。
为何要有这种刻板的结构?为了安全。必须防止有缺陷或恶意的用户应用程序导致整个系统崩溃。它不能被允许涂写内核内存或直接命令磁盘驱动器。如果一个用户程序尝试执行这种被禁止的操作,CPU 硬件不会盲从;它会当场停止这个违规程序,并向监督者发出信号。这种保护性的中断被称为陷入(trap)。
这就引出了虚拟化的根本挑战:一个操作系统,比如 Windows 或 Linux,被设计成一个监督者。它完全期望拥有绝对的控制权。那么,我们如何能将一个客户机操作系统作为“臣民”运行在另一个主机操作系统内部,而它却意识不到自己已被降级呢?答案是,我们必须欺骗它,而欺骗的关键在于控制陷入。
我们将让客户机操作系统在我们的 VMM(真正的监督者)的监视下,以一个较低的特权模式运行。为了让这个戏法成功,我们必须能够拦截客户机为行使其“监督”权力所做的每一次尝试。在 1970 年代,两位计算机科学家 Gerald Popek 和 Robert Goldberg 阐明了一个架构要实现这一点所必须满足的精确条件。
他们定义了两类关键的指令:
特权指令:这些指令如果从非监督模式执行,总是会引发陷入。HALT 是一个经典的例子。如果一个用户程序试图停止机器,它会陷入到内核。
敏感指令:这是一个更广泛的类别。如果一条指令试图改变或查询机器的状态,那么它就是敏感的。这包括特权指令,但也包括其他指令。例如,一条读取当前特权级别的指令是敏感的,因为它的结果取决于机器的状态。
基于这些定义,Popek 和 Goldberg 阐述了他们的黄金法则:对于一个架构而言,要能使用陷入与仿真技术进行经典虚拟化,敏感指令集必须是特权指令集的子集。换句话说,每一条可能揭露骗局或扰乱主机的指令,在客户机试图使用它时都必须引发陷入。
如果一条指令是敏感的但不是特权的,它就会造成一个虚拟化漏洞。客户机可以执行它,而 VMM 却永远不会得到通知。这条指令可能会静默失败,使客户机感到困惑;或者它可能会成功并与真实硬件交互,从而打破虚拟的幻象。假设有一个 CPU,我们称之为 Z-ISA,它有一条 READ_SR 指令,可以读取状态寄存器(包括特权模式)但不会陷入。一个在 Z-ISA 上运行的客户机操作系统会执行 READ_SR 并立即发现自己并不在监督者模式下,游戏就此结束。
当一个架构满足 Popek 和 Goldberg 标准时,VMM 就可以表演它那优雅的双人舞了。
第一步:陷入
客户机操作系统在其非特权模式下愉快地运行着,决定做一些只有监督者才应该做的事情,比如改变内存映射。它执行了这条敏感指令。由于这条指令同时也是特权的,硬件立即行动起来。它不会完成该指令。相反,它会停止客户机,保存其当前状态(寄存器、程序计数器等),并将控制权转移给 VMM。这种从客户机到 VMM 的突然上下文切换通常被称为虚拟机退出(VM exit)。现在,客户机被冻结了,而 VMM 则被唤醒并掌握了控制权。
第二步:仿真
VMM 检查被冻结的客户机的状态,确定它试图做什么。假设客户机试图写入控制寄存器 CR3 以切换到新的地址空间。VMM 不能简单地让它在真实硬件上发生,因为那会扰乱主机。相反,VMM 执行了一次纯粹的模拟。它维护着一套影子页表——一种将客户机的虚拟内存地址转换为主机上实际物理内存地址的数据结构。VMM 更新这些影子页表以反映客户机想要的更改,然后将它自己的影子页表的地址加载到真实的 CR3 寄存器中。现在,硬件正在使用 VMM 精心构建的映射,客户机相信它的命令成功了,幻象得以维持。
这种舞蹈适用于一切。如果客户机试图执行 STI 指令以启用中断,它会陷入。VMM 不会触碰主机的中断标志;那会造成混乱。它只是在一个为客户机维护的软件结构中翻转一个位,一个虚拟中断标志(VIF)。如果一个真实的硬件中断为该客户机到达,VMM 在决定是否向客户机注入一个相应的虚拟中断之前,会检查这个 VIF。VMM 必须是一个完美的模仿者,甚至要复制像 STI 之后的一条指令“中断影子”这样的微妙架构细节,即中断在下一条指令完成之前不会被识别到。VMM 必须为虚拟 CPU 的每个敏感部分维护一个完整的影子状态,从控制寄存器到标志位。
这种在客户机和 VMM 之间的不断切换是一个强大的技巧,但它不是没有代价的。每一次虚拟机退出以及随后返回客户机(一次虚拟机进入)都带有显著的性能成本。CPU 必须保存一个世界的完整上下文并加载另一个世界的上下文。
想象一个简单的程序在一个紧凑的循环中反复请求当前时间。在原生环境下,这是一个非常快速的操作。但在虚拟化下,读取系统时间戳计数器(RDTSC)的指令是敏感的。每次循环执行它时,都会陷入到 VMM。让我们看一些假设但现实的数字。一次原生的 RDTSC 可能需要 个周期。循环中的算术运算可能需要 个周期。但是虚拟机退出和重新进入的过程可能耗费高达 个周期,而 VMM 模拟虚拟计时器的工作可能还会增加 个周期。每次循环迭代的总成本从 个周期飙升到 个周期。程序运行速度慢了超过 倍!。这个例子揭示了一个关键事实:对于陷入密集的工况,主要的开销不是 VMM 的仿真工作,而是陷入本身的上下文切换成本。
我们可以用一个简单而优美的方程来为这种开销建模。如果一个系统在没有陷入时,基准吞吐量为每秒 次操作,那么当它每秒经受 次陷入时,其吞吐量 大约是:
在这里, 是单次陷入与仿真周期的固定时间惩罚。每次陷入实际上都从客户机那里偷走了一小片时间 ,减少了可用于有效工作的时间比例。对于一个典型系统,这个惩罚可能大约是每次陷入 纳秒。
早期虚拟化工作面临的最大问题是,最常见的 CPU 架构 x86 并不是经典可虚拟化的。它充满了虚拟化漏洞——即那些非特权的敏感指令。例如,SIDT 指令,它读取中断表的位置,会直接返回主机的值而不会陷入。客户机操作系统可以利用这一点立即检测到 VMM 的存在。
虚拟化的先驱们没有放弃。他们发明了巧妙的软件变通方法:
半虚拟化 (PV): 这种方法修改客户机操作系统的源代码。有问题的指令被替换为显式的“hypercall”——对 VMM 的直接请求。这种方式效率很高,但需要一个经过专门移植的操作系统。
动态二进制翻译 (DBT): 一种更复杂但更强大的技术。VMM 实时扫描客户机的二进制代码,在一段代码即将执行前,它会动态地重写任何敏感的非特权指令,用能够安全陷入到 VMM 的代码替换它们。这使得未经修改的操作系统也能够被虚拟化,是一项重大突破。
最终,CPU 制造商伸出了援手。他们将对虚拟化的支持直接集成到硬件中。像 Intel VT-x 和 AMD-V 这样的技术修复了架构上的缺陷。它们为客户机引入了一种新的、受限的执行模式(通常称为“非-root 模式”)。在这种模式下,VMM 可以配置硬件,使其在各种敏感事件上引发虚拟机退出,从而有效地关闭了虚拟化漏洞,并使得像 SIDT 这样的指令表现得如同特权指令一般。
有趣的是,在软件(DBT)和硬件辅助虚拟化之间的选择并非总是泾渭分明。DBT 翻译一段代码有很高的一次性成本,但每条指令的开销可以很低。硬件陷入没有设置成本,但虚拟机退出/进入周期的成本相对较高。对于一个敏感指令非常少的工况,硬件辅助是明显的赢家。而对于一个敏感指令很多的工况,DBT 较高的初始成本可能会被其在数百万次指令执行中较低的开销所弥补。
衡量一个 VMM 设计的真正标准,不仅仅是它如何处理预期事件,更是它如何处理意外情况。当 VMM 在处理客户机陷入的过程中,自己也遇到了问题,会发生什么?
想象一下这个场景:一个客户机程序执行了一条导致页错误的指令。这是一个敏感操作,因此它会陷入到 VMM。VMM 开始工作,准备向客户机注入一个虚拟页错误。但在此过程中,VMM 自己的代码试图访问一片它自己尚未分配的内存,导致了一个主机页错误。VMM 在处理一个错误时自己也出错了!
应该发生什么?答案在于虚拟化的基本法则:透明性。客户机必须对其 VMM 的内部挣扎毫不知情。主机操作系统将处理 VMM 的页错误,也许是通过分配所需的内存。一旦 VMM 恢复执行,它必须足够健壮,能够认识到它之前的仿真尝试被打断了。它必须小心地回滚它对客户机虚拟状态所做的任何部分更改,然后从头开始重新注入最初的客户机页错误。从客户机的角度来看,没有发生任何异常;它只是经历了一次单一、干净的页错误,就像在真实硬件上一样。
这就是 hypervisor 的艺术。它就像一位魔术大师,可能在背后失手掉了一张牌,但恢复得如此天衣无缝,以至于观众从未从幻觉中惊醒。通过陷入与仿真这种优美而有原则的舞蹈,VMM 维持着这种完美的幻象,创造出一个稳定、隔离的虚拟世界,由它自己制定的法则所统治。
理解了陷入与仿真的机制之后,我们现在可以退后一步,欣赏它让我们得以构建的广阔且时常令人惊奇的图景。这个原则不仅仅是在一个操作系统内部运行另一个操作系统的巧妙技巧;它是一个基础工具,像一个万能铰链,连接着不同的世界——物理与虚拟,旧与新,安全与敌对。它让 hypervisor 能够成为一个完美的伪造者、一个细致的世界构建者、一个狡猾的优化者和一个宏大的编排者,而所有这一切都只需精通拦截与响应这门简单的艺术。
从本质上讲,虚拟化是一种完美的伪造行为。虚拟机监视器(VMM)必须创造一个如此无懈可击的幻觉,以至于客户机操作系统无法将其与现实区分开来。这并非“差不多就行”的问题;这是一份绝对语义等价的契约。每一条指令、每一个寄存器、每一个标志位的行为都必须与架构手册所规定的完全一致。
想象一下,我们受命去仿真一条单一、简单的特权指令——一条向设备端口写入一个值,或许是更新一个计数器的指令。如果客户机执行这条指令,VMM 会将其陷入。VMM 的职责就是执行一个计算,得出与硬件直接执行时完全相同的最终状态——相同的计数器值,相同的异常标志状态。无论该指令是在特权的内核模式下执行,还是从非特权的用户模式陷入并被仿真,对客户机可见的结果都必须完全相同,精确到最后一位。这种完美模仿的原则是所有其他应用得以建立的基石。没有这种正确性的保证,整个虚拟化的大厦将会崩塌。
凭借完美伪造的力量,hypervisor 可以着手其最宏大的项目:为客户机构建一个完整的、自给自足的宇宙。这个宇宙是一台完整的虚拟计算机,拥有自己的 CPU 和一套自己的外围设备。
一个虚拟 CPU 不仅仅是一串被执行的指令流;它有一个身份,一个它承诺支持的特性集合。考虑一个现代数据中心,一个由不同年代的服务器组成的庞大城市。一个虚拟机可能在一台新服务器上开始其生命,配备了最新的指令集扩展如 。如果我们需要将这个正在运行的虚拟机实时迁移到一台缺少此功能的旧服务器上,会发生什么?如果客户机操作系统认为它拥有 ,当应用程序的指令突然失败时,它可能会戏剧性地崩溃。hypervisor 通过充当架构的守门人来防止这场灾难。它拦截客户机识别其特性的尝试(通过 CPUID 指令),并呈现一个精心策划的、稳定的身份。为了允许在一个池中的任何机器之间安全迁移,hypervisor 只宣告交集——即所有可能的物理主机所共有的特性集。这创造了一个“最小公分母”的虚拟 CPU,它可能不如最先进的主机强大,但它是可靠的,而且至关重要的是,它是可移动的。
当然,一个宇宙需要的不仅仅是 CPU。它还需要设备。在这里,陷入与仿真再次成为关键。一个未经修改的客户机操作系统期望直接与硬件对话,使用传统的基于端口的 I/O(通过 IN 和 OUT 指令)或现代的内存映射 I/O(MMIO)。hypervisor 配置硬件以陷入任何此类访问。对于端口 I/O,它使用像 I/O 权限位图这样的机制。对于 MMIO,它在嵌套页表中将相应的内存页面标记为“不存在”,从而导致错误。当陷入发生时,VMM介入。它解码客户机的请求——它是在尝试从虚拟网卡读取数据,还是在向虚拟磁盘的配置寄存器写入数据?——然后仿真该虚拟设备的行为,同时保持与物理硬件和其他虚拟机的完全隔离。这正是允许一千个虚拟机,每个都拥有自己私有的一套“硬件”,安全地运行在单一物理服务器上的魔力,构成了云计算的根本基础。
如果每个特权操作都需要一次陷入,我们的虚拟世界将会慢得令人痛苦。一次陷入是一次“跨世界”事件,一次从客户机宇宙到 hypervisor 宇宙的完整上下文切换,它带来了巨大的开销,通常是数千个处理器周期。因此,虚拟化的真正艺术不仅在于陷入,还在于知道如何智能地陷入——以及如何完全避免它。
一种方法是更聪明地决定为何陷入。与其陷入许多微小的、独立的操作,一个半虚拟化的客户机可以与 hypervisor 合作。它可以将一系列请求打包成一个单一的、显式的 hypercall。虽然单个 hypercall 的开销可能高于单次陷入,但这个成本被分摊到了所有批处理的操作上。对于足够大的批处理大小,这种合作显著降低了总的转换开销,使系统效率大大提高。
一个更好的方法是首先就消除陷入的需要。这是一段软件与硬件之间优美共舞的历史。早期的 VMM 必须陷入对敏感状态的每一次访问,比如页表基址寄存器(CR3),以维持幻象。这很慢。观察到这一点,像 Intel 和 AMD 这样的公司的硬件设计师们引入了新的特性,比如扩展页表(EPT),它允许硬件本身管理两个层次的地址转换(客户机虚拟地址到客户机物理地址,以及客户机物理地址到主机物理地址)。有了这种硬件辅助,客户机可以直接读取自己的 寄存器而无需陷入,因为硬件已经参与到这个秘密中来了。陷入变得非必要,性能也随之飙升。
然而,有时,一次有针对性的陷入是最优雅的解决方案。考虑同一虚拟机的两个虚拟 CPU 运行在同一个物理核心上。一个 VCPU 获取了一个自旋锁,然后被 hypervisor 抢占。第二个 VCPU 被调度并开始自旋,徒劳地燃烧周期试图获取一个无法被释放的锁。这是经典的“锁持有者抢占”问题。一个粗暴的解决方案是陷入每一条 lock 指令,但这会慢得离谱。一个更优美的解决方案通过另一次软硬件协作应运而生。客户机的自旋锁代码在其循环中使用 pause 指令作为它正在等待的提示。现代 CPU 可以检测到 pause 指令的紧密循环,并在达到某个阈值后,触发一个名为“Pause 循环退出”的特殊虚拟机退出。这次陷入智能地通知 hypervisor:“这个 VCPU 正在进行无谓的自旋。” hypervisor 随后可以明智地取消调度这个自旋者,并将 CPU 时间交还给锁的持有者,从而以外科手术般的精度解决争用问题。
凭借对陷入与仿真的深刻理解,我们可以实现近乎巫术的壮举,推动进入新的学科领域,并将虚拟化平台转变为一个强大的研究和安全工具。
如果我们想在一个 hypervisor 内部……运行另一个 hypervisor 呢?这就是令人费解的嵌套虚拟化世界。一个 L0 hypervisor 运行一个 L1 客户机 hypervisor,后者又运行一个 L2 客户机。假设在 L2 客户机中发生了一个被配置为由 L0 拦截的异常。L0 hypervisor 捕获了这个事件。为了保持隐身,L0 不能自己处理这个异常。相反,它必须将异常“反射”给 L1。它通过暂停 L1 并小心地修改其虚拟状态——设置其虚拟异常程序计数器和状态字——使其看起来好像是硬件刚刚直接从 L2 客户机向 L1 传递了一个陷入。这是陷入与仿真的递归应用,一场戏中戏,其中 L0 是总舞台监督,向内层戏剧的导演 L1 提供提示。
在实时迁移期间,hypervisor 作为宏大编排者的角色从未如此明显。想象一个客户机操作系统发出一条 WBINVD 指令,强制将其所有数据从缓存刷到主内存,以确保与设备的一致性。如果这发生在实时迁移期间,VMM 必须执行一场令人难以置信的协同动作交响曲。它陷入该指令并暂停虚拟机的所有 vCPU。然后它将相关数据从主机 CPU 缓存刷新到主机内存。它静默仿真设备以确保其内存视图是一致的。至关重要的是,它在迁移流中插入一个屏障,强制所有在此之前被弄脏的内存都必须发送到目的地,然后才允许客户机恢复。一条被陷入的指令成为了指挥棒,确保一个一致的状态在时间和空间上得以保持。
也许最激动人心的应用在于网络安全的持续军备竞赛中。恶意软件作者知道他们的作品将在虚拟机中被分析,因此开发了复杂的技巧来检测这种幻象。他们检查 CPUID 结果中的 hypervisor 指纹,测量 RDTSC 的时间以检测虚拟化开销,并寻找虚拟设备的供应商 ID。安全分析师的工作是创建一个完美到无法被检测到的虚拟机。使用 Type-1(裸金属)hypervisor,分析师将陷入与仿真作为他们的主要武器。他们配置 VMM 来谎报 CPUID,通过将 vCPU 钉在物理核心上来呈现一个完美稳定和低延迟的时间戳计数器,使用 IOMMU 直通真实的物理设备,并清理虚拟 BIOS 中的任何泄密信息。在这里,陷入与仿真不仅仅是用于整合或移动的工具,而是在一场高风险的数字战场上的盾牌和欺骗手段。
从确保单条指令的简单正确性,到编排数据中心的迁移,再到与无形的网络威胁作战,陷入与仿真的原则揭示了自己是现代计算中最强大、用途最广泛的思想之一。它证明了创造一个完美幻象的简单行为,如何能赋予我们构建新世界的力量。