图3-1描绘了KVM的宏观架构。
图3-1 KVM的宏观架构
如图3-1所示,以KVM为核心的宿主机主要分为以下三大部分。
●用户态VMM(User Mode VMM),如QEMU、Firecracker、kvmtool等。此类应用程序负责和上层虚拟机管理软件(如virt-manager)的交互,包括解析虚拟机管理软件的配置参数、虚拟机的生命周期管理、虚拟机的迁移,还包括与KVM API模块交互进行虚拟设备的模拟等。值得指出的是,在KVM的宿主机中,每个虚拟机都是作为一个用户态VMM进程呈现的。目前最典型的用户态VMM就是QEMU,当用它来创建虚拟机时,每个虚拟机即为一个QEMU进程,这意味着虚拟机的内存同时也是QEMU进程的内存;而虚拟机中的每个vCPU则是一个QEMU线程。用户态VMM通过KVM API(即KVM IOCTL接口)与内核KVM交互。
●通用KVM内核模块,即kvm.ko。该模块是平台无关的通用虚拟化实现,负责提供KVM API以及与Linux其他模块进行交互。
●平台相关KVM内核模块,如kvm-intel.ko、kvm-amd.ko等。这些模块是具体CPU平台的虚拟化实现。如kvm-intel.ko是基于Intel VMX 的虚拟化实现,而kvm-amd. ko则是基于AMD的SVM。
KVM的分层设计带来诸多开发和管理上的便捷。从开发者的角度来看,KVM作为内核模块,和其他驱动程序一样,可以很方便地利用Linux操作系统的特性,例如内存管理、调度器等。对KVM的更新不需要宿主机重启,只重新加载KVM及其相关的模块即可。将KVM分为通用内核模块与平台相关的内核模块,为新的硬件平台支持虚拟化提供了便捷。KVM的IOCTL接口为用户态VMM提供了统一的API,开发者遵循该API便可设计出更为简洁轻便的用户态VMM。从管理者的角度来看,由于虚拟机仅仅是宿主机上的一个进程,而Linux系统有着丰富的进程管理工具,管理员完全可以利用这些工具来对虚拟机进行操作。例如,用taskset命令将处于用户态的vCPU与pCPU绑定,配置pCPU在多个VMM之间共享,从而获得更高的资源利用率,再如通过perf工具来查看虚拟机的性能。
3.2.1节讲到KVM API是通过IOCTL接口提供给用户态VMM来创建、配置、启动及运行虚拟机的。这些IOCTL接口大致可以分为以下三类。
●系统全局接口:用于设置全局的信息,例如整个KVM模块的配置。此外,创建虚拟机的IOCTL也属于此类接口。
●虚拟机相关接口:用于配置管理一台虚拟机,比如设置虚拟机的内存布局、创建vCPU等。
●虚拟CPU相关接口:用于查询和设置vCPU的属性,例如vCPU的CPUID策略等;负责管理vCPU的运行,查询vCPU进出客户机的状态及原因,从而转交上层用户态VMM进行后续的处理,包括中断模拟、设备的模拟访问等。
前面提到,目前最典型的用户态VMM就是QEMU,这是因为其原本就有着丰富的设备模拟代码实现。KVM在诞生之初即复用了这些代码,并通过增加KVM API的调用使其能够与KVM进行交互。但是KVM作为虚拟化的核心模块并不依赖于QEMU。这里给出一个示例来实现一个极简版的用户态VMM,使读者对KVM API有一个较为直观的认识。在该示例中,我们首先提供一段精简的代码在虚拟机中运行:
这段代码所做的事情非常简单,即向端口0xf1两次写入字符,然后执行hlt指令。通过以下命令将其编译成二进制文件:
用户态VMM可以通过KVM API创建一台虚拟机,并加载guest_code.bin作为一个极简的内核,仅运行于虚拟机的实模式(Real Mode)下。简化版的用户态VMM实现如下。
在运行以上代码之前,首先执行下面的一条命令,该命令的结果需要显示出此时kvm.ko和kvm_intel.ko已被加载:
然后编译vmm.c,运行结果如下:
那么,这个vmm.c文件都做了哪些事情呢?下面来看一下。
1)首先,KVM暴露给用户态VMM一个字符设备“/dev/kvm”。vmm.c中第30行代码通过打开该设备获得一个文件描述符kvm_fd。对kvm_fd的IOCTL操作即是系统全局接口调用。第31~35行通过KVM_GET_API_VERSION来检查KVM API的版本信息(该版本号固定为12)。
2)确认KVM API版本信息无误后,vmm.c的第37~41行代码创建了一个大小为4KB的进程地址空间,然后打开guest_code.bin文件,并将其内容加载到该4KB页面中。在3.2.1节中,我们提到虚拟机的内存同时也是用户态VMM进程的内存,即客户机物理地址(GPA)空间同时也是用户态VMM的进程地址空间,即宿主机虚拟地址(Host Virtual Address,HVA)空间。这一点将在KVM内存虚拟化的章节中详细展开说明。
3)有了kvm_fd,第43行通过KVM_CREATE_VM来创建虚拟机。该接口也是KVM系统全局接口,其返回值是一个虚拟机的文件描述符,即vm_fd。对vm_fd的IOCTL操作即是虚拟机相关接口调用。
4)获得vm_fd之后,第45、46行通过KVM_SET_USER_MEMORY_REGION来为虚拟机设置一块 内存区域 (Memory Region)。内存区域是KVM内存虚拟化中的一个重要概念,用于描述一段GPA与HVA之间的映射关系以及相关的属性。本例中,我们将GPA首地址为0x1000的一个页面与VMM的虚拟地址mem关联起来。
5)除了为虚拟机设置内存布局以外,还有一个不可或缺的虚拟机相关接口KVM_CREATE_VCPU。如第48行所示,VMM使用该接口为虚拟机创建vCPU,返回值为一个vCPU的文件描述符,即vcpu_fd。对vcpu_fd的IOCTL操作即是vCPU相关接口调用。在第49~52行中,我们通过vCPU的接口KVM_GET_VCPU_MMAP_SIZE和后面的mmap()系统调用,为该vCPU分配一段HVA,用于保存VMM和KVM之间的共享信息,在后面处理VM Exit时使用,从该共享内存run中识别VM Exit的原因、对应的地址等。
6)第54~58行通过KVM_GET_SREGS、KVM_SET_SREGS和KVM_SET_REGS这几个vCPU相关接口,来设置vCPU的CS段寄存器以及RIP寄存器,也就是客户机下的CPU寄存器,从而保证vCPU的第一条指令地址为0x1000,且其模式为实模式。
7)设置好vCPU的初始状态后,就可以通过KVM_RUN来运行了,如第61行所示。此时KVM vCPU的模块就会进入客户机中执行对应的指令。在vCPU的运行过程中,会发生多次VM Exit,比如发生外部中断、执行特权指令等。对于某些VM Exit,KVM还要交由用户态VMM来进行模拟,比如这里的第63~72行。当vCPU执行了对I/O端口的操作后,vmm会将该端口的数据输出到终端;当vCPU执行了hlt指令后,vmm会输出“VM halted.”并最终退出。
当然,本例中的代码是极简版的,对于IOCTL的返回值都未作检查,没有对文件的关闭操作,也没有将vCPU放在单独的线程中管理。但是至少可以看出,只要遵循KVM API,就可以快速地实现一个用户态VMM。对于没有大量模拟需求的场景,一个轻量级的用户态VMM可以带来诸多便捷,一个典型的例子便是AWS Serverless环境中使用的Firecracker。限于篇幅,在接下来的章节中,我们将注意力更多地集中在KVM内核的实现上。