Linux 内核 DMA 子系统概述:dmaengine 框架简介
做 DMA 驱动这么多年,最让我头疼的事情之一就是不同厂商的 DMA 控制器接口千差万别。比如 i.MX 平台和 STM32 平台的 DMA 寄存器完全不一样,如果每个驱动都自己造轮子,那内核驱动栈就变成一团乱麻了。好在 Linux 内核很早就意识到了这个问题,搞出了 dmaengine 框架。说白了,它就是一套统一的 DMA 传输抽象层,让设备驱动不用关心底层硬件怎么控寄存器,只要调 API 就行了。
dmaengine 框架长什么样?
你想想看,一个典型的 DMA 传输场景是这样的:设备驱动(比如音频或网卡驱动)需要把内存数据搬运到 FIFO 或从 FIFO 搬运回来。如果没有框架,你得自己配置 DMA 通道、设置源地址、目的地址、字节数,然后启动传输,最后还得处理中断——每个芯片都要重写一遍。dmaengine 把这些步骤抽象成四层:
- 用户层驱动(Peripheral Driver):比如 SPI、I2C、UART 驱动,它们调用 dmaengine 的 slave API 提交传输请求。
- 虚拟通道层(Virtual DMA):这是内核帮忙封装的一层,专门处理多队列、事务排队、链表提交等通用逻辑。
- 核心层(dmaengine core):管理通道分配、设备注册、传输描述符池等。
- 控制器驱动(Controller Driver):具体芯片的 DMA 驱动,实现底层硬件操作回调。
我刚开始学的时候总搞不清“虚拟通道”和“硬件通道”的区别。后来在调试一个双通道 DMA 的 bug 时才算彻底弄明白:虚拟通道是一个纯软件概念,一个硬件通道可以同时支持多个虚拟通道,内核用链表把待办传输挂上去——这就是多队列的基础。
核心数据结构:你得记住的几个 struct
写 dmaengine 驱动其实就是在跟下面几个结构体打交道。我给你列出最关键的三个:
| 结构体 | 作用 | 我踩过的坑 |
|---|---|---|
struct dma_device | 代表一个 DMA 控制器,包含通道列表、设备能力、操作回调 | 必须正确设置 cap_mask,否则 DMA 通道无法申请 |
struct dma_chan | 一个 DMA 通道实例,每个通道绑定到某个硬件通道或虚拟通道 | 注意 chan->private 不要乱改,容易导致通道泄漏 |
struct dma_async_tx_descriptor | 单次 DMA 传输的描述符,包含源/目的地址、长度、回调函数等 | 回调上下文必须填充正确,我见过因为 callback 为 NULL 导致死锁的 |
嗯,这里要注意dma_device的device_alloc_chan_resources和device_free_chan_resources回调。如果你写的控制器驱动没实现这两个,申请通道时会直接返回 -ENOMEM。我记得之前帮同事排查一个问题,他注册的 dma_device 没填这两个回调,结果音频驱动怎么都申请不到通道——查了半天才发现是驱动侧少实现了两个函数。
传输生命线:从 prep 到 submit 再到 callback
一个典型的 dmaengine 传输流程就三步。我用代码片段聊,你一看就懂:
// 1. 准备传输描述符(注意方向、地址、长度)
struct dma_async_tx_descriptor *tx;
tx = dmaengine_prep_slave_sg(chan, sgl, nents, direction,
DMA_PREP_INTERRUPT | DMA_CTRL_ACK);
if (!tx) {
dev_err(dev, "prepare failed\n");
return -EIO;
}
// 2. 设置完成回调(特别重要)
tx->callback = my_dma_done;
tx->callback_param = my_priv_data;
// 3. 提交并启动传输
dma_cookie_t cookie = dmaengine_submit(tx);
dma_async_issue_pending(chan);
我习惯把dma_async_issue_pending放在提交之后立马调用,这样通道会立即处理队列里的描述符。如果你希望攒一批再批量触发,可以用dma_async_issue_pending延迟调用——这在多队列场景下很有用。曾经有一个项目,我在批处理模式下忘记调dma_async_issue_pending,结果传输队列一直 pending,测试卡了一整天……从那以后,我每次提交完都会检查 pending 状态位。
虚拟通道:多队列的基石
你可能会问:多队列 DMA 是怎么实现的?答案就藏在虚拟通道(virt-dma)里。内核源码在 drivers/dma/virt-dma.h 里定义了一套标准结构:
struct virt_dma_chan:包含一个struct dma_chan基类、一个工作队列、一个传输描述符链表。struct virt_dma_desc:继承dma_async_tx_descriptor,额外包含链表节点和完成状态。
框架把每个硬件 DMA 通道做成多个虚拟通道的“服务端”。每个虚拟通道都有自己的待办链表,内核依次把链表的描述符喂给硬件通道。这样做的好处是:一个硬件通道可以服务多个虚拟通道,通过软件调度实现多队列效果。我在做网络 DMA 卸载时,利用 virt-dma 把接收队列和发送队列分别绑定到不同的虚拟通道上,结果重传时不会互相阻塞——这就是多队列的本质。
💡 快速判断你的驱动是否用了 virt-dma:检查源码中是否有 vchan_init 和 vchan_tx_submit 调用。如果出现了,说明驱动已经拥抱了多队列框架,而不是每个通道独占一个硬件资源。
避坑指南
我自己在驱动开发中积累了几个关键教训,写在这里希望能帮你少走弯路:
⚠️ 曾经有一次,我在中断上下文里直接调用了 dmaengine_prep_slave_sg——结果 panic 了。 因为 prep 函数可能会分配内存(GFP_KERNEL),而中断上下文不允许睡眠。解决方案:要么在 tasklet 或工作队列里 prep,要么提前分配好描述符池。还有,dmaengine_submit 之后别再修改描述符内容,硬件拿到指针后可能正在读取,改了就是数据不一致。
✅ 调试小技巧:每次提交传输时打印 cookie 和描述符地址。如果发现 cookie 为负值,说明通道无效或 dmaengine 内部错误。我写了个小脚本,每次 dmaengine 传输出错时自动 dump 通道状态——这个方法帮我揪出了三四个厂商驱动的寄存器配置错误。
表格总结:不同 API 的关系
最后,我用一个表格把常用 API 和它们的作用串起来,方便你查阅:
| API 函数 | 所属层面 | 说明 |
|---|---|---|
dma_request_channel | 核心层 | 申请一个符合能力的 DMA 通道 |
dmaengine_slave_config | slave API | 配置传输方向、地址宽度、突发大小等 |
dmaengine_prep_slave_sg | slave API | 基于 scatter-gather 列表准备传输 |
dmaengine_submit | 核心层 | 把描述符放入通道的待办队列 |
dma_async_issue_pending | 核心层 | 启动通道处理待办队列中的描述符 |
dmaengine_desc_get_cookie | 核心层 | 获取传输的 cookie,用于轮询完成状态 |
dmaengine 框架设计得很巧妙——它把通用逻辑和硬件无关的细节都封装好了。你只需要写好那几个硬件回调(prep、start、pause、resume、terminate),剩下的事情内核会帮你打理。好了,这一节的关键点就这些。记住:多队列的核心不在硬件有多少个通道,而在于虚拟通道对链表的调度能力。