KVM 虚拟化技术在 AMD 平台上的实现,第 2 部分

内存、IO 设备虚拟化

Comments

内存虚拟化

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. 性能损失
图 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。


相关主题


评论

添加或订阅评论,请先登录注册

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Linux
ArticleID=831586
ArticleTitle=KVM 虚拟化技术在 AMD 平台上的实现,第 2 部分
publish-date=08232012