14.3 用 KVM 运行一条 Guest 指令
叠屋最深处没有完整的另一座地心,只有一间空房、一小段 memory 和一颗等待启动的 vCPU。你要亲手把一条 HLT 指令放进去,再看 CPU 从 Guest 退回 Host。这样做比启动现成虚拟机简陋得多,却能看清虚拟机监控器最小的控制面。
KVM 把 Linux 内核中的硬件虚拟化能力暴露为 file descriptor 与 ioctl。打开 /dev/kvm 不会凭空得到完整 VM;用户态还要创建 VM 与 vCPU、注册 Guest memory,并处理每次 KVM_RUN 返回的退出原因。本实验没有 firmware、disk、NIC、interrupt controller 或 Guest OS,边界会在代码前写清楚。
1. KVM API 的三层文件描述符
官方 API 按操作对象区分 ioctl:
open("/dev/kvm")
│
├─ system fd
│ ├─ KVM_GET_API_VERSION
│ ├─ KVM_CHECK_EXTENSION
│ └─ KVM_CREATE_VM
│ │
│ └─ VM fd
│ ├─ KVM_SET_USER_MEMORY_REGION
│ └─ KVM_CREATE_VCPU
│ │
│ └─ vCPU fd
│ ├─ KVM_SET_REGS
│ ├─ KVM_SET_SREGS
│ └─ KVM_RUN错误地把某个 ioctl 发给另一层 fd,会返回错误。一个 VM 可以拥有多个 vCPU fd;VMM 通常用独立 Host 线程驱动各个 vCPU,并另外处理设备与管理事件。
2. 运行前检查
需要满足:
- x86 Linux Host;
- CPU 与内核启用 KVM;
/dev/kvm存在;- 当前用户有权打开它;
- 已安装提供
linux/kvm.h的内核用户态头文件。
test -e /dev/kvm && echo "KVM device exists"
ls -l /dev/kvm容器里看不到 /dev/kvm,可能只是设备没有传入,不代表物理 CPU 不支持虚拟化。云 VM 还需要提供 nested virtualization,才能在 Guest 中继续使用 KVM。
3. 把空房、memory 与 vCPU 接起来
叠屋的三层 file descriptor 已经分清,现在把 Guest memory 注册给 VM,把 HLT 放到入口地址,再让 vCPU 跨过 KVM_RUN。程序只验证这条最短路径,不假装自己已经是一套虚拟机产品。
#define _GNU_SOURCE
#include <fcntl.h>
#include <linux/kvm.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <unistd.h>
static void fail(const char *operation) {
perror(operation);
exit(EXIT_FAILURE);
}
int main(void) {
#if !defined(__x86_64__) && !defined(__i386__)
fputs("this example requires an x86 host\n", stderr);
return EXIT_FAILURE;
#else
const size_t memory_size = 0x1000;
int system_fd = open("/dev/kvm", O_RDWR | O_CLOEXEC);
if (system_fd < 0) {
fail("open /dev/kvm");
}
int api_version = ioctl(system_fd, KVM_GET_API_VERSION, 0);
if (api_version != KVM_API_VERSION) {
fprintf(stderr, "unexpected KVM API version: %d\n",
api_version);
return EXIT_FAILURE;
}
int vm_fd = ioctl(system_fd, KVM_CREATE_VM, 0);
if (vm_fd < 0) {
fail("KVM_CREATE_VM");
}
/*
* x86 KVM reserves this high Guest physical address for a
* three-page TSS region. Set it before creating vCPUs.
*/
if (ioctl(vm_fd, KVM_SET_TSS_ADDR, 0xfffbd000UL) < 0) {
fail("KVM_SET_TSS_ADDR");
}
uint8_t *memory = mmap(
NULL,
memory_size,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS,
-1,
0
);
if (memory == MAP_FAILED) {
fail("mmap guest memory");
}
memory[0] = 0xf4; /* x86 HLT */
struct kvm_userspace_memory_region region = {
.slot = 0,
.flags = 0,
.guest_phys_addr = 0,
.memory_size = memory_size,
.userspace_addr = (uintptr_t)memory,
};
if (ioctl(vm_fd, KVM_SET_USER_MEMORY_REGION, ®ion) < 0) {
fail("KVM_SET_USER_MEMORY_REGION");
}
int vcpu_fd = ioctl(vm_fd, KVM_CREATE_VCPU, 0);
if (vcpu_fd < 0) {
fail("KVM_CREATE_VCPU");
}
int run_size = ioctl(system_fd, KVM_GET_VCPU_MMAP_SIZE, 0);
if (run_size < (int)sizeof(struct kvm_run)) {
fputs("KVM vCPU mmap region is too small\n", stderr);
return EXIT_FAILURE;
}
struct kvm_run *run = mmap(
NULL,
(size_t)run_size,
PROT_READ | PROT_WRITE,
MAP_SHARED,
vcpu_fd,
0
);
if (run == MAP_FAILED) {
fail("mmap kvm_run");
}
struct kvm_sregs special;
if (ioctl(vcpu_fd, KVM_GET_SREGS, &special) < 0) {
fail("KVM_GET_SREGS");
}
special.cs.base = 0;
special.cs.selector = 0;
if (ioctl(vcpu_fd, KVM_SET_SREGS, &special) < 0) {
fail("KVM_SET_SREGS");
}
struct kvm_regs registers = {
.rip = 0,
.rflags = 0x2,
};
if (ioctl(vcpu_fd, KVM_SET_REGS, ®isters) < 0) {
fail("KVM_SET_REGS");
}
puts("entering Guest");
if (ioctl(vcpu_fd, KVM_RUN, 0) < 0) {
fail("KVM_RUN");
}
if (run->exit_reason != KVM_EXIT_HLT) {
fprintf(stderr, "unexpected exit reason: %u\n",
run->exit_reason);
return EXIT_FAILURE;
}
puts("Guest executed HLT");
if (munmap(run, (size_t)run_size) != 0
|| munmap(memory, memory_size) != 0
|| close(vcpu_fd) != 0
|| close(vm_fd) != 0
|| close(system_fd) != 0) {
fail("cleanup");
}
return EXIT_SUCCESS;
#endif
}编译:
cc -std=c17 -O2 -Wall -Wextra kvm-hlt.c -o kvm-hlt
./kvm-hlt
# entering Guest
# Guest executed HLT无法打开 /dev/kvm 时,程序会在第一步明确失败。不要为了让实验通过而把设备权限改成所有用户可写;应使用发行版推荐的 KVM 用户组或受控设备授权。
4. 每一步改变了什么
API 版本
KVM_GET_API_VERSION 返回稳定 KVM API 的版本。官方文档要求应用在它不等于 KVM_API_VERSION 时拒绝继续,而不是猜测后续结构是否兼容。
空 VM
KVM_CREATE_VM 返回的 VM 最初没有 vCPU 和 Guest memory。VM fd 是后续注册内存、创建 vCPU 和设备的控制对象。
Guest memory
mmap 先在 VMM 的用户地址空间准备一页普通内存。KVM_SET_USER_MEMORY_REGION 再把这段 userspace address 注册为 Guest physical address 0 开始的 memory slot。
这不是把整段 Host 虚拟地址直接告诉 Guest。Guest 只看到 GPA;KVM 与硬件第二阶段页表负责把它映射到背后的 Host 页面。
生产 VMM 需要处理多个 slot、只读区域、脏页记录、内存热插拔和失效顺序。修改已注册区域时必须遵守官方接口的并发与生命周期规则。
vCPU 共享区
KVM_GET_VCPU_MMAP_SIZE 返回 vCPU fd 可映射的通信区大小。映射后的 struct kvm_run 同时承载运行前控制字段和运行后的 exit 信息。
这个共享映射避免为每个退出复制一整套状态。通用寄存器和 special registers 仍通过相应 ioctl 设置。
实模式入口
程序把 CS base 与 selector 设为 0,并让 RIP 从 0 开始。GPA 0 的第一个字节是 HLT,所以 Guest 进入后立即执行它。RFLAGS 的保留位 1 必须保持为 1,因此初值使用 0x2。
5. KVM_RUN 是一个退出处理循环
完整 VMM 不会只调用一次 KVM_RUN。它围绕退出原因循环:
for (;;) {
if (ioctl(vcpu_fd, KVM_RUN, 0) < 0) {
handle_run_error();
}
switch (run->exit_reason) {
case KVM_EXIT_HLT:
return;
case KVM_EXIT_IO:
emulate_port_io(run);
break;
case KVM_EXIT_MMIO:
emulate_mmio(run);
break;
default:
report_unhandled_exit(run->exit_reason);
return;
}
}这是结构示意,不是可独立编译的第二个程序。I/O 与 MMIO 处理要验证方向、长度、地址和缓冲区边界;把 Guest 提供的数据直接当成可信结构,会把设备模型变成攻击入口。
有些硬件事件由 KVM 在内核中处理,不会每次返回用户态。哪些事件退出、哪些由硬件或内核完成,取决于架构、能力与 VM 配置。
6. 从一条 HLT 到可启动系统
要启动真实 Guest,还需要大量组件:
- 内存布局与固件;
- CPU 拓扑、CPUID 和 model-specific registers;
- 中断控制器、计时器与时钟;
- 串口、磁盘、网卡和图形设备;
- virtio 或模拟设备后端;
- 引导镜像与块设备格式;
- vCPU 线程、中断注入和事件循环;
- 快照、迁移、错误恢复和管理接口。
KVM 负责把 vCPU 安全地交给硬件运行,并在必要时返回控制权。QEMU、Cloud Hypervisor、Firecracker 等 VMM 在不同目标下补齐用户态机器模型;它们不是 KVM 的简单命令行外壳。
7. 实验边界
这个程序只能证明当前机器上的最小 x86 KVM 路径可用。它没有验证:
- Guest 内存保护是否满足多租户威胁模型;
- 设备模拟器是否安全;
- vCPU 并发与中断是否正确;
- 迁移后的 CPU 特性是否兼容;
- Host 超额订阅下的性能;
- 任何操作系统能否启动。
若 KVM_RUN 返回意外原因,先打印原因并按官方文档查相应 union 字段。不要假设数值在不同架构上表达同一类附加信息。
8. 小结
最小 KVM 程序揭示了 VMM 的控制骨架:
- system fd 创建 VM;
- VM fd 注册 Guest memory 并创建 vCPU;
- vCPU fd 设置状态并进入
KVM_RUN; kvm_run共享区报告退出原因;- 用户态处理需要模拟的事件,再让 Guest 继续。
下一章进入CPU 流水线。虚拟化决定一条指令在哪个 CPU 上运行,微架构则决定它如何与前后指令重叠执行。