跳到内容

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

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 可以拥有多个 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. 把空房、memory 与 vCPU 接起来

叠屋的三层 file descriptor 已经分清,现在把 Guest memory 注册给 VM,把 HLT 放到入口地址,再让 vCPU 跨过 KVM_RUN。程序只验证这条最短路径,不假装自己已经是一套虚拟机产品。

c
#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, &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 < (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, &registers) < 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
}

编译:

bash
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。它围绕退出原因循环:

c
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 上运行,微架构则决定它如何与前后指令重叠执行。

参考

Built with VitePress | Software Systems Atlas