《重识云原生系列》专题索引:
主流开源虚拟化技术KVM知识地图:
KVM知识地图
KVM:Kernel-based Virtual Machine,是基于Linux内核的开源虚拟化解决方案,从2.6.20版本开始被合入kernel主分支维护。最初只支持X86平台的上支持VMX或者SVM的CPU,不久后被确认为标准Linux内核的虚拟化方案并逐步支持S390、IA64和PowerPC等体系架构;KVM本身只提供部分的虚拟化功能(虚拟CPU和内存),而由经过特殊改造后的Qemu(Qemu-kvm)来帮助下提供完整的平台虚拟化功能。
KVM依赖于x86硬件的虚拟化特性,提供全虚拟化的虚拟机,其基本思想是在Linux内核的基础上添加虚拟机管理模块,重用Linux内核中已完善和成熟的机制和模块,比如进程调度、内存管理、IO管理等,使之成为一个可以支持运行虚拟机的Hypervisor。整体架构如下图所示:
KVM是基于硬件辅助虚拟化技术(如Intel VT-x)的全虚拟化解决方案,如上图所示,说明如下:
VMM(即KVM内核)运行于根模式下的Ring0;
宿主机上的用户态进程运行于根模式下的Ring3;
虚拟机中的Kernel运行于非根模式下的Ring0;
虚拟机中的用户态进程运行于非根模式下的Ring3;
Qemu-kvm是KVM官方提供并维护的改进后的Qemu,针对KVM解决方案,将标准Qemu做了针对性的改造,使其具有更好的性能,并与KVM进行了完美的融合;
一个VM(虚拟机)就是一个传统的Linux进程,VM运行于Qemu-KVM进程的地址空间中;
VMM向上层提供/dev/kvm接口,/dev/kvm是一个标准的字符设备,通过ioctl接口控制;Qemu-kvm通过调用/dev/kvm设备的ioctl接口,对虚拟机进行相关控制,比如创建虚拟机、创建VCPU、运行虚拟机等;
为提升KVM虚拟机中的IO性能,KVM还提供了Virtio驱动,相当于Xen环境中的半虚拟化驱动。
如下图,KVM虚拟化有两个核心模块:
1)KVM内核模块:负责CPU与内存虚拟化,包括VM创建,内存分配与管理、vCPU执行模式切换等,主要包括KVM虚拟化核心模块kvm.ko,以及硬件相关的kvm-intel.ko或kvm-amd.ko模块。
Kvm.ko是KVM的核心公共模块,kvm-intel.ko和kvm-amd.ko分别是针对Intel和AMD平台架构的独立模块。在KVM核心公共模块中,包含了IOMMU、中断控制、KVM arch、设备管理等部分代码,这些代码构成了虚拟机管理的核心功能,从这些模块的大致信息,也可以看出KVM自身并没有实现一个完整的PC系统的虚拟化,而只是实现了最核心的CPU虚拟化、内存虚拟化和IO虚拟化等部分功能并向上层提供了相应的API,其余虚拟化和管理工作主要交给了Qemu-kvm负责。
2)QEMU-KVM设备模拟模块:实现IO虚拟化与各设备模拟(磁盘、网卡、显卡、声卡等),通过IOCTL系统调用与KVM内核交互。KVM仅支持基于硬件辅助的虚拟化(如Intel-VT与AMD-V),在内核加载时,KVM先初始化内部数据结构,打开CPU控制寄存器CR4里面的虚拟化模式开关,执行VMXON指令将Host OS设置为root模式,并创建的特殊设备文件/dev/kvm等待来自用户空间的命令,然后由KVM内核与QEMU相互配合实现VM的管理。KVM会复用部分Linux内核的能力,如进程管理调度、设备驱动,内存管理等。
KVM虚拟化架构图
KVM运行过程中存在三种模式:
KVM运行的基本如下图所示:
KVM运行流程图
流程描述:
如下图,物理服务器上通常配置2个物理pCPU(Socket),每个CPU有多个核(core)。开启超线程Hyper-Threading技术后,每个core有2个线程(Thread)。在虚拟化环境中一个Thread对应一个vCPU。在KVM中每一个VM就是一个用户空间的QEMU进程,分配给Guest的vCPU就是该进程派生的一个线程Thread,由Linux内核动态调度到基于时分复用的物理pCPU上运行。KVM支持设置CPU亲和性,将vCPU绑定到特定物理pCPU,如通过libvirt驱动指定从NUMA节点为Guest分配vCPU与内存。KVM支持vCPU超分(over-commit)使得分配给Guest的vCPU数量可超过物理CPU线程总量。
pCPU与vCPU关系图
2.1.2.1 VCPU描述符数据结构
硬件虚拟化使用VCPU(Virtual CPU)描述符来描述虚拟CPU,VCPU描述符与OS中进程描述符类似,本质是一个结构体,其中包含如下信息:
总体来说,VCPU可以划分为两部分:
当VMM创建虚拟机时,首先要为虚拟机创建VCPU,整个虚拟机的运行实际上可以看做VMM调度不同的VCPU运行。
CPU虚拟化是VMM中最核心的部分,由于内存和IO访问的指令都是敏感指令,所以内存和IO虚拟化都依赖于CPU虚拟化的实现。CPU虚拟化的目标是让虚拟机中执行的所有敏感指令都能产生异常而“陷入”,并由VMM进行捕获模拟。VMM的陷入是通过CPU的保护机制、中断或异常来完成的。通常,VMM的陷入方式有如下3种:
1、由CPU的保护机制触发。CPU在执行敏感指令之前,会检查执行条件是否满足,执行条件主要包括:当前特权级别、运行模式、内存映射关系等,只要有任一条件不满足,就会VM-Exit陷入到VMM进行进一步处理。
2、异步中断。包括处理器内部的中断源和外设的中断源,当中断信号到达CPU时,CPU会强行中断Guest OS当前执行的指令,然后VM-Exit到VMM注册的中断服务程序进行进一步处理。
3、虚拟机主动触发的异常,也就是通常所说的陷阱。虚拟机可以通过陷阱指令主动VM-Exit到VMM中。
在KVM虚拟化解决方案中,利用了硬件的虚拟化特性(比如Intel VT-x和AMD-V)实现CPU的虚拟化。如之前介绍,VT-x提供了一套称作VMX的新的工作模式,工作在该模式下的处理器又具有两类操作模式:VMX root operation和VMX non-root operation。对操作系统来说,VMX non-root operation模式与传统的x86处理器兼容,最大的差别在于当虚拟机执行一些访问全局资源的指令时将导致虚拟机退出操作(VM-Exit),从而使虚拟机监控器获得控制权,以便对访问全局资源的指令进行模拟。以后,虚拟机监控器可以通过虚拟机进入操作(VM-Entry)使虚拟机重新获得控制权。
其次,VT-x为系统编程接口状态的切换提供硬件支持。VT-x为每个虚拟机维护至少一个VMCS(Virtual Machine Control Structure)结构,其中保存了虚拟机和虚拟机监控器的系统编程接口状态。当执行VM exit和VM entry操作时,VT-x自动根据VMCS中的内容完成虚拟机和虚拟机监控器间的系统编程接口状态切换。另外,VT-x还提供了一组指令,使得虚拟机监控器通过一条指令就可以完成虚拟机间的切换。
如下图,KVM内核加载时执行VMXON指令进入VMX操作模式,VMM进入VMX Root模式,可执行VMXOFF指令退出。GuestOS执行特权或敏感指令时触发VM Exit,系统挂起GuestOS,通过VMCALL调用VMM切换到Root模式执行,VMExit开销是比较大的。VMM执行完成后,可执行VMLANCH或VMRESUME指令触发VM Entry切换到Non-root模式,系统自动加载GuestOS运行。
VMX定义了VMCS(Virtual Machine Control Structure)数据结构来记录vCPU相关的寄存器内容与控制信息,发生VMExit或VMEntry时需要查询和更新VMCS。VMM为每个vCPU维护一个VMCS,大小不超过4KB,存储在内存中VMCS区域,通过VMCS指针进行管理。VMCS主要包括3部分信息,control data主要保存触发模式切换的事件及原因;Guest state 保存Guest运行时状态,在VM Entry时加载;Host state保存VMM运行时状态,在VM Exit时加载。通过读写VMCS结构对Guest进行控制。
VMX Root/Non-Root 模式切换图
在物理平台上,中断架构和外部中断处理流程如下图所示:
外部中断处理流程图
大致处理过程为:
在虚拟化环境中,VMM为Guest OS虚拟了一个与物理中断架构类似的虚拟中断架构,如下图所示:
虚拟中断架构示意图
每个VCPU对应一个虚拟Local APIC,用于接收中断,同时还模拟了虚拟IO APIC(或虚拟PIC)用于接收外设(虚拟设备)发出的中断请求并进行转发。虚拟Local APIC、虚拟IO APIC、虚拟PIC等都是VMM中的软件实体(对应于相应的数据结构)。主要处理流程如下:
在KVM虚拟化环境中,KVM虚拟机作为一个系统进程运行,因此虚拟机的调度,实际上就是利用了Linux自身的调度器完成。本文不做详述。
在物理机上,虚拟地址通过Guest页表即可转换为物理地址。但是在虚拟化环境中,由于VMM和VM都需要独立的地址空间,则产生了冲突。
为实现内存虚拟化,让客户机使用一个隔离的、从零开始且具有连续的内存空间,KVM 引入一层新的地址空间,即客户机物理地址空间 (Guest Physical Address, GPA),该地址空间并不是真正的物理地址空间,它只是宿主机(Host主机)虚拟地址空间在Guest地址空间的一个映射。对Guest来说,客户机物理地址空间都是从零开始的连续地址空间,但对于宿主机来说,客户机的物理地址空间并不一定是连续的,客户机物理地址空间有可能映射在若干个不连续的宿主机地址区间,如下图所示:
由于物理MMU只能通过Host机的物理地址(Host Physical Address, HPA)进行寻址,所以实现内存虚拟化,关键是需要将Guest机的虚拟地址(Guest Virtual Address, GVA)转换为HPA。传统的实现方案中,这个过程需要经历:GVA -> GPA -> HVA -> HPA的转换过程,需要对地址进行多次转换,而且需要KVM的介入,效率非常低。为提高GVA到HPA的地址转换效率,KVM提供了两种地址转换方式:
另外,CPU使用TLB(Translation Lookaside Buffer)缓存线性虚拟地址到物理地址的映射,地址转换时CPU先根据GPA先查找TLB,如果未找到映射的HPA,将根据页表中的映射填充TLB,再进行地址转换。如果基于影子页表方案,不同Guest的vCPU切换执行时需要刷新TLB,严重影响了内存访问效率。因此,Intel引入了VPID(Virtual-Processor Identifier)技术在硬件上为TLB增加一个标志,每个TLB表项与一个VPID关联,唯一对应一个vCPU,当vCPU切换时可根据VPID找到并保留已有的TLB表项,减少TLB刷新。
2.2.2.1 基本原理
由于内存虚拟化在将GVA转换为HPA的过程中,需要经历多次转换,无法直接使用Guest机页表和CR3页表寄存器。使用影子页表(Shadow Page Table)可以实现客户机虚拟地址(GVA)到宿主机物理地址(HPA)的直接转换,与传统方式的转换过程对比如下:
影子页表中记录的是GVA跟HPA的对应关系,每个页表项指向的都是宿主机的物理地址。由于客户机中每个进程都有自己的虚拟地址空间,所以 KVM 需要为客户机中的每个进程页表都要维护一套相应的影子页表。在Guest机访问内存时,VMM在物理MMU中载入的是Guest机当前页表所对应的影子页表,从而实现GVA到HPA的直接转换。
Guest机中的每一个页表项都有一个影子页表项与之相对应。为了快速检索Guest机页表所对应的的影子页表,KVM 为每个客户机都维护了一个哈希表,影子页表和Guest机页表通过此哈希表进行映射,基本原理如下:
对于每一个Guest机来说,Guest机的页目录/页表都有唯一的GPA,通过页目录/页表的GPA就可以在哈希链表中快速地找到对应的影子页目录/页表。在检索哈希表时,KVM 把Guest页目录/页表的客户机物理地址低10位作为键值进行索引,根据其键值定位到对应的链表,然后遍历此链表找到对应的影子页目录/页表。当然,如果没有找到对应的影子页目录/页表,则说明影子页表项和Guest页表项的对应关系还没有建立 ,此时KVM 会为其分配新的物理页,并建立起Guest页目录/页表和对应的影子页目录/页表之间的映射。
2.2.2.2 影子页表的建立与更新
影子页表的建立和更新过程交织在一起,影子页表的建立和更新主要发生在如下3种情况下:
1、Guest OS修改Guest CR3寄存器。由于相关指令为敏感指令,所以相关操作会被VMM截获,此时VMM会根据相关情况进行影子页表的维护。比如,当客户机切换进程时,客户机操作系统会把待切换进程的页表基址载入 CR3,而该特权指令将被VMM截获,进行新的处理,即在哈希表中找到与此页表基址对应的影子页表基址,载入客户机 CR3,使客户机在恢复运行时CR3 实际指向的是新切换进程对应的影子页表。
2、因Guest主机页表和影子页表不一致而触发的缺页异常,此时也会VM-Exit到VMM,进而可进行相关维护操作。
3、Guest OS中执行INVLPG指令刷新TLB时,由于INVLPG指令为敏感指令,所以该操作也会被VMM进行截获,并进行影子页表相关维护操作。
其中,第2种情况发生几率最高,相关处理也最复杂,如下做重点描述。不同的缺页异常,处理方式不用,常见的缺页异常包括如下3类:
1)影子页表初始化时产生的缺页异常,在虚拟机运行之初,VMM中与Guest主机页表对应的影子页表都没有建立,而宿主机CR3寄存器中载入的却是影子页目录地址,所以,此时任何的内存操作都会引发异常,如果此时Guest主机的相应页表已经建立,那么处理这种异常即是建立相应的影子页表可;如果Guest主机的页表项尚未建立,那就是Guest主机自身的缺页异常,即为如下的第2种情况。
2)Guest主机上的缺页异常。如果Guest OS尚未给这个GVA分配Guest主机物理页,即相应的Guest主机页表项尚未建立,此时将引发缺页异常。另外,当Guest机访问的Guest页表项存在位(Present Bit)为0,或相关访问权限不匹配时,也将引发缺页异常。
3)VMM将Host机物理页换出到硬盘上时引发的缺页异常。
影子页表缺页异常的默认处理流程
VMM截获缺页异常(VM-Exit),并检查此异常是否由Guest即自身引发,如果是,则将直接返回Guest OS(Vm-Entry),然后由Guest OS自身的page fault流程处理;如果不是,则为影子页表和Guest机页表不一致导致,这样的异常也叫“影子缺页异常”,此时,VMM会根据Guest机页表同步影子页表,过程如下:
影子页表和Guest主机页表不是时刻同步的,只有在需要时才进行通过,从某种角度看,影子页表可以看做是Guest页表的TLB,常称为虚拟TLB(VTLB)。
影子页表解决了传统IA32架构下的内存虚拟化问题,由于影子页表可被载入物理 MMU 为客户机直接寻址使用, 所以客户机的大多数内存访问都可以在没有 KVM 介入的情况下正常执行,没有额外的地址转换开销,也就大大提高了客户机运行的效率。但也有比较明显的缺点:
2.2.3.1 简述
为解决影子页表的问题,Intel在CPU中使用EPT技术,AMD也提供的类似技术叫做NPT,即Nested Page Tables,都是直接在硬件上支持GVA-->GPA-->HPA的两次地址转换,从而降低内存虚拟化实现的复杂度,也进一步提升了内存虚拟化的性能。本文主要以Intel EPT技术为例进行阐述。
2.2.3.2 EPT页结构
Intel处理器设计了EPT的页结构用来将保存GPA到HPA的映射,因此说EPT是硬件支持的,EPT的页结构如下图的咖啡色部分,整个页结构有4级,和4-level分页模式的结构一模一样,只有存放PML4 Table物理地址的地方不一样,4-level将PML4 Table的地址存放在CR3寄存器中;EPT没有,EPT中PML4 Table的地址被称为EPTP,它存放在VMCS中的VM-execution 控制字段。VMM用VMCS来配置VM的运行环境以及控制VM的运行。
进入客户态之前,VMM首先通过特殊指令VMPTRLD加载VMCS内存地址的指针,其中就包括EPTP字段,因此理论上EPTP是针对每个虚机而言的,EPT对每个虚机是可以配置的。有人会问,客户态的所有物理地址都是GPA,那么存放EPT物理地址的EPTP是什么地址呢?个人理解,这个地址不是GPA,它非常特殊,因为它是在未进入客户态之前由VMM加载的,不可能是GPA,应该是主机上的地址HPA。
2.2.3.3 EPTP —— Extended Page Table Pointer
EPTP(extended-page-table pointer)是VMCS中存放EPT页表物理地址的字段,它的格式如下:
EPTP格式
EPTP指向的下一级是PML4 Table,它是页对齐的,因此至少是4K对齐,所以低12 bit没有用,可以拿来复用,其中bit 6是和脏页跟踪有关的标志位,它被置1时,CPU访问EPTP指向的任何下级页结构的时候,Access位都会被置1,最后的页表项中的Dirty位会被置1。
EPTP bit 12之上的字段用来存放下一级表PML4 Table的物理地址。
2.2.3.4 PML4E —— Page Map Level 4 Entry
PML4E格式
Accessed flag:bit 8访问标志位,当EPTP中的accessed and dirty flags置位时,CPU每次通过EPT多级页表结构转换物理地址时,只要使用到这一级的页表项,就需要将Accessed flag置位。
Physical address:bit 12 ~ bit (N-1),存放下一级页表结构的物理地址。
2.2.3.5 PDPT —— Page Directory Pointer Table
PDPT格式
Accessed flag:bit 8访问标志位,当EPTP中的accessed and dirty flags置位时,CPU每次通过EPT多级页表结构转换物理地址时,只要使用到这一级的页表项,就需要将Accessed flag置位。
Physical address:bit 30 ~ bit (N-1),存放下一级页表结构的物理地址。
2.2.3.6 PDE —— Page-Directory Entry
PDE格式
Accessed flag:bit 8访问标志位,当EPTP中的accessed and dirty flags置位时,CPU每次通过EPT多级页表结构转换物理地址时,只要使用到这一级的页表项,就需要将Accessed flag置位。
Physical address:bit 12 ~ bit (N-1),存放下一级页表结构的物理地址。
2.2.3.7 PTE —— Page Table Entry
PTE格式
Accessed flag:bit 8访问标志位,当CPU对该页表项指向的物理页又访问操作时,读写操作都算,会将Dirty flag置位。
Dirty flag:bit 9脏页标志位,当CPU对该页表项指向的物理页进行写操作时,会将Dirty flag置位。
Physical address:bit 12 ~ bit (N-1),存放下一级页表结构的物理地址。
2.2.3.8 EPT缺页异常处理
在GPA到HPA转换的过程中,由于缺页、写权限不足等原因也会导致客户机退出,产生 EPT 异常。对于 EPT 缺页异常,处理过程大致如下:
EPT 页表相对于影子页表,其实现方式大大简化,主要地址转换工作都由硬件自动完成,而且Guest内部的缺页异常也不会导致VM-Exit,因此Guest运行性能更好,开销更小。
2.2.3.9 EPT地址转换流程
EPT地址转换流程图
完整的地址翻译流程描述为:
EPT页表实现GPA到HPA的转换的原理,与Guest页表实现GVA到HPA的转换原理相同,需要经历多级页表的查询,图中没有详细画出。假设Guest机有m级页表,宿主机EPT有n级,在TLB均miss的最坏情况下,会产生m*n次内存访问,完成一次客户机的地址翻译,EPT硬件通过增大硬件EPT TLB来尽量减少内存访问。
2.2.3.10 EPT页表的建立流程
初始情况下:Guest CR3指向的Guest物理页面为空页面;
Guest页表缺页异常,KVM采用不处理Guest页表缺页的机制,不会导致VM Exit,由Guest的缺页异常处理函数负责分配一个Guest物理页面(GPA),将该页面物理地址回填,建立Guest页表结构;
完成该映射的过程需要将GPA翻译到HPA,此时该进程相应的EPT页表为空,产生EPT_VIOLATION,虚拟机退出到根模式下执行,由KVM捕获该异常,建立该GPA到HOST物理地址HPA的映射,完成一套EPT页表的建立,中断返回,切换到非根模式继续运行。
VCPU的mmu查询下一级Guest页表,根据GVA的偏移产生一条新的GPA,Guest寻址该GPA对应页面,产生Guest缺页,不发生VM_Exit,由Guest系统的缺页处理函数捕获该异常,从Guest物理内存中选择一个空闲页,将该Guest物理地址GPA回填给Guest页表;
此时该GPA对应的EPT页表项不存在,发生EPT_VIOLATION,切换到根模式下,由KVM负责建立该GPA->HPA映射,再切换回非根模式;
如此往复,直到非根模式下GVA最后的偏移建立最后一级Guest页表,分配GPA,缺页异常退出到根模式建立最后一套EPT页表。
至此,一条GVA对应在真实物理内存单元中的内容,便可通过这一套二维页表结构获得。
TLB(Translation Lookaside Buffer)转换检测缓冲区是一个内存管理单元,用于改进虚拟地址到物理地址转换速度的缓存。
VPID(VirtualProcessor Identifiers,虚拟处理器标识),是在硬件上对TLB资源管理的优化,通过在硬件上为每个TLB项增加一个标识,用于不同的虚拟处理器的地址空间,从而能够区分开Hypervisor和不同处理器的TLB。
硬件区分了不同的TLB项分别属于不同虚拟处理器,因此可以避免每次进行VM-Entry和VM-Exit时都让TLB全部失效,提高了VM切换的效率。
由于有了这些在VM切换后仍然继续存在的TLB项,硬件减少了一些不必要的页表访问,减少了内存访问次数,从而提高了Hypervisor和客户机的运行速度。
VPID也会对客户机的实时迁移(Live Migration)有很好的效率提升,会节省实时迁移的开销,会提升实时迁移的速度,降低迁移的延迟(Latency)。
VPID与EPT是一起加入到CPU中的特性,也是Intel公司在2009年推出Nehalem系列处理器上新增的与虚拟化相关的重要功能。
基于EPT与VPID的内存地址转换
x86 CPU默认使用4KB的内存页面,目前已经支持2MB,1GB的内存大页(Huge Page)。使用大页内存可减少内存页数与页表项数,节省了页表所占用的CPU缓存空间,同时也减少内存地址转换次数,以及TLB失效和刷新的次数,从而提升内存使用效率与性能。但使用内存大页也有一些弊端:如大页必须在使用前准备好;应用程序必须显式地使用大页(一般是调用mmap、shmget或使用libhugetlbfs库进行封装);需要超级用户权限来挂载hugetlbfs文件系统;如果大页内存没有实际使用会造成内存浪费等。
2009年实现的透明大页THP(Transparent Hugepage)技术创建了一个抽象层,能够自动创建、管理和使用传统大页,实现发挥大页优势同时也规避以上弊端。当前主流的Linux版本都默认支持。KVM中可以在Host和Guest中同时使用THB技术。
由于Guest使用内存时采用瘦分配按需增加的模式,KVM通过支持内存超分(Over-Commit)可使得分配给Guest的内存总量大于实际物理内存总量,从系统访问性能与稳定性考虑,在生产环境中一般不建议使用内存超分。内存超分有三种实现方式:
1) 内存交换(Swapping):当系统内存不够用时,把部分长时间未操作的内存交换到磁盘上配置的Swap分区,等相关程序需要运行时再恢复到内存中。
2) 气球(Ballooning):通过virtio_balloon驱动实现动态调整Guest与Host的可用内存空间。气球中的内存是Host可使用,Guest不能使用。当Host内存不足时,可以使气球膨胀,从而回收部分已分配给Guest的内存。当Guest内存不足时可请求压缩气球,从Host申请更多内存使用。
3) 页共享(Page Sharing):通过KSM(Kernel Samepage Merging)让内核扫描正在运行进程的内存。如果发现完全相同的内存页就会合并为单一内存页,并标志位写时复制COW(Copy On Write)。如果有进程尝试修改该内存页,将复制一个新的内存页供其使用。KVM中QEMU通过madvise系统调用告知内核那些内存可以合并,通过配置开关控制是否企业KSM功能。Guest就是QEMU进程,如果多个Guest运行相同OS或应用,且不常更新,使用KSM能大幅提升内存使用效率与性能。当然扫描和对比内存需要消耗CPU资源对性能会有一定影响。
在虚拟化环境中,Guest的IO操作需要经过特殊处理才能在底层IO设备上执行。如表1,KVM支持5种IO虚拟化技术。
虚拟化环境IO处理方式对比
如下图所示,全虚拟化的设备模拟与半虚拟化的virtio驱动都是通过软件实现IO虚拟化。
设备模拟
KVM中通过QEMU来模拟网卡或磁盘设备。Guest发起IO操作时被KVM的内核捕获,处理后发送到IO共享页并通知QEMU;QEMU获取IO交给硬件模拟代码模拟IO操作,并发送IO请求到底层硬件处理,处理结果返回到IO共享页;然后通知IO捕获代码,读取结果并返回到Guest中。
Virtio驱动
在Guest中部署virtio前端驱动,如virtio-net、virtio-blk、virtio_pci、virtio_balloon、virtio_scsi、virtio_console等,然后在QEMU中部署对应的后端驱动,前后端之间定义了虚拟环形缓冲区队列virtio-ring,用于保存IO请求与执行信息。Guest的IO从前端驱动发送到virtio-ring,然后后端驱动获取IO请求并转发到底层设备处理,处理完后返回结果。目前主流的Linux与windows都支持virtio,以获得更好的IO性能。
基于软件的IO虚拟化
vhost-net与vhost-user
virtio中后端驱动由用户空间的QEMU提供,但网络协议栈处于内核中,如果通过内核空间来处理网络IO,可以减少了网络IO处理过程中的多次上下文切换,从而提高网络吞吐量与性能。所以,新的内核中提供vhost-net驱动,使前端网络驱动virtio-net的后端处理任务从用户态的QEMU改到Host内核空间执行。
大规模云计算环境中会使用OVS(Open vSwitch)或SDN方案,而进程运行在用户态,如果继续使用内核态的vhost-net,依然存在大量用户态与内核态的切换,所以引入了vhost-user(内核态vhost功能在用户态实现)。vhost-user定义了Master(QEMU进程)和slave(OVS进程)作为通信两端,Master与slave之间控制面通过共享的虚拟队列virtqueure交换控制逻辑,数据面通过共享内存交换信息。结合vhost-user、vSwitch与DPDK可以在用户态完成网络数据包交换处理,从而大幅提升了网络虚拟化性能。
通过软件方式实现IO虚拟化存在一定系统开销与性能损耗,所以Intel推出了硬件辅助虚拟化技术(AMD-V类似)。
Intel 硬件辅助虚拟化技术
2.3.3.1 设备直通PCI Pass-through
基于硬件辅助虚拟化技术,KVM支持将Host的PCI/PCI-E物理设备(如网卡、磁盘、USB、显卡、GPU等)直接分配给Guest使用。Guest的对该设备的IO操作与物理设备一样,不经过QEMU/KVM处理。直通设备不能共享给多个Guest使用,且不能随Guest进行动态迁移,需要通过热插拔或libvirt工具来解决。
2.3.3.2 设备共享SR-IOV标准
为了实现多个Guest可以共享同一个物理设备,PCI-SIG发布了SR-IOV(Single Root-IO Virtualization)标准,让一个物理设备支持多个虚拟功能接口,可以独立分配给不同的Guest使用。SR-IOV定义了两个功能:
1) 物理功能PF(Physical Function):拥有物理PCI-e设备的完整功能,可独立使用;以及SR-IOV扩展能力,可配置管理VF。
2) 虚拟功能VF(Virtual Function):由PF衍生的轻量级PCI-e功能,包括数据传送的必要资源。不同的VF可以分配给不同的Guest,SR-IOV为Guest独立使用VF提供了独立的内存、中断与DMA流,数据传输过程无需VMM介入。
2.3.3.3 DPDK/SPDK
为了更好地提升虚拟化网络或磁盘IO性能,Intel等公司推出了数据平面开发工具集DPDK(Data Plance Development Kit)与存储性能开发工具集SPDK(Storage Performace Development Kit)。DPDK、vhost-user、OVS结合使用,可跳过Linux内核,直接在用户态完成网络数据包交换处理。SPDK通过环境抽象层EAL与UIO将存储驱动放在用户态处理,同时通过PMD轮询机制代替传统中断模式来处理IO。DPDK与SPDK大幅缩短了IO处理路径与系统开销, IO性能提升非常明显。DPDK与SPDK详情参考云网络介绍与云存储介绍部分。
2.3.4.1 图像与声音虚拟化处理
QEMU中对Guest的图像显示默认使用SDL(Simple DirectMedia Layer)实现。SDL是一个基于C语言的、跨平台的,开源的多媒体程序库,提供了简单的接口用于操作硬件平台的图形显示、声音、输入设备等,广泛应用于各种操作系统。同时在虚拟化环境中也广泛使用虚拟网络控制台VNC(Virtual Network Computing),采用RFB(Remote Frame Buffer)协议将客户端(浏览器或VNC Viewer)的键盘与鼠标输入传递到远程服务端VNC Server中,并返回输出结果。VNC不依赖操作系统,在VNC窗口断开后,已发出操作仍然会在服务端继续执行。由于VNC是GPL授权,衍生出多个版本RealVNC、TightVNC与UltraVNC,其对比如表3。
VNC版本对比
2.3.4.2 热插拔Hot Plugging
热插拔就是在服务器运行时插上或拔出硬件,以保障服务器的扩展性与灵活性。热插拔需要总线电气特性、主板BIOS、操作系统以及设备驱动的支持。目前支持服务器硬盘、CPU、内存、网卡、USB设备与风扇等部件热插拔。在KVM中在Guest不关机情况下支持PCI设备(如模拟、半虚拟化或直通的网卡、硬盘、USB设备)热拔插,但CPU和内存热插拔硬件平台和OS层面的限制还比较多。
Kvm结构体代表一个具体的虚拟机,当通过KVM_CREATE_VM指令字创建一个虚拟机后,就会创建一个新的kvm结构体对象。Kvm结构体中包括了VCPU、内存、APIC、IRQ、MMU、Event事件等相关信息,该结构体主要在KVM虚拟机内部使用,用于跟踪虚拟机状态。
重要的结构体成员说明如下:
struct kvm {
…
struct kvm_memslots *memslots; //KVM虚拟机分配的内存slot,用于GPAàHVA的转换,内存虚拟化使用
…
struct kvm_vcpu *vcpus[KVM_MAX_VCPUS]; //KVM虚拟机中包含的VCPU结构体数组,一个VCPU对应一个数组成员。
…
struct kvm_io_bus *buses[KVM_NR_BUSES]; //KVM虚拟机中包括的IO总线结构体数组,一条总线对应一个kvm_io_bus结构体,如ISA总线、PCI总线。
…
struct kvm_vm_stat stat; //KVM虚拟机中的运行时状态信息,比如页表、MMU等状态。
struct kvm_arch arch; //KVM中跟arch相关的参数。
…
}
kvm_run结构体记录了KVM的内部运行状态,如VM-Exit发生的原因等。重要字段说明如下:
struct kvm_run {
__u8 request_interrupt_window; //向VCPU注入一个中断,让VCPU做好相关准备工作
…
__u8 ready_for_interrupt_injection; //响应request_interrupt_window的中断请求,当设置时,说明VCPU可以接收中断。
__u8 if_flag; //中断使能标识,如果使用了APIC,则无效
struct {
__u64 hardware_exit_reason; //当发生VM-Exit时,该字段保存了由于硬件原因导致VM-Exit的相关信息。
} hw;
struct {
#define KVM_EXIT_IO_IN 0
#define KVM_EXIT_IO_OUT 1
__u8 direction;
__u8 size; /* bytes */
__u16 port;
__u32 count;
__u64 data_offset; /* relative to kvm_run start */
} io; //当由于IO操作导致发生VM-Exit时,该结构体保存IO相关信息。
…
};
在通过KVM_CREATE_VCPU指令字为VM创建VCPU后,KVM模块将创建kvm_vcpu结构体,其中包含了VCPU相关的信息,重要字段说明如下:
struct kvm_run {
__u8 request_interrupt_window; //向VCPU注入一个中断,让VCPU做好相关准备工作
…
__u8 ready_for_interrupt_injection; //响应request_interrupt_window的中断请求,当设置时,说明VCPU可以接收中断。
__u8 if_flag; //中断使能标识,如果使用了APIC,则无效
struct {
__u64 hardware_exit_reason; //当发生VM-Exit时,该字段保存了由于硬件原因导致VM-Exit的相关信息。
} hw;
struct {
#define KVM_EXIT_IO_IN 0
#define KVM_EXIT_IO_OUT 1
__u8 direction;
__u8 size; /* bytes */
__u16 port;
__u32 count;
__u64 data_offset; /* relative to kvm_run start */
} io; //当由于IO操作导致发生VM-Exit时,该结构体保存IO相关信息。
…
};
kvm_x86_ops结构体只包含了针对具体CPU架构进行虚拟化时的函数指针,在kvm-intel.ko和kvm-amd.ko模块中,提供了不同的接口和实现。在KVM初始化和正常运行过程中,将使用相关接口进行实际的硬件操作。主要包括如下几种类型的操作:
VMM状态初始化
VCPU管理
中断管理
寄存器管理
时钟管理
Kvm_x86_ops结构体在intel架构中初始化为:
static struct kvm_x86_ops vmx_x86_ops = {
.cpu_has_kvm_support = cpu_has_kvm_support,
.disabled_by_bios = vmx_disabled_by_bios,
.hardware_setup = hardware_setup,
.hardware_unsetup = hardware_unsetup,
.check_processor_compatibility = vmx_check_processor_compat,
.hardware_enable = hardware_enable,
.hardware_disable = hardware_disable,
.cpu_has_accelerated_tpr = report_flexpriority,
.vcpu_create = vmx_create_vcpu,
.vcpu_free = vmx_free_vcpu,
.vcpu_reset = vmx_vcpu_reset,
.prepare_guest_switch = vmx_save_host_state,
.vcpu_load = vmx_vcpu_load,
.vcpu_put = vmx_vcpu_put,
.set_guest_debug = set_guest_debug,
.get_msr = vmx_get_msr,
.set_msr = vmx_set_msr,
.get_segment_base = vmx_get_segment_base,
.get_segment = vmx_get_segment,
.set_segment = vmx_set_segment,
.get_cpl = vmx_get_cpl,
.get_cs_db_l_bits = vmx_get_cs_db_l_bits,
.decache_cr0_guest_bits = vmx_decache_cr0_guest_bits,
.decache_cr4_guest_bits = vmx_decache_cr4_guest_bits,
.set_cr0 = vmx_set_cr0,
.set_cr3 = vmx_set_cr3,
.set_cr4 = vmx_set_cr4,
.set_efer = vmx_set_efer,
.get_idt = vmx_get_idt,
.set_idt = vmx_set_idt,
.get_gdt = vmx_get_gdt,
.set_gdt = vmx_set_gdt,
.cache_reg = vmx_cache_reg,
.get_rflags = vmx_get_rflags,
.set_rflags = vmx_set_rflags,
.fpu_deactivate = vmx_fpu_deactivate,
.tlb_flush = vmx_flush_tlb,
.run = vmx_vcpu_run,
.handle_exit = vmx_handle_exit,
.skip_emulated_instruction = skip_emulated_instruction,
.set_interrupt_shadow = vmx_set_interrupt_shadow,
.get_interrupt_shadow = vmx_get_interrupt_shadow,
.patch_hypercall = vmx_patch_hypercall,
.set_irq = vmx_inject_irq,
.set_nmi = vmx_inject_nmi,
.queue_exception = vmx_queue_exception,
.interrupt_allowed = vmx_interrupt_allowed,
.nmi_allowed = vmx_nmi_allowed,
.get_nmi_mask = vmx_get_nmi_mask,
.set_nmi_mask = vmx_set_nmi_mask,
.enable_nmi_window = enable_nmi_window,
.enable_irq_window = enable_irq_window,
.update_cr8_intercept = update_cr8_intercept,
.set_tss_addr = vmx_set_tss_addr,
.get_tdp_level = get_ept_level,
.get_mt_mask = vmx_get_mt_mask,
.exit_reasons_str = vmx_exit_reasons_str,
.gb_page_enable = vmx_gb_page_enable,
.set_tsc_khz = vmx_set_tsc_khz,
.write_tsc_offset = vmx_write_tsc_offset,
.adjust_tsc_offset = vmx_adjust_tsc_offset,
.compute_tsc_offset = vmx_compute_tsc_offset,
};
如之前描述,KVM中主要包括:kvm.ko,kvm-intel.ko和kvm-amd.ko 3个模块,主要的初始化流程如下:
主要过程包括两部分:
内核函数kvm_create_vm(linux/virt/kvm/kvm_main.c)用于创建虚拟机,用户态Qemu-kvm通过如下过程,最终进入内核的此函数中,由该函数完成虚拟机的创建。
ioctl(KVM_CREATE_VM..)
-> kvm_dev_ioctl
-> kvm_dev_ioctl_create_vm
-> kvm_create_vm
kvm_create_vm主要完成KVM虚拟机结构体的创建、KVM的MMU操作接口的安装、KVM的IO总线、事件通道的初始化等操作,主要流程如下:
在通过ioctl(KVM_CREATE_VM…)创建虚拟机并获得相应的fd后,可以通过ioctl(KVM_CREATE_VCPU…)创建VCPU(相关操作通常在Qemu-kvm用户态进程中进行)。
ioctl(KVM_CREATE_VCPU..)
-> kvm_vm_ioctl
-> kvm_dev_ioctl_create_vcpu
VCPU的创建过程只要是创建VCPU描述符,即kvm_vcpu结构体,该结构体内容较多,包括硬件相关的内容。具体实现由内核函数kvm_dev_ioctl_create_vcpu()完成,主要流程如下:
在VM和VCPU创建好并完成初始化后,就可以调度该VCPU运行了。VCPU(虚拟机)的运行主要任务是要进行上下文切换,主要就是相关寄存器、APIC状态、TLB等,通常上下文切换的过程如下:
Qemu-kvm可以通过ioctl(KVM_RUN…)使虚拟机运行。过程如下:
ioctl(KVM_CREATE_VCPU..)
-> kvm_vcpu_ioctl
-> kvm_arch_vcpu_ioctl_run
具体由内核函数kvm_arch_vcpu_ioctl_run完成相关工作。主要流程如下:
QEMU是一套由Fabrice Bellard编写的模拟处理器的自由软件,其代码地址 http://git.qemu.org/qemu.git 。它是一个完整的可以单独运行的软件,可以独立模拟出整台计算机,包括CPU,内存,IO设备,通过一个特殊的“重编译器”对特定的处理器的二进制代码进行翻译,从而具有了跨平台的通用性。QEMU有两种工作模式:系统模式,可以模拟出整个电脑系统;另一种是用户模式,可以运行不同与当前硬件平台的其他平台上的程序(比如在x86平台上运行跑在ARM平台上的程序)。在0.9.1及之前版本还可以使用kqemu加速器(可以理解为QEMU的一个插件,用来提高QEMU的翻译性能,支持Windows平台),但1.0以后版本就只能使用qemu-kvm(只支持Linux)进行加速了,1.3版本后QEMU和QEMU-KVM合二为一了。
QEMU-KVM:从前面对KVM内核模块的介绍知道,它只负责CPU和内存的虚拟化,加载了它以后,用户就可以进一步通过工具创建虚拟机(KVM提供接口),但仅有KVM还不够,因KVM是运行在内核态,用户是无法直接操控的,故还须有个运行在用户空间的管理工具来配合,KVM的开发者选择了比较成熟的开源虚拟化软件QEMU来作为这个虚拟资源管理工具,并对其进行了修改,最后形成了QEMU-KVM。
在QEMU-KVM中,KVM运行在内核空间,QEMU运行在用户空间,来实际模拟创建、管理各种虚拟硬件。QEMU将KVM整合了进来,通过/ioctl 调用 /dev/kvm,从而将CPU指令处理部分交给内核态KVM模块来做,以此提升虚拟化性能。但KVM只实现了CPU和内存的虚拟化,不能虚拟其他硬件设备,因此qemu还提供模拟IO设备(磁盘、网卡、显卡等)等功能,故KVM加上QEMU才是完整意义上的KVM服务器虚拟化解决方案。
QEMU的架构如下图所示,由几个基本的组件组成:
QEMU架构图
如图所示,QEMU由以下几个部分组成:
Hypervisor(虚拟机管理程序)是一种创建和运行虚拟机的虚拟机监视器。 QEMU中的Hypervisor(虚拟机管理程序)从磁盘映像加载二进制机器代码,使用TCG将其转换为本机机器代码,连接到虚拟或实际设备,并启动软件MMU,然后开始在磁盘映像中虚拟操作系统。其中,TCG和软件MMU是实现虚拟化CPU和内存的关键。
而集成KVM后,QEMU将使用Linux内核的KVM功能以全虚拟化模式执行虚拟机。KVM基本上是Linux内核中的Hypervisor(虚拟机管理程序)。它可以并行运行多个操作系统。QEMU可以在KVM中启动一个新线程以执行虚拟操作系统,然后由KVM控制执行。从这部分来说,KVM的Hypervisor(虚拟机管理程序)替换掉了QEMU的Hypervisor(虚拟机管理程序)。
在QEMU中,Tiny Code Generator(TCG)将基于源处理器编译的机器代码转换为目标虚拟机所运行处理器的机器代码(如x86机器代码)。单从CPU架构与指令集格式的角度来说,是不可能在一类处理器(例如Intel x86)上运行基于另一类处理器(例如ARM)指令集所编译的机器代码的,例如想在x86处理器上执行基于ARM指令集编译的程序。因此,引入中间转换环节对不同处理器指令集架构(ISA)进行翻译和转换是实现虚拟化通用性的可预见的技术途径和解决方案。
在Tiny Code Generator(TCG)中,这些已经翻译的代码块放在转换缓存中,并通过跳转指令将源处理器的指令集和目标处理器的指令集链接在一起。当Hypervisor(虚拟机管理程序)在执行代码时,存放于转换缓存中的链接指令可以跳转到指定的代码块,并且执行过程中可以在不同的已翻译代码块上切换运行,直到需要翻译新块为止。在执行的过程中,如果遇到了需要翻译的代码块,执行动作就会暂停并回会跳回到Hypervisor(虚拟机管理程序),Hypervisor(虚拟机管理程序)就会协调TCG对需要进行二进制翻译的源处理器指令集进行转换并存储到转换缓存中,以便继续程序执行。
下图显示了QEMU的TCG工作原理:
微代码生成器工作原理
在TCG运行过程中存在一个小缺点,即它无法识别并正确运行自修改代码,因为它没有将修改后的代码页进行标记,再次运行时需要重新翻译。这影响了QEMU的二进制运行效率,当然从另外一个角度来说,这也增加了一定的运行安全性。自修改代码在软件世界中容易被漏洞利用,特别是缓冲区溢出攻击等内存损坏漏洞,这些漏洞利用威胁代理(例如后门)提供的特殊代码覆盖易受攻击的应用程序代码。如果已经被覆盖的代码已经被运行(并因此被缓存),就会导致TCG运行和翻译失败,从而导致程序复现异常或崩溃,由此规避了此类漏洞攻击。
此外,在翻译的过程中,如果新处理器使用的寄存器多于x86处理器并且具有许多复杂指令,那么对TCG进行编程以处理和适应新的CPU仿真就需要更多的工作量。目前来说,QEMU所支持的大部分处理器都拥有部分相同的指令集,例如,“MOV”指令几乎存在于所有处理器中并且可以简单地复制。除非CPU寄存器中存在一些位大小差异,例如,在32位处理器上模拟64位处理器可能需要新增许多额外的指令,这也需要更多时间在TCG转换器中进行编程。
在QEMU的源代码中,有一个名为'tcg'的子目录,其中包含将机器指令转换为相应的x86机器指令的代码。此代码是一个用C编写的简单翻译状态机,还有用于内存访问和跳转的特殊转换,因为它们可以生成对软件内存管理单元的调用。而虚拟化CPU和内存也往往是在一起的,因为从本质上来说,CPU的工作就是对内存的区域数据进行搬运,CPU是内存的搬运工。在QEMU保护代码块之外的其他内存区域,机器代码中的跳转和分支也必须确保到达正确的存储器地址。
所以通过二进制翻译技术,针对CPU的仿真和虚拟化就非常简单了。TCG和Hypervisor(虚拟机管理程序)能够实现基于CPU的仿真。其中,CPU仿真流程如下图所示:
从上图我们可以看到,针对CPU的仿真和虚拟化本质上就是将源处理器的指令集转换成目标处理器的指令集。CPU仿真和虚拟化就是通过中间的转换和翻译来实现的,由此,针对CPU虚拟化的第一种技术就完全实现了。这种二进制翻译技术是最早的CPU虚拟化技术,诞生了VMware这样的虚拟化巨头,也诞生了QEMU这样的开源虚拟化鼻祖。
此部分内容参见2.3节。
QEMU可以处理几种不同的磁盘映像格式。首选格式为raw或qcow2。Raw是一种非常简单的格式,它将文件系统中的字节逐字节存储在文件中。大多数其他仿真器都支持此格式。Qcow2是QEMU自己的图像格式,对小图像很有用。并且支持磁盘映像压缩以及捕获磁盘映像状态的快照。还支持另外两种格式:在VirtualBox中使用的vdi和在VMWare中使用的vmdk。
QEMU的磁盘映像通过其存储IO协议栈来进行支持,其存储协议栈如下图所示:
QEMU存储协议栈
从QEMU的存储协议栈来说,应用程序和虚拟机内核的工作类似于裸机。虚拟机通过仿真硬件与QEMU交互,并将IO执行情况的控制流和数据流交互给QEMU,QEMU代表虚拟机对磁盘镜像文件执行I / O操作。而从主机内核层面上,主机内核会将虚拟机I / O视为一种用户空间的应用程序IO请求进行正常的执行处理。
传统处理器中的内存管理单元(MMU)处理对计算机内存位置的访问。当处理器想要访问某个存储器地址时,MMU获取该地址的内容,此内容可以来自处理器芯片上的本地快速缓存,也可来自随机存取存储器(RAM)或来自光盘,它甚至可以做出一些关于缓存某些内存位置的控制决定。
QEMU有一个基于软件的MMU,其工作方式与硬件MMU类似。它使用地址转换缓存,其中包含访客地址、主机地址和偏移值,以提高转换速度。它还允许智能链接代码块,以便在没有内存故障的情况下实现更快的执行,其中必须重新加载和重新转换内存块。
在寻找在QEMU中运行的虚拟机的漏洞时,软件MMU是否正在进行翻译和正确放置块会是其测试和Fuzz的重点。