DMA 和 swiotlb

swiotlb 是 Linux 内核 DMA 层使用的内存缓冲区分配器。它通常在执行 DMA 的设备由于硬件限制或其他要求而无法直接访问目标内存缓冲区时使用。在这种情况下,DMA 层会调用 swiotlb 来分配一个符合限制的临时内存缓冲区。DMA 在此临时内存缓冲区上进行,并且 CPU 在临时缓冲区和原始目标内存缓冲区之间复制数据。这种方法通常被称为“反弹缓冲”,临时内存缓冲区称为“反弹缓冲区”。

设备驱动程序不直接与 swiotlb 交互。相反,驱动程序将它们管理的设备的 DMA 属性通知 DMA 层,并在编程设备执行 DMA 时使用正常的 DMA 映射、取消映射和同步 API。这些 API 使用设备 DMA 属性和内核范围设置来确定是否需要反弹缓冲。如果需要,DMA 层会管理反弹缓冲区的分配、释放和同步。由于 DMA 属性是每个设备的,因此系统中某些设备可能使用反弹缓冲,而其他设备则不使用。

由于 CPU 在反弹缓冲区和原始目标内存缓冲区之间复制数据,因此执行反弹缓冲比直接对原始内存缓冲区执行 DMA 慢,并且会消耗更多的 CPU 资源。因此,它仅在提供 DMA 功能必要时才使用。

使用场景

swiotlb 最初创建是为了处理具有寻址限制的设备的 DMA。随着物理内存大小超过 4 GiB,某些设备只能提供 32 位 DMA 地址。通过在 4 GiB 线以下分配反弹缓冲区内存,这些具有寻址限制的设备仍然可以工作并执行 DMA。

最近,机密计算 (CoCo) VM 的来宾 VM 的内存默认情况下已加密,并且主机虚拟机管理程序和 VMM 无法访问该内存。为了让主机代表来宾执行 I/O,I/O 必须定向到未加密的来宾内存。CoCo VM 设置内核范围的选项来强制所有 DMA I/O 使用反弹缓冲区,并且反弹缓冲区内存设置为未加密。主机对反弹缓冲区内存执行 DMA I/O,并且 Linux 内核 DMA 层执行“同步”操作以使 CPU 将数据从/复制到原始目标内存缓冲区。CPU 复制桥接了未加密和加密内存之间的桥梁。这种反弹缓冲区的用法允许设备驱动程序在 CoCo VM 中“正常工作”,无需修改即可处理内存加密的复杂性。

反弹缓冲区还会出现其他边缘情况。例如,当为与“不可信”设备之间进行 DMA 操作设置 IOMMU 映射时,该设备应仅被授予对包含正在传输的数据的内存的访问权限。但是,如果该内存仅占用 IOMMU 粒度的一部分,则粒度的其他部分可能包含不相关的内核数据。由于 IOMMU 访问控制是按粒度进行的,因此不受信任的设备可以访问不相关的内核数据。通过反弹缓冲 DMA 操作并确保反弹缓冲区未使用的部分不包含任何不相关的内核数据,可以解决此问题。

核心功能

主要的 swiotlb API 是 swiotlb_tbl_map_single() 和 swiotlb_tbl_unmap_single()。“map”API 分配指定大小(以字节为单位)的反弹缓冲区,并返回缓冲区的物理地址。缓冲区内存是物理上连续的。期望是 DMA 层将物理内存地址映射到 DMA 地址,并将 DMA 地址返回给驱动程序以编程到设备中。如果 DMA 操作指定多个内存缓冲区段,则必须为每个段分配单独的反弹缓冲区。swiotlb_tbl_map_single() 始终执行“同步”操作(即 CPU 复制)以初始化反弹缓冲区以匹配原始缓冲区的内容。

swiotlb_tbl_unmap_single() 执行相反的操作。如果 DMA 操作可能已更新反弹缓冲区内存,并且未设置 DMA_ATTR_SKIP_CPU_SYNC,则取消映射会执行“同步”操作,以使 CPU 将数据从反弹缓冲区复制回原始缓冲区。然后释放反弹缓冲区内存。

swiotlb 还提供与驱动程序在缓冲区控制在 CPU 和设备之间转换时可能使用的 dma_sync_*() API 相对应的“同步”API。swiotlb “同步”API 导致 CPU 在原始缓冲区和反弹缓冲区之间复制数据。与 dma_sync_*() API 一样,swiotlb “同步”API 支持执行部分同步,其中仅将反弹缓冲区的子集复制到/从原始缓冲区复制。

核心功能约束

swiotlb 映射/取消映射/同步 API 必须在不阻塞的情况下运行,因为它们由可能在无法阻塞的上下文中运行的相应 DMA API 调用。因此,swiotlb 分配的默认内存池必须在启动时预先分配(但请参阅下面的动态 swiotlb)。由于 swiotlb 分配必须在物理上连续,因此整个默认内存池作为单个连续块分配。

预先分配默认 swiotlb 池的需求会产生启动时权衡。该池应该足够大,以确保始终可以满足反弹缓冲区请求,因为非阻塞要求意味着请求无法等待空间可用。但是,一个大型池可能会浪费内存,因为此预分配的内存不可用于系统中的其他用途。这种权衡在为所有 DMA I/O 使用反弹缓冲区的 CoCo VM 中尤为突出。这些 VM 使用启发式方法将默认池大小设置为内存的 ~6%,最大值为 1 GiB,这可能会非常浪费内存。相反,根据 VM 中工作负载的 I/O 模式,启发式方法可能会产生不足的大小。下面描述的动态 swiotlb 功能可以提供帮助,但存在局限性。更好地管理 swiotlb 默认内存池大小仍然是一个悬而未决的问题。

来自 swiotlb 的单个分配限制为 IO_TLB_SIZE * IO_TLB_SEGSIZE 字节,这在当前定义中为 256 KiB。当设备的 DMA 设置使得设备可能使用 swiotlb 时,DMA 段的最大大小必须限制为 256 KiB。此值通过 dma_map_mapping_size() 和 swiotlb_max_mapping_size() 传递给更高级别的内核代码。如果更高级别的代码未能考虑此限制,则可能会发出对于 swiotlb 来说过大的请求,并收到“swiotlb full”错误。

关键设备 DMA 设置是 “min_align_mask”,它是 2 的幂减 1,因此设置了某些低位,或者可能为零。swiotlb 分配确保反弹缓冲区的物理地址的这些 min_align_mask 位与原始缓冲区地址中的相同位匹配。当 min_align_mask 非零时,可能会在反弹缓冲区的地址中产生“对齐偏移”,从而稍微减小分配的最大大小。此潜在的对齐偏移反映在 swiotlb_max_mapping_size() 返回的值中,该值可能会出现在 /sys/block/<device>/queue/max_sectors_kb 等位置。例如,如果设备不使用 swiotlb,则 max_sectors_kb 可能为 512 KiB 或更大。如果设备可能使用 swiotlb,则 max_sectors_kb 将为 256 KiB。当 min_align_mask 非零时,max_sectors_kb 可能会更小,例如 252 KiB。

swiotlb_tbl_map_single() 还采用 “alloc_align_mask” 参数。此参数指定反弹缓冲区空间的分配必须从物理地址开始,并将 alloc_align_mask 位设置为零。但是,如果 min_align_mask 非零,则实际的反弹缓冲区可能会在更大的地址开始。因此,在反弹缓冲区开始之前可能存在预填充空间。同样,反弹缓冲区的末尾向上舍入到 alloc_align_mask 边界,从而可能导致后填充空间。任何预填充或后填充空间都不会由 swiotlb 代码初始化。“alloc_align_mask” 参数由 IOMMU 代码在为不受信任的设备进行映射时使用。它设置为粒度大小 - 1,以便反弹缓冲区完全从未用于任何其他目的的粒度中分配。

数据结构概念

用于 swiotlb 反弹缓冲区的内存是从整个系统内存中分配的一个或多个“池”。默认池在系统启动期间分配,默认大小为 64 MiB。“swiotlb=”内核引导行参数可以修改默认池大小。默认大小也可能因其他条件而调整,例如在 CoCo VM 中运行,如上所述。如果启用了 CONFIG_SWIOTLB_DYNAMIC,则可以在系统生命周期的后期分配额外的池。每个池必须是物理内存的连续范围。默认池分配在 4 GiB 物理地址线以下,因此它适用于只能寻址 32 位物理内存的设备(除非特定于体系结构的代码提供了 SWIOTLB_ANY 标志)。在 CoCo VM 中,必须先解密池内存,然后才能使用 swiotlb。

每个池被划分为大小为 IO_TLB_SIZE 的“槽”,当前定义为 2 KiB。IO_TLB_SEGSIZE 个连续的槽(128 个槽)构成所谓的“槽集”。当分配反弹缓冲区时,它占用一个或多个连续的槽。一个槽永远不会被多个反弹缓冲区共享。此外,反弹缓冲区必须从单个槽集中分配,这导致最大反弹缓冲区大小为 IO_TLB_SIZE * IO_TLB_SEGSIZE。如果可以满足对齐和大小约束,则多个较小的反弹缓冲区可以共存于单个槽集中。

槽也被分组到“区域”中,约束条件是槽集完全存在于单个区域中。每个区域都有自己的自旋锁,必须持有该自旋锁才能操作该区域中的槽。划分成区域避免了在大量使用 swiotlb 时争用单个全局自旋锁,例如在 CoCo VM 中。区域的数量默认为系统中 CPU 的数量,以实现最大并行性,但由于一个区域不能小于 IO_TLB_SEGSIZE 个槽,因此可能需要将多个 CPU 分配到同一个区域。区域的数量也可以通过 “swiotlb=” 内核引导参数设置。

当分配反弹缓冲区时,如果与调用 CPU 关联的区域没有足够的可用空间,则会依次尝试与其他 CPU 关联的区域。对于尝试的每个区域,必须先获得该区域的自旋锁才能尝试分配,因此如果 swiotlb 整体比较繁忙,则可能会发生争用。但是,除非所有区域都没有足够的可用空间,否则分配请求不会失败。

IO_TLB_SIZE、IO_TLB_SEGSIZE 和区域的数量都必须是 2 的幂,因为代码使用移位和位掩码来执行许多计算。如果需要满足此要求,区域的数量将向上舍入为 2 的幂。

默认池以 PAGE_SIZE 对齐分配。如果 swiotlb_tbl_map_single() 的 alloc_align_mask 参数指定更大的对齐方式,则每个槽集中的一个或多个初始槽可能不符合 alloc_align_mask 标准。由于反弹缓冲区分配不能跨越槽集边界,因此消除这些初始槽会有效地减小反弹缓冲区的最大大小。目前,没有问题,因为 alloc_align_mask 是根据 IOMMU 粒度大小设置的,并且粒度不能大于 PAGE_SIZE。但是,如果将来这种情况发生变化,则初始池分配可能需要使用大于 PAGE_SIZE 的对齐方式完成。

动态 swiotlb

启用 CONFIG_SWIOTLB_DYNAMIC 时,swiotlb 可以按需扩展可用于分配为反弹缓冲区的内存量。如果由于缺少可用空间导致反弹缓冲区请求失败,则会启动一个异步后台任务,从通用系统内存中分配内存并将其转换为 swiotlb 池。创建额外的池必须异步完成,因为内存分配可能会阻塞,并且如上所述,不允许 swiotlb 请求阻塞。一旦启动后台任务,反弹缓冲区请求将创建一个“临时池”,以避免返回“swiotlb 已满”错误。临时池的大小与反弹缓冲区请求的大小相同,并在释放反弹缓冲区时删除。此临时池的内存来自通用系统内存原子池,因此创建不会阻塞。创建临时池的成本相对较高,尤其是在 CoCo VM 中必须解密内存的情况下,因此它仅作为权宜之计,直到后台任务可以添加另一个非临时池。

添加动态池具有局限性。与默认池一样,内存必须是物理上连续的,因此大小限制为 MAX_PAGE_ORDER 个页面(例如,在典型的 x86 系统上为 4 MiB)。由于内存碎片,可能无法获得最大大小的分配。动态池分配器会尝试较小的尺寸,直到成功为止,但最小尺寸为 1 MiB。如果系统内存碎片足够,则动态添加池可能根本不会成功。

动态池中的区域数量可能与默认池中的区域数量不同。由于新池的大小通常最多为几个 MiB,因此区域的数量可能会更小。例如,如果新池的大小为 4 MiB,并且最小区域大小为 256 KiB,则只能创建 16 个区域。如果系统有超过 16 个 CPU,则多个 CPU 必须共享一个区域,从而导致更多的锁争用。

通过动态 swiotlb 添加的新池以线性列表的形式链接在一起。swiotlb 代码经常必须搜索包含特定 swiotlb 物理地址的池,因此该搜索是线性的,并且在存在大量动态池的情况下性能不佳。可以改进数据结构以加快搜索速度。

总的来说,动态 swiotlb 最适合具有相对较少 CPU 的小型配置。它允许默认 swiotlb 池较小,从而避免浪费内存,并在需要时使用动态池提供更多空间(只要碎片不是障碍)。它对于大型 CoCo VM 的用处较小。

数据结构详细信息

swiotlb 由四个主要数据结构管理:io_tlb_mem、io_tlb_pool、io_tlb_area 和 io_tlb_slot。io_tlb_mem 描述一个 swiotlb 内存分配器,其中包括默认内存池和与其链接的任何动态或临时池。每个内存分配器都会保留 swiotlb 使用情况的有限统计信息,并存储在此数据结构中。设置 CONFIG_DEBUG_FS 时,这些统计信息在 /sys/kernel/debug/swiotlb 下可用。

io_tlb_pool 描述一个内存池,可以是默认池、动态池或临时池。该描述包括池中内存的起始地址和结束地址、指向 io_tlb_area 结构数组的指针以及指向与该池关联的 io_tlb_slot 结构数组的指针。

io_tlb_area 描述一个区域。主字段是用于序列化对该区域中槽的访问的自旋锁。池的 io_tlb_area 数组中每个区域都有一个条目,并使用从调用处理器 ID 派生的从 0 开始的区域索引进行访问。区域的存在仅仅是为了允许多个 CPU 并行访问 swiotlb。

io_tlb_slot 描述池中的单个内存槽,大小为 IO_TLB_SIZE(当前为 2 KiB)。io_tlb_slot 数组通过从相对于池的起始内存地址的反弹缓冲区地址计算的槽索引进行索引。struct io_tlb_slot 的大小为 24 字节,因此开销约为槽大小的 1%。

io_tlb_slot 数组旨在满足几个要求。首先,DMA API 和相应的 swiotlb API 使用反弹缓冲区地址作为反弹缓冲区的标识符。此地址由 swiotlb_tbl_map_single() 返回,然后作为参数传递给 swiotlb_tbl_unmap_single() 和 swiotlb_sync_*() 函数。原始内存缓冲区地址显然必须作为参数传递给 swiotlb_tbl_map_single(),但不会传递给其他 API。因此,swiotlb 数据结构必须保存原始内存缓冲区地址,以便在执行同步操作时可以使用该地址。此原始地址保存在 io_tlb_slot 数组中。

其次,io_tlb_slot 数组必须处理部分同步请求。在这种情况下,swiotlb_sync_*() 的参数不是反弹缓冲区起始地址,而是反弹缓冲区中间的某个地址,并且 swiotlb 代码不知道反弹缓冲区起始地址。但是 swiotlb 代码必须能够计算出相应的原始内存缓冲区地址,才能执行“同步”所要求的 CPU 复制。因此,对于反弹缓冲区占用的每个槽,都会在 struct io_tlb_slot 中填充一个调整后的原始内存缓冲区地址。反弹缓冲区调整后的“alloc_size”也会记录在每个 struct io_tlb_slot 中,以便可以对“同步”操作的大小进行健全性检查。“alloc_size”字段不使用,除非用于健全性检查。

第三,io_tlb_slot 数组用于跟踪可用槽。struct io_tlb_slot 中的“list”字段记录了从该槽开始存在的连续可用槽的数量。“0”表示该槽被占用。“1”表示只有当前槽可用。“2”表示当前槽和下一个槽可用,等等。最大值为 IO_TLB_SEGSIZE,该值可以出现在槽集中的第一个槽中,表示整个槽集可用。当搜索用于新反弹缓冲区的可用槽时,会使用这些值。它们在新分配反弹缓冲区和释放反弹缓冲区时更新。在池创建时,“list”字段在每个槽集中初始化为从 IO_TLB_SEGSIZE 到 1 的值。

第四,io_tlb_slot 数组会跟踪为满足上述 alloc_align_mask 要求而分配的任何“填充槽”。当 swiotlb_tbl_map_single() 分配反弹缓冲区空间以满足 alloc_align_mask 要求时,它可能会跨零个或多个槽分配预填充空间。但是,当使用反弹缓冲区地址调用 swiotlb_tbl_unmap_single() 时,不知道控制分配的 alloc_align_mask 值,因此也不知道任何填充槽的分配。 “pad_slots”字段记录填充槽的数量,以便 swiotlb_tbl_unmap_single() 可以释放它们。“pad_slots”值仅记录在分配给反弹缓冲区的第一个非填充槽中。

受限池

swiotlb 机制也用于“受限池”,这些池是与默认 swiotlb 池分离的内存池,专门用于特定设备的 DMA 使用。在硬件保护能力有限的系统上(例如那些缺少 IOMMU 的系统),受限池提供了一定程度的 DMA 内存保护。这种用法由 DeviceTree 条目指定,并且需要设置 CONFIG_DMA_RESTRICTED_POOL。每个受限池都基于其自身的 io_tlb_mem 数据结构,该结构独立于主 swiotlb io_tlb_mem。

受限池添加了 swiotlb_alloc() 和 swiotlb_free() API,这些 API 从 dma_alloc_*() 和 dma_free_*() API 中调用。swiotlb_alloc/free() API 直接从/向受限池分配/释放槽位,并且不经过 swiotlb_tbl_map/unmap_single()。