最近在调一个设备直通的性能问题时,我同时把QEMU和KVMTool两边对guest物理内存的管理代码翻了个底朝天。查着查着发现一个很有意思的现象:同样是把GPA映射到HVA,两边最终都落在KVM_SET_USER_MEMORY_REGION这个ioctl上,但前置的代码路径和心智模型却差了十万八千里。网上讨论qemu安装、Windows下WHPX、虚拟显卡svga的帖子不少,可一旦你往深了走——设备直通、热迁移、内存热插拔——最后一定绕不开GPA、IOVA、HVA这三个地址概念。这篇文章我就把两边在GPA到HVA映射这条链路上的异同掰开揉碎讲一遍,适合已经跑通过基本虚拟化环境、准备深挖KVM内存子系统的朋友。
1. 先厘清一组容易混淆的地址概念:GPA、IOVA、HVA到底谁是谁
1.1 一次内存访问在虚拟化环境中要经过几层翻译
很多初学者会把GPA和HVA当成同一个东西,其实不是。一次guest内部的内存访问,在KVM加速下大致要经过四层地址转换:
- GVA(Guest Virtual Address):guest进程看到的虚拟地址,由guest自己的页表负责。
- GPA(Guest Physical Address):guest物理地址,CPU通过EPT/NPT把GVA翻译成GPA。
- HVA(Host Virtual Address):宿主上VMM进程(比如qemu或kvmtool)地址空间里的虚拟地址。
- HPA(Host Physical Address):真正的物理内存,由宿主的进程页表把HVA翻译成HPA。
这里容易忽略的一个点是:KVM内核模块只负责两件事——通过EPT/NPT加速“GVA到GPA”的翻译,以及维护一张“GPA到HVA”的映射表。GPA到HVA的映射由VMM通过KVM_SET_USER_MEMORY_REGION注册给KVM,而HVA到HPA的映射,本质上是VMM自己用户态地址空间的普通页表映射,由宿主内核正常的内存管理来维护。
换句话说,KVM的memory slot就是一座桥:客体的物理地址要想落到宿主上真实的内存,必须先把这座桥架好。
1.2 IOVA在标题语境下到底指什么
标题里的IOVA会让人有点困惑,因为它在不同场景下至少有两个身份。
一个是IOMMU/设备视角的IOVA(I/O Virtual Address)。设备发起DMA时使用的是IOVA,IOMMU负责把IOVA翻译成HPA。设备直通(PCI passthrough/VFIO)场景下,VMM会尽量让设备看到的IOVA与GPA保持一致,这样guest驱动里写好的DMA地址可以直接用,省去guest内部再做一次地址转换。这是常见的“IOVA=GPA”策略。
另一个身份更贴近本文的语境:当设备由软件模拟时,设备模型看到的“客户机物理地址”其实也叫IOVA。一个模拟出来的网卡或显卡,它眼中的总线地址就是GPA。所以在这类讨论中,IOVA与GPA经常被混着用,本质上说的是同一件事。
所以在本文里,我统一把GPA当作用户侧地址,HVA当作宿主侧地址,IOVA则指的是设备/IOMMU视角下的地址。搞清楚了这层关系,后面再看两套VMM的映射实现才不会绕晕。
1.3 定义GPA到HVA映射的那个核心数据结构
不管QEMU还是KVMTool,最终都会申请一个struct kvm_userspace_memory_region然后发起ioctl:
c复制struct kvm_userspace_memory_region {
__u32 slot;
__u32 flags;
__u64 guest_phys_addr;
__u64 memory_size;
__u64 userspace_addr;
};
其中guest_phys_addr是GPA起始地址,userspace_addr是HVA起始地址,memory_size是这段区间的长度,flags里常用的有KVM_MEM_LOG_DIRTY_PAGES(脏页记录)和KVM_MEM_READONLY(只读)。
这个ioctl是KVM暴露给VMM最核心的内存接口。QEMU和KVMTool的所有差异,本质上都是在“怎么把用户态内存对象翻译成这个结构体”这件事上产生的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. QEMU的映射路径:从MemoryRegion到KVMSlot中间隔了多少层
2.1 为什么要引入MemoryRegion和AddressSpace这套复杂模型
QEMU的地址管理是出了名的绕,但绕得有道理。它面对的设备模型太复杂了:PCI总线上的每个设备都有BAR空间、ROM空间,板卡上还有各种MMIO区域、保留区域,而且这些区域在运行时可能被重新分配、热插拔、别名映射。
如果像裸机一样直接用“一段连续物理内存”来描述整个guest地址空间,根本不可能。所以QEMU设计了两层抽象:
- MemoryRegion:描述一块内存区域的属性和回调,可以是RAM、MMIO、ROM,也可以是alias(别名)。
- AddressSpace:把一组MemoryRegion按照guest物理地址空间的关系“渲染”成一张扁平的视图,这张视图就是FlatView。
CPU访问某段GPA时,QEMU只需要在FlatView里查这段地址落在哪个MemoryRegion上,然后调用对应回调。设备热插拔时BAR重新分配,本质上就是更新MemoryRegion树,然后触发FlatView重算。
2.2 RAMBlock、内存监听器和KVMSlot的联动
我在调试时最喜欢看的一条链路,是从创建一块guest RAM到它真正注册给KVM的完整调用链。
QEMU创建普通RAM时,memory_region_init_ram会为这块区域分配一个RAMBlock。RAMBlock在用户态对应一块通过mmap得到的连续HVA内存。这块内存在guest里有一个GPA起始地址,在宿主里有一个HVA地址,两者之间没有天然联系,必须显式注册给KVM。
注册动作是通过MemoryListener机制完成的。QEMU里有一个全局的kvm_memory_listener,它的region_add和region_del回调会在地址空间拓扑变化时被触发。每次FlatView发生改变,listener就会遍历所有变化的区域,构造struct kvmslot,最后调用kvm_set_user_memory_region。
关键函数在accel/kvm/kvm-all.c里:
c复制static int kvm_set_user_memory_region(KVMMemoryListener *kml, KVMSlot *slot)
{
struct kvm_userspace_memory_region mem;
...
mem.slot = slot->slot_id;
mem.guest_phys_addr = slot->start_addr;
mem.memory_size = slot->memory_size;
mem.userspace_addr = slot->ram_start_offset + (unsigned long)slot->ram_ptr;
mem.flags = slot->flags;
...
return kvm_vm_ioctl(kvm_state, KVM_SET_USER_MEMORY_REGION, &mem);
}
到这里GPA和HVA的映射关系就交给KVM了,后面EPT缺页时KVM直接查这张表。
2.3 动态更新和它带来的潜在问题
QEMU的内存监听器不是一锤子买卖。启动时它会注册所有RAM,之后每次PCI BAR重新分配、ROM区域生效、内存热插拔、设备fw_cfg变更,只要有地址空间拓扑变化,listener就会重新计算增量并更新对应slot。
这种动态机制带来了灵活性,也带来了两个实际代价。
第一,slot数量会碎片化。x86平台KVM可用的memory slot数量有限(一般几百个),如果有设备反复插拔、区域反复拆合,slot号会被不断消耗。我在实际测试中遇到过guest启动时明明内存不多,却出现“kvm: too many memory slots”的情况,最后查出来就是热插拔脚本反复触发区域重建,把slot号耗完了。
第二,FlatView的更新是全局性的。虽然KVM侧是增量更新,但QEMU每次重建FlatView都要遍历整棵MemoryRegion树,设备数量多、拓扑复杂时,这个开销会被放大。这也是为什么QEMU在纯计算型轻负载场景下显得“重”的原因之一。
3. KVMTOOL的映射路径:一个线性数组就把事情办完了
3.1 没有MemoryRegion,KVMTOOL怎么管理guest内存
KVMTool(很多地方直接叫kvmtool)是内核社区维护的一个轻量级VMM,主要用途是快速启动一个guest来验证内核功能和测试KVM特性。它的代码量比QEMU小一两个数量级,内存管理自然也不走“MemoryRegion树”那条路。
KVMTool的内存管理核心就是一个数组,每个元素是一个struct kvm_mem_bank:
c复制struct kvm_mem_bank {
struct list_head list;
u64 guest_phys_addr;
u64 host_addr;
u64 size;
void *priv;
};
每个内存bank对应一段连续的guest地址区间,host_addr就是这段区间在宿主上的HVA地址。这个结构几乎可以直接映射到kvm_userspace_memory_region上,中间不需要再做任何层次化抽象。
3.2 从mmap到KVM_SET_USER_MEMORY_REGION的全过程
KVMTool在初始化guest内存时,沿用的是“先映射、后注册”的流程。以x86为例,kvm__arch__setup_memory_region负责分配guest RAM并注册到KVM:
- 通过
mmap在用户态分配一块连续内存,得到HVA地址。 - 填充
struct kvm_userspace_memory_region,指定guest_phys_addr为约定好的GPA区间。 - 调用
kvm__register_mem发起KVM_SET_USER_MEMORY_REGION。
核心注册函数在kvm.c里:
c复制int kvm__register_mem(struct kvm *kvm, u64 guest_phys, u64 size, void *userspace_addr)
{
struct kvm_userspace_memory_region mem = {
.slot = kvm->mem_slots++,
.flags = 0,
.guest_phys_addr = guest_phys,
.memory_size = size,
.userspace_addr = (u64)userspace_addr,
};
...
return ioctl(kvm->vm_fd, KVM_SET_USER_MEMORY_REGION, &mem);
}
没有listener,没有FlatView,没有region回调,一步到位。
3.3 MMIO为什么不需要复杂的地址空间抽象
QEMU之所以需要复杂的地址空间模型,很大一部分原因是要精确模拟种类繁多的总线设备。KVMTool的思路完全不同:它默认guest只跑内核开发测试所需的最小设备集,MMIO区域由每个设备自己注册,实现方式也直白得多。
KVMTool为MMIO设备提供了一套基于信号和eventfd的机制,设备注册自己的MMIO地址范围后,guest访问这些地址时KVM产生VM exit或通过ioeventfd通知VMM,VMM再分发给对应设备处理。整个过程不需要一个全局的“FlatView”,因为设备数量少、区域固定,线性查找就够了。
3.4 为什么这种“简陋”方案对轻量VMM完全够用
KVMTool的定位是跑一个快速的Linux guest来做内核验证,不是做企业级虚拟化产品。它不需要热插拔、不需要复杂的PCI拓扑、不需要迁移、不需要块设备热迁移。既然需求边界清晰,线性数组加直接注册的设计就是最优解。
代价是,一旦你想要在KVMTool上做设备直通或者更复杂的动态内存管理,很多东西都得自己补。这个我们在第5节会展开讲。
4. 核心差异对比:从数据结构到生命周期管理
4.1 一张表看清两边的映射实现差异
我把两边在GPA-HVA映射这条链路上的关键差异整理了一下:
| 对比维度 | QEMU | KVMTool |
|---|---|---|
| 地址空间抽象 | MemoryRegion + AddressSpace + FlatView | 线性mem_bank数组 |
| 用户态内存对象 | RAMBlock | mmap返回的裸内存 |
| 注册入口 | kvm_memory_listener回调 | kvm__register_mem直接ioctl |
| 更新触发方式 | 地址空间拓扑变化时增量更新 | 启动时一次性注册为主 |
| 热插拔支持 | 完整支持 | 基本不支持 |
| 重叠/别名映射 | 支持alias和重叠区域 | 不支持 |
| MMIO处理 | FlatView回调分发 | eventfd/信号机制 |
| 脏页跟踪 | KVM_MEM_LOG_DIRTY_PAGES + 迁移模块 | 需要自己实现 |
| 设备直通 | 内置VFIO监听器 | 需自行扩展 |
| 代码规模 | 内存子系统上万行 | 内存管理几百行 |
4.2 注册时机和可观测性的差异
QEMU的注册是持续性的,运行过程中随时可能有新的slot被创建、旧slot被删除。这意味着你在生产环境里看到一副动态变化的GPA-HVA映射表。KVMTool则基本是启动时一次性把全部内存注册完,运行中很少改动。
这个差异直接影响排查问题的思路。在QEMU环境里遇到“guest访问某段地址异常”,我一般先怀疑FlatView是不是没更新,或者slot注册顺序有没有问题。在KVMTool环境里,反而更简单,只需要确认启动时那一次注册的时候地址有没有重叠。
4.3 大页内存支持上的差异
大页(HugePage)在两边都要做,但方式略有不同。
QEMU支持通过-object memory-backend-file,mem-path=/dev/hugepages,size=...预先分配大页内存,这个过程中mmap得到的是对齐到2MB/1GB的HVA。之后RAMBlock、slot注册都基于这个大页映射。优势是guest的HPA天然对齐,减少了TLB压力。
KVMTool同样支持大页,做法是让用户先把hugetlbfs挂载好,然后指定内存文件路径,mmap之后注册给KVM。原理上没有本质差别,只是KVMTool没有QEMU那种复杂的memory-backend对象体系,配置方式少一些,但也更直接。
4.4 dirty page tracking:两个VMM的差距最明显
这块差距在实际项目里非常致命。
QEMU的迁移功能依赖脏页跟踪。它在注册slot时如果带了KVM_MEM_LOG_DIRTY_PAGES,KVM在EPT页表上标记dirty bit,QEMU通过KVM_GET_DIRTY_LOG周期性地读取脏页位图,从而知道哪些页需要迁移。QEMU整个迁移状态机就是建立在这个机制之上的。
KVMTool没有内置迁移模块。它虽然可以通过KVM接口设置dirty log,但整个脏页收集、位图管理、数据传输链路都需要自己写。如果只是跑内核测试,这块完全用不到;但如果你想把KVMTool改造成一个轻量级带迁移能力的VMM,这里的工作量不小。
5. 直通场景下的映射博弈:IOVA、IOMMU与脏页跟踪的连锁反应
5.1 软件模拟设备时,设备看到的IOVA就是GPA
在没有设备直通的情况下,guest里的驱动发起一次DMA,用的地址是GPA。设备是被QEMU/KVMTool模拟出来的,模拟器收到DMA请求后,直接在VMM进程的用户态内存里读写对应HVA地址即可。因为VMM进程本身能访问整个guest RAM,这条路径完全不需要IOMMU参与。
这时候GPA到HVA的映射,就是前面说的KVM memory slot。VMM的用户态指针可以直接当作HVA使用,不需要额外处理。
5.2 设备直通时,GPA映射到HPA,路径直接跳过HVA
设备直通后情况完全不同。真实设备通过PCIe发起DMA,DMA地址经过IOMMU翻译成HPA。为了让设备能访问guest内存,VMM必须把guest内存的GPA(此时充当IOVA)映射到对应的HPA。
这里有个关键点:IOMMU页表负责的是GPA(IOVA)到HPA的映射,这与KVM memory slot的GPA到HVA映射是两套独立机制。设备直通时,VMM进程用户态里的HVA只是“备份”,设备访问内存可以完全绕过CPU、绕过VMM进程页表。
QEMU在这一块有非常成熟的内置支持,核心就是vfio_listener。它注册在QEMU的内存监听器上,每当FlatView更新,它就会同步更新VFIO容器的IOMMU映射:
- 某个GPA区间新增了RAM映射,vfio_listener调用
vfio_dma_map把GPA映射到真实的HPA。 - 某个GPA区间被移除,vfio_listener调用
vfio_dma_unmap解除映射。
这样guest热插拔内存时,IOMMU映射也会跟着动态变化。VFIO容器、IOMMU组、设备绑定,这一整套基础设施QEMU已经封装好了。
5.3 KVMTool做直通需要自己补什么
KVMTool原生没有一套完整的VFIO监听链。如果你要在KVMTool上做设备直通,至少需要自己完成三件事:
第一,VFIO容器和组的初始化。需要打开/dev/vfio/vfio,创建容器,设置IOMMU类型,把设备绑定到组里。
第二,把guest内存逐段映射进IOMMU。这一步相当于自己实现一个简化版的vfio_listener。KVMTool的mem_bank结构刚好是一个天然遍历对象,但问题是它注册的都是启动时的固定区域,运行中新增区域就麻烦了。
第三,处理MMIO与irqfd。KVMTool已有的eventfd机制可以复用,但VFIO的中断重映射、irqfd路由需要自己接。
这些工作量加起来不小。我在一个实验项目里尝试过给KVMTool加上基本直通能力,只用到了VFs(SR-IOV虚拟功能)场景,光是处理内存映射的生命周期就花了很长时间。这也是为什么实际产品里做直通,基本都会回到QEMU这条路线上。
5.4 直通和迁移之间的天然矛盾
设备直通配合热迁移,是虚拟化里难度最高的组合题之一。核心矛盾在于:设备DMA可能随时访问guest任意内存,迁移过程中内存拷贝和设备状态保存必须在一个原子的一致视图下完成。
QEMU的做法是用KVM_MEM_LOG_DIRTY_PAGES标记直通设备所在的每个内存段,配合VFIO的IOMMU dirty page tracking(如果硬件支持),在迁移迭代阶段持续收集脏页。KVMTool因为没有迁移状态机,这个问题直接从设计上绕开了。
如果要用KVMTool做直通加迁移,你需要同时处理KVM dirty log和IOMMU dirty log,再保证两者的一致性。这已经不是一个周末能搞定的工程量了。
6. 实践视角:读两个VMM的代码时应该盯住哪些关键位置
6.1 QEMU这边建议盯住的代码路径
想从代码层面理解QEMU的GPA-HVA映射,我推荐按这个顺序读:
softmmu/physmem.c:RAMBlock创建、FlatView触发的核心逻辑。softmmu/memory.c:AddressSpace、MemoryRegion、listener机制。accel/kvm/kvm-all.c:KVMMemoryListener、KVMSlot的创建和更新,最终走到kvm_set_user_memory_region。hw/vfio/common.c:vfio_listener,看它如何把内存映射同步到IOMMU。
调试时QEMU开-d memory会打印内存区域的变动信息,非常有用。我把这个参数和-d guest_errors一起用时,能快速定位PCI BAR分配和更新时机。
6.2 KVMTool这边建议盯住的代码路径
KVMTool代码量小,阅读路径清晰得多:
kvm.c:kvm__register_mem、kvm__unregister_mem,slot分配和释放。include/kvm/kvm.h:struct kvm_mem_bank定义。arch/x86/kvm.c:kvm__arch__setup_memory_region,x86下guest RAM初始化。virtio/目录:看virtio设备如何分配MMIO区域并绑定eventfd。
KVMTool最好的阅读方式是配合strace看ioctl调用序列,启动一个guest后直接就能看到KVM_SET_USER_MEMORY_REGION的调用时机和参数,比读代码直观很多。
6.3 一个快速验证映射布局的bpftrace脚本
调试线上问题时,我经常用bpftrace直接抓KVM的tracepoint,观察GPA-HVA映射到底是怎么变的:
bash复制bpftrace -e '
tracepoint:kvm:kvm_set_user_memory_region
{
printf("slot=%d gpa=0x%lx size=0x%lx hva=0x%lx flags=0x%x\n",
args->slot, args->phys, args->size,
args->userspace_addr, args->flags);
}'
这个脚本无论对QEMU还是KVMTool都有效,因为最终调用的是同一个KVM接口。用起来之后你会发现:QEMU在启动阶段会连续注册大量slot,而KVMTool往往只有少数几个大段。这个对比非常直观,能帮你快速判断当前VMM是哪一类实现。
注意不同内核版本的tracepoint字段名可能略有差异,实际使用前先到/sys/kernel/debug/tracing/events/kvm/下确认一下字段。
6.4 排查映射问题时我自己的判断顺序
遇到过几次“guest内存访问异常”的问题后,我总结了一个排查顺序,分享出来供参考:
第一,先看guest报错现象。如果是guest内部页错误,多半是guest自身页表问题,先不怀疑VMM映射。
第二,确认VMM注册的slot是否覆盖了问题地址。用上面的bpftrace脚本抓一下,看GPA区间和HVA地址是否合理。
第三,确认KVM侧返回的mmap的HVA是否真实可访问。用/proc/<vmm-pid>/maps对照VMM进程地址空间,检查是否有地址重叠或越界。
第四,如果涉及设备直通,再看IOMMU侧映射。用vfio的调试接口或dmesg看IOMMU fault日志,确认IOVA到HPA的映射是否同步。
这套流程在QEMU和KVMTool上都适用。绝大多数“异常”到最后基本都是两类问题:槽位被占满了,或者地址对齐和重叠没有处理好。
我个人在实际操作中体会最深的一点是:QEMU的复杂模型是为了应对不确定性,KVMTool的简单模型是因为它不需要面对不确定性。如果你的需求是快速验证KVM或内核功能,KVMTool的方式足够;但一旦进入企业级功能地带——迁移、直通、热插拔、动态内存——QEMU这套MemoryRegion加listener的设计才是能撑住复杂度的底座。选型前先想清楚你要的是“能跑”还是“能扛”,对应的代码路径完全不是一回事。最后再分享一个小技巧:在任何VMM里看到slot耗尽的报错时,别急着调大KVM_MEM_SLOTS_NUM,先抓一次tracepoint统计slot的创建和释放记录,很多时候是某个设备反复更新BAR导致的,修掉设备模型的问题比改内核参数可靠得多。
