2. CPU虚拟化

0 概述

对于传统的虚拟化漏洞, 在硬件设计对此问题改进前, 一些模拟技术已经先被使用来弥补这个漏洞, 提供平台虚拟化的能力. 可以说, 基于软件的CPU完全虚拟化, 其本质就是软件模拟. 所有的虚拟化的形式都可以用模拟来实现, 模拟的强大之处在于, VMM可以将虚拟机的整个执行过程置于控制中, VMM执行每一条指令都有时机进行模拟, 进而不会漏过需要模拟的敏感指令.

模拟技术早在现代虚拟化诞生之前就存在. 使用模拟器, 人们可以在一种平台上运行另一种平台的应用程序或操作系统, 例如DEC开发的FX!32能在ALPHA平台运行x86平台编译的应用程序. 图4-1就是一个模拟器架构图.

2. CPU虚拟化

模拟技术不仅能用于应用程序级模拟, 而且可以用于系统级模拟. 它既能用于不同硬件体系结构间的模拟, 更可以用于相同硬件体系结构的模拟, 只不过在相同硬件体系结构下情况更简单, 这使得产生一些改进技术以提高虚拟化的性能.

1 解释执行

在模拟技术中, 最简单最直接的模拟技术是解释执行, 即取一条指令, 模拟出这条指令执行的效果, 再继续取下一条指令, 周而复始. 由于是一条一条取指令而不会漏掉没一条指令, 在某种程度上即每条指令都"陷入"了, 所以解决了陷入再模拟的问题, 进而避免了虚拟化漏洞. 这种方法不仅适用于模拟与物理机相同体系结构的虚拟机, 而且也适用于模拟与物理机不同体系结构的虚拟机.

图4-2(a)所示为代码以正常执行的方式运行, 图4-2(b)为虚拟机的代码以解释执行的方式运行. 图中灰色部分表示会被载入物理CPU执行的代码, 白色部分表示不会被载入物理CPU执行的代码.

  • 正常执行的方式就是最常见的直接在物理CPU上运行编译好的代码;
  • 而在解释执行中, 编译好的二进制代码是不会被载入到物理CPU直接运行的, 而是由解释器逐条解码, 再调用对应的函数模拟对应指令的功能.

2. CPU虚拟化

虽然这种方法保证了所有指令执行受到VMM的监视控制, 然而它对每条指令不会区分对待, 最大缺点就是性能太差. 由于这里所说的虚拟化前提是模拟与物理机相同体系结构的虚拟机, 那么至少有很多非敏感指令不用模拟而可以直接在物理CPU上运行, 这便诞生了以下两种改进技术.

2 改进技术一: 扫描与修补(Scan-and-patch)

由于解释执行有很大的性能损失, 加上虚拟机中模拟的CPU和物理CPU体系结构相同, 这样多数指令可以被映射到物理CPU上直接运行, 因此, CPU虚拟化过程中可以采用更优化的模拟技术来弥补虚拟化漏洞.

扫描和修补技术通过这样的方式, 让大多数指令直接运行在物理CPU上, 而把操作系统代码中的敏感指令替换为 跳转指令!!!会陷入到VMM中去的指令!!!, 使其一旦运行到敏感指令处控制流就会进入VMM中, 由VMM代为模拟执行.

扫描与修补技术的流程如下.

VMM会在虚拟机开始执行每段代码之前对其进行扫描, 解析每一条指令!!!, 查找到特权指令敏感指令.

补丁代码!!! 会在VMM中动态生成, 通常每个需要修补的指令会对应一块补丁代码.

敏感指令被替换成一个外跳转(没有特权级切换!!!), 从虚拟机跳转到VMM的空间中, 在VMM中执行动态生成的补丁代码.

⓸ 当补丁代码执行完后, 执行流再跳转回虚拟机中的下一条代码继续执行.

需要注意, 在补丁比被修补的指令长的时候, 需要使用更巧妙的方法来完成修补. 例如, 在x86-32中, 一个外跳转指令占5个字节!!!, 比有些特权或敏感指令长. 一个解决方法就是使用更短的能够引起陷入的指令, 例如INT 3指令等. 在陷入后, VMM陷入发生的地址查表找出对应的原指令, 然后进行模拟. 与外跳转不同, 陷入会引起特权级切换!!!, 因而性能开销更大.

图4-3是一个从VirtualBox的实际代码中提取出来的补丁代码例子, 它对应的是Intel IA32关闭中断指令CLI, 其中一些非相关的代码已经略去.

2. CPU虚拟化

可以将这段补丁代码看作一个模板,

扫描与修补原理示意图如图4-4.

在图4-4中, 灰色部分仍然表示会被载入物理CPU执行的代码, 白色表示不会被载入物理CPU执行的代码. 除了一些敏感指令被VMM替换成了外跳转外, 其它指令都能直接被物理CPU载入运行. 对于那些被打上补丁的地方, 外跳转将执行流转到了对应的补丁代码块, 从而模拟该指令的功能. 执行监控模块负责动态将要执行的原代码块进行扫描, 找到需要打补丁的地方打补丁, 并生成相应的补丁代码块.

2. CPU虚拟化

值得一提的是, 补丁代码块存放在VMM内存空间代码缓存中. 由于缓存容量有限, 所以随着虚拟机的运行, 缓存会被填满, 有些补丁代码块可能会被逐出缓存. 所以VMM中会记录一个PC到补丁代码块的对应关系(下面称为PC-补丁代码对). 当补丁代码块生成的时候, VMM会记录下这个PC-补丁代码对; 当补丁代码被逐出缓存时, 这个PC-补丁代码对也会从相应记录中删除. 这样, VMM只需要查找记录就能知道哪些PC对应的指令已经生成过补丁代码了, 并且这些补丁代码块现在还存在代码缓存中.

在扫描与修补技术中, 异常的处理也相对比较简单. 由于指令

扫描与修补技术实现相对简单, 在扫描与修补技术中, 大多数客户机操作系统和用户代码可以直接在物理CPU上运行, 其性能损失也相对较小. 当然, 扫描与修补技术也有其缺点.

⓵ 由于特权指令和敏感指令都被模拟执行, 各条指令的模拟执行时间可能会很短, 但也可能会很长.

⓶ 由于每个补丁都引入了额外的跳转, 这些跳转回降低代码的局部性.

⓷ 由于扫描与修补技术直接在虚拟机内存中进行代码修补, 其必须维护一份与补丁对应的原始代码的备份, 以便在需要时将代码恢复原状.

3 改进技术二: 二进制代码翻译(BT)

为更好提升性能, 更为复杂的代码缓冲区技术也被用到了模拟技术中. 二进制代码翻译技术(BT技术)在VMM中开辟一块代码缓存, 将代码翻译好放在其中. 这样, 客户机OS代码不会直接被物理CPU执行, 所有要被执行的代码都会在代码缓存中. 相比较而言, BT技术最为复杂, 其在性能上同 "扫描与修补技术" 各有长短.

3.1 基本概念

首先, 先了解一些基本概念.

编译理论中, 基本块是一个重要的概念, 它表示只有一个入口和一个出口的代码块, 即在这块代码只能从头进入, 从尾退出. 既不会有外界跳转跳入到代码块中间的某个地方, 也不会有代码块中间某个地方有外界跳转跳出改代码块. 这里, 基本块可以认为是静态基本块.

BT技术的动态翻译也是以基本块为单位的, 称为动态基本块.

与编译器不同的是, 编译器静态能够得到的源代码信息不包含在编译生成的二进制代码!!!中的, 因而在运行时无法获得这种源代码信息的. 例如, 源代码中的跳转标签基本块分析会被作为划分基本块的分界, 因为标签所在位置是一个可能的调整入口.

但是, 在动态运行时, 二进制代码中不包含这种信息的, 所以, 动态划分基本块时能准确找到出口, 但会遗漏一些入口!!!. 基于这种原因, 动态基本块可能会比静态基本块要大一些.

例如, Fedora 6系统在QEMU-0.9.1上启动时, 整个过程进行了1 322 514次基本块翻译, 在VMware上启动和关闭64位的Windows XP专业版共翻译了 259 936个基本块.

BT技术将源代码基本块为粒度翻译代码, 模拟器动态地按需要读入二进制代码!!! 进行翻译, 将翻译好的目标代码存放在模拟器开辟内存空间中, 这块空间被称为代码缓存(translation cache). 这与扫描与修补技术的代码缓存概念类似. 同样, 由于代码缓存是在模拟器的内存空间分配的, 因此其容量是有限的, 在代码缓存用满时, 部分缓存就需要被释放出来, 因此, 一个好的管理策略是很重要的.

源代码中的指令翻译后的代码用某种映射关系联系起来, 例如, 最常用的是哈希表, 即由源代码的PC值通过哈希函数计算查表得到其在代码缓存区中的位置. 如果一个PC没有找到对应的表项, 表示这块代码还未被翻译, 或者在释放缓存空间时已被清理.

最后, 介绍一下什么是翻译. 模拟器对于读入的二进制代码不作限制, 它们可以是应用程序代码, 也可以是操作系统内核代码. 读入的二进制代码可能包含所有的x86体系结构的指令, 模拟器将其翻译输出为x86指令的一个安全的子集, 即其中不包含特权指令和敏感指令, 能够运行在内核态.

在原体系结构和目标体系结构相同的情况下, 模拟器翻译方法大致可以分为两种: 简单翻译等值翻译. 简单翻译比较直接, 但指令数量会大大膨胀; 等值翻译相对高效, 但动态分析比前者困难. 例如, QEMU使用的是一种简单指令模板!!! 来进行翻译. 图4-5给出一个例子, 其目的是用加法指令将寄存器ECX和EDX相加并存入EDX中.

2. CPU虚拟化

经简单翻译后, 可以看到, REGS结构是模拟器中为每个虚拟CPU维护的一个数据结构, 存有虚拟CPU所有寄存器的值, 即相当于包含所有虚拟寄存器. 在目标代码生成时, 上面REGS会被替换成这个数据结构中内存中的地址, 而temp1会用一个寄存器替换.

同硬件体系结构的模拟中, 很多指令是可以等值翻译的, 即原代码和目标代码一样(!!!不修改. 理论上, 大多数指令是可以等值翻译的, 除了以下几种例外.

⓵ PC相对寻址的指令.

⓶ 直接控制转换.

⓷ 间接控制转换.

⓸ 特权指令.

这里再提一下, 在同体系结构下, 等值翻译的一个潜在前提是虚拟机执行的代码可能会用到所有CPU寄存器. 因而, 在从模拟器运行时环境和虚拟机环境之间切换时, 所有寄存器的内容都需要有一次切换. 为了让虚拟机能够从模拟环境中跳到模拟器环境中, 虚拟机需要用一个寄存器来存放跳转的目标地址, 这个寄存器可以是暂时不再被使用的寄存器, 也可以把一个寄存器的值临时保存到栈上以腾出空间.

3.2 基于BT技术的CPU虚拟化

QEMU为例说明.

首先说明, 在QEMU中, 它为每个虚拟CPU都维护了一个数据结构ENV, 它保存的是当前虚拟CPU的运行环境, 包括各种寄存器的参数和值.

图4-6是QEMU翻译一个Linux基本块的过程.

  • 图中的4条指令是一个基本块, 是QEMU通过反汇编原代码(二进制代码), 解码得到的x86 指令.

  • 然后, QEMU逐条指令!!!套用翻译模板!!!, 将其变为中间形式.

  • 在对中间形式的伪指令进行优化后, QEMU最终将其生成目标指令.

2. CPU虚拟化

其中, 敏感指令CPUID翻译后再目标代码中生成了一个函数调用. 这个函数是QEMU在用户空间的一个辅助函数helper_cpuid. 它所做的事情就是根据虚拟CPU的配置, 将返回信息填好, 模拟出CPUID指令的执行效果. 整个过程在用户态就能完成.

对于如INT $0x80这样的系统调用, 是不能在一个QEMU的辅助函数中完成模拟的, 它的翻译过程略显不同. 在虚拟机启动时候, 初始化IDT表的方法不会直接修改到硬件的IDT表, 而是会修改ENV结构中虚拟的IDT数据结构. 这个数据结构会被QEMU用于查找虚拟机操作系统的中断或异常处理函数的入口, 以及其权限设置等.

如图4-7所示, 输入代码块中的INT $0x80指令被翻译为两条指令. 一条是将发生中断的EIP保存在ENV环境变量中, 然后调用raise_interrupt函数, 并传入两个参数, 前一个参数0x80指示的是当前中断的中断号, 后一个参数0x2表示的是INT指令的长度. QEMU能用这个值计算出下一条指令所在的EIP.

输出代码块中, 中间形式的伪代码被逐条翻译成x86指令, 寄存器虚拟机地址也都在这步被分配和确定.

2. CPU虚拟化

raise_interrupt函数做的事情是使得QEMU从主运行循环跳出, 并向虚拟机的OS传播中断. QEMU首先将当前执行的EIP和寄存器等状态保存在ENV结构中, 然后在ENV结构中找到系统启动时记录下的IDT表的值, 从中得到系统调用的中断描述符. 通过一些保护性检查后, QEMU当前EIP指向系统调用处理函数入口, 并装载虚拟机的内核的代码/数据段!!!, 然后返回主循环继续执行. 这样, 执行就转入到虚拟机的内核, 开始系统调用的处理.

与之相对的, 在系统调用处理结束后, 中断返回指令会执行相反的操作, 即载入用户态的代码/数据段, 恢复用户态的寄存器的值, 返回到中断指令的下一条指令继续运行.

除了系统调用外, 其它虚拟机主动地陷入也是类似处理的, 例如x86的INT 3和into等.

对于异常(Fault)和外部中断的处理和系统调用比较类似. 不同在于, QEMU从宿主机得到中断和异常的信号. 例如, 缺页异常是先由宿主机收到并处理的, 宿主机会通过发送信号将异常通知QEMU进程. QEMU进程的执行被打断, 转而执行信号处理函数. 信号处理函数会用类似方法将中断或异常向上传输给客户机OS. 另一个不同是, 在结束外部中断或异常的处理后, QEMU返回到用户态被中断的那条指令继续执行, 而不是被中断的指令的下一条指令.

可以总结下, BT技术在VMM中开辟一块代码缓存, 将代码翻译好放在其中. 原始的客户机OS代码并不会直接被物理CPU执行, 它们以基本块的形式组织, 模拟器先将即将执行的基本块翻译成目标代码块, 再转入目标代码块执行, 再翻译接下来要执行的原始基本块, 如图.

2. CPU虚拟化

3.3 BT技术的难点

3.4 BT技术的优化