设备虚拟化是虚拟机中复杂且难以普遍适用的部分。常见的虚拟化设备有仿真(全虚拟化)设备、半虚拟化设备和直通设备。后面会依次介绍其原理 。
虚拟化技术在刚开始的时候,虚拟机监视器开发商如VMware并不是操作系统供应商,VMM开发商只能直接在客户机运行市面上现成的操作系统。这些商业操作系统采用的设备驱动程序都是市面上已经有具体硬件实现的设备驱动程序,如NE2000网卡驱动和VGA显卡驱动等。VMM开发商通过在VMM层面模拟一个虚拟的NE2000网卡或者VGA显卡的方法,来实现对客户机网络设备和现实设备的支持。这种模拟方法就是全虚拟化的设备仿真(emulation),其模型如图3-12所示。
全虚拟化情况下的设备仿真可以使Hypervisor很容易实现对各种现成的商业操作系统(如Windows)的支持,但是可能不是最高效的。传统设备(如NE2000和VGA)的设计是为了设备硬件本身以及当时的计算机平台设计优化的,这些设计的接口并没有预测到虚拟化背景下的设备仿真需要,因此也不是最优的,甚至是很差的。举例来说,NE2000是20世纪90年代中后期的产品,那时候的PC主频还只有几十兆赫,那时一个MMIO的访问相对于缓存和内存访问虽然慢一些,但是不像现在有这么大差距(现在一个面向Cache的内存访问大概只需要十几个CPU周期,面向内存的访问是几百个CPU周期,但是一个MMIO访问需要几千个CPU周期)。NE2000的设计在处理一个网络包的时候,需要访问多个MMIO寄存器。这在当时的硬件上是没有问题的,但是在VMM情况下成为一个巨大瓶颈,因为VMM对一个MMIO访问的模拟一般需要几万甚至十几万个CPU周期。由于全虚拟化的模拟中存在大量的端口I/O(Port I/O,PIO)以及MMIO(Memory-Mapped I/O)的拦截模拟操作,因此全虚拟化的设备仿真性能很差。
图3-12 仿真设备模型
仿真设备不需要对客户机做任何改动,客户机运行已有设备的驱动程序,而VMM通过对客户机的I/O(含PIO和MMIO)访问的陷入和模拟(trap-and-emulate),使客户机感觉犹如运行在真实的原生系统中。图3-12显示了仿真设备的工作原理。仿真设备引入了大量的虚拟机陷入和模拟事件,因此性能是最差的,为了提高设备模拟的性能产生了半虚拟化(Paravirtualization)的设备模型。
VMM设计人员从VMM的特点出发,发挥VMM的长处,如快速共享内存访问速度,避开VMM的短处,如过长的MMIO模拟开销,开始重新设计专门应用在VMM情况下的虚拟设备接口以便获得较高的性能。虚拟设备I/O(virtio)就是在这样的背景下产生的,其概念最早在Austin举办的第一届KVM会议上提出,与会者确定了基于PCIe总线的设计思路(相对于Xen和Hyper-V基于自己专有的虚拟总线)。半虚拟化设备模型首先基于VMM定义设备接口,如虚拟MMIO寄存器和用于描述I/O请求的虚拟环,然后在这个接口的基础上同时开发运行在客户机中的前端设备驱动程序和运行在VMM上的后端设备仿真程序。与全虚拟化设备仿真相比,半虚拟化设备模型大大减少了客户机对MMIO的访问次数,节省了VMM的虚拟化开销,大幅提高了I/O虚拟化的性能。其工作模型如图3-13所示。
虽然半虚拟化设备模型在很大程度上提高了虚拟I/O的性能,降低了客户机和VMM之间在I/O命令传递上的开销,但是在数据传输环节,仍需要从真实硬件接收数据并将数据复制到客户机内存中,即模拟虚拟DMA的过程。在数据流量大的情况下,虚拟DMA的模拟过程会成为整个系统的瓶颈,极大地阻碍虚拟机性能的进一步提高。直通设备和单根I/O虚拟化(Single Root I/O Virtualization,SR-IOV)由此产生。
图3-13 半虚拟化设备模型
VMM对客户机设备DMA操作的模拟在高速设备上成为主要的性能瓶颈,这使得硬件辅助成为必需,于是IOMMU被引入虚拟化的计算机系统。IOMMU对来自设备(客户机)的DMA请求的地址进行重新影射,使VMM不用介入每一个DMA操作本身。图3-14展示了Intel公司的IOMMU体系结构,AMD公司的IOMMU体系结构基本类似。在IOMMU中,每一个设备可以被一个唯一的设备地址标识,即32位的BDF(Bus,Device,Function)号码。
图3-14 IOMMU体系结构
如图3-15所示,直通设备模型完全消除了客户机设备驱动程序运行时对DMA和设备I/O访问的模拟导致的虚拟化开销,因此具有最好的性能。直通设备模型利用IOMMU在硬件层面实现对客户机编程的DMA地址到真实DMA地址(或者称为主机地址)的转换。在直通设备模型中,该硬件设备只能被一个客户机使用。客户机驱动程序运行时对I/O的访问(主要是MMIO)可以直接传递到硬件设备,而不需要VMM的介入。这是因为该设备已经被客户机专用,同时客户机驱动程序编程的DMA地址可以被IOMMU重新影射到主机地址。直通设备模型具有最高的设备虚拟化性能,但是它牺牲了设备的共享特性。
单根I/O虚拟化在继承直通设备模型高性能的基础上,同时实现了设备共享。在单根I/O虚拟化中,设备硬件需要能够提供多个PCIe实例(PCIe Function),而每一个PCIe实例具有自己的PCIe标识地址即BDF。这些PCIe实例共享大部分的设备硬件资源,由硬件实现内部各个PCIe实例之间的资源调度。单根I/O虚拟化在客户机上运行一个虚拟功能(Virtual Function,VF)实例驱动程序,控制VF设备,而在主机特权虚拟机上运行物理功能(Physical Function,PF)实例驱动程序,控制PF设备。PF设备通常具有对VF设备的控制、配置等权限,从而在实现高性能共享的同时实现安全、隔离和管理性。图3-16显示了单根I/O虚拟化的原理。
图3-15 直通设备模型
图3-16 SR-IOV的原理
上述三种虚拟化设备模型其实是三种不同的实现方案。不难看出,三种方案不是简单的并列关系,而是从前一种方案面临性能瓶颈的角度出发,提出新的解决方案来对性能加以提升,这与3.3.4节所讲述的内存虚拟化的两种方案异曲同工,设备虚拟化的实现方案同样是一个逐渐演进的过程。
KVM对各种设备虚拟化方式均有支持,其在设备虚拟化方面的代码实现可谓鸿篇巨制,本节无法逐一详述。在此仅以KVM对MMIO的模拟实现为例,阐述全虚拟化情况下KVM的模拟过程,使读者体会其性能瓶颈所在。这是进一步探究KVM对其他两种方案实现的基础,同时由于其访问方式是内存映射,因此也是对KVM内存虚拟化内容的一个补充。
内存映射I/O(Memory-Mapped I/O,MMIO)是PCI规范的一部分,I/O设备被放置在内存空间而不是I/O空间。从处理器的角度看,设备经内存映射后就像访问普通内存一样。KVM中定义了多种内存区域类型,如随机存取存储器(Random-Access Memory,RAM)、MMIO、只读存储器(Read-Only Memory,ROM)等,MMIO便是其中之一。KVM在模拟MMIO时,一方面通过执行与客户机操作系统相同的指令,模拟客户机读写MMIO内存的行为,另一方面则要监控客户机对MMIO内存的访问,一旦有读写操作,就需要陷入KVM中来触发执行用户态VMM注册的回调函数。
MMIO内存与普通RAM内存不同,客户机在写MMIO内存时每次都会引发页面错误(Page Fault)并发生VM Exit,交由后端处理,类似于敏感指令。同样都是由于页面错误导致的退出,KVM如何区分是由MMIO还是由普通内存产生的页面错误呢?
在前面曾经提到,用户态VMM是通过ioctl(KVM_SET_USER_MEMORY_REGION)这一系统调用来为RAM设置内存区域的,即将该内存GPA到HVA的映射关系传递给KVM,KVM在收到这些信息后,会为其分配内存槽。而对于模拟MMIO内存空间而言,用户态VMM虽然初始化了该内存区域,但在向KVM传递内存区域信息时确定它不是RAM则不会为其注册,KVM接收不到调用通知,自然也不会为其分配相应内存槽。
然而,MMIO毕竟直接将I/O设备映射到物理地址空间,而虚拟机物理内存的虚拟化又是通过EPT机制来完成的,那么EPT机制势必会在实现模拟MMIO的过程中发挥一定的作用。当客户机第一次访问MMIO地址时,发现它对应的GPA在EPT页表中不存在,这将导致EPT违例触发VM Exit退出到KVM。KVM发现该GPA对应的宿主机PFN不存在,显然这是由于当初用户态VMM根本没有为其进行注册,因此KVM推断这必然是一个MMIO地址,进而设置PFN为KVM_PFN_NOSLOT。图3-17展示了当客户机第一次访问MMIO地址发生EPT违例时,KVM创建MMIO的EPT页表项的一系列调用过程。
图3-17 KVM创建MMIO的EPT页表项的调用过程
KVM最终通过make_mmio_spte()生成MMIO页表项,并对其进行特殊标记,代码如下:
这里的64位无符号整型变量spte就是用来存储EPT页表项值的。读者可能会问,spte不是用来命名影子页表项的吗?的确如此,KVM在EPT机制下依然沿用了影子页表的数据结构和变量名称,从而也再次印证了一套代码框架可以兼容两种不同的内存虚拟化方案。变量shadow_mmio_value存储MMIO的EPT页表项的特殊标记值,凡置位该特殊标记的EPT页表项将会被识别为MMIO地址空间,它最初是在VMX模块初始化过程中赋值的。图3-18展示了KVM初始化MMIO的EPT页表项特殊标记值的调用过程。
图3-18 KVM初始化MMIO的EPT页表项特殊标记值的调用过程
KVM会将初始值VMX_EPT_MISCONFIG_WX_VALUE传递给kvm_mmu_set_mmio_spte_mask()函数并最终记录在变量shadow_mmio_value中,代码如下:
常量VMX_EPT_MISCONFIG_WX_VALUE的定义如下:
从以上定义可知,KVM将MMIO的特殊标记值设为可写可执行而不可读,即110b。通过查阅《英特尔64位与IA-32架构软件开发人员手册》 得知,这是一种EPT谬置(EPT misconfiguration),是引发VM Exit的情形之一。在翻译GPA的过程中,当逻辑处理器遇到包含不支持的EPT页表项值时会发生EPT谬置。至此问题得以诠释,原来,KVM正是借助EPT机制刻意构造了一类EPT谬置从而专门用来区分MMIO内存地址,以在客户机访问MMIO地址空间时触发由EPT谬置导致的VM Exit,KVM借此对其与普通内存加以区别。
在客户机首次访问MMIO地址发生EPT违例时,由make_mmio_spte()返回生成的页表项spte后,KVM进而会通过mmu_spte_set()最终调用__set_spte()实际写入硬件,这样就结束了由EPT违例引发的VM Exit从而返回至客户机。与处理普通内存页面错误的流程类似,客户机会重新执行先前导致EPT违例的指令再次访问该MMIO的GPA,但此次因KVM早已将该GPA对应的EPT页表项置位了特殊标记,则会直接产生由EPT谬置所引发的VM Exit,进而由KVM对相应的MMIO捕获(MMIO Trap)做出处理。图3-19展现了当客户机再次访问该MMIO地址进而发生EPT谬置时KVM模拟MMIO指令的一系列调用过程。
图3-19 发生EPT谬置时KVM模拟MMIO指令的调用过程
与handle_ept_violation()开始的处理类似,handle_ept_misconfig()也会调用kvm_mmu_page_fault()进行页面错误处理,与之不同的是在调用handle_mmio_page_fault()后分道扬镳,该函数负责判断一个页面错误是否由MMIO引起,其主要代码如下:
函数get_mmio_spte()通过addr指定的GPA遍历EPT页表获取其对应的页表项,并将其内容返回至spte中,函数is_mmio_spte()根据之前在spte所置的特殊标记做出判断。通常情况下spte应为MMIO页表项,故函数会返回RET_PF_EMULATE,表示需要KVM模拟MMIO内存的读写指令。
接下来便会执行x86_emulate_instruction()函数进行指令模拟,值得一提的是,当模拟指令的操作数是内存地址时,还需根据该地址遍历客户机页表,这是由于该操作数所记录的内存地址仍为客户机虚拟地址(GVA),遍历所调用的转址回调函数gva_to_gpa()就是在内存管理单元(MMU)初始化阶段准备完成的。因指令模拟的实现逻辑较为复杂,其中涉及指令解码、特殊指令的处理等,此处不再赘述,感兴趣的读者可以根据实际需要做进一步的研究。
KVM完成MMIO读写指令模拟后,就会切入非根模式,返回用户态进入客户机系统。