写点什么

CPU 虚拟化系列文章 1——x86 架构 CPU 虚拟化

用户头像
华章IT
关注
发布于: 2020 年 11 月 27 日
CPU虚拟化系列文章1——x86架构CPU虚拟化

本文摘自于王柏生、谢广军撰写的《深度探索 Linux 系统虚拟化:原理与实现》一书,介绍了 CPU 虚拟化的基本概念,探讨了 x86 架构在虚拟化时面临的障碍,以及为支持 CPU 虚拟化,Intel 在硬件层面实现的扩展 VMX。同时,介绍了在 VMX 扩展支持下,虚拟 CPU 从 Host 模式到 Guest 模式,再回到 Host 模式的完整生命周期。


https://item.jd.com/12742101.html



Gerald J. Popek 和 Robert P. Goldberg 在 1974 年发表的论文“Formal Requirements for Virtualizable Third Generation Architectures”中提出了虚拟化的 3 个条件:


1)等价性,即 VMM 需要在宿主机上为虚拟机模拟出一个本质上与物理机一致的环境。虚拟机在这个环境上运行与其在物理机上运行别无二致,除了可能因为资源竞争或者 VMM 的干预导致在虚拟环境中表现略有差异,比如虚拟机的 I/O、网络等因宿主机的限速或者多个虚拟机共享资源,导致速度可能要比独占物理机时慢一些。


2)高效性,即虚拟机指令执行的性能与其在物理机上运行相比并无明显损耗。该标准要求虚拟机中的绝大部分指令无须 VMM 干预而直接运行在物理 CPU 上,比如我们在 x86 架构上通过 Qemu 运行的 ARM 系统并不是虚拟化,而是模拟。


3)资源控制,即 VMM 可以完全控制系统资源。由 VMM 控制协调宿主机资源给各个虚拟机,而不能由虚拟机控制了宿主机的资源。


1 陷入和模拟模型


为了满足 Gerald J. Popek 和 Robert P. Goldberg 提出的虚拟化的 3 个条件,一个典型的解决方案是陷入和模拟(Trap and Emulate)模型。


一般来说,处理器分为两种运行模式:系统模式和用户模式。相应地,CPU 的指令也分为特权指令和非特权指令。特权指令只能在系统模式运行,如果在用户模式运行就将触发处理器异常。操作系统允许内核运行在系统模式,因为内核需要管理系统资源,需要运行特权指令,而普通的用户程序则运行在用户模式。


在陷入和模拟模型下,虚拟机的用户程序仍然运行在用户模式,但是虚拟机的内核也将运行在用户模式,这种方式称为特权级压缩(Ring Compression)。在这种方式下,虚拟机中的非特权指令直接运行在处理器上,满足了虚拟化标准中高效的要求,即大部分指令无须 VMM 干预直接在处理器上运行。但是,当虚拟机执行特权指令时,因为是在用户模式下运行,将触发处理器异常,从而陷入 VMM 中,由 VMM 代理虚拟机完成系统资源的访问,即所谓的模拟(emulate)。如此,又满足了虚拟化标准中 VMM 控制系统资源的要求,虚拟机将不会因为可以直接运行特权指令而修改宿主机的资源,从而破坏宿主机的环境。


2 x86 架构虚拟化的障碍


Gerald J. Popek 和 Robert P. Goldberg 指出,修改系统资源的,或者在不同模式下行为有不同表现的,都属于敏感指令。在虚拟化场景下,VMM 需要监测这些敏感指令。一个支持虚拟化的体系架构的敏感指令都属于特权指令,即在非特权级别执行这些敏感指令时 CPU 会抛出异常,进入 VMM 的异常处理函数,从而实现了控制 VM 访问敏感资源的目的。


但是,x86 架构恰恰不能满足这个准则。x86 架构并不是所有的敏感指令都是特权指令,有些敏感指令在非特权模式下执行时并不会抛出异常,此时 VMM 就无法拦截处理 VM 的行为了。我们以修改 FLAGS 寄存器中的 IF(Interrupt Flag)为例,我们首先使用指令 pushf 将 FLAGS 寄存器的内容压到栈中,然后将栈顶的 IF 清零,最后使用 popf 指令从栈中恢复 FLAGS 寄存器。如果虚拟机内核没有运行在 ring 0,x86 的 CPU 并不会抛出异常,而只是默默地忽略指令 popf,因此虚拟机关闭 IF 的目的并没有生效。


有人提出半虚拟化的解决方案,即修改 Guest 的代码,但是这不符合虚拟化的透明准则。后来,人们提出了二进制翻译的方案,包括静态翻译和动态翻译。静态翻译就是在运行前扫描整个可执行文件,对敏感指令进行翻译,形成一个新的文件。然而,静态翻译必须提前处理,而且对于有些指令只有在运行时才会产生的副作用,无法静态处理。于是,动态翻译应运而生,即在运行时以代码块为单元动态地修改二进制代码。动态翻译在很多 VMM 中得到应用,而且优化的效果非常不错。


3 VMX


虽然大家从软件层面采用了多种方案来解决 x86 架构在虚拟化时遇到的问题,但是这些解决方案除了引入了额外的开销外,还给 VMM 的实现带来了巨大的复杂性。于是,Intel 尝试从硬件层面解决这个问题。Intel 并没有将那些非特权的敏感指令修改为特权指令,因为并不是所有的特权指令都需要拦截处理。举一个典型的例子,每当操作系统内核切换进程时,都会切换 cr3 寄存器,使其指向当前运行进程的页表。但是,当使用影子页表进行 GVA 到 HPA 的映射时,VMM 模块需要捕获 Guest 每一次设置 cr3 寄存器的操作,使其指向影子页表。而当启用了硬件层面的 EPT 支持后,cr3 寄存器不再需要指向影子页表,其仍然指向 Guest 的进程的页表。因此,VMM 无须再捕捉 Guest 设置 cr3 寄存器的操作,也就是说,虽然写 cr3 寄存器是一个特权操作,但这个操作不需要陷入 VMM。


Intel 开发了 VT 技术以支持虚拟化,为 CPU 增加了 Virtual-Machine Extensions,简称 VMX。一旦启动了 CPU 的 VMX 支持,CPU 将提供两种运行模式:VMX Root Mode 和 VMX non-Root Mode,每一种模式都支持 ring 0 ~ ring 3。VMM 运行在 VMX Root Mode,除了支持 VMX 外,VMX Root Mode 和普通的模式并无本质区别。VM 运行在 VMX non-Root Mode,Guest 无须再采用特权级压缩方式,Guest kernel 可以直接运行在 VMX non-Root Mode 的 ring 0 中,如图 1 所示。


图 1 VMX 运行模式


处于 VMX Root Mode 的 VMM 可以通过执行 CPU 提供的虚拟化指令 VMLaunch 切换到 VMX non-Root Mode,因为这个过程相当于进入 Guest,所以通常也被称为 VM entry。当 Guest 内部执行了敏感指令,比如某些 I/O 操作后,将触发 CPU 发生陷入的动作,从 VMX non-Root Mode 切换回 VMX Root Mode,这个过程相当于退出 VM,所以也称为 VM exit。然后 VMM 将对 Guest 的操作进行模拟。相比于将 Guest 的内核也运行在用户模式(ring 1 ~ ring 3)的方式,支持 VMX 的 CPU 有以下 3 点不同:


1)运行于 Guest 模式时,Guest 用户空间的系统调用直接陷入 Guest 模式的内核空间,而不再是陷入 Host 模式的内核空间。


2)对于外部中断,因为需要由 VMM 控制系统的资源,所以处于 Guest 模式的 CPU 收到外部中断后,则触发 CPU 从 Guest 模式退出到 Host 模式,由 Host 内核处理外部中断。处理完中断后,再重新切入 Guest 模式。为了提高 I/O 效率,Intel 支持外设透传模式,在这种模式下,Guest 不必产生 VM exit,“设备虚拟化”一章将讨论这种特殊方式。


3)不再是所有的特权指令都会导致处于 Guest 模式的 CPU 发生 VM exit,仅当运行敏感指令时才会导致 CPU 从 Guest 模式陷入 Host 模式,因为有的特权指令并不需要由 VMM 介入处理。


如同一个 CPU 可以分时运行多个任务一样,每个任务有自己的上下文,由调度器在调度时切换上下文,从而实现同一个 CPU 同时运行多个任务。在虚拟化场景下,同一个物理 CPU“一人分饰多角”,分时运行着 Host 及 Guest,在不同模式间按需切换,因此,不同模式也需要保存自己的上下文。为此,VMX 设计了一个保存上下文的数据结构:VMCS。每一个 Guest 都有一个 VMCS 实例,当物理 CPU 加载了不同的 VMCS 时,将运行不同的 Guest 如图 2 所示。


图 2 多个 Guest 切换


VMCS 中主要保存着两大类数据,一类是状态,包括 Host 的状态和 Guest 的状态,另外一类是控制 Guest 运行时的行为。其中:


1)Guest-state area,保存虚拟机状态的区域。当发生 VM exit 时,Guest 的状态将保存在这个区域;当 VM entry 时,这些状态将被装载到 CPU 中。这些都是硬件层面的自动行为,无须 VMM 编码干预。


2)Host-state area,保存宿主机状态的区域。当发生 VM entry 时,CPU 自动将宿主机状态保存到这个区域;当发生 VM exit 时,CPU 自动从 VMCS 恢复宿主机状态到物理 CPU。


3)VM-exit information fields。当虚拟机发生 VM exit 时,VMM 需要知道导致 VM exit 的原因,然后才能“对症下药”,进行相应的模拟操作。为此,CPU 会自动将 Guest 退出的原因保存在这个区域,供 VMM 使用。


4)VM-execution control fields。这个区域中的各种字段控制着虚拟机运行时的一些行为,比如设置 Guest 运行时访问 cr3 寄存器时是否触发 VM exit;控制 VM entry 与 VM exit 时行为的 VM-entry control fields 和 VM-exit control fields。此外还有很多不同功能的区域,我们不再一一列举,读者如有需要可以查阅 Intel 手册。


在创建 VCPU 时,KVM 模块将为每个 VCPU 申请一个 VMCS,每次 CPU 准备切入 Guest 模式时,将设置其 VMCS 指针指向即将切入的 Guest 对应的 VMCS 实例:


commit 6aa8b732ca01c3d7a54e93f4d701b8aabbe60fb7[PATCH] kvm: userspace interfacelinux.git/drivers/kvm/vmx.c
static struct kvm_vcpu *vmx_vcpu_load(struct kvm_vcpu *vcpu){ u64 phys_addr = __pa(vcpu->vmcs); int cpu;
cpu = get_cpu(); if (per_cpu(current_vmcs, cpu) != vcpu->vmcs) { per_cpu(current_vmcs, cpu) = vcpu->vmcs; asm volatile (ASM_VMX_VMPTRLD_RAX "; setna %0" : "=g"(error) : "a"(&phys_addr), "m"(phys_addr) : "cc"); }}
复制代码


并不是所有的状态都由 CPU 自动保存与恢复,我们还需要考虑效率。以 cr2 寄存器为例,大多数时候,从 Guest 退出 Host 到再次进入 Guest 期间,Host 并不会改变 cr2 寄存器的值,而且写 cr2 的开销很大,如果每次 VM entry 时都更新一次 cr2,除了浪费 CPU 的算力毫无意义。因此,将这些状态交给 VMM,由软件自行控制更为合理。


4 VCPU 生命周期


对于每个虚拟处理器(VCPU),VMM 使用一个线程来代表 VCPU 这个实体。在 Guest 运转过程中,每个 VCPU 基本都在如图 3 所示的状态中不断地转换。


图 3 VCPU 生命周期


  1. 在用户空间准备好后,VCPU 所在线程向内核中 KVM 模块发起一个 ioctl 请求 KVM_RUN,告知内核中的 KVM 模块,用户空间的操作已经完成,可以切入 Guest 模式运行 Guest 了。

  2. 在进入内核态后,KVM 模块将调用 CPU 提供的虚拟化指令切入 Guest 模式。如果是首次运行 Guest,则使用 VMLaunch 指令,否则使用 VMResume 指令。在这个切换过程中,首先,CPU 的状态(也就是 Host 的状态)将会被保存到 VMCS 中存储 Host 状态的区域,非 CPU 自动保存的状态由 KVM 负责保存。然后,加载存储在 VMCS 中的 Guest 的状态到物理 CPU,非 CPU 自动恢复的状态则由 KVM 负责恢复。

  3. 物理 CPU 切入 Guest 模式,运行 Guest 指令。当执行 Guest 指令遇到敏感指令时,CPU 将从 Guest 模式切回到 Host 模式的 ring 0,进入 Host 内核的 KVM 模块。在这个切换过程中,首先,CPU 的状态(也就是 Guest 的状态)将会被保存到 VMCS 中存储 Guest 状态的区域,然后,加载存储在 VMCS 中的 Host 的状态到物理 CPU。同样的,非 CPU 自动保存的状态由 KVM 模块负责保存。

  4. 处于内核态的 KVM 模块从 VMCS 中读取虚拟机退出原因,尝试在内核中处理。如果内核中可以处理,那么虚拟机就不必再切换到 Host 模式的用户态了,处理完后,直接快速切回 Guest。这种退出也称为轻量级虚拟机退出。

  5. 如果内核态的 KVM 模块不能处理虚拟机退出,那么 VCPU 将再进行一次上下文切换,从 Host 的内核态切换到 Host 的用户态,由 VMM 的用户空间部分进行处理。VMM 用户空间处理完毕,再次发起切入 Guest 模式的指令。在整个虚拟机运行过程中,步骤 1~5 循环往复。


下面是 KVM 切入、切出 Guest 的代码:


commit 6aa8b732ca01c3d7a54e93f4d701b8aabbe60fb7[PATCH] kvm: userspace interfacelinux.git/drivers/kvm/vmx.c
static int vmx_vcpu_run(struct kvm_vcpu *vcpu, …){ u8 fail; u16 fs_sel, gs_sel, ldt_sel; int fs_gs_ldt_reload_needed;
again: /* Enter guest mode */ "jne launched \n\t" ASM_VMX_VMLAUNCH "\n\t" "jmp kvm_vmx_return \n\t" "launched: " ASM_VMX_VMRESUME "\n\t" ".globl kvm_vmx_return \n\t" "kvm_vmx_return: " /* Save guest registers, load host registers, keep flags */ if (kvm_handle_exit(kvm_run, vcpu)) { goto again; } } return 0;}
复制代码


在从 Guest 退出时,KVM 模块首先调用函数 kvm_handle_exit 尝试在内核空间处理 Guest 退出。函数 kvm_handle_exit 有个约定,如果在内核空间可以成功处理虚拟机退出,或者是因为其他干扰比如外部中断导致虚拟机退出等无须切换到 Host 的用户空间,则返回 1;否则返回 0,表示需要求助 KVM 的用户空间处理虚拟机退出,比如需要 KVM 用户空间的模拟设备处理外设请求。


如果内核空间成功处理了虚拟机的退出,则函数 kvm_handle_exit 返回 1,在上述代码中即直接跳转到标签 again 处,然后程序流程会再次切入 Guest。如果函数 kvm_handle_exit 返回 0,则函数 vmx_vcpu_run 结束执行,CPU 从内核空间返回到用户空间,以 kvmtool 为例,其相关代码片段如下:


commit 8d20223edc81c6b199842b36fcd5b0aa1b8d3456Dump KVM_EXIT_IO detailskvmtool.git/kvm.c
int main(int argc, char *argv[]){ for (;;) { kvm__run(kvm);
switch (kvm->kvm_run->exit_reason) { case KVM_EXIT_IO: }}
复制代码


根据代码可见,kvmtool 发起进入 Guest 的代码处于一个 for 的无限循环中。当从 KVM 内核空间返回用户空间后,kvmtool 在用户空间处理 Guest 的请求,比如调用模拟设备处理 I/O 请求。在处理完 Guest 的请求后,重新进入下一轮 for 循环,kvmtool 再次请求 KVM 模块切入 Guest。



作者简介:


王柏生


资深技术专家,先后就职于中科院软件所、红旗 Linux 和百度,现任百度主任架构师。在操作系统、虚拟化技术、分布式系统、云计算、自动驾驶等相关领域耕耘多年,有着丰富的实践经验。


著有畅销书《深度探索 Linux 操作系统》(2013 年出版)。


谢广军


计算机专业博士,毕业于南开大学计算机系。


资深技术专家,有多年的 IT 行业工作经验。现担任百度智能云副总经理,负责云计算相关产品的研发。多年来一直从事操作系统、虚拟化技术、分布式系统、大数据、云计算等相关领域的研发工作,实践经验丰富。


*本文经出版社授权发布,更多关于虚拟化技术的内容推荐阅读《深度探索 Linux 系统虚拟化:原理与实现》。


发布于: 2020 年 11 月 27 日阅读数: 793
用户头像

华章IT

关注

通向科学殿堂 托起一流人才 2020.06.16 加入

还未添加个人简介

评论

发布
暂无评论
CPU虚拟化系列文章1——x86架构CPU虚拟化