2. DMA 基本概念:引擎架构、描述符链、环形缓冲区、寄存器映射

做 DMA 驱动,先把这四个东西搞明白:引擎架构、描述符链、环形缓冲区、寄存器映射。这四个不是孤立的,它们串起来就是 DMA 的完整工作流。

2.1 DMA 引擎架构

说白了,DMA 引擎就是一块硬件,负责在内存和 PCIe 设备之间搬数据,不需要 CPU 插手每次拷贝。我习惯把它拆成三块来看:

  • 读引擎(Read Engine):从源地址取数据。源可能是系统内存,也可能是设备本地内存。
  • 写引擎(Write Engine):把数据写到目的地址。
  • 控制逻辑(Control Logic):解析描述符、管理通道、处理中断。

你想想看,如果读引擎和写引擎不协调,数据就可能乱。我在项目中遇到过一块 FPGA 卡,读引擎时钟域跨到了写引擎那边,结果传输总丢最后一个字节。排查了三天,最后发现是异步 FIFO 深度设小了。

PCIe DMA 引擎通常支持多通道(Multi-Channel)。每个通道独立跑自己的描述符链,通道之间不互相干扰。多通道的好处是——你可以把高优先级流量和高吞吐流量分开,避免互相影响。

特性 单通道 多通道
并发性 低,一次只能处理一个传输 高,多个传输并行
优先级支持 差,无法区分流量 好,可分配不同优先级
中断压力 低 较高,需合理聚合中断
典型场景 简单块设备 网络、GPU、存储阵列

我个人习惯:调试阶段先只开一个通道,跑通后再上多通道。否则描述符错位、中断风暴一起出现,根本分不清是哪里的问题。

2.2 描述符链(Descriptor Chain)

描述符是啥?就是一个个“运输任务单”。每个描述符告诉 DMA 引擎:从哪取、放到哪、搬多少字节、搬完要不要发中断。

// 典型 DMA 描述符结构
struct dma_descriptor {
    uint64_t src_addr;      // 源地址
    uint64_t dst_addr;      // 目的地址
    uint32_t length;        // 传输长度
    uint16_t flags;         // 控制标志
    uint16_t next_ptr;      // 下一个描述符指针
};

描述符链就是把这些“任务单”串成链表。引擎从链头开始,拿到一个执行一个,执行完通过 next_ptr 找到下一个,直到链尾。

为什么要用链?

  • CPU 一次性提交多个传输任务,不用每次等中断再下发下一个。
  • 引擎空闲时会自动取下一个任务,CPU 利用率大幅提升。
  • 链可以动态增加——你可以在引擎跑着的时候往链尾追加描述符,很多高端 DMA 引擎都支持“运行时追加”。

嗯,这里要注意:描述符的内存必须物理连续吗?不一定。有些引擎要求所有描述符在一个连续的物理内存块里,有些则支持分散的页。你得看芯片手册,这个坑我踩过——某款国产控制器要求描述符物理连续,我没仔细看,结果 DMA 老是随机停。

我曾经吃过这样的亏:描述符的 next_ptr 写错了物理地址,引擎跳到了一个随机内存区域,解析出了非法描述符,直接卡死整条链路,必须复位控制器才能恢复。从此我写描述符链的代码,都会加一个地址有效性校验。

2.3 环形缓冲区(Ring Buffer)

环形缓冲区其实就是描述符链的“环状”版本。链表的尾节点再指向头节点,就形成了一个环。

为什么要用环?有两个原因:

  1. 避免频繁分配/释放:描述符数组提前分配好,环内指针循环递进,没有内存碎片。
  2. 硬件友好:很多 PCIe DMA 引擎原生支持环形缓冲区,硬件内部维护一个生产指针(生产者是 CPU/驱动,消费者是 DMA 引擎)和一个消费指针,减少了软件层面的锁开销。

你写驱动的时候,通常需要在内存中分配一块 DMA 可访问的区域(比如用 dma_alloc_coherent),把它做成一个环形描述符数组,然后把起始地址和大小告诉 DMA 控制器。

关键概念对比

维度描述符链环形缓冲区
存储结构链表(动态链接)定长数组(循环索引)
内存分配可以动态新增/释放预先分配好固定大小
硬件友好度一般,需解析 next_ptr高,硬件可直接索引
适用场景暂停/恢复灵活的流量稳定的高吞吐传输

我个人建议:初期调试推荐用环形缓冲区。因为它结构规整,写错了也容易用肉眼检查描述符数组的状态。等环形缓冲区跑稳了,再考虑改用链式实现动态编排。

2.4 寄存器映射(Register Mapping)

驱动和 DMA 引擎通信,靠的就是寄存器。这些寄存器通过 PCIe BAR(Base Address Register)暴露给 CPU。映射到驱动里就是一段内存地址,你往这个地址读写,就相当于在操作寄存器。

常见的 DMA 寄存器包括:

寄存器名 偏移(举例) 功能
DMA_CTRL 0x00 控制寄存器:启动/暂停/复位
DMA_STATUS 0x04 状态寄存器:忙/闲/错误位
DESC_BASE 0x08 描述符链/环的基地址
DESC_SIZE 0x0C 描述符数量或总大小
PROD_IDX 0x10 生产者索引(环形缓冲区用)
CONS_IDX 0x14 消费者索引(环形缓冲区用)
INT_MASK 0x18 中断掩码寄存器

写驱动的时候,先把 BAR 地址 ioremap 到内核虚拟地址空间,然后就能用 readl/writel 操作了。但记得——寄存器操作一定要用 volatile 指针,否则编译器优化可能会把写操作合并或去掉。

// 典型的寄存器操作代码
void *dma_regs;
dma_regs = ioremap(pci_resource_start(pdev, 2), 
                   pci_resource_len(pdev, 2));

// 启动 DMA 通道
writel(0x1, dma_regs + DMA_CTRL);

// 等待完成
while (readl(dma_regs + DMA_STATUS) & DMA_BUSY)
    cpu_relax();

我曾经踩过的坑:读了一个不存在的寄存器偏移,驱动直接死锁。因为某些 BAR 空间并不是完全映射的,中间有空洞。读空洞返回全 F,但 DMA 引擎的状态寄存器也返回全 F,让我误以为 DMA 一直忙。从此我做寄存器映射之前,一定先用 pci_resource_len 确认 BAR 空间大小,并且只在已知偏移处读写。

说到这里,我想你大概已经理解了——DMA 引擎、描述符链、环形缓冲区、寄存器映射,这四个概念构成了 DMA 传输的骨架。它们之间的关系可以用下面这张图来概括:

DMA 传输核心架构 主机内存 描述符链/环形缓冲区 数据缓冲区 PCIe 总线 DMA 控制器 读引擎 写引擎 控制/中断逻辑 寄存器映射 (DMA_CTRL/STATUS 等) 设备接口 PCIe 设备 设备内存 FIFO PIO 端口 描述符链/环形缓冲区 ↔ DMA 读/写引擎 ↔ 寄存器映射 ↔ PCIe 设备,构成了完整的 DMA 传输路径

好了,以上就是 DMA 四大基本概念。把它们琢磨透了,再去看驱动的具体实现,你会觉得脉络清晰很多。接下来的章节会深入每个部分的具体故障场景和调试方法。