目录

qemu kvm 虚拟化技术初探

CPU 虚拟化

在有硬件虚拟化之前, cpu 虚拟化技术分为下面几种:

  • 一种是全虚拟化, vmm 运行在 ring0, GuestOS kernel 运行在 ring1, 执行特权/敏感指令(读写时钟/中断寄存器)时会陷入到 vmm, 由 vmm 捕获, 翻译, 模拟执行;
  • 还有一种是半虚拟化, vmm 和 guestOS 都运行在 ring0, 需要对 guestOS 进行修改, 通过 Hypercall 来调用 vmm 的特权指令, 不需要捕获.
  • qemu 严格来说只能是模拟器(emulator), 它的特点是可以跨架构模拟, qemu 的 vmm 和 guestOS 都工作在 ring3.

执行特权与敏感指令的区别

  • 执行特权: 特权模式下才能执行的指令
  • 敏感指令: 不一定是特权指令, 比如一些全局数据的读取与修改

现代 cpu 虚拟化技术都有硬件虚拟化支持. 下面以intel x86 vt-x(vmx)为例. vmx 有两种角色, vmm(virtual machine monitor)vm(virtual machine), vmm 负责管理 vm 的生命周期, 包括创建/配置/删除/启停/调度等. hostOS 工作在vmx root mode, guestOS 工作在vmx non-root mode, 每个 mode 下都有完整的 ring0~3; cpu 从vmx root mode进入vmx non-root mode称为vm entry, 反过来称为vm exit. 在vmx non-root mode中执行敏感指令会触发vm entry, 可以类比操作系统中用户进程执行系统调用时发生 trap.

https://static-1251996892.file.myqcloud.com/img/markdown/2022/vm-life-cycle.jpg

VMCS

硬件虚拟化为每个 vCPU 分配一个 VMCS(Virtual Machine Control Structure), 用于管理 vCPU 的元数据以及操控 vCPU 的行为, vCPU 与 VMCS 一一对应, 此处可以类比 linux 进程的 PCB/task_struct. 操作 VMCS 的指令有VMCLEAR, VMPTRLD, VMREAD, VMWRITE,

VMCS 有 6 个区域, 这里不详细介绍

  • Guest-state 区域
  • Host-state 区域
  • VM-execuite 区域
  • VM Exit 控制区域
  • VM Entry 控制区域
  • VM Exit 信息区域

vCPU 状态转换

https://static-1251996892.file.myqcloud.com/img/markdown/2022/vxm-state-trans.jpg

内存虚拟化

内存虚拟化的大概流程是 qemu 在用户进程空间创建出一块虚拟地址(HVA), 并把这块地址映射为 guestOS 的物理地址(GVA)空间, 在没有 EPT(Extended Page Table) 支持之前, guestOS 在访问内存时需要使用影子页表, 影子页表实现 GVA 到 HPA 的转换.

扩展页表 EPT 在 non-root 模式生效, 在 non-root 模式下, guestOS 的虚拟页表(GVA->GPA 的映射)由自己维护, 即 guestOS 使用自己的 cr3, 自己的页表, 来查询 GVA, non-root 模式下访问 GVA 时, cpu 会使用 EPT 将 GPA 映射到 HPA, EPT 页表与宿主机一样, 也使用 IA-32e 分页模式(48 位虚拟地址, 4 级页表), EPT 页表由宿主机维护, EPT 页表的基址在 VMCS 的 EPTP 中. guestOS 发生缺页中断时, 由 guestOS 自己处理, EPT 发生缺页时, 则会触发 EPT 异常, 执行 vm exit, 退回 vmm, 由 vmm 分配 HPA 同时构建 EPT 页表.

1
2
3
4
5
6
7
GVA
 |  <-- vm cr3
 |
GPA --> EPT
 |       |
 |       |
HPA  <----

关于 tlb

在操作系统中不同进程的 tlb 缓存通过 ASID/PCID 标志来区分, 而在虚拟化技术中使用 VPID 来区分(每个 vCPU 分配一个唯一的 VPID, 在 VMCS 中)不同的 tlb cache 条目.

内存映射初始化

前面讲到 vm 的 EPT 页表以及物理内存是由 vmm(KVM) 管理的, 那么这些数据是如何初始化的呢.

KVM 有个 ioctl 接口 KVM_SET_USER_MEMORY_REGION, qemu 先用 mmap 为 vm 分配好内存(GPA 和 HVA), 然后把 GPA 到 HVA 的映射关系注册到 KVM, 就是通过这个接口执行的.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// mmap 分配虚拟机内存
uchar *ram = mmap(NULL, 0x1000, PROT_READ | PROT_WREIT | MAP_SHARE | MAP_ANONYMOUS, -1, 0);
// 配置 kvm_userspace_memory_region 结构体, 记录GPA到HVA的映射
struct kvm_userspace_memory_region mem = {
  .slot=0,
  .guest_phys_addr=0,
  .memory_size = 0x1000,
  .userspace_addr = (ul)ram,
}
// 向传入内存区域描述结构
ioctl(vmfd, KVM_SET_USER_MEMORY_REGION, &mem);

上面这段代码描述的就是使用 KVM_SET_USER_MEMORY_REGION 向虚拟机注册内存空间

MMIO

MMIO 技术是指 IO 统一编址, 除了 Port IO 之外的编址方式, 即通过统一的内存地址空间访问 IO 设备.

那么 KVM 会认为该段内存的 pfn 类型为 KVM_PFN_NOSLOT, 进而调用 set_mmio_spte 来设置该段地址对应到 spte, 而该函数中会判断 pfn 是否为 NOSLOT 标记以确认这段地址空间为 MMIO.

qemu 中的实现步骤:

  1. qemu 声明一段 memory_region, 用作 MMIO, 但不会实际分配, 执行 kvm_set_phys_mem 注册到 KVM 的过程中会执行 memory_region_is_ram 来判断这段物理内存空间是否是 RAM 设备, 不是就会直接返回. 并不会调用 kvm_set_user_memory_region 来真正注册.
  2. SeaBIOS 分配好设备 MMIO 的基址.
  3. guestOS 第一次访问时触发 EPT violation(没有 EPT 页表, 类似缺页中断), VM Exit
  4. kvm 创建一个 EPT 页表, 设置页表项特殊标志位(将 EPT 页表项低三位设置为 110, 可读可写但未分配, 显然是错误的, 会触发 EPT misconfig, 同时设置 spte 标志位表示是 MMIO). 由于访问的这段 GPA 并没有被 kvm_set_user_memory_region 真正的注册, 那么 KVM 会认为该段内存的 pfn 类型为 KVM_PFN_NOSLOT, 进而调用 set_mmio_spte 来设置该段地址对应到 spte, 而该函数中会判断 pfn 是否为 NOSLOT 标记以确认这段地址空间为 MMIO.
  5. guestOS 再次访问时触发 EPT misconfig(由特殊标志位引起的), VM Exit, 退回到 KVM 内核, 然后 KVM 将事件转发给 qemu(共享内存)

中断虚拟化

早期系统使用XT-PIC的中断模式, 是一种由两个 8259 芯片级联起来的支持 15 个中断线的方案. 现代主要使用 APIC(Advanced Programmable Interrupt Controller)高级可编程中断控制器, 主要包括LAPICIO APIC.

设备驱动先在IDT中断向量表中注册一个中断向量, 并初始化中断处理程序, 让中断向量指向中断处理程序, 当设备向 CPU 发起中断后, CPU 收到一个终端信号, 同时也会收到终端向量号, 然后根据中断向量号去查找中断服程序并执行.

IO APIC类似用XT-PIC, 有 24 根中断线, 并对用XT-PIC保持兼容, 前 16 条会按照XT-PIC模式分配, IO APIC只剩下 8 条可用的中断线, 因此IO APIC一般要共享中断线.

还有一种 MSI 中断模式, 它允许设备直接写 LAPIC, MSI 模式支持 224 个中断, 数量多因此不允许共享.

https://static-1251996892.file.myqcloud.com/img/markdown/2022/x86-interrupt-arch.png

  • IO APIC中断模式工作流程如下:

    1. 设备向IO APIC应交发出信号
    2. IO APIC收到信号后将其转换为中断向量, 并将中断向量发到 LAPIC, 中断线(irq)和中断向量(vector)的映射由IO APIC中的 IO 重定向表维护
    3. 被中断的 CPU 执行中断处理例程, 可能会有多个.
    4. 中断例程会判断中断是否对应自己的设备, 如果不是就忽略.
  • MSI中断模式工作流程如下:

    1. 设备直接将中断向量写入到对应 CPU 的 LAPIC 中.
    2. 被中断的 CPU 执行中断服务例程.

中断注入流程

VMCS 中的 VM-entry interruption-information filed 可用于设置中断. x86 架构下设置中断为 set_irq, 对于 intel 就是 vmx_inject_irq, 先获取到中断号, 最后 vmcs_write32 写入.

设备虚拟化

设备虚拟化主要分为三种, 全模拟, virtio 半模拟, 设备直通

全模拟

全模拟设备由 qemu 模拟物理设备的行为, 主要特点是 VM 驱动不需要修改, 直接使用物理驱动, 而设备的虚拟方式可以是 MMIO 或者 PORT IO.

下面以 MMIO 为例:

前面讲到 VM 读写 MMIO 地址的时候会触发ETP misconfig, 陷入 kvm, kvm 从 VMCS 中读出 VM 要读写的 GPA 以及数据, 将其写到共享内存, 返回到 qemu, qemu 从共享内存中读到要读写的 MMIO 地址, 数据, 长度等信息, 执行 MMIO 地址初始化时注册的读写回调, 回调中对模拟设备进行读写, 模拟设备可以是一个简单的拥有几个寄存器的状态机, 也可以是一个真实的挂载物理机上的设备(qemu 将对虚拟设备的操作转发到真实设备上).

网络虚拟化的例子

qemu 的网卡虚拟化分为前端和后端部分, 前端指 vm 内部的虚拟设备以及驱动, 用于接受 vm 的数据收发, 后端指实际与外界收发包的网络设备, 一般由 tun/tap 虚拟网卡实现(tap 工作在二层, tun 为三层).

一般 qemu 中的 vm 是通过 前端网卡--后端网卡--虚拟网桥--宿主机网卡 这样的拓扑与宿主机相连接的

1
2
3
4
                      br0 --- ens33
                  -----------
       qemu        |    |        qemu
  e1000 --- tap0 ---     --- tap1 --- e1000

后端网卡可以通过 /dev/net/tun 文件来创建, 用户向 tun 模块申请一个 tun/tap 网卡, 用一个文件描述符标记, 对这个文件进行写操作会最终调用网络协议栈的 netif_rx_ni, 也就是网卡收包过程, 此时其他进程就可以从这个虚拟网卡中读到数据.

一个完整的网络发包流程大概是这样的:

  1. vm 用户进程执行 socket 发包.
  2. 陷入 vm 内核 ring0, 执行前端网卡驱动中的网卡发包函数, VM Exit.
  3. KVM 将发包请求转发给 qemu, qemu 向后端网卡 tap0 虚拟设备写入数据.
  4. tap0 中的数据交由 br0 转发到 ens33, 至此数据包到达了宿主机.

virtio 虚拟队列(Virtqueue)

virtio 主要由 VM中的前端驱动, qemu中的后端模拟设备, 虚拟队列(virtqueue)构成, virtio 驱动和后端模拟设备之间的数据交互主要依赖 virtqueue.

virtqueue结构

virtqueue 由 vring 实现, 它是 Vm 和 qemu 之间的一段共享环形缓冲区

vring 包括三个部分

  • descriptor table: 描述符表, 传输数据信息, 地址, 长度等信息
  • available vring: 表示后端可用的描述符表的索引
  • used vring: 已用的描述符表索引, 后端已经用过的

virtqueue包括 5 种操作:

  • add_buf: 填充一个或多个 desc table, 更新 available
  • get_buf: 检查 last_used_idx < used.idx,表示有已经完成的请求需要处理,然后返回 add_buf 存放的 data ,修改 last_used_idx。
  • kick: 通过写 pci io 触发 VM Exit, 通知 kvm 数据已经准备好了
  • enable_cb
  • disable_cb

virtqueue初始化流程

virtnet_probe 函数入口中,通过 init_vqs 完成 Virtqueue 的初始化,这个逐级调用关系如图所示,最终会调用到 vring_create_virtqueue 来创建 Virtqueue;

通过写 PCI IO 空间 VIRTIO_PCI_QUEUE_PFN 来告知 Host ,Guest 的 virtqueue 的 GPA 地址;qemu 将 gpa 转换为 hva

virtio 数据收发流程

  • virtio driver -> qemu:

    • 执行 add_buf 将数据放到 avaliable vring, 修改 index
    • 执行 kick 通知后端
  • qemu -> virtio driver:

    • 根据 available vring 的信息把请求从 decsriptor 上取下来
    • 处理其请求
    • 更新 used vring
    • 发送中断到 virtio 驱动

virto 网卡发包流程

虚拟机内的 virtio 网卡发送数据的时候,会调用预先注册的函数 xmit_skb()。要发送的数据会调用 virtqueue_add_outbuf()放置在 available ring 中。最终在 virtqueue_add_outbuf()函数中,会调用 virtqueue_kick()函数,并进一步调用 virtqueue_notify()函数。在 virtqueue_notify()函数中,virtio 前端通过 I/O 写寄存器的方式通知 virtio 后端模拟设备。这部分前端驱动的代码在 drivers/virtio/virtio_ring.c 中。

执行 IO 指令后会触发 VM exit, 被 kvm 截获, 调用 io 处理函数 handle_io(), 最终通知 qemu, qemu 收到后, 会执行 virtio_queue_host_notifier_read() –> 调用回调 virtio_ioprt_write() –> 调用 virtio-net 网络设备注册的数据包接收函数 virtio_net_handle_rx(), 然后把数据复制到 tap 的队列中, 再调用 qemu_notify_event()通知前端, 最终调用 kvm_set_irq()触发中断通知前端.

VFIO 设备直通 (等待补充)

一个最简单 qemu kvm 虚拟机运行过程

下面是 qemu 调用 kvm 运行一个虚拟机的大概流程的伪代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 获取kvmfd
int kvmfd = open("/dev/kvm", O_RDWR);
// 检查api版本
ioctl(kvmfd, KVM_GETAPI_VERSION, NULL);
// KVM_CREATE_VM 创建vm
int vmfd = ioctl(kvmfd, KVM_CREATE_VM, 0);
// mmap 分配虚拟机内存
uchar *ram = mmap(NULL, 0x1000, PROT_READ | PROT_WREIT | MAP_SHARE | MAP_ANONYMOUS, -1, 0);
// 配置 kvm_userspace_memory_region 结构体, 记录GPA到HVA的映射
struct kvm_userspace_memory_region mem = {
  .slot=0,
  .guest_phys_addr=0,
  .memory_size = 0x1000,
  .userspace_addr = (ul)ram,
}
// 向传入内存区域描述结构
ioctl(vmfd, KVM_SET_USER_MEMORY_REGION, &mem);
// 创建vCPU
int vcpufd ioctl(vmfd, KVM_CREATE_VCPU, 0);
// 获取qemu与kvm内核态共享内存大小, 并将kvm共享内存映射到用户空间
int mmap_size = ioctl(vcpufd, KVM_GET_VCPU_MMAP_SIZE, NULL);
struct kvm_run *run = mmap(NULL, MMAP_SIZE, PROT_READ | PROT_WREIT | MAP_SHARE, vcpufd, 0);
// 获取并设置vCPU的段寄存器
struct kvm_sregs sregs;
ioctl(vcpufd, KVM_GET_SREGS, &sregs);
sregs.cs.base = 0;
sregs.cs.selector = 0;
ioctl(vcpufd, KVM_SET_SREGS, &sregs);
// 设置指令寄存器
strut kvm_regs regs = {
  .rip = 0,
};
ioctl(vcpufd, KVM_SET_REGS, &regs);
// 运行vcpu并处理vm exit
while(1) {
  int ret = ioctl(vcpufd, KVM_RUN, NULL);
  it(ret = -1) return -1;
  switch(run->exit_reason) {
    case KVM_EXIT_HLT:
    ...
    case KVM_EXIT_IO:
    ...
    return -1;
  }
}
1
2
3
4
// 回顾mmap使用方式
void *mmap(void *addr, size_t length, int prot, int flags,
    int fd, off_t offset);
int munmap(void *addr, size_t length);

参考资料