arm64虚拟化技术与kvm实现原理分享

1 简介

虚拟化类型:

hypervisor(guest 管理机) 又称为 VMM(virtual machine monitor)

(1)软件虚拟化

不借助硬件支持,通过软件完整的模拟一台虚拟机 => qemu tcg。

(2)硬件虚拟化

根据 VMM 实现不同,硬件虚拟化包含 type1 hypervisor 和 type2 hypervisor 两种类型。

type1(XEN,sel4) hypervisor 直接运行在硬件上,并管理系统中所有的硬件和虚拟机资源。

type2(KVM)hypervisor 会运行在一个 host os 上,host os 负责管理和控制系统的硬件资源。

  1. type2,host os 和 hypervisor 运行在不同的异常级别,通过异常方式实现功能调用,相对于 type1 效率更低
  2. 为了解决这个问题,arm8.1 增加了 VHE 拓展,使得 host os 和 hypervisor 都可以运行在 el2 级别,此时他们之间通过函数调用,效率与 type1 基本相同。

(2.1)半虚拟化

全虚拟化是指虚拟机上 guest os 不需要修改任何代码,其执行方式与真实硬件上完全相同,但是由于每次 guest os 的每次 io 都需要返回 hypervisor,中间涉及异常,上下文切换等,效率比较低。

半虚拟化为了提高虚机 IO 能力而实现,通过修改 guest os 中驱动代码,实现与 hypervisor 之间更高效的 IO 交互,从而提升虚机整体性能。典型方案是 virtio,定义了 guest os 驱动与 hypervisor 中模拟设备之间的 io 数据通信协议,通过 channel 方式管理数据传输,避免频繁的 io 操作。

虚拟化包含的内容:

让每个虚拟机 vm 都拥有完整的硬件视角,需要包含:cpu,内存,io 设备,中断控制器,定时器等。因此 hypervisor 需要提供相关的虚机化实现,如捕获到虚机 cpu 的敏感指令,并为其提供模拟实现。vm 视角下的物理内存是 hypervisor 的虚拟内存,因此 hypervisor 需要为其建立与实际物理内存的映射页表。捕获 vm 的 IO 访问,并提供对应的模拟实现。

2 arm64 虚拟化相关硬件支持

2.1 arm64 cpu 虚拟化基本原理及硬件支持

cpu 一般包含至少两个运行级别,用户级和特权级。用户级用于执行用户空间代码,具有较低的执行权限,特权级可执行操作系统内核代码,具有较高的执行权限。当特权级执行特殊指令时,触发异常并被 hyperviosr 捕获,hypervisor 负责模拟或者处理该异常。比如 guest os 执行 wfi,wfe 进入低功耗状态,对于管理机来说不会让你占用硬件资源,因此该类指令为敏感指令,当geust os 执行时将会触发异常,进入 hypervisor,hypervisor 捕获该异常,并将该 vcpu 调度出去,由其他 vcpu 使用。

因此 armv8 在虚拟化中提供了敏感指令和异常的捕获功能,可以通过配置 hypervisor 控制寄存器 hcr_el2 实现,它将 vm 执行的敏感指令或异常路由到 el2 中,hypervisor 捕获到相关异常后可为其模拟相关功能。

hcr_el2 相关 bit 描述如下:

(1)FMO:配置该位会将fiq中断路由到EL2
(2)IMO:配置该位会将irq中断路由到EL2
(3)AMO:配置该位会将serror异常路由到EL2
(4)TWI:配置该位会将WFI指令路由到EL2
(5)TWE:配置该位会将WFE指令路由到EL2
(6)TGE:该位只有在支持VHE的架构下才有效。当配置该位后所有需要路由到EL1的异常都会被路由到EL2。因此只要设置该位后,不再设置以上这些位,其对应的异常也会被路由到EL2下

2.2 系统寄存器捕获和虚拟寄存器支持

有些系统寄存器的值在 vmm 和 guest os 视角下并不相同,如 ID_AA64MMFR0_EL1 寄存器用于提供 cpu 支持的内存特性(其支持的最大物理地址范围,支持的 ASID 位数,大小端支持情况等)。对于这类寄存器每个 guest os 可以拥有其自身的属性值,因此 vmm 需要通过异常捕获 guest os 对其的访问,并在异常处理流程中为其提供一个虚拟值。

这类寄存器的访问频率并不高,因此捕获异常对性能影响有限。而对于高频访问的寄存器(如 MIDR_EL1,MPIDR_EL1)如果使用异常方式则对 vm 性能造成很大影响。

因此 arm 为该类问题提供了优化,即为该类寄存器专门提供了虚拟化版本,如为 MIDR_EL1 定义了 VMIDR_EL2,为MPIDR_EL1定义了VMPIDR_EL2。vmm 在切换到 vcpu 之前将其值写入这些虚拟寄存器中,当 guest os 访问 MIDR_EL1 时,其实际获取到的即为 VMIDR_EL2 中的值。通过这种方式,guest os 对这类寄存器的访问将不再需要触发异常。

2.3 VHE 特性支持

通过上面提到的方式,可以解决特殊指令等访问效率问题,但对于 kvm type2 类型的 hypervisor 设计来说,hypervisor 只是 host os 的一个组件,由于 host os 本身是一个操作系统(linux),他被定义运行在 el1。

若需要让其在 el2 运行,代码上会改动非常多,如在 linux 中使用 vbar_el1 访问异常向量表基地址寄存器,此时需要修改为 vbar_el2。并且 el1 支持两个页表基地址寄存器 ttbr0_el1 和 ttbr1_el1,而在 armv8.0 的 el2 异常等级中只包含一个 ttbr0_el2。

因此为了不修改 host os 本身代码,最初的 type2 方案中的 host os 被设计为运行在 el1,而 hypervisor 作为它的一个模块运行在 el2。

此时由于 hypervisor 和 host os 位于不同异常级别,因此它们之间的调用都需要通过异常完成,该流程需要执行上下文的保存和恢复,因此会影响 hypervisor 的效率,为了解决该问题,arm 在 armv8.1 架构之后增加了 VHE 特性,从而使得 host os 不经过改动就能运行在 el2 中。

VHE 主要是为了支持将 host os 运行在 EL2 中,以提高 hypervisor 和虚拟机之间的切换代价。

其主要包含以下特性:

(1)vhe 扩展了 EL2 的内存映射能力。在不支持 vhe 的 armv8.0 中 EL2 只有一个页表基地址寄存器 ttbr0_el2,因此其能映射的地址范围为0x0000 0000 0000 0000 – 0x000f ffff ffff ffff。

而 linux 内核要求的地址映射范围为 0xfff0 0000 0000 0000 – 0xffff ffff ffff ffff,且由于 EL0 和 EL1 都支持通过 ASID 将地址与进程关联,以避免在进程切换时刷新 tlb,从而减少进程切换的开销,而 armv8.0 的 EL2 并不支持 ASID,因此若不进行改造,EL2 在页表层级并不支持运行 host os。

为此 vhe 特性增加了 ttbr1_el2 寄存器以及对 asid 的支持,从而使 EL2 具有了与 EL0 和 EL1 相同的页表能力。

(2)由于 os 内核(如 linux)被设计为访问 EL1异常等级下的系统寄存器,因此为了在不修改代码的情况下使其实际访问 EL2 寄存器,vhe 实现了寄存器重定向功能。即在使能 vhe 之后,所有 EL1寄存器的访问操作都被硬件转换为对相应 EL2 寄存器的访问,如 OS 访问ttbr0_el1寄存器,则会按下图方式被重定向到 ttbr0_el2。

el2: msr ttbr0_el1, x0
  	E2H == 1 -> ttbr0_el2
  	E2H == 0 -> ttbr0_el1

这里还有一个问题,hypervisor 实际上还是有访问实际 el1寄存器的需求,因此 vhe 也对其做了扩展,当访问实际 el1寄存器时,则需要使用新的特殊指令。

el2: msr ttbr0_el2, x0
  	E2H == 1 -> ttbr0_el1

(3)当支持 vhe 且 hcr.tge 被设置后,不管hcr_el2.imo/fmo/amo是否被设置,所有 EL0 和 EL1 的异常都会被路由到 EL2 中。

有了以上扩展之后,host os 就可以不经任何修改,而像运行在 EL1 中一样运行于 EL2 中。

2.4 内存虚拟化支持

首先有一下几个地址范围定义:

(1)HVA(host virtual address):host视角下的虚拟地址
(2)HPA(host physical address):host视角下的物理地址
(3)GVA(guest virtual address):guest视角下的虚拟地址
(4)GPA(guest physical address):guest视角下的物理地址
(5)IPA(intermediate physical address):它与gpa含义相同,是arm的对guest物理地址的一种命名方式

gva -> translation table 1 -> gpa -> translation table 2 -> hpa

guest os 通过 stage1 访问物理地址(IPA),硬件通过 stage2 映射访问实际物理地址(PA)

影子页表:

不支持硬件虚拟化的系统中,由于只有一个 mmu,若其被 guest os 用于 GVA -> GPA 的转换,则由于无法访问到 HPA,从而导致内存访问失败。为了解决 GPA -> HPA 之间的内存转换,引入影子页表。

影子页表是 hvpervisor 将 GVA -> GPA 和 GPA -> HPA 两级页表转换关系合并成一张表,以实现 guest 内存访问的一种机制。

(1)hypervisor 为每个 vm 中的每个进程都维护一张合并了 GVA–>GPA 和 GPA–>HPA 两级页表关系的 shadow 页表。当 guest os 执行页表切换操作时,hypervisor 将截获该操作,并用这张合并后的页表替换掉 guest os 本身的页表。它就像影子一样覆盖掉了 guest os 的页表,因此其被称为影子页表

(2)guest os 在自身页表中建立 GVA 到 GPA 的映射关系

(3)当 guest os 通过 GVA 访问内存时,若该 GVA 在 shadow 页表中已建立,则可正常通过 MMU 访问内存,否则会引起缺页异常

(4)由于 hypervisor 在页表替换时知道被替换 guest os 页表的基地址,因此在缺页异常处理流程中可通过遍历其对应的页表,查询 GVA 对应的 GPA

(5)GPA 是在虚拟机初始化内存条时设置,并与一段 host 中的用户态虚拟地址 HVA 绑定,因此 hypervisor 可以通过 GPA 获取其对应的 HVA

(6)此时可通过 host 内存管理模块的 HVA 获取到其对应的 HPA

(7)最后对以上流程进行合并,计算得到 GVA–>HPA 之间的关系,并将其填到影子页表中

显然以上流程中页表创建的开销是比较大的,因此对虚拟机的性能会有比较大的影响。为此硬件引入了两级页表,通过第一级页表完成 GVA–>GPA 的转换,并且通过第二级页表完成 GPA–>HPA 的转换,从而提高了地址转换的效率。

Armv8 两级页表:

在硬件支持两级页表后,不需要 hypervisor 执行页表合并这个复杂操作。一级页表由 guest os 创建,用于 GVA -> GPA(IPA,stage 1)的换转,而二级页表由 hypervisor 创建,用于执行 GPA(IPA,stage 2) -> HPA 转换,同样对 guest os 是透明的。

与 ASID 类似,armv8 为每个虚机提供了 VMID,用于在 TLB 中标识地址是属于哪个虚机的。有了 VMID 之后,当 cpu 执行虚机切换操作时,就不需要失效 tlb 中的内容,当下次该 vm 再次被切换回来时,若 tlb 中包含了该 vm 先前的 entry,则这些 entry 依然有效,因此可以提高虚机切换的效率和运行性能。

由于 tlb entry 中每个 vm 含有自身的 VMID tag,而每个 VM 中的每个进程又含有对应的 ASID tag,因此在实际的页表转换时需要将它们进行合并。

同样由于 stage 1 页表和 stage 2 页表都可以设置各自的属性,因此在实际页表转换时 MMU 需要选择两级页表中访问限制更严格的属性,如:

stage 1(device),stage 2 (normal),此时选择 device。
2.5 IO 虚拟化支持

虚机除了模拟物理内存空间,还需要模拟外设的地址空间,以用于其对模拟设备的访问。由于虚机视角的物理地址是 IPA,因此 IPA 需要提供虚机对外设的访问能力。对于物理地址,只需要为 IPA 分配实际的物理页框,并为其建立 stage 2 页表即可,而对于 IO 地址的目的是为了操作设备的功能,因此需要将其与设备操作相关联。

如对于一个 vm 直通的外设,IPA 地址与实际设备的 IO 地址建立页表,即可使 VM 具备了对实际设备的控制能力。而在虚拟化系统中,大部分设备都并不是某一个 VM 独享的,这种情况下 hypervisor 需要捕获 VM 的 IO 地址访问操作,并根据 IO 访问信息模拟相关设备的能力。此时不需要为这些 IO 建立 stage 2 映射,而是当 vm 访问这些地址时就会产生缺页异常,vmm 再根据 HPFAR_EL2 寄存器获取到异常地址,并根据 ESR_EL2 获取产生异常原因,最后再根据这些信息执行实际的设备模拟流程。

2.6 DMA 虚拟化支持

系统除了 cpu 外,dma 也是需要访问内存的 master 设备。在 guest os 视角中,只能感知 IPA 地址存在,因此 guest 操作 DMA 时会以 IPA 地址作为 DMA 数据搬移的源地址和目的地址。

由于 IPA 地址并非总线上的实际物理地址,因此若直接执行 DMA 操作会导致地址访问错误。同样若没有硬件拓展,vmm 可以捕获所有 DMA 控制器的 IO 操作,当捕获到 DMA 地址设置操作时,vmm 为 dma 地址分配实际物理内存,并将物理地址写入实际的 DMA 寄存器,同时建立 IPA 到 HPA 的内存映射。

上述方法效率低,从而引入 SMMU,通过 SMMU 为 master 设备创建 stage 2 映射,从而使 DMA 可以通过 IPA 直接看到 PA。

vmm 只需要为 DMA 创建 SMMU 页表,将其 IPA 转换为 PA 即可实现 DMA 的内存访问操作。

2.7 中断虚拟化支持

如果没有硬件支持中断虚拟化时,此时 guest os 正常的初始化中断控制器,异常向量表,注册中断函数。vmm 需要为 vm 模拟一个虚拟的中断控制器,捕获 guest os 中断相关的设置,并将其转发给虚拟中断控制器处理。若虚拟中断与物理中断关联,还需要根据与实际中断的映射关系,获取其对应的物理中断信息,并将其设置到物理中断控制器中。

当中断触发时,首先由 hypervisor 捕获物理中断,然后根据其物理中断与虚拟中断的映射关系设置虚拟中断控制器的状态信息,以向 vcpu 注入中断。在切换到 vcpu 之前 hypervisor 检查虚机中断是否被触发,并确定是否需要跳转到 vcpu 的中断处理入口。

为了提高效率和简化虚拟中断注入流程,GIC 在硬件层面提供了虚拟中断注入功能。如 GICv3 提供了一组 list register寄存器用于hypervisor为vcpu注入虚拟中断,并且为vcpu在硬件层面提供了vIRQ和vFIQ中断信号,用于响应注入到list register中的中断。最后,GICv3还为vcpu提供了一组虚拟cpu interface寄存器,vcpu可以像处理普通中断一样操作这组寄存器,完成中断信息读取、应答和结束等流程。

在增加硬件支持以后,armv8架构虚拟中断的流程就会变为如下这个样子:

(1)hypervisor 为 GICv3 模拟虚拟 distributor 和虚拟 redistributor。由于虚拟cpu interface已经由硬件支持,因此不再需要hypervisor模拟

(2)guest os的中断配置信息由hypervisor捕获,并发送给虚拟distributor和虚拟redistributor处理

(3)hypervisor捕获实际的物理中断,查找其对应的虚拟中断信息,并将虚拟中断信息写入list register寄存器中,以向vcpu注入虚拟中断

(4)hypervisor调度中断对应的vcpu运行,由于virq/vfiq已经被触发,vcpu进入中断处理流程。此时,其对cpu interface的操作实际上都会被GICv3转换为对虚拟cpu interface的操作,并直接作用于GIC硬件。

2.8 定时器虚拟化支持

Armv8架构的每个cpu都包含一个generic timer定时器,该定时器可以通过比较器与系统计数器比较,以确定定时器是否到期。当定时器到期时,可以产生一个PPI中断以触发定时器事件。

但是在支持虚拟化之后,由于物理cpu是被vcpu共享的,因此它们可能需要在物理cpu上交替运行。

对于交替运行的两个 vcpu,定时器如何共享?

为了解决该问题,armv8为 vm 同时提供了虚拟定时器和物理定时器。

Physical Count      Virtual offset
  (cntpct_el0)			(cntoff_el2)
  
Virtual Count
  (cntvct_el0)

其中虚拟计数器提供了一个与物理计数器之间的 offset 偏移量,该值可以由 vmm 进行设置。当不同 vcpu 运行时,为其设置不同的 offset。

3 arm64 kvm 初始化流程

kvm是基于linux内核实现的一种type 2虚拟化方案,它作为内核的一个模块负责虚拟化环境初始化,虚拟机和虚拟cpu模拟,以及IO捕获与转发等功能。在kvm中虚拟机和虚拟cpu分别通过host os的进程和线程实现,并且由host os的调度器对其进行调度。

除了像中断控制器之类的关键设备,kvm 不会模拟设备工作,因此它通常与 qemu 结合使用。由 qemu 执行实际的 IO 设备模拟,以及虚拟机创建和参数配置功能。kvm 提供 ioctl 向用户导出一组虚拟机管理接口。

3.1 初始化总体流程

kvm 初始化的主要目的是为虚拟机的创建和运行提供必要的软硬件环境,总体流程如下:

arm_init -> kvm_init
  -> kvm_arch_init
  -> kvm_irqfd_init
  		-> alloc_workqueue
  -> cpuhp_setup_state_nocalls
  		-> kvm_starting_cpu
  		-> kvm_dying_cpu
  -> register_reboot_notifier
  		-> blocking_notifier_chain_register
  -> kmem_cache_create_usercopy
  -> misc_register
  -> register_syscore_ops
  -> kvm_init_debug
  		-> debugfs_create_dir
  -> kvm_vfio_ops_init
  		-> kvm_register_device_ops

可以看到 kvm 初始化流程主要包括以下几个部分:

(1)架构相关的初始化

(2)电源管理接口注册回调,处理kvm在电源管理流程中的行为

(3)为 kvm 注册字符设备,提供 ioctl 接口

(4)其他一些辅助功能

(1)电源管理接口注册回调

可以热插拔的 cpu 和休眠的系统上,cpu可以进行 online/offline 的状态转换。

kvm_syscore_ops提供了系统挂起和恢复的回调。

register_reboot_notifier提供了在系统reboot后,下线时的通知回调,这里对应调用架构自己实现的 kvm_arch_hardware_disable

cpuhp_setup_state_nocalls则提供了cpu重新上线/下线的回调,上线则对应 reboot 时的kvm_arch_hardware_enable

下线同样对应kvm_arch_hardware_disable

(2)ioctl 接口注册

kvm 的 ioctl 接口注册在 misc_register 中完成,提供了kvm_dev_ioctl回调接口。

对应的该 ioctl 接口可以分为三个类型:kvm ioctl,vm ioctl,vcpu ioctl。

kvm ioctl 全局控制 kvm,vm ioctl 对英语特定 vm 虚机控制,vcpu ioctl 特定于 vcpu 控制。

vm ioctl 通过全局的 KVM_CREATE_VM 命令创建一个 vm fd,并且绑定匿名 inode,该匿名 inode 对应 kvm_vm_fops ioctl 回调。

vcpu ioctl 通过上面的 vm ioctl 命名 KVM_CREATE_VCPU 命名创建一个 vcpu fd,并绑定匿名 inode,该匿名 inode 对应 kvm_vcpu_fops ioctl 回调。

(3)其他一些辅助功能

  1. kvm_irqfd_init为 eventfd 创建了一个全局的工作队列,它用于在虚机被关闭时:关闭所有与其相关的 irqfd,并等待该操作完成。
  2. kvm_init_debug用于为 kvm 创建 debugfs 相关接口
  3. kvm_vfio_ops_init用于为 vfio 注册设备回调函数

(4)架构相关初始化 kvm_arch_init

armv8的虚拟化方案有两种方式:nvhe 和 vhe。

vhe 实现虚拟支持 VHE 拓展,该实现中 host os 和 guest os 都运行在 el2,此时 host os 和 hypervisor 公用所有的 el2 寄存器,且 host 可以直接通过函数调用的方式调用hypervisor接口,因此 vhe 模式下的 kvm 模块初始化流程更简单,主要包括一些 host context 初始化,以及虚拟 gic 和 timer 的初始化。

nvhe 实现相对则是更早期的实现,只需要硬件支持虚拟化即可,不需要 vhe 拓展。nvhe 的 host os 和 hypervisor 运行在不同异常级别,hypervisor 处于 el2,host os 处于 el1。比如 host os 需要通过异常(hvc)的方式进入 hypervisor,并且为 el2 的 hypervisor 独立设置异常处理函数,映射代码段,数据段等,因此初始化相对复杂,见下面。

3.2 aarch64 架构初始化总体流程
kvm_arch_init
  -> is_hyp_mode_available
  -> is_kernel_in_hyp_mode
  -> !in_hyp_mode && kvm_arch_requires_vhe
  -> check_kvm_target_cpu
  		-> kvm_target_cpu
  -> init_common_resources
  		-> kvm_get_vmid_bits
  		-> kvm_set_ipa_limit
  -> init_hyp_mode (nvhe 模式 true)
  		-> kvm_mmu_init
  		-> per_cpu(kvm_arm_hyp_stack_page, cpu) = stack_page;
			-> create_hyp_mappings
      -> kvm_map_vectors
  -> init_subsystems
      -> _kvm_arch_hardware_enable
      -> hyp_cpu_pm_init
  		-> kvm_vgic_hyp_init
      -> kvm_timer_hyp_init
      -> kvm_perf_init
      -> kvm_coproc_table_init

(1)is_hyp_mode_available:由于管理机需要运行在 el2,所以这里通过判断启动是否是在el2来判断是否支持hypervisor(可以参考之前的 arm64 启动流程分析中关于 el2_setup 的解析)。

(2)is_kernel_in_hyp_mode: 判断当前所处的异常级别判断自己是否支持 vhe,如果支持 VHE 那么 host 此时是处于 el2 的,那么 is_kernel_in_hyp_mode 返回 ture。

(3)!in_hyp_mode && kvm_arch_requires_vhe:如果没有支持 vhe,但是 kvm_arch_requires_vhe 表示我们需要 VHE 这个特性,那么此处如果不成立,则 kvm 不可用,直接返回 -ENODEV。

(4)check_kvm_target_cpu:该函数通过 smp_call_function_single ipi 在每个 cpu 上执行,通过读取 cpu 的型号并判断是否合法。

(5)init_common_resources:获取 vmid bits,以及通过读取 SYS_ID_AA64MMFR0_EL1,并根据支持的大小页判断当前系统配置的 page size 能否被硬件支持,通过解析出能够支持的最大物理地址长度。

(6)init_hyp_mode:如果没有支持 vhe,那么则会进入该流程,下面详细说明。

init_hyp_mode

(1)kvm_mmu_init

之前说过,nvhe 模式 hypervisor 和 host os 处于不同级别,此时我们处于 el1,我们需要为 el2 的 hypervisor 创建页表映射:

int kvm_mmu_init(void)
{
   
   
	int err;

	hyp_idmap_start = kvm_virt_to_phys(__hyp_idmap_text_start);
	hyp_idmap_start = ALIGN_DOWN(hyp_idmap_start, PAGE_SIZE);
	hyp_idmap_end = kvm_virt_to_phys(__hyp_idmap_text_end);
	hyp_idmap_end = ALIGN(hyp_idmap_end, PAGE_SIZE);
	hyp_idmap_vector = kvm_virt_to_phys(__kvm_hyp_init);
...
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值