这份笔记的起点其实是一次挺磨人的排障。之前帮某实验室搭虚拟化平台,系统装好后执行modprobe kvm_intel一直报错,虚拟机自然起不来。我一开始以为问题出在 QEMU 配置上,来回改参数改了一天,最后才发现是内核模块的加载环境、BIOS 开关和模块参数三者共同作祟。从那之后,我就把 Linux 内核模块和 KVM 虚拟化放在同一份文档里做笔记,越整理越觉得这两样东西必须一起理解。先弄清模块怎么编译、怎么加载、怎么和硬件打交道,再去理解 KVM 如何借用这些机制去接管 CPU 和内存,排障的时候才不至于绕着圈找问题。
这份笔记适合三类人。第一类是在用 libvirt 或 KVM 创建虚拟机,但遇到KVM: no hardware support、/dev/kvm Permission denied这类报错,想知道问题出在哪的运维;第二类是刚接触内核模块开发,想找一个能落地的最小示例,把编译、加载、卸载全流程跑通的开发者;第三类是纯粹想搞懂“KVM 和 QEMU 到底谁负责什么”的虚拟化学习者。我会先从模块机制讲起,再进到 KVM 的底层协作原理,最后给完整的落地步骤和排障表。里面所有命令都是我实际敲过的,踩过的坑也会明确标出来。
1. 内核模块与KVM为何要放到一起学
1.1 内核模块到底是个什么东西
内核模块不是一个普通程序,它是一份.ko文件,在系统运行期间被内核直接加载进内核地址空间并执行。它和用户态程序最大的区别是权限等级:模块里的代码运行在内核态,可以调用任意内核函数、访问任意物理内存。你用一根 USB 线往主板上插外设,内核模块就像主板上新插的扩展卡,系统不用重启,功能立刻生效。
但代价也很直接。普通程序崩溃了,内核会杀掉进程,系统继续跑;内核模块一旦指针写错、访问了非法地址,后果经常是直接 panic,整个机器重启。我见过有人写模块时用了一个未初始化的指针,加载后宿主机瞬间失去响应,连日志都没来得及留。这不是吓唬人,使用模块前必须先把测试环境的快照做好。
模块的生命周期很简单。加载时内核调用模块里的 init 函数,卸载时调用 exit 函数。init 返回值 0 表示加载成功,负数表示失败;exit 函数在卸载阶段做清理工作,比如释放内存、注销设备、关闭文件。模块之间的依赖关系由内核维护,lsmod输出的第二列是使用计数,第三列是依赖它的模块列表。你rmmod一个正在被别的模块引用的模块,内核会直接拒绝。
1.2 KVM虚拟化技术栈里的模块分工
KVM 不是像 VirtualBox 那样的单一大程序,它是一套内核机制加用户态工具的集合。内核侧通常涉及三个模块:kvm.ko是核心框架,负责虚拟机生命周期、vCPU 调度、内存管理等通用逻辑;kvm_intel.ko和kvm_amd.ko分别封装 Intel VT-x 和 AMD SVM 的硬件特性,把 CPU 虚拟化的能力暴露给上层。用户态那部分是 QEMU,负责模拟磁盘、网卡、显示等设备,以及处理各种策略问题。
整个链路可以简写成:
CPU(VT-x/SVM 硬件) ↓ kvm_intel / kvm_amd 模块 ↓ kvm.ko 核心框架 ↓ /dev/kvm 设备文件 ↓ QEMU 用户态进程没有 KVM 模块时,QEMU 也不是完全不能用,它会退回软件二进制翻译模式(TCG),每条客户机指令都要在宿主机上解释执行,性能会差一个数量级。有了 KVM 模块,客户机的 CPU 指令直接交给硬件虚拟化单元执行,速度逼近原生。这也是为什么我们排查虚拟化问题,第一反应不是去改 QEMU 参数,而是先确认 KVM 模块是否加载成功。
1.3 这份笔记能帮你解决什么
我给自己定的目标是:看完之后,你能独立完成三件事。第一,从零写一个最小内核模块,编译加载再卸载,并且知道每一步在做什么;第二,知道 KVM 的kvm.ko、kvm_intel、kvm_amd三个模块是怎么协作的,为什么没有硬件虚拟化标志就一定起不来;第三,在真实机器上创建第一台 KVM 虚拟机,遇到常见报错时能按图索骥。如果只是急着用虚拟化,可以直接跳到第 4 节照着操作;如果想搞明白原理,从第 2 节开始顺着读,逻辑是通的。
2. 手写第一个内核模块:从环境到加载验证
2.1 环境准备:内核头文件怎么对齐
内核模块开发不像普通 C 程序那样装个 gcc 就行,你必须有和当前运行内核严格匹配的头文件。实际项目中绝大多数加载失败问题,根源都是头文件版本不匹配。先看内核版本:
uname -r在 Debian 系系统上,用apt install linux-headers-$(uname -r)安装对应头文件。装完之后检查一下/lib/modules/$(uname -r)/build是否存在,它通常是一个符号链接,指向真正的内核源码目录或头文件目录。这一步很关键,因为模块的 Makefile 最终就要用到这个路径。
还有一类容易踩的坑:测试机升级内核后,头文件也跟着换版本了,但你之前编译生成的.ko还留在工作目录里。这时候直接insmod大概率报Invalid module format,因为模块的 vermagic 字符串对不上。解决方式是清理重编,不要把旧文件留着。条件允许的话,我建议模块实验放在独立虚拟机里做,内核模块出问题重启恢复成本低,不会影响工作环境。
2.2 最小模块的代码结构拆解
先放一个我实际用来验证环境的最简模块,文件名叫hello.c:
#include <linux/init.h> #include <linux/module.h> #include <linux/kernel.h> static int __init hello_init(void) { printk(KERN_INFO "hello: module loaded\n"); return 0; } static void __exit hello_exit(void) { printk(KERN_INFO "hello: module unloaded\n"); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("A developer"); MODULE_DESCRIPTION("Minimal demo module");很多刚接触内核模块的人会问:main 函数去哪了?用户态程序的入口是main,内核模块没有这个概念。加载时,内核调用的入口由module_init宏指定,这里就是hello_init;卸载时由module_exit宏指定。两个宏定义在linux/module.h里,所以这个头文件必须包含。
__init和__exit是内核里的特殊标记。带__init的函数,在模块加载完成后占用的内存会被释放,避免长期浪费;带__exit的函数,如果模块被编译进内核而不是作为可加载模块,就会被忽略,因为编译进内核的东西不需要卸载。
printk和用户态的printf看着像,但输出不进终端,而是写到内核日志缓冲区,用dmesg查看。它支持日志级别前缀,比如KERN_INFO、KERN_ERR。我后面排查模块问题,第一步永远是dmesg | tail,比任何调试器都直接。
还有一个细节值得提:模块代码运行在内核栈上,内核栈默认只有 8KB 左右,别在函数里定义大数组,否则栈溢出会让系统直接挂掉。我写模块时习惯动态分配内存,而不是在栈上开大块缓冲区。
2.3 Makefile、编译参数与常见报错
同目录下的 Makefile 长这样:
obj-m := hello.o KDIR := /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: $(MAKE) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) clean逐行看。obj-m := hello.o告诉内核构建系统,我们要以可加载模块方式编译hello.o。KDIR指向运行内核的 build 目录,这是编译头文件和配置的源头。PWD是当前模块源码目录。核心命令是$(MAKE) -C $(KDIR) M=$(PWD) modules,-C切换工作目录到内核树,M=$(PWD)告诉内核构建系统模块源码在哪,modules表示生成模块目标。
编译:
make成功后目录里会出现hello.ko。先用file hello.ko看一眼 ELF 格式是否正常,再用modinfo hello.ko查看模块信息:
modinfo hello.ko你应该能看到vermagic字段,它对应内核版本和 SMP 配置。如果构建时用的头文件与运行内核不一致,这个字段会对不上,加载时直接报错。
常见报错是找不到内核构建树:
make: *** /lib/modules/6.8.0-.../build: No such file or directory这基本就是没装头文件。还有个习惯问题:编译模块别用sudo make,普通用户编译就行,只有insmod/rmmod需要 root。
2.4 insmod、rmmod 与日志查看
编译完成后,把模块加载进内核:
sudo insmod ./hello.ko检查是否加载成功:
lsmod | grep hello查看内核日志:
sudo dmesg | tail你应该能看到hello: module loaded。
卸载:
sudo rmmod hello再看日志,会看到hello: module unloaded。
insmod和modprobe的区别值得说清楚。insmod只按你给的路径加载单个模块,不管依赖;modprobe会读取/lib/modules/$(uname -r)/modules.dep依赖关系,自动加载依赖模块。自编译的.ko通常用insmod,因为还没装进标准模块目录;如果想把模块交给系统管理,可以复制到/lib/modules/$(uname -r)/extra/然后运行depmod再modprobe hello。我第一次用modprobe加载自编译模块失败,就是因为忘了depmod,系统找不到模块路径。另外,卸载前看下lsmod输出里的使用计数,如果第二列不为 0,rmmod会提示Module is in use,先查是谁在用。
3. KVM 与内核模块交互的底层原理
3.1 CPU 虚拟化的 VMX 执行环
这部分单独讲会太抽象,我用一个场景来理解。Intel VT-x 引入了两种执行模式:VMX root operation 和 VMX non-root operation。hypervisor(这里就是 KVM 内核模块)运行在 root 模式下,客户机运行在 non-root 模式下。正常情况下,客户机的指令直接在 CPU 上执行,不经过任何翻译;只有遇到敏感指令、中断或访问特殊寄存器时,CPU 硬件会触发一次 VM exit,自动切回 root 模式,把控制权交给 KVM。KVM 处理完事件后,再执行一次 VM entry,把 CPU 切回 non-root 模式,继续跑客户机。
可以把这种机制类比成比赛里的裁判。球员正常比赛时裁判不干预,只有出现犯规或界外球才吹哨暂停,处理完再恢复比赛。这个“哨声”就是 VM exit/VM entry。硬件这么设计的价值在于:绝大部分计算不需要额外付出,虚拟化开销被压缩到了极小范围。早期纯软件虚拟化需要翻译每条指令,性能因此差得离谱。
AMD SVM 的设计思路和 VT-x 类似,只是寄存器、控制数据结构名字不同。Intel 侧叫 VMCS,AMD 侧叫 VMCB,它们都保存了客户机状态和 hypervisor 状态,保证模式切换时上下文不会丢。这些数据结构由kvm_intel或kvm_amd模块管理。所以看/proc/cpuinfo里的vmx或svm标志,就是在确认当前 CPU 是否具备这个硬件能力。
3.2 kvm.ko、kvm_intel、kvm_amd 是怎么协作的
kvm.ko是 KVM 的核心框架,但它不负责具体 CPU 厂商的硬件细节。真正和 VT-x/SVM 打交道的是kvm_intel.ko或kvm_amd.ko,它们通过一组回调函数注册到核心框架里。简单说,kvm.ko假设底层有kvm_x86_ops结构体,里面是一堆函数指针,比如创建 vCPU、执行 vCPU、切换模式;Intel 或 AMD 模块在加载时把自己的实现填进去。
设备层面,kvm.ko加载后会在系统中创建/dev/kvm字符设备。QEMU 启动虚拟机时,打开这个设备文件,拿到文件描述符,然后用ioctl往里发指令,大致流程是:
打开 /dev/kvm ↓ KVM_CREATE_VM:创建虚拟机对象 ↓ KVM_CREATE_VCPU:创建虚拟 CPU ↓ KVM_RUN:进入 vCPU 执行循环每个新的虚拟 CPU 都会对应内核里一个线程。内核线程再调度到物理 CPU 上运行,这就是“虚拟机是内核管理的进程线程”这个说法的来源。你运行ps -ef | grep qemu看到的是 QEMU 进程,但里面还有一堆kvm前缀的内核线程,它们在真正驱动硬件加速。
模块参数也很重要。比如nested表示是否支持嵌套虚拟化,也就是虚拟机里再开虚拟机;ept表示是否启用 Intel EPT 内存虚拟化。查看参数:
cat /sys/module/kvm_intel/parameters/nested cat /sys/module/kvm_intel/parameters/ept如果是Y,说明对应的特性已经开启。我遇到过 BIOS 支持虚拟化但模块参数默认关闭的情况,改模块参数后还要重新加载模块才生效。
3.3 内存虚拟化与 I/O 路径上的模块角色
内存虚拟化是整个 KVM 里最复杂也最值得理解的部分。客户机看到的物理地址,并不是宿主机物理地址,中间隔了一层。早期没有硬件辅助时,KVM 要维护影子页表,把客户机物理地址和宿主机物理地址的映射关系直接体现到 CPU 页表里,每次客户机页表变化都可能触发同步,开销很大。
现代 Intel EPT 和 AMD NPT 解决了这个问题。CPU 硬件支持两阶段地址转换:客户机 virtual address 先转成客户机 physical address,再由 EPT/NPT 转成宿主机物理地址。整个过程对客户机是透明的,KVM 只需要维护 EPT 页表,缺页导致 VM exit 的次数大幅减少。这也是为什么现在 KVM 虚拟机内存性能可以接近原生。
I/O 路径同样和内核模块深度相关。早期虚拟机的网卡、磁盘访问都会触发 VM exit,然后回到用户态 QEMU 去模拟设备行为,路径很长,性能损耗明显。半虚拟化的思路是用 virtio 协议,在共享内存里建立一个环形缓冲区,客户机驱动把 I/O 请求直接丢进这个缓冲区,QEMU 在另一头消费,不用每次访问都 VM exit。网络方向还有vhost_net内核模块,它把数据通路放进内核态,减少用户态上下文切换。
我实际测过,虚拟机网卡用 virtio 和默认模拟网卡对比,单纯小包转发的差距非常夸张。这个经验在后面性能调优部分会用到。
4. 落地实践:从检查硬件到创建第一台虚拟机
4.1 检查虚拟化能力与加载模块
这是创建 KVM 虚拟机前的必经环节。第一步,确认 CPU 支持硬件虚拟化:
grep -E -c "vmx|svm" /proc/cpuinfo输出大于 0 表示支持。如果为 0,大概率是 BIOS 里虚拟化开关没打开。不同厂商 BIOS 的菜单名字不一样,一般在 Processor 或 Security 相关区域,叫 Intel VT-d / VT-x 或 SVM Mode。改完 BIOS 要断电重启才生效。
第二步,加载对应厂商的 KVM 模块。Intel 加载kvm_intel,AMD 加载kvm_amd:
sudo modprobe kvm_intel如果你在两家的 CPU 平台上乱加载,比如 Intel 机器上加载kvm_amd,会报错提示没有对应硬件。用lsmod | grep kvm确认加载结果:
kvm_intel 122880 0 kvm 692224 1 kvm_intel可以看到kvm.ko的使用计数是1,被kvm_intel引用,符合预期。再检查设备节点:
ls -l /dev/kvm如果/dev/kvm不存在,说明 KVM 模块没正确加载或内核配置没开。接着确认嵌套虚拟化参数:
cat /sys/module/kvm_intel/parameters/nested4.2 创建磁盘镜像并安装虚拟机
模块就绪后,先从磁盘镜像开始。测试环境首选 qcow2 格式,它按需增长,创建 20G 的镜像文件初始很小,只有数据写进去才膨胀,而且支持快照,非常适合做实验。对性能要求极高的生产存储,raw 格式少了 qcow2 的元数据开销,可以考虑。创建镜像:
qemu-img create -f qcow2 /var/lib/libvirt/images/demo.qcow2 20G如果装了 libvirt 工具集,我习惯用virt-install创建虚拟机:
sudo virt-install \ --name demo \ --ram 2048 \ --vcpus 2 \ --disk path=/var/lib/libvirt/images/demo.qcow2,size=20,format=qcow2,bus=virtio \ --network network=default,model=virtio \ --os-variant ubuntu22.04 \ --cdrom /tmp/installer.iso \ --graphics vnc,listen=0.0.0.0 \ --noautoconsole每个参数都有用意。--name demo是虚拟机名字;--ram 2048分配 2G 内存;--vcpus 2分配 2 个 vCPU;--disk使用刚才创建的 qcow2 镜像,并且明确bus=virtio,绕开慢速模拟磁盘;--network network=default,model=virtio走默认 NAT 网络并用 virtio 网卡;--cdrom挂载系统安装镜像;--noautoconsole表示安装时不自动接入虚拟控制台,避免终端卡住。
安装完成后:
virsh list --all virsh start demo我个人的建议是,第一次操作不要跳过 virtio。很多人在虚拟机里看到网卡速率异常、磁盘 IO 差,往往是因为默认用了模拟的 IDE 或 e1000 设备。后面重装驱动很麻烦,不如建虚拟机时就直接指定 virtio。
4.3 CPU、内存、磁盘与网络的性能参数
这部分是我实际使用中最常调整的几组参数。
CPU 方面,KVM 允许 vCPU 超售,但我一般不把超售比例压到 2 倍以上。宿主机在top里可以看到每个 vCPU 线程,如果%steal数值很高,说明 vCPU 在等待物理 CPU,虚拟机里的负载会莫名其妙变慢。生产环境我建议用host-model或host-passthrough模型,前者兼顾迁移兼容性,后者性能最接近原生但迁移到不同 CPU 的机器时可能失败。
内存方面,大页是关键。默认 4K 内存页在虚拟机内存很大时,TLB 命中率下降严重。启用 2MB HugeTLB:
echo 1024 > /proc/sys/vm/nr_hugepages持久化写入/etc/sysctl.conf:
vm.nr_hugepages=1024QEMU 侧再用memory-backend-file指定 hpages 路径。开启后最直观的感受是内存密集型应用在虚拟机里的性能抖动明显减少,代价是内存被大页占住,不能像普通内存那样灵活回收。
磁盘方面,缓存策略要按业务场景选。数据库类应用我倾向cache=none,绕过宿主 page cache,用 O_DIRECT 直写,减少意外断电时的数据不一致风险;对性能要求高但能接受丢失风险的环境,writeback会快不少。网络方面,确认vhost_net模块加载,这是内核态加速路径,默认开着,但如果你用了自定义内核,可能没编进去。
5. 常见问题速查与排障思路
5.1 模块加载阶段的典型故障
| 现象 | 可能原因 | 排查命令 | 解决方式 |
|---|---|---|---|
insmod: Invalid module format | 头文件版本与运行内核不一致 | modinfo hello.ko看 vermagic | 清掉旧.ko,重新对版本编译 |
modprobe: could not insert 'kvm_intel': Operation not permitted | BIOS 未开启虚拟化,或模块参数异常 | grep -E "vmx|svm" /proc/cpuinfo | 进 BIOS 开启 VT-x/SVM,重启 |
Unknown symbol报错 | 依赖模块未加载或符号未导出 | nm hello.ko | grep ' U ' | 改用modprobe自动拉依赖 |
rmmod: Module hello is in use | 有其他模块或程序使用该模块 | lsmod看 Used by | 先停用引用方,别用rmmod -f强卸 |
内核模块排障第一入口永远是dmesg。加载失败时,内核会把具体原因写进日志,比如版本不匹配会直接显示模块 vermagic 和内核 vermagic,这是最有价值的信息。我见过有人不看日志,反复重新编译三四次,后来一看日志,问题根本不是代码,是头文件路径指向了旧内核。
5.2 虚拟机运行阶段的典型故障
| 现象 | 可能原因 | 排查命令 | 解决方式 |
|---|---|---|---|
/dev/kvm Permission denied | 当前用户不在 kvm 组 | ls -l /dev/kvm | sudo usermod -aG kvm $USER,重新登录 |
KVM acceleration not found | KVM 模块未加载或硬件不支持 | lsmod | grep kvm,grep vmx /proc/cpuinfo | 按 4.1 节流程检查模块与 BIOS |
| 虚拟机网络慢、ping 延迟高 | 网卡模型用了模拟网卡 | 客户机内lspci、ethtool -i eth0 | 换成 virtio-net,必要时重装驱动 |
虚拟机卡顿,top显示 %steal 高 | vCPU 超售过度或宿主负载高 | vmstat 1,ps -eo pid,pcpu,comm | grep qemu | 降低 vCPU 数量、绑定 CPU 亲和、错峰调度 |
| qcow2 镜像越来越大 | qcow2 按写增长是正常机制 | qemu-img info demo.qcow2 | 定期清理内部垃圾,必要时转 raw |
虚拟机运行阶段的问题,真正出在 KVM 模块本身的比例不大,更多是配置、权限、设备模型和资源争抢。特别是%steal时间,我在新手上路阶段曾以为它只是好看的数字,后来才意识到它直接代表虚拟机在等宿主机资源,比很多监控指标都真实。看到 steal 高,第一反应应该是查宿主机负载,而不是在虚拟机里调应用参数。
5.3 一个简单的环境自检脚本
为了减少重复劳动,我写过一个环境自检脚本,新机器到手先跑一遍,再决定要不要继续折腾:
#!/usr/bin/env bash echo "== CPU ==" if grep -qE '(vmx|svm)' /proc/cpuinfo; then echo "OK: vmx/svm found" else echo "FAIL: enable VT in BIOS" fi echo "== KVM modules ==" sudo modprobe kvm_intel 2>/dev/null || sudo modprobe kvm_amd 2>/dev/null if lsmod | grep -q '^kvm'; then echo "OK: kvm module loaded" else echo "FAIL: no kvm module" exit 1 fi echo "== /dev/kvm ==" if [ -c /dev/kvm ]; then echo "OK: /dev/kvm exists" else echo "FAIL: /dev/kvm missing" fi echo "== permission ==" if [ -w /dev/kvm ]; then echo "OK: you can access /dev/kvm" else echo "FAIL: add user to kvm group" fi echo "== nested ==" for f in /sys/module/kvm_*/parameters/nested; do [ -f "$f" ] && echo "$f: $(cat "$f")" done这个脚本没有多复杂,就是把我排障时的检查顺序固化了。遇到环境问题先跑一遍,能省掉一半无效操作。比如权限问题,有些人反复重装 libvirt,其实一条usermod -aG kvm就能解决。
6. 给正在入门的朋友几句经验话
6.1 先从模块视角定位虚拟化问题
我自己的体会是,虚拟化环境出问题,七成藏在内核模块、设备权限和 BIOS 开关里,而不在 QEMU 复杂参数里。你看到KVM acceleration not found,先别急着改虚拟机 XML,按 CPU 标志、模块加载、/dev/kvm三个顺序查,通常几分钟就能定位。内核日志里往往写着真正的答案,dmesg看不明白的时候,再放大某个模块的日志级别,而不是盲目重启服务。
还有一点,QEMU 在 KVM 模块没加载时也会尝试启动,不要看到虚拟机进程起来了就以为万事大吉。客户机里用lscpu查看 Hypervisor 信息,确认是不是真的走了硬件加速。实际跑一个密集计算任务,对比开启 KVM 前后的耗时差异,你很快就能理解模块加载与否的差距有多大。
6.2 踩过几次坑后的操作习惯
我现在养成几个习惯。任何内核模块都先在测试机上验证,包括自己写的hello.ko,确认加载正常再考虑生产环境;卸载模块前先看lsmod的使用计数,绝不用rmmod -f强卸,那是在制造未知风险;加载 KVM 相关模块后,立刻检查/sys/module/kvm_*/parameters/下的状态,避免参数被发行版默认关闭。磁盘镜像也建议明确格式和缓存策略,裸奔qcow2做高 IO 测试会吃不少亏。
这份笔记目前能覆盖的,是从模块基础到虚拟化落地的主链路。下一步我打算继续整理 virtio 队列机制和磁盘调度器对虚拟机 IO 的影响,这部分踩过的坑也不少。如果你在实践里遇到了这里没提到的问题,欢迎来交流,排障经验本来就是互相补全的。