
虚拟化是现代计算的基石,从大型云数据中心到我们笔记本电脑上的开发环境,无处不在。但是,一个认为自己完全控制硬件的完整操作系统(OS),如何能像一个普通应用程序一样在另一个操作系统内部运行呢?这在 CPU 严格的特权层级结构中产生了一个根本性冲突,因为只能有一个真正的“内核”进行统治。本文将揭开其神秘面紗,通过阐述一个优雅的计算机科学原理——陷阱与模拟(trap-and-emulate),来解决客户机操作系统被降权(de-privileged)的挑战。
在接下来的章节中,您将深入理解这一基础概念。首先,在“原理与机制”部分,我们将剖析 Hypervisor 如何拦截和模拟特权操作,探讨实现这一点的架构要求,并分析制造这种假象的性能成本。然后,在“应用与跨学科联系”部分,我们将看到这单一机制如何催生了众多技术,从为恶意软件分析创建安全沙箱,到在虚拟机内部运行虚拟机的令人费解的现实。让我们从探索维持客户机操作系统权力假象的核心原理开始。
要理解虚拟化的魔力,我们必须首先领会现代计算的一个基本概念:并非所有软件生而平等。计算机的中央处理器(CPU)就像一个拥有严格权力等级的王国,通常被形象地描绘为一系列同心圆环。在最中心的、特权最高的“环 0”(Ring 0)中,坐镇着操作系统(OS)内核。它是绝对的君主,完全控制着王国最宝贵的资源:内存、设备以及 CPU 自身的内部状态。所有其他程序,例如您的网页浏览器或文本编辑器,都生活在外部的、特权较低的“环 3”(Ring 3)中。它们是臣民,几乎所有重要操作都必须请求内核的许可。只能在环 0 中执行的指令被称为特权指令。如果一个环 3 的应用程序试图执行一条特权指令,它不会成功;相反,硬件会发出警报——一个陷阱(trap)——立即将控制权转移给操作系统内核,由内核来决定如何处理。
这种保护机制是稳定系统的基石。但它也带来了一个有趣的难题:我们如何在一个操作系统内部运行一个客户机操作系统,而这个客户机本身也认为自己是君主?我们不能简单地让客户机操作系统在环 0 中运行,因为这会与宿主机操作系统冲突。最直接的想法是“降权”客户机操作系统,比如让它在中间的“环 1”(Ring 1)中运行。但现在,每当客户机操作系统试图执行一条特权指令时——比如与设备通信或管理内存——它就会触发陷阱。绝对权力的假象被打破了。真的如此吗?
这正是计算机科学中最优雅的思想之一发挥作用的地方:陷阱与模拟(trap-and-emulate)。我们不让陷阱成为一个错误,而是把它变成一个机会。来自客户机操作系统的陷阱被一个运行在真正环 0 的特殊程序所拦截:虚拟机监视器(VMM),即 Hypervisor。VMM 是王座背后真正的权力,是 CPU 王朝中一位狡猾的外交官。
当客户机操作系统试图执行特权操作并触发陷阱时,VMM 会捕获它。这就是陷阱(trap)阶段。然后,VMM 检查客户机试图做什么。它是在尝试禁用中断?访问特定的硬件端口?更改内存映射?VMM 的任务现在是代表客户机执行一个等效的操作,但方式是安全可控的。它操作的不是真实硬件,而是 VMM 在软件中维护的一个虚拟版本的硬件。这就是模拟(emulate)阶段。
想象一个客户机操作系统想通过写入一个特定的 I/O 端口来向一个虚拟计数器设备发送一个值。在真实硬件上,这将是一条特权 OUT 指令。在我们的虚拟化世界中,运行在较低特权级别的客户机执行了 OUT 指令。CPU 陷入(trap)到 VMM。VMM 看到客户机想要将值 写入端口 。于是,它更新自己内部代表虚拟计数器状态的软件变量,就像真实硬件会做的那样,然后无缝地将控制权返回给客户机。从客户机的角度来看,指令完美成功;它完全没有意识到这位“外交官”的干预。这就是陷阱与模拟的精髓:确保原生执行与虚拟化执行之间的语义等价性。
为了让这场优雅的舞蹈得以进行,必须满足一个关键条件。每一条可能破坏虚拟化(通过暴露宿主机状态或对其进行干扰)的指令,在客户机执行时都必须引发陷阱。在他们 1974 年的开创性论文中,Gerald Popek 和 Robert Goldberg 将此形式化。他们将敏感指令(sensitive instruction)定义为与机器资源(如控制寄存器或中断设置)状态交互或读取其状态的指令。他们将特权指令(privileged instruction)定义为如果不在环 0 运行就会触发陷阱的指令。一个架构要成为“经典可虚拟化”的“黄金法则”很简单:敏感指令集必须是特权指令集的子集。换句话说,每一个敏感操作都必须可靠地触发陷阱。
多年来,流行的 x86 架构——我们大多数计算机所使用的架构——在这个基础上存在裂缝。它包含了一些敏感但非特权的指令。一个典型的例子是 SIDT 指令,它读取中断描述符表寄存器(IDTR)的位置,这是一个关键的操作系统结构。当被降权的客户机操作系统执行时,这条指令不会触发陷阱;它会直接执行并返回宿主机而非客户机的 IDTR!面具滑落,客户机看到了其操纵者的面目。这个“虚拟化漏洞”意味着纯粹、简单的陷阱与模拟是不可行的。
为了解决这个问题,先驱们开发了一种极其聪明但复杂的技术,称为动态二进制翻译(BT)。VMM 会像一个一丝不苟的实时编辑器,在客户机代码运行前对其进行扫描。当发现这些有问题的敏感但非特权的指令时,它会动态地重写它们,将其替换为一个安全的代码序列,该序列会显式调用 VMM 以获取正确的虚拟值。这是一项里程碑式的软件成就,但它也伴随着代价。
制造这些假象,无论是通过陷阱还是二进制翻译,都不是没有成本的。虚拟化会带来开销。
陷阱与模拟的成本集中在陷阱本身。一次虚拟机退出(VM exit,从客户机到 VMM 的陷阱)和一次虚拟机进入(VM entry,返回到客户机)都是重量级操作。CPU 必须保存客户机的完整上下文并加载 VMM 的上下文,反之亦然。考虑一条简单的指令,如 RDTSC,它读取 CPU 的高精度时间戳计数器。在原生环境下,它可能只需要 25 个时钟周期。但如果 VMM 为了提供虚拟化的时间感而捕获这条指令,这个过程可能会慢得惊人。虚拟机退出/进入可能耗费 1500 个周期,VMM 模拟计时器的工作又需要 200 个周期。那条 25 个周期的指令现在膨胀到了 1700 个周期——单次操作的性能下降了近 70 倍!对于一个在紧密循环中反复调用 RDTSC 的程序来说,整体性能可能会急剧下降。主要成本并非模拟工作本身,而是跨越客户机和宿主机边界的巨大开销。
另一方面,二进制翻译具有不同的成本模型。它涉及一个巨大的前期翻译开销()来分析和重写代码块。然而,一旦翻译完成,模拟操作的每指令开销()通常远低于陷阱与模拟的开销()。这就产生了一个有趣的权衡。如果一个程序执行的敏感指令非常少,那么二进制翻译的高昂固定成本就不值得;陷阱更便宜。但对于一个包含大量敏感指令的工作负载,一次性的二进制翻译成本很快就会被较低的每指令成本摊销,使其从长远来看成为更快的选择。存在一个盈亏平衡点,即一个特定的敏感指令频率,在该频率下两种方法的性能相等。
虚拟化漏洞的挑战和纯软件解决方案的性能权衡,促使 CPU 架构发生了根本性变化。Intel 和 AMD 推出了硬件扩展(分别为 VT-x 和 SVM),这些扩展从一开始就是为支持虚拟化而设计的。
这些扩展不仅仅是修补了旧的特权环系统。它们引入了一个新的、更强大的特权维度:根模式(root mode)与非根模式(non-root mode)。VMM 运行在全能的根模式下。客户机操作系统及其应用程序则运行在非根模式下,该模式拥有自己的一套环 0 到环 3。真正的魔力在于,根模式下的 VMM 获得了一个控制面板(VMCS 或 VMCB),可以在其中极其精细地指定哪些客户机操作应触发虚拟机退出(VM exit)。
至关重要的是,这允许 VMM 配置 CPU 对那些先前有问题的敏感但非特权的指令(如 SIDT)进行陷阱。虚拟化漏洞最终被硬件填补。这使得陷阱与模拟模型变得健壮、简洁且效率更高,很大程度上消除了 CPU 虚拟化对复杂二进制翻译的需求。此外,这些扩展还为虚拟化的其他方面提供了加速,例如内存管理(如扩展页表),这使得某些指令(如读取 页表寄存器)可以完全不需虚拟机退出就能执行,在某些情况下提供了接近原生的性能。
有了一个健壮的陷阱机制后,VMM 的主要挑战就变成了等式中的“模拟”部分。而完美的模拟是一门艺术,需要对机器最深层的秘密给予细致入微的关注。
内存虚拟化: 客户机操作系统如何管理自己的虚拟内存,相信自己控制着页表,却永远看不到宿主机的物理内存?在硬件辅助出现之前,VMM 使用一种称为影子页表(shadow page tables)的技术。VMM 将客户机的页表(将客户机虚拟地址映射到客户机物理地址)保存在内存中,但将其标记为只读。然后,VMM 创建一个独立的影子页表,该页表将客户机虚拟地址直接映射到宿主机物理地址。这个影子页表是实际硬件 MMU 使用的。当客户机试图更改其页表时,会触发一个写保护故障(一个陷阱!)。VMM 捕获此故障,按要求更新客户机的页表,然后将该更改传播到其秘密的影子页表中。这种精巧的欺骗确保了隔离性和正确性。
时间和中断虚拟化: 模拟不仅仅是得到正确的结果,还要确保正确的时序。在 x86 上,用于启用中断的 STI 指令有一个奇特的特性:中断实际上要等到下一条指令执行完毕后才会被启用。这被称为“中断影子”。VMM 不能简单地翻转一个虚拟的“中断开启”开关。它必须精确地模拟这个单条指令的延迟,或许可以通过设置一个虚拟标志,在下一条指令边界后进行倒计时,然后才向客户机注入一个挂起的虚拟中断。
I/O 虚拟化: 设备主要通过两种方式通信:通过使用 IN 和 OUT 等指令的特殊 I/O 端口,或者通过内存映射 I/O (MMIO),其中设备寄存器显示为内存地址。VMM 必须拦截这两种方式。对于端口 I/O,它配置 CPU 在任何 IN/OUT 指令上触发陷阱。对于 MMIO,它利用其对内存映射的控制(例如,通过扩展页表)将与虚拟设备对应的内存区域标记为“不存在”。客户机任何访问该内存的尝试都会导致页错误,这同样会陷入 VMM。在这两种情况下,陷阱都允许 VMM介入并模拟虚拟设备的行为。这显示了陷阱与模拟模型的统一性,即使用不同的硬件触发器来拦截对不同类别资源的访问。
VMM 是整个虚拟机赖以存在的基石。它必须永不失效。但是,如果 VMM 本身在处理客户机陷阱的过程中遇到故障会发生什么?例如,VMM 可能需要访问一个已被换出到磁盘的数据结构,从而导致宿主机级别的页错误。
这种“嵌套故障”场景必须极其小心地处理。宿主机级别的故障是 VMM 的一个实现细节;它对客户机是完全不可见且无意义的。在任何情况下,VMM 都不能将这个内部问题暴露给客户机。正确且唯一正确的行为是 VMM 透明地处理自己的故障。在宿主机操作系统解决了 VMM 的内部故障后,VMM 必须回滚它对客户机状态所做的任何部分的、不完整的更改,并从头开始重新执行模拟。从客户机的角度来看,原始指令产生了一个单一的、原子的、架构上正确的结果,丝毫没有察觉到其宿主机内部发生的动荡。VMM 必须像一个完美的事务系统一样行事,确保每个模拟的客户机操作都是“要么全做,要么全不做”的。这是构建虚拟世界所需健壮性的最终证明。
正如我们所见,“陷阱与模拟”的原理是一种精妙的幻术艺术。这是一个简单而深刻的思想:让一个客户机程序自由运行,直到它尝试做一些“敏感”的事情,然后捕获它,暂停它的世界,让一个更高级别的力量——Hypervisor——介入来模拟预期的效果。但这个简单的机制绝非小把戏。它是一项基础技术,开启了从云计算的基石到网络安全的前沿,乃至我们对“机器”概念边界探索的广阔应用前景。
在我们探索这些应用时,记住总是有权衡是很有用的。每一次陷阱都是一次中断,是虚拟机现实结构中的一次瞬间撕裂,会产生性能成本。现代虚拟化的大部分天才之处在于最小化这些陷阱。在一些被称为半虚拟化系统(paravirtualized systems)中,客户机操作系统被修改以进行合作,用对 Hypervisor 的显式“hypercall”来替换敏感指令,就像一个礼貌的访客在尝试一扇锁着的门之前先请求许可一样。相比之下,硬件辅助虚拟化(HVM)依赖 CPU 本身来检测客户机何时越界,并自动触发陷阱。这使得未经修改的操作系统,从现代 Linux 到旧版 Windows,都可以被虚拟化。我们的旅程将聚焦于这个充满自动陷阱的迷人世界,在这里,Hypervisor 必须为那些甚至不知道自己身处舞台之上的客户机扮演一位魔术大师。
“陷阱与模拟”的核心是创建一个令人信服的物理机复制品。这种幻象必须完美无瑕,直至处理器状态最晦涩的细节。以处理器的状态寄存器为例,在 x86 系统上通常称为 EFLAGS。它包含一组控制机器最基本行为的位。
其中之一是中断标志(Interrupt Flag),即 。当此标志置位时,CPU 会响应外部中断——来自键盘、网卡、硬盘的信号。当此标志清零时,CPU 会忽略它们。一个相信自己拥有完全控制权的客户机操作系统会频繁地操作这个标志。但如果允许客户机直接改变宿主机 CPU 上的物理 会发生什么?它可能会为整台机器禁用中断,实际上使 Hypervisor 和任何其他虚拟机“失聪”。整个系统将陷入停顿。
这是不允许的。解决方案是一场漂亮的骗局。Hypervisor 配置硬件以捕获任何试图修改 的客户机指令,例如 CLI、STI 或 POPF。在客户机运行时,Hypervisor 始终保持 CPU 上的物理 标志处于关闭状态,确保自己永远不会“失聪”。同时,在一块私有内存中,它为客户机维护一个标志寄存器的虚拟或影子副本。当客户机尝试设置其 时,指令触发陷阱。Hypervisor 捕获陷阱,翻转客户机影子寄存器中的相应位,然后恢复客户机。当客户机尝试读取其标志时,Hypervisor 同样会捕获该操作,并向其提供来自影子寄存器的值。客户机对此非常满意,生活在一个其 EFLAGS 寄存器行为完全符合预期的世界里,完全不知道它的现实是一个被精心管理的软件构造。
这个原理延伸到 CPU 的其他数十个角落。现代处理器有数百个模型特定寄存器(MSR),它们控制着从电源管理到性能监控和高级功能的方方面面。Hypervisor 必须扮演一个一丝不苟的守门人角色。对于每个 MSR,它都必须做出选择:这个寄存器的状态对宿主机至关重要,还是对客户机而言是无害的局部状态?
EFER),是极其敏感的。客户机的写操作必须被捕获,并针对一个虚拟的 EFER 进行模拟,以防止它(例如)关闭宿主机所依赖的功能。FS/GS 基地址,只影响那一个客户机线程。捕获它会很浪费。Hypervisor 可以配置硬件让客户机直接修改它,从而节省宝贵的时钟周期。TSC)的 MSR 是一个特例。如果客户机可以读取宿主机的真实时钟,它可能会在被 Hypervisor 暂停和恢复时注意到时间的奇怪跳跃,从而打破连续执行的幻象。但捕获每一次时钟读取——一个非常普遍的操作——将是一场性能灾难。因此,现代 CPU 提供了一个巧妙的折衷方案:TSC 偏移量。Hypervisor 告诉硬件:“每当客户机请求时间时,给它真实时间加上这个偏移量。” 硬件以全速执行此操作,无需陷阱,并且 Hypervisor 可以在每次客户机暂停时调整偏移量,以创造一个平滑、不间断的时间线。对 TSC 的写操作(较为罕见)仍然会被捕获。这种逐个寄存器进行的仔细分类,是在完美隔离和接近原生性能之间不断的平衡,是整个虚拟化工程学科的一个缩影。
这个精巧的幻象并非没有代价。每一次陷阱,每一次 Hypervisor 的干预,都需要时间。为了对此有一个直观的感受,想象一下我们试图在软件中模拟一个旧的硬件特性,比如内存分段。在一个采用平坦内存模型的原生系统上,一次内存访问是一个单一操作。为了模拟分段,我们必须在每次访问前插入一个软件检查:if (offset > limit) trap; else physical_address = base + offset;。那个简单的检查——一次加载、一次比较、一次分支——给每次内存操作增加了一个虽小但固定的开销。如果检查失败,这个“陷阱”就是调用一个软件例程,其成本要高得多。
这正是在虚拟机中发生的情况。一条触发陷阱的客户机指令可能会引发成百上千条 Hypervisor 指令的连锁反应。对于大多数指令来说,这无关紧要,因为它们直接在硬件上运行。但当一条被捕获的指令位于一个紧密循环中时,性能损失可能是灾难性的。
在自旋锁(spin lock)这样的并发原语上,这一点表现得尤为明显。操作系统使用自旋锁来保护共享资源。希望访问该资源的线程在一个紧密循环中“自旋”,用一条极其快速的原子指令(如 Test-And-Set)反复尝试获取锁。在裸机上,如果锁的持有时间很短,这是高效的。
在虚拟机中,这可能导致灾难。如果 Hypervisor 必须捕获该原子指令,那个快速的、单周期的操作就会膨胀成一个缓慢的、数千周期的模拟。现在考虑一个云计算中的常见场景:虚拟 CPU(VCPU)的数量多于物理 CPU 核心。想象一个 VCPU,我们称之为 ,它获取了一个自旋锁,然后被抢占了——它的时间片结束了,Hypervisor 在同一个物理核心上调度了另一个 VCPU,。 现在尝试获取同一个锁。它开始自旋。但锁的持有者 正在“睡眠”!它无法释放锁。而 将耗尽其整个时间片来执行成本极高的、被捕获的自旋尝试,一无所获。这种病态现象被称为锁持有者抢占(lock-holder preemption),它能让系统瘫痪。
解决方案是硬件和软件之间另一次漂亮的协作。现代操作系统是“有礼貌的”。当它们自旋时,会在循环中插入一条特殊的 PAUSE 指令。这条指令是给处理器的一个提示,表明它正处于一个自旋循环中。Hypervisor 可以通过一个名为暂停循环退出(Pause Loop Exiting, PLE)的特性来利用这个提示。Hypervisor 告诉 CPU:“如果你看到一个客户机连续执行了几千次 PAUSE,它显然是卡在自旋里了。向我陷阱。” 当陷阱发生时,Hypervisor 就很有把握地知道这个 VCPU 正在等待一个锁。明智的做法是不再在它身上浪费时间。Hypervisor 可以立即让这个自旋的 VCPU 进入休眠状态,并调度另一个——最好是那个持有锁的 VCPU,这样它就可以完成工作并释放锁。这将一个性能噩梦转变为一场智能的、合作的舞蹈,一切都由陷阱与模拟机制来协调。
拦截任何客户机操作的能力赋予了 Hypervisor 上帝般的视角。这种能力不仅可以用来制造幻象,还可以用来观察、控制和保护。
这是现代恶意软件分析的基础。安全研究人员需要执行一个恶意程序来观察其行为,但必须在一个它无法造成伤害的“沙箱”中进行。虚拟机是完美的沙箱。问题是什么?恶意软件作者知道这一点。复杂的恶意软件通常包含反虚拟机检查,以检测自己是否正在被分析。它可能会:
CPUID 指令来查找 Hypervisor 的签名。反过来,Hypervisor 可以参与一场猫鼠游戏。它使用陷阱与模拟作为其盾牌。当恶意软件调用 CPUID 时,Hypervisor 捕获它并返回伪造的数据,使其看起来像一个真实的处理器。它使用硬件辅助来呈现一个平滑、一致的时钟。它甚至可以使用 I/O 虚拟化(IOMMU)将物理网卡或显卡直接透传给客户机,使硬件环境看起来完全真实。在这种对抗性背景下,陷阱与模拟的目标是创造一个如此完美的幻象,以至于即使是怀有敌意的观察者也无法将其与现实区分开来。
同样,这种拦截能力对软件开发者来说是一份礼物。调试一个复杂的操作系统内核是出了名的困难。但通过在虚拟机中运行操作系统,开发者可以利用 Hypervisor 作为终极调试器。当开发者在客户机内核中设置一个断点时,他们实际上是在告诉 Hypervisor 监视特定地址的执行。Hypervisor 不需要修改客户机。当客户机的执行到达该地址时,它会触发陷阱。Hypervisor 随后可以冻结客户机的所有状态——所有的寄存器、所有的内存——以供检查。它甚至可以模拟像软件断点(INT 3)这样的事件,捕获客户机调用其自身调试器的尝试,并以与真实硬件无法区分的方式小心地注入异常,同时保持宿主机和客户机调试状态的完美隔离。
我们已经看到 Hypervisor 作为幻术大师、性能工程师和安全哨兵。但是,如果我们将陷阱与模拟的原理推向其逻辑极限会怎样?如果我们正在虚拟化的客户机操作系统本身就是一个 Hypervisor 呢?这就是令人费解的嵌套虚拟化(nested virtualization)概念。
想象一个顶层 Hypervisor,,运行一个本身也是 Hypervisor 的客户机,。而 Hypervisor 又想运行它自己的客户机,。当 尝试启动时,它会执行开启 CPU 虚拟化硬件的指令(例如,在 Intel CPU 上的 VMXON)。但是,该硬件已经被 占用了!只有一个真正的“根”虚拟化模式。
解决方案是陷阱与模拟的终极体现。 配置硬件以捕获 执行 VMXON 的尝试。捕获后, 不会失败。相反,它开始为 模拟整个虚拟化架构。它为 创建一个虚拟的虚拟机控制结构(VMCS)。 随后执行的每一条虚拟化指令——用于配置其 客户机、启动它、处理其退出——也都会被捕获。对于每一次陷阱, 都会拦截指令,解码 试图做什么,并在虚拟 VMCS 和 的虚拟状态上模拟该效果。
其复杂性是惊人的。例如,如果在 客户机中发生了一个本应由 处理的异常,该事件首先会被 拦截。然后, 必须执行一次“虚拟异常反射”。它必须暂停,小心地修改 客户机的保存状态,使其看起来像是刚刚从其 客户机接收到了一个硬件异常,然后在 的异常处理程序入口点恢复 。它正在为一个其全部工作就是构建虚拟现实的程序构建和管理一个虚拟现实。
从一个简单的原理——陷阱与模拟——我们构建了世界中的世界。我们制造了工具来驯服最复杂的软件,研究最恶意的程序。我们在完美的幻象和完美的性能之间的根本性张力中搏斗。这一个思想已成为现代计算的基石,证明了抽象的力量以及隐藏在我们机器架构中那份宁静、优雅之美。