目录

  1. 概述
  2. CPU虚拟化
    1. 解释执行
      1. 扫描与修补
    2. 二进制代码翻译
      1. 基本概念
        1. 基本块
        2. 代码缓存
        3. 翻译
      2. 基于BT技术的CPU虚拟化
      3. BT技术的难点
      4. BT技术的优化
  3. 内存虚拟化
    1. 概述
    2. 影子页表
      1. 影子页表的结构
      2. 影子页表的建立
      3. 影子页表的缺页处理机制
    3. 内存虚拟化的优化
      1. 自伸缩内存调节技术
      2. 页共享技术
  4. I/O虚拟化
    1. 设备模型
    2. 设备模型的软件接口
      1. PCI配置空间
      2. 端口I/O
      3. MMIO
      4. DMA
      5. 中断
    3. 接口拦截和模拟
      1. 端口I/O
      2. MMIO
      3. DMA
      4. PCI配置空间
      5. 中断
    4. 功能实现
    5. IDE的DMA操作

概述

由于硬件体系结构在虚拟化设计方面设计存在缺陷,系统虚拟化因此不能直接而有效地实现。为了弥补虚拟化漏洞,在硬件还未提供足够的支持之前,基于软件的虚拟化技术就已经先给出了两种可行的解决方案:模拟执行和直接源代码改写。这里,模拟执行对应的就是基于软件的完全虚拟化技术,而直接源代码改写对应的就是类虚拟化技术。

所有虚拟化形式都可以用模拟实现。解释执行就是最简单最直接的模拟实现方式,取一条指令出来,模拟出这条指令执行的效果,再继续取下一条指令,从某种程度上解决了陷入再模拟,也避免了虚拟化漏洞。模拟技术通常可以用在不同体系结构的虚拟化中,也就是在一种硬件体系结构上模拟出另一种不同硬件体系结构的运行环境。而在同一种体系结构的模拟中,情况会变得更容易一些,因为大多数指令可以不需要被模拟执行而直接放在真实的硬件上执行,于是一条指令再一条指令地解释执行在这里就没有必要了。可以采用改进的代码扫描与修补(Scan-and-patch)技术和二进制代码翻译技术,尽可能地提高虚拟化的性能。

CPU虚拟化

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

模拟技术早在现代虚拟化诞生之前就存在了。使用模拟器,人们可以在一种平台上运行另一种平台的应用程序或者操作系统。

模拟器架构模拟器架构

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

解释执行

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

下图为代码以正常执行的方式运行和以解释执行的方式运行。

正常执行与解释执行正常执行与解释执行

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

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

扫描与修补

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

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

扫描与修补的流程如下。

  1. VMM会在虚拟机开始执行每段代码之前对其进行扫描,解析每一条指令,查找到特权指令和敏感指令。
  2. 补丁代码会在VMM中动态生成,通常每个需要修补的指令会对应一块补丁代码。
  3. 敏感指令会被替换成一个外跳转,从虚拟机跳转到VMM的空间里,在VMM中执行动态生成的补丁代码。
  4. 当补丁代码执行完后,执行流再跳回虚拟机中的下一条代码继续执行。

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

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

CLI指令的补丁代码CLI指令的补丁代码

可以把这段补丁代码看作一个模板,其中以PATM开头的标签都会在补丁代码生成时被替换成相应的变量的地址或值。整个补丁代码含义如下。

  1. 第1行的指令是将一个变量赋值为0。PATM_INTERRUPTFLAG标签在补丁代码生成时会被替换为客户机上下文结构中的一个变量fPIF的地址。客户机上下文结构被保存在VMM的内存中,段选择符SS在这里的作用是让客户机能够访问VMM的地址空间。这条指令的作用是告诉VMM当前正在执行到生成的补丁代码,在这个临界区,发生异常是危险的。
  2. 第2行指令将EFLAGS寄存器的低16位内容保存在栈上。
  3. 第3行真正执行关中操作,但关中的效果也是通过修改客户机上下文中的一个变量来实现的。标识符PATM_VMFLAGS会被动态地替换为这个变量的地址。
  4. 第4行栈上保存的内容会被恢复回EFLAGS寄存器。第2行和第4行的目的是为了避免临界区中间的运算指令改变EFLAGS中的标志位。
  5. 第5行通过给fPIF赋值为1来标记退出临界区。
  6. 第6行是一个伪代码,0xE09是jmp指令的机器码。
  7. 第7行是一个4字节的占位符。PATM_JUMPDELTA在补丁代码生成时会被替换为虚拟机中被打补丁的指令的下一条指令。如果下一条指令也是需要打补丁的,那么会将两个补丁代码块串联起来,从而减少了两次控制流在虚拟机和VMM之间的转移。

扫描与修补原理示意图如下图所示。

扫描与修补扫描与修补

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

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

在扫描与修补技术中,异常的处理也是相对比较简单。由于指令是被一条一条打补丁的,原代码块相对的位置没有改变,因此,发生异常时可以很方便地找到异常指令对应的PC,然后将这个异常交给客户机操作系统处理就可以了。

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

  1. 由于特权指令和敏感指令都被模拟执行,各条指令的模拟执行时间可能会很短,但也可能会很长。
  2. 由于每个补丁都会引入了额外的跳转,这些跳转会降低代码的局部性。
  3. 由于扫描与修补技术直接在虚拟机内存中进行代码修补,其须维护一份与补丁对应的原始代码的备份,以便在需要时将代码恢复原状。

二进制代码翻译

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

基本概念

基本块

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

BT技术的动态翻译也是以基本块为单位的,称之为动态基本块。与编译器不同的是,编译器在静态能够得到的源代码信息是不包含在编译生成的二进制代码中的,因而在运行时是无法获得这种源代码信息的。例如,源代码中的跳转标签在基本块分析会被作为划分基本块的分界,因为标签所在位置是一个可能的调整入口。但是,在动态运行时,二进制代码中是不包含这种信息的,所以,动态划分基本块时能够准确找到出口,但会遗漏一些入口。基于这个原因,动态基本块可能会比静态基本块要大些。

代码缓存

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

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

翻译

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

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

1
2
3
4
add %ECX, %EDX
翻译成
mov REGS -> ECX, templ
add templ, REGS -> EDX

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

在同硬件体系结构的模拟中,很多指令是可以等值翻译的,即原代码和目标代码是一样的。理论上来讲,大多数指令是可以等值翻译的,除了以下几种除外。

  1. PC相对寻址的指令。这类指令的寻址与PC相关,但由于原代码和目标代码的指令相对关系是不同的,因此不能直接使用。模拟器的翻译模块需要目标代码中插入一些补偿代码来确保寻址的正确。这类翻译会导致目标代码少量增大,因而引起一些性能损失。
  2. 直接控制转换。原代码中的控制转换,例如函数调用和跳转指令,其目标地址需要被替换成存于代码缓存的目标代码地址。其中,直接调用和直接跳转可以被直接替换为代码缓存中的目标地址,因为它们时固定的,其引起的性能损失是可以忽略的。
  3. 间接控制转换。间接调用、返回和间接跳转的目标地址是动态运行时得到的。由于目标地址不固定,代码翻译时就无法绑定跳转目标。跳转目标通常在动态时计算出来。例如通过查询哈希表。根据运行程序的不同,这种翻译的性能损失也不同,但通常在百分之几以内。
  4. 特权指令。对于特权指令的翻译分两种。对于简单的能够就地模拟的指令,例如CLI,翻译的代码通常只要简单地设置一下模拟器中的某个标志位就可以完成对应效果了,例如vcpu.flags.IF = 0。而对于稍复杂的指令,需要做的就是用跳转,从模拟环境跳到模拟器中进行深度模拟,而且这个动作会引起比较大的性能开销。

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

基于BT技术的CPU虚拟化

BT技术如何被运用在CPU虚拟化中进行软件模拟。以QEMU为例来说明。在QEMU中,它为每个虚拟CPU都维护了一个数据结构ENV,它保存的是当前虚拟CPU的运行环境,包括各种寄存器的参数和值。

下面展示QEMU翻译一个Linux代码基本块的过程。
基本块。

1
2
3
4
5
IN:
0xc075a4f8: mov $0x1, %eax
0xc075a4fd: cpuid
0xc075a4ff: and $0x2, %dh
0xc075a502: jne 0xc075a50d

反汇编。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
OP:
0x0000: movl_T0_im 0x1
0x0001: movl_EAX_T0
0x0002: cpuid
0x0003: movl_T1_im 0x2
0x0004: movh_T0_EDX
0x0005: andl_T0_T1
0x0006: movh_EDX_T0
0x0007: update 1_cc
0x0008: set_cc_op 0x16
0x0009: jz_subb 0x0
0x000a: goto_tb0
0x000b: movl_eip_im 0xc075a50d
0x000c: movl_T0_im 0x837b8bc
0x000d: exit_tb
0x000e: goto_tb1
0x000f: movl_eip_im 0xc075a504
0x0010: movl_TO_im 0x837b8db
0x0011: exit_tb
0x0012: end

生成目标指令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
OUT: [siez=88]
0x08cbef40: mov $0x1, %ebx
0x08cbef45: mov %ebx, 0x0(%ebp)
0x08cbef48: call 0x80f2428
0x08cbef4d: mov $0x2, %esi
0x08cbef52: mov 0x8(%ebp), %eax
0x08cbef55: mov %eax, %ebx
0x08cbef57: shr $0x8, %ebx
0x08cbef5a: and %esi, %ebx
0x08cbef5c: mov %bl, 0x9(%ebp)
0x08cbef5f: mov %ebx, 0x2c(%ebp)
0x08cbef62: movl $0x16, 0x30(%ebp)
0x08cbef69: cmpb $0x0, 0x2c(%ebp)
0x08cbef6d: jne 0x8cbef74
0x08cbef6f: jmp 0x8cbef86
0x08cbef74: jmp 0xa789cc7
0x08cbef79: movl $0xc075a50d, 0x20(%ebp)
0x08cbef80: mov $0x837b8bc, %ebx
0x08cbef85: ret
0x08cbef86: jmp 0xa789cc7
0x08cbef8b: movl $0xc075a504, 0x20(%ebp)
0x08cbef92: mov $0x837b8bd, %ebx
0x08cbef97: ret

4条指令是一个基本块,是QEMU通过反汇编原代码,解码得到的x86指令。然后,QEMU逐条指令地套用翻译模板,将其变成中间形式,在对中间形式的伪指令进行优化以后,QEMU最终将其生成目标指令。

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

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

两条指令。

1
2
3
IN:
0x0819b930: mov $0x40, %eax
0x0819b935: int $0x80

传入参数。

1
2
3
4
5
6
OP:
0x0000: movl_T0_im 0x40
0x0001: movl_EAX_T0
0x0002: movl_eip_im 0x819b935
0x0003: raise_interrupt 0x80 0x2
0x0004: end

输出代码块。

1
2
3
4
5
6
7
8
9
10
OUT: [size=37]
0x0913a210: mov $0x40, %ebx
0x0913a215: mov %ebx, 0x0(%ebx)
0x0913a218: movl $0x819b935, 0x20(%ebp)
0x0913a21f: push $0x2
0x0913a224: push $0x0
0x0913a226: push $0x1
0x0913a228: push $0x80
0x0913a22d: call 0x80f5cb0
0x0913a232: add $0x10, %esp

输入代码块中的INT $0x80指令会被翻译为两条指令。一条是将发生中断的EIP保存在ENV环境变量中,。然后调用raise_interrupt函数,并传入两个参数,前一个参数0x80指示的是当前中断的中断信号,后一个参数0x2表示的是INT指令的长度。QEMU能够用这个值计算出下一条指令所在EIP.在输出代码块中,中间形式的伪代码为逐条翻译成x86指令,寄存器和虚拟地址也都在这步被分配和确定。

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

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

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

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

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

二进制代码翻译二进制代码翻译

BT技术的难点

对于以下几种情况,BT技术在处理过程中会遇到困难,需要特别处理。

  1. 自修改代码(Self Modifying Code)。自修改代码值程序会修改自身代码段的内容。一旦发生修改操作,模拟器需要将代码缓存中对应已翻译的代码清除掉,对新写的代码重新翻译。
  2. 自参考代码(Self Referential Code)。指的是程序会从自己代码段中读取内容。在这种情况下,模拟器需要让程序读取原代码段的内容而不是代码缓冲区的内容。
  3. 精确异常(Precise Exceptions)。精确异常指在翻译代码执行中发生率中断或异常,这时需要将运行状态对应到原代码执行到异常点时的状态,然后交给客户机操作系统去处理。精确异常问题对于BT技术来讲比较难解决,这主要是由于翻译的代码和原代码已经失去了逐条对应的关系。一个可能的解决方法就是在发生异常时,模拟器回滚到基本块的开头,然后用解释执行的方式逐条执行原代码。
  4. 实时代码。对于实时性要求比较高的代码,运行在模拟环境下会损失时间精确性。

BT技术的优化

在BT技术发展的时候,也积累了许多优化技术,可以提高整体性能。这些优化技术有基本块串联、自适应翻译和指令缓存布局优化等。

BT技术的优化首先想到的就是减少模拟器环境和虚拟机环境的切换,一个方法就是使得运行尽可能不要跳出虚拟机环境,让执行从一个基本块直接跳转到下一个基本块,而不需要模拟器的介入,这样的基本块串联起来以后就形成了超级快。基本块的串联可以通过修改直接控制流,转移指令的跳转目标来完成,例如固定目标地址的CALL和JMP指令等。

一些敏感指令只有在涉及敏感数据时才需要模拟执行。例如,大多数时候mov指令只是在读取或写入普通数据,只有在其读取或写入页表内容时才需要被模拟执行。BT技术用自适应翻译的目的是有效地找出这小部分的敏感操作,而不影响敏感指令的非敏感操作。自适应翻译的原理就是基于“无罪假定(Innocent Until Proven Guilty)”。无罪假定的意思是,如果模拟器不能确定一条指令会还是不会进行敏感操作,那就先假定不会发生,直到这条指令确实发生了敏感操作。在平时运行时,模拟器对敏感数据进行读写保护,这样,敏感指令涉及到这些数据就会触发陷入异常,进而,模拟器才特别对其处理。这样优化的好处是,模拟器不需要事先知道特别处理的敏感指令有哪些,这样的方法对于筛选出少部分的指令很有效果。

合理地放置代码缓存能够加强执行时CPU中指令缓存的局部性,这一点对于性能优化有很大帮助。有时,一些虚拟执行的计算密集型程序会出现性能浩宇原代码执行,其原因就是指令/数据缓存有更好的局部性。但是,要刻意做出这种优化,其难度是非常大的。

指令缓存布局优化指令缓存布局优化

内存虚拟化

内存虚拟化的目的有两个。

  1. 提供给虚拟机一个从0开始的连续物理内存空间。
  2. 在各虚拟机之间有效隔离、调度以及共享内存资源。

概述

为了让客户机操作系统使用一个隔离的、从0开始且具有连续性的内存空间,VMM引入一层新的地址空间,即客户机物理地址空间。客户机物理地址空间时客户机操作系统所能看见和管理的物理地址空间,这个地址空间不是真正的物理地址空间,它和物理地址空间还有一层映射。有了客户机物理地址空间,就形成了从应用程序所在的客户机虚拟地址(Guest Virtual Address, GVA)到客户机物理地址(Guest Physical Address,GPA),再从客户机物理地址GPA到宿主机物理地址(Hsot Physical Address,HPA)的两层地址转换。前一个转换由客户机操作系统完成,后一个转换由VMM负责。

为了实现从客户机物理地址GPA到宿主机物理地址HPA的地址翻译,VMM为每一个虚拟机动态地维护了一张客户机物理地址到宿主机物理地址映射关系的表。

有了这张表之后,VMM截获任何试图修改客户机页表或刷新TLB(Translation Lookaside Buffer,旁路转换缓冲区)的指令,根据这张表,将修改从客户机虚拟地址到客户机物理地址映射的操作,变成修改客户机虚拟地址到相应的宿主机物理地址映射的操作。

有了这张表之后,虽然宿主机物理地址只有一个零起始地址,但在不同客户机物理地址空间里,可以各有一个零起始地址,而且对于客户机操作系统来说,客户机物理内存空间看起来是连续的,其对应的宿主机物理内存空间可能不是连续的,而这增加了VMM为多个虚拟机分配宿主机物理内存的灵活性,提高了宿主机物理内存的利用率。

VMM还可以通过该表确保运行于同一宿主机上的不同客户机访问的是不同的物理内存,即相同的客户机物理地址呗映射到了不同的宿主机物理地址上。这样一来,一个客户机只能访问VMNM通过该表设置分配给它的宿主机物理内存,而不能访问其他客户机拥有的宿主机物理内存。

有时,VMM使用页共享技术以写时复制(Copy On Write)的方式上不同的客户机可以共享包含相同数据的宿主机物理页,删除多余的页备份。这种页共享技术,是通过将不同客户机的某些客户机 物理地址映射到相同宿主机物理地址上,来实现共享这个宿主机物理地址对应的宿主机物理页。

除此之外,VMM还可以在客户机完全不知情的情况下,将客户及所拥有的某一客户机物理页映射到一张新的宿主机物理页上,甚至,可以将客户机所拥有的某一客户机物理页所对应的宿主机物理页换出到硬盘上,而客户机仍然以为它访问的客户机物理页是普通的硬件内存资源。只有当它被真正访问时,VMM才将换出的页再次换入到宿主机内存中。

影子页表

客户机操作系统所维护的页表负责传统的从客户机虚拟地址GVA到客户机物理地址GPA的转换。如果MMU(Memory Management Unit,内存管理单元)直接装载客户机操作系统所维护的页表来进行内存访问,那么由于页表中每项所记录的都是GPA,硬件无法正确通过多级页表来进行地址翻译。

针对这个问题,影子页表(Shadow Page Table)是一个有效的解决方法。如下图所示,一份影子页表与一份客户机操作系统的页表对应,其作用是由GVA直接到HPA的地址翻译。

影子页表的作用影子页表的作用

如上图所示,为了使影子页表机制能够工作,VMM需要对MMU实现虚拟化。客户机操作系统所能看到和操作的都是虚拟MMU,客户机操作系统所维护的页表只是被客户机操作系统载入到虚拟MMU中,不能被物理MMU所直接利用进行MMU硬件实现的地址翻译。因而,真正被VMM载入到物理MMU中的页表是影子页表。

影子页表是被物理MMU所装载使用的页表,VMM为每个客户机操作系统中的每一套页表都维护来看看一套相应的影子页表。有了影子页表,普通的内存访问只需要使用影子页表即可实现从客户机虚拟地址GVA到宿主机物理地址HPA的转换,而不需要在每次访问内存时都进行客户机虚拟地址GVA到客户机物理地址GPA以及客户机物理地址GPA到宿主机物理地址HPA的两次转换。而且,在TLB和CPU缓存上缓存的是来自影子页表的从客户机虚拟地址GVA到宿主机物理地址HPA的映射,因而大大降低了额外的性能开销。

分页机制启动以后,在访问存储器时若一个虚拟地址到物理地址的映射不在TLB缓存中,就需要从页表基地址寄存器中的物理地址所指向的物理页开始遍历页表。以x86架构的最简单的两级页表为例,CR3寄存器所指向的是一个页目录表页,该页目录表中的每一项要么为空,要么指向一个页表页,而页表页中的每一项,要么为空,要么指向一个物理页。假设在遍历客户机页表时,客户机虚拟地址0xc4567010处在页目录第0x311项,并处在该页目录项所指向的页表中第0x167项所指向的客户机物理页中偏移0x010字节处。那么,在宿主机上遍历影子页表时,硬件CR3寄存器中的宿主机物理地址所指向的页目录表的第0x100项,通过第0x100项的值找到对应的页表,并从对应页表的第0x200项中,找到宿主机物理页,并且在找到的宿主机物理页中偏移0x010处的宿主机物理地址,就是该客户机虚拟地址0xc4567010所对应的宿主机物理地址。

实际上,在影子页表的实现中,影子页表的页表结构不一定与客户机页表的页表结构完全一致,例如在64位的宿主机上,就可以运行32位的客户机,尽管客户机的页表结构只有两级,但这并无妨碍,必须保证的是,相对于同一个虚拟地址,在影子页表中最后的那级页表的页表项所指向的宿主机物理页是,并且只能是客户机物理页在客户机物理地址与宿主物理地址映射表中对应的宿主机物理页。只有这样,客户机操作系统才能由影子页表访问到它想访问的客户机物理地址。这就是影子页表的基本原理。

影子页表与客户机操作系统页表影子页表与客户机操作系统页表

客户机操作系统使用的页表不是静态的,客户机总是在不时地修改客户机页表。在传统的物理机上执行时,刷新TLB的场合大致有如下三种。

  1. 写CR3寄存器,若写入CR3寄存器的物理地址与CR3寄存器原来的内容相同,意味着操作系统只想当前TLB的内容全部失效,要访问虚拟地址,硬件需要重新遍历页表。
  2. 若写入CR3寄存器的是个不同的物理地址,意味着操作系统想使用新的一套页表,通常就意味着另一个进程占用了处理器,自然TLB的内容也全部失效。
  3. 操作系统是修改部分的页表。这时,若原来的页表项所涉及到的虚拟地址到物理地址的转换已在TLB中,操作系统必须使该TLB项失效,即使与单个TLB项失效,这可以用INVLPG指令来完成,通常修改页表和INVLPG是伴随执行的。这是在传统的物理机上执行的操作。在宿主机上执行时,为保证与客户机页表的一致性,VMM必须对影子页表做相应的操作。

当客户机操作系统修改从客户机虚拟地址到客户机物理地址的内存映射关系,即企图修改它所维护的客户机页表时,为了保证一致性,VMM必须对影子页表也做相应的维护。为此,VMM必须截获这样的内存访问操作,修改在影子页表中同一客户机虚拟地址到宿主机物理地址的映射关系,使之仍然符合从客户机物理地址到宿主机物理地址的映射关系,这保证了客户机的正确执行,也带来额外的性能开销。

事实上,这样的性能开销一直是客户机性能开销的热点所在。影子页表的性能开销也就是内存虚拟化的开销是影响客户机系统性能的关键因素。以何种方式截获客户机的内存访问操作,截获之后又如何处理影子页表和客户机页表的修改,一直都是影子页表的关键设计所在。不同的设计可能使整个客户机的性能相差很大。

CR3的写入和INVLPG都属于特权指令,VMM可以截获它们,并作相应的处理。比较复杂的是客户机操作系统修改客户机页表的操作。

由于页表也也是内存的一部分,其本身也必然作为普通数据页在页表中有一份映射。又由于客户机的页表是由自身维护的,它本身具有对页表页的读写权限。在与客户机维护的页表相对应的影子页表中,如果对于页表页的映射也是可写的,那么VMM将失去截获页表修改的机会,也就失去了客户机操作系统维护的页表的更新追踪。

因此,在影子页表中,对于页表页访问权限是只读的。任何写页表页的操作都会触发缺页异常,由VMM截获处理。在处理函数中,VMM除了代客户机操作系统更新页表项之外,还会将更新的GPA翻译成HPA,同时更新影子页表。

影子页表的建立与修改都是由VMM完成的,所以VMM总是可以控制影子页表的页访问权限的,由此,VMM也总是可以控制何时和怎样截获客户机操作系统对客户机页表的修改。

需要注意的是,某些时候客户机操作系统会在VMM无法感知的情况下,将客户机页表页回收,并将作为普通的数据页使用。例如,当一个进程退出时,其使用的页表页也被回收,但这一事件并非系统级事件,无法从VMM获知。也就是说,客户机操作系统回收页表的操作通常不是由特权或敏感指令完成的,VMM无从截获,只能凭经验推测。此时,VMM应对这些页取消写保护,否则就会造成 不必要的额外性能的开销。这也是影子页表的设计要考虑的因素之一。

从时间上看,由于提供了影子页表供物理MMU直接寻址使用,大多数的内存访问可以在不受VMM干预的情况下正常执行,没有额外的地址翻译开销。因此,总体而言,影子页表为完全内存虚拟化减少了时间上的开销,但在与非虚拟化环境下相比仍有一定的差距。

从空间上看,影子页表的引入意味着VMM需要为每个客户机操作系统的每套页表结构都维护一套相应的影子页表,这会带来较大空间上的额外开销。考虑到宿主机上可能同时运行多个客户机,而客户机上也可能同时运行多个进程,同时维护的影子页表所占用的物理地址空间可能非常惊人。另外,客户机操作系统在进程终止时会回收进程页表,这类事件VMM无法直接获知,让VMM有机会回收已不再使用的影子页表。因此,影子页表的设计中对影子页表占用的物理空间也通常作优化处理。

一方面,VMM会根据客户机操作系统对客户机页表的修改得到一些暗示,从而推断出客户机操作系统回收页表的事件,这些推测可能多数情况下帮助VMM正确回收影子页表,释放宿主机的物理空间。另一方面,在影子页表的设计中,通常都会含有积极的影子页表的回收机制。

影子页表的结构

在影子页表中,每个页表项包含的都是宿主机物理地址HPA。这些宿主机物理地址是如何得到,与客户机页表中对应的客户机物理地址GPA有什么关系。

以x86架构上最简单的二级页表为例,当客户机操作系统进入保护模式之前,会为第一个保护模式的进程先准备好客户机CR3寄存器的值,该值的高20位(右移12位)称为客户机页帧号(Guest Frame Number,GFN)。每帧代表一个内存页。VMM在为它建立影子页表时,会根据GFN找到与之对应的映射在宿主机上的物理帧页号(Machine Frame Number,MFN)。客户机认为是存储在该GFN中的数据实际上储存在MFN中。

VMM要从宿主机的物理内存中新分配一个物理页,该物理页对的起始地址右移12位后称为影子宿主机物理帧页号(Shadow Machine Frame Number,SMFN),VMM拟将这个物理页的起始地址载入物理CR3寄存器,指向相应客户机进程的影子页表。客户机操作系统总会切换进程,当下次这个进程又重新被调度执行时,VMM不需要重新分配新的宿主机物理页,只需要找到以前为它分配的可载入物理CR3寄存器的宿主机物理页SMFN即可。为此,VMM要在GFN、MFN和SMFN之间建立一定的联系,考虑到GFN和MFN是一对一的映射关系,只需要建立MFN和SMFN的关系即可。最常用的算法就是hash表,以MFN的值和SMFN所对应的影子页表的类型type(通常是指影子页表中是第几级页表,也有其他特殊类型)为键值来索引SMFN,即SMFN=hash(MFN, type)。

现在载入物理CR3寄存器的宿主机物理页已经有了,对两级页表来说,每张影子页表都有1024个页表项,每个页表项对应客户机页表相应位置的页表项。客户机页表项的存在位(Present Bit)如果为1,则VMM会为相应的影子页表的页表项填入宿主机物理地址。如果该页表项所处的页表不是页表结构的最后一级页表,那么根据客户机页表项所含物理地址右移12位得到GFN,将之转换为相应的MFN,若根据hash(MFN, type)可以得到相应的SMFN,则该SMFN就是应该填入该影子页表项的宿主机物理地址;反之,VMM需要为其新分配宿主机物理页,并为该宿主机物理页和客户机物理页映射的宿主物理页在hash表中建立映射关系,以备下次使用。

影子页表的的建立过程与修改过程交织在一起,贯穿于VMM针对客户机操作系统修改客户机页表和刷新TLB所做的三种操作中,即VMM对客户机操作系统修改客户机CR3寄存器的截获和处理,VMM对客户机操作系统INVLPG指令的截获与处理,以及VMM因客户机页表和影子页表不一致而出触发的缺页异常的截获与处理。通常,最后一种发生几率最高,处理也最复杂。

影子页表的建立

开始时,VMM中与客户机操作系统所拥有的页表相对应的影子页表是空的,不包含任何从客户机虚拟地址到宿主机物理地址的映射关系。随后,VMM以按需调整的方式,根据客户机操作系统修改它所拥有的客户机页表,相应地修改与之对应的影子页表。

一开始,影子页表是空的,而影子页表又是载入到物理CR3中真正为物理MMU所利用进行寻址的页表,因此开始时任何的内存访问操作都会引起缺页异常。如果客户机操作系统为所访问的客户机虚拟地址分配了客户机物理页,即客户机操作系统的当前页表中包含了从这个客户机虚拟地址到已经分配了的某一客户机物理页地址的映射,那么,它正是由于影子页表中相应从客户机虚拟地址到宿主机物理地址的映射尚未初始化造成了这种异常的发生。需要注意的是,这样的缺页异常在非虚拟环境中是不会出现的。在这种情况下,VMM截获该缺页异常,在相应的影子页表中建立从这个客户机虚拟地址到与客户机物理地址相对应的宿主机物理地址的映射,从而完成了对缺页异常的处理,完成影子页表的初始化,而且这种异常不会再告知给客户机操作系统。

此外,如果VMM在客户机操作系统不知情的情况下将分配给客户机的宿主机物理页换出到了硬盘,那么,也会出现上述类似的情况,即虽然客户机操作系统为访问的客户机虚拟地址分配了客户机物理页,但是VMM却没有在影子页表中为这个客户机虚拟地址建立相应的到宿主机物理地址映射,这样也会发生上述类型的缺页异常,由VMM处理。

当发生缺页异常时,如果客户机操作系统尚未给这个客户机虚拟地址分配客户机物理页,即相应的客户机操作系统页表中的确没有这个客户机虚拟地址到某一客户页的映射,那么,VMM首先将缺页异常传递给客户机操作系统,由客户机操作系统为这个客户机虚拟地址分配客户机物理页,而由于客户机操作系统分配客户机物理页需要修改其页表,因此这个操作又被VMM截获,VMM更新影子页表中相应的页目录和页表项,增加从这个客户机虚拟地址到新分配的客户机物理页相对应的宿主机物理页的映射。

影子页表的缺页处理机制

当缺页异常发生时,首先由VMM截获。VMM将发生异常的客户机虚拟地址在客户机页表中对应页表中对应页表项的访问权限为与缺页异常的错误码进行比较,从而检查此缺页异常是否由客户机本身引起的。对于由客户机本身引起的缺页异常,例如客户机所访问的客户页表项存在位(Present Bit)为0,或者写一个只读的客户机物理页,VMM将直接返回客户机操作系统,再由客户机操作系统的缺页异常处理机制来处理该缺页异常;如果缺页异常不是由客户机引起的,那么它必定是由客户机页表和影子页表不一致所引起的,这样的缺页异常又称为影子缺页异常(Shadow Page Fault)。对于影子缺页异常,VMM尝试根据客户机页表同步影子页表。

首先,VMM根据客户机页表建立起相应的影子页目录和页表结构。然后,VMM根据放生缺页异常的客户机虚拟地址,在客户机页表的相应表项中得到与之对应的客户机物理地址。最后根据客户机物理地址在地址转换表中得到相应的宿主机物理地址,VMM再把这个宿主机物理地址填入到影子页表项中。此时该影子页表项一定在最后一级影子页表中。这样,就建立起从发生了缺页异常的客户机虚拟地址到影子页表中相应的宿主机物理地址的映射。

在根据客户机页表项同步影子页表时,除了要建立起相应的影子页表数据结构、填充宿主机物理地址到影子页表项中之外,VMM还要根据客户页表项的访问位和修改位设置对应影子页表项的访问位和修改位,以保证影子页表项中这些为的语义最终能和客户机页表中的相同。

  1. 如果发生缺页异常,客户机页表页表的存在位为0,表示客户机物理页不存在,那么,VMM将相应的影子页表项设为空值,以保证对应的宿主机物理页也不存在。在这里,VMM如果只是将相应的影子页表项的存在位设为0是不够的,因为VMM只将存在位设置为0还有其他用处。
  2. 如果客户机页表项的存在位为1,即客户机物理页存在,并且它的访问位和修改位都被置为1,那么VMM将相应的影子页表的访问位和修改位设置为1。
  3. 如果客户机页表项的访问位为0,表示客户机尚未访问相应的客户机物理页。这种情况下,VMM需要截获将来客户机页表项的访问位被设置为1的操作,从而将相应的影子页表项的访问位设置为1。为此,VMM将相应的影子页表项的存在位标记为0,当下次客户机访问这个页是,无论是读还是写访问,由于影子页表项的存在位为0,因此会发生缺页异常。VMM截获这个异常,将相应的影子页表项的访问位设置为1。注意,上面为了截获读取访问这个页引起的客户机页表项的访问位设置为1,需要将影子页表项的存在位设置为0,所以只是将影子页表项的读写位设置为只读是不够的。
  4. 对于客户机页表项的修改位,如果它为0,表示客户机物理页不曾被写入过。这种情况下,VMM需要设法截获未来客户机写此客户机的物理页,引起客户机页表项的修改位被置1的情况,从而设置相应的影子页表项的修改位和访问位。为此,VMM会将影子页表项的读写位设为只读。这样,客户机要写客户机物理页时,会发生写相应的只读宿主机物理页而引起的缺页异常,这个异常会被VMM截获,VMM将影子页表项的访问位和修改位设为1,并将相应的客户机页表项的访问位和修改位设为1。

影子页表和客户机页表之间并不是时刻同步的,只有在需要的时候才进行同步。这时候,影子页表充当了客户机页表巨大的TLB,称为虚拟TLB。当客户机操作系统需要访问它的客户机页表时,物理MMU真正访问的是这个称为虚拟TLB的影子页表。

当客户机页表被修改时,若影子页表中对应该客户机页表的表项访问权限低于客户机页表表项的,VMM会截获一个缺页异常,这就可以理解为TLB未命中,它表示尽管客户机页表中访问的是合法的地址映射,但是影子页表中尚未建立起与之相对应的映射,即发生影子缺页异常。此时,VMM根据客户机页表的客户机虚拟地址到客户机物理地址的映射,在影子页表中建立相应的客户机虚拟地址到宿主机物理地址的映射,并且设置上相应的权限位,就相当于TLB填充。

当客户机试图修改客户机页表的表项,由于客户机试图执行敏感指令重写CR3或执行INVLPG敏感指令以刷新TLB,VMM截获这一操作,并对影子页表进行相应的修改,刷新影子页表这一相对于客户机页表的虚拟TLB中的全部或部分内容,这就相当于TLB刷新。如下图所示。

虚拟TLB虚拟TLB

内存虚拟化的优化

自伸缩内存调节技术

自伸缩内存调节技术是一种VMM通过诱导客户机操作系统来回收会分配客户机所拥有的宿主机物理内存的技术。

在这种技术中,一个被称为“气球”的模块作为一个伪设备驱动程序或内核服务载入到客户机操作系统之中。气球模块不提供操作系统会上层应用程序调用的接口,而是只为VMM提供了私有的交互接口,通过和VMM的交互,实现自伸缩的宿主机物理内存回收和分配。

当VMM需要从客户机回收宿主机物理内存时,它通知植入客户机操作系统的气球模块,由气球模块调用客户机操作系统本身的内存函数分配客户机物理内存;VMM相应地把这些客户机物理内存所对应宿主机物理内存回收掉。在这个过程中,气球模块申请客户机物理内存,但并不能真正有效地使用它们,而是通知VMM这些客户机物理内存对应的宿主机物理内存可以被回收,通过气球的膨胀实现了为VMM圈地的功能;而VMM将气球圈的地顺理成章地回收掉,挪作他用,从而实现了宿主机物理内存的回收。

气球的膨胀是气球模块箱客户机操作系统申请更多的客户机物理内存,这使得客户机操作系统所拥有的客户机物理内存资源变得更为紧缺。这将导致客户机操作系统调用它自身的内存管理算法:当客户机物理内存足够时,客户机操作系统从闲置客户机物理内存链表中返回客户机物理内存给气球;当客户机物理内存资源稀缺时,客户机操作系统必须回收一部分客户机物理内存,以满足气球申请客户机物理内存的需要。客户机操作系统自己决定回收那些特定的客户机物理页,而且,如果必要的话,将一部分客户机物理页换出到硬盘上,再将它们供气球。对每个分配气球的客户机物理页,气球模块将它的客户机物理页号告诉VMM,VMM根据它就能对应的宿主机物理页回收掉了。

当VMM需要为客户机分配宿主机物理内存时,也是类似。

由于被气球膨胀而圈起来的客户机物理内存所对应的宿主机物理内存为VMM所回收,因此,客户机操作系统应该不能访问这些分配给气球的客户机物理内存。然而,即使客户机操作系统企图访问这些被气球圈起来的客户机物理内存(所对应宿主机物理内存已经被VMM回收),VMM仍然有办法确保系统的正确性不被破坏。当一个客户机物理页被分配给气球时,系统会标记它,回收这个客户机物理业所对应的宿主机物理页号。这样,此后任何对这个客户机物理页的访问都会引起缺页错误,并被VMM所截获。从而,VMM可以有效地重置这一页的状态,把这个客户机物理页从气球那儿还给客户机操作系统,并且将这个客户机物理页映射到新的一张宿主机物理页上。这样一来,在客户机操作系统看来,它可以正常地第一次访问原先被气球夺走的客户机物理页,其实是那页现在对应另一张新的宿主机物理页,而客户机物理页先前所对应的宿主机物理页仍能顺利的被VMM回收。

页共享技术

在客户机技术的许多场景中,都存在着不同的客户机之间共享宿主机物理内存的可能性。例如,当多个客户机运行同一个操作系统的不同实例时,运行相同的应用程序的不同实例时,或者包含共享的数据时,都有共享包含相同数据的宿主机物理内存的机会。因此,如果在一台物理主机上运行多个客户机,VMM通过实现页共享技术可以有效地节省宿主机物理内存资源。

页共享技术很早就在操作系统的层面上得以实现。例如,有的操作系统实现了对应用程度代码段、只读数据段等只读的物理内存的共享。不同的进程运行相同的应用程序时,可以共享这一应用程序的代码段和只读数据段。万一发生对共享也的写操作,利用写时备份机制加以处理。这种页共享只能识别出有限数量的可共享页,在现实应用中,很可能存在许多可写页实际并没有发生写操作,因而可以被共享。此外,虽然这样的页共享对应用程序是透明的,但是需要修改操作系统,并且可能改变应用程序的编程接口。

随着虚拟机技术的引入,在VMM层面上实现页共享,就避免了对操作系统的修改,保持了应用程序接口不变。更为重要的是,基于内容的页共享技术被引入。它通过比较宿主机物理页的数据,可以识别出包含相同数据的宿主机物理页,从而实现最大程度的页共享。对于包含相同数据的宿主机物理页,无论它们在客户机操作系统层面上的用途是什么,在VMM的层面上,它们都可以被无所顾虑地共享。这种虚拟机层面上实现的页共享有两大优点。

  1. 它无须修改操作系统,甚至完全不必关心操作系统的逻辑。
  2. 实现了最大程度的页共享,而不是只共享有限的只读页。

基于内容的页共享主要性能开销,来源于扫描宿主机物理内存数据以挖掘出相同数据的不同宿主机物理页。为了避免这种好事的宿主机物理页扫描和两两宿主机物理页之间的数据对比,可以利用哈希表以索引的方式高效地描述宿主机物理页的共享情况。

对每个宿主机物理页,系统为它的数据计算哈希值,作为哈希索引的键值。如果这个键值对应的哈希表单元已经存在,那么,这个宿主机物理页和先前已经加入到这个哈希表单元中宿主机物理页拥有相同的哈希值,这意味着它们有很高概率包含相同的数据而可以贡献。接着,对这个哈希值相同的宿主机物理页,系统进一步比较它们的数据,若数据相同,则确认它们可以共享。

对于可以共享的宿主机物理页,系统使用经典的写时备份机制实现共享,多余的备份被删除。任何尝试对共享也的写操作都会引起缺页错误,并被VMM所截获。VMM在客户机操作系统完全不知情的情况下复制一份备份分配给需要进行写操作的客户机。

如果这个键值对应的哈希表单元不存在,那么久创建一个新单元,将这个宿主机物理页的描述符加入到这个单元中。直到未来系统发现有另外一个也和它包含相同的数据,这个引起新单元创建的宿主机物理页才会和新加入的宿主机物理页一起被标记为写时备份。与将引起新单元创建的也立即标记为写时备份相比,通过延后标记,为写时备份避免了由于不能共享的宿主机物理页被标记为写时备份而引起不必要的缺页异常。

I/O虚拟化

设备模型

虚拟机中侦测和驱动的设备一般不是直接对应于硬件设备,而是由VMM抽象出来的,其设备的种类和型号与真实设备可能比较接近,也可能完全不同。不同的VMM提供的虚拟设备种类和型号都是不同的。

虚拟设备的功能可以多于或少于真实硬件,甚至能够模拟出真实硬件不具备的一些特性,虚拟出不存在的硬件设备。例如,在VMware Workstation中,虚拟机可以拥有一个SCSI磁盘,而真实硬件上所用的可以是IDE磁盘,SCSI磁盘有些特性是IDE磁盘不具备的,这些特性都是由设备模型模拟出来的。

在软件完全虚拟化系统中,一般使用I/O模拟的方法来虚拟化I/O设备。具体来说,在进行设备虚拟化时,VMM需要对某一目标设备进行模拟,为客户机提供一个虚拟的设备,使其可以透明地对这个虚拟设备进行操作;客户机操作系统发现虚拟的目标设备后,会使用目标设备的驱动程序来驱动该设备。客户机中的驱动程序会发出一些请求并等待设备的响应,这些请求被VMM拦截处理后,响应会返回给客户机操作系统。如果这个响应与真实物理设备的响应相似,客户机操作系统可以安全地任务自己运行于物理硬件平台之上,VMM就成功地给客户机提供了一个虚拟设备。

VMM中进行设备模拟,并处理所有设备请求和相应的逻辑模块,就是设备模型。

设备模型并不需要精确模拟目标设备。从操作系统的角度,设备可以分为软件可见部分和软件不可见部分。前者是操作系统操作硬件的所有接口,后者则包括硬件的内部逻辑以及与其他设备的连接等。设备模型在进行设备I/O模拟的时候,只需要正确模拟目标设备的软件接口就可以保证客户机操作系统观察到的虚拟设备与目标设备一致,而不必考虑真实硬件的硬件构造及硬件接口,也不需要了解所运行的客户机操作系统的技术细节,如下图所示。

目标设备与虚拟设备目标设备与虚拟设备

设备模型为模拟目标设备软件接口,也需要同时实现目标设备的功能。这些功能是基于软件实现的,因此设备模型所模拟的目标设备与宿主机的硬件不存在直接的关联和对应关系,而是建立在一定的运行环境之上。例如,操作系统所提供的系统调用,这种方法使得设备模型可以完全独立于宿主机的硬件,进而实现跨平台的设备模拟。下图显示了设备墨香的逻辑层次关系。

设备模型在VMM中的分层设备模型在VMM中的分层

对于不同构造的虚拟机,其逻辑层次都是类似的:VMM拦截客户机的I/O操作,将这些操作传递给设备模型进行处理;设备模型运行在一个特定的运行环境下,这可以是操作系统,可以是VMM本身,也可以是另一个客户机。

下图是宿主机模型中设备模型的一个可能的实现。

宿主机模型中的设备模型宿主机模型中的设备模型

在这个例子中,VMM的主要部分是一个宿主机操作系统的内核模块,设备模型是一个用户态进程。当客户机发生I/O之后,VMM作为内核模块将其拦截后,会通过宿主机内核态-用户态接口传递给用户态的设备模型处理。设备模型运行于宿主机操作系统之上,可以使用相应的系统调用以及所有运行库。宿主机操作系统及其上的运行库,就构成了设备模型的运行环境。

在Hypervisor模型中,设备模型的位置如下图所示。

Hypervisor模型中的设备模型Hypervisor模型中的设备模型

设备模型是位于虚拟机设备驱动程序与实际设备驱动之间的同一个模块。由设备驱动所发出的I/O请求先通过设备模型模块转换为物理I/O设备的请求,再通过调用物理设备驱动来完成相应的I/O操作。反过来,设备驱动将I/O操作结果通过设备模型模块,返回给客户机操作系统的虚拟设备驱动程序。

设备模型的软件接口

由于设备多种多样,不同的设备其软件接口也差异巨大,一个完整的设备模型需要大量的代码分别对每个设备的接口和逻辑进行模拟。然而,还是可以看到,不同设备的软硬件叫换信息的方法是有限的,对于一个典型的PCI设备,它可能包含以下种类的接口。

在虚拟机中,当客户机通过这些接口与虚拟设备进行数据交换时,VMM会截获这些访问,并将其重定向至设备模型,就可以进行设备模拟了,如下图。

设备模型的软件接口设备模型的软件接口

PCI配置空间

PCI配置空间包含了设备的很多基本信息,最重要的包括设备标识符,它使OS可以发现并识别设备类型;基地址寄存器,它使OS可以映射并寻址属于该设备的寄存器。PCI配置空间通过平台相关的寄存器访问,可以是端口I/O,也可以是MMIO,一般由两个寄存器组成,一个用于指定设备和偏移,另一个用于读取或写入数据。PCI配置空间的寻址方式如下图所示。另外,PCI设备的发现也是通过客户机操作系统遍历PCI总线(总线号、设备号及功能号的组合),检查返回值的有效性来进行的。

PCI配置空间偏移寄存器PCI配置空间偏移寄存器

端口I/O

操作系统通过特定指令访问I/O空间,在x86平台上,这包括in、out、ins和outs。这些端口I/O一般是设备相关的寄存器。

MMIO

某些特定的物理区域(如0xf000000 ~ 0xffffffff)并不会映射到真正的RAM存储器,而可能是设备的MMIO,其中包含设备的寄存器。操作系统通过页表将相应的物理内存区域以特定的内存类型(例如某些情况下不进行缓存)映射到虚拟地址空间内,并通过类似访问内存的方式访问设备寄存器。

DMA

PCI设备并不适用ISA设备所用的DMA控制器来进行DMA操作,而是通过自己的寄存器使操作系统可以控制DMA的传输。例如,操作系统可以先向特定的硬件寄存器写入DMA的地址,然后向另一寄存器写入DMA命令来发起一个DMA。这种方式可以使得DMA使用更大的物理地址空间。

中断

当设备需要通知操作系统处理某些中断时,它会通过其中断控制器发起中断。在CPU响应该中断时,一般会通过读写硬件设备的特定寄存器清除中断源。

接口拦截和模拟

通过VMM,可以将客户机对虚拟设备的软件接口的操作完全拦截下来,并交给设备模型处理。

端口I/O

端口I/O的实现。以IDE控制器为例,传统的PIO模式下的IDE控制器使用4个不同的端口I/O范围,分别是0x1f0 ~ 0x1f7、0x3f4 ~ 0x3f7、0x170 ~ 0x177以及0x374 ~ 0x377。其中,前两组对第一条IDE电缆(Primary),后两组对应第二条IDE电缆(Secondary)。所有IDE命令和数据的读写都会通过in、out、ins和outs这4条指令由客户机发起。对于这4条敏感指令,VMM可以通过修补、动态翻译或者直接 陷入的方式拦截并执行端口I/O的处理函数。在初始化阶段,设备模型首先会将这些I/O在VMM中进行注册,客户机运行过程中当这些端口的发生时,VMM会根据其端口号和访问的数据宽度(1、2、4字节等)分发至相应的设备模型预先注册的端口I/O处理函数,相应的端口I/O处理函数可以由此用软件模拟所需逻辑。下图描述了当客户机试图在主IDE设备的COMMAND寄存器块的DATA寄存器上写入4ge字节时,VMM及设备模型的处理过程。

IDE控制器设备模型的端口I/O处理IDE控制器设备模型的端口I/O处理

MMIO

需要较大寄存器空间的设备一般会使用MMIO,即内存映射的I/O,例如网络设备、显卡等。MMIO与物理内存共用一个地址空间,例如MMIO可以防止在3.75 ~ 4GB的高地址上。对于VMM、MMIO的处理方法与端口I/O是类似的,也是基于拦截/分发/处理这一过程。但是,MMIO的模拟与端口I/O相比也有一些显著的不同。

  1. 由于MMIO的访问不限于某些特定指令,因此不可能采用类似端口I/O的提前修补或翻译。为了使MMIO访问陷入,在初始化阶段客户机映射MMIO所属额物理地址范围时,VMM不会建立相应的影子页表项。而当运行时,客户机的MMIO访问都会造成缺页异常,VMM拦截这些异常后就可以将控制交由设备模型进行处理了。
  2. 一个I/O端口上可以进行多字节的访问,例如,对于0x1f0的4字节地址与0x1f1 ~ 0x1f7完全无关,在MMIO中则并非如此。一个4字节的MMIO寄存器一般会占用内存地址空间的4个字节。因此,对MMIO的处理要度访问宽度、越界和非对齐访问小心地检查和处理。
  3. 由于端口I/O的空间比较小,又不存在对齐问题,一般可以采用数组结构来存储各端口对应的处理函数,这会获得较高的性能。但是,在MMIO的情况下,由于所占范围较大(可能达上百MB),使用数组结构会占用过大的内存,利用率也很差。所以,一般MMIO分发都是基于区域(Region)实现的,即设备模型向VMM指定其可以处理的MMIO区域(基地址和长度)及相应的处理函数。这样的方法减少了内存的使用,但却在一定程度上影响了性能。在MMIO陷入发生时,VMM要根据异常地址查找其对应的区域。在MMIO较少时,可以简单地使用链表作为数据结构;当需要处理的MMIO区域非常多是,可能要考虑引入更加复杂的查找算法。
  4. 最后,由于MMIO与系统内存在同一地址空间,而且都是由缺页异常陷入的,MMIO与内存的异常并不容易区分。为了区分一个缺页异常是MMIO还是系统内存可能对系统性能带来进一步的影响。如果先处理MMIO,则会导致每一个普通的影子页表异常处理时间变长,使得系统整体性能变差;如果先处理内存异常,则MMIO的处理时间变长,这对I/O敏感的一些设备来说(例如网卡)都会造成性能上的下降。为解决这一矛盾,需要对系统的I/O和内存使用作出一定权衡,或引入更快的方法进行区分。

DMA

DMA的拦截相对简单,由于DMA的发起是通过设备的寄存器来控制的,设备模型在端口I/O或MMIO处理函数中就可以拦截所有DMA操作。以IDE控制器为例,PCI配置空间中有一项资源描述BMIBA(Bus Master Interface Base Address),指向了一块16个段口的与DMA操作有关的寄存器块。客户机通过对其中的命令寄存器BMICX的第0位置1就可以发起DMA操作。

设备模型不需要了解具体设备上DMA的实现方法,而只需将数据从客户机所属内存中读出或写入即可,这需要通过内存管理模块的帮助将客户机用于DMA传输的缓冲区映射到设备模型的地址空间内。与一般的多线程程序类似,在这一过程中,设备模型需要注意缓存和可能出现的更新顺序问题,以确保客户机在知道DMA结束的时候(例如通过中断),DMA内存中的数据是有效的。

PCI配置空间

客户机发现和初识化设备的时候会首先访问PCI配置空间,其中的基地址寄存器和命令寄存器使得客户机可以使用该设备的其他I/O资源。由于客户机所有设备使用相同的PCI设备空间寄存器来访问所有设备的配置空间,而配置空间的头部又有统一的标准,设备模型通常可以使用统一的配置空间处理函数来处理设备的I/O资源分配和映射。另外,客户机BIOS或操作系统可能会通过写入基地址寄存器的方法重新配置I/O资源的基地址,设备模型在拦截到这些操作后也要将对应资源映射做相应的修改。

然而,还是会有一些设备使用PCI配置空间可能与其他设备不同,从而需要特别处理。例如,由于历史原因,在访问第一个IDE控制器(或者唯一的IDE控制器)时,部分客户机会忽略基地址寄存器的前四项,而是使用特定的端口I/O 0x1f0、0x3f4、0x170和0x374。但是对于第二个IDE控制器(如果存在),客户机使用资源则会遵循基地址寄存器。因此,为了正确运行这些客户机,设备模型需要在基地址寄存器外额外注册这4组端口。再如,一部分设备会在配置空间中放置一些重要的设备相关寄存器(如与中断有关的寄存器等)。在处理这些设备的PCI配置空间时,就必须引入设备相关的逻辑。

中断

中断的处理需要设备模型模拟的中断控制器来处理。作为PCI设备,只需要控制其到中断控制器的中断线即可,与物理设备的逻辑和处理方法类似。

功能实现

由于在功能实现时不必拘泥于目标设备的硬件结构和组成,实现虚拟设备的功能要灵活得多。在前述的IDE存储系统的例子中,真实设备一般是由IDE控制器以及挂在其下的具体IDE硬盘所组成:IDE控制器是一个PCI设备,有一系列软件可控的接口;而硬盘本身则被控制器控制,没有独立的软件接口。而在虚拟IDE时,只需要将IDE控制器的软件接口模拟并暴露给客户机使用,而并不一定需要遵从控制器——硬盘这一真实物理结构。事实上,为了加大灵活性,虚拟IDE往往会搭建一个专门的块设备抽象层,它的实现可以是一块硬盘,或者一个分区,也可以是不同文件格式的单一文件。通过在块设备的实现上使用文件格式,可以引入一些真实硬件所没有货较难实现的高级特性,如加密、增量存储和备份等。

在实现虚拟设备的功能时,一般要访问物理上的真实硬件,这是通过运行环境(如宿主机操作系统)的系统调用完成的。这个过程中,设备模型和运行环境会一起给虚拟设备的I/O带来额外的时间和CPU的开销,这些与设备模型本身所采用的的I/O模型,VMM处理的及时性都有关。在对I/O效率要求较高的设备模拟时,例如网络设备,如何减少这些额外开销就显得尤其重要。

与一般的I/O密集型程序类似,I/O模型是影响设备模型和整个VMM性能的一个重要部分。下面是以POSIX运行环境为例的经过简化的3种典型的I/O模型,它们被不同VMM广泛使用。

将非阻塞查询置于主线程的主循环之中,每次循环都对等待的I/O操作进行非阻塞的查询,当无数据时则继续循环,否则读取并处理数据。当需要等待的I/O操作较多时,一般会用非阻塞的select代替。

非阻塞I/O非阻塞I/O

POSIX aio的设备模型,异步I/O,需要发起I/O时,设备模型在主线程中指定缓冲区并调用aio_read,然后继续循环,当数据结束时则自动调用信号处理函数完成通知过程。

异步I/O异步I/O

使用独立的I/O线程,允许设备模型使用阻塞I/O,将调度任务交给运行环境的调度器来完成。

独立I/O线程独立I/O线程

这三种不同的I/O模型其优缺点与普通程序也是一致的。非阻塞I/O实现较为简单,但由于需要等待运行部分结束后才进行查询,在数据到达之后会引入额外的延时。异步I/O在响应速度上略好,而且在主线程内减少了数据到达后重新调用read系统调用的时间,不过aio的跨平台性略差,而且需要健壮的信号处理程序来避免可能出现的丢失信号的问题。独立的I/O线程则较为稳定,可以依靠良好的调度器获得较好的性能。处理不同的I/O时,还可以使用多个I/O线程,进一步提高性能。

IDE的DMA操作

客户机的IDE DMA操作。假定虚拟设备的端口I/O为0x1f0 ~ 0x1f7(命令寄存器)、0x3f4 ~ 0x3f7(控制寄存器)和0xc100 ~ 0xc10f(DMA寄存器),设备模型使用aio方式。整个过程如下图所示。

IDE的DMA操作IDE的DMA操作
  1. 为了完成DMA操作,客户机驱动程序首先需要设置一个物理区域描述符表(Physical Region Descriptor Table,PRDT),并将其物理地址写入寄存器BMIDTPX(0xc104)。
  2. VMM拦截了这个端口I/O写入,并调用IDE设备模型中处理这个端口的相应函数。
  3. 设备模型用VMM所提供的内存管理功能将PRDT映射到自己的地址空间。
  4. 返回客户机后,驱动程序将用于存放读取数据的缓冲区的物理地址及长度写入PRDT。
  5. 客户机驱动程序通过命令寄存器0x1f0 ~ 0x1f7指定需要读取的IDE扇区,并通过写入0x1 ~ BMICX(0xc100)发起读DMA操作。
  6. 设备模型截获这个操作后,首先读取PRDT中的描述符,并将客户机缓冲区映射到自己的地址空间。然后通过扇区地址,计算出这些数据实际存在的位置,例如映像文件内的偏移。
  7. 设备模型使用aio_read系统调用发起读取操作,将实际数据读入缓冲区,然后返回客户机运行。
  8. 当异步I/O结束之后,运行环境通过信号通知设备模型。当设备模型的信号处理函数运行时,需要读取的数据已经被读入了缓冲区中。
  9. 设备模型通过虚拟中断控制器项客户机注入中断。
  10. 客户机响应中断,通过写寄存器BMISX(0xc102)清除中断标志。至此,一次IDE的DMA读操作就结束了,在客户机缓冲区内出现的数据就可以被客户机继续使用。