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)
环形缓冲区其实就是描述符链的“环状”版本。链表的尾节点再指向头节点,就形成了一个环。
为什么要用环?有两个原因:
- 避免频繁分配/释放:描述符数组提前分配好,环内指针循环递进,没有内存碎片。
- 硬件友好:很多 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 四大基本概念。把它们琢磨透了,再去看驱动的具体实现,你会觉得脉络清晰很多。接下来的章节会深入每个部分的具体故障场景和调试方法。