22、内存池与DMA:DMA缓冲区要求、物理连续内存、IOMMU映射

聊到内存池和DMA,我得先跟你交个底——DMA这玩意儿,对内存的要求非常苛刻。它不是你想怎么传就怎么传的。

我早年做嵌入式驱动时,就吃过这个亏。当时一个网络包死活发不出去,查了三天,最后发现是DMA缓冲区分配在了非连续物理内存上。嗯,那种感觉,就像你写好了所有逻辑,结果被硬件一巴掌扇回来。

DMA缓冲区的基本要求

DMA控制器直接访问内存,不经过CPU。所以它有个硬性前提:缓冲区必须物理连续

为什么?因为DMA控制器没有MMU那样的页表机制。它看到的地址就是总线地址,是一段连续的物理地址空间。你给它一个虚拟地址,它根本不认。

核心要点:DMA缓冲区必须是物理连续的,并且起始地址要对齐到DMA控制器的对齐要求(通常是32字节或64字节)。

举个例子,你分配了一个4KB的缓冲区,如果它在物理内存中是分散的——比如前2KB在物理页0x1000,后2KB在物理页0x2000——DMA传完前2KB后,地址跳变,数据就全乱了。

物理连续内存的分配策略

那怎么拿到物理连续的内存呢?我总结了几种常用方法:

  • 启动时预留:在系统启动阶段,通过内核参数预留一大块连续内存。比如memmap=4G$32G。这种方式最可靠,但灵活性差。
  • 使用dma_alloc_coherent:这是Linux内核提供的标准API。它会分配物理连续且DMA可访问的内存,同时返回CPU虚拟地址和DMA总线地址。
  • 使用kmalloc配合GFP_DMA标志kmalloc本身分配的是物理连续内存,加上GFP_DMA标志后,它会从DMA可用区域分配。
  • 内存池预分配:在系统初始化时,提前从dma_alloc_coherent分配一批缓冲区,放入内存池。运行时直接从池中取,避免动态分配的开销和失败风险。
// 使用 dma_alloc_coherent 分配 DMA 缓冲区
dma_addr_t dma_handle;
void *cpu_addr = dma_alloc_coherent(dev, size, &dma_handle, GFP_KERNEL);
if (!cpu_addr) {
    // 分配失败,处理错误
    return -ENOMEM;
}
// cpu_addr 是 CPU 访问的虚拟地址
// dma_handle 是 DMA 控制器使用的总线地址

我的经验:在实时性要求高的场景,比如音频或视频采集,我习惯在驱动加载时就预分配好DMA缓冲区池。运行时只从池中取和还,绝不动态分配。这样能避免分配失败导致的丢帧问题。

IOMMU映射:解决物理连续难题

你可能会问:如果系统有IOMMU,是不是就不用担心物理连续了?

没错。IOMMU就是DMA的MMU。它能把不连续的物理页映射成DMA控制器看到的连续地址空间。

我举个例子。你分配了4个不连续的物理页:0x1000、0x5000、0x9000、0xD000。通过IOMMU,你可以把它们映射成一个连续的IOVA(I/O Virtual Address)空间,比如0x10000到0x14000。DMA控制器看到的就是0x10000这个连续地址,它根本不知道底层物理页是分散的。

注意:IOMMU映射不是免费的。每次映射和解除映射都有开销,包括页表遍历和TLB刷新。在高频DMA操作中,这个开销可能成为瓶颈。

我做过一个NVMe驱动优化,当时频繁映射/解除映射IOMMU,导致IOPS上不去。后来改成预映射+复用的策略,性能直接翻倍。

IOMMU映射的典型流程

  1. 分配物理内存(可以是不连续的)
  2. 通过IOMMU API创建映射域(domain)
  3. 将物理页映射到连续的IOVA空间
  4. 将IOVA地址传递给DMA控制器
  5. DMA传输完成后,解除映射
// IOMMU映射示例(简化)
struct iommu_domain *domain;
unsigned long iova;

domain = iommu_domain_alloc(bus);
// 将物理页0x1000, 0x5000, 0x9000, 0xD000映射到连续的IOVA
iova = iommu_map(domain, 0x10000, 0x1000, 4096, prot);
iommu_map(domain, 0x11000, 0x5000, 4096, prot);
iommu_map(domain, 0x12000, 0x9000, 4096, prot);
iommu_map(domain, 0x13000, 0xD000, 4096, prot);
// 现在DMA可以使用0x10000-0x14000这个连续地址

DMA缓冲区与内存池的结合

在实际项目中,我通常这样设计:

  • 对于小尺寸、高频的DMA传输(比如网络包、USB数据),使用预分配的DMA内存池。池中每个缓冲区都是物理连续的,且已经映射好IOMMU。
  • 对于大尺寸、低频的传输(比如文件读写),动态分配并映射,用完后释放。

这种分层设计,既保证了高频场景的性能,又避免了低频场景浪费内存。

避坑指南:我曾经在DMA传输完成后,忘记调用dma_unmap_singleiommu_unmap。结果IOMMU的TLB被撑爆,系统卡死。嗯,从那以后我每次写DMA代码,都会在错误路径和正常路径上都加上unmap操作。

知识体系总览

下面这张图,帮你理清DMA缓冲区、物理连续内存和IOMMU之间的关系:

DMA缓冲区与内存池知识体系 DMA控制器 访问IOVA IOMMU 映射到物理页 物理内存(可能不连续) 页0x1000 | 页0x5000 | 页0x9000 | 页0xD000 通过IOMMU映射为连续IOVA 无IOMMU时 必须物理连续 DMA内存池(预分配+预映射) 预分配 核心思路:高频场景用内存池预分配,低频场景动态分配+IOMMU映射

说白了,DMA缓冲区的核心就三件事:物理连续、地址对齐、正确映射。IOMMU能帮你解决物理连续的问题,但代价是映射开销。内存池则是在性能和灵活性之间找平衡。

我个人习惯是:能用内存池的,绝不动态分配。尤其是在中断上下文或实时线程中,动态分配内存简直就是给自己挖坑。


公众号:蓝海资料掘金营,微信deep3321