跳转至

QEMU 与 KVM 概述*

"All problems in computer science can be solved by another level of indirection" --David Wheeler

QEMU 与 KVM 架构*

上图为 QEMU 与 KVM 的完整架构,比较完整地展现了 QEMU 与 KVM 虚拟化的各个方面,包括 QEMU 的运行机制,KVM 的组成,QEMU 与 KVM 的关系,虚拟机 CPU、内存、外设等的虚拟化。

左上半部分是 VMX root 模式的应用层,下面是 VMX root 模式的内核层。所谓 VMX root,其实是相对于 VMX non-root 模式而言。都是 CPU 引入支持硬件虚拟化指令集 VT-x 之后出现的。现在可将 VMX root 理解成宿主机模式,VMX non-root 理解成虚拟机模式。右边上半部分表示的是虚拟机运行在 VMX non-root 模式下。

CPU 的运行模式与 CPU 运行时的特权级是正交的。

左边上半部分列出了 QEMU 的主要任务,QEMU 在初始化的时候会创建模拟的芯片组,创建 CPU 线程来表示虚拟机的 CPU 执行流,在 QEMU 的虚拟地址空间中分配空间作为虚拟机的物理地址,QEMU 还需要根据用户在命令行指定的设备为虚拟机创建对应的虚拟设备。在虚拟机运行期间,QEMU 会在主线程中监听多种事件,包括虚拟机对设备 I/O 的访问、用户对虚拟机管理界面、虚拟设备对应的宿主机上的一些 I/O 事件(如网络数据接收)等。QEMU 应用层接收到这些事件后会调用预先定义好的函数进行处理。

右上部分表示虚拟机的运行。虚拟机的一个 CPU 对应为 QEMU 进程中的一个线程,通过 QEMU 和 KVM 的相互协作,这些线程被宿主机操作系统正常调度,直接执行虚拟机中的代码。虚拟机的物理内存对应位 QEMU 进程中的虚拟内存,由 KVM 的页表完成 GPA 到 HPA 的转换。虚拟机的设备通过 QEMU 呈现给它,操作系统启动时进行设备枚举,加载对应的驱动。虚拟机操作系统通过设备的 I/O 端口或者 MMIO 进行交互,KVM 会截获这些请求,大多数会分发到用户空间的 QEMU 进程。

下半部分表示位于 Linux 内核中的 KVM 模块。一方面通过 /dev/kvm 设备导出了一系列接口供 QEMU 等用户态程序通过接口控制虚拟机,另一方面 KVM 需要截获虚拟机产生的 VM Exit 事件并进行处理。

  • CPU 虚拟化,QEMU 创建 VCPU 线程,初始化 VCPU 寄存器值,然后调用 KVM 接口,运行虚拟机,在物理 CPU 上执行虚拟机的代码。虚拟机运行后,KVM 需要截获虚拟机中的敏感指令,当虚拟机执行敏感指令或满足一定条件时,CPU 会从 VMX non-root 模式退出到 KVM,如果 KVM 无法处理,则会将操作分派到 QEMU 中。处理完成后又可以将 CPU 置于 VMX non-root 模式,即 VM Entry。KVM 使用 VMCS 结构来保存虚拟机 VM Exit 和 VM Entry 的状态。

  • 内存虚拟化,QEMU 初始化时调用 KVM 接口告知虚拟机所需的物理内存。通过 mmap 系统调用分配虚拟内存空间作为虚拟机的物理内存,QEMU 在不断更新内存布局的过程中会持续调用 KVM 的接口告知虚拟机内存分布。虚拟机运行时,要将 GVA 转换为 GPA,再转为 HVA,最后转换为 HPA。CPU 支持 EPT 之前,使用影子页表的软件方法实现 GVA 到 HPA 的转换。CPU 支持 EPT 之后,CPU 会自动完成 GVA 到 HPA 的转换。虚拟机第一次访问内存的时候就会陷入 KVM,逐渐建立起所谓的 EPT 页面。VCPU 之后访问 GVA 时,首先被转换为 GPA,查找 EPT 页表,得到 HPA。

  • 设备虚拟化,QEMU 最多的代码就是设备模拟,其本质是要为虚拟机提供一个与物理设备接口完全一致的虚拟接口。虚拟机的操作系统与设备进行的数据交互或者由 QEMU 或 KVM 完成,或者由宿主机上对应的后端设备完成。QEMU 在初始化过程中会创建好模拟芯片组和必要的模拟设备,包括南北桥芯片、PCI 总线等总线系统,以及各种设备。QEMU 的命令行可以指定可选的设备以及设备配置项。最开始 QEMU 只有纯软件模拟,对设备寄存器的读写会陷入 KVM,进而到 QEMU 进行处理并模拟硬件行为。后来提出了 virtio 设备方案,virtio 是一类特殊的设备,没有对应的物理设备,需要虚拟机内部操作系统安装特殊的 virtio 驱动,相当于变成半虚拟化。最后又有了设备直通方案,将物理硬件直接挂到虚拟机上,直接交互,与设备直通经常一起使用的有设备的硬件虚拟化支持技术 SRIOV Single Root I/O Virtualization,它能将单个的物理硬件高效地虚拟出多个虚拟硬件。

  • 中断虚拟化,中断系统是操作系统必不可少的部分。操作系统通过写设备的 I/O 端口或者 MMIO 地址与设备交互,设备通过发送中断来通知操作系统事件。QEMU 在初始化主板芯片时初始化中断控制器,支持单 CPU 的 8259 和 SMP 的 I/O APIC 和 LAPIC。传统上,如果虚拟外设通过 QEMU 向虚拟机注入中断,需要先陷入到 KVM,再由 KVM 注入中断。为了提高效率,KVM 也实现了中断控制器,用户可以有选择地让 QEMU 或 KVM 模拟中断控制器。QEMU/KVM 一方面要模拟终端设备,另一方面需要模拟中断的请求。

KVM API 使用实例*

KVM 导出了一系列接口供用户态创建、配置、启动虚拟机,典型的用户态软件就是 QEMU。作者在本节以一个简单的例子展示了 QEMU 和 KVM 的关系。

首先一个精简的内核代码,向端口 0xf1 写入 Hello 字符串,然后调用 hlt 指令。

start:
    mov $0x48,%al
    outb %al,$0xf1
    mov $0x65,%al
    outb %al,$0xf1
    mov $0x6c,%al
    outb %al,$0xf1
    mov $0x6c,%al
    outb %al,$0xf1
    mov $0x6f,%al
    outb %al,$0xf1
    mov $0x0a,%al
    outb %al,$0xf1


    hlt

然后是一个精简的 QEMU:

#include<stdio.h>
#include<fcntl.h>
#include<sys/mman.h>
#include<linux/kvm.h>

int main() {
    struct kvm_sregs sregs;
    int ret;
    // 获取系统中 KVM 子系统的文件描述符 kvmfd
    int kvmfd = open("/dev/kvm", O_RDWR);
    // 获取 KVM 版本号,以便查看接口
    printf("%d\n", ioctl(kvmfd, KVM_GET_API_VERSION, NULL));
    // 创建一个虚拟机,返回一个虚拟机描述符 vmfd,可用于控制虚拟机的内存、VCPU 等
    int vmfd = ioctl(kvmfd, KVM_CREATE_VM, 0);
    // 虚拟机的物理内存对应 QEMU 的进程地址空间
    // 使用 mmap 系统调用分配 1 页内存
    unsigned char *ram = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
    int kfd = open("test.bin", O_RDONLY);
    // 将内核代码加载到分配的内存
    read(kfd, ram, 4096);
    // 用分配的内存地址初始化对象
    struct kvm_userspace_memory_region mem = {
        .slot = 0,                              // 表示不同的内存空间
        .guest_phys_addr = 0,                   // 这段空间在虚拟机物理内存中的位置
        .memory_size = 0x1000,                  // 内存大小
        .userspace_addr = (unsigned long)ram,   // 物理空间对应宿主机的虚拟内存地址
    };
    // 为虚拟机指定了一个内存条
    ret = ioctl(vmfd, KVM_SET_USER_MEMORY_REGION, &mem);
    // 创建 VCPU
    int vcpufd = ioctl(vmfd, KVM_CREATE_VCPU, 0);
    // 每个 VCPU 都有一个 struct kvm_run 结构,用来在用户态 light-qemu 和内核态 KVM 共享数据
    int mmap_size = ioctl(kvmfd, KVM_GET_VCPU_MMAP_SIZE, NULL);
    // 映射到用户空间
    struct kvm_run *run = mmap(NULL, mmap_size, PROT_READ | PROT_WRITE, MAP_SHARED, vcpufd, 0);
    // 设置 VCPU 寄存器,段寄存器和控制寄存器放在 kvm_sregs,通用寄存器放在 kvm_regs
    ret = ioctl(vcpufd, KVM_GET_SREGS, &sregs);
    sregs.cs.base = 0;
    sregs.cs.selector = 0;
    ret = ioctl(vcpufd, KVM_SET_SREGS, &sregs);
    struct kvm_regs regs = {
        .rip = 0,
    };
    ret = ioctl(vcpufd, KVM_SET_REGS, &regs);
    // 一个简单的虚拟机和虚拟机 VCPU、内存都准备完毕,寄存器设置完成,可以运行了
    while(1) {
        // 对 vcpufd 调用 KVM_RUN,遇到敏感指令就会退出
        // 如果 KVM 不能处理就就会交给应用层软件处理
        // ioctl 返回,将一些信息保存在 kvm_run 中
        ret = ioctl(vcpufd, KVM_RUN, NULL);
        if(ret == -1) {
            printf("exit unknown\n");
            return -1;
        }
        switch (run->exit_reason)
        {
        case KVM_EXIT_HLT:
            puts("KVM_EXIT_HLT");
            return 0;
        case KVM_EXIT_IO:
            putchar(*(((char*)run) + run->io.data_offset));
            break;
        case KVM_EXIT_FAIL_ENTRY:
            puts("entry error");
            return -1;
        default:
            puts("other error");
            printf("exit_reason: %d\n", run->exit_reason);
            return -1;
        }
    }

}

编译执行:

$ as -32 test.S -o test.o
$ objcopy -O binary test.o test.bin

$ gcc qemu.c -o light-qemu
$ ./light-qemu
Hello
KVM_EXIT_HLT

KVM 通过一组 ioctl 向用户空间导出接口,这些接口能够用于虚拟机的创建、内存设置、VCPU 的创建和运行等。按照接口所使用的文件描述符 fd 不同,大致分为三类:

  • 系统全局的 ioctl,这类 ioctl 的作用对象是 KVM 模块本身,比如一些全局的配置项,创建虚拟机的 ioctl 也在此列。
  • 虚拟机相关的 ioctl,这类 ioctl 的作用对象是一台虚拟机,比如设置虚拟机的内存布局、创建 VCPU。
  • 虚拟机 VCPU 相关的 ioctl,这类 ioctl 的作用对象是一个虚拟机的 VCPU,比如开始 VCPU 的运行。

最后更新: November 26, 2020