DMA传输核心三要素:描述符、环形缓冲、中断机制

搞PCIe DMA,说到底就是三个东西在跳舞:描述符、环形缓冲、中断机制。这三者缺一个,传输就玩不转。我刚入行那会儿,总觉得描述符就是个结构体,环形缓冲就是个队列,中断就是发个信号——简单嘛。结果第一个板卡调了整整两周才跑通,就是因为对这三个角色的配合关系没吃透。

这一章咱们就把它们拆开揉碎,看看每个到底干嘛的,怎么配合才能让数据飞起来。

1. 描述符——传输的“任务单”

描述符(Descriptor)说白了就是一张任务单。CPU往这张单子上写:从哪取数据、写到哪、传多少字节、传完要干嘛。然后DMA控制器照着单子干活。

我习惯把它看成“文件传输请求”的精简版。每个描述符通常包含这些字段:

字段说明常见位宽
源地址PCIe设备侧的内存地址或BAR空间64位
目的地址主机侧物理地址(HPA)64位
传输长度一次DMA搬运的字节数16/32位
控制标志中断使能、链式使能、方向等16位
状态字段传输完成后硬件回写的完成状态32位
下一个描述符指针链式DMA时指向下一个描述符64位
避坑小记: 我之前给描述符里的长度字段只配了16位,结果一次传超过64KB就截断了,设备直接挂死。你想想,现在动不动传几MB数据,长度字段至少给32位,甚至用硬件自动分包。

描述符的存放位置也很关键。大部分方案让驱动程序在主机内存里分配一块连续的区域,把描述符数组放在那,然后通过寄存器把基地址告诉DMA控制器。控制器通过PCIe读描述符,拿到任务信息,再启动数据搬运。

// 典型的描述符结构体(C语言)
struct dma_descriptor {
    uint64_t src_addr;    // 源地址(设备侧)
    uint64_t dst_addr;    // 目的地址(主机侧)
    uint32_t length;      // 传输长度(字节)
    uint16_t control;     // 控制标志位
    uint16_t status;      // 硬件回写状态
    uint64_t next_desc;   // 链式下一个描述符地址
} __attribute__((packed));

2. 环形缓冲——描述符的“传送带”

单个描述符太慢了。你想,每传一笔都中断一次CPU,让CPU重新写描述符再启动,那跟PIO拷贝有啥区别?

所以就有了环形缓冲(Ring Buffer)。它本质是一个固定大小的描述符数组,头和尾指针在里边循环转。CPU在尾部插入新任务,DMA从头部取任务,处理完把头指针往前挪。就像工厂的传送带——CPU往传送带上放零件,DMA机械手拿走加工。

为什么叫“环形”?因为数组是循环使用的,写到底了就绕回开头。这样做最大的好处是:减少CPU和硬件的交互次数。CPU可以一次塞一批描述符进去,然后DMA自己挨个处理,处理完一根,自动取下一根——这个机制就是后面要讲的“链式DMA”的雏形。

关键指标: 环形缓冲的深度(Entry数量)直接影响吞吐。太浅,CPU来不及填描述符,DMA会等空;太深,占内存且延迟响应。我一般从128个Entry开始调,根据实际负载增减。

环形缓冲还有两个重要寄存器:生产索引(Producer Index)和消费索引(Consumer Index)。CPU更新生产索引,DMA更新消费索引。怎么判断队列满了还是空了?有很多种实现,我最常用的是“留一个空位”法:当消费索引+1 == 生产索引时,队列满;当两者相等时,队列空。

// 判断环形缓冲状态(假设深度为N)
#define RING_SIZE  128
int ring_full(unsigned int prod, unsigned int cons) {
    return ((prod + 1) % RING_SIZE) == cons;
}
int ring_empty(unsigned int prod, unsigned int cons) {
    return prod == cons;
}
踩过的坑: 我曾经犯过一个低级错误——没把生产和消费索引声明为volatile。结果编译器把寄存器值优化到缓存里,DMA那边索引都更新两轮了,CPU读到的还是老值,整个系统乱套。记住,跨硬件/软件边界的变量,必须用volatile。

3. 中断机制——传输的“完成通知”

描述符和环形缓冲搞定了怎么干活,但CPU怎么知道活干完了?总不能一直轮询状态寄存器吧?那块PCIe带宽就浪费了。

这时候就得靠中断。DMA传输完一个描述符或一批描述符后,向CPU发送一个MSI/MSI-X中断。中断处理程序里,驱动更新消费索引,把完成的数据交给上层协议栈或用户态程序。

中断有两种常见模式:

  • 每描述符中断:每个描述符完成都发一次中断。响应最快,但中断频率太高,CPU扛不住。适合低带宽、低延迟的场景,比如控制类消息。
  • 批量中断:完成一批描述符(比如消费索引跳过N个)才发一次中断。高吞吐场景的首选,我做过NVMe SSD的驱动,就是批量中断配合中断合并(Interrupt Coalescing),吞吐提升30%以上。
个人习惯: 我通常把中断控制在每1ms~10ms触发一次,根据数据速率动态调整。太密集的中断会吃掉CPU,太稀疏的又会增加延迟。用MSI-X多队列的话,还可以把不同描述符的中断分配到不同CPU核心,做到负载均衡。

中断的另一个重要细节是中断状态清除。硬件在描述符的状态字段里写一个完成标志,驱动读到这个标志后,再写寄存器清除中断。如果时序没处理好——比如驱动还没读完状态,硬件就把中断清掉了——那数据就可能丢失。

三要素的协同工作流

为了让你更直观地理解这三者怎么配合,我画了一张图。它把描述符、环形缓冲、中断的交互流程串了起来:

DMA传输三要素协同流程 CPU(驱动) 环形缓冲 (描述符数组) DMA控制器 设备内存/外设 主机内存 (数据缓冲区) 中断控制器 填写描述符 读取描述符 读取源数据 写回数据 完成中断 IRQ 更新生产索引 流程说明: ① CPU在主机内存的环形缓冲中写入描述符,更新生产索引。 ② DMA控制器通过PCIe读取描述符,获得源地址、目的地址、长度。 ③ DMA从设备读取数据,经PCIe写入主机内存;完成后更新描述符状态并发出MSI-X中断。 ④ CPU中断处理程序消费数据,更新消费索引,解锁新描述符槽位。

实践经验:把三者串起来

你看这张图,从左到右,从CPU到DMA到中断,形成了一个闭环。实际调试时,我最常用的方法是:先确认描述符的格式对不对(拿逻辑分析仪抓一次PCIe读写),再看环形缓冲的索引是否按预期增减,最后挂个示波器看中断引脚(或者用PCIe tracer看MSI-X包)。

一句话总结核心: 描述符是任务,环形缓冲是任务队列,中断是完成信号。驱动把任务排进队列,硬件按队列顺序执行,执行完了拉铃通知。这三样配合好了,DMA就能像流水线一样全速跑。

嗯,这一章内容比较多,但你只要记住这三个概念以及它们之间的交互,后面讲链式DMA、多队列、原子操作的时候,你就不会觉得乱了。