☰
IOMMUFD脏页跟踪机制解析:从Dirty Bits读取到热迁移实践
2026/9/29 19:52:54 网站建设 项目流程

这个系列写到现在,前面把 iommufd 的 ioas 管理、hwpt 创建还有 iova 映射都拆得差不多了,这一篇终于要碰热迁移里最要命的一环:脏页跟踪与 Dirty Bits 读取。VFIO 设备直通场景下,虚机要热迁移,宿主机不可能把几十个 G 的客户机内存全量拷贝过去,只能靠 IOMMU 硬件记录设备 DMA 写脏的页,再把这些脏页位图交给迁移工具做增量同步。IOMMUFD 作为新一代用户态 IOMMU 接口,把脏页跟踪从 VFIO type1 的老框架里抽出来,下沉到了 iommu_domain 层面,这套设计的思路很值得单独开一篇来聊。

不管你是做虚拟化平台底层开发,还是在调 QEMU 的 VFIO migration 流程,或者单纯想搞明白 IOMMUFD 的 uapi 里那几个 dirty bitmap 相关结构体怎么用,这篇都适合你。我会从“为什么需要脏页跟踪”开始,把 iommu_dirty_ops 的软件骨架、双缓冲位图的设计意图、ioctl 读取的完整调用链,以及 Intel/AMD/ARM 这几类硬件实现的差异,逐个讲清楚。最后放几个我在实际调试中踩过的坑,给后面动手的人省点时间。

1. 背景:热迁移为什么离不开脏页跟踪

1.1 数据面需求:从全量拷贝到增量同步

设备直通的虚机做热迁移,和普通虚机最大的区别在于:除了 CPU 寄存器和内存,你还要考虑设备 DMA 写过的内存。普通虚机迁移时,QEMU 自己就能追踪 vCPU 写脏的页,但直通设备走的是 IOMMU,设备 DMA 直接写客户机物理内存,QEMU 根本不知道哪一页被设备写过了。如果迁移过程中不处理这部分脏页,目标端恢复出来的内存状态就会缺数据,设备一接管就要出问题。

最简单的方案是全量拷贝:先把所有内存页传给目标端,再停设备、停 vCPU,把剩余状态同步过去。这对小内存虚机还能忍,但对动辄几十 G 的数据库实例来说,全量拷贝的耗时和带宽占用是完全不可接受的。于是就有了脏页跟踪:迁移过程中,只同步“从上一次同步点到现在,又被设备写脏的那些页”。IOMMU 硬件在页表项里维护 dirty 位,软件定期读取并清除这些位,就能得到一个增量位图。

这里要强调一个很容易被忽视的细节:新映射的 iova 在开启脏页跟踪后,会被默认视为脏页。原因是目标端还没同步过这片内存,如果新 map 的区域不标记为脏,迁移就会漏数据。这个逻辑是在 iommu core 的 map/unmap 路径里实现的,后面第 2 章我会具体说。

1.2 IOMMUFD 与 VFIO 老框架的路线差异

老的 VFIO 框架里,脏页跟踪是通过VFIO_IOMMU_DIRTY_PAGES这个 ioctl 做的,由vfio_iommu_type1这个模块自己维护一份位图。它的问题是:脏页记录逻辑和 vfio 的 iommu 映射管理强耦合,type1 代码里到处是 bitmap 操作的边角逻辑,而且不同的 IOMMU 驱动能力差异被掩盖在这一层抽象后面,很难做精细控制。

IOMMUFD 的方案是把脏页跟踪下沉到 iommu_domain。IOMMU 核心层定义了统一的iommu_dirty_ops,每个 IOMMU 驱动自己实现“开跟踪、读脏页、清脏页”这三个动作。IOMMUFD 的用户态接口(IOMMU_HWPT_GET_DIRTY_BITMAP)只是薄薄一层封装,把用户的请求翻译成对iommu_domain_dirty_bitmap()的调用。这样做的好处是:VFIO、vfio-pci、甚至其他非 VFIO 的上层模块,都能复用同一套脏页跟踪机制,不再各写各的。

从源码看,IOMMUFD 的 dirty tracking 还引入了引用计数和 ioas 生命周期管理,确保在 ioas 被 unmap 或者 hwpt 释放的瞬间,不会出现正在读位图、底层的页表却被拆了的竞态。这一层在 VFIO 老框架里是靠大锁硬扛的,IOMMUFD 里的处理要干净得多。

2. 核心数据结构与双缓冲位图设计

2.1 iommu_domain 里的脏页跟踪基础设施

IOMMUFD 的脏页跟踪,核心落在 iommu_domain 这个结构体上。在include/linux/iommu.h里,和 dirty tracking 相关的字段大致是这样:

struct iommu_domain { ... unsigned long pgsize_bitmap; ... struct iommu_dirty_ops *dirty_ops; unsigned long *dirty_bitmap; spinlock_t dirty_lock; ... };

pgsize_bitmap表示这个 domain 支持的页粒度集合,比如4K | 2M | 1G,脏页位图的每一位对应的就是最小的那段粒度。dirty_bitmap是 IOMMU core 自己维护的一份“软件侧”脏页位图,dirty_lock是保护它和脏页状态切换的自旋锁。dirty_ops则是 IOMMU 驱动注册的回调集合,定义如下:

struct iommu_dirty_ops { int (*set_dirty_tracking)(struct iommu_domain *domain, bool enabled); int (*read_and_clear_dirty)(struct iommu_domain *domain, unsigned long *bitmap, unsigned long iova, size_t size, unsigned long flags); int (*dirty_bitmap_clear)(struct iommu_domain *domain, unsigned long *bitmap, unsigned long iova, size_t size, unsigned long flags); };

这三个回调分别对应三件事:打开或关闭脏页跟踪、读取并清除硬件记录的脏页、只清除不读取。大部分 IOMMU 驱动只需要实现前两个,dirty_bitmap_clear在部分场景下可以直接复用 read_and_clear 然后丢弃结果,但单独拆出来是为了给某些硬件提供更高效的清脏路径。

2.2 map/unmap 时脏页标记的联动逻辑

脏页跟踪开启后,内存映射的变化也必须同步反映到位图里,否则会出现漏记或误记。在 iommu core 的 map 路径里,大致是这样的逻辑:如果当前 domain 开启了脏页跟踪,那么新映射的 iova 范围会被整体标记为脏;反过来,unmap 的时候,这个范围对应的位会被清除。

为什么新 map 的页要标记为脏?因为热迁移场景下,目标端的内存镜像只包含“已经同步过的页”。新映射出来的内存,目标端一定还没有,如果不在脏页位图里标出来,下次增量同步就不会带它过去,迁移完成后目标端访问这片内存就是空的。这个设计思路和 CPU 内存迁移工具(比如用户态的 migration bitmap)是一致的:新 map 即脏。

unmap 清除位图则比较直观:这段 iova 对应的物理页都不在这个 domain 里了,自然也就不需要再跟踪它的写脏状态。但这里有一个需要注意的时序问题:如果正在读脏页位图的过程中有人 unmap,可能读到半新半旧的位图。IOMMUFD 层通过 ioas 的引用计数和 hwpt 的状态机来串行化这些操作,保证脏页读取和映射修改不会交错。

2.3 双缓冲位图为什么这么设计

很多第一次看代码的人会疑惑:IOMMU 驱动自己不是有硬件 dirty 位吗?为什么 iommu core 还要再维护一份domain->dirty_bitmap?这其实是双缓冲的精髓:硬件侧记录的是“自上次清除以来的脏页”,软件侧记录的是“已经上报过、但还没返回给用户态的脏页状态”的补充。

举个实际场景:用户态第一次读取脏页位图,把硬件侧读出来并清零了,但返回给用户态的过程中,设备又 DMA 写脏了同一页。用户态拿到位图后执行第二轮增量同步,如果只有硬件侧这一份记录,这一页的“新脏”状态会丢失吗?不会,因为硬件侧会再次置位。真正的问题是另一类场景:某些 IOMMU 驱动在 read_and_clear 的时候,只能按整段 iova 范围清除,或者清除动作本身有延迟,如果用户态拿到的位图还没拷贝完,硬件侧已经被清了,而软件侧又没有备份,这中间产生的脏页就丢了。

双缓冲的语义是:read_and_clear_dirty 把硬件侧脏页读出来,先和domain->dirty_bitmap合并,再把合并结果返回给用户态,最后把domain->dirty_bitmap里这一段清掉。这样即使硬件侧因为清除时序丢了几个位,软件侧也能在下次读取时兜底。代价就是多一份内存和一次位图合并的 CPU 开销,但在热迁移场景里,这笔开销换来的数据完整性是值得的。

3. IOMMUFD 脏页读取的完整调用链

3.1 uapi 层的数据容器结构

IOMMUFD 的脏页读取接口是IOMMU_HWPT_GET_DIRTY_BITMAP,对应的用户态结构体在include/uapi/linux/iommufd.h里:

struct iommu_hwpt_get_dirty_bitmap { __u32 size; __u32 hwpt_id; __u32 flags; __u32 hugepages; __aligned_u64 data; __aligned_u64 data_len; }; struct iommu_dirty_bitmap_data { __aligned_u64 iova; __aligned_u64 length; __aligned_u64 bitmap; __aligned_u64 bitmap_size; };

外层结构指定 hwpt 的 ID 和 flags,data指针指向一个iommu_dirty_bitmap_data,里面才是真正要查询的 iova 范围、位图用户态缓冲区地址和缓冲区大小。flags里有个IOMMU_HWPT_GET_DIRTY_BITMAP_NO_CLEAR,置上之后这次读取不会清除硬件侧的脏页标志,适合做“只观察不清除”的调试或最后确认。

hugepages字段值得单独说一下。置 1 时,位图每一位对应的是 huge page 粒度,而不是 IOMMU 页表的最小页粒度。对大内存虚机来说,按 4K 粒度维护位图,几十 G 内存对应几百万个 bit,用户态处理和网络传输都很重;按 2M 甚至 1G 粒度,位图体积直接少几个数量级。代价是精度变粗,同步时可能会多传一些“其实没脏”的页,但在大内存场景这是非常划算的取舍。

3.2 ioctl 入口到 iommu_domain_dirty_bitmap 的调用链

从 ioctl 入口到底层驱动,整个链路不算长,但每一层要做的事情很明确:

ioctl(IOMMU_HWPT_GET_DIRTY_BITMAP) └─ iommufd_hwpt_get_dirty_bitmap() ├─ 校验 uapi 结构体和 data 指针 ├─ 根据 hwpt_id 查找到 iommufd_hwpt ├─ 访问 iommu_dirty_bitmap_data,取出 iova/length/bitmap/bitmap_size ├─ iommu_domain_dirty_bitmap(domain, kbitmap, iova, length, flags) │ ├─ 调用 domain->dirty_ops->read_and_clear_dirty() │ ├─ 把结果与 domain->dirty_bitmap 合并到用户传入的位图 │ └─ 清除 domain->dirty_bitmap 中对应区域 └─ copy_to_user 把最终位图拷回用户态 data.bitmap

iommufd_hwpt_get_dirty_bitmap做的事主要是对象查找和权限校验。它根据 hwpt_id 在 iommufd 的对象表里找到对应的 hwpt,然后取到它背后的 iommu_domain。接下来真正的脏页读取逻辑在 iommu core 的iommu_domain_dirty_bitmap()里完成。

iommu_domain_dirty_bitmap()先检查这个 domain 是否支持脏页跟踪(dirty_ops是否为 NULL),再检查 user 传入的 iova 和 size 是否按页对齐。然后它会临时分配一块内核位图,调用驱动的read_and_clear_dirty把硬件侧脏页读进来,再通过位图合并操作把domain->dirty_bitmap里尚未消费的脏页并进去,最终结果才拷贝给用户态。注意,这个函数返回之后,驱动侧已经被清过了,如果用户态在拿到位图后又发生 DMA 写,那要等下一次读取才能看到。

3.3 位图数据如何组织:粒度、对齐与长度计算

位图的长度计算是新手最容易出错的地方。假设 domain 的最小页粒度是 4K,用户要查询的 iova 范围是 [0x100000, 0x500000),长度是 4M,那么对应 1024 页,位图需要 1024 bit,也就是 128 字节。bitmap_size必须按 8 字节对齐,并且内核一般要求不小于实际需要的字节数。如果用户传小了,调用会直接失败,不会给你静默截断。

更严谨一点,位图不同位的索引偏移是:

page_index = (iova - range_start) >> granule_shift;

granule_shift在 hugepages=0 时取ilog2(min_io_pagesize),在 hugepages=1 时取用户指定或驱动支持的最大 huge page 粒度。看到这里你应该理解为什么我不能直接建议“固定用 4K 粒度”了:如果你的虚机内存大、迁移窗口又紧,2M 粒度能省掉大量位图传输时间;但如果你的工作负载 DMA 写非常分散,2M 粒度会导致每个 2M 块里只要有一页被写,就整块重传,可能比 4K 粒度还慢。这个 trade-off 要结合业务选,不能一概而论。

4. 不同 IOMMU 硬件背后的 dirty 实现差异

4.1 Intel/AMD 的页表 Dirty 位机制

Intel IOMMU 和 AMD IOMMU 的脏页跟踪思路很接近,都是靠页表项里的 dirty 位来记录设备写操作。开启脏页跟踪时,驱动会确保新分配的页表项支持 dirty 维护;DMA 写发生的时候,IOMMU 硬件在地址翻译过程中自动把对应 PTE 的 dirty 位置上。软件要做的事,就是遍历用户查询范围内的页表项,把 dirty 位搬到软件位图里,然后清掉这些位。

注意这里有个页表遍历的开销问题。如果 iova 范围很大、页表层级深,一次全量扫描可能涉及几千个页表项,而且清 dirty 位之后还要考虑 IOTLB 的失效。很多驱动在 read_and_clear_dirty 里会合并连续页面的处理,尽量用批量操作而不是一次页表项一个操作。还有,如果开了 super page(比如 2M/1G 映射),一个 PTE 就对应一大片内存,遍历起来会快很多,这也是为什么前面说的 hugepages 参数在真实迁移场景里有价值。

4.2 ARM SMMU 的 HTTU 与其它实现

ARM SMMUv3 的情况要复杂一点。它有一套硬件机制叫 HTTU(Hardware Translation Table Update),可以自动维护页表项里的访问位和脏位。但不是所有 SMMUv3 实现都开了 HTTU,需要固件和内核驱动配合使能。在驱动层面,arm_smmu_read_and_clear_dirty的实现逻辑和 Intel/AMD 类似,都是扫页表,但具体判断哪一位是 dirty、要不要先做 TLBI,就得看 SMMU 的架构版本和实现细节了。

另外也提醒一下,不是所有 IOMMU 驱动都有完整的 dirty_ops。比如一些老平台或者嵌入式场景下使用的 IOMMU 驱动,可能只支持 set_dirty_tracking 但 read_and_clear_dirty 返回 -EOPNOTSUPP,或者干脆三个回调都是 NULL。这时候 iommu core 的能力探测就会返回不支持,用户态用IOMMU_GET_HW_INFO查询IOMMU_CAP_DIRTY也会拿不到。遇到这种情况,先别急着怀疑 IOMMUFD 的代码,先确认底层 iommu_driver 有没有注册 dirty_ops。

4.3 用户态如何探测能力并选择脏页跟踪策略

IOMMUFD 的能力探测是通过IOMMU_GET_HW_INFO这个 ioctl 做的。内核填回的信息里带一组 capabilities,其中就包括IOMMU_CAP_DIRTY。只有这个位被置上,你才能期待后续创建 hwpt 时传入IOMMU_HWPT_ALLOC_DIRTY_TRACKING会成功。

实际项目中,我一般建议用户态在初始化阶段就把能力和粒度信息查出来,而不是每次都靠错误码去猜。查询结果包括pgsize_bitmap和 dirty 能力,然后你根据这个决定:

  1. 是否开启脏页跟踪;
  2. 位图粒度用 4K 还是 huge page;
  3. 最后的“停设备前确认”要不要带 NO_CLEAR 读一次。

如果 hwpt 创建时没带 dirty tracking 标志,后面又想临时开,按照目前 IOMMUFD 的接口设计,只能销毁 hwpt 重建。这在运行期是个比较大的动作,所以虚机启动时如果知道自己有迁移需求,最好一开始就带上这个标志,省得迁移前临时重建。

下面用表格总结一下几类常见 IOMMU 驱动的 dirty tracking 能力特点:

驱动能力探测dirty 位来源read_and_clear 典型实现
Intel IOMMUIOMMU_CAP_DIRTYPTE dirty 位遍历页表,搬位并清位
AMD IOMMUIOMMU_CAP_DIRTYPTE dirty 位遍历页表,搬位并清位
ARM SMMUv3视 HTTU 而定PTE dirty/access 位扫页表 + TLBI
其它老旧驱动通常不支持无dirty_ops 为空

5. 实操心得与问题排查

5.1 开启脏页跟踪后性能下降明显

第一次在真实设备上打开 DIRTY_TRACKING 之后,我注意到持续 DMA 写入的场景下吞吐有可感知的下降。原因是每次 DMA 写都可能涉及页表 dirty 位的维护,而且热迁移过程中还要周期性地扫描页表、清 dirty 位、做 TLB 失效。这个开销是硬性的,不可能完全消除。

所以实操上我的建议是:只有在确实要做迁移时才开 dirty tracking,虚机正常运行时不要一直开着。如果业务上确实需要长期开启(比如做持续容灾备份),那就接受性能代价,并且把脏页读取间隔调到业务能接受的频率。不要每 10ms 读一次位图,那等于把 CPU 全花在页表扫描上了。

另外,如果你发现某次读取脏页位图时耗时特别长,多半和 huge page 映射状况有关。检查一下 iova 范围内是否有大页映射,如果大量 4K 映射,位图遍历的页表项数量就会暴涨。

5.2 bitmap 长度计算错了导致读取截断

这是我见过最多的使用错误。用户态把bitmap_size传小了,或者iova/length没有按页对齐,内核返回 -EINVAL。更隐蔽的是:用户态按 4K 粒度分配了位图,但创建 hwpt 时由于底层 IOMMU 的pgsize_bitmap最小粒度是 8K 或者 64K(有些平台默认页就是 64K),导致实际的位图每一位对应的内存大小比你预期的大,算出来的位图大小直接就不对。

碰到这类问题,先打印pgsize_bitmap和iommu_domain的granule_shift,确认你算位图长度用的粒度是不是和内核一致。不要假设一定是 4K,这对 ARM 平台特别重要,很多 ARM 平台的默认 IOMMU page size 就是 64K。

我在调试时一般会写一个小工具,先查询能力,再手动构造一小段 iova 映射,DMA 写几个页,然后读位图,验证每一位的对应关系。先把最小的 case 跑通,再上大内存迁移,能省很多排查时间。

5.3 设备未完全停止就取脏页,最后一批数据不一致

热迁移的脏页读取是有阶段的。迁移初期可以边跑边同步脏页,但最后收尾时,必须先让设备进入 quiesce(静止)状态,再取最后一轮脏页。原因很简单:设备还在 DMA 写入的时候,你永远不知道当前这次的位图是不是“最终状态”。

我在一次自研迁移工具里犯过这个错:最后一轮取脏页时设备还在跑,位图拿回来了,同步也做完了,但迁移完成后目标端设备一恢复,发现有一小块内存数据不对。排查到最后,发现就是设备在“取完位图之后、设备真正停下之前”又写了一个页。这个页既不在最后一轮位图里,也没有被后续流程兜住。

正确流程应该是:迁移工具发出设备暂停请求,确认设备已经进入静止状态,然后做最后一轮脏页读取和同步。如果设备驱动支持,最好再配合 NO_CLEAR 读一次做校验,确保硬件侧已经没有新 dirty 位了。这个步骤不能省。

5.4 常见问题速查表

问题现象可能原因排查方向
IOMMU_HWPT_ALLOC 返回 EOPNOTSUPP底层 IOMMU 驱动没注册 dirty_ops用 IOMMU_GET_HW_INFO 查 IOMMU_CAP_DIRTY
GET_DIRTY_BITMAP 返回 EINVALiova/length 未对齐或 bitmap_size 不足打印 pgsize_bitmap,按最小页粒度重新计算
读到的位图全是 0脏页跟踪没真正开启,或设备从未 DMA 写目标范围确认 hwpt 创建时带 DIRTY_TRACKING 标志
迁移完成后目标端数据不一致最后一轮取脏页前设备未静止先进 quiesce,再读位图
开启跟踪后性能骤降脏页维护开销 + 位图读取频率过高降低读取频率,考虑 hugepages=1

最后分享一个我实际调过的 case:QEMU 侧做 VFIO migration 时,有一版内核iommu_domain_dirty_bitmap()对 iova 范围合法性检查比较严格,用户态传来的 length 跨了多个已经不存在的映射区域,结果返回了错误。那个问题查了很久,最后发现是用户态在调用前没有做 ioas 映射的连通性检查,传了一个“洞”进去。如果你也遇到类似问题,先确认你查询的 iova 范围在 ioas 里是完整映射的,而不是中间有空洞,这个前提一旦不满足,内核的行为就可能和你预期不一致。

脏页跟踪这块的水很深,但把 iommu_dirty_ops 的职责、双缓冲的语义、以及 uapi 的长度计算规则吃透之后,后面做迁移工具就顺手多了。下一篇我准备写 IOMMUFD 的 page table mapp/unmap 的并发处理,那个方向也是坑不少。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询