QEMU/KVM部分源码分析-CPU
环境:QEMU v6.1.0,Linux kernel v5.15
虚拟机的创建
QEMU
创建虚拟机的请求自用户侧的QEMU发出。如果启动QEMU时包含了--enable-kvm
参数,经参数解析后会调用accel/kvm/kvm-all.c
中的kvm_init
函数。
1 | // accel/kvm/kvm-all.c, 2296 |
该函数的核心部分是打开了KVM所创建的misc设备,这就是KVM暴露给用户态的接口。运行在用户态的QEMU向内核态的KVM发出请求的方式,就是对这个misc设备的文件描述符进行ioctl
操作。
1 | // accel/kvm/kvm-all.c, 2335 |
而真正创建虚拟机的请求来自于ioctl(KVM_CREATE_VM)
接口:
1 | // accel/kvm/kvm-all.c, 2380 |
这个请求会经过VFS,并最终调用KVM初始化时注册的ioctl
handler,即kvm_dev_ioctl
。
KVM
KVM侧的kvm_dev_ioctl
处理用户态的ioctl
请求,根据其type
参数进行dispatch。处理KVM_CREATE_VM
请求的是kvm_dev_ioctl_create_vm
函数。
1 | // virt/kvm/kvm_main.c, 4555 |
该函数的创建工作在kvm_create_vm
中完成,之后会使用linux提供的anon_inode_getfile
接口创建一个匿名文件并关联到刚刚创建的虚拟机上,用户(QEMU)就可以通过操作这个匿名文件对新创建的虚拟机进行操作。
kvm_create_vm
1 | // virt/kvm/kvm_main.c, 1034 |
该函数:
- 首先使用
kvm_arch_alloc_vm
分配struct kvm
(定义在include/linux/kvm_host.h
中),并进行初始化,其中kvm->arch
在kvm_arch_init_vm
中进行初始化(这一部分是arch相关的)。 - 初始化
struct kvm
的内存槽kvm_memslots
和设备总线kvm_io_bus
。 - 之后调用
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可以被使用,要经历以下这些步骤:
- 初始化某CPU的类型。这与QOM相关,主要就是设置回调函数以实现类似“构造函数继承”的效果,调用子类的构造函数会把继承链上父类的构造函数一并调用。
- 初始化某CPU类型的实例,即创建对象。对x86
CPU,该步骤对应基类的
cpu_common_initfn
和子类的x86_cpu_initfn
函数。 - realize创建出的CPU对象,对应
x86_cpu_realizefn
和cpu_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_exec
在kvm_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
实际上从调用vmlaunch
的vmx_vmenter
返回到了__vmx_vcpu_run
,它向调用者vmx_vcpu_enter_exit
返回0,表明VM
Exit(区别于返回1,代表VM
Fail)。所以vmx_vcpu_enter_exit
也是VM
Exit之后执行流回到host的返回位置。
1 | ; arch/x86/kvm/vmx/vmenter.S |
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_load
和vcpu_put
这一对函数,它们分别是vmx_vcpu_load
和vmx_vcpu_put
的wrapper,会在每个ioctl
的入口和出口处被调用。vcpu_load
还会注册preempt_notifier,在VCPU因抢占而被调度到/调离物理CPU的情况下分别回调kvm_sched_in
和kvm_sched_out
,它们也是vmx_vcpu_load
和vmx_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
)。