跳到内容

第 3 卷 / 第 14 章 / 第 3 课

14.3 用 KVM 运行一条 Guest 指令 ​

本实验只创建一段 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:

text
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 ioctl 要来自创建 VM 的进程;除文档列出的异步接口外,vCPU ioctl 应由创建 vCPU 的线程发起。一个 VM 可以拥有多个 vCPU fd;VMM 通常用独立 Host 线程驱动各个 vCPU,并另外处理设备与管理事件。

2. 运行前检查 ​

需要满足:

  • x86 Linux Host;
  • CPU 与内核启用 KVM;
  • /dev/kvm 存在;
  • 当前用户有权打开它;
  • 已安装提供 linux/kvm.h 的内核用户态头文件。
bash
test -e /dev/kvm && echo "KVM device exists"
ls -l /dev/kvm

容器里看不到 /dev/kvm,可能只是设备没有传入,不代表物理 CPU 不支持虚拟化。云 VM 还需要提供 nested virtualization,才能在 Guest 中继续使用 KVM。

3. 连接 VM、memory 与 vCPU ​

分清三层 file descriptor 后,把 Guest memory 注册给 VM,把 HLT 放到入口地址,再调用 KVM_RUN。保存为 kvm-hlt.c。程序验证这条最短路径,失败时结束整个进程,由内核释放剩余描述符与映射;嵌入长期运行的 VMM 时应改成逐项清理。

c
#define _GNU_SOURCE

#include <errno.h>
#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 < 0) fail("KVM_GET_API_VERSION");
    if (api_version != KVM_API_VERSION) {
        fprintf(stderr, "unexpected KVM API version: %d\n",
                api_version);
        return EXIT_FAILURE;
    }

    const int required[] = {KVM_CAP_USER_MEMORY, KVM_CAP_SET_TSS_ADDR};
    for (size_t i = 0; i < sizeof required / sizeof required[0]; ++i) {
        int supported = ioctl(system_fd, KVM_CHECK_EXTENSION, required[i]);
        if (supported < 0) fail("KVM_CHECK_EXTENSION");
        if (supported == 0) {
            fprintf(stderr, "missing KVM capability: %d\n", required[i]);
            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, &region) < 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 < 0) fail("KVM_GET_VCPU_MMAP_SIZE");
    if ((size_t)run_size < 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, &registers) < 0) {
        fail("KVM_SET_REGS");
    }

    puts("entering Guest");
    int run_result;
    do {
        run_result = ioctl(vcpu_fd, KVM_RUN, 0);
    } while (run_result < 0 && errno == EINTR);
    if (run_result < 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
}

编译:

bash
cc -std=c17 -O2 -Wall -Wextra -Werror 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。它围绕退出原因循环:

c
for (;;) {
    if (ioctl(vcpu_fd, KVM_RUN, 0) < 0) {
        if (errno == EINTR) continue;  /* also check shutdown policy */
        handle_run_error();
        return;
    }

    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;
    }
}

这是结构示意,不是可独立编译的第二个程序。EINTR 后按运行策略重试或停止,不读取失效的 exit 信息。这里的单指令实验重试 EINTR;长期运行的 VMM 还需检查停止请求。HLT 也不总是关闭整台 VM,完整系统可能等待中断。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 executed HLT 才说明这条路径跑通;仅编译成功不能证明 Host 能运行 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 上运行,微架构则决定它如何与前后指令重叠执行。

参考 ​

Built with VitePress | Software Systems Atlas