本文属于第三册“内核抽象:dma-mapping 接口的设计原则”,回答第 0 篇提出的问题 10:
- 物理上分散的内存为何能以连续地址的形式提供给设备?
dma_map_sg完成了哪些工作?
本文讨论内核如何在映射接口层面统一处理设备的分段能力与 IOMMU 的地址翻译。首先介绍struct scatterlist与struct sg_table及其构建方式;然后分析dma_map_sg()在直接映射与 IOMMU 两条路径上的工作及段合并的条件;接着说明映射前后两个条目计数的分工;最后简要介绍基于 IOVA 的新接口。
1. 问题回顾
第 5 篇第 2 节指出,vmalloc()与用户态缓冲区在物理上通常分散,而设备以“起始地址 + 长度”访问的单个区域必须物理连续。解决途径有两种:支持分段描述(scatter-gather,SG)的设备为每个物理连续的片段分别提供地址与长度;启用 IOMMU 时,分散的物理页可以映射到一段连续的 IOVA(第 6 篇第 4.2 节)。设备对每段的长度与边界还可能存在约束(第 5 篇第 5 节)。
dma_map_single()只能映射一段物理连续的缓冲区。若驱动逐段调用它,则无法利用 IOMMU 合并多段的能力,每段还要各自经历一次 IOVA 分配与失效(第 7 篇第 6 节)。内核因此提供了以分段列表为单位的dma_map_sg()。需要回答的是:分散的内存如何描述;dma_map_sg()如何处理这一描述,何时能合并多段;驱动应如何使用映射的结果。
2. 分散内存的描述:scatterlist 与 sg_table
2.1 条目的两组字段
内核以struct scatterlist描述一个物理连续的片段(include/linux/scatterlist.h):
structscatterlist{unsignedlongpage_link;unsignedintoffset;unsignedintlength;dma_addr_tdma_address;#ifdefCONFIG_NEED_SG_DMA_LENGTHunsignedintdma_length;#endif#ifdefCONFIG_NEED_SG_DMA_FLAGSunsignedintdma_flags;#endif};结构中的字段分为两组。page_link、offset与length是 CPU 侧的描述,即片段所在的页、页内偏移与长度,由构建列表的代码填写。dma_address与dma_length是设备侧的描述,由dma_map_sg()填写,驱动应通过宏访问(同一文件,节选):
#definesg_dma_address(sg)((sg)->dma_address)#ifdefCONFIG_NEED_SG_DMA_LENGTH#definesg_dma_len(sg)((sg)->dma_length)#else#definesg_dma_len(sg)((sg)->length)#endif两组长度分开存放,是因为映射可能合并多个条目,合并后的 DMA 段长度与任何条目的 CPU 侧长度都不同。x86、ARM64 等架构与IOMMU_DMA都选择了CONFIG_NEED_SG_DMA_LENGTH。dma_flags用于标记某段是 PCI 总线地址还是经 SWIOTLB 中转。
2.2 条目数组的链接
条目以数组的形式分配,但一个数组的长度受单次分配大小的限制(同一文件):
#defineSG_MAX_SINGLE_ALLOC(PAGE_SIZE/sizeof(structscatterlist))在 4 KB 页的 64 位系统上,一个条目占 32 字节,一个数组至多容纳 128 个条目。更长的列表由多个数组链接而成。由于struct page的地址至少按 4 字节对齐,链接信息可以编码在page_link的低两位中(同一文件,节选):
#defineSG_CHAIN0x01UL#defineSG_END0x02UL数组的最后一项若带有SG_CHAIN标志,则不描述内存,而是指向下一个数组;列表的最后一个条目带有SG_END标志。遍历时须使用sg_next()(同一文件):
staticinlinestructscatterlist*sg_next(structscatterlist*sg){if(sg_is_last(sg))returnNULL;sg++;if(unlikely(sg_is_chain(sg)))sg=sg_chain_ptr(sg);returnsg;}sg_alloc_table()在条目数超过SG_MAX_SINGLE_ALLOC时自动建立链接,每个数组的最后一项被用作链接项。
2.3 sg_table 与两个计数
struct sg_table将列表与其计数组织在一起(同一文件):
structsg_table{structscatterlist*sgl;/* the list */unsignedintnents;/* number of mapped entries */unsignedintorig_nents;/* original size of list */};orig_nents是列表中描述内存的条目数,nents是映射后的 DMA 段数,后者可能因合并而较小。内核为两种遍历分别提供了宏(同一文件,节选):
#definefor_each_sgtable_sg(sgt,sg,i)\for_each_sg((sgt)->sgl,sg,(sgt)->orig_nents,i)#definefor_each_sgtable_dma_sg(sgt,sg,i)\for_each_sg((sgt)->sgl,sg,(sgt)->nents,i)列表、链接与两组字段的关系如图 1 所示。
3. 构建 scatterlist
3.1 由页数组构建
驱动持有一组页时,例如钉住的用户态页,可以调用sg_alloc_table_from_pages_segment()构建列表。该函数将物理上相邻的页合并到同一个条目中(lib/scatterlist.c,节选):
staticboolpages_are_mergeable(structpage*a,structpage*b){if(page_to_pfn(a)!=page_to_pfn(b)+1)returnfalse;if(!zone_device_pages_have_same_pgmap(a,b))returnfalse;returntrue;}合并后的条目长度不超过max_segment参数;不带_segment后缀的sg_alloc_table_from_pages()以UINT_MAX为上限。这一步属于 CPU 侧的合并:条目数减少,但每个条目仍是一段物理连续的内存,与是否启用 IOMMU 无关。
3.2 由块请求构建
块设备的一次请求由一个或多个 bio 组成,每个 bio 包含多个bio_vec。存储驱动通常调用blk_rq_map_sg()将请求转换为 scatterlist,其内部同样合并物理相邻的bio_vec,并遵守队列的段长度与段边界限制(block/blk-mq-dma.c)。这些限制与 DMA 映射层所用的限制来自同一组设备参数,例如 SCSI 中间层将主机适配器声明的约束同时传递给两者(drivers/scsi/scsi_lib.c,节选):
lim->seg_boundary_mask=shost->dma_boundary;lim->max_segment_size=shost->max_segment_size;...if(dev->dma_parms){dma_set_seg_boundary(dev,shost->dma_boundary);dma_set_max_seg_size(dev,shost->max_segment_size);}块层构建列表与 DMA 映射层合并段因此遵守相同的约束(第 5 篇第 5 节)。
4.dma_map_sg的工作
4.1 接口与返回值
dma_map_sg()的分发方式与其他映射接口相同(kernel/dma/mapping.c,节选并简化):
if(dma_map_direct(dev,ops)||arch_dma_map_sg_direct(dev,sg,nents))ents=dma_direct_map_sg(dev,sg,nents,dir,attrs);elseif(use_dma_iommu(dev))ents=iommu_dma_map_sg(dev,sg,nents,dir,attrs);elseents=ops->map_sg(dev,sg,nents,dir,attrs);返回值的含义由内核文档规定(Documentation/core-api/dma-api.rst,节选):
Maps a scatter/gather list for DMA. Returns the number of DMA address segments mapped, which may be smaller than <nents> passed in if several consecutive sglist entries are merged (e.g. with an IOMMU, or if some adjacent segments just happen to be physically contiguous). Please note that the sg cannot be mapped again if it has been mapped once. The mapping process is allowed to destroy information in the sg.dma_map_sg()在失败时返回 0。较新的dma_map_sgtable()以sg_table为参数,成功时将段数写入sgt->nents,失败时返回区分情形的负错误码,新代码宜优先使用。“不能再次映射”的限制源于映射会改写条目中的设备侧字段,IOMMU 路径还会临时改写 CPU 侧字段。
4.2 直接映射:逐项映射
未启用 IOMMU 时,dma_direct_map_sg()对每个条目分别执行第 8 篇第 4.2 节所述的直接映射(kernel/dma/direct.c,节选并简化):
for_each_sg(sgl,sg,nents,i){...need_sync=true;sg->dma_address=dma_direct_map_phys(dev,sg_phys(sg),sg->length,dir,attrs,false);if(sg->dma_address==DMA_MAPPING_ERROR){ret=-EIO;gotoout_unmap;}...sg_dma_len(sg)=sg->length;}if(need_sync&&!dev_is_dma_coherent(dev))arch_sync_dma_flush();returnnents;省略的部分处理 PCI 设备之间的点对点传输。每个条目的 DMA 地址由其物理地址转换而来,超出寻址范围的条目各自经 SWIOTLB 中转,非一致性设备的 cache 维护在循环中发出。函数返回nents,即使相邻条目恰好物理相邻也不合并,这一合并已在构建列表时完成(第 3 节)。因此直接映射路径不改变段的数目与长度,设备的段约束只能由构建列表的一方保证。
4.3 IOMMU:一段 IOVA 与段的合并
启用 IOMMU 时,iommu_dma_map_sg()为整个列表分配一段连续的 IOVA,第 6 篇第 4.2 节已引用其开头的注释。函数分为三步,如图 2 所示。
iommu_dma_map_sg()的三步操作在此之前,若未指定DMA_ATTR_SKIP_CPU_SYNC,函数先对全部条目执行面向设备的 cache 维护。第一步计算所需的 IOVA 总长度,并将每个条目扩展到 IOVA 粒度的边界(drivers/iommu/dma-iommu.c,节选并简化):
for_each_sg(sg,s,nents,i){size_ts_iova_off=iova_offset(iovad,s->offset);size_ts_length=s->length;size_tpad_len=(mask-iova_len+1)&mask;...sg_dma_address(s)=s_iova_off;sg_dma_len(s)=s_length;s->offset-=s_iova_off;s_length=iova_align(iovad,s_length+s_iova_off);s->length=s_length;...if(pad_len&&pad_len<s_length-1){prev->length+=pad_len;iova_len+=pad_len;}iova_len+=s_length;prev=s;}IOMMU 以页为单位建立映射,每个条目在 IOVA 空间中至少占据完整的粒度。函数将条目的偏移向下、长度向上对齐到粒度,原始值暂存在尚未使用的dma_address与dma_length中。pad_len用于处理段边界:若下一个条目会跨越边界,则在前一个条目之后补齐。
第二步一次分配全部 IOVA,并调用iommu_map_sg()将各条目依次映射到其中(同一函数,节选):
iova=iommu_dma_alloc_iova(domain,iova_len,dma_get_mask(dev),dev);...ret=iommu_map_sg(domain,iova,sg,nents,prot,GFP_ATOMIC);...return__finalise_sg(dev,sg,nents,iova);第三步由__finalise_sg()恢复条目原有的偏移与长度,并计算设备侧的 DMA 段。合并的判断如下(同一文件,节选并简化):
if(cur_len&&!s_iova_off&&(dma_addr&seg_mask)&&(max_len-cur_len>=s_length)){cur_len+=s_length;}else{if(i>0)cur=sg_next(cur);cur_len=s_length;count++;sg_dma_address(cur)=dma_addr+s_iova_off;}sg_dma_len(cur)=cur_len;dma_addr+=s_iova_len;if(s_length+s_iova_off<s_iova_len)cur_len=0;合并须同时满足:存在可追加的输出段,当前条目从粒度边界开始,不处于段边界(seg_mask来自dma_get_seg_boundary()),合并后不超过dma_get_max_seg_size()。最后一个判断表明,条目若未在粒度边界处结束,其后到下一个粒度边界之间是一段未使用的 IOVA,下一个条目不能与之合并。
合并结果依次写入列表前部条目的设备侧字段,函数返回输出段的数目,其余条目的sg_dma_len()为 0。图 3 以一个 5 条目的列表为例,对比了两条路径的映射结果。
这正是问题 10 的答案:物理上分散的片段之所以能以连续地址提供给设备,是因为 IOMMU 将它们映射到了一段连续的 IOVA;dma_map_sg()在此路径上对齐各片段、一次分配并映射 IOVA,再在满足条件时合并相邻的 DMA 段。未启用 IOMMU 时,连续性只能依靠设备的分段能力。
4.4 经 SWIOTLB 中转时不合并
IOMMU 路径中有一个例外。不可信设备的映射,以及非一致性设备上未按 cache 行对齐的小缓冲区,须经 SWIOTLB 中转(第 6 篇第 4.3 节、第 3 篇第 5.2 节)。此时iommu_dma_map_sg()改为逐项映射(同一文件,节选):
if(dev_use_sg_swiotlb(dev,sg,nents,dir))returniommu_dma_map_sg_swiotlb(dev,sg,nents,dir,attrs);iommu_dma_map_sg_swiotlb()对每个条目调用iommu_dma_map_phys(),返回nents,不合并任何条目。对于不可信设备,dev_use_sg_swiotlb()总是返回真,因此即使启用了 IOMMU,其段数也不会减少。
5. 映射结果的使用
5.1 两个计数的分工
映射之后,列表的 CPU 侧字段仍描述原来的各个片段,设备侧字段描述合并后的 DMA 段,驱动须根据用途选择计数,如图 4 所示。
填写设备的段描述符时,应遍历映射返回的段数(Documentation/core-api/dma-api-howto.rst,节选):
inti,count=dma_map_sg(dev,sglist,nents,direction);structscatterlist*sg;for_each_sg(sglist,sg,count,i){hw_address[i]=sg_dma_address(sg);hw_len[i]=sg_dma_len(sg);}而解除映射与同步须使用传入dma_map_sg()的原始条目数(同一文件,节选):
The 'nents' argument to the dma_unmap_sg call must be the _same_ one you passed into the dma_map_sg call, it should _NOT_ be the 'count' value _returned_ from the dma_map_sg call.其原因在于 cache 维护与 SWIOTLB 复制都以物理地址为单位,合并后的 DMA 段在物理上并不连续,只能逐个处理原来的片段。IOMMU 路径的同步实现即遍历全部原始条目(drivers/iommu/dma-iommu.c,节选):
if(sg_dma_is_swiotlb(sgl)){for_each_sg(sgl,sg,nelems,i)iommu_dma_sync_single_for_cpu(dev,sg_dma_address(sg),sg->length,dir);}elseif(!dev_is_dma_coherent(dev)){for_each_sg(sgl,sg,nelems,i)arch_sync_dma_for_cpu(sg_phys(sg),sg->length,dir);arch_sync_dma_flush();}若以映射返回的段数调用,后面的片段将得不到 cache 维护,这也是第 9 篇第 4.1 节对dma_sync_sg_*()的nents提出要求的原因。dma_map_sgtable()、dma_unmap_sgtable()与dma_sync_sgtable_*()自动选择正确的计数,可以避免这类错误。
5.2 GPU 驱动中的使用
GPU 驱动以sg_table描述由页组成的缓冲区。amdgpu 将用户态内存(userptr)映射给 GPU 时,先由钉住的页构建列表,再整体映射(drivers/gpu/drm/amd/amdgpu/amdgpu_ttm.c,节选):
/* Allocate an SG array and squash pages into it */r=sg_alloc_table_from_pages(ttm->sg,ttm->pages,ttm->num_pages,0,(u64)ttm->num_pages<<PAGE_SHIFT,GFP_KERNEL);if(r)gotorelease_sg;/* Map SG to device */r=dma_map_sgtable(adev->dev,ttm->sg,direction,0);if(r)gotorelease_sg_table;/* convert SG to linear array of pages and dma addresses */drm_prime_sg_to_dma_addr_array(ttm->sg,gtt->ttm.dma_address,ttm->num_pages);GPU 有自己的页表,需要的是每一页的 DMA 地址。drm_prime_sg_to_dma_addr_array()以for_each_sgtable_dma_page()按页遍历nents个 DMA 段,因此在 IOMMU 合并了段之后,得到的仍是正确的逐页地址。这也表明sg_table的作用不限于向设备提供段描述符,它是内核在子系统之间传递“一组页及其 DMA 地址”的通用形式,第 13 篇讨论的 DMA-BUF 即以此形式向导入方提供映射结果。
5.3 合并能力的查询
驱动可以通过dma_get_merge_boundary()查询映射层能否合并:未使用 IOMMU 且映射操作未提供该能力时返回 0,否则返回 IOMMU 最小页长减一(kernel/dma/mapping.c)。NVMe 驱动为控制器分配主机内存缓冲区时即据此选择分配方式:若 IOMMU 能够按页合并,先尝试以dma_alloc_noncontiguous()分配一段在设备侧连续的内存,只向控制器提供一个描述符;否则分配多个物理连续的块(drivers/nvme/host/pci.c,节选):
unsignedlongdma_merge_boundary=dma_get_merge_boundary(dev->dev);...if(dma_merge_boundary&&(PAGE_SIZE&dma_merge_boundary)==0){if(!nvme_alloc_host_mem_single(dev,preferred))return0;}第 8 篇第 6.1 节介绍的dma_alloc_noncontiguous()保证返回的sg_table只有一个 DMA 段,其前提同样是 IOMMU 的合并能力。
6. scatterlist 之外:基于 IOVA 的映射接口
scatterlist 将 CPU 侧与设备侧的描述存放在同一结构中,每个条目占 32 字节,且须在映射之前构建完整的列表。对于每秒处理大量请求的存储驱动,这些开销并非可以忽略。内核为此新增了一组可选的基于 IOVA 的接口,文档仅建议对映射性能或 DMA 地址的存储开销敏感的驱动使用(Documentation/core-api/dma-api.rst)。
驱动先以dma_iova_try_alloc()分配一段 IOVA,再以dma_iova_link()将各段物理内存逐一映射到其中,最后以dma_iova_sync()一次完成 IOMMU 映射的同步。它将iommu_dma_map_sg()中“分配一段 IOVA、依次映射”的过程开放给驱动直接调用,省去了中间的 scatterlist。各段须按dma_get_merge_boundary()对齐,这与第 4.3 节的合并条件一致。未启用 IOMMU 时,dma_iova_try_alloc()返回假,调用者回退到逐段的流式映射。
在本文所依据的内核版本中,NVMe 驱动已经由块层的blk_rq_dma_map_iter_start()使用这组接口,不再调用dma_map_sg()。对大多数驱动而言,sg_table与dma_map_sgtable()仍是描述与映射分散内存的标准方式。
7. 常见误区
7.1 以原始条目数遍历 DMA 段,或以sg->length作为段长度
部分驱动在映射之后以传入的nents遍历列表并填写设备描述符,或者读取sg->length而非sg_dma_len()。这类代码在直接映射路径上恰好可以工作;在启用 IOMMU 的平台上,段被合并之后,后部条目的sg_dma_len()为 0,sg->length也不再等于 DMA 段的长度,设备将收到错误的地址与长度。
7.2 以映射的返回值调用解除映射或同步接口
与上一误区相反,部分驱动以映射返回的段数调用dma_unmap_sg()或dma_sync_sg_*(),使后部的片段得不到 cache 维护与 SWIOTLB 复制;启用 DMA 调试时,check_sync()会报告 “device driver syncs DMA sg list with different entry count”。
7.3 以数组下标访问 scatterlist
条目超过SG_MAX_SINGLE_ALLOC时,列表由多个数组链接而成,sg[i]可能访问到链接项或越过数组末尾;驱动也可能从其他子系统接收到链接的列表。遍历时应使用for_each_sg()、for_each_sgtable_sg()等宏。
7.4 认为dma_map_sg()总能将分散的内存合并为一段
合并只在启用 IOMMU 且不经 SWIOTLB 中转时发生,并要求相邻片段在粒度边界处衔接。只支持少数几段的设备若依赖合并才能工作,在未启用 IOMMU 或 IOMMU 处于直通模式的系统上将无法处理较大的分散缓冲区。驱动应检查映射返回的段数是否超出设备上限;需要设备侧单段时,应先查询dma_get_merge_boundary(),或使用dma_alloc_noncontiguous()并在其失败时回退到物理连续的分配。
8. 小结
本文回答了问题 10,要点如下:
- scatterlist 同时描述 CPU 侧与设备侧。条目的
page_link、offset、length描述一段物理连续的内存,dma_address、dma_length由映射填写;数组以page_link低位的标志链接。sg_table以orig_nents与nents分别记录条目数与 DMA 段数。 - 合并发生在两个层次。构建列表时合并物理相邻的页;映射时,IOMMU 路径合并在 IOVA 中相邻的 DMA 段。两个层次都遵守设备声明的段约束。
dma_map_sg()的工作取决于路径。直接映射逐项转换地址,段数不变;IOMMU 路径对齐各片段、一次分配并映射 IOVA,并在满足条件时合并段;经 SWIOTLB 中转时逐项映射,不合并。- 两个计数各有用途。填写设备描述符使用映射返回的段数与
sg_dma_*()宏;解除映射与同步使用原始条目数,dma_map_sgtable()等接口自动维护两者。 - 连续的设备地址依赖 IOMMU。驱动可以通过
dma_get_merge_boundary()查询这一能力,但不应在没有回退路径的情况下依赖它。
前几篇中 SWIOTLB 中转多次出现:地址超出设备的寻址范围时,映射层以数据复制保证正确性。设备能够访问哪些地址,由驱动声明的 DMA 掩码决定。驱动为何需要声明 DMA 掩码,掩码设置不当会引入何种性能代价,即为问题 11。
系列导航:
- 上一篇《第 9 篇 DMA 方向与缓冲区所有权:
dma_sync_*的作用》 - 下一篇《第 11 篇 DMA 掩码与寻址能力协商:
dma_set_mask与 SWIOTLB》