ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

内核模块开发与KVM虚拟化:从底层原理到观测实战

内核模块开发与KVM虚拟化:从底层原理到观测实战

1. 项目源起:为什么把内核模块和 KVM 放在一起学

我先说说这套笔记的来龙去脉。最近在折腾服务器虚拟化,手头一台物理机上跑了几个虚拟机,总觉得对底层机制理解不够透——比如虚拟机异常退出时,宿主机的日志里只给出 bare 的 KVM 调用栈,看不到具体是哪条指令触发的,也没法判断是设备模拟的问题还是内存映射的问题。后来索性把内核模块开发的基础补了一遍,再回头看 KVM 的代码,思路一下就通了。

这套笔记本质上是一份实验记录,主线是:从零编写一个最小可用的内核模块,然后在这个基础上理解 KVM 的架构,最后把两者串起来,用自己写的模块去观测 KVM 的行为。内容包括环境准备、模块的加载卸载与参数传递、字符设备交互、KVM 的 vCPU 生命周期、VMCS 切换、内存虚拟化的影子页表机制,以及一组可以直接上手的排查脚本和内核代码阅读路径。

适合三类人:一是刚接触内核开发、想找一个“能跑起来”的入手点的开发者;二是用 KVM 做过虚拟化但是对底层实现只有模糊认识的运维和平台工程师;三是准备深入阅读内核源码但不知道从哪下手的同学。不需要你有多深的背景,但至少得会基本的 C 语言,并且对操作系统课程里的进程地址空间、中断、页表这些概念有个印象,否则读 VMCS 和页表相关的代码会比较吃力。

这套笔记所有操作都要求在一台你拥有完整 root 权限的 Linux 机器上进行,推荐内核版本锁定在 5.15 LTS 左右。为什么锁定版本?KVM 的内部结构在不同内核版本之间差异很大,比如 5.10 到 6.1 之间 vCPU 的创建路径就改了好几次,网上很多旧文章已经对不上了。锁定一个版本,笔记里的行号和函数名才真正有参考价值。

2. 整体设计思路:先写模块,再摸 KVM,最后联动观测

2.1 学习路径设计的三个原因

我把学习路径设计成“模块 → KVM → 联动观测”三个阶段,不是随手定的,有三个实际原因。

第一个原因是工具链复用。开发内核模块和阅读 KVM 代码用的是同一套环境:同一份内核源码树、同一套编译工具链、同一套调试符号。你写完一个 hello 模块,编译、加载、卸载、看 dmesg 的全套流程,在分析 KVM 模块时完全复用,无非是把 MODULE_LICENSE 从 GPL 换成别的,把 module_init 里做的事情从打印一句话变成注册一个 miscdevice。省去重复搭环境的时间。

第二个原因是代码量梯度。内核模块的 API 虽然晦涩,但单个模块通常只有几百行,可以从零写一个完整的东西;而 KVM 是一个几万行的子系统,直接扑进去必然迷失。先用模块把内核态与用户态交互、内存分配、文件操作、并发访问这些概念过一遍,再看 KVM 时就能把注意力集中在虚拟化特有的概念上,而不是被字符设备注册这类“常识性”代码绊住。

第三个原因是观测需求。想要真正理解 KVM 在做什么,必须有一个“挂在目标机器上、能拿到 KVM 内部数据”的手段。内核模块天然适合干这个——它可以访问内核符号、遍历数据结构、插入 kprobe 探针。把模块写好后,我可以在模块里实现对 vmcs 字段的读取、对 vmexit 原因的统计,相当于给自己装了一台显微镜。

2.2 环境准备:内核源码树、编译工具和宿主机配置

实验环境强烈建议用一台独立的物理机或者 VMware/KVM 嵌套的虚拟机,不要在云主机上做,因为云主机大多不允许加载自定义内核模块,即使允许,重启一次就丢了内核头文件。

我用的环境如下:

项目配置
操作系统Ubuntu 22.04 LTS,内核 5.15.0-generic
内核源码与运行内核同版本的 linux-5.15.x,放在 /usr/src/linux 下
编译工具gcc、make、libelf-dev、openssl、pahole
调试工具crash、gdb、perf、trace-cmd
虚拟机监控工具virsh、qemu-system-x86_64、virt-manager(可选)

编译内核模块只需要内核头文件,但如果要深入读 KVM 源码,就必须有完整的源码树,而且建议做一次完整编译(至少 make modules_prepare),这样 vmlinux 和 ko 的符号信息才完整。

说一个很多人踩过的坑:自定义编译的模块 foo.ko 在另一台同内核版本的机器上无法加载,提示 version magic 不匹配。原因是 Ubuntu 发行版内核默认开启了 CONFIG_MODVERSIONS,编译模块时的 vermagic 字符串包含了内核版本和额外的标记。解决方法是把源码树配置成和当前运行内核完全一致——最简单的方式是安装 linux-source 包,然后用 /boot/config-$(uname -r) 作为 .config 重新 make olddefconfig。

环境搭完之后,第一件事就是跑通“模块的完整生命周期”,从 compile 到 insmod 再到 rmmod,这条链路通顺了,后面的实验才有基础。

3. 内核模块开发实录:从 hello 到字符设备

3.1 最小模块的骨架与加载流程

先看一个最小的模块代码,这个模块不做任何事,只在加载和卸载时各打印一条日志:

#include <linux/init.h> #include <linux/module.h> #include <linux/kernel.h> static int __init demo_init(void) { printk(KERN_INFO "[demo] module loaded\n"); return 0; } static void __exit demo_exit(void) { printk(KERN_INFO "[demo] module unloaded\n"); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("developer"); MODULE_DESCRIPTION("minimal demo module");

对应的 Makefile 只有一行核心规则:

obj-m := demo.o KDIR := /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) clean

执行 make 后生成 demo.ko,然后用 insmod 和 rmmod 测试。加载后 dmesg 里能看到输出。这一步虽然简单,但有几个细节值得注意。

第一,__init和__exit宏的作用是把初始化函数和退出函数放到特定的 ELF section,内核在加载完成后会释放__init占用内存。如果你的 init 函数被错误地标记并且在模块卸载时被调用,会直接触发空指针。所以记住:init 函数的逻辑就是“只做一次性初始化,不要在里面注册回调给未来使用”。

第二,printk 的日志级别。KERN_INFO 是普通消息,KERN_ERR 是错误消息。默认控制台日志级别可能过滤掉 KERN_INFO,导致你 insmod 后终端上什么都看不到,但 dmesg 里有。排查模块问题时第一步永远是 dmesg -H 看内核环形缓冲区,别盯着终端。

第三,MODULE_LICENSE 的影响。如果写 "GPL",你的模块可以使用 EXPORT_SYMBOL_GPL 导出的符号;如果写 "Proprietary",有些 GPL-only 符号(比如很多 KVM 相关的函数)就无法引用,链接阶段会报 undefined symbol。后面我们要写 KVM 观测模块,这里必须写 GPL。

3.2 模块参数与调试信息接口

实际项目中,模块经常需要接收外部参数。拿一个虚拟化观测模块举例,我想让它打印指定 PID 的进程页表信息,或者指定 vCPU 号的 VMCS 状态。参数传入的方式有两种:加载时传入和运行时通过 sysfs 修改。

加载时传参的代码写法:

static int vcpu_id = 0; module_param(vcpu_id, int, 0644); MODULE_PARM_DESC(vcpu_id, "vcpu id to inspect");

insmod 时直接insmod kvm_observer.ko vcpu_id=2。参数权限位 0644 表示运行时可以通过 /sys/module/kvm_observer/parameters/vcpu_id 动态修改。

这里有个容易忽视的点:参数类型的选择。int 类型对用户输入几乎不做校验,如果传进去一个负数,后面用 vcpu_id 去索引数组时就会越界。我习惯使用 unsigned int 或者额外加一层校验函数。

另一个实用接口是 procfs。在 /proc 下创建自己的只读文件,用来输出模块捕获的统计信息,比 dmesg 更灵活。看一个实际写法:

static struct proc_dir_entry *demo_proc; static int demo_proc_show(struct seq_file *m, void *v) { seq_printf(m, "vcpu_id=%u\n", vcpu_id); seq_printf(m, "vmexit_count=%llu\n", vmexit_counter); return 0; } static int demo_proc_open(struct inode *inode, struct file *file) { return single_open(file, demo_proc_show, NULL); } static const struct proc_ops demo_proc_fops = { .proc_open = demo_proc_open, .proc_read = seq_read, .proc_lseek = seq_lseek, .proc_release = single_release, }; static int __init demo_init(void) { demo_proc = proc_create("kvm_observer", 0444, NULL, &demo_proc_fops); return 0; }

这个模式值得重点掌握,它是内核空间向外输出数据的最标准方式。内核 5.6 之后用struct proc_ops,之前是struct file_operations,写代码时注意区分。

3.3 字符设备:实现用户态交互的通行证

procfs 适合输出只读的统计信息,但如果用户态要向模块发送指令——比如“开始采集”、“重置计数器”、“读取某段运行数据”——就需要一个完整的字符设备接口。

注册字符设备的流程分三步。第一步是分配设备号,可以用register_chrdev_region指定主设备号,也可以用alloc_chrdev_region让内核自动分配。测试模块用自动分配更省事,不会和已有设备冲突。

第二步是初始化 cdev 结构并添加到内核:

static struct cdev demo_cdev; static dev_t demo_dev; static int __init demo_init(void) { alloc_chrdev_region(&demo_dev, 0, 1, "kvm_observer"); cdev_init(&demo_cdev, &demo_fops); cdev_add(&demo_cdev, demo_dev, 1); // 然后在 /dev 下创建节点用 class_create/device_create }

第三步是定义 file_operations,核心是 ioctl 处理函数:

static long demo_ioctl(struct file *file, unsigned int cmd, unsigned long arg) { switch (cmd) { case IOCTL_RESET_COUNTER: vmexit_counter = 0; return 0; case IOCTL_GET_COUNTER: return put_user(vmexit_counter, (unsigned long __user *)arg); default: return -ENOTTY; } }

ioctl 让用户态程序可以和模块完成“指令-应答”式的交互。注意put_user和copy_to_user的使用规范:内核空间不能直接解引用用户态指针,必须通过这两个函数拷贝。我见过不止一次因为直接*arg = vmexit_counter导致内核崩溃的案例,用户态传进来的地址可能根本没有映射,内核直接访问会发生缺页异常然后 oops。

写完字符设备,你的内核模块就具备了完整的“输入-处理-输出”能力。此时你已经掌握了内核开发的核心套路,后面进入 KVM 时会顺畅很多。

4. KVM 核心机制拆解:vCPU 生命周期与 VMCS

4.1 KVM 的整体架构分层

KVM 不是一个独立的操作系统组件,它借用了 Linux 内核已有的进程调度、内存管理、中断处理框架,只是在其中加入了一个“让 CPU 进入虚拟化模式”的开关。

理解 KVM 的核心不是去看复杂的 IOMMU 和 PCI 直通,而是抓住一个模型:虚拟机 = 一组 vCPU 线程 + 一组内存映射 + 一组设备模拟。qemu 进程在用户态创建 vCPU 线程,每个线程通过 ioctl 进入 KVM_RUN,这个 ioctl 会让 CPU 切换到 VMX non-root 模式执行客户机代码,直到发生 vmexit 才退回内核态处理。

整个生命周期的代码路径非常清晰:

qemu 用户态 → kvm_vcpu_ioctl(KVM_RUN) → __kvm_vcpu_run (kvm_main.c) → vcpu_enter_guest (kvm/x86/vmx/vmx.c) → vmx_vcpu_run → __vmx_vcpu_run (汇编代码,完成真正的 VM entry)

初次接触这个路径的人,都会在大约 20 分钟内迷失在函数调用栈里。我的建议是不要从上往下读,而是先从最底层——__vmx_vcpu_run这个汇编函数读起。它只有几百行,做的事情就是保存宿主寄存器、加载 VMCS、执行vmlaunch或vmresume、处理错误。

4.2 VMCS 结构:虚拟化的硬件状态机

VMCS(Virtual Machine Control Structure)是 Intel 虚拟化引入的核心数据结构,这是一块由物理 CPU 维护的内存区域,包含六类信息:客户机状态字段、宿主机状态字段、VM entry 控制字段、VM exit 控制字段、VM exit 原因字段、和一堆控制位。

用生活化的类比:VMCS 就像一张“任务切换卡片”,上面写着“从任务 A 切换过来时,CPU 应该保存哪些寄存器和恢复哪些寄存器;切换到任务 B 时,入口在哪里,入口条件是什么”。CPU 执行 vmlaunch 指令时读取这张卡片进入客户机,执行 vmexit 时自动把现场信息写回卡片,然后跳转到一个指定的宿主处理函数。

为什么虚拟机性能损耗比纯软件模拟低一个量级?关键就在 VMCS 硬件化。保存和恢复寄存器的操作不需要软件介入,CPU 硬件在几条指令内完成,避免了传统进程切换中软件保存数百个寄存器的时间开销。

在内核源码中,VMCS 的布局在arch/x86/kvm/vmx/vmcs.h里定义。字段编号是一堆宏,比如GUEST_RIP对应 0x681E。读取某个字段的值,要用vmcs_readl(GUEST_RIP),写则用vmcs_writel(GUEST_RIP, value)。但在 vCPU 运行过程中,并非所有时刻都能访问 VMCS——只有当前 CPU 正在执行 VMXON 模式下的代码、且没有发生 vmexit 时才可以直接访问。模块里想读取 VMCS,只能通过安装 vmexit handler 或 hook 来实现,这正好是后面联动实验要做的。

4.3 内存虚拟化:从影子页表到 EPT

内存虚拟化是整个 KVM 中最难理解、也最影响性能的部分。物理机上一个客户机以为自己拥有从 0 开始的一整块物理内存,但真实物理内存被 A 和 B 两个客户机瓜分,而且互相不可见、隔离。

早期方案是影子页表:KVM 为每个客户进程维护一份页表,把客户机的虚拟地址通过“客户机物理地址 → 宿主机物理地址”的映射合并成一份新的页表。vCPU 运行时不使用客户机自己维护的页表,而是直接加载影子页表。这个方案的问题是每次客户机切换进程或修改页表,KVM 都要重新同步影子页表,TLB 刷新的开销非常可观。

后来 Intel 引入了 EPT(Extended Page Table)。CPU 硬件增加了一个额外的页表层级:客户机物理地址到宿主机物理地址的映射由 CPU 硬件遍历 EPT 完成,KVM 只需要维护 EPT 本身,客户机的页表依然由客户机自己管理。这意味着客户机切换到新进程时,宿主机不用做任何事,TLB 不刷。

理解这个区别对排查性能问题很重要。你经常会看到某些虚拟化场景下,vmexit次数特别高,原因往往不是 CPU 计算太密集,而是内存访问频繁触发了 EPT violation——比如客户机第一次访问某个内存页、或者回收页导致 EPT 条目无效。一个有效的调优手段是使用大页内存,减少 EPT 页表层级和 TLB miss。启动虚拟机时加上-object memory-backend-file,size=4G,mem-path=/dev/hugepages,id=mem0,就能让 qemu 用 2MB 大页做客户机内存,对内存密集型负载的收益通常有 20% 到 50%。

4.4 一个最小 KVM 用户态程序的完整流程

与其用 qemu 这种庞然大物学习 KVM,不如写一个最小用户态程序,直接调用 /dev/kvm 的接口。这段代码其实不到 300 行,却能完整展示 KVM 的使用流程:

#include <linux/kvm.h> #include <fcntl.h> #include <sys/ioctl.h> #include <sys/mman.h> #include <stdint.h> #include <stdio.h> #include <string.h> #define KVM_DEV "/dev/kvm" #define RAM_SIZE (2 * 1024 * 1024) /* 2MB guest RAM */ static uint8_t code[] = { 0xba, 0xf8, 0x03, 0x00, 0x00, /* 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 */ }; int main(void) { int kvmfd = open(KVM_DEV, O_RDWR); int vmfd = ioctl(kvmfd, KVM_CREATE_VM, 0); int cpufd = ioctl(vmfd, KVM_CREATE_VCPU, 0); /* 设置客户机内存 */ struct kvm_userspace_memory_region region = { .slot = 0, .guest_phys_addr = 0, .memory_size = RAM_SIZE, .userspace_addr = (unsigned long)malloc(RAM_SIZE), }; ioctl(vmfd, KVM_SET_USER_MEMORY_REGION, &region); /* 把代码拷贝到客户机物理地址 0 */ memcpy((void *)region.userspace_addr, code, sizeof(code)); /* 设置 vCPU 寄存器 */ struct kvm_regs regs; ioctl(cpufd, KVM_GET_REGS, &regs); regs.rip = 0; regs.rbx = 2; /* 2+0x30='2',串口会输出字符 '2' */ ioctl(cpufd, KVM_SET_REGS, &regs); /* 运行客户机 */ while (1) { ioctl(cpufd, KVM_RUN, 0); if (run->exit_reason == KVM_EXIT_IO) { /* 处理串口输出 */ putchar(run->io.port == 0x3f8 ? *(char *)(region.userspace_addr + run->io.data_offset) : '?'); } else if (run->exit_reason == KVM_EXIT_HLT) { break; } else { fprintf(stderr, "unexpected exit reason %d\n", run->exit_reason); break; } } return 0; }

这段代码做的事情是:创建一个虚拟机和 vCPU,把一段简单的 16 位代码加载到客户机内存,把寄存器设置好,然后进入 KVM_RUN。客户机代码执行一个加法,把结果通过串口输出。qemu 上千行的逻辑在这里被压缩成了几十行,核心流程全部保留。

跑这段代码有一个前提:你的 CPU 需要支持虚拟化,且 /dev/kvm 存在。在 Ubuntu 上可以ls /dev/kvm确认,如果没有,执行modprobe kvm_intel加载模块。云主机上通常这个设备不可用,这也是前面强调要用物理机的原因。

5. 联动观测:用自己的内核模块监控 KVM 行为

5.1 用 kprobe 插桩 vmexit 处理函数

现在把前面开发的内核模块和 KVM 联动起来,做一个实际用得上的观测工具。目标:统计虚拟机运行过程中各类 vmexit 原因的次数和比例,为性能诊断提供数据。

KVM 的 vmexit 处理入口在arch/x86/kvm/vmx/vmx.c中的vmx_handle_exit,每个 vCPU 退出到内核态时都会走到这里。可以用 kprobe 在这个函数上插入探针,拿到vmx_exit_reason的返回值。

内核模块中注册 kprobe 的代码:

#include <linux/kprobes.h> static struct kprobe kp; static int handler_pre(struct kprobe *p, struct pt_regs *regs) { /* x86-64 下第一个参数在 rdi,vmx_handle_exit 的原型是 * static int vmx_handle_exit(struct kvm_vcpu *vcpu, * fastpath_t exit_fastpath) * 返回值是 exit_reason,但这里是 pre_handler, * 拿不到返回值,只能拿 vcpu 指针里的 exit_reason 字段。 */ struct kvm_vcpu *vcpu = (struct kvm_vcpu *)regs->di; u32 exit_reason = vcpu->arch.exit_reason; if (exit_reason < 64) per_cpu_counter[exit_reason]++; return 0; } static int __init kprobe_init(void) { kp.symbol_name = "vmx_handle_exit"; kp.pre_handler = handler_pre; return register_kprobe(&kp); }

这段代码能跑通的前提是模块编译时选中了 CONFIG_KPROBES,并且内核符号表里有 vmx_handle_exit。模块加载后,counter 数组会不断累加。配合前面写的 proc 文件,用户态直接 cat /proc/kvm_exit_stat 就能看到分布。

实测的典型结果大概像这样:

exit_reason 编号名称次数占比
10EXIT_REASON_CPUID5231.2%
30EXIT_REASON_EPT_VIOLATION3810282.4%
32EXIT_REASON_EPT_MISCONFIG30.01%
48EXIT_REASON_APIC_ACCESS1240.3%
1EXIT_REASON_EXCEPTION_NMI24115.2%

如果 EPT violation 占了大头,说明访存密集且 TLB 效率低,优先考虑大页优化。如果 CPUID 占比较高,往往是因为客户机频繁查询 CPU 特性,可以尝试固定 vCPU 拓扑减少这类 exit。这就是一个能把 KVM 内部行为“显形”的最简工具,实际排障时可以按需扩展。

5.2 观测 VMCS 字段切换

另一个实用的观测点是记录每次 vmexit 发生时客户机的 RIP 和 exit reason。实现方式不是在 vmx_handle_exit 里做,因为此时 VMCS 已经切回宿主上下文,部分字段仍然可读,但客户机 RIP 需要通过vmcs_readl(GUEST_RIP)获取。

在退出路径里加上:

static unsigned long long last_guest_rip; static int handler_exit(struct kprobe *p, struct pt_regs *regs) { last_guest_rip = vmcs_readl(GUEST_RIP); return 0; }

注意这里不能对 vmcs_readl 的结果随意操作,因为在 pre_handler 阶段 VMCS 是当前 vCPU 的,但调度器可能在其他 CPU 上同时运行多个 vCPU,读取到的数据可能不是当前探针所在的 vCPU 的数据。稳妥的做法是在数据结构里记录 CPU 编号,按 CPU 分别统计。

这个观测手段的价值在于:当客户机出现死循环或者行为异常时,你不再需要通过 gdb 去连 qemu 进程查 RIP,而是直接看模块日志里最近的 guest_rip 序列,快速定位客户机卡在哪条指令上。

5.3 安全提醒:模块操作不当会导致整个宿主机崩溃

写 KVM 观测模块比写普通模块危险得多,必须反复强调几个红线。

第一,vmcs_readl 只能在 VMXON 状态下调用。如果你的探针注册在非 VMX 上下文,或者在模块卸载后仍有探针残留,读到的将是随机数据,可能直接触发 #GP 异常导致宿主机 panic。挂载 kprobe 前务必确认探针符号确实存在,并且只挂载在 vmexit 处理路径上。

第二,不要在 kprobe handler 里做复杂操作。pre_handler 运行在中断关闭或原子上下文,不能调用可能睡眠的函数,比如 kmalloc(GFP_KERNEL)、printk 大量日志、mutex_lock。如果一定要记录大段数据,预先分配好缓冲,handler 里只做 memcpy。我早期调试时在 handler 里加了一行 printk,结果宿主机直接死机,重启后才发现是 printk 导致的锁死。

第三,模块卸载时一定要先 unregister_kprobe,再销毁 proc 和字符设备。顺序反了会导致用户态还持有打开的文件描述符时,内核模块已经释放了私有数据,访问时触发 use-after-free。

6. 常见问题与排查技巧实录

6.1 模块加载失败:version magic 不匹配

这是出现频率最高的问题。报错信息类似于:

demo: version magic '5.15.0-86-generic SMP mod_unload modversions' should be '5.15.0-86-generic SMP mod_unload '

意思是模块编译时的 vermagic 和当前内核不匹配。常见原因有三类:一是用了错误版本的内核源码树;二是源码树 .config 和运行内核不一致,特别是 CONFIG_MODVERSIONS 开启与否;三是用了第三方工具链,其 gcc 版本不同导致 CRC 校验不一致。

排查步骤:先确认 uname -r 和 /lib/modules/$(uname -r)/build 的存在;然后对比 /proc/version 和模块编译时的编译器版本;最后检查源码树 .config 中 CONFIG_MODVERSIONS 的值。如果一个模块在 A 机器编译完拷到 B 机器加载失败,大概率是 B 机器的 CONFIG_MODVERSIONS 配置不同。

6.2 KVM_RUN 运行后卡死或立即退出

最小 KVM 程序中,常见的问题有两个。

第一个是客户机代码触发了未处理的 exit reason。比如代码里没有处理 KVM_EXIT_MMIO,一旦客户机访问未映射的 I/O 端口,KVM 返回的 exit_reason 是 KVM_EXIT_MMIO,主循环里没有对应分支就会退出。解决办法是跑之前研究一下要执行的代码会涉及哪些 I/O 操作,最小程序里注释给出的KVM_EXIT_IO分支只覆盖了串口输出。

第二个是 vCPU 寄存器初始状态不对。x86 客户机进入时要保证几个关键寄存器合法,特别是 CR0 不能所有位都是零,否则 CPU 在 real mode 下也跑不起来。最小代码里没有设置 CR0,但实际运行时内核会使用默认值。如果代码中使用了 32 位段寄存器,还要设置 GDT。最稳妥的做法是把 regs 全部清零之后,只设置 rip 和 rsp,并且在客户机代码开头立刻执行 cli 屏蔽中断,否则可能会遇到异常或外部中断,导致需要额外的中断处理逻辑。

6.3 kprobe 探针注册失败

register_kprobe 返回 -EINVAL 或 -ENOENT,最常见的原因是符号名不存在。可以先确认:

grep vmx_handle_exit /proc/kallsyms

如果输出为空,说明你的 KVM 模块没有被加载,或者该符号被编译成静态函数并做了内联。KVM 被编译为模块时符号大量存在,被编译进内核时部分符号会消失。推荐的做法是把 KVM 编成模块(make M=arch/x86/kvm),这样便于加载卸载和调试。

另一个坑是符号被编译器内联后,kprobe 挂载到内联点会出现“地址不在指令边界”的问题。可以尝试挂到vmx_vcpu_run或vcpu_enter_guest,这两个函数一般不会被完全内联。

6.4 内存观测数据不准确的排查

如果你统计出的 vmexit 次数和 kvm_static_data 里的数值对不上,先注意统计时机的取舍。pre_handler 是在函数入口、可能在任何 return 之前执行;如果你需要“vmexit 已完成”的数据,必须用 post_handler 或者直接挂到代码路径末尾的某个函数上。

其次注意多 vCPU 并发。如果模块里的计数器是单个全局变量,没有做 per-CPU 区分,那么统计结果会受到 CPU 调度的干扰。建议用DEFINE_PER_CPU(unsigned long, vmexit_counter),在 handler 里用this_cpu_inc,导出时再汇总。

6.5 使用 crash 工具分析宕机现场

最后推荐一个必备技能:用 crash 工具分析内核 panic 的 vmcore。如果宿主机因为你的模块崩溃了,接口从 kexec 转储拿到 vmcore 后:

crash /usr/lib/debug/boot/vmlinux-$(uname -r) /var/crash/vmcore crash> bt crash> log crash> struct kvm_vcpu 0xffff8881234 -x

bt 显示崩溃时的调用栈,log 显示内核日志,struct 命令可以 dump 出指定地址的 vCPU 结构内容。这套组合在排查虚拟化问题时几乎每天都用,建议花一个小时提前练熟。

7. 后续可以继续深挖的方向

如果你走完了前面这些步骤,内核模块的基础和 KVM 的基本机制都已经打通了,后续可以往几个方向继续深入,按难度递增排列。

第一个方向是移植到 ARM64 平台。ARM 的虚拟化没有 VMCS,用的是 VHE(Virtualization Host Extensions)和二维页表(Stage-2 translation)。对比 x86 和 ARM 的差异能让你更深刻地理解虚拟化本质,而不是停留在 Intel 手册的条条框框里。

第二个方向是学习设备直通和中断虚拟化。PCIe 直通让客户机直接访问物理设备,大幅提升 I/O 性能,但这涉及 IOMMU 和中断重映射,是另一个知识密集区域。建议先掌握 VFIO 的基本框架,再配合一个具体设备(网卡或 NVMe)做实验。

第三个方向是了解目前主流的轻量级虚拟化方案如何在 KVM 之上构建隔离环境。这套思路虽然比传统虚拟机更受关注,但底层的 vCPU 创建、内存保护、设备隔离仍然依赖 KVM 的能力。理解了 KVM 之后,你会迅速看懂这些方案中一半以上的设计取舍。

这个领域的知识确实很吃时间,但积累下来的东西几乎不过时。底层的模型在那里,无论上层怎么变,理解透了之后迁移成本很低。我个人在这个实验过程中最大的感受是:与其急着看各种二手教程和框图,不如静下心读一个完整的内核函数调用链,把所有符号展开,一行一行走过去。虚拟化没有黑魔法,所有的“快”都是硬件和软件精妙配合的结果,把那些配合点一个个找到、验证、记录下来,你就真正成了这个领域的熟手。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进