引言

在没有 DMA(Direct Memory Access)的时代,CPU 必须亲自搬运每一个字节——从网卡到内存、从磁盘到缓冲区。当千兆网卡每秒涌入 148 万个数据包时,1GHz CPU 的时钟周期将完全消耗在 memcpy 上,无任何余力处理业务逻辑。DMA 控制器的存在,正是为了将 CPU 从这一繁琐工作中解放出来。

然而 DMA 并非简单的"自动搬运"。-cache 一致性、地址映射、分散/聚集传输、缓存行对齐、IOMMU 隔离——每一个环节都暗藏陷阱。本文将深入 Linux 内核 DMA 子系统,从硬件原理到内核 API,从驱动实战到性能调优,全方位剖析这一核心机制。

一、DMA 基础:让硬件直接读写内存

1.1 DMA 传输的本质

DMA 传输的核心思想:CPU 仅向 DMA 控制器下发"从设备地址 X 搬运 N 字节到内存地址 Y"的指令,随后可转去执行其他任务。DMA 控制器完成搬运后,通过中断通知 CPU。一次完整的 DMA 传输包含三个阶段:

  1. 编程阶段:CPU 设置 DMA 控制器的源地址、目标地址、传输长度和方向
  2. 搬运阶段:DMA 控制器在总线仲裁后直接操控总线完成数据搬运
  3. 完成阶段:DMA 控制器发出中断,CPU 执行后续处理

1.2 总线架构中的 DMA

在现代 PCIe 系统中,DMA 有两个来源:

  • 设备到内存(Device DMA):PCIe 设备(网卡、NVMe 网卡)作为总线主控发起 MRd/MWr TLP
  • I/O DMA:传统 DMA 控制器(如 Intel 8237)或平台 I/O DMA 引擎操作

关键区别在于,PCIe 设备发起的 DMA 使用总线地址(Bus Address),它与 CPU 看到的物理地址在存在 IOMMU 时是不同的。

1.3 Cache 一致性问题

DMA 最棘手的问题并非传输本身,而是缓存一致性。考虑以下场景:

// CPU 写入 buffer,部分数据在 L2 Cache 中
memcpy(buffer, data, len);

// 启动 DMA 传输:设备直接读取内存
// ⚠️ 问题:Cache 中的脏数据尚未写回内存
//       设备可能读到陈旧数据!

// 设备往 buffer 写入接收到的数据
// ⚠️ 问题:Cache 中可能仍保留 buffer 的旧视图
//       CPU 后续读取可能命中 Cache 读到过期数据

这引出了两个专业方向:

  • Coherent DMA(一致性 DMA):硬件自动维护 Cache 一致性(如 ARM 的 CCI/CMN Intel 的 QPI 完全一致性)
  • Streaming DMA(流式 DMA):软件必须在 DMA 传输前后手动同步 Cache(dma_map_single / dma_unmap_single 内部执行 Cache Flush/Invalidate)

二、DMA 地址空间与 IOMMU

2.1 物理地址 vs 总线地址 vs I/O 虚拟地址

在 DMA 子系统中,存在三种地址概念:

地址类型来源说明
CPU 物理地址 (PA)MMU 页表CPU 视角的实物理地址
总线地址 (BPA)平台定义设备在总线上看到的地址
I/O 虚拟地址 (IOVA)IOMMU 页表IOMMU 为设备建立的虚拟地址空间

在无 IOMMU 的平台(如大多数嵌入式 ARM),总线地址 == CPU 物理地址。但在现代 x86 服务器和高级 SoC 上,IOMMU 启用时:

┌─────────┐     ┌──────────┐     ┌──────────────┐
│  Device  │────▶│  IOMMU   │────▶│ Physical RAM │
└─────────┘     └──────────┘     └──────────────┘
    IO Bus   →   IOVA → PA →   DDR
   
设备使用 IOVA(IO 虚拟地址)
IOMMU 负责 IOVA → PA 的页表翻译

2.2 IOMMU 的核心价值

IOMMU(I/O Virtualization Unit,Intel 称 VT-d,AMD 称 AMD-Vi,SMMU)提供三大能力:

  1. 地址隔离:限制设备只能访问 IOVA 页表中映射的物理页,防止恶意设备通过 DMA 读取内核或其他进程的内存(DMA 攻击)
  2. 地址扩展:32 位 PCI 设备通过 IOMMU 可访问超过 4GB 的物理内存(无需 bounce buffer)
  3. 地址重映射:支持分散/聚集列表的透明翻译,设备使用的是连续 IOVA,实际物理内存可能是分散的页

2.3 内核 DMA 域的层次结构

Linux API 将设备按 DMA 能力分为三类域:

struct device {
    // DMA 能力描述
    u64              dma_mask;      // 地址总线位数
    u64              coherent_dma_mask; // 一致性 DMA 地址范围
    struct iommu_group *iommu_group;   // IOMMU 组
    ...
};

三种域的 API 选择:

  • Coherent 域(dma_alloc_coherent):设备与 CPU 共享一块硬件保证 Cache 一致性的内存,适用于控制结构、环形缓冲区
  • Streaming 域(dma_map_single/sg):软件管理 Cache 同步,适用于大数据量传输(网卡数据包、磁盘 I/O)
  • DMA-pool 域(dma_pool_alloc):从 coherent 区域预分配池中分配小块 DMA 缓冲区(< PAGE_SIZE)

三、内核 DMA API 深度解析

3.1 Coherent 一致性 DMA 映射

最安全也是最简单的 DMA 方式——分配的内存硬件保证 Cache 一致性(即 CPU 写入设备立即可见,设备写入 CPU 读取立即可见):

// 分配一致性 DMA 内存
void *dma_alloc_coherent(struct device *dev, size_t size,
                         dma_addr_t *dma_handle, gfp_t flag);

// 返回值:CPU 虚拟地址 + 总线地址
// CPU 通过虚拟地址读写,设备通过 dma_handle(总线地址)访问
// 两者看到的是同一块物理内存

void dma_free_coherent(struct device *dev, size_t size,
                       void *cpu_addr, dma_addr_t dma_handle);

底层实现:内核从DMA coherent pool分配内存。coherent 内存来源有三:

  1. 设备树/DT DMA coherent 属性(dma-coherent 节点)
  2. 预留的 CMA(Contiguous Memory Allocator)区域
  3. 强制 uncached(不可缓存)映射(pgprot_noncached),牺牲性能换取一致性——在无硬件 Cache 一致性支持的平台上非常常见

适用场景:网卡 Rx/Tx 环形描述符、NVMe SQ/CQ 命令队列、设备控制结构体。因为这些结构体频繁被 CPU 和设备交替写入,手动 Cache 同步开销太大。

不适用:高速数据通路(网卡数据包数据、磁盘数据块)。因为 coherent 内存通常是 uncached 的——每次 CPU 读写都直接访问内存,延迟是不可 cached 的 5-10 倍。

3.2 Streaming 流式 DMA 映射

性能最优的 DMA 方式。数据所在内存是 CPU Cacheable 的,通过显式 Cache 同步保证 DMA 正确性:

// 单向 DMA:CPU → 设备(发送)
dma_addr_t dma_map_single(struct device *dev, void *cpu_addr,
                           size_t size, enum dma_data_direction dir);

// 单向 DMA:设备 → CPU(接收)
void dma_unmap_single(struct device *dev, dma_addr_t dma_handle,
                      size_t size, enum dma_data_direction dir);

// dir 参数决定 Cache 操作方向:
// DMA_TO_DEVICE:      同步 CPU Cache(写回)到内存 → 设备读取最新数据
// DMA_FROM_DEVICE:    无效化 CPU Cache → 设备写入后 CPU 不会读到旧 Cache
// DMA_BIDIRECTIONAL:  写回 + 无效化(最保守但越慢)

为什么 unmap 时需要 invalidate? 考虑网卡接收:

// 1. CPU 分配接收 buffer(可 Cache)
void *buf = kmalloc(len, GFP_KERNEL);

// 2. 映射 DMA,告诉网卡把数据写入 dma_handle
dma_addr_t handle = dma_map_single(dev, buf, len, DMA_FROM_DEVICE);
//     ⚠️ 这一步可能 invalidate CPU Cache,确保无旧阴影

// 3. 把 handle 编程到网卡 Rx 描述符
// 4. ... 网卡 DMA 写入 buf ...

// 5. 接收完成后 unmap
dma_unmap_single(dev, handle, len, DMA_FROM_DEVICE);
//     ⚠️ 如果网卡以 Write-allocate 方式写入了 Cache(罕见)
//     这里会最终确保 Cache 一致性

// 6. CPU 安全读取 buf
process_received_packet(buf);

3.3 分散/聚集 DMA(Scatter-Gather)

现代高速设备(NVMe、万兆网卡)几乎都依赖 SG DMA,因为用户数据经常分散在多个物理页中(甚至被 Page Cache 拆成更细的片段):

int dma_map_sg(struct device *dev, struct scatterlist *sg,
               int nents, enum dma_data_direction dir);

// scatterlist:将分散的物理页片段聚合为一个 DMA 描述数组
struct scatterlist {
    unsigned long   page_link;   // Page 指针 + 标志
    unsigned int    offset;      // 页内偏移
    unsigned int    length;      // 片段长度
    dma_addr_t      dma_address; // 映射后的总线地址
    unsigned int    dma_length;  // 可能合并后更长
};

结构示意:虚拟连续的 buffer 对应多个物理页帧,SG 列表将它们逐一映射为连续的总线地址描述:

用户视角(虚拟连续):
[  buffer: PAGE_SIZE * 3 bytes  ]

物理内存视角:
[ Page0: +----------- ]
[ Page1:        ----+---- ]
[ Page2:              ---+-- ]

SG DMA 映射后:
[ {pa:0x3f000, len:4096}, {pa:0x5a010, len:2048}, {pa:0x1c800, len:4096} ]

IOMMU 视角(开启时):
IOVA 连续: [0x100000, 0x110000) → 被拆成三个物理映射!

3.4 DMA Pool:小对象高频分配器

对于频繁分配/释放的小型 DMA buffer(如 USB URB 的 setup packet、SATA FIS 结构体),dma_map_single 的开销过高。DMA Pool 通过在初始化阶段预分配一块 coherent 内存,然后从中切分小块使用:

struct dma_pool *dma_pool_create(const char *name, struct device *dev,
                                  size_t size, size_t align, size_t boundary);

void *dma_pool_alloc(struct dma_pool *pool, gfp_t mem_flags, dma_addr_t *handle);

void dma_pool_free(struct dma_pool *pool, void *vaddr, dma_addr_t addr);

void dma_pool_destroy(struct dma_pool *pool);

// 分配对象最小单元必须满足:
// - size: 单个对象大小
// - align: 对齐要求(如 SATA FIS 需要 256 字节对齐)
// - boundary: 不能跨过的边界(如 4K 边界)

DMA Pool 的优势:

  • 免去了每次 coherent 分配的页表操作开销
  • 对象来自 uncached 一致内存,无需 Cache 管理
  • 对象连续分配在物理上通常靠近,Cache 利用率好

四、通用层:dmaengine 子系统

4.1 DMA Engine 的必要性

许多 SoC/平台集成通用 DMA 引擎(如 PL330、DW DMAC、Intel IOMMU 隐藏的平台 DMA),用于无专用 DMA 控制器的外设(如 SPI、SDIO、UART 在高吞吐场景)。dmaengine 子系统为这些通用 DMA 通道提供统一的用户态和内核态 API。

4.2 内核驱动中使用 DMA Engine

// 1. 请求一个 DMA 通道
struct dma_chan *dma_request_chan(struct device *dev, const char *name);

// 2. 配置通道参数
struct dma_slave_config config = {};
config.direction = DMA_MEM_TO_DEV;
config.dst_addr = fifo_reg_addr; // 设备 FIFO 的物理地址
config.dst_addr_width = DMA_SLAVE_BUSWIDTH_4_BYTES;
config.dst_maxburst = 16;
dmaengine_slave_config(chan, &config);

// 3. 准备一个传输描述符
struct dma_async_tx_descriptor *tx;
tx = dmaengine_prep_dma_memcpy(chan, dst_dma, src_dma, len,
                                DMA_PREP_INTERRUPT | DMA_CTRL_ACK);

// 4. 设置完成回调
tx->callback = my_dma_callback;
tx->callback_param = my_dev;

// 5. 提交并启动
dmaengine_submit(tx);
dma_async_issue_pending(chan);

4.3 DMA 传输类型

  • Memcpy(DMA_MEM_TO_MEM):物理内存拷贝,如帧缓冲区搬运、加密引擎数据搬运
  • Slave SG(DMA_MEM_TO_DEV / DMA_DEV_TO_MEM):内存与外设 FIFO 之间的多段传输
  • Cyclic:环形缓冲区的循环 DMA,适用于音频(ALSA AFE 或 I2S 循环播放)
  • Interleaved:交织 DMA(如视频帧的去隔行/隔行转换)

五、硬件视角:不同接口的 DMA 实现

5.1 PCIe DMA

PCIe 设备通过 TLP(Transaction Layer Packet)发起 DMA 操作:

  • MRd(Memory Read TLP):读取 CPU 内存(对应 DMA_FROM_DEVICE)
  • MWr(Memory Write TLP):写入 CPU 内存(对应 DMA_TO_DEVICE)

关键约束:PCIe MRd TLP 的最大载荷(MPS)通常为 128-512 字节,而 MWr 可达 256-4096 字节。因此读取方向性能通常弱于写入方向。

5.2 NVMe DMA

NVMe 的 DMA 通过提交队列(SQ)和完成队列(CQ)实现:

  • SQ/CQ 本身使用 dma_alloc_coherent(coherent CPU ↔ SSD 异步消费)
  • PRP/SGL 描述的数据缓冲区使用 Streaming DMA 映射
  • SGL(Scatter Gather List)支持分散/聚集,一次 I/O 可涉及多个不连续物理页
  • NVMe 的 Doorbell 寄存器通过 MMIO 写入——需保证 WC(Write-Combining)映射

5.3 Platform Device DMA

嵌入式 SoC 上的 SPI/I2C/UART 等 Platform 设备通常使用 dma_request_chan(dev, "rx") 从通用 DMA 引擎获取通道。设备树的 dmas 属性描述了路由:

dmas = <&dma0 4>, <&dma0 5>;
dma-names = "tx", "rx";
// dma0 通道4 → TX, 通道5 → RX

六、DMA 性能调优实战

6.1 对齐与 Cache 行优化

DMA buffer 的第一个优化维度是缓存行对齐。如果两个共享 buffer 的设备/DMA 通道交错访问同一 Cache 行,也会引发 Cache 一致性开销(即使分属不同的 coherent/streaming 域):

// ✅ 正确:按 Cache 行(通常 64 字节)对齐
void *buf = kmalloc(size, GFP_KERNEL | __DMA_ALIGN);

// ✅ 或更精确控制
void *buf = kmalloc_node(size, GFP_KERNEL, node);

// dma_map_single 内部会根据 dma_handle 调整 Cache 行写回边界
// 但不同平台实现不同,显式对齐更安全

// ❌ 错误:两个 DMA buffer 共享同一 Cache 行
struct {
    void  *net_buf;     // 0x1000, 4096 bytes
    void  *disk_buf;    // 0x1FFF, 4096 bytes → Cross cache-line!
} shared;

6.2 避免 Bounce Buffer 复制

当设备 dma_mask 不足以覆盖高端内存时(如 32 位 PCI 设备在 8GB RAM 系统),内核必须在低端分配 bounce buffer 并拷贝:

// 原始 buffer 在高端 DMA 不可达区域
void *high_buf = kmalloc(4096, GFP_KERNEL);  // 0x1_80001000 (64-bit PA)

// 32 位设备 (DMA_BIT_MASK(32)): dma_mask = 0xFFFFFFFF
// dma_map_single 发现不可达 → 分配 bounce buffer (GFP_DMA)
void *bounce = alloc_bounce_buffer(4096, GFP_DMA); // 低端

memcpy(bounce, high_buf, 4096);  // 发送前复制到低端
dma_map_single(dev, bounce, ...); // 映射低端地址给设备

// 这个复制开销对于 1Gbps+ 的设备是致命的!

解决方案:使用 CMA(Contiguous Memory Allocator)预留设备可达区域的连续物理内存,或使用 IOMMU(IOMMU 使 32 位设备也能通过 IOVA 访问任意物理内存)。

6.3 IOMMU 性能优化

IOMMU 并非零开销。IOMMU 的页表翻译(类似于 CPU 的 MMU TLB 查询)会引入延迟。但在 Linux 中有几种优化手段:

  • Pass-through 模式:对可信设备可完全禁用 IOMMU(iommu.passthrough=1),物理地址 == 总线地址
  • 大页映射:IOMMU 支持 2M/1G 映射页,减少 TLB 压力
  • -map/unmap 批处理:SG 列表一次调用 dma_map_sg 完成所有映射比多次 dma_map_single 效率高很多

6.4 网卡驱动中的 DMA 最佳实践

// 现代网卡驱动(如 iavf/ice/mlx5)的 RX 数据通路典型模式:

// A. 预分配:NAPI poll 入口时补充 buffer
for (ring slot in rx_ring) {
    // 1. 从 pool 分配 page + 头偏移(预留 headroom)
    skb = napi_alloc_skb(&ring->napi, NET_SKB_PAD + len);
    
    // 2. 映射 DMA(流式,CPU 不可见最初内容)
    dma_addr = dma_map_page(dev, virt_to_page(skb->data),
                            offset_in_page(skb->data), len,
                            DMA_FROM_DEVICE);
    
    // 3. 写入描述符
    rx_desc->read.pkt_addr = cpu_to_le64(dma_addr);
}

// B. 数据包到达 → 中断触发 → NAPI poll 回收
// 4. 当网卡中断到达,在 unmap 前 kernel 读取设备写入
// 5. unmap + 处理:
dma_unmap_page(dev, dma_addr, len, DMA_FROM_DEVICE);
// 6. 现在可以安全访问 skb->data
netif_receive_skb(skb);

// C. 释放
// 7. skb 释放,buffer 回到 page pool
//    page pool 内部缓存 DMA 映射,避免 map/unmap 开销

Linux 5.x+ 引入的Page Pool 进一步消除了重复的 dma_map/unmap 开销——buffer 经回收时保留 DMA 映射(coherent),下次直接从池中取出映射地址复用。

6.5 避免 DMA 死锁与递归

一个反直觉的问题:如果 DMA 控制器本身通过总线完成传输,而该总线也被 CPU 用于处理缺页异常,则 DMA 传输页面自身的缺页会形成死锁。解决方案:

  • DMA 缓冲区分配使用 GFP_DMA / GFP_DMA32 或 zone_dma,从不可换页的 zone 预留内存
  • 不可换页的 GFP_ZERO + GFP_KERNEL + __GFP_NOFAIL
  • 驱动初始化阶段预分配所有 DMA 缓冲区,避免在传输路径中调用可能触发缺页的分配器

七、调试与常见问题排查

7.1 DMA 调试工具箱

# 查看 DMA 相干内存使用情况
cat /proc/meminfo | grep -i dma
cat /sys/kernel/debug/dma_buf/bufinfo         # DMA-BUF 导出信息

# IOMMU 域状态
dmesg | grep -i -E 'iommu|dma|dmabuf'
virsh iommu-group-list vm_name               # KVM 场景(Libvirt)

# DMA 追踪(需 CONFIG_DMA_API_DEBUG)
echo 1 > /sys/kernel/debug/dapi/enable
cat /sys/kernel/debug/dmapools                # DMA pool 使用统计
cat /sys/kernel/debug/dma_buf/summary         # DMA buffer 总览

7.2 典型错误码与根因

错误码/日志根因修复方案
DMA address 0x0dma_map 前未分配 buffer / memory 不足检查 GFP 标志和预留
X86_DMARIOMMU 检测到设备违规 DMA(越权访问)检查驱动越界 / 修复 IOMMU 映射
swiotlb buffer is fullBounce buffer 被耗尽增大 swiotlb=2048 或启用 IOMMU
DMA-API: device ... mapping sg ... from CPU同一 buffer 已被映射确保不双重 map / 严格管理生命周期
coherent pool = 0超出 coherent DMA 限制(如 24 位 ISA)降低 dma_alloc_coherent 大小,或分多块
WARNING: CPU … at … check_syncDMA 传输仍活跃时释放了 buffer等待传输完成 DMA 通道 pending == 0 再释放

7.3 DMA 数据损坏的排查清单

  1. 修改后是否 unmap 前 CPU 访问了 buffer?——CPU 此时看到的可能不是最新数据
  2. unmap 后 DMA 控制器仍有未完成的飞行传输?——设备可能继续写入,CPU 看到的是正在被修改的数据
  3. Cache 行写回假象:DMA 写入的 buffer 与 CPU 近期访问的变量位于同一 Cache 行,且使用 WC(Write-Combining)映射的 MMIO 被意外写入 Cache
  4. IOMMU 页表未更新:dma_map_page 返回后、设备发起 DMA 前,IOMMU TLB 未刷新——需要在 iommu_dma_unmap 后或切换进程后显式 iommu_flush_iotlb_all

八、DMA 安全与隔离

8.1 DMA 攻击案例

IOMMU 诞生前,恶意 PCIe 设备可读写任意物理内存(冷启动攻击变种)。典型案例:

  • Thunderclap(2019):通过 Thunderbolt PCIe 设备绕过 IOMMU 固件漏洞获取系统控制权
  • Screaming Angels(2020):Thunderclap 更新版利用 misconfigured IOMMU 漏洞读取 AES-NI 密钥缓存
  • PCILeech:开源 DMA 攻击工具,支持通过 FPGA PCIe 设备读取 Windows/Linux 内核内存、窃取凭据

8.2 Linux DMA 安全机制

// 1. IOMMU 隔离:每个设备分配独立的 IOVA 页表
iommu_group_add_device(group, dev);

// 2. VFIO(Virtual Function I/O):用户态隔离直通
//    将 PCI 设备的 IOMMU group 直接暴露到用户态驱动
open("/dev/vfio/vfio", O_RDWR);
ioctl(vfio_fd, VFIO_GROUP_SET_CONTAINER, &container_fd);

// 3. DMA buffer 加密(Confidential Computing)
//    如 AMD SEV-SNP / Intel TDX 的物理内存加密,DMA 读取也是密文

// 4. 内核参数加固
// iommu=force          - 强制启用 IOMMU(即使平台报告不需要)
// iommu.strict=1       - 严格模式,IOMMU TLB unmap 后立即 flush
// iommu.passthrough=0  - 禁用直通模式

九、

Linux DMA 子系统是一扇窗口,让我们得以窥见现代计算机系统中 CPU、内存、外设三者之间复杂的协调机制。从硬件 Cache 一致性到 IOMMU 地址隔离,从 bounce buffer 到 scatter-gather 列表,从嵌入式 SoC 的 PL330 到服务器 NVMe 磁盘的 PRP/SGL,每一层都体现了性能与安全的权衡。

DMA 相关的调试可能是驱动开发中最棘手的部分之一——错误通常表现为间歇性数据损坏、难以复现的 edac 错误、或神秘的 IOMMU 异常。但一旦理解了 Cache 同步的根本逻辑和 IOMMU 地址翻译的运作定律,大部分问题都能循迹定位。对于那些需要极致性能的高速网卡和 NVMe 而言,DMA 的精细调优甚至可能带来 40% 以上的吞吐提升——这是 CPU 频率提升好几代才能做到的。

如果你觉得本文对你有帮助,欢迎在评论区讨论你遇到的 DMA 调优难题。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论