环境:QEMU v6.1.0,Linux kernel v5.15

虚拟机的创建

QEMU

创建虚拟机的请求自用户侧的QEMU发出。如果启动QEMU时包含了--enable-kvm参数,经参数解析后会调用accel/kvm/kvm-all.c中的kvm_init函数。

1
2
// accel/kvm/kvm-all.c, 2296
static int kvm_init(MachineState *ms);

该函数的核心部分是打开了KVM所创建的misc设备,这就是KVM暴露给用户态的接口。运行在用户态的QEMU向内核态的KVM发出请求的方式,就是对这个misc设备的文件描述符进行ioctl操作。

1
2
3
4
5
6
7
// accel/kvm/kvm-all.c, 2335
s->fd = qemu_open_old("/dev/kvm", O_RDWR);
if (s->fd == -1) {
fprintf(stderr, "Could not access KVM kernel module: %m\n");
ret = -errno;
goto err;
}

而真正创建虚拟机的请求来自于ioctl(KVM_CREATE_VM)接口:

1
2
3
4
// accel/kvm/kvm-all.c, 2380
do {
ret = kvm_ioctl(s, KVM_CREATE_VM, type);
} while (ret == -EINTR);

这个请求会经过VFS,并最终调用KVM初始化时注册的ioctl handler,即kvm_dev_ioctl

KVM

KVM侧的kvm_dev_ioctl处理用户态的ioctl请求,根据其type参数进行dispatch。处理KVM_CREATE_VM请求的是kvm_dev_ioctl_create_vm函数。

1
2
// virt/kvm/kvm_main.c, 4555
static int kvm_dev_ioctl_create_vm(unsigned long type);

该函数的创建工作在kvm_create_vm中完成,之后会使用linux提供的anon_inode_getfile接口创建一个匿名文件并关联到刚刚创建的虚拟机上,用户(QEMU)就可以通过操作这个匿名文件对新创建的虚拟机进行操作。

kvm_create_vm
1
2
// virt/kvm/kvm_main.c, 1034
static struct kvm *kvm_create_vm(unsigned long type);

该函数:

  1. 首先使用kvm_arch_alloc_vm分配struct kvm(定义在include/linux/kvm_host.h中),并进行初始化,其中kvm->archkvm_arch_init_vm中进行初始化(这一部分是arch相关的)。
  2. 初始化struct kvm的内存槽kvm_memslots和设备总线kvm_io_bus
  3. 之后调用hardware_enable_all启动硬件对虚拟化的支持,该函数对每个CPU core调用hardware_enable_nolock,之后又是进行arch相关的操作。

虚拟CPU的创建

QEMU

x86_cpu_realizefn()

- -> qemu_init_vcpu()

-  -> kvm_start_vcpu_thread()

- -> cpu_common_realizefn()

QEMU中实现了一个抽象层,从而可以使用类似C++的,基于类的继承,封装,多态等等,称为QOM(QEMU Object Model)。QEMU将其能模拟的CPU类型维护成了一个类继承结构,创建一台CPU其实就是创建某个类的对象。

要让一个具体的虚拟CPU可以被使用,要经历以下这些步骤:

  1. 初始化某CPU的类型。这与QOM相关,主要就是设置回调函数以实现类似“构造函数继承”的效果,调用子类的构造函数会把继承链上父类的构造函数一并调用。
  2. 初始化某CPU类型的实例,即创建对象。对x86 CPU,该步骤对应基类的cpu_common_initfn和子类的x86_cpu_initfn函数。
  3. realize创建出的CPU对象,对应x86_cpu_realizefncpu_common_realizefn函数。这里的顺序比较特殊,先被调用的是子类中的x86_cpu_realizefn,后被调用的反而是父类中的cpu_common_realizefn。这是因为x86_cpu_common_class_init中将父类DeviceClass中的realize函数设为了x86_cpu_realizefn,将子类X86CPUClass中的parent_realize函数设为了cpu_common_realizefn,外部调用的时候先调的是DeviceClass::realize,在x86_cpu_realizefn的末尾调用了X86CPUClass::parent_realize

本段主要关注步骤3。

x86_cpu_realizefn

x86_cpu_realizefn是经历了CPU类型的初始化,CPU对象实例的初始化后才被调用的。这里包括了创建VCPU线程,以及向KVM发起创建VCPU请求的过程。经此,VCPU线程已经可以被调度到物理CPU上执行。

  • qemu_init_vcpu函数记录CPU核心数,线程数,并创建地址空间。最关键的是对cpus_accel->create_vcpu_thread的调用,这是一个accel相关的函数。对于使用KVM作为accelerator的情形,这个调用会动态绑定到kvm_start_vcpu_thread上。

  • kvm_start_vcpu_thread调用qemu_thread_create创建VCPU线程,线程上执行的是kvm_vcpu_thread_fn函数。

  • x86_cpu_realizefn接着会调用cpu_reset,现在的实现已经不是书中所说的走reset函数了,而是走了ResettableClass这个接口,不过最终还是调了x86_cpu_reset函数(具体的调用路径可以用gdb下断点看backtrace)。x86_cpu_reset初始化CPU寄存器(通用寄存器,控制寄存器,gdtr等等)。

    1
    2
    3
    4
    5
    6
    7
    #0  x86_cpu_reset (dev=0x555556a72870) at ../target/i386/cpu.c:5630
    #1 0x0000555555d82c09 in resettable_phase_hold (obj=obj@entry=0x555556a72870, opaque=opaque@entry=0x0, type=type@entry=RESET_TYPE_COLD)
    at ../hw/core/resettable.c:182
    #2 0x0000555555d833a9 in resettable_assert_reset (obj=0x555556a72870, type=<optimized out>) at ../hw/core/resettable.c:60
    #3 0x0000555555d8373d in resettable_reset (obj=0x555556a72870, type=type@entry=RESET_TYPE_COLD) at ../hw/core/resettable.c:45
    #4 0x0000555555d8105b in device_cold_reset (dev=<optimized out>) at ../hw/core/qdev.c:345
    #5 0x0000555555a93967 in cpu_reset (cpu=0x555556a72870) at /home/stopire/qemu-6.1.0/include/hw/qdev-core.h:17
  • 最后x86_cpu_realizefn通过xcc->parent_realize调用了cpu_common_realizefn,这个函数中cpu_resume使VCPU不再处于停止状态。返回后,VCPU线程就可以被调度,并执行绑定的kvm_vcpu_thread_fn函数了。

kvm_vcpu_thread_fn

该函数是VCPU线程上跑的函数,首先会调用kvm_init_vcpu

  • 首先调用kvm_get_vcpu,这正是QEMU创建VCPU时通过ioctl(KVM_CREATE_VCPU)调用KVM接口的位置。
  • 接着通过ioctl(KVM_GET_VCPU_MMAP_SIZE)获取QEMU和KVM共享内存的大小,并利用此大小以及VCPU的匿名文件描述符mmap一段共享内存。这段内存和内存虚拟化无关,只是作为QEMU和KVM之间的通信使用:VM Exit时,KVM可以把信息放在共享内存中传递给QEMU。KVM中处理这个mmap请求的是VCPU文件注册的kvm_vcpu_mmap函数,它指定了kvm_vcpu_fault处理访问这段共享内存时发生的page fault:将kvm_vcpu结构体中的对应成员关联到虚拟地址空间中。

之后,kvm_vcpu_thread_fn中运行一个循环,当VCPU处于可运行的状态时反复调用kvm_cpu_exec

KVM

在用户态,QEMU使用之前创建虚拟机时从KVM拿到的,代表虚拟机的文件描述符调用ioctl(KVM_RUN)接口。虚拟机fd的ioctl操作定义在kvm_vm_ioctl中,而对于创建CPU的请求,该函数会将其dispatch到kvm_vm_ioctl_create_vcpu处理。

kvm_vm_ioctl_create_vcpu创建一个struct kvm_vcpu *vcpu结构体,并将其交由kvm_arch_vcpu_create函数做arch相关的初始化操作。kvm_arch_vcpu_create最后有一个static_call(kvm_x86_vcpu_create)(*vcpu*),这个调用绑定到了vmx_create_vcpu函数(详见arch/x86/include/asm/kvm-x86-ops.h),完成了VMX层面VCPU的创建工作。

最后,kvm_vm_ioctl_create_vcpu为新创建的vcpu指定一个file descriptor供用户态访问使用,并将vcpu结构体放入kvm结构体中。

虚拟CPU的运行

kvm_vcpu_thread_fn()

- -> kvm_cpu_exec()(循环)

-   -> ioctl(KVM_RUN)(进入内核态)

-   -> ... -> ... -> vcpu_run()

-    -> vcpu_enter_guest(循环,VCPU可运行时执行)

-    -> vcpu_block(循环,VCPU不可运行时执行)

QEMU

在QEMU侧,与VCPU运行相关的逻辑主要在kvm_cpu_exec中。kvm_cpu_execkvm_vcpu_thread_fn的一个循环中被反复调用,是用户态QEMU进入内核态运行VCPU的入口点。该函数调用KVM的ioctl(KVM_RUN)接口,VCPU就开始运行。当出现VM Exit并且KVM无法处理时,控制从此处返回到用户态,kvm_cpu_exec接下来就包括了处理各种exit reason的代码。

KVM

VM Enter

接下来我们关注KVM是怎么处理ioctl(KVM_RUN)请求的。这是一个针对VCPU文件描述符的ioctl操作,因此会被kvm_vcpu_ioctl处理,它将KVM_RUN类型的请求dispatch到arch相关的kvm_arch_vcpu_ioctl_run(x86定义在arch/x86/kvm/x86.c中)。该函数仅做参数校验和特殊情况处理,主体工作由调用的vcpu_run完成。

vcpu_run的主体部分也是大循环,在VCPU可运行的时候调用vcpu_enter_guest进入虚拟机执行代码,在VCPU因故不可运行的时候调用vcpu_block,(在不考虑poll时)通过schedule让出VCPU(VCPU其实是宿主机上的一个线程)。

vcpu_enter_guest会调用static_call(kvm_x86_run)(*vcpu*),绑定到vmx_vcpu_run函数,该函数中会通过vmx_vcpu_enter_exit最终调用汇编代码__vmx_vcpu_run__vmx_vcpu_run会调用vmx_vmenter,它使用vmlaunch汇编指令让物理CPU进入VMX non-root模式,并在其之上运行虚拟机指令。

当出现VM Exit时,宿主机回到VMX root模式并从vmx_vmexit开始执行(创建VCPU的时候通过vmcs_writel(HOST_RIP, (unsigned long)vmx_vmexit)注册),vmx_vmexit它是一个简单的ret(不考虑retpoline)。这个ret实际上从调用vmlaunchvmx_vmenter返回到了__vmx_vcpu_run,它向调用者vmx_vcpu_enter_exit返回0,表明VM Exit(区别于返回1,代表VM Fail)。所以vmx_vcpu_enter_exit也是VM Exit之后执行流回到host的返回位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
; arch/x86/kvm/vmx/vmenter.S
SYM_FUNC_START_LOCAL(vmx_vmenter)
je 2f
...
2: vmlaunch
ret
...
SYM_FUNC_START(__vmx_vcpu_run)
...

/* Enter guest mode */
call vmx_vmenter
...
VM Exit

VM Exit之后,vmx_vcpu_run会通过vmcs_read32从VMCS中获取exit reason,而处理exit reason的handler分布在多条执行路径上。总的来说,对于需要由用户态QEMU处理的exit reason,其对应的handler会返回0;由KVM就能处理的exit reason,对应的handler返回1。这个返回值最后会被vcpu_run接收,它在返回值为0时打断大循环,从ioctl(KVM_RUN)返回到用户态。之后QEMU会根据之前建立的共享内存读出exit reason并进行处理(kvm_cpu_exec)。

VCPU的调度

每个VCPU在host看来仅仅是一个线程,这也就意味着它们在整个声明周期中可能会被放在不同的物理CPU上调度执行。每个VCPU都有一个对应的VMCS(VM Control Structure)数据结构,记录了VCPU的状态。VMCS是处理器的一部分,也就是说,当VCPU需要被调度到/调离物理CPU时,其对应的VMCS数据结构必须从CPU保存到内存/从内存加载到CPU,就像通用寄存器一样。

KVM中负责加载/保存VMCS的接口是vcpu_loadvcpu_put这一对函数,它们分别是vmx_vcpu_loadvmx_vcpu_put的wrapper,会在每个ioctl的入口和出口处被调用。vcpu_load还会注册preempt_notifier,在VCPU因抢占而被调度到/调离物理CPU的情况下分别回调kvm_sched_inkvm_sched_out,它们也是vmx_vcpu_loadvmx_vcpu_put的wrapper。

vmx_vcpu_load中有保存/加载VMCS的逻辑,实现在vmx_vcpu_load_vmcs中:当vmx->loaded_vmcs->cpu记录的上一次运行的物理CPUID与当前CPU不一致时,调用loaded_vmcs_clear从上一个CPU那里将VMCS从CPU清空并刷到内存(汇编指令vmclear),再通过vmcs_load将VMCS从内存加载到当前CPU(汇编指令vmptrld)。