正如Xen,QEMU / KVM或kvmtool之类的软件所提供的那样,许多开发人员,用户和整个行业都依赖于虚拟化。 尽管QEMU可以运行基于软件的虚拟机,而Xen可以运行不带硬件支持的协同半虚拟化OS,但是虚拟化的大多数当前使用和部署都依赖于硬件加速的虚拟化,这是许多现代硬件平台上提供的。 Linux通过内核虚拟机(KVM)API支持硬件虚拟化。 在本文中,我们将仔细研究KVM API,使用它直接建立虚拟机而不使用任何现有的虚拟机实现。

使用KVM的虚拟机无需运行完整的操作系统或仿真全套硬件设备。 使用KVM API,程序可以在沙箱中运行代码,并为该沙箱提供任意虚拟硬件接口。 如果要仿真标准硬件以外的任何东西,或者运行标准操作系统以外的任何东西,则需要使用虚拟机实现所使用的KVM API。 为了证明KVM可以比一个完整的操作系统运行更多(或更少),我们将运行少量的指令,这些指令只需计算2 + 2并将结果打印到仿真的串行端口即可。

KVM API提供了各种平台的硬件虚拟化功能的抽象。 但是,任何使用KVM API的软件仍然需要处理某些特定于机器的细节,例如处理器寄存器和预期的硬件设备。 出于本文的目的,我们将使用Intel VT设置x86虚拟机。 对于另一个平台,您需要处理不同的寄存器,不同的虚拟硬件以及对内存布局和初始状态的不同期望。

Linux内核在Documentation / virtual / kvm / api.txt中包含KVM API的文档,并在Documentation / virtual / kvm /目录中包含其他文件。

本文包括功能齐全的示例程序(MIT许可)中的示例代码片段。 该程序广泛使用err()和errx()函数进行错误处理。 但是,本文中引用的摘录仅包含非平凡的错误处理。

示例虚拟机的定义

使用KVM的完整虚拟机通常会模拟各种虚拟硬件设备和固件功能,以及可能复杂的初始状态和初始内存内容。 对于我们的示例虚拟机,我们将运行以下16位x86代码:

mov $0x3f8, %dx
    add %bl, %al
    add $'0', %al
    out %al, (%dx)
    mov $'\n', %al
    out %al, (%dx)
    hlt

这些指令将添加al和bl寄存器的初始内容(我们将对其进行预初始化为2),通过将结果加和(4)转换为ASCII(通过添加“ 0”),然后将其输出到0x3f8处的串行端口 换行,然后暂停。

而不是从目标文件或可执行文件中读取代码,我们将这些指令(通过gcc和objdump)预先组装为存储在静态数组中的机器代码:

const uint8_t code[] = {
	0xba, 0xf8, 0x03, /* mov $0x3f8, %dx */
	0x00, 0xd8,       /* add %bl, %al */
	0x04, '0',        /* add $'0', %al */
	0xee,             /* out %al, (%dx) */
	0xb0, '\n',       /* mov $'\n', %al */
	0xee,             /* out %al, (%dx) */
	0xf4,             /* hlt */
    };

对于我们的初始状态,我们会将这段代码预加载到客户“物理”内存的第二页中(以避免与地址0处不存在的实模式中断描述符表发生冲突)。 al和bl包含2,代码段(cs)的底数为0,指令指针(ip)指向第二页的开始处0x1000。

除了模拟虚拟机通常提供的大量虚拟硬件之外,我们将仅模拟端口0x3f8上的普通串行端口。

最后,请注意,运行带有硬件VT支持的16位实模式代码需要具有“不受限制的来宾”支持的处理器。 原始的VT实现仅支持启用了分页的保护模式。 因此,诸如QEMU之类的仿真器必须处理软件中的虚拟化,直到达到分页保护模式(通常在操作系统启动后),然后将虚拟系统状态输入KVM以开始进行硬件仿真。 但是,“ Westmere”和更高版本的处理器支持“不受限制的来宾”模式,该模式增加了对16位实模式,“大实模式”和受保护模式的硬件支持,而无需分页。 自2009年6月Linux 2.6.32起,Linux KVM子系统已支持“不受限制的来宾”功能。

建立虚拟机

首先,我们需要打开/ dev / kvm:

kvm = open("/dev/kvm", O_RDWR | O_CLOEXEC);

我们需要对设备具有读写访问权限才能设置虚拟机,所有未明确打算跨exec继承的打开都应使用O_CLOEXEC。

根据您的系统,您可能可以通过名为“ kvm”的组或通过访问控制列表(ACL)来访问/ dev / kvm,该访问控制列表将授予在控制台上登录的用户访问权限。

在使用KVM API之前,应确保拥有可以使用的版本。 KVM的早期版本具有不稳定的API,且版本号不断增加,但是KVM_API_VERSION最终于2007年4月在Linux 2.6.22上更改为12,并在2.6.24中被锁定为稳定接口。 从那时起,KVM API的更改仅通过向后兼容的扩展(与所有其他内核API一样)进行。 因此,您的应用程序应首先通过KVM_GET_API_VERSION ioctl()确认其版本为12:

ret = ioctl(kvm, KVM_GET_API_VERSION, NULL);
    if (ret == -1)
	err(1, "KVM_GET_API_VERSION");
    if (ret != 12)
	errx(1, "KVM_GET_API_VERSION %d, expected 12", ret);

检查版本之后,您可能需要使用KVM_CHECK_EXTENSION ioctl()检查使用的扩展名。 但是,对于添加了新ioctl()调用的扩展,通常可以只调用ioctl(),否则它将失败并显示错误(ENOTTY)(如果不存在)。

如果要检查在此示例程序中使用的一个扩展名KVM_CAP_USER_MEM(需要通过KVM_SET_USER_MEMORY_REGION ioctl()设置来宾存储器),则检查将如下所示:

ret = ioctl(kvm, KVM_CHECK_EXTENSION, KVM_CAP_USER_MEMORY);
    if (ret == -1)
	err(1, "KVM_CHECK_EXTENSION");
    if (!ret)
	errx(1, "Required extension KVM_CAP_USER_MEM not available");

接下来,我们需要创建一个虚拟机(VM),该虚拟机代表与一个仿真系统相关的所有内容,包括内存和一个或多个CPU。 KVM以文件描述符的形式为我们提供了此VM的句柄:

vmfd = ioctl(kvm, KVM_CREATE_VM, (unsigned long)0);

VM将需要一些内存,我们在页面中提供这些内存。 这对应于VM看到的“物理”地址空间。 为了提高性能,我们不想捕获每个内存访问并通过返回相应的数据来模拟它; 相反,当虚拟CPU尝试访问内存时,该CPU的硬件虚拟化将首先尝试通过我们配置的内存页面来满足该访问要求。 如果失败(由于虚拟机访问了没有映射内存的“物理”地址),内核将让KVM API的用户处理访问,例如通过模拟内存映射的I / O设备或生成 一个错误。

对于我们的简单示例,我们将分配一个内存页面来保存代码,直接使用mmap()获得页面对齐的零初始化内存:

mem = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);

然后,我们需要将机器代码复制到其中:

memcpy(mem, code, sizeof(code));

最后,向KVM虚拟机介绍其宽敞的4096字节新内存:

struct kvm_userspace_memory_region region = {
	.slot = 0,
	.guest_phys_addr = 0x1000,
	.memory_size = 0x1000,
	.userspace_addr = (uint64_t)mem,
    };
    ioctl(vmfd, KVM_SET_USER_MEMORY_REGION, ®ion);

slot字段提供一个整数索引,该整数索引标识了我们交给KVM的每个内存区域; 再次使用相同的插槽调用KVM_SET_USER_MEMORY_REGION将替换此映射,而使用新的插槽调用将创建单独的映射。 guest_phys_addr指定从来宾看到的基本“物理”地址,而userspace_addr指向我们使用mmap()分配的进程中的后备内存。 请注意,即使在32位平台上,它们也始终使用64位值。 memory_size指定要映射的内存量:一页,0x1000字节。

现在我们有了一个VM,其内存中包含要运行的代码,我们需要创建一个虚拟CPU来运行该代码。 KVM虚拟CPU代表一个仿真CPU的状态,包括处理器寄存器和其他执行状态。 同样,KVM以文件描述符的形式为我们提供了此VCPU的句柄:

vcpufd = ioctl(vmfd, KVM_CREATE_VCPU, (unsigned long)0);

此处的0表示顺序虚拟CPU索引。 具有多个CPU的VM将在此处分配一系列小标识符,从0到系统特定的限制(可通过使用KVM_CHECK_EXTENSION检查KVM_CAP_MAX_VCPUS功能来获得)。

每个虚拟CPU都有一个关联的struct kvm_run数据结构,用于在内核和用户空间之间传递有关CPU的信息。 特别是,每当硬件虚拟化停止(称为“ vmexit”)(例如模拟某些虚拟硬件)时,kvm_run结构将包含有关其停止原因的信息。 我们使用mmap()将该结构映射到用户空间,但是首先,我们需要知道要映射多少内存,KVM通过KVM_GET_VCPU_MMAP_SIZE ioctl()告诉我们:

mmap_size = ioctl(kvm, KVM_GET_VCPU_MMAP_SIZE, NULL);

请注意,mmap大小通常会超过kvm_run结构的大小,因为内核还将使用该空间来存储kvm_run可能指向的其他临时结构。

现在我们有了大小,我们可以mmap()kvm_run结构:

run = mmap(NULL, mmap_size, PROT_READ | PROT_WRITE, MAP_SHARED, vcpufd, 0);

VCPU还包括处理器的寄存器状态,分为两组寄存器:标准寄存器和“特殊”寄存器。 它们分别对应于两个特定于体系结构的数据结构:struct kvm_regs和struct kvm_sregs。 在x86上,标准寄存器包括通用寄存器以及指令指针和标志。 “特殊”寄存器主要包括段寄存器和控制寄存器。

在运行代码之前,我们需要设置这些寄存器集的初始状态。 在“特殊”寄存器中,我们只需要更改代码段(cs); 其默认状态(以及初始指令指针)指向内存顶部下方16个字节处的复位向量,但我们希望CS改为指向0。 kvm_sregs中的每个段都包含完整的段描述符; 我们不需要更改各种标志或限制,但是我们将基本字段和选择器字段归零,这两个字段共同确定段指向的内存地址。 为了避免更改任何其他初始“特殊”寄存器状态,我们将其读出,更改cs并将其写回:

ioctl(vcpufd, KVM_GET_SREGS, &sregs);
    sregs.cs.base = 0;
    sregs.cs.selector = 0;
    ioctl(vcpufd, KVM_SET_SREGS, &sregs);

对于标准寄存器,除了初始指令指针(指向代码0x1000,相对于cs指向0),加数(2和2)以及标志的初始状态( 由x86架构指定为0x2;如果未设置此选项,则启动VM将会失败):

struct kvm_regs regs = {
	.rip = 0x1000,
	.rax = 2,
	.rbx = 2,
	.rflags = 0x2,
    };
    ioctl(vcpufd, KVM_SET_REGS, ®s);

创建了VM和VCPU,映射并初始化了内存,并设置了初始寄存器状态后,我们现在可以使用KVM_RUN ioctl()开始使用VCPU运行指令。 每当虚拟化停止时,这将成功返回,例如让我们模拟硬件,因此我们将循环运行它:

while (1) {
	ioctl(vcpufd, KVM_RUN, NULL);
	switch (run->exit_reason) {
	/* Handle exit */
	}
    }

请注意,KVM_RUN在当前线程的上下文中运行VM,并且直到仿真停止后才返回。 要运行多CPU VM,用户空间进程必须产生多个线程,并为不同线程中的不同虚拟CPU调用KVM_RUN。

要处理退出,我们检查run-> exit_reason以了解为什么退出。 这可以包含几十个退出原因中的任何一个,它们对应于kvm_run中联合的不同分支。 对于这个简单的VM,我们将只处理其中的几个,并将任何其他exit_reason视为错误。

我们将暂停指令视为已完成的标志,因为我们没有什么可以唤醒我们的:

case KVM_EXIT_HLT:
	    puts("KVM_EXIT_HLT");
	    return 0;

为了让虚拟代码输出其结果,我们在I / O端口0x3f8上模拟了一个串行端口。 run-> io中的字段指示方向(输入或输出),大小(1、2或4),端口和值的数量。 为了传递实际数据,内核使用了在kvm_run结构之后映射的缓冲区,并且run-> io.data_offset提供了从该映射开始的偏移量。

case KVM_EXIT_IO:
	    if (run->io.direction == KVM_EXIT_IO_OUT &&
		    run->io.size == 1 &&
		    run->io.port == 0x3f8 &&
		    run->io.count == 1)
		putchar(*(((char *)run) + run->io.data_offset));
	    else
		errx(1, "unhandled KVM_EXIT_IO");
	    break;

为了简化调试设置和运行VM的过程,我们处理了一些常见的错误。 特别是在更改VM的初始条件时,经常会显示KVM_EXIT_FAIL_ENTRY。 这表明底层硬件虚拟化机制(在这种情况下为VT)无法启动VM,因为初始条件不符合其要求。 (在其他原因中,如果标志寄存器未设置位0x2,或者段或任务切换寄存器的初始值未通过各种设置条件,则将发生此错误。)hardware_entry_failure_reason实际上无法区分许多情况, 因此,此类错误通常需要仔细阅读硬件文档。

case KVM_EXIT_FAIL_ENTRY:
	    errx(1, "KVM_EXIT_FAIL_ENTRY: hardware_entry_failure_reason = 0x%llx",
		 (unsigned long long)run->fail_entry.hardware_entry_failure_reason);

KVM_EXIT_INTERNAL_ERROR表示来自Linux KVM子系统而不是硬件的错误。 特别是,在各种情况下,KVM子系统将在内核中而不是通过硬件来模拟一个或多个指令,例如出于性能原因(合并用于I / O的一系列vmexits)。 run-> internal.suberror值KVM_INTERNAL_ERROR_EMULATION指示VM遇到了一条其不知道如何模拟的指令,该指令通常表示无效的指令。

case KVM_EXIT_INTERNAL_ERROR:
	    errx(1, "KVM_EXIT_INTERNAL_ERROR: suberror = 0x%x",
	         run->internal.suberror);

当我们将所有这些放到示例代码中,对其进行构建并运行时,我们得到以下信息:

$ ./kvmtest
    4
    KVM_EXIT_HLT

成功! 我们运行了机器代码,该代码添加了2 + 2,将其转换为ASCII 4,并将其写入端口0x3f8。 这导致KVM_RUN ioctl()以KVM_EXIT_IO停止,我们通过打印4来模拟。然后循环并重新输入KVM_RUN,对于\ n再次以KVM_EXIT_IO停止。 在第三个也是最后一个循环中,KVM_RUN以KVM_EXIT_HLT停止,因此我们打印一条消息并退出。

其他KVM API功能

该示例虚拟机演示了KVM API的核心,但忽略了许多非平凡虚拟机将关注的其他几个主要领域。

内存映射I / O设备的潜在实现者将希望查看exit_reason KVM_EXIT_MMIO,以及用于减少vmexits的KVM_CAP_COALESCED_MMIO扩展以及ioeventfd机制,以在不使用vmexit的情况下异步处理I / O。

有关硬件中断,请使用KVM_CAP_IRQFD扩展功能,参见irqfd机制。 这提供了一个文件描述符,可以将硬件中断注入KVM虚拟机,而无需先将其中断。 因此,虚拟机可以从单独的事件循环或设备处理线程对此进行写入,并且为虚拟CPU运行KVM_RUN的线程将在下一个可用时机处理该中断。

x86虚拟机可能会希望支持CPUID和特定于模型的寄存器(MSR),它们都具有特定于体系结构的ioctl()来最大程度地减少vmexits。

KVM API的应用

除了学习,调试虚拟机实现或作为聚会技巧之外,为什么还要直接使用/ dev / kvm?

诸如qemu-kvm或kvmtool之类的虚拟机通常会模拟目标体系结构的标准硬件。 例如,标准的x86 PC。 尽管它们可以支持其他设备和virtio硬件,但是如果您要模拟一种完全不同的系统,而该系统所共享的内容仅多于指令集体系结构,则您可能想要实现一个新的VM。 甚至在现有的虚拟机实现中,新型virtio硬件设备的作者也希望对KVM API有清晰的了解。

诸如novm和kvmtool之类的工作使用KVM API来构建轻量级VM,专用于运行Linux而不是任意OS。 最近,Clear Containers项目使用kvmtool通过硬件虚拟化运行容器。

另外,VM根本不需要运行OS。 相反,基于KVM的VM可以在没有虚拟硬件设备且没有OS的情况下实现硬件辅助的沙箱,从而提供任意的虚拟“硬件”设备作为沙箱和沙箱VM之间的API。

虽然运行完整的虚拟机仍然是硬件虚拟化的主要用例,但最近我们已经看到了KVM API的许多创新用法,并且我们当然可以期望将来会更多。