
搞设备直通的人都知道真正决定迁移能否收敛的往往不是 CPU 侧那点脏页而是设备通过 IOMMU 写进 guest 内存的那部分。这个系列前面聊过 VFIO 容器、IOMMU 域、页表映射、ioas 的分配这次把第 22 篇放在 IOMMUFD 的脏页跟踪和 Dirty Bits 读取上。我翻代码的时候最大的感触是脏页跟踪这条路径被很多讲 VFIO 迁移的文章一句话带过但它其实横跨了 UAPI、iommu core、具体 IOMMU 驱动、QEMU 迁移线程四层任何一个地方不配合迁移就卡在最后一步。这篇文章会把脏页跟踪为什么存在、IOMMUFD 的接口怎么设计、Dirty Bits 读出来到底走了哪些函数、以及底层 Intel/AMD/ARM 实现长什么样一条线讲清楚。适合正在做 VFIO 迁移、QEMU 迁移性能调优或者想搞懂IOMMU_HWPT_GET_DIRTY_BITMAP背后逻辑的读者。1. 实时迁移为什么要盯住 IOMMU 页表上的 D 位在做 VFIO 设备直通时实时迁移最难处理的不是内存本身而是设备 DMA 和 CPU 写入之间存在一个“观测盲区”。QEMU/KVM 的预拷贝迁移流程大家都很熟第一轮把全部 guest 内存传给目标机之后每一轮只传上一次同步之后被写脏的页面直到脏页率足够低就暂停虚拟机进入 stop-and-copy把残余脏页和运行状态一起送过去然后切到目标机。这个流程能收敛的前提是我能准确拿到“哪些页面被写脏了”。KVM 拿脏页的方式依赖 CPU 侧的页表机制。x86 上利用 EPT 的 A/D 位或者影子页表写保护ARM 上利用 stage-2 页表的硬件更新位。guest vCPU 每写一个页面都逃不过 MMU 的眼睛QEMU 通过KVM_GET_DIRTY_LOG把这一轮的新脏页拉出来计入内存迁移位图。这套机制在普通虚拟机里很可靠但一旦插了 VFIO 直通设备它就不完整了。直通设备做 DMA 时走的是 IOMMU 页表IOMMU 把设备发出的 IOVA 翻译成系统物理地址然后 DMA 直接写内存。这个写操作发生在 PCIe 总线上CPU 和 KVM 的脏页记录机制完全看不到。更麻烦的是设备的 DMA 不会因为 QEMU 停掉了 vCPU 就停下来它只受设备侧迁移状态机控制。所以实时迁移期间必须额外追问一个问题IOMMU 页表里哪些物理页面被 DMA 写脏了答案就是给 IOMMU 页表项也增加脏位记录。Intel VT-d 在 SL 页表项里提供 A/D 位ARM SMMUv3 通过 HTTU 硬件翻译表更新机制维护访问位和脏位AMD IOMMU 也有对应的硬件位。IOMMU 驱动在映射页表时把这些位置上当 DMA 写一个页面的同时硬件会把这个页表项对应的 D 位置 1。迁移软件周期性读取这些 D 位并清零就能得到“IOMMU 视角的脏页集合”。两个脏页集合必须合并使用。KVM 给的是 CPU 写的脏页IOMMU 给的是 DMA 写的脏页迁移循环里两者缺一不可。如果只看 KVM 的位图设备 DMA 正在写的那部分内存可能在最后一轮被漏掉目标机启动后的数据不一致如果只看 IOMMU 的位图普通 CPU 写入的页面也收不齐。所以现代 VFIO 迁移的脏页同步流程一定是migration_bitmap_sync阶段同时调 KVM 和 VFIO/IOMMUFD 两侧接口把两份位图合并到同一个 ramblock 位图里。很多人在看实时迁移的日志时发现明明设备已经进入停流状态迁移还是卡很久原因往往就是 IOMMU 侧脏页收敛速度太慢。设备一直做小粒度 DMA 写入脏位图每轮都扫出一堆 4K 页迁移线程反复重传机器切不过去。这种问题到后面 QEMU 集成部分我会再展开。2. 从 VFIO type1 到 IOMMUFD脏页获取接口为什么重构老一代 VFIO 实现里脏页跟踪接口长在vfio_iommu_type1上也就是通过容器的 fd 直接操作。QEMU 那边先打开/dev/vfio/vfio拿到 container fd再向里面加设备和 DMA 映射。对应获取脏页的 ioctl 是VFIO_IOMMU_DIRTY_PAGES用户态传递的结构体大概是这样struct vfio_iommu_type1_dirty_bitmap { __u32 argsz; __u32 flags; __u64 pgsize; __u64 bitmap_size; __aligned_u64 data; }; #define VFIO_IOMMU_DIRTY_PAGES_FLAG_START (1 0) #define VFIO_IOMMU_DIRTY_PAGES_FLAG_STOP (1 1) #define VFIO_IOMMU_DIRTY_PAGES_FLAG_GET_BITMAP (1 2) #define VFIO_IOMMU_DIRTY_PAGES_FLAG_NO_CLEAR_BITMAP (1 3)使用方式是先带START标志打开脏页记录之后周期性带GET_BITMAP把位图读出来。默认读操作是 destructive 的读完后内部清零如果这次只是想看一下不想影响下一轮统计就带NO_CLEAR_BITMAP。这套 ABI 用了很多年逻辑上能工作但从架构演进的角度看它有几个不好拔的刺。第一个问题是容器状态和脏页跟踪状态捆绑得太死。vfio_iommu_type1内部用一个布尔变量维护 dirty page tracking 开关整个容器只有一个开关。但真实世界里一个容器里可能挂着多个设备它们对应的 IOMMU 域、DMA 映射范围、迁移策略都不完全一样。你没法只对其中一块映射启用脏页跟踪粒度太粗。第二个问题是vfio_iommu_type1自己维护了一份软件层面的 dirty bitmap同时还要跟底层 iommu_domain 的硬件脏位协调。每次GET_BITMAP的路径要先遍历容器的 dma 列表找到所有映射区间再逐个区域调底层接口这套代码长期靠补丁堆叠维护成本越来越高。第三个问题是跟设备 fd 和 container fd 耦合太紧。用户态拿到脏页信息之前得先持有 container fd、device fd还得保证 IOMMU 域已经创建好。到了嵌套页表、多级 hwpt、用户态管理 IOAS 这些新场景里这个模型根本伸展不开。IOMMUFD 的设计思路是把“映射管理”和“页表对象”拆开。IOMMUFD 引入struct iommu_hw_pagetable作为独立的页表对象用户态通过IOMMU_HWPT_ALLOC创建 hwpt拿到一个 hwpt_id。脏页跟踪状态挂在 hwpt 上而不是挂在容器上。对用户态来说我只需要持有一个 hwpt_id就能独立控制脏页跟踪的开关、读取和清零不用关心它跟哪台设备绑定。接口重构的另一个动机是给老 VFIO 代码“减负”。IOMMUFD 在底层仍然复用 iommu_domain、iommu_group 这些基础设施但它把 VFIO 那套容器内嵌的逻辑重新整理成一组更小的对象ioas 管地址空间hwpt 管硬件页表device 管设备关联。脏页跟踪成了 hwpt 的一个属性而不是 type1 容器里的一个全局状态。这套模型对后续支持嵌套页表、KVM/vDPA 等其他消费者也更友好。从 QEMU 角度看老的VFIO_IOMMU_DIRTY_PAGES还是可以用只是当 QEMU 配置了iommufdon时它会走IOMMU_HWPT_SET_DIRTY_TRACKING和IOMMU_HWPT_GET_DIRTY_BITMAP这一组新 ioctl。两条路径对外表现一致但内部代码干净得多。这也是为什么我建议新项目直接切 IOMMUFD——老 type1 接口短时间内不会删但新特性和性能优化基本不会再往那边加了。3. IOMMUFD 脏页跟踪的核心对象hwpt、标志位和位图结构IOMMUFD 的脏页跟踪能力是在创建 hwpt 的时候声明的。看include/uapi/linux/iommufd.h里的 hwpt 分配标志enum iommu_hwpt_alloc_flags { IOMMU_HWPT_ALLOC_NEST_PARENT 1 0, IOMMU_HWPT_ALLOC_DIRTY_TRACKING 1 1, };IOMMU_HWPT_ALLOC_DIRTY_TRACKING表示用户态要求这个 hwpt 关联的 IOMMU 域从创建开始就具备脏页跟踪能力。底层对应的 iommu_domain 必须支持set_dirty_tracking这个 domain op否则分配就会失败。这里注意不是所有 IOMMU 硬件都支持硬件脏位记录比如纯软件模拟的 IOMMU或者某些 SMMU 平台没实现 HTTU那创建带 dirty tracking 的 hwpt 会返回EOPNOTSUPP。hwpt 创建成功之后脏页跟踪可以动态开关对应的命令是IOMMU_HWPT_SET_DIRTY_TRACKINGstruct iommu_hwpt_set_dirty_tracking { __u32 size; __u32 hwpt_id; __u8 flags; __u8 pad[3]; };flags 里带IOMMU_HWPT_SET_DIRTY_TRACKING_ENABLE就是打开不带就是关闭。很多人会问既然分配时已经声明了 dirty tracking为什么还要一个独立的开关原因是脏页跟踪开启后IOMMU 侧要做的工作不只是记录 D 位还包括一些性能折损。比如 Intel 某些平台上开启硬件脏页跟踪后页表缓存命中率会下降DMA TLB 刷新的频率也会变高。所以实际使用中QEMU 只在迁移开始后才打开 dirty tracking平时不开启减少对设备直通稳态性能的影响。真正把脏页位图读出来的是IOMMU_HWPT_GET_DIRTY_BITMAP命令struct iommu_hwpt_get_dirty_bitmap { __u32 size; __u32 hwpt_id; __u8 flags; __u8 pad[3]; struct iommu_dirty_bitmap __user *uptr; };其中uptr指向一个struct iommu_dirty_bitmap这个结构体是用户态和内核之间传递地址范围和位图缓冲区的描述符定义在include/uapi/linux/iommu.hstruct iommu_dirty_bitmap { __aligned_u64 iova; __aligned_u64 length; __aligned_u64 bitmap_size; __aligned_u64 data; };四个字段分别表示起始 IOVA、扫描长度、用户态位图缓冲区的字节数、以及位图缓冲区本身的用户态地址。IOMMU 驱动会按照 IOMMU 支持的最小页粒度把[iova, iovalength)这段范围内被硬件标记为 dirty 的页面对应到 bit 位上写入data缓冲区。位图里面每个 bit 对应一个页但具体页大小不是 QEMU 迁移用的 4K而是 IOMMU 当前支持的 pgsize 中的最小粒度。QEMU 侧需要先知道这个 pgsize才能正确换算位图大小。IOMMUFD 这边通过在分配 hwpt 时的硬件能力查询来获取 pgsize_bitmap用户态也可以用IOMMU_GET_HW_INFO拿到 IOMMU 的页粒度信息。IOMMU_HWPT_GET_DIRTY_BITMAP的 flags 里有一个跟老 VFIO 接口对应的语义是否清除脏位。默认情况下读取脏位图是 destructive 的驱动在读完后把对应页表项的 D 位清掉这样下一轮迭代只统计新写入的页。如果想要读取但不影响累计状态就在 flags 里带上 no-clear 标志。迁移主流程几乎总是用默认的“读后清”因为每轮迭代需要的本来就是增量只有在调试或者做预测性扫描时才用 no-clear。IOMMUFD 的这套对象关系非常清晰ioas 是软件地址空间hwpt 是硬件页表dirty tracking 是 hwpt 的开关状态dirty bitmap 是读出来的结果。相比老 type1 在容器里维护一个全局 dirty 状态的做法IOMMUFD 可以同时支持多个 hwpt、每个 hwpt 独立的 dirty tracking 开关这在多设备、多 IOMMU 域的场景下优势很明显。4. Dirty Bits 读取路径源码走读从用户态 ioctl 到硬件读清这一节我会沿着真正的调用路径走一遍从 ioctl 入口到最底层驱动。先看命令分发。IOMMUFD 的所有 ioctl 在drivers/iommu/iommufd/main.c里有一张命令表static const struct iommufd_ioctl_op iommufd_ioctl_ops[] { IOCTL_OP(IOMMU_HWPT_ALLOC, iommufd_hwpt_alloc, struct iommu_hwpt_alloc), ... IOCTL_OP(IOMMU_HWPT_SET_DIRTY_TRACKING, iommufd_hwpt_set_dirty_tracking, struct iommu_hwpt_set_dirty_tracking), IOCTL_OP(IOMMU_HWPT_GET_DIRTY_BITMAP, iommufd_hwpt_get_dirty_bitmap, struct iommu_hwpt_get_dirty_bitmap), };用户态调用ioctl(hwpt_fd, IOMMU_HWPT_GET_DIRTY_BITMAP, arg)时iommufd_fops_ioctl会根据 cmd 号查表把用户态 arg 拷贝到内核栈上然后调iommufd_hwpt_get_dirty_bitmap。这个函数的处理逻辑大致分几步。第一步是根据cmd-hwpt_id查对象表拿到struct iommufd_hw_pagetable。这个对象里挂着struct iommu_domain *domain以及对应的 ioas。第二步是检查hwpt-domain的 capabilities核心就是看 domain ops 里有没有实现read_and_clear_dirty没有就直接返回EOPNOTSUPP。第三步是处理用户态传进来的struct iommu_dirty_bitmap。内核先把uptr指向的描述符从用户态拷进来拿到 iova、length、bitmap_size 和 data 指针。然后根据 bitmap_size 分配一块内核缓冲区比如用kvmalloc把内存清零准备承接硬件扫描出来的位图结果。接下来就是真正的脏位获取调用rc iommu_read_and_clear_dirty(domain, dirty.iova, dirty.length, kernel_bitmap);这个函数声明在 iommu core实现在drivers/iommu/iommu.c本质上就是对 domain ops 的一层薄封装int iommu_read_and_clear_dirty(struct iommu_domain *domain, unsigned long iova, size_t size, unsigned long *bits) { if (!domain-ops-read_and_clear_dirty) return -EOPNOTSUPP; return domain-ops-read_and_clear_dirty(domain, iova, size, bits); }所以真正的脏页扫描、D 位读取、D 位清除全部在具体 IOMMU 驱动的read_and_clear_dirty回调里完成。以 Intel VT-d 为例函数实现会先根据 iova 和 size 求出涉及到的页目录层级从顶层 PGDT 开始逐级往下走直到叶子 PTE。每碰到一个页表项就检查它的 D 位如果置位就把对应的 bit 在输出位图里置 1然后根据是否带 no-clear 标志决定要不要写回清除。如果映射使用了 2M 或者 1G 大页驱动需要根据大页覆盖的页数量一次性把连续的一整段 bit 置位。这个“读后清”操作实际上会成为迁移性能的关键瓶颈。IOMMU 页表项在物理内存里读写 D 位就要反复访问页表内存DMA 频繁的页也会让页表 cache 的压力变大。Intel 驱动在read_and_clear_dirty里做了不少优化比如对一个大页只读一次页表项而不是把大页拆成 512 个 4K 页逐项读又比如在某些平台上使用硬件支持的批量 dirty bitmap 扫描能力把页表扫描下推到硬件。再看iommufd_hwpt_set_dirty_tracking的路径。这个函数更短只是把 cmd-flags 里的 enable 位取出来然后调rc iommu_set_dirty_tracking(hwpt-domain, enable);iommu core 同样把调用下放到 domain ops 的set_dirty_tracking。底层驱动的实现主要是设置硬件状态比如 Intel 打开对应 IOMMU 的 dirty tracking 硬件开关ARM SMMUv3 则是在 STE 里设置 HD/HA 位让硬件在访问页表项时自动更新脏位和访问位。值得注意的一个细节是IOMMUFD 在开启 dirty tracking 之后会要求在读取脏位图时遍历的地址范围仍然处于映射状态。如果用户态先把一段 IOVA 范围 unmap 了再读脏位图驱动拿不到页表项这段范围内的脏位信息就丢了。所以迁移流程里QEMU 必须在整个迁移周期内保持地址映射稳定不能随意取消映射否则会出现漏检。这也是为什么 IOMMUFD 内部有“dirty tracking 时禁止冲突类操作”的校验逻辑。5. 三种典型 IOMMU 的 Dirty Bits 读取实现差异脏页跟踪既然是硬件能力那落到不同 IOMMU 平台上的实现差异就非常大。我分别说一下 Intel VT-d、AMD IOMMU、ARM SMMUv3 三家的做法这部分对做多平台迁移的人尤其有用。Intel VT-d 上SL 页表项的格式跟 x86 CPU 页表很像页表项里有 A/D 位。IOMMU 硬件在 DMA 读写时自动更新访问位和脏位。驱动侧的核心函数是intel_iommu_read_and_clear_dirty。因为 Intel IOMMU 支持多种页大小驱动在扫描大页时会把一个大页直接映射到输出位图里连续的若干个 bit而不是拆页逐项读。另一个特点是 Intel 某些较新的平台支持在无 PTE 拆分的情况下做硬件辅助脏位扫描驱动可以通过描述符一次性拿到一个较大页表范围内的脏页信息。AMD IOMMU 的实现与之类似但页表项里对脏位的维护方式和硬件标志并不完全一样。AMD 在 IOMMU 页表项里也提供访问更新位驱动通过amd_iommu_read_and_clear_dirty扫描页表。实际工程中 AMD 平台开启脏页跟踪后的页表扫描开销在某些 benchmark 下会比 Intel 更明显一是因为页表层级遍历路径更长二是缺少大页批量扫描优化。好在 AMD 平台在 VFIO 迁移场景下的占比相对低踩到的概率也小。ARM SMMUv3 是最特殊的一个因为它的脏页跟踪依赖 HTTU 和 BBML 这两个硬件特性。HTTU 允许 SMMU 在 stage-1 或 stage-2 页表翻译过程中维护 AF 访问标志和 DB 脏位但能不能记录脏位还取决于页表条目里是否配置了对应的控制位。BBML 则是关于 break-before-make 的支持它决定了驱动在修改页表项时是否需要先做一次无效化处理这对脏位清除和页表更新有直接影响。SMMUv3 驱动在启用脏页跟踪时会在 STE 里设置 HA/HD 位这样硬件翻译时才会计脏。arm_smmu_read_and_clear_dirty的实现要处理两个容易出问题的地方一个是大页分裂问题SMMUv3 页表支持 4K/64K/2M 等组合扫描时不能简单假设页表项粒度跟 guest 页粒度一致另一个是 BBML 约束下的页表更新清除脏位本身也是一个写页表项操作必须遵守对应的内存屏障和 TLB invalidate 规则。为了更直观地比较我把三家的关键差异列在下面平台脏位载体主要实现函数典型坑Intel VT-dSL 页表项 A/D 位intel_iommu_read_and_clear_dirty大页批量 bit 映射容易算错偏移AMD IOMMUIOMMU 页表更新位amd_iommu_read_and_clear_dirty扫描开销偏大缺少批量优化ARM SMMUv3HTTU AF/DB 位arm_smmu_read_and_clear_dirtyBBML 约束、STE 配置复杂不管哪种平台驱动最终输出的位图格式到 iommu core 层都是一致的每个 bit 代表一个最小页粒度的页面。IOMMUFD 的代码不需要关心底层用的是什么页表格式它只要把用户态传进来的 iova 范围和输出位图缓冲区交给read_and_clear_dirty就行。这个抽象是 IOMMUFD 能同时兼容 x86 和 ARM 的关键。还有一个必须注意的硬件差异dirty tracking 的开关状态和页表映射状态强相关。Intel 和 AMD 在打开 dirty tracking 后新建立的映射会正常在硬件里维护脏位但已经存在的旧映射是否能补上脏位记录能力各平台实现不完全一致。ARM SMMUv3 上如果先把设备映射建立好了再开 dirty tracking某些 STE 配置下需要重新写 STE 才能让 HTTU 生效。所以 QEMU 的实际顺序一般是分配 hwpt 时直接带IOMMU_HWPT_ALLOC_DIRTY_TRACKING再建立映射避免中途切换带来的状态不一致。6. QEMU 迁移循环里的 Dirty Bits 读取节奏以及我踩过的性能坑最后把镜头拉到用户态。QEMU 在启用 IOMMUFD 后VFIO 迁移相关代码会走hw/vfio/iommufd.c里封装的接口而不是老的VFIO_IOMMU_DIRTY_PAGES。迁移线程在每一轮开始时会调vfio_get_dirty_bitmap逻辑是根据 ramblock 的 iova 范围填一个struct iommu_dirty_bitmap算好 bitmap_size然后执行IOMMU_HWPT_GET_DIRTY_BITMAPioctl。拿到结果后把位图合并进 QEMU 的ramblock-bmap作为这一轮的迁移脏页基础。这里有一个非常常见的换算错误。QEMU 的迁移记账通常以 TARGET_PAGE_SIZE也就是 4K 为单位但 IOMMU 驱动返回的位图最小粒度可能不是 4K。比如某些 ARM SMMUv3 平台配置了 64K 页粒度或者 Intel 平台因为映射使用 2M 大页导致驱动按 2M 为单位标记脏位这时直接把 IOMMU 位图跟 QEMU ramblock 位图做 OR就会错位。我最初调一个 ARM 平台 VFIO 网卡迁移时迁移偶尔会出现“脏页越传越多”的现象最后定位到就是 bit 粒度不一致导致的重复传输。正确的做法是先通过硬件能力查询拿到 IOMMU 的 pgsize_bitmap确定脏位图中每个 bit 代表的页大小然后按比例把位图展开或者聚合到 QEMU 的 4K 粒度。QEMU 里这块逻辑在vfio_migration和ramblock合并处都有做适配但如果自己写工具或者调试脚本特别容易漏。第二个坑是脏页跟踪开启后的性能下降。IOMMUFD 脏页跟踪不是免费的打开后 IOMMU 硬件要多做 A/D 位更新页表条目在 D 位被修改后相关 cache line 容易失效。Intel 平台上的实测里开启 dirty tracking 后网络设备直通的吞吐可能掉 3%~5%如果设备是高频率小包 DMA下降会更明显。所以在非迁移状态下QEMU 不应该一直开 dirty tracking只有进入 migration dirty sync 周期时才打开迁移结束立刻关掉。第三个坑是位图扫描的地址范围对齐。IOMMU_HWPT_GET_DIRTY_BITMAP的iova和length最好对齐到 IOMMU 的最小页粒度。如果 iova 不是页对齐驱动虽然在内部会做向下取整之类处理但用户态位图的 offset 计算就可能对不上导致脏页偏移。我自己写调试程序时就犯过这个错iova 从 ramblock 的 host 地址偏移了一个非页对齐量结果是拿到的位图整体错了一位排查了很久才发现是偏移没对齐。第四个要提醒的是IOMMUFD 要求 dirty tracking 开启期间被跟踪的映射不能被随意修改。之前讲源码时提到过如果你在迁移过程中 unmap 了一段 iova再回来读脏位图这段区间新产生的 DMA 脏信息就会丢。QEMU 的 VFIO 迁移流程里整个迁移周期里对设备内存区域的映射是保持稳定的但如果上层软件自己做了内存 hotplug 或者 unmap就很容易踩到这个限制。遇到这种场景合理做法是先停掉 dirty tracking完成映射变更再重新打开虽然麻烦但状态是干净的。关于位图大小的计算我再放一个可以直接用的公式。假设扫描范围是[iova, iovalength)最小页大小是page_size那么需要的 bit 总数是length / page_size换算成字节数bitmap_bytes ALIGN(length / page_size, BITS_PER_LONG) / BITS_PER_LONG * sizeof(unsigned long);用户态分配好这个大小的缓冲区填进struct iommu_dirty_bitmap.data再把bitmap_size设成bitmap_bytes传给 ioctl 即可。bitmap_bytes 最好按unsigned long对齐因为内核里大量位操作是按 long 做的缓冲区末尾多几个字节不会出错但少了就可能导致越界写。最后一个个人体会排查 IOMMUFD 脏页跟踪问题时不要一上来就翻 QEMU先在drivers/iommu/iommufd里确认 ioctl 路径是否走到了你想走的地方。打开 ftrace 或者加简单打印看iommufd_hwpt_get_dirty_bitmap有没有被调用、底层read_and_clear_dirty返回的位图数量是否合理比在 QEMU 日志里猜快得多。这类问题往往是 QEMU 侧传错了 iova 范围或 bitmap_size内核侧反而一直工作正常先分层排查能节省大量时间。我把这段代码从 UAPI 一路看到驱动最后看 QEMU最大的感受是脏页跟踪表面上是“读一个位图”实际上是对整条 VFIO 映射链路的严格考验。任何一层对页粒度、对齐、映射生命周期的假设不一致都会在迁移最后一刻暴露出来。希望这篇文章能帮后面做迁移开发的人绕过我踩过的那些坑。