本文基于以下软硬件假定:
架构:AARCH64
内核版本:5.14.0-rc5
1 kvm概述
kvm是基于linux内核实现的一种type 2虚拟化方案,它作为内核的一个模块负责虚拟化环境初始化,虚拟机和虚拟cpu模拟,以及IO捕获与转发等功能。在kvm中虚拟机和虚拟cpu分别通过host os的进程和线程实现,并且由host os的调度器对其进行调度。
由于除了像中断控制器之类的关键设备之外,kvm并不会执行设备模拟工作,因此它通常需要与qemu结合使用。由qemu执行实际的IO设备模拟,以及虚拟机创建和参数配置功能。为此,kvm需要通过ioctl向用户态导出一组虚拟机管理相关的接口,它们之间的关系如下图:
2 初始化总体流程
kvm初始化的主要目的是为虚拟机的创建和运行提供必要的软硬件环境,其总体流程图如下:
从上图可看出,kvm的初始化流程比较清晰,其主要包含以下几部分:
(1)架构相关的初始化流程
(2)为电源管理接口注册回调函数,以处理kvm在电源管理流程中的行为
(3)为kvm注册字符设备以为用户态提供ioctl接口
(4)其它一些辅助接口
由于架构相关的初始化流程比较复杂,我们将在后面单独用一章进行讨论,因此下面将分别介绍其它的一些流程
2.1 电源管理回调注册
由于在cpu热插拔和系统休眠唤醒流程中需要执行cpu的offline和online状态转换,因此对于需要控制cpu的相关模块,在这一流程中需要正确管理本模块的cpu状态设置。在电源管理流程中,相关模块可以向电源管理模块注册回调,当对应的电源管理事件发生时,该回调函数将会被调用。
其中cpuhp_setup_state_nocalls用于注册cpu热插拔时的回调,register_syscore_op用于注册系统休眠唤醒时的回调,而register_reboot_notifier用于注册系统重启时的通知。它们最终都由kvm_arch_hardware_enable和kvm_arch_hardware_disable实现,用于在cpu下线时关闭hypervisor,并在cpu上线时重新初始化hypervisor。
2.2 ioctl接口注册
从在上一章图中可看到kvm一共为用户态提供了三组ioctl接口:kvm ioctl、vm ioctl和vcpu ioctl。它们分别为用于控制kvm全局、特定虚拟机以及特定vcpu相关的操作。
其中kvm全局ioctl通过misc_register()接口以字符设备的方式注册,而vm ioctl和vcpu ioctl则通过anon_inode_getfd()接口以匿名inode方式注册。
Linux中一般的文件都包含一个inode和与若干个其关联的dentry,其中dentry用于表示其在文件系统中的路径。若用户态希望操作该文件时,可通过打开dentry对应的文件名,并获取一个fd。
但是有些文件操作希望将fd与inode直接关联起来,其文件名不在文件系统中被显示,这就是匿名inode。如vm的匿名inode在下图所示的虚拟机创建流程中建立:
而vcpu的匿名inode同样在vcpu创建流程中建立,其流程如下:
2.3 其它辅助接口
(1)kvm_irqfd_init():为eventfd创建一个全局的工作队列,它用于在虚拟机被关闭时,关闭所有与其相关的irqfd,并等待该操作完成
(2)kmem_cache_create_usercopy()和kvm_async_pf_init()用于创建特定的slab
(3)kvm_init_debug()用于为kvm创建debugfs相关接口
(4)kvm_vfio_ops_init()用于为vfio注册设备回调函数
3 架构相关初始化
Armv8的虚拟化方案具有两种实现方式nvhe和vhe,在vhe实现中host os和hypervisor都运行在EL2中,此时host os与hypervisor共用所有的EL2寄存器,且host可以直接通过函数调用方式调用hypervisor的接口。因此对于运行在vhe模式下的kvm模块,其初始化流程比较简单,主要包括一些host context的初始化,以及虚拟gic和timer初始化等流程。
相对而言,vnhe由于host os和hypervisor运行在如下图所示的不同异常等级下,因此其初始化流程更加复杂。如host os需要通过异常的方式进入hypervisor,因此需要在EL2下为hypervisor设置独立的异常处理函数。同时,由于hypervisor的代码运行于el2,还需要为其在该异常等级下映射代码段、数据段等程序的内存地址
3.1 aarch64架构初始化总体流程
下图为kvm在aarch64下的架构相关初始化流程:
图中黄色部分为vhe和nvhe共同包含的流程,而灰色部分的流程只有nvhe才需要执行。
(1)is_kernel_in_hyp_mode:由于hypervisor运行在EL2,因此该函数通过判断当前是否在EL2中执行,以确定是否处于hypervisor模式。实际上nvhe的kvm实现包含两部分,位于EL1 host中的kvm驱动和位于EL2中的hypervisor,因此对于nvhe实现当前实际还在host驱动中执行,因此其会返回false。而vhe由于host本身就在EL2中执行,故其会返回true
(2)check_kvm_target_cpu:该函数会通过smp_call_function_single()调用分别在每个cpu上执行,用于读取cpu的型号并判断其是否为合法的值。其中smp_call_function_single()函数用于smp核之间的核间通信,其原理为通过向特定cpu发送ipi中断,以使其执行参数给定的回调函数
(3)init_common_resources:该函数读取内存属性寄存器ID_AA64MMFR0_EL1的值,并根据其支持的页大小判断当前系统配置的page size是否能被硬件支持,同时它也会从该寄存器中解析出硬件支持的最大物理地址长度
(4)kvm_arm_init_sve:sve是arm用于支持向量计算的可变长度单指令多数据指令,其特点是向量寄存器长度是可变的。该函数即被用于设置vcpu支持的最大sve向量长度
(5)init_hyp_mode函数将在后面重点介绍
(6)kvm_init_vector_slots:由于cpu具有多级流水线和分支预测功能,在分支预测时可能会将该分支对应的数据提前加载到cache中。一旦分支预测失败,则由于另一分支的数据未被加载到cache,因此其访问速度和预测成功时相比会慢的多。
因此攻击者可能利用这种执行速度的差异来判断分支预测是否成功,更糟糕的是失败分支的数据依然还位于cache中。因此,攻击者最终可能有机会从cache中获取该数据,从而造成数据泄露。这就是安全界鼎鼎大名的spectre漏洞,其本质是利用侧信道方式攻击cache中的数据。
vector slots中不同slot中的vector就是根据硬件能力,用于防止不同等级spectre漏洞的向量表集合。当然,其如何预防的具体原理我也没有研究过
(7)init _subsystems:该函数也将在后面重点介绍
(8)finalize_hyp_mode:该函数将与init_hyp_mode一起介绍
(免费订阅,永久学习)学习地址: Dpdk/网络协议栈/vpp/OvS/DDos/NFV/虚拟化/高性能专家-学习视频教程-腾讯课堂
更多DPDK相关学习资料有需要的可以自行报名学习,免费订阅,永久学习,或点击这里加qun免费
领取,关注我持续更新哦! !
3.2 nvhe特有的初始化流程
由于armv8异常等级越高具有的权限也越大,因此低异常等级只能通过像smc或hvc之类的异常,通过EL异常处理函数提供的服务。故nvhe若需要支持EL2下的hypervisor,则host os必须要先从EL2启动,并完成EL2异常处理函数设置等基本初始化流程后,才能跳转到EL1中运行。该流程位于下图所示的内核启动阶段(arch/arm64/kernel/head.S):
其代码如下:
SYM_FUNC_START(init_kernel_el)
mrs x0, CurrentEL
cmp x0, #CurrentEL_EL2 (1)
b.eq init_el2 (2)
…
SYM_INNER_LABEL(init_el2, SYM_L_LOCAL)
mov_q x0, HCR_HOST_NVHE_FLAGS
msr hcr_el2, x0 (3)
isb
init_el2_state (4)
adr_l x0, __hyp_stub_vectors
msr vbar_el2, x0 (5)
isb
mrs x0, hcr_el2
and x0, x0, #HCR_E2H
cbz x0, 1f (6)
mov_q x0, INIT_SCTLR_EL1_MMU_OFF
msr_s SYS_SCTLR_EL12, x0
mov x0, #INIT_PSTATE_EL2
msr spsr_el1, x0
adr x0, __cpu_stick_to_vhe
msr elr_el1, x0 (7)
eret
1:
mov_q x0, INIT_SCTLR_EL1_MMU_OFF
msr sctlr_el1, x0
msr elr_el2, lr
mov w0, #BOOT_CPU_MODE_EL2
eret (8)
__cpu_stick_to_vhe:
mov x0, #HVC_VHE_RESTART
hvc #0
mov x0, #BOOT_CPU_MODE_EL2
ret
SYM_FUNC_END(init_kernel_el)
(1)获取启动时的EL,并判断内核是否从EL2开始启动
(2)若从EL2启动,则调用init_el2初始化el2相关的寄存器
(3)初始化hcr_el2寄存器的值
(4)初始化el2的系统寄存器,如sctlr_el2等
(5)设置el2的初始异常向量表__hyp_stub_vectors
(6)通过hcr_el2.e2h判断是否支持vhe,若支持vhe则跳转到标号1处
(7)对于nvhe模式,el2系统寄存器初始化和stub异常处理函数设置完成。host os可以切换到el1执行,此后若需要进入el2,则只需调用hvc指令进入__hyp_stub_vectors异常处理流程
(8)对于vhe模式,host os继续保持在el2下运行
接下来我们继续看init_hyp_mode函数的实现:
3.2.1 kvm_mmu_init
我们回忆一下上一节的el2初始化流程只是设置了一些系统寄存器,而并没有为其内存创建页表和开启mmu,因此在hypervisor初始化流程中需要完成该流程。
本函数的主要目的就是为hypervisor分配页表pgd,并且基于该pgd为hypervisor的identity段建立identity映射。如果看过先前内核启动分析博文的同学,可能还记得在内核初始化时会将开启mmu附近的代码放在一个叫做identity的段中,在建立页表时该段将会映射到与物理地址相同的虚拟地址上,从而保证mmu使能时能平滑切换。
hypervisor映射也类似,其也包含一个mmu切换相关的identity段,并且也需要建立虚拟地址与物理地址相等的映射关系。以下为代码的实际流程:
int kvm_mmu_init(u32 *hyp_va_bits)
{
…
hyp_idmap_start = __pa_symbol(__hyp_idmap_text_start);
hyp_idmap_start = ALIGN_DOWN(hyp_idmap_start, PAGE_SIZE);
hyp_idmap_end = __pa_symbol(__hyp_idmap_text_end); (1)
hyp_idmap_end = ALIGN(hyp_idmap_end, PAGE_SIZE);
hyp_idmap_vector = __pa_symbol(__kvm_hyp_init);
…
hyp_pgtable = kzalloc(sizeof(*hyp_pgtable), GFP_KERNEL);
…
err = kvm_pgtable_hyp_init(hyp_pgtable, *hyp_va_bits, &kvm_hyp_mm_ops); (2)
if (err)
goto out_free_pgtable;
err = kvm_map_idmap_text(); (3)
…
}
(1)获取identity段的物理地址
(2)初始化hypervisor的pgd
(3)为identity段建立页表
我们再看下其页表建立实现流程
static int kvm_map_idmap_text(void)
{
…
int err = __create_hyp_mappings(hyp_idmap_start, size, hyp_idmap_start,
PAGE_HYP_EXEC);
…
}
从上面的参数可看出其物理地址和虚拟地址的起始值都为hyp_idmap_start
3.2.2 hypervisor的栈和percpu内存分配
该流程比较简单,主要是为所有的vcpu分别分配对应的内存,其流程如下:
for_each_possible_cpu(cpu) {
unsigned long stack_page;
stack_page = __get_free_page(GFP_KERNEL); (1)
…
per_cpu(kvm_arm_hyp_stack_page, cpu) = stack_page; (2)
}
for_each_possible_cpu(cpu) {
…
page = alloc_pages(GFP_KERNEL, nvhe_percpu_order());
…
page_addr = page_address(page); (3)
memcpy(page_addr, CHOOSE_NVHE_SYM(__per_cpu_start), nvhe_percpu_size()); (4)
kvm_arm_hyp_percpu_base[cpu] = (unsigned long)page_addr; (5)
}
(1)为该vcpu的栈分配内存
(2)将其保存到percpu的全局变量中
(3)为该vcpu分配percpu内存并获取其地址
(4)将代码段中的percpu数据拷贝到该地址处
(5)将该地址保存到全局变量中
3.2.3 其它内存的映射
由于armv8在支持vhe之前,el2下只有一个页表基地址寄存器ttbr0_el2,而ttbr0_el2支持的地址范围为0x0000 0000 0000 0000 - 0x000f ffff ffff ffff。但是hypervisor代码是与内核链接在一起的,我们知道内核链接脚本中定义的虚拟地址位于0xfff0 0000 0000 0000 – 0xffff ffff ffff ffff之间。因此其在映射时需要对内核链接脚本中定义的虚拟地址做一些调整,使其位于ttbr0_el2支持的范围之内。它是在内存映射接口create_hyp_mappings()中通过kern_hyp_va()宏实现的。
最后我们再看一下映射的接口的实现:
static int __create_hyp_mappings(unsigned long start, unsigned long size,
unsigned long phys, enum kvm_pgtable_prot prot)
{
…
if (!kvm_host_owns_hyp_mappings()) { (1)
return kvm_call_hyp_nvhe(__pkvm_create_mappings,
start, size, phys, prot); (2)
}
mutex_lock(&kvm_hyp_pgd_mutex);
err = kvm_pgtable_hyp_map(hyp_pgtable, start, size, phys, prot); (3)
mutex_unlock(&kvm_hyp_pgd_mutex);
…
}
从该流程中可看出根据kvm_host_owns_hyp_mappings 值的不同,hypervisor有两种映射方式:kvm_call_hyp_nvhe和kvm_pgtable_hyp_map。
原因为在kvm初始化时,hypervisor只能处理__hyp_stub_vectors已经定义的服务,而该函数只实现了一些基本的功能,因此el2自身并不能完成建立页表的能力。此时就要通过step 3的方式由host os帮其先建好页表,然后将该页表的pgd基地址传给el2,并由el2将其设置到ttbr0_el2中。
当hypervisor初始化完成后,其异常向量表将会被替换为最终运行时的vectors。该vector本身实现了页表创建相关的服务,故在此时可以通过step 2的方式由hypervisor自身完成页表创建功能。其中kvm_call_hyp_nvhe函数的功能即是通过hvc指令陷入el2中
3.2.4 cpu_prepare_hyp_mode
该函数为每个cpu设置其一些属性相关系统寄存器context的初始值,如tpidr_el2、mair_el2等。并且将前面建立页表的pgd基地址和为vcpu分配的栈指针保存到vcpu的context中。其基本流程如下:
static void cpu_prepare_hyp_mode(int cpu)
{
struct kvm_nvhe_init_params *params = per_cpu_ptr_nvhe_sym(kvm_init_params, cpu);
unsigned long tcr;
params->tpidr_el2 = (unsigned long)kasan_reset_tag(per_cpu_ptr_nvhe_sym(__per_cpu_start, cpu)) -
(unsigned long)kvm_ksym_ref(CHOOSE_NVHE_SYM(__per_cpu_start));
params->mair_el2 = read_sysreg(mair_el1);
tcr = (read_sysreg(tcr_el1) & TCR_EL2_MASK) | TCR_EL2_RES1;
tcr &= ~TCR_T0SZ_MASK;
tcr |= (idmap_t0sz & GENMASK(TCR_TxSZ_WIDTH - 1, 0)) << TCR_T0SZ_OFFSET;
params->tcr_el2 = tcr;
params->stack_hyp_va = kern_hyp_va(per_cpu(kvm_arm_hyp_stack_page, cpu) + PAGE_SIZE);
params->pgd_pa = kvm_mmu_get_httbr();
if (is_protected_kvm_enabled())
params->hcr_el2 = HCR_HOST_NVHE_PROTECTED_FLAGS;
else
params->hcr_el2 = HCR_HOST_NVHE_FLAGS;
params->vttbr = params->vtcr = 0;
kvm_flush_dcache_to_poc(params, sizeof(*params));
}
3.2.5 kvm_hyp_init_protection
该函数的实现如下:
static int kvm_hyp_init_protection(u32 hyp_va_bits)
{
void *addr = phys_to_virt(hyp_mem_base);
int ret;
kvm_nvhe_sym(id_aa64mmfr0_el1_sys_val) = read_sanitised_ftr_reg(SYS_ID_AA64MMFR0_EL1);
kvm_nvhe_sym(id_aa64mmfr1_el1_sys_val) = read_sanitised_ftr_reg(SYS_ID_AA64MMFR1_EL1); (1)
ret = create_hyp_mappings(addr, addr + hyp_mem_size, PAGE_HYP); (2)
if (ret)
return ret;
ret = do_pkvm_init(hyp_va_bits); (3)
if (ret)
return ret;
free_hyp_pgds(); (4)
return 0;
}
(1)读取并保存ID_AA64MMFR1_EL0和ID_AA64MMFR1_EL1寄存器的值
(2)为hypervisor的保留内存创建el2页表,该内存由arch/arm64/kvm/hyp/reserved_mem.c中的 kvm_hyp_reserve函数定义
(3)该函数用于执行实际的hypervisor初始化工作,它主要包含以下部分:
(a)调用hvc异常,将异常处理函数从__hyp_stub_vectors切换为idmap时的处理函数__kvm_hyp_init。该异常处理函数只被用于hypervisor初始化流程
(b)通过hvc异常调用__kvm_hyp_init函数,以初始化hypervisor。它主要包括初始化sp、hcr_el2等系统寄存器,设置前面创建的页表并使能mmu。最后将异常处理函数切换到最终工作时使用的版本__kvm_hyp_host_vector
(c)由于el2的初始化页表是使用内核内存建立的,而实际上step 2已经为el2保留了页表相关的内存。因此__pkvm_init会使用该内存作为页表重新为el2建立页表,并将其页表切换到新的位置
以下为其代码流程图:
(4)由于上一步已经使用新的内存为hypervisor重新建立的页表,故初始化时建立的页表不再需要,因此可释放该内存
3.3 subsystem初始化流程
subsystem初始化流程图如下:
它主要包括对cpu、vgic和timer的初始化流程。由于前面只初始化了当前运行cpu的hypervisor,而对于smp系统实际每个cpu都需要为其执行初始化流程。因此在本函数中通过向所有cpu发送ipu中断以执行_kvm_arch_hardware_enable操作,其主要目的包括为el2设置正确的异常里程序,以及初始化vgic的list register值。其中list register是一组gic用于向vcpu注入虚拟中断的寄存器
vgic初始化流程主要是将vgic设备注册到kvm中,并为其实现一组回调函数。这组回调函数可被用于操作vgic相关组件,如vgic_v3_set_attr可用于设置vgic中虚拟distributor、redistributor等的寄存器,而vgic_v3_get_attr可用于读取相关寄存器的值
timer初始化流程主要是为每个cpu注册vtimer和ptimer的ppi中断,并在ppi中断处理函数中将该中断以虚拟中断方式注入给vcpu。该流程相对比较简单,故不再赘述
4 小结
本文主要介绍了kvm初始化相关流程,其主要包含以下内容:
(1)kvm所有架构共同流程的初始化
(2)armv8架构中nvhe和vhe共同流程的初始化
(3)armv8架构nvhe特有流程的初始化
在初始化流程完成后,用户态就可以通过kvm导出的ioctl接口执行虚拟机创建,vcpu创建及运行等操作了
原文链接:https://zhuanlan.zhihu.com/p/530130205