本文属于第二册"IOMMU:地址翻译与隔离的硬件机制及内核管理",回答第 0 篇提出的问题 7:IOMMU 地址翻译存在哪些隐性开销?频繁的 map/unmap 操作为何会成为性能瓶颈?
第 6 篇说明了 IOMMU 如何以地址翻译统一解决物理连续性、寻址范围与设备隔离三个问题。这些能力并非没有代价:每次映射都需要分配 IOVA 并更新 I/O 页表,每次解除映射都需要使缓存的翻译结果失效,设备的每次访问也依赖翻译缓存的命中。本文按一次流式映射的生命周期,依次分析设备访问时的翻译开销、建立映射时的 IOVA 分配与页表更新开销、解除映射时的 IOTLB 失效开销,然后说明内核以批量失效降低开销的做法及其代价,最后归纳降低开销的途径。
1. 问题回顾
第 6 篇第 4.1 节给出了 IOMMU 路径下建立映射的两个步骤:先在设备的寻址范围内分配一段 IOVA,再调用 iommu_map() 将其映射到缓冲区所在的物理页。解除映射则按相反的顺序进行。
第 0 篇描述的网卡接收场景中,驱动为每个接收缓冲区建立流式映射,数据到达后解除映射并将缓冲区交给协议栈,再为新的缓冲区建立映射。流式映射是指为驱动已持有的缓冲区建立映射,在一次 DMA 传输完成后即解除映射的方式。第 0 篇第 2.8 节已提出这一概念,它与一致性映射的区别将在第 8 篇讨论;本篇关注的是其中与开销相关的特点,即映射的建立与解除随每次 I/O 发生。以 100 Gb/s 的网卡接收 1500 字节的帧为例,线路上每秒约有 813 万帧,平均每帧约 123 ns。即使收包工作分散到多个 CPU 上,每个 CPU 留给一次映射与解除映射的时间也相当有限。
这些开销分布在 DMA 路径上的多个硬件单元中。在逐一分析之前,先将前几篇涉及的硬件单元汇总在一起:CPU 及其 cache、内存控制器与内存、片上互连与一致性控制点、Root Complex 中的 IOMMU 及其各级翻译缓存,以及网卡中的门铃寄存器、DMA 引擎与设备 TLB,它们之间的包含关系与交互路径如图 1 所示。图中编号标出了一次接收及其后解除映射所经过的五条路径,本篇讨论的开销集中在路径③与路径⑤上,即 IOMMU 的翻译缓存与内存中的 I/O 页表、失效命令队列之间。

图 1:一次接收及其后解除映射所经过的五条路径
图 2 给出了一次流式映射的完整生命周期,并标出了可能成为性能瓶颈的步骤。以下各节分别讨论设备访问、建立映射与解除映射三个阶段的开销。

图 2:一次流式映射的完整生命周期
2. 设备访问的开销:IOTLB 与页表遍历
2.1 IOTLB 与页表缓存
第 6 篇第 3.3 节指出,以 4 KB 页、48 位 IOVA 为例,一次完整的翻译需要遍历 4 级页表,即读取 4 次内存;若设备表项也未被缓存,还需要先读取根表与上下文表。设备的每次读写请求都执行一遍这一过程是不可接受的,因此 IOMMU 设置了以下几类缓存:
- IOTLB:缓存 IOVA 到物理地址的最终翻译结果及访问权限。VT-d 的 IOTLB 项以 domain ID 与 IOVA 为标签,同一 IOMMU 下的所有设备共享这一缓存;
- 页表缓存:缓存中间各级页表项,未命中 IOTLB 时可跳过上层页表,只读取最后一级或几级;
- 设备表缓存:缓存设备表项,VT-d 称为上下文缓存(context cache),避免每次请求都读取根表与上下文表。
IOTLB 命中时,翻译在 IOMMU 内部完成,开销很小;未命中时,IOMMU 须读取内存中的页表,请求的延迟随之增加。页表的读取经由内存控制器完成,与 CPU 和其他设备的访存竞争带宽。
2.2 访问模式与 IOTLB 的覆盖范围
IOTLB 的容量有限,具体数值取决于硬件实现。IOTLB 能否命中,取决于设备访问的地址范围与 IOTLB 能够覆盖的范围之比。以 4 KB 页映射时,每个 IOTLB 项只覆盖 4 KB。网卡的接收环中通常有数千个缓冲区,每个缓冲区各占一个或数个页,且分布在不连续的 IOVA 上;设备在这些缓冲区之间轮转访问时,IOTLB 难以保留全部翻译结果,未命中的比例随之上升。
提高覆盖范围的直接方法是使用大页:一个 2 MB 的页表项只占用一个 IOTLB 项,覆盖范围是 4 KB 页的 512 倍,页表遍历也少一级。内核在建立映射时,会根据地址的对齐情况与硬件支持的页大小,为每一段选择尽可能大的页(drivers/iommu/iommu.c,节选):
c
static size_t iommu_pgsize(struct iommu_domain *domain, unsigned long iova,
phys_addr_t paddr, size_t size, size_t *count)
{
...
unsigned long addr_merge = paddr | iova;
/* Page sizes supported by the hardware and small enough for @size */
pgsizes = domain->pgsize_bitmap & GENMASK(__fls(size), 0);
/* Constrain the page sizes further based on the maximum alignment */
if (likely(addr_merge))
pgsizes &= GENMASK(__ffs(addr_merge), 0);
...
/* Pick the biggest page size remaining */
pgsize_idx = __fls(pgsizes);
pgsize = BIT(pgsize_idx);
pgsize_bitmap 记录硬件支持的页大小。只有当映射的长度足够大,且 IOVA 与物理地址都按大页对齐时,才能使用大页。流式映射的缓冲区通常只有一个或几个页,物理地址也不保证按 2 MB 对齐,因此大页主要用于大块的物理连续内存,例如大尺寸的一致性缓冲区,以及虚拟化场景中以大页为后备的客户机内存(第 15 篇)。
2.3 设备侧的翻译缓存
PCIe 定义了地址翻译服务(ATS):设备可以向 IOMMU 请求翻译,并将结果缓存在设备自身的地址翻译缓存(ATC,内核中也称为设备 TLB)中,此后发出的请求直接携带物理地址,IOMMU 不再翻译。第 6 篇第 3.2 节提到的 VT-d 上下文表项翻译类型 CONTEXT_TT_DEV_IOTLB 即表示允许设备使用 ATS。内核在设备支持时通过 pci_enable_ats() 启用这一功能。
ATS 将一部分翻译工作转移到设备上,减轻了 IOMMU 中 IOTLB 的压力,代价是翻译结果多了一份副本:解除映射时,除 IOMMU 内部的缓存外,还须使设备中的缓存项失效,而这需要经由 PCIe 向设备发送失效请求并等待设备完成,开销明显增加(第 4.2 节)。ATS 与 PASID、设备缺页的关系将在第 20、21 篇讨论。
3. 建立映射的开销
3.1 IOVA 分配:红黑树与全局锁
IOVA 分配器以 struct iova_domain 管理一个 domain 的 IOVA 空间,已分配的区间按地址组织在一棵红黑树中(include/linux/iova.h,节选):
c
struct iova_domain {
spinlock_t iova_rbtree_lock; /* Lock to protect update of rbtree */
struct rb_root rbroot; /* iova domain rbtree root */
struct rb_node *cached_node; /* Save last alloced node */
struct rb_node *cached32_node; /* Save last 32-bit alloced node */
...
unsigned long max32_alloc_size; /* Size of last failed allocation */
...
};
分配一段 IOVA 的过程是在树中寻找一个足够大的空闲区间(drivers/iommu/iova.c,节选):
c
if (size_aligned)
align_mask <<= fls_long(size - 1);
/* Walk the tree backwards */
spin_lock_irqsave(&iovad->iova_rbtree_lock, flags);
if (limit_pfn <= iovad->dma_32bit_pfn &&
size >= iovad->max32_alloc_size)
goto iova32_full;
curr = __get_cached_rbnode(iovad, limit_pfn);
curr_iova = to_iova(curr);
retry_pfn = curr_iova->pfn_hi;
retry:
do {
high_pfn = min(high_pfn, curr_iova->pfn_lo);
new_pfn = (high_pfn - size) & align_mask;
prev = curr;
curr = rb_prev(curr);
curr_iova = to_iova(curr);
} while (curr && new_pfn <= curr_iova->pfn_hi && new_pfn >= low_pfn);
分配从地址上限开始,沿红黑树向低地址方向逐个检查已分配区间之间的空隙,直至找到能容纳请求的位置。cached_node 记录上一次分配的位置,使下一次查找不必每次从树的最高处开始;max32_alloc_size 记录 32 位范围内上一次失败的大小,使同样大小或更大的请求直接失败,而不必遍历整棵树。
这一实现存在两方面的开销。其一,空闲区间分散时,查找需要经过较多节点。其二,整个查找与插入过程持有 iova_rbtree_lock,而这把锁由 domain 内所有 CPU 共用。多个 CPU 同时为同一设备的不同队列建立映射时,会在这把锁上相互等待。
3.2 IOVA 缓存:每 CPU 的 magazine
流式映射的请求大多具有两个特点:长度较小,并且同样大小的请求反复出现。针对这一特点,内核在红黑树之上增加了按大小分类的 IOVA 缓存,其设计借鉴了 slab 分配器的 magazine 结构(drivers/iommu/iova.c,节选):
c
/*
* Magazine caches for IOVA ranges. For an introduction to magazines,
* see the USENIX 2001 paper "Magazines and Vmem: Extending the Slab
* Allocator to Many CPUs and Arbitrary Resources" by Bonwick and Adams.
* For simplicity, we use a static magazine size and don't implement the
* dynamic size tuning described in the paper.
*/
/*
* As kmalloc's buffer size is fixed to power of 2, 127 is chosen to
* assure size of 'iova_magazine' to be 1024 bytes, so that no memory
* will be wasted. Since only full magazines are inserted into the depot,
* we don't need to waste PFN capacity on a separate list head either.
*/
#define IOVA_MAG_SIZE 127
#define IOVA_DEPOT_DELAY msecs_to_jiffies(100)
struct iova_magazine {
union {
unsigned long size;
struct iova_magazine *next;
};
unsigned long pfns[IOVA_MAG_SIZE];
};
struct iova_cpu_rcache {
spinlock_t lock;
struct iova_magazine *loaded;
struct iova_magazine *prev;
};
一个 magazine 是一个最多存放 127 个 IOVA 的数组。每个 CPU 持有两个 magazine:loaded 为当前使用的一个,prev 为备用的一个。各 CPU 之间另有一个共享的 depot,存放已满的 magazine。图 3 展示了这三层结构及 IOVA 在其间的流动。

图 3:IOVA 缓存
释放 IOVA 时,内核首先尝试将其放入当前 CPU 的缓存(drivers/iommu/iova.c,节选):
c
cpu_rcache = raw_cpu_ptr(rcache->cpu_rcaches);
spin_lock_irqsave(&cpu_rcache->lock, flags);
if (!iova_magazine_full(cpu_rcache->loaded)) {
can_insert = true;
} else if (!iova_magazine_full(cpu_rcache->prev)) {
swap(cpu_rcache->prev, cpu_rcache->loaded);
can_insert = true;
} else {
struct iova_magazine *new_mag = iova_magazine_alloc(GFP_ATOMIC);
if (new_mag) {
spin_lock(&rcache->lock);
iova_depot_push(rcache, cpu_rcache->loaded);
spin_unlock(&rcache->lock);
schedule_delayed_work(&rcache->work, IOVA_DEPOT_DELAY);
cpu_rcache->loaded = new_mag;
can_insert = true;
}
}
if (can_insert)
iova_magazine_push(cpu_rcache->loaded, iova_pfn);
分配的过程与之对称:先从 loaded 中取出,为空时与 prev 交换,二者均空时从 depot 换入一个已满的 magazine。每 CPU 缓存只需持有本 CPU 的锁,不同 CPU 之间互不干扰;只有在两个 magazine 均满或均空时才访问 depot,由于每个 magazine 可容纳 127 个 IOVA,访问 depot 的频率远低于分配与释放的频率。depot 中的 magazine 多于在线 CPU 数时,后台工作每 100 ms 将其中一个 magazine 中的 IOVA 归还红黑树,避免缓存长期占用 IOVA 空间。
缓存按大小分类,每一类只存放相同大小的 IOVA。为了使不同长度的请求能够共用缓存,分配时会将长度向上取整为 2 的幂(drivers/iommu/iova.c,节选):
c
/*
* Freeing non-power-of-two-sized allocations back into the IOVA caches
* will come back to bite us badly, so we have to waste a bit of space
* rounding up anything cacheable to make sure that can't happen. The
* order of the unadjusted size will still match upon freeing.
*/
if (size < (1 << (IOVA_RANGE_CACHE_MAX_SIZE - 1)))
size = roundup_pow_of_two(size);
iova_pfn = iova_rcache_get(iovad, size, limit_pfn + 1);
if (iova_pfn)
return iova_pfn;
retry:
new_iova = alloc_iova(iovad, size, limit_pfn, true);
if (!new_iova) {
unsigned int cpu;
if (!flush_rcache)
return 0;
/* Try replenishing IOVAs by flushing rcache. */
flush_rcache = false;
for_each_online_cpu(cpu)
free_cpu_cached_iovas(cpu, iovad);
free_global_cached_iovas(iovad);
goto retry;
}
IOVA_RANGE_CACHE_MAX_SIZE 为 6,即缓存分为 1、2、4、8、16、32 页共六类,以 4 KB 页计,128 KB 以内的请求可以使用缓存,更大的请求直接在红黑树中分配。这一设计以空间换取时间:取整浪费了一部分 IOVA 空间,缓存中的 IOVA 也不能被其他大小的请求使用。当红黑树中找不到空闲区间时,内核将所有 CPU 缓存与 depot 中的 IOVA 归还红黑树后再重试一次,这一过程需要遍历所有 CPU,开销较大,但只在 IOVA 空间紧张时发生。
3.3 页表更新
分配到 IOVA 后,iommu_map() 将其写入 I/O 页表(drivers/iommu/iommu.c,节选):
c
int iommu_map(struct iommu_domain *domain, unsigned long iova,
phys_addr_t paddr, size_t size, int prot, gfp_t gfp)
{
int ret;
ret = iommu_map_nosync(domain, iova, paddr, size, prot, gfp);
if (ret)
return ret;
ret = iommu_sync_map(domain, iova, size);
if (ret)
iommu_unmap(domain, iova, size);
return ret;
}
iommu_map_nosync() 将映射按第 2.2 节所述的规则拆分为若干段,逐段调用 IOMMU 驱动的 map_pages 回调写入页表项。这一步骤的开销主要来自以下三个方面。
第一,若目标位置所需的中间级页表尚不存在,需要先分配页表页。第 6 篇第 4.1 节的代码以 GFP_ATOMIC 调用 iommu_map(),因为流式映射可能在不能睡眠的上下文中执行,页表页的分配因此可能失败,此时映射失败,驱动收到 DMA_MAPPING_ERROR。
第二,若 IOMMU 读取页表时不侦听 CPU cache,CPU 写入的页表项可能仍停留在 cache 中,IOMMU 读到的是内存中的旧值。此时每次写入页表项后都须将相应的 cache 行写回内存。内核的通用页表实现以一个特性位描述这一要求(include/linux/generic_pt/common.h,节选):
c
/**
* @PT_FEAT_DMA_INCOHERENT: Cache flush page table memory before
* assuming the HW can read it. Otherwise a SMP release is sufficient
* for HW to read it.
*/
PT_FEAT_DMA_INCOHERENT,
这与第 3 篇讨论的 cache 一致性问题相同,只是此处的"设备"是 IOMMU 本身,被访问的数据是页表。
第三,在一般情况下,新建的映射不需要失效任何缓存,因为页表项此前不存在,缓存中也就不会有对应的项。iommu_sync_map() 只在硬件有特殊要求时执行操作。VT-d 驱动在两种情况下需要在建立映射后刷新缓存(drivers/iommu/intel/iommu.c,节选):
c
if (rwbf_required(iommu) || cap_caching_mode(iommu->cap))
dmar_domain->iotlb_sync_map = true;
其中 RWBF 表示硬件要求刷新内部写缓冲区;Caching Mode 表示硬件可能缓存无效的页表项,常见于虚拟机内由虚拟化软件模拟的 IOMMU。在后一种情况下,每次建立映射都要提交失效命令,而虚拟机内的失效命令会被虚拟化软件截获处理,开销显著增加。
4. 解除映射的开销
4.1 解除映射必须使缓存失效
解除映射时,清除页表项只完成了一半工作:IOTLB、页表缓存以及设备 TLB 中可能仍保存着该 IOVA 的翻译结果,设备仍能经由这些缓存项访问原来的物理页。如果此时将该物理页交还给内核另作他用,或者将该 IOVA 分配给新的缓冲区,设备的访问就可能落在不属于它的内存上。因此,解除映射必须按以下顺序进行:
- 清除页表项;
- 使相关的缓存项失效,并等待失效完成;
- 释放 IOVA,以及解除映射过程中不再使用的页表页。
内核以 struct iommu_iotlb_gather 在清除页表项的过程中收集待失效的范围与待释放的页表页(include/linux/iommu.h,节选):
c
/**
* struct iommu_iotlb_gather - Range information for a pending IOTLB flush
*
* @start: IOVA representing the start of the range to be flushed
* @end: IOVA representing the end of the range to be flushed (inclusive)
* @pgsize: The interval at which to perform the flush
* @freelist: Removed pages to free after sync
* @queued: Indicates that the flush will be queued
*
* This structure is intended to be updated by multiple calls to the
* ->unmap() function in struct iommu_ops before eventually being passed
* into ->iotlb_sync(). Drivers can add pages to @freelist to be freed after
* ->iotlb_sync() or ->iotlb_flush_all() have cleared all cached references to
* them. @queued is set to indicate when ->iotlb_flush_all() will be called
* later instead of ->iotlb_sync(), so drivers may optimise accordingly.
*/
struct iommu_iotlb_gather {
unsigned long start;
unsigned long end;
size_t pgsize;
struct iommu_pages_list freelist;
bool queued;
};
注释明确了页表页的释放时机:必须在失效操作清除了所有对它们的缓存引用之后。DMA 映射层的解除映射函数按上述顺序组织(drivers/iommu/dma-iommu.c,节选):
c
iommu_iotlb_gather_init(&iotlb_gather);
iotlb_gather.queued = READ_ONCE(cookie->fq_domain);
unmapped = iommu_unmap_fast(domain, dma_addr, size, &iotlb_gather);
WARN_ON(unmapped != size);
if (!iotlb_gather.queued)
iommu_iotlb_sync(domain, &iotlb_gather);
iommu_dma_free_iova(domain, dma_addr, size, &iotlb_gather);
iommu_unmap_fast() 清除页表项并填写 iotlb_gather;iommu_iotlb_sync() 调用 IOMMU 驱动的 iotlb_sync 回调,执行失效并等待完成;最后释放 IOVA。queued 为真时跳过同步失效,这一分支将在第 5 节讨论。
4.2 失效命令的提交与等待
各类 IOMMU 都通过内存中的命令队列接收失效命令:CPU 将命令写入队列,再通知 IOMMU 读取执行。由于命令是异步执行的,CPU 还需要提交一条同步命令,并等待其完成,才能确认之前的失效已经生效。主要实现的对应命令如下:
| 实现 | 命令队列 | IOTLB 失效 | 设备 TLB 失效 | 同步与等待 |
|---|---|---|---|---|
| Intel VT-d | 失效队列(Invalidation Queue) | IOTLB 失效描述符 | 设备 TLB 失效描述符 | 等待描述符 |
| AMD-Vi | 命令缓冲区(Command Buffer) | CMD_INV_IOMMU_PAGES |
CMD_INV_IOTLB_PAGES |
CMD_COMPL_WAIT |
| ARM SMMUv3 | 命令队列(Command Queue) | CMDQ_OP_TLBI_NH_VA 等 |
CMDQ_OP_ATC_INV |
CMDQ_OP_CMD_SYNC |
以 VT-d 为例,失效命令由 qi_submit_sync() 提交(drivers/iommu/intel/dmar.c,节选):
c
/*
* Function to submit invalidation descriptors of all types to the queued
* invalidation interface(QI). Multiple descriptors can be submitted at a
* time, a wait descriptor will be appended to each submission to ensure
* hardware has completed the invalidation before return. Wait descriptors
* can be part of the submission but it will not be polled for completion.
*/
int qi_submit_sync(struct intel_iommu *iommu, struct qi_desc *desc,
unsigned int count, unsigned long options)
{
...
raw_spin_lock_irqsave(&qi->q_lock, flags);
...
wait_desc.qw0 = QI_IWD_STATUS_DATA(QI_DONE) |
QI_IWD_STATUS_WRITE | QI_IWD_TYPE;
...
wait_desc.qw1 = virt_to_phys(&qi->desc_status[wait_index]);
...
/*
* update the HW tail register indicating the presence of
* new descriptors.
*/
writel(qi->free_head << shift, iommu->reg + DMAR_IQT_REG);
while (READ_ONCE(qi->desc_status[wait_index]) != QI_DONE) {
/*
* We will leave the interrupts disabled, to prevent interrupt
* context to queue another cmd while a cmd is already submitted
* and waiting for completion on this cpu. This is to avoid
* a deadlock where the interrupt context can wait indefinitely
* for free slots in the queue.
*/
rc = qi_check_fault(iommu, index, wait_index);
if (rc)
break;
raw_spin_unlock(&qi->q_lock);
cpu_relax();
raw_spin_lock(&qi->q_lock);
}
图 4 展示了这一过程。CPU 将失效描述符与一个等待描述符写入环形队列,更新尾指针寄存器;IOMMU 依次执行描述符,执行到等待描述符时,向内存中的状态字写入 QI_DONE;CPU 在此期间关闭中断,反复读取该状态字。

图 4:失效命令的提交与等待过程
这一过程的开销体现在以下几个方面:
- CPU 同步等待 :失效命令的执行时间完全计入
dma_unmap_*()的调用者,CPU 在关中断的状态下轮询,期间不能做其他工作; - 跨 CPU 串行化:每个 IOMMU 实例只有一个失效队列,所有 CPU 提交的命令都由同一个硬件单元依次执行,并且提交时须获取同一把队列锁。设备的多个队列分布在多个 CPU 上时,各 CPU 的解除映射操作在这里汇合;
- 设备 TLB 的往返:设备启用了 ATS 时,失效请求须经 PCIe 发往设备,设备完成后再返回完成消息,等待时间包含一次完整的 PCIe 往返以及设备内部的处理时间。
失效的范围也影响开销。VT-d 支持按页范围失效(PSI)与按 domain 整体失效(DSI),硬件不支持按页失效或范围过大时,驱动退化为整体失效(drivers/iommu/intel/cache.c,节选):
c
/*
* Fallback to domain selective flush if no PSI support or the size
* is too big.
*/
if (!cap_pgsel_inv(iommu->cap) ||
mask > cap_max_amask_val(iommu->cap)) {
addr = 0;
mask = 0;
ih = 0;
type = DMA_TLB_DSI_FLUSH;
}
整体失效一次清除该 domain 的全部缓存项,此后设备对其他仍然有效的映射的访问也会因 IOTLB 未命中而重新遍历页表。其中的 ih 为失效提示位,VT-d 驱动在没有页表页被释放时设置该位,告知硬件只有最后一级页表项发生了变化,页表缓存中的中间级页表项不必失效(drivers/iommu/intel/iommu.c,节选):
c
static void intel_iommu_tlb_sync(struct iommu_domain *domain,
struct iommu_iotlb_gather *gather)
{
cache_tag_flush_range(to_dmar_domain(domain), gather->start,
gather->end,
iommu_pages_list_empty(&gather->freelist));
iommu_put_pages_list(&gather->freelist);
}
综合第 3、4 节可以看到,频繁的映射与解除映射成为瓶颈,主要原因不在于翻译本身,而在于每次解除映射都包含一次 CPU 与 IOMMU 之间的同步,并且这一同步在所有 CPU 之间是串行的。
5. 批量失效:flush queue
5.1 思路
第 4.1 节列出的顺序中,失效必须先于 IOVA 的释放,但并不要求每次解除映射都立即失效。只要被解除映射的 IOVA 在失效完成之前不被重新分配,新的映射就不会命中旧的缓存项。基于这一点,内核可以先将待释放的 IOVA 放入队列,积累到一定数量后执行一次覆盖整个 domain 的失效,再将队列中的 IOVA 一并释放。这样,多次解除映射只需要一次 CPU 与 IOMMU 之间的同步。
这一做法对应第 6 篇第 5.1 节提到的 IOMMU_DOMAIN_DMA_FQ 类型的 domain,其中 FQ 即 flush queue。第 4.1 节代码中的 iotlb_gather.queued 在 domain 启用了 flush queue 时为真,此时跳过同步失效,iommu_dma_free_iova() 将 IOVA 交给 queue_iova() 而非直接释放。
5.2 实现
flush queue 的参数如下(drivers/iommu/dma-iommu.c,节选):
c
/* Number of entries per flush queue */
#define IOVA_DEFAULT_FQ_SIZE 256
#define IOVA_SINGLE_FQ_SIZE 32768
/* Timeout (in ms) after which entries are flushed from the queue */
#define IOVA_DEFAULT_FQ_TIMEOUT 10
#define IOVA_SINGLE_FQ_TIMEOUT 1000
/* Flush queue entry for deferred flushing */
struct iova_fq_entry {
unsigned long iova_pfn;
unsigned long pages;
struct iommu_pages_list freelist;
u64 counter; /* Flush counter when this entry was added */
};
默认情况下,每个 CPU 拥有一个容量为 256 项的队列,定时器周期为 10 ms。若 IOMMU 的失效操作被用于同步影子页表,内核改用一个容量为 32768 项的全局队列与 1000 ms 的定时器,以进一步减少失效次数。每一项记录待释放的 IOVA 区间、待释放的页表页,以及入队时的失效计数。入队过程如下(drivers/iommu/dma-iommu.c,节选):
c
if (cookie->options.qt == IOMMU_DMA_OPTS_SINGLE_QUEUE)
fq = cookie->single_fq;
else
fq = raw_cpu_ptr(cookie->percpu_fq);
spin_lock_irqsave(&fq->lock, flags);
/*
* First remove all entries from the flush queue that have already been
* flushed out on another CPU. This makes the fq_full() check below less
* likely to be true.
*/
fq_ring_free_locked(cookie, fq);
if (fq_full(fq)) {
fq_flush_iotlb(cookie);
fq_ring_free_locked(cookie, fq);
}
idx = fq_ring_add(fq);
fq->entries[idx].iova_pfn = pfn;
fq->entries[idx].pages = pages;
fq->entries[idx].counter = atomic64_read(&cookie->fq_flush_start_cnt);
iommu_pages_list_splice(freelist, &fq->entries[idx].freelist);
spin_unlock_irqrestore(&fq->lock, flags);
/* Avoid false sharing as much as possible. */
if (!atomic_read(&cookie->fq_timer_on) &&
!atomic_xchg(&cookie->fq_timer_on, 1))
mod_timer(&cookie->fq_timer,
jiffies + msecs_to_jiffies(cookie->options.fq_timeout));
失效由 fq_flush_iotlb() 执行,它在调用 IOMMU 驱动的 flush_iotlb_all 回调前后分别递增两个计数:
c
atomic64_inc(&cookie->fq_flush_start_cnt);
cookie->fq_domain->ops->flush_iotlb_all(cookie->fq_domain);
atomic64_inc(&cookie->fq_flush_finish_cnt);
一个队列项入队时记录了当时的 fq_flush_start_cnt,只有当 fq_flush_finish_cnt 超过这一记录值时,才能确认入队之后至少完成了一次完整的失效,该项的 IOVA 与页表页才可以释放(fq_ring_free_locked())。借助这两个计数,任何一个 CPU 触发的失效都可以使所有 CPU 队列中的早期项得到释放。失效在两种情况下触发:当前 CPU 的队列已满;或者定时器到期,此时定时器回调对整个 domain 执行一次失效,并清理所有 CPU 的队列。
图 5 对比了严格模式与延迟模式的时序。严格模式下,每次解除映射都包含一次失效并等待;延迟模式下,解除映射只需清除页表项并入队,多次解除映射共用一次全局失效。

图 5:严格模式与延迟模式的时序对比
5.3 代价:隔离窗口
延迟模式减少了失效次数,但也延后了失效生效的时间。从解除映射到失效完成之间,IOVA 虽然不会被重新分配,但对应的物理页已经交还给驱动,可能被释放并分配给其他用途。在此期间,设备若仍持有该 IOVA,就可能经由 IOTLB 中尚未失效的缓存项读写这块已被他用的内存。按默认参数,这一窗口最长约为定时器周期 10 ms。第 6 篇第 6.2 节列出的"解除映射后,IOTLB 中的缓存项在失效之前仍可能被设备使用"即指这一情形。
内核配置选项的说明直接给出了这一权衡(drivers/iommu/Kconfig,节选):
config IOMMU_DEFAULT_DMA_LAZY
bool "Translated - Lazy"
help
Trusted devices use translation to restrict their access to only
DMA-mapped pages, but with "lazy" batched TLB invalidation. This
mode allows higher performance with some IOMMUs due to reduced TLB
flushing, but at the cost of reduced isolation since devices may be
able to access memory for some time after it has been unmapped.
Equivalent to passing "iommu.passthrough=0 iommu.strict=0" on the
command line.
与之对应,严格模式的说明指出,不可信设备总是使用严格模式,并且额外使用反弹缓冲(第 6 篇第 4.3 节)。两种模式的选择方式如下:
- 编译时默认值:x86 与 s390 默认为延迟模式,其他架构默认为严格模式;
- 启动参数 :
iommu.strict=1选择严格模式,iommu.strict=0选择延迟模式;若 IOMMU 驱动不支持延迟失效,则回退到严格模式; - 运行时 :
/sys/kernel/iommu_groups/<n>/type显示DMA或DMA-FQ。修改 group 的默认 domain 类型一般需要先解除设备与驱动的绑定,从DMA改为DMA-FQ是例外。
6. 降低开销的途径
前几节分析的开销来源及相应的缓解手段可归纳如下:
| 开销来源 | 所在阶段 | 内核或驱动的缓解手段 |
|---|---|---|
| IOTLB 未命中与页表遍历 | 设备访问 | 使用大页;设备侧缓存(ATS) |
| IOVA 分配的锁竞争与树查找 | 建立映射 | 每 CPU 的 IOVA 缓存 |
| 页表页分配与 cache 刷写 | 建立映射 | 复用已有的中间级页表;硬件支持一致的页表访问 |
| 失效命令的同步等待 | 解除映射 | 批量失效(DMA_FQ 类型的 domain) |
| 映射与解除映射的次数 | 全过程 | 复用映射;合并分散的缓冲区 |
其中,内核内部的优化已经默认生效,驱动可以进一步从以下几个方面减少开销。
复用映射。 最直接的方法是减少映射与解除映射的次数。网络协议栈的 page_pool 是这一方法的典型实现(include/net/page_pool/types.h,节选):
c
#define PP_FLAG_DMA_MAP BIT(0) /* Should page_pool do the DMA
* map/unmap
*/
驱动创建 page_pool 时指定这一标志后,page_pool 在页首次从页分配器进入池中时建立映射,在页离开池、归还页分配器时才解除映射(net/core/page_pool.c 中的 page_pool_dma_map() 与 __page_pool_release_netmem_dma())。协议栈处理完数据后,页回到池中,供网卡再次接收使用,其映射保持不变。这样,大多数收包操作不再经过 IOVA 分配与 IOTLB 失效,只需要处理 cache 一致性,这部分将在第 9 篇讨论。
使用长期存在的缓冲区。 描述符环等在设备工作期间一直使用的数据结构,应在初始化时一次性分配并映射,而不是随每次 I/O 映射。内核为此提供了专门的一致性映射接口,这正是问题 8 要讨论的内容。
合并分散的缓冲区。 第 6 篇第 4.2 节指出,iommu_dma_map_sg() 为整个分散聚集列表分配一段连续的 IOVA,解除映射时也只需一次失效。将多个缓冲区组织为一个列表一并映射,比逐个映射的开销更低。分散聚集映射将在第 10 篇讨论。
放弃翻译。 将设备挂接到 IDENTITY 类型的 domain(iommu.passthrough=1 或 iommu=pt),可以消除上述全部开销,但同时也放弃了 IOMMU 提供的设备隔离:DMA 映射回到直接映射路径(第 6 篇第 5.1 节),寻址范围问题重新由第 5 篇所述的 SWIOTLB 处理。这一选择只适用于设备与驱动均可信、且不需要 IOMMU 提供隔离的场合。
7. 常见误区
7.1 认为 IOMMU 的开销主要在于地址翻译本身
IOTLB 命中时,翻译在 IOMMU 内部完成,对单次访问的延迟影响很小。在逐次映射与解除映射的场景中,开销往往集中在解除映射的同步失效上:CPU 必须等待 IOMMU 执行完失效命令,并且所有 CPU 的失效命令都汇集到同一个 IOMMU 的命令队列中。分析此类性能问题时,应首先检查 group 的 type 属性是 DMA 还是 DMA-FQ,以及 dma_unmap_*() 在 CPU 时间中所占的比例,而不是只关注设备访问的延迟。
7.2 认为延迟失效只是性能选项,不影响安全
延迟模式下,已解除映射的页在一段时间内仍可能被设备访问,这段时间内该页可能已经分配给内核的其他部分。对于可信设备,这一窗口通常不会被利用;但若设备存在缺陷或被恶意控制,这一窗口即构成可利用的攻击面。这也是不可信设备总是使用严格模式的原因。在评估一个系统的 DMA 隔离程度时,应将失效模式与第 6 篇第 6.2 节列出的其他因素一并考虑。
7.3 认为启用直通模式是消除 IOMMU 开销的唯一途径
直通模式同时放弃了隔离与地址重映射能力。在多数场景中,延迟失效、page_pool 等映射复用机制以及分散聚集映射已能将开销降到可接受的水平。只有在确认开销来源且上述手段均不适用之后,才应考虑直通模式,并且需要明确由此放弃了哪些保护。
7.4 认为 IOVA 释放后可以立即被重新使用
无论在哪种模式下,IOVA 都只能在相应的缓存项失效之后才能重新分配。严格模式下,这一顺序由 __iommu_dma_unmap() 中失效先于释放来保证;延迟模式下,由 flush queue 的计数机制保证。IOMMU 驱动或绕过 DMA 映射层直接调用 iommu_map()、iommu_unmap() 的代码,也必须遵守同样的顺序,否则新的映射可能命中旧的缓存项,导致设备访问到错误的物理页。
8. 小结
本文回答了问题 7,要点如下:
- 设备访问的开销来自 IOTLB 未命中。 IOMMU 以 IOTLB、页表缓存与设备表缓存避免每次都遍历页表。IOTLB 容量有限,以 4 KB 页映射大量分散的缓冲区时容易未命中;大页与 ATS 可以提高覆盖范围,但 ATS 增加了失效的成本。
- 建立映射的开销来自 IOVA 分配与页表更新。 IOVA 分配器以红黑树管理地址空间,查找与插入持有 domain 内的全局锁;内核以每 CPU 的 magazine 缓存与共享的 depot 减少锁竞争,代价是长度向上取整为 2 的幂。页表更新可能需要分配页表页,在 IOMMU 不侦听 cache 时还需刷写 cache 行。
- 解除映射的开销来自同步失效。 清除页表项后,必须使 IOTLB、页表缓存与设备 TLB 中的相应项失效并等待完成,才能释放 IOVA 与页表页。失效命令经由每个 IOMMU 唯一的命令队列提交,CPU 同步等待,多个 CPU 之间串行执行,这是频繁映射与解除映射成为瓶颈的主要原因。
- 批量失效以隔离窗口换取性能。 DMA_FQ 类型的 domain 将待释放的 IOVA 放入每 CPU 队列,队列满或定时器到期时执行一次全局失效;IOVA 在失效之前不会被复用,但已解除映射的页在此期间仍可能被设备访问。不可信设备总是使用严格模式。
- 降低开销的根本途径是减少映射次数。 page_pool 等机制使缓冲区的映射在多次 I/O 之间保持不变,分散聚集映射使多个缓冲区共用一次失效。
减少映射次数,就要求驱动区分两类缓冲区:一类在设备工作期间长期存在,应当一次映射、长期使用;另一类随每次 I/O 交给设备,用完即解除映射。内核为这两类缓冲区分别提供了 dma_alloc_coherent() 与 dma_map_single() 等接口,二者在 cache 一致性的处理方式上也截然不同。二者的本质区别是什么,内核为何提供两种映射契约,即为问题 8。