[IBM文摘]KVM 虚拟化技术在 AMD 平台上的实现

摘录两篇文章,关于KVM虚拟化技术在AMD平台上的实现,作者:张前锋,是一位资深架构师。

1.《KVM 虚拟化技术在 AMD 平台上的实现 第 1 部分 AMD-V 技术及 KVM》

原文链接如:http://www.ibm.com/developerworks/cn/linux/l-cn-amd-virt/index.html

2.《KVM 虚拟化技术在 AMD 平台上的实现,第 2 部分 内存、IO 设备虚拟化》

原文连接见:http://www.ibm.com/developerworks/cn/linux/l-cn-amd-virt2/index.html

声明:此处仅作学习参考。

Linux方面IBM有更多的好资料,参见:http://www.ibm.com/developerworks/cn/views/linux/libraryview.jsp

下面试着两篇关于KVM虚拟化的东西:

==================================================================================

在 x86 平台上实现虚拟化的问题

引言

虚拟化,即在一个物理机上同时运行多个操作系统实例的技术, 自 20 世纪 60 年代开始在 IBM 的大型主机等专有平台上获得了重要的应用。 自 80 年代以来,基于 X86 处理器的 PC 技术开始高速发展并变得无处不在。 然而由于 PC 的低廉价格,人们似乎淡忘虚拟化的存在意义。进入 21 世纪,随着数据中心技术的发展和 VMware 等厂商的推动,虚拟化开始进入 PC 领域,直到今天在有着广泛应用的 PC 平台上施用虚拟化技术已经成为了趋势性的事件。在 IBM 的大型主机上,虚拟化主要目的是用来让多个操作系统共享单个主机的资源,以最大化资源的利用率。 然而在今天,由于 PC 的无处不在,虚拟化的价值已经远不仅在于最大化单台物理机的资源利用率。 相比较于传统的裸机操作系统,虚拟化能带来很多新的机会和优势。 在笔者看来,虚拟化最少在如下方面好处明显 :

应用服务的整合和安全隔离。

将多个物理机上运行的服务应用整合到一个物理机上,提高物理资源的利用率,极大的节省机房空间,提供灵活的电源管理,满足绿色经济的要求; 同时为在小的空间内部署复杂的计算或网络环境提供可能 ( 如 IP 测试床 )。 另一方面,原本运行在相同物理机上的多个应用可利用虚拟化方式部署进行隔离,提高应用的安全性。

快速动态部署能力

快速及动态的部署使应用服务的高可用性变的更简单, 使部署大规模的计算工作变的容易; 提供商在动态部署的基础上,实行精确的按使用计费或按需计费,使计算资源租用作为一种商业模式变得更有吸引力,而计算资源租用模式本身可利用对资源的灵活分配,统一管理来节省对总体资源的消耗。

削减操作系统和应用软件对硬件的依赖性

使大量无需或者难以升级的旧的应用系统得以在新的硬件平台上继续运行, 减少重新投资软件的必要。

建立复杂的软件测试环境

复杂的软件测试环境通常要求多个不同级别的处理器硬件平台,并搭配不通的操作系统和编译器版本,对于一个中小型用户来说,利用物理硬件来满足这个要求通常是很困难的,然而虚拟化技术让这个要求变得容易实现,只需要虚拟化软件创建多个不同的虚拟机,仿真出不通的处理器特征,然后再安装各自的软件环境即可。

为保持 x86 平台继续在数据中心服务器,计算工作站,个人电脑等市场的无处不在地位,Intel 等 x86 厂商必然会想方设法适应虚拟化所带来的变化和要求, 在 x86 上如何优化虚拟化技术的实现,提高虚拟化的性能是他们需要考虑的重大问题。

在 x86 上运行虚拟化的具体问题

虚拟化技术的一个重要要求之一就是通过虚拟机监控软件 (VMM) 运行的操作系统 ( 我们称之为客操作系统 ) 在运行时,其运行效果应该和直接在裸机上运行操作系统是一致的,即客操作系统不应该感觉到虚拟化技术的存在。 IBM 的主机采用 “trap-and-emulate” 的方法来实现 CPU 的虚拟化,即一般的指令直接运行,那些可能改变主机全局行为的“敏感指令”则会被截取,交由 VMM 通过仿真来完成其功能。 “trap-and-emulate” 被认为是实现 CPU 虚拟化的最好方法。 作者 Popek 和 GoldBerg 在 1974 年的一篇文章提出了 “classic virtualization” 的概念,该概念认为能够比较完好的实现 “trap-and-emulate”的硬件平台才是 “classically virtualizable” 的平台,即如果该平台上的 VMM 如果能够比较容易的捕获敏感指令,我们才认为该平台是典型的比较易于虚拟化的平台。 X86 平台由于其迅速的无处不在的统治力,在最初的设计上并没太多考虑虚拟化的要求,根据 GoldBerg 的标准,传统的 x86 属于非 “classically virtualizable”的平台。 Scott Robin 在 2000 年的文章以 Intel Pentium 为例详细介绍了 x86 平台在支持虚拟化方面存在的问题。GoldBerg 的标准并不排除用其他方法解决“trap-and-emulate”的问题, 如 X86 上的虚拟化提供商 VMware 和 XenSource 分别采用“Binary Translation” 和“Para-virtualization”来解决敏感指令问题。其中“Binary Translation”技术提前发现敏感的指令并通过插入断点来截获之,交由 VMM 来解释执行。 “Para-virtualization” 方法则直接修改客操作系统代码,修改其特权级,并将敏感指令改为 Trap Call 直接通知 VMM 来处理。 这两种在软件上处心积虑的方法会导致软件实现的复杂性,限制了 VMM 性能的提升空间,“Para-virtualization” 更是没法施用在 Windows 等私有操作系统上。

在说明 X86 平台对虚拟化的支持能力之前,我们有必要解释一下特权级的概念。 x86 硬件支持 4 个特权级 (Ring),一般内核运行在 Ring 0, 用户应用运行在 Ring 3, 更小的 Ring 有比更高的 Ring 能访问更多的系统全局资源,即更高的特权。 有些指令只能在 Ring 0 才能正确执行,如 LGDT、LMSW 指令,我们称之为特权指令;另外有些指令可以在 Ring 3 正确执行,如 SGDT、 SMSW、PUSHF/POPF,我们称之为非特权指令。

在传统的 X86 平台上支持虚拟化上存在如下问题 :

X86 指令集中存在 17 条敏感的非特权指令

“非特权指令”表明这些指令可以在 x86 的 ring 3 执行, 而 “敏感性” 说明 VMM 是不可以轻易让客操作系统执行这些指令的。 这 17 条指令在客操作系统上的执行或者会导致系统全局状态的破坏,如 POPF 指令,或者会导致客操作系统逻辑上的问题,如 SMSW 等读系统状态或控制寄存器的指令。 传统的 X86 没法捕获这些敏感的非特权指令

“Ring deprivileging” 带来的问题

除了那 17 条敏感的非特权指令,其他敏感的指令都是敏感的特权指令。 在 x86 虚拟化环境,VMM 需要对系统资源进行统一的控制,所以其必然要占据最高的特权级,即 Ring 0, 所以为了捕获特权指令,在传统 x86 上一个直接可行的方法是 “Ring deprivileging”, 如将客操作系统内核的特权级从 Ring 0 改为 Ring 1 或 Ring 3, 即 “消除” 客内核的特权,以低于 VMM 所在的 Ring 0, 从而让 VMM 捕获敏感的特权指令。 然而,采用 “Ring deprivileging” 又会带来如下问题 :

    • “Ring aliasing”。 该问题是指客操作系统可通过读取 cs,ss 段寄存器的值而知道其自身已经不处于 Ring 0, 这一结果理论上可以让客操作系统改变自己的行为,违背了虚拟化应该对客操作系统透明的原则。
    • “Ring compression”。 无论采用哪些特权级,传统 x86 都需通过分段或分页的方法来实现地址空间间的访问控制。然而传统 x86 上所有 64 位的操作系统都没使用分段,另外 x86 上的分页不区分 Ring 0、Ring 1 和 Ring 2, 也就是说 x86 上没法通过分段或分页机制来阻止 Ring 1 上的 64 位客操作系统内核来访问 VMM 的地址空间。 所以,客内核被迫采用 Ring 3, 也就是和客操作系统上的应用相同的运行级,这当然就会导致新的问题。
    • “Adverse Impact on Guest system calls”。 该问题和用 sysenter/sysexit 指令实现的系统调用机制有关。Sysenter 不同于 “int 0x80”, 其不需要经过异常表的控制而明确切换到 Ring 0, 所以 sysenter 是 x86 平台上性能更高的系统调用实现方式。 但在虚拟化情况下 sysenter/sysexit 带来的问题是, sysenter 让客操作系统上的应用进入 Ring 0 而不是客内核的 Ring 1, sysexit 在 Ring 1 上执行直接导致 fault, 解决这一问题的方法只能是让 VMM 来仿真 sysenter/sysexit, 或者让 VMM 向客操作系统屏蔽掉虚拟 CPU 的 sysenter 能力,无论哪种做法,都会导致客操作系统上应用性能下降。

“Address-space Compression”问题

传统 x86 上的操作系统如 Linux 都采用统一的线性地址空间,通过页表和特权级来控制用户进程对内核地址区域的访问,用户进程执行系统调用,只是改变自己的特权级,并不改变自己的地址空间,所有进程可以通过多级页表机制共享内核地址区域的内容。 那么在虚拟化环境下需要考虑的问题是, 是应该让 VMM 占据客操作系统地址空间的一部分,还是让其采用独立的地址空间? 采用前一方法对只有 4G 地址空间的 32 位客操作系统难一解决,另外要考虑如何防止客内核对 VMM 地址区域的访问,及如何保持对客操作系统的透明性。 采用后一种方法需要考虑如何快速实现地址空间切换,如何建构用于客操作系统和 VMM 间互相切换的控制结构 ( 类似于 IDT 和 GDT)。传统的 x86 似乎还没有很好的机制支持这个问题的解决 。

中断的虚拟化问题。

X86 操作系统的内核通过修改 EFLAGS 的 IF 位来控制外部中断的投放。 在虚拟化环境下 VMM 有诸多理由希望能统一的控制中断的投放, 然而通常情况下客操作系统对 EFLAGS.IF 的修改是很频繁的行为, 如果 VMM 通过捕获客操作系统对 EFLAGS.IF 的修改而获得对中断的控制权,显然代价过于高昂。另外一个方面是虚拟化的中断,VMM 该如何向客操作系统发起一个中断?该如何控制虚拟的中断被客操作系统投放的时间 ?

对特权资源的频繁访问问题

VMM 通过特权级控制来捕获客操作系统对特权资源的访问,在一般情况下不是一个问题,但对某些特权资源,如 APIC 的 TPR 寄存器,一方面客操作系统可能会频繁访问,另一方面 VMM 为了统一控制而又不得不截取之,导致的巨大性能开销是一个不得不严肃考虑的问题。

Intel 和 AMD 的解决方法

Intel 和 AMD 通过对其 X86 硬件架构进行扩展,解决了 X86 架构不能很好支持 “Classic Virtualization” 的问题,Intel 和 AMD 的这种 x86 虚拟化扩展分别称为 Intel VT-x 和 AMD-V( 或从代码的角度分别称为 VMX 和 SVM)。 我们认为,具有 Intel VT-x 或 AMD-V 能力的处理器都是 “classically virtualizable”的, 当然 Intel 和 AMD 不会满足于此,除了解决了前面提到的 x86 平台虚拟化存在的问题外,目前的 Intel 和 AMD 的处理器在 MMU 虚拟化和 IO 虚拟化方面都会提供相当的支持,并且在许多细节问题方面都会有所考虑。

Intel VT-x 和 AMD-V 提供的特征大多功能类似,但名称可能不一样,如 Intel VT-x 将用于存放虚拟机状态和控制信息的数据结构称为 VMCS, 而 AMD-V 称之为 VMCB; Intel VT-x 将 TLB 记录中用于标记 VM 地址空间的字段为 VPID, 而 AMD-V 称之为 ASID; Intel VT-x 将二级地址翻译称之为 EPT, AMD 则称为 NPT, 等等一些区别。 读者必须注意,尽管其相似性,Intel VT-x 和 AMD-V 在实现上对 VMM 而言是不兼容的,我们后面的介绍只限于 AMD-V。

AMD-V 技术

AMD-V 结构简介

AMD-V 在 AMD 传统的 x86-64 基础上引入了 “guest” 操作模式。 “guest”操作模式就是 CPU 在进入客操作系统运行时所处的模式。 “guest”操作模式为客操作系统设定了一个不同于 VMM 的运行环境而不需要改变客操作系统已有的 4 个特权级机制,也就是说在“guest”模式下,客操作系统的内核仍然运行在 Ring 0, 用户程序仍然在 Ring 3。 裸机上的操作系统和 VMM 所在的操作模式依然和传统的 x86 中一样,我们姑且称之为“host”操作模式。 VMM 通过执行 VMRUN 指令使 CPU 进入“guest”操作模式而执行客操作系统的代码; 客操作系统在运行时,遇到敏感指令或事件,硬件就执行 VMEXIT 行为,使 CPU 回到“host”模式而执行 VMM 的代码。 VMRUN 指令运行的参数是一个物理地址指针,其指向一个 Virtual Machine Control Block (VMCB) 的内存数据结构, 该数据结构包含了启动和控制一个虚拟机的全部信息。

“guest”模式的意义在于其让客操作系统处于完全不同的运行环境,而不需要改变客操作系统的代码。“guest”模式的设立在系统中建立了一个比 Ring 0 更强的特权控制,即客操作系统的 Ring 0 特权必须让位于 VMM 的 Ring 0 特权。 客操作系统上运行的那些特权指令,即便是在 Ring 0 上也变的可以被 VMM 截取的了, “Ring Deprivileging”由硬件自动搞定。 此外,VMM 还可以通过 VMCB 中的各种截取控制字段选择性的对指令和事情进行截取,或设置有条件的截取,所有的敏感的特权或非特权指令都在其控制之中。 有了“guest”模式,前面提到的 17 条敏感非特权指令问题和 “Ring Deprivileging”带来的三个问题被简单解决。

VMCB

VMCB 数据结构主要包含如下内容 :

1. 用于描述需要截取的指令或事件的字段列表。其中 :

        • 2 个 16 位的字段用于控制对 CR 类控制寄存器读写的截取
        • 2 个 16 位的字段用于控制对 DR 类调试寄存器的读写的截取
        • 一个 32 位的字段用于控制 exceptions 的截取
        • 一个 64 位的字段用于控制各种引起系统状态变化的事件或指令的截取,如 INTR, NMI, SMI 等事 件, HLT, CPUID,INVD/WBINVD, INVLPG/INVLPGA,MWAIT 等指令, 还包括两位分别标志是否对 IO 指令和 MSR 寄存器的读写进行控制

2. 指向IO端口访问控制位图和MSR读写控制位图的物理地址指针字段。该位图用于差别性地控制虚拟机对不同的 IO 端口和 MSR 寄存器进行读写访问。

3. 描述虚拟机CPU状态的信息。包含除通用寄存器外的大部分控制寄存器,段寄存器,描述符表寄存器,代码指针等。 RAX 寄存器也在其中,因为 RAX 在 VMM 执行 VMRUN 时是用来存放 VMCB 物理地址的。 对于段寄存器,该信息中还包含段寄存器对应的段描述符,也就那些传统 x86 上对软件隐藏的信息。

4. 对虚拟机的执行进行控制的字段。主要是控制虚拟机中断和 NPT 的字段。

5. 指示虚拟机进入“guest”模式后要执行的行动的字段。包括用来描述 VMM 向虚拟机注入的中断或异常的信息的字段。 注入的中断或异常在 VMRUN 进入“guest”模式后立即执行,就象完全发生在虚拟机内一样。

6. 提供VMEXIT信息的字段。包括导致 VMEXIT 的事件的代码,异常或中断的号码,page fault 的线性地址,被截获的指令的编码等。

VMCB 以及其涉及的控制位图,完全通过物理地址进行指向,这就避免了“guest”和“host”模式切换的过程依赖于“guest”空间的线性地址 ( 传统操作系统内用户空间到内核的切换确实依赖于 IDT 中提供的目标的线性地址 ), 使得 VMM 可以采用和客操作系统完全不同的地址空间,避免了前面提到的“Address-space compression”问题。

VMCB 的内容在物理上被分成了俩部分,其中用于保存虚拟机 CPU 状态的信息占据 2048 字节的后半部分,我们可称之为 VMCB.SAVE; 其他信息,占据前 1024 字节范围,我们可称之为 VMCB.CONTROL。

VMRUN 命令以 VMCB 为参数,使 CPU 进入“guest”状态, 按 VMCB.SAVE 的内容恢复虚拟机的 CPU 寄存器状态,并按 VMCB.SAVE 中 CS:RIP 字段指示的地址开始执行虚拟机 的代码, 并将之前 VMM 的 CPU 状态保存在 MSR_VM_HSAVE_PA 寄存器所指向的物理内存区域中。VMRUN 所保存的 VMM 的 CPU 状态的 CS:RIP 实际上就是 VMM 的代码中 VMCB 的下一个指令, 当虚拟机因某种原因而导致 #VMEXIT 时,VMM 会从 VMRUN 后的一条指令开始执行。 CPU 执行 #VMEXIT 行为时,会自动将虚拟机的状态保存到 VMCB.SAVE 区,并从 MSR_VM_HSAVE_PA 指定的区域加载 VMM 的 CPU 状态。

VMLOAD 和 VMSAVE 指令是对 VMRUN 的补充,他们用来加载和恢复一些并不需要经常使用的 CPU 状态,如 FS, GS, TR, LDTR 寄存器以及其相关的隐含的描述符寄存器的内容,VMLOAD 和 VMSAVE 可以让 VMM 的实现对 “guest”进入和退出的过程进行优化,让多数情况下只使用 VMRUN 进行最少的状态保存和恢复。

VMMCALL 指令是 AMD-V 为客操作系统内核提供的明确的功能调用接口,类似于 syscall 指令 ( 从 Ring 3 到 Ring 0), VMMCALL 让客操作系统直接执行 #VMEXIT 而进入 VMM,请求 VMM 的服务。

中断的虚拟化

AMD-V 对中断的虚拟化有如下支持 :

虚拟中断

虚拟中断是指由虚拟机的 EFLAGS.IF 标志控制的中断。 AMD-V 在 VMCB.CONTROL 中加入了一个 V_INTR_MASKING 字段, 当 V_INTR_MASKING 设置为 0 时, EFLAGS.IF 同时控制虚拟中断和物理中断; 当 V_INTR_MASKING 设置为 1 时,ELAGS.IF 值只控制虚拟中断,而 VMM 在执行 VMRUN 时保存的主机的 EFLAGS.IF 则控制物理中断。 VMCB 还提供了几个字段 V_IRQ, V_INTR_PRIO, V_INTR_VECTOR 及 V_TPR。 其中 V_TPR 代表虚拟 CPU 的 TPR, 当 V_INTR_MASKING 为 1 时,虚拟机上对 CR8 寄存器的访问被映射到对 V_TPR 字段的访问, 并由 V_TPR 和 V_INTR_PRIO 一起决定是否产生虚拟中断。 V_IRQ 表示当前存在虚拟中断请求,V_INTR_PRIO 是当前虚拟中断的优先级,如果 V_INTR_PRIO 比 V_TPR 高,则当虚拟机的 EFLAGS.IF 变为 1 时,虚拟中断就可被递交,V_INTR_VECTOR 就是中断的向量号。VMM 可以通过设置 VMCB 的 V_IRQ, V_INTR_PRIO, V_INTR_VECTOR 字段向虚拟机发起一个虚拟中断。 虚拟中断的机制扩展了中断的概念,不需要物理设备的存在,VMM 可以代表其仿真的虚拟设备,向虚拟机发起中断请求。

全局的中断控制

AMD-V 的硬件扩展包含一个标志位 GIF 可用来对物理中断和虚拟中断进行统一控制,当 GIF 被清 0 时,物理中断和虚拟中断均被屏蔽,当 GIF 被设置时,物理中断和虚拟中断才能按各自的控制机制进行递交。 STGI 指令设置 GIF, CLGI 指令清除 GIF。

中断截取

通过 VMCB.CONTROL 的 5 个控制位,物理的、虚拟的、NMI、SMI、 INIT 类型的中断都可以被截取或不截取,以便于 VMM 来控制中断相关的行为。一般来说,固定的低优先级的虚拟中断可不被截取由客操作系统自己处理, 高优先级的虚拟中断一般需要截取,以便 VMM 能快速的应答或通过 IER 关闭该中断以避免该中断延迟更低优先级的中断。

事件注入

VMCB.CONTROL 提供一个 EVENTINJ 字段,在执行 VMRUN 之前,VMM 通过设置该字段,向虚拟机注入一个异常或中断 . 被注入的事件完全在虚拟机的环境执行,就象完全发生在客操作系统中一样。被注入的事件是虚拟机返回“guest”模式后最先执行的代码,在 VMCB.SAVE 的 CS:RIP 字段指定的返回代码之前执行。

扩展的 APIC 特征

增加了 IER 控制寄存器和 SEOI 寄存器。IER 可被 VMM 软件用来控制 Local APIC 上 Pending 的中断,让某 Pending 中断不参与优先级的裁决,每个中断在 IER 中都有一个对应的控制位。SEOI 可被 VMM 软件用来结束中断的 Pending 状态,软件将需要结束的中断的向量号写入 SEOI 的 Vector 字段即可。

KVM

KVM 简介

KVM 全称 Kernel-based Virtual Machine, 即基于 Linux 内核的虚拟化技术, 精确的说,就是 KVM VMM 的核心功能是通过一个 Linux 内核模块实现的。 “基于 Linux 内核”是 KVM 在软件实现上不同于其他 VMM 实现的最重要特点, 使得 KVM 在实现上能获得如下好处 :

      • 利用 Linux 内核已有的功能和基础服务,减少不必要的重新开发。 如任务调度,物理内存管理,内存空间虚拟化,电源管理等功能,通常是一个 VMM 所必须具备的,但 KVM 可不必重新开发这些功能,直接使用 Linux 上已经相当成熟的技术。
      • 利用强大的 Linux 社区,吸引优秀的 Linux 内核程序员参与到 KVM 的开发中, 壮大 KVM 的群体, 这些程序员以及红帽等 Linux 社区背后的厂商,也乐于在 Linux 上发展一个成熟的 VMM 技术。
      • 可以长期享受 Linux 内核技术不断成熟和进步的好处,优化 KVM 的实现。 如 Linux 内核的 HugeTLBPage 技术可以用于削减 KVM 在虚拟机内存使用上的性能开销, eventfd 可以用于提升 KVM 内核执行路径和 Qemu-kvm 用户空间的交互效率。

KVM 在 VMM 的理论上属于硬件辅助的虚拟化技术, 即 KVM 需要利用 AMD-V 提供的虚拟化能力。AMD-V 让 KVM 上的虚拟机正常情况下运行在 “guest” 模式, 在执行敏感的的指令或行为时透明地切换到 “host” 模式,并在 “host”模式由 KVM VMM 的代码仿真那些敏感的指令或行为,完成后又回到“guest”模式由虚拟机运行其正常的代码。 KVM 的 VMM 代码实际上就是当虚拟机被捕获时才进入,执行仿真代码,然后又执行状态切换回到虚拟机代码直到其下一次被捕获,如此循环不断。 当然 KVM 的 VMM 在仿真复杂的行为时,可能需要用户空间的帮助, 所以 KVM 在此期间切换到用户空间。

KVM 在 Linux 上的实现

KVM 的 VMM 代码包括内核代码和用户空间代码俩部分。 内核代码即 kvm.ko 模块的代码,分布在内核源码的 virt/kvm 和 arch/x86/kvm 两个目录下, 前者是和硬件结构无关的代码, 后者是和硬件结构相关的代码,其中和 AMD-V 相关的代码由 arch/x86/kvm/svm.c 文件提供。KVM 内核空间代码实现的功能包括如下几个方面 :

1. 实现硬件辅助的虚拟化的核心功能。包括实现“guest”模式及模式切换,虚拟 CPU 的状态和控制,指令仿真等所有之前提到的 x86 虚拟化所要解决的问题。代码分布在 arch/x86/kvm 下 x86.c, svm.c, emulate.c 等文件中。

2. 提供用户空间对KVM控制。实现 /dev/kvm 字符设备接口用于 qemu-kvm 对 KVM 的控制,包括创建虚拟机,创建虚拟 CPU, 创建虚拟机内存空间,运行虚拟机等功能的 ioctl() 接口 . 其实现的 ioctl() 功能主要分成二类,针对单个虚拟机整体的功能和针对单个虚拟 CPU 的功能。 相关代码分布在 kvm_main.c, x86.c, svm.c 等文件中。

3. 对x86平台设备进行仿真。包括仿真 PIC,IOAPIC,Local APIC 及 PIT 的代码。

4. 实现IO Port空间和MMIO空间的仿真。这是实现平台设备和外部设备仿真所需要的基础功能。代码分布在 coalesced_mmio.c, iodev.h, emulate.c, x86.c 及 svm.c 等文件中。

5. 实现基于文件描述符的通知机制。代码在 eventfd.c 文件中。 其中 ioeventfd 机制用于内核空间仿真 PIO 后向用户空间发送通知, irqfd 机制用于 qemu-kvm 进程向虚拟机注入中断。

6.MMU虚拟化的支持。包括 shadow 页表的实现,TLB 的虚拟化。 主要代码在 mmu.c 文件中。

7. 和PCI-Passthrough相关的功能。代码分布在 kvm_main.c 和 iommu.c 中。当然对物理 IOMMU 单元的管理还需要 Linux 内核 IOMMU 设备抽象层的代码及厂家 IOMMU 驱动的代码,分别分布在 driver/base/iommu.c 及 arch/x86/kernel/ 下的 amd_iommu.c 和 amd_iommu_init.c 文件中。 对 IOMMU 的支持是独立于 KVM 的,它也可以被用于非系统虚拟化的情景。

用户空间的代码就是 qemu-kvm 的代码。 Qemu-kvm 是从 qemu 分支出来的项目 , 其目的就是利用传统 qemu 的功能,来实现各种虚拟设备的仿真,并提供对 KVM 进行控制的用户空间接口。Qemu-kvm 实现的功能包括如下方面 :

1. 实现与KVM内核接口的用户空间逻辑。包括对 x86 虚拟机 PC 结构的定义,虚拟机的创建,平台设备的创建,虚拟 CPU 的创建接口。

2. 各种层次和类型的硬件设备的仿真。包括 BIOS, PCI Hub,PCI 设备 ( 包括 USB 控制器,IDE 控制器,SCSI 适配器, 声卡,显卡,网卡等 ),USB 设备, IDE 磁盘, SCSI 磁盘。 Qemu-kvm 采用一个 qdev 的设备模型,来简化不同类型设备的仿真实现。

3. 虚拟块设备的不同磁盘Image文件格式的支持。包括 qcow2, qed, vmdk, vpc 等。 这方面的能力影响 KVM 平台的可管理性 ( 如快照 ) 及 KVM 虚拟化技术和管理软件的兼容性。

4.VNCSPICE等表示层协议的支持。这方面的支持能力决定声卡,显卡的仿真在用户使用层次的效果。

5.Virtio设备的后端。VirtIO 是 KVM 上设备虚拟化的标准,即完全虚拟出来的网络和块设备,客操作系统端使用虚拟的前端驱动和 Qemu-kvm 实现的后端之间紧密合作实现的网络和块设备逻辑, VirtIO 设备的效率要远高于完全由 Qemu-kvm 仿真出的传统的网络和块设备。

6.QMP 协议的支持。 QMP 即 Qemu Monitor Protocol, 是虚拟化管理接口层,如 libvirtd, 用来控制和查询 Qemu-kvm 状态及信息的协议。

Qemu-kvm 的代码很大,笔者难以解释各代码文件实现的功能,但有必要介绍一下 qemu-kvm 代码的组织结构和编译过程, 弄清楚 qem-kvm 可执行文件是如何编译出来的,图一和图二的内容分别是经过笔者裁剪的 Qemu-kvm 的源码根目录和 x86_64-softmmu 目录下的 Makefile 文件 :


图 1. Qemu-kvm 的源码根目录
图 1. Qemu-kvm 的源码根目录 

图 2. x86_64-softmmu 目录下的 Makefile 文件
图 2. x86_64-softmmu 目录下的 Makefile 文件 

结合这两个文件和 qemu-kvm 的源码树,笔者有如下几点说明:

1.Qemu-kvm可执行文件是通过X86_64-softmmu/Makefile制作的。根目录下的 Makefile 在调用 x86_64-softmmu/Makefile 之前需要编译 qemu-io, qemu-img, qemu-nbd 三个工具以及 libqemu_common.a 这个库。 libqemu_common.a 包含了所有和处理器结构及平台无关的代码,如 spice, vnc, usb 等协议的实现,qed,qcow2 等虚拟磁盘格式实现,Slirp, VDE 网络功能代码, 还有那些独立于系统平台的硬件设备的仿真代码。

2.Qemu-kvm可执行文件由若干目标文件libqemu_common.alibqemu.alibqemuhw64.a三个库一起链接而成。其中 libqemu.a 所包含的代码主要是用于各种 CPU 结构的仿真的,在 qemu-kvm 中用不上。libqemuhw64.a 是通过 libhw64/Makefile 生成的,主要提供 x86 结构下特有的物理设备的仿真代码,如 Intel 的网卡 ne2000 的仿真。 “若干目标文件” 主要包含和虚拟化实现相关的内容, 有 x86 平台专有的部分如 vga,ide 等 x86 专用设备的仿真;还有和平台无关的部分,如 virtIO 设备的后端,e1000 等在多种平台上被使用的设备的仿真代码。

3.源码文件主要分布在源码根目录, 以及hw/block/audionet/slirp/ui/QMP/target-i386等子目录下。Libhw64/ 和 X86_64-softmmu/ 目录下不提供源码文件。

4. 子目录 Kvm/ 是一个独立的目录,其下面也包含 Makefile 和源码文件,是用来实现一个微小型的系统仿真器,用来测试 KVM 的内核提供的功能的,可以看成是一个高度简化的 qemu-kvm 的实现,和 qemu-kvm 本身没关系。初学读者可先学习 kvm/ 目录下的内容了解 KVM 内核提供的接口的功能。

5.Qemu-kvm 直接和 KVM 内核层相关的代码主要存在于 vl.c, kvm-all.c, qemu-kvm.c, target-i386/kvm.c 及 qemu-kvm-x86.c 中, 其中后两个文件是直接和 x86 硬件结构相关的。

重要的数据结构和接口

一个快速分析,理解 KVM 实现的方法是了解其代码中一些重要数据结构和接口函数 :

1.struct kvm 和 struct kvm_arch。 用来描述单个虚拟机全局的状态, 如代表虚拟机物理内存区域的 memslots, 代表当前需要仿真的 Coalesced MMIO 的 coalesced_mmio_ring, 用来仿真平台设备 PIC, IOAPIC, PIT, KVMCLOCK 的数据结构, 用来组织 Shadow 页表的数据结构等等。

2.struct kvm_vcpu 和 struct kvm_vcpu_arch。 用来描述单个虚拟 CPU 的状态, 如需要仿真的虚拟 CPU 的特征位 ( 客操作系统 CPUID 看到的东西 ), 当前尚未处理完的 PIO,等待注入的异常和中断事件,用来仿真 Local APIC 的数据结构,尚未同步到 VMCB 中的修改过的寄存器的缓存等等。

3.struct vmcb。 用来实现 AMD-V 的 VMCB 的数据结构。 其中的字段的格式和长度当然需要严格遵守 VMCB 的规范。 两个子结构 struct vmcb_control_area 和 struct vmcb_save_area 分别代表物理上的 VMCB.CONTROL 和 VMCB.SAVE。

4.Struct vcpu_svm。用来代表 AMD-V 实现下的 VCPU, 除关联 struct kvm_vcpu 和 struct vmcb 外, 还用来实现一些 AMD-V 特定的东西,如 ASID 和 Sysenter 的支持。

5.Struct kvm_memslots。 该结构挂在 struct kvm 下,用来描述虚拟机的全部物理内存区域, 每个区域对应一个 struct kvm_memory_slot 结构,其中包含该区域的 gpa 起点,大小及在 qemu-kvm 用户空间的 hva 起点。

6.Struct kvm_x86_ops。该接口定义了若干操作,规定 KVM 在 x86 平台上的基本实现方式,svm.c 中提供了大部分操作的独立实现,其中多数函数以 “svm_xxx”方式命名,读者可直接查找 svm_x86_ops 变量了解每个操作在 svm.c 中对应的函数。

7.Struct kvm_io_dev_ops。该接口定义了三个操作 (read,write,destructor),规定了在 KVM 内核中 PIO 或 MMIO 仿真的实现结构,目前平台设备 PIT, PIC, IOAPIC 的 PIO/MMIO 仿真处理都是按该接口的方式实现的。有趣的是,Ioeventfd 机制也使用了该接口, Ioeventfd 实现该接口的 write 操作,当 KVM 截取客操作系统向某特定的 PIO 或 MMIO 地址写入某特定的值时,Ioeventfd 实现的 ioeventfd_write() 操作就会执行, 唤醒等待在指定的文件描述符上的 qemu-kvm 线程。RHEL6.2 上 qemu-kvm 实现的 virtIO 设备的后端,已经使用了 ioeventfd 的功能。

重要的执行路径介绍

为了帮助读者了解 kvm 及 qemu-kvm 代码的主体结构,我们可以看一下代表一个 VCPU 的 qemu-kvm 线程的重要执行路径,如图三所示 :


图 3. 执行路径
图 3. 执行路径 

在此图中,最左侧的执行路径是 qemu-kvm 的代码建立一个虚拟机的过程, 起点是 pc_init_rhel620, 该函数的代码在 hw/pc.c 中,“rhel620”代表 qemu-kvm 对虚拟机的定义, hw/pc.c 中提供了多个虚拟机定义,“rhel620” 是保留选用的定义,“rhel620”定义采用“cpu64-rhel6” CPU 模型,支持最大 255 个虚拟 CPU。一个 CPU 模型定义了虚拟的 CPU 所支持的特征列, qemu-kvm 定义的全部 CPU 模型在 sysconfigs/target/cpu-x86_64.conf 文件下。函数 pc_init1 是 qemu-kvm 用户空间很关键的一个函数,其代码中需要创建虚拟机的 PC 硬件,如分配物理内存,加载 BIOS, 分配 IO 端口空间,创建平台设备,创建 VGA 设备等。 pc_init1 然后要做的工作就是为每个 VCPU 创建一个线程。

VCPU 线程的启动函数为 ap_main_loop, 其代码在 qemu-kvm.c 中,该函数的代码需要从内核中创建 VCPU 抽象及单个 VCPU 相关的硬件,然后调用 kvm_main_loop_cpu。 kvm_main_loop_cpu 中包含 while 循环,除非 VCPU 关闭,否则 VCPU 的线程就不会退出该循环。kvm_rum 中调用 ioctl(,KVM_RUN) 进入 KVM 内核,在内核代码中切换进入客操作系统的代码执行,真正运行起一个 VCPU。一般只有当有 IO 操作需要用户空间仿真时,kvm_run 才会从 ioctl(,KVM_RUN) 中返回,执行 handle_mmio 等一些 IO 操作仿真的代码。

在内核空间,KVM 的 ioctl 功能分派函数会调用 __vcpu_run, 该函数也是一个包含 while 循环的函数,在每次工作的开始调用 vcpu_enter_guest, vcpu_enter_guest 做一些检查工作后调用 svm 特定的 svm_vcpu_run 函数, svm_vcpu_run 是进行客主模式切换的地方, 这里调用 VMRUM 指令加载 VMCB 的内容,进入“guest”模式。 客操作系统的代码运行在“guest”模式,遇到任何中断,异常或其他需要被捕获的事件,硬件就会执行 VMEXIT 而退出“guest”模式,退出后从 svm_vcpu_run 中位于 VMRUN 后的一条语句开始执行。 退出 svm_vcpu_run 后在 vcpu_enter_guest 中会调用 svm 特定的函数 handle_exit, 做退出事件的处理。 如果 handle_exit 处理的结果表明立即需要用户空间继续处理,如外部设备 IO_Memory 的仿真, 则从 vcpu_enter_guest 返回后的代码会退出 __vcpu_run, 进而退出到用户空间,从 ioctl(,KVM_RUN) 返回,执行 handle_mmio 等用户空间的仿真工作, 并在用户空间 kvm_main_loop_cpu 的主循环中重新调用 ioctl(,KVM_RUN) 进入 KVM, 进而重新进入客操作系统。

下部分我们将探讨内存虚拟化、IO 设备虚拟化等方面的话题。

=======================================================================================

内存虚拟化

Shadow Paging

作者 Shawn 在其中文博客中很详尽地介绍了 KVM 在只支持一级分页的 x86 平台上用 “Shadow Paging”进行 MMU 虚拟化的实现,由于目前新的 X86 硬件平台提供的虚拟化扩展都能支持两维分页处理,所以笔者在此没必要再细节描述“Shadow Paging” 的实现, 但仍有必要概括一下其特点 :

1. 每个虚拟机对应的 qemu-kvm 进程通过分配不同的虚拟内存区间来映射虚拟机不同的物理内存区域。 每个虚拟机对应的 struct kvm 的 memslots 数组用来描述虚拟机物理内存和 qemu-kvm 虚拟区间的对应关系。 ( 在采用二维分页技术的 KVM 实现中,这种通过 qemu-kvm 的虚拟内存区域来映射虚拟机物理内存的方法是相同的 )

2. 虚拟机运行时,Shadow 页表或 CPU 的 TLB 将客操作系统上的虚拟地址 (gva) 翻译成主机上的物理地址 (hpa)。 每个虚拟机的 CPU 的 CR3 寄存器指向的是 shadow 页表的根目录的物理地址。 KVM 能截取并屏蔽客操作系统对 CR3 的访问。 在采用分页模式的虚拟机中,客操作系统内的页表记录的是客操作系统意义上的物理地址 (gpa)

3. 虚拟机进行内存访问时,只有 CPU 的 TLB 记录及 Shadow 页表项的缺失或访问控制才会导致“Page Fault”, 产生 VMEXIT 状态切换。 客操作系统的页表状态和“Page Fault”无关

4.KVM 在处理 “Page Fault”时,会首先检查客操作系统页表的状态,如果客操作系统页表本身就不存在从 gva 到 gpa 的映射, 则 KVM 要向虚拟机注入一个“Page Fault”, 由客操作系统首先完成其逻辑上的页分配; 如果客操作系统上已经存在 gva 到 gpa 的映射,则 KVM 需要根据 gpa 和 memslots 的记录确定该 gpa 对应的 qemu-kvm 进程空间的虚拟地址,即 hva, 然后调用 get_user_pages() 确定或分配物理页, 即确定 hpa, 最后根据 gva 和 hpa, 建立他们在 Shadow 页表中的映射关系。 ( get_user_pages 是 Linux 上用于用户进程空间的物理页分配接口, 也就是说 KVM 的实现完全利用了 Linux 已有的物理页管理和分配功能 )

5. 为了维护客操作系统页表和 Shadow 页表的一致性,KVM 实现上采取了一些技巧 :

  1. 在第一次建立 gva 到 hpa 的映射时,KVM 需要将该 hpa 标记为只读的, 以便于下一次该页被写时,KVM 能将 CPU TLB 及 Shadow 页表的 “dirty”标记同步到客操作系统对应的页表项上。
  2. 在 shadow 页表上建立 gva 到 hpa 的映射时,如果该 gva 对应的是客操作系统上的页表页, 则 KVM 将该 hpa 映射为只读的。 这样做的目的是能随时控制客操作系统对其页表的修改,及时将客操作系统页表的变化同步到 Shadow 页表上。
  3. 为维护客操作系统的页表页和其对应的 Shadow 页表页的对应关系, KVM 在描述每个虚拟机物理内存区域的 struct kvm_memory_slot 中提供了逆向映射信息,能将一个客操作系统页表页的 gfn 映射到其所有 Shadow 页表项, 其中对于有多个 Shadow 页表项对应于相同 gfn 的情况, 采用了一个 struct kvm_rmap_desc 数据结构来组织映射关系。

6. 分页模式的客操作系统的每一个进程在 KVM 上都有一个独立的 Shadow 页表,为了避免减低性能,在客操作系统进程切换时,KVM 是不能直接释放以前进程的 Shadow 页表的,即系统中所有的 Shadow 页表页都是缓存的。 为了在虚拟机的 CR3 发生变化后重新使用必要的 Shadow 页表页, KVM 为每一个 Shadow 页表页提供了一个 struct kvm_mmu_page 数据结构,该结构中包含该 Shadow 页表页对应的 gfn。 在每个虚拟机对应的 struct kvm_arch 结构中有一个 hash 表,mmu_page_hash[], 用于按 hash 方式组织虚拟机全部的 Shadow 页表页。 每次发生 CR3 改变或 Shadow 页表的“Page Fault”行为时,KVM 以 gfn 为参数,通过查找 hash 表,可确定对应的 Shadow 页表页是否在缓存中存在。

7. 对于那些采用非分页模式的客操作系统 ( 如 Linux 启动阶段的实模式,非分页保护模式 ),KVM 仍然采用 Shadow 页表的分页模式来实现,这是提供统一的物理内存分配所必须的, 只不过对于非分页模式的虚拟机而言,免去了客操作系统和 Shadow 页表同步的问题。 当然,KVM 能通过对 CR0 的截取,向客操作系统屏蔽分页机制的存在。

Nested Paging

作为纯粹用软件方法实现的解决虚拟机使用物理内存的技术,“Shadow Paging”明显的缺点是在传统操作系统分页处理的基础上增加了额外的开销,影响虚拟化解决的性能。 这种额外的开销包括 VMM 对客操作系统页表修改的截取以及相关的同步操作,对 CR3 切换的截取以及相关的 Shadow 页表上下文切换操作, 这些操作通常是相当频繁的。 为了解决 “Shadow Paging”的性能问题,AMD 为其 AMD-V 扩展增加了一个新的特征,称之为 NPT 或 “Nested Paging”。 NPT 是采用二维分页的技术,即运行的客操作系统会使用二个体系的页表来执行虚拟地址到物理地址的映射,第一个页表映射从 gva 到 gpa,完全由客操作系统的页表来控制, 第二个页表映射从 gpa 到 hpa, 由 VMM 上的页表来控制。

为实现”Nested Paging”,AMD-V 提供了如下硬件特征 :

      • 提供了一个 nCR3 寄存器用来存放虚拟机运行时第二维页表的物理地址, 该寄存器的值由 VMCB.CONTROL 的 N_CR3 字段在 VMRUN 时进行设定, 软件无法直接读取该寄存器的值。
      • 提供了一个 gPAT 寄存器用来影子虚拟机的 PAT MSR 寄存器, gPAT 的值由 VMCB.SAVE 的 G_PAT 字段在 VMRUN 时进行设定,VMM 软件无法读取该寄存器的值。显然,用 gPAT 来对客操作系统的 PAT 进行影子,是必要的硬件支持, 因为一方面客操作系统的页表需要运行时参考 PAT, 所以 PAT 不可能象其他 MSR 一样通过截取来仿真,另一方面也不能直接开放物理的 PAT 寄存器给客操作系统,因为 Host 层进程的页表也需要使用它。
      • CR0, CR4, EFER 寄存器具有复制的硬件状态。 也就是说当 NPT 功能激活时,VMRUN 从 VMCB.SAVE 加载的这三个客操作系统的寄存器不会破坏 VMRUN 运行前该 CPU 的 CR0,CR4,EFER. 虚拟机和主机有二套分开的 CR0,CR4,EFER 来分别控制其各自层次的分页行为。
      • 提供了一个无名的 NPT 状态控制位,该位的值由 VMCB.CONTROL 的 NP_ENABLE 字段来初始化。当该 NPT 状态控制位为 1 时,运行在该 CPU 上的客操作系统才能使用二维分页。

图 1 所示的是 Nested Paging 的物理结构 . 运行在 NPT 情况下,虚拟机 CPU 的 CR3 寄存器指向的是客操作系统上页表的 gpa, nCR3 指向的是由 VMM 维护的第二维页表的 hpa。 在硬件的驱动下,客操作系统的 gva 首先被第一维页表翻译成 gpa, 然后 VMM 上的第二维页表又将该 gpa 翻译称 hpa。 物理的页表遍历硬件逻辑是相当复杂的,其中第一维页表 CR3 及每一级翻译输出的 gpa 都要经过第二维页表的翻译才能转换成 hpa, 因此翻译一个 gva 可能需要经过 20 多次的物理内存访问, 导致较高的物理延迟。 在 NPT 情况下,已有的 TLB 用来缓存 gva 到 hpa 的映射。 此外,支持 NPT 的 AMD 的处理器一般都提供了一个 Nested TLB 来缓存 gpa 到 hpa 的映射, 以平衡在 TLB 的 “Cache Miss” 情况下 NPT 二维页表遍历延迟较高所带来的性能损失。


图 1. 性能损失
图 1. 性能损失 

另外一个和 NPT 相关的问题是物理页属性控制问题,在 NPT 情况下,针对一个 CPU 上运行的客操作系统,有两套控制寄存器 (CR0,CR4,EFER,PAT,MTRRx) 以及两层页表上的属性位来控制一个物理页的访问属性, 针对每个控制寄存器和属性位,NPT 一般采用 guest 和 host 两级的交集来执行控制,如只有当 guest 和 host 两级的页表和控制寄存器都允许写某物理页时,该物理页才允许被写。 MTRRx 的处理比较特殊,目前客操作系统的 MTRR 寄存器实际上对页的访问控制是无效的, 一方面 AMD-V 硬件上没有对 MTRR 寄存器建立影子,另一方面 KVM 截取客操作系统对 MTRR 的访问的处理比较简单,在 MTRR 被设置时会重新初始化整个第二维的页表, 释放所有的页表页,当以后 KVM 上“page fault” 重新建立这些页表页时,这些 guest 层的 MTRR 的设置才会同步到页表项中去。

在 NPT 情况下,客操作系统对其自己的页表 ( 第一维页表 ) 有完全的控制,因此 KVM 不必要截获客操作系统对页表的修改。 另外,由于第二维页表执行的是从 gpa 到 hpa 的映射,在一个虚拟机运行过程中,KVM 只需要维护一个唯一的第二维页表,也就免去了截获客操作系统切换 CR3 的开销。 当然,NPT 的另外一个优点是比 Shadow Paging 节省很多 VMM 层的页表页, 减少物理内存的总体消耗。 很多测试表明,在采用 NPT 技术后,虚拟化应用的性能会显著提高,内存分配密集型的应用尤其如此。

IO 设备的虚拟化

物理设备仿真

对传统物理设备进行仿真是 KVM 最早期采用的支持虚拟机 IO 的方式。 这种方式的特点是客操作系统看到的设备和通常裸机操作系统上看到的设备有相同的格式和规范,如同样格式的 PCI 配置空间,同样的 MMIO 区域尺寸和格式,同样的 PIO 寄存器和功能。 因此客操作系统能使用已有的传统物理设备的驱动来访问这些仿真的设备。这种方式的好处是不要求客操作系统做任何软件上的改变。

设备仿真的实现过程主要是让 VMM 截取客操作系统对设备 MMIO 和 PIO 空间的访问,然后分派不同的后端来处理与 MMIO 或 PIO 访问对应的逻辑。 就 KVM 而言,一般平台型设备,如 PIT, PIC, APIC 等是直接在 KVM 内核中实现后端的代码的, 而 PCI 总线上的设备或更外围的方式连接的设备,由于其逻辑的复杂性,都是通过 qemu-kvm 在用户空间来仿真的。在 KVM 内核空间的 MMIO 和 PIO 实现比较简单, KVM 实现了一个抽象的 IO_BUS 模型,把所有的设备都按 struct kvm_io_device 抽象实现,要求每个都提供自己的 struct kvm_io_dev_ops 接口的实现, 每个平台设备的 struct kvm_io_dev_ops 的 read/write 函数的主要功能就是根据 IO 地址读写该设备内核数据结构的不同部分, 并根据数据的值变化实现一定的逻辑。

就 qemu-kvm 用户空间而言,最重要的工作是仿真设备的 MMIO 内存和 PIO 端口。 设备的 MMIO 内存可实现成三种方式 :

  1. “physical_memory”。 这种方式所仿真的 MMIO 和虚拟机的 RAM 是一样的,即和 RAM 一样由 KVM 进行缺页处理并分配物理页, 后续的读写不用截取,这种方式经常用于 VGA 设备, 也可用于那种读写以后不需要同步通知 qemu-kvm 后端或用其他方式通知 qemu-kvm 后端的 MMIO 区域。
  2. “io_memory”。 这种方式所仿真的 MMIO 会被 KVM 内核截取,执行路径会跳出 KVM 内核而回到用户空间,由 qemu-kvm 来完成 MMIO 读写的仿真。 这种方式下,一般不同类型的设备都提供了自己特定的 read/write 函数。 为支持这种方式,KVM 内核中代表每个 VCPU 的 struct kvm_run 区域被 mmap 到了用户空间,qemu-kvm 进程可根据 struct kvm_run 中的信息知道当前需要仿真的 MMIO 读写操作涉及的地址和尺寸。
  3. “coalesced_memory”。 这种方式所仿真的 MMIO 会被 KVM 内核截取,但 KVM 并不会立即跳出到 qemu-kvm 用户空间,KVM 将需要仿真的读写操作形成一个记录 (struct kvm_coalesced_mmio), 放在在代表整个 VM 的 struct kvm 所指向的一个环形缓冲区中 (struct kvm_coalesced_mmio_ring), 这个环形缓冲区被 mmap 到了用户空间。 当下一次代表某个 VCPU 的 qemu-kvm 线程返回到用户空间后,就会对环形缓冲区中的记录进行处理,执行 MMIO 读写仿真。 也就是说,对于 “coalesced_memory” 方式, qemu-kvm 一次仿真的可能是已经被积累起来的多个 MMIO 读写操作, 显然这种方式是一种性能优化,它适合于对响应时间要求不是很严格的 MMIO 写操作。

PIO 的仿真更简单。所有的 PIO 都会被 KVM 所截取,不能在内核处理的 PIO 使 KVM 跳出到 qemu-kvm 用户空间, 由 qemu-kvm 来完成 PIO 读写的仿真。 Qemu-kvm 同样是根据用户空间已经 mmap 了的每个 struct kvm_vcpu 的 struct kvm_run 区域以及 pid_data 区域知道需要仿真的 PIO 读写操作涉及的端口地址、尺寸及数据。

当然 Qemu-kvm 设备仿真要做的更大量工作是在逻辑层,如对于网络设备,需要考虑仿真的网络包怎样经过 Host 的网络进行发送和接收, 桥接的还是 NAT 的; 对于块设备,需要考虑客操作系统写过来的磁盘块以怎样的方式组织到设备 Image 文件中, Qcow2 或 QED 的选择; 对于 VGA 设备,需要实现怎样把图像缓冲区中的内容通过远程表现处理,VNC 或 SPICE 等。 设备逻辑层所需要做的工作应该是目前开发空间比较大的地方, 这部分和 KVM 虚拟化的基本机制没太大关系,内容太多,在此就不描述了。

虚拟的功能设备

实现虚拟的功能设备是支持虚拟机 IO 的第二种方法。 虚拟的功能设备就是说虚拟机使用的 IO 设备不一定要遵守已有的设备标准如 SCSI 协议,Intel 以太网卡的规范等等, 只要实现上能完成操作系统想要的功能即可,如支持磁盘数据块的传送,TCP/IP 网络包的传送,实现 IO 完成的通知等。 这种方式由于不遵守已有的设备格式和规范,无法使用客操作系统上已有的物理设备驱动, 在 VMM 上虚拟化设备的同时,在客操作系统上也需要专有的驱动程序配合。 不同的 VMM 实现往往有自己独立的虚拟设备的实现机制,目前 KVM 采用的是称为 virtIO 的技术。 VirtIO 是 IBM 的 Rusty Russell 提出的实现虚拟设备的规范,其核心思想是通过定义一个公共的 ABI, 让客操作系统以简易的方式向 VMM 告知其数据缓冲区,并以这些缓冲区为基础承载不同类型虚拟设备的数据交换。 在 virtIO 的思想中,虽然逻辑数据是客主双方交换的,但缓冲区总是由客操作系统方面提供的。 以 Linux 客操作系统为例,VirtIO 的实现可简要描述如下 :

  1. KVM 以仿真的 PCI 设备的方式向客操作系统呈现每一个 VirtIO 设备。 所有类型的 VirtIO 设备都使用同一个 Device ID。 VirtIO 设备的类型由 PCI 设备的 subsystem Vendor Id 及 subsystem Device Id 来区分。 目前已经实现的 VirtIO 设备包括 virtio_net, virtio_blk, virtio_baloon, virtio_console 及 virtio_hw_random。 在 Linux 上 virtio_pci 驱动的设备 probe 函数 virtio_pci_probe 最终会调用具体的 VirtIO 设备的 probe 函数 ( 如 virtnet_probe) 来识别其设备,建立相关的数据结构。
  2. 数据结构 struct virtio_device 用来代表被识别的每一个 virtIO 设备, 和该结构关联的是一组操作接口, 包括 get, set, get_status, set_status, get_features,finalize_features,reset, find_vqs 及 del_vqs。 这些接口的作用就是通过对 virtIO 设备的配置空间的读写,来和设备进行交互,或对设备进行控制。 其中 find_vqs 就是用来发现该 virtIO 设备的后端提高了那些 virtioqueue。
  3. 在 virtIO 的框架中,用来在客操作系统和 VMM 之间实现数据传输的抽象机制就称为 virtioqueue。 Virtioqueue 就是一组操作接口, 包括 add_buf, get_buf, disable_cb, enable_cb, notify 以及 callback。 其中 callback 是由具体类型的 virtIO 驱动实现的,其他几个操作的实现則由特定 Linux 版本的 virtioqueue 采用的具体实现方式确定。
  4. virtioqueue 针对于不通的 VMM,是可以有不同的数据传输实现方式的。 对于目前的 KVM 来说,virtioqueue 采用的实现方式称为 vring。 Vring 规定了客操作系统发布到 virtioqueue 的 buf 的组织方式。物理的 vring 包括 vring_desc 数组, 可用的 vring_desc 环,已用的 vring_desc 环三个部分。 其中 vring_desc 数组用来将客操作系统向 virtIO 设备发送的 IO Request 组织称 vring_desc 链表的形式; 可用的 vring_desc 环用来标识那些 VMM 端当前可以处理的描述符; 已用的 vring_desc 环是 VMM 用来标识当前客操作系统端可处理的描述符。 Vring 的这三个部分处于一个连续的客操作系统物理内存块上,其 gpa 由 virtioqueue 在初始化时确定。 vring 的三个部分中用到的全部指针,包括客操作系统分配的 buf, 协议控制头数据,状态块数据的指针也都是用的 gpa,所以 VMM 端不存在困难理解 vring。 Vring 和 virtio 设备的 PCI 配置空间一起,提供了一个明确的实现虚拟设备机制的 ABI。
  5. virtioqueue 的 notify 接口是客操作系统端用来向 VMM 发通知的,如一组数据相关的若干 vring_desc 写入到 vring 后,客操作系统可通过 notify 通知 VMM,以便 VMM 能立即处理。 Notify 的实现实际上就是向 virtio 设备的 PCI 配置空间的某寄存器写入某个值,VMM 通过 MMIO 捕获机制接收到该通知。 另外 virtio 后端在合适的时候还会通过虚拟中断的方式向客操作系统发中断,virtio 驱动提供的 callback 函数就是由中断处理程序激活了 work_queue 的方式执行的。

目前 virtIO 设备的后端主要在 qemu-kvm 用户空间实现,但对于 virtio-net 设备,RHEL6.2 已经将后端驱动的代码移植到了内核层,称为 vhost-net, 这样做消除了不必要的用户和内核空间的交互,能明显提高 virtio-net 的性能。

目前 KVM 上除 virtIO 外,还有其他的方式的虚拟设备的实现,如 kvmclock。 kvmclock 实现的是一个时钟源 kvm_clock, 为客操作系统提供精确的 System Time 和 Wall Clock。 kvm_clock 的实现使用了硬件的支持,如 AMD-V 的 VMCB.CONTROL 提供的 TSC_OFFSET, 以及 KVM 使用的一些技巧,如用两个定制的 MSR 寄存器 MSR_KVM_WALL_CLOCK 和 MSR_KVM_SYSTIME_TIME, 通过截取这两个定制的 MSR 让 KVM 来直接访问客操作系统的时间变量。 在 KVM 上,和时间源及定时器硬件相关的虚拟化本身可构成一个独立的话题,笔者打算以后用专门的文章介绍。

IOMMU

采用 virtIO 设备比仿真物理设备的方式会急剧的减小因 MMIO 和 PIO 截取而导致的 VMEXIT 数量,因此客操作系统性能会有明显的提升。 但 virtIO 方式仍然没有摆脱这种由 VMM 层来实现 IO 设备行为的架构,客操作系统和 VMM 之间仍然存在必不可少的交互操作,让 virtIO 的性能难以提升到真实物理设备所能提供的能力。让客操作系统直接使用 PCI 设备 ( 或称 PCI-passthrough) 是最大化其 IO 性能的方式。

然而,如果没有特殊的硬件支持,让客操作系统直接使用 PCI 设备会带来问题。 第一个问题是,PCI 设备通过 DMA 方式访问内存,最终需要向 DRAM 控制器发出一个有效的物理地址,即 hpa, 但是运行在客操作系统上的设备驱动,其向设备的 DMA 控制寄存器写人的必然是一个 gpa, 这种地址的差别如何解决?第二个问题是对 DMA 的地址和设备的中断控制的问题,如何防止客操作系统恶意地设置用于 DMA 的地址和或通过 PCI 配置空间发起恶意的中断。

在 AMD 平台上,IOMMU 就是解决虚拟机直接使用物理设备问题的技术。 在物理上,IOMMU 就是一个针对于外围设备的内存管理单元,相似于 CPU 上的 MMU。 AMD IOMMU 提供如下功能:

  • 地址翻译。
  • 隔离和访问控制。

在 IOMMU 出现之前,x86 上的 GART 硬件实际上已经提供了一定的地址翻译功能。GART 和 IOMMU 相比,在地址翻译上局限性比较大,第一个局限是 GART 只能将其 Aperture 空间的地址翻译成随意的物理地址, 每个使用 GART 的设备必须从 Aperture 申请空间,由于 Aperture 空间的局限性,GART 一般只是用于由 AGP 卡独立使用,进行数据拷贝的情形; 第二个局限就是 GART 不具备地址空间隔离能力,所有的设备只能通过一个翻译表将 Aperture 地址空间翻译成其他物理地址区域,无法阻止一个设备的驱动使用另外一个设备的 Aperture 范围。

AMD-V 提供的 Device Exclusion Vector 扩展能提供地址空间隔离和访问控制, DEV 通过一个查询表来为每一个 PCI 设备指定一个保护域,并且为每一个保护域提供了一个访问控制位图。 IO 设备执行内存访问时,DEV 机制根据其 HyperTransport 链路及 PCI Id 确定其保护域,然后以该设备访问的地址为索引检测保护域相关的控制位图的相应位,以确定设备能否成功访问相应的物理页。DEV 每个保护域的访问控制位图是存放在系统物理内存中的。

IOMMU 的功能是在 GART 和 DEV 结合的基础上形成的, 其主要包括如下功能组件 :

  • 设备表。 即一个按设备 ID 索引的数组,类似于 DEV 的查询表。 每个表项为其设备 ID 指定一个 IO 页表根指针及中断转换表的物理地址。 不同的设备 ID 可通过其设备表项中的指针共享 IO 页表或中断转换表。系统的全部 IOMMU 可共享一个设备表。 ( 在 AMD 平台上一般一个 NUMA Node 或北桥控制器会带一个独立的 IOMMU)
  • IO 页表。 不同于 DEV 的位图,IO 页表是层次性的。 和 CPU MMU 使用的页表完全兼容。 在 Nested Paging 情况下,对于某个被分配到虚拟机的物理设备,其 IO 页表完全可以共享其虚拟机的第二维页表。 IO 页表能翻译的设备地址的范围完全取决于页表目录本身的有效 entries 覆盖的范围,不受类似于 GART Aperture 空间的限制。
  • 中断转换表。 将一个中断向量号转换成另一个向量号,并确定该中断的目标 APIC 控制器。 利用中断转换表,VMM 能控制被独占设备产生的中断。
  • 命令缓冲区。用于向 IOMMU 发送命令的环型队列结构, 每个 IOMMU 单独有一个。 发送的命令一般是操作设备表或 IO 页表项的命令。
  • 事件日志缓冲区。用于 IOMMU 记录事件的环形队列结构, 每个 IOMMU 单独有一个。

这些数据结构都是分配在系统物理内存中的,其物理地址由 IOMMU 的控制寄存器所指向。 另外在 IOMMU 内还会有地址翻译的缓存,即 IOTLB, 其运行机制和 CPU MMU 的 TLB 一致。

在 Linux 上支持 AMD IOMMU 的代码是 arch/x86/kernel/ 目录下的 amd_iommu.c 和 amd_iommu_init.c 两个文件。 amd_iommu_init.c 中主要是实现 IOMMU 检测和控制寄存器操作的代码。 IOMMU 的检测是通过分析 ACPI IVRS 表进行的。 文件 amd_iommu.c 中对 AMD IOMMU 进行了抽象并为 IOMMU 通用层实现了一组操作接口。一个数据结构 struct protect_domain 用来抽象一个保护域,其中有个字段是该保护域的 IO 页表的顶层目录的物理地址,所有关联到这个域的设备都只能使用该页表。设备到域的关联当然是通过 dev_table 实现的。 除硬件需要的 dev_table 外,软件还使用了 rlookup_table 和 pd_table。rlookup_table 是用来确定一个具体的 PCI 设备是物理上连接到哪个 IOMMU 上的,这在采用多个 IOMMU 的结构中当然是有必要的。 pd_table 是用来确定一个设备对应的 protect_domain 数据结构。 这三个表都是以设备的 ID 为索引的。目前在 RHEL6.2 的实现中,似乎还没有对 IOMMU 的中断转换功能的支持。 在 amd_iommu.c 中实现的操作接口主要包括几个函数 : amd_iommu_domain_init, amd_iommu_domain_destroy, amd_iommu_attach_device, amd_iommu_detach_device, amd_iommu_map_range, amd_iommu_unmap_range, amd_iommu_iova_to_phys 及 amd_iommu_domain_has_cap。 读者不难通过函数名和代码看出这几个函数的功能。

Linux 内核 IOMMU 通用层定义的 API 在 driver/base/iommu.c 中, 有八个函数 : iommu_domain_alloc, iommu_domain_free, iommu_attach_device, iommu_detach_device, iommu_map_range, iommu_unmap_range, iommu_iova_to_phys 及 iommu_domain_has_cap, 分别调用 AMD IOMMU 层的功能实现函数。

KMM 支持 PCI-Passthrough 的代码在 virt/kvm/iommu.c 中,这里可通过其中的代码简单描述 PCI-Passthrough 的实现过程 :

  • Qemu-kvm 会根据管理软件或命令的指定执行 “Assign Device”的操作。为执行该操作,Qemu-kvm 在创建完一个 VM 的数据结构后,通过 ioctl() 向 KVM 内核发起 “Assign Device”的操作,执行 virt/kvm/kvm_main.c 中的 kvm_vm_ioctl_assign_device 函数。
  • kvm_vm_ioctl_assign_device 调用 virt/kvm/iommu.c 中的 kvm_iommu_map_guest, 该函数首先会调用 iommu_domain_alloc 分配一个 domain( 对于一个虚拟机来说,所有分配给它的设备共享一个域 ), 然后该函数调用 virt/kvm/iommu.c 中的 kvm_iommu_map_memslots()。
  • kvm_iommu_map_memslots 会遍历该虚拟机的全部物理内存区域对应的 mem_slots, 为每一个 mem_slot, 调用 virt/kvm/iommu.c 中的 kvm_iommu_map_pages 函数。对于每个 mem_slot,我们知道其代表一个连续的 gpa 区域, 所以 kvm_iommu_map_pages 能遍历该区域的所有页面,调用 iommu_iova_to_phys 获取其物理页面号,然后调用 iommu_map_range() 建立该 guest 页在 IO 页表中的映射。
  • kvm_vm_ioctl_assign_device 中在调用 kvm_iommu_map_guest 为该虚拟机建立保护域和 IO 页表后,立即调用 virt/kvm/iommu.c 中的 kvm_assign_device, 该函数会调用 iommu_attach_device 将被 assigned 的 PCI 设备加入到该 guest 的域中。

根据上面的代码路径,我们可以理解,被 Assigned 的设备,其 DMA 访问的空间,会被完全控制在属主的客操作系统的物理内存页范围之内。“Deassign Device” 的执行过程类似,在内核中的路径由 kvm_vm_ioctl_deassign_device 开始,具体这里就不用描述了。

AMD-V 支持的增强特征

AMD-V 提供不少增强的特征为 VMM 的实现提供优化,KVM 利用这些特征能提高系统的性能或解决特定环境的客操作系统的问题。

VMCB 状态缓存及 Clean 控制位

比较新的 AMD-V 处理器中存在 VMCB 状态缓存硬件, 用来在 VMEXIT 至 VMRUN 操作之间缓存虚拟机的寄存器的状态。 VMRUN 在加载虚拟机的寄存器状态时,可以选择从 VMCB 中加载,或者从状态缓存中加载, 当然从状态缓存中加载寄存器要快很多。VMCB.CONTROL 的 “Clean Bits” 字段,可被 VMM 用来控制寄存器的状态加载方式,当相关的 Clean Bit 为 1 时,从状态缓存加载该寄存器的状态,否则从 VMCB 加载该寄存器的状态。一般来说 VMM 软件在虚拟机 VMEXIT 到 VMRUN 之间如果修改了 VMCB 中某字段的值,则需要将 VMCB.CONTROL.CLEAN_BITS 字段相关位清除,让 VMRUN 直接从 VMCB 加载该寄存器。并不是所有的寄存器在 VMCB 状态缓存中都有位置的,具体参考 AMD 的系统编程手册。另外,一个 CPU 的硬件中可存在多个虚拟机的 VMCB 状态缓存,VMRUN 可根据 VMCB 的物理地址来识别特定的状态缓存。目前 RHEL6.2 的 KVM 实现已很好的利用了 VMCB.CONTROL 的 “Clean Bits” 字段做 VMRUN 状态加载的优化。

ASID

即 Address Space ID, 是较新的 AMD-V 处理器支持的特征。 ASID 就是在 TLB 的 entries 中增加一个 ASID 字段用于区分不同地址空间上下文的 entries, 以便多个地址空间的 TLB entries 可以共存在一个 TLB 中,减少地址空间切换时不必要的 TLB Flush 操作。 为支持对 ASID 特征的使用,AMD-V 在 VMCS.CONTROL 中增加了两个字段 G_ASID 和 TLB_CTRL。 G_ASID 用于指定 VMRUN 所运行的虚拟机的 ASID。 TLB_CTRL 用于控制 VMRUN 在重新加载虚拟机状态时怎样 Flush TLB。 TLB_CTRL 可有 000,001,011,111 四个值,其中 000 表示不做 TLB Flush, 001 表示刷全部的 TLB Entries, 011 表示 Flush 本 ASID 的全部 TLB Entries, 111 表示 Flush 本 ASID 的非全局的 TLB Entries。 目前 RHEL6.2 的 KVM 代码已经利用 ASID 和 VMCB.TLB_CTRL 做了 TLB Flush 方面的优化。 另外 AMD-V 中还有一个和 ASID 直接相关的指令 invlpga, 执行该指令只 Flush 指定线性地址和 ASID 对应的 TLB entries。 Invlpga 能为 VMM 对 TLB 的管理提供更多的优化空间,如主机上 Linux 的物理页回收代码在释放掉一个物理页时,可根据该页所属的 ASID, 用 Invlpga 而非 Invlpg 来做 TLB Flush, 目前的 KVM 还没有有效的利用 Invlpga。

Pause Filter Detect

PAUSE 指令在 Linux 内核等代码中广泛被用于自旋锁的 SPIN 循环中,用于标识一个 CPU 的代码处于自旋锁等待状态。 在虚拟化环境中存在一个问题是,我们并不希望一个虚拟机的 CPU 太长时间处于自旋锁等待状态,因为在主机上有其他的虚拟机或计算任务可以利用该 CPU 的计算资源。 解决该问题的一个比较好的思想是计算某虚拟 CPU 连续调用 PAUSE 指令的次数,当超过了规定的值,就截取该虚拟 CPU,调度其他 VMM 上的进程,或想法加快持有自旋锁的 VCPU 线程的调度执行。 为了实现这一思想,较近的 AMD-V 处理器的 VMCB.CONTROL 中增加了一 PAUSE_FILTER_COUNT 字段及一个 INTERCEPT_PAUSE 控制位,用于设定对 PAUSE 的截取,即当 INTERCEPT_PAUSE 为 1 时,用 PAUSE_FILTER_COUNT 初始化 CPU 内某寄存器,每次 PAUSE 执行时,该寄存器的值减 1,当减到 0 时产生截取。 PAUSE_FILTER_COUNT 完全按 PAUSE 的次数控制是否截取 PAUSE 指令,没有考虑不同 PAUSE 指令之间的执行间隔,如俩个 PAUSE 指令属于不同代码或 PAUSE 指令间有中断的情况,这些情况不应该被误判为自旋锁 SPIN 循环的存在。为弥补 PAUSE_FILTER_COUNT 机制的不足,更新的 AMD-V 处理器在 VMCB.CONTROL 中增加了一个 PAUSE_FILTER_THRESHOLD 字段, 新的字段用来规定一个 CPU Cycles 的长度,当最近一次 PAUSE 过去的 CPU Cycles 超过了该字段规定的值时,PAUSE_FILTER_COUNT 对应的 CPU 内部计数器会重新设置为 PAUSE_FILTER_COUNT;如果过去的 CPU Cycles 没超过 PAUSE_FILTER_THRESHOLD 字段规定的值,则内部计数器按正常的方式递减,直到其值为 0,产生截取。 目前 RHEL6.2 上 KVM 的实现已经支持 PAUSE_FILTER_COUNT,但尚不支持 PAUSE_FILTER_THRESHOLD。

 

posted @ 2012-09-05 13:58  念槐聚  阅读(1346)  评论(0编辑  收藏  举报