DMA传输原理:直接内存访问的概念、DMA引擎工作方式、PCIe DMA数据传输流程

我做了这么多年存储驱动,回头想想,DMA(Direct Memory Access,直接内存访问)这个机制,真是整个高性能IO的基石。你想想看,要是每次数据搬移都得CPU亲自下场,那SSD再快也没用——CPU早就累趴下了。

说白了,DMA就是让外设和内存之间直接传数据,不用CPU逐字节搬运。CPU只需要发个「开始吧」的指令,然后就干自己的活去了。等传输完成,DMA控制器发个中断通知一声,完事。

为什么CPU自己搬数据不行?

我见过不少刚入行的朋友,觉得CPU搬数据也没什么大不了的。其实这里有个巨大的性能鸿沟。做个简单算术:

传输方式 数据量 CPU占用率 延迟
CPU直接搬运 4KB ~100% (忙等) 微秒级
PIO模式 4KB ~80% (轮询) 微秒级
DMA传输 4KB ~0% (释放) 亚微秒级
DMA传输 1MB ~0% 毫秒级(但CPU空闲)
注意:不要被「毫秒级」吓到。这个延迟是从CPU发起请求到传输完成的时间,但CPU在这个时间段里可以处理其他任务。如果用PIO搬1MB数据,CPU会卡住数十毫秒——这在现代系统中完全不可接受。

DMA引擎的三种工作方式

DMA引擎不是只有一种形态。我在不同的项目里遇到过三种主流工作方式,各有各的适用场景:

  1. 飞拍方式(Flyby)——早期PCIe设备用得比较多。 典型流程:DMA控制器从源地址读一个数据,然后直接写到目标地址。数据不经过DMA控制器的内部寄存器。特点是延迟低,但只能做固定地址偏移的搬运。
  2. 块传输方式(Block Transfer)——现在最常用的模式。 我习惯叫它「整块搬」。DMA控制器一次性传输一块连续的数据。源地址和目标地址可以指定,大小也灵活。
  3. 分散-聚集方式(Scatter-Gather)——处理非连续内存的利器。 这个就厉害了。你传的数据可能在内存里是东一块西一块的,DMA引擎能通过一个描述符链表,把这些碎片自动拼接成连续的数据流。NVMe用这种方式特别多。

嗯,说起来我第一次接触Scatter-Gather是在调某个国产SSD的时候。当时上层应用申请的内存不连续,我还傻傻地想着用CPU先拼好再传……后来发现DMA本身就能搞定,真是白费了大力气。

建议:在实际项目中,如果能保证物理连续性,优先用块传输模式——逻辑简单,也容易定位问题。但当系统内存碎片较多时,Scatter-Gather才是正确出路。不要试图在驱动层做内存拼接,那是吃力不讨好。

PCIe DMA数据传输流程

好,现在我们来走一遍完整的PCIe DMA数据传输流程。我会结合NVMe的场景来说,这样更有代入感。

核心流程概览:

CPU设置描述符 → 写入门铃寄存器 → DMA引擎自动搬运 → 完成中断通知 → CPU处理完成

具体来说,分这么几步:

  • Step 1: 准备阶段
    CPU在内存中构建一个DMA描述符。描述符里包含:源地址(比如SSD内部缓存)、目标地址(系统内存缓冲区)、传输长度(比如4KB)。
  • Step 2: 通知硬件
    CPU往NVMe控制器的门铃寄存器写一个值。这个动作告诉控制器:「我有新的任务给你,开始干吧」。
  • Step 3: DMA引擎接管
    控制器内部的DMA引擎读取描述符,然后发起PCIe Memory Read事务(从SSD缓存读数据),再发起PCIe Memory Write事务(把数据写到系统内存)。这中间完全不需要CPU干预。
  • Step 4: 中断通知
    数据全部写完以后,DMA引擎往完成队列写一个完成状态,然后触发MSI/MSI-X中断。
  • Step 5: 驱动收尾
    CPU的中断服务程序被触发,检查完成状态,通知上层应用数据可用。
PCIe DMA数据传输流程 CPU 构建描述符 + 写门铃 NVMe控制器 解析描述符 -> 启动DMA 设备缓存 数据源(SSD内部) ① 写门铃 ② PCIe读取 系统内存 目标缓冲区 ③ PCIe写入 完成队列 状态更新 CPU(中断处理) 检查完成状态 ④ MSI/MSI-X中断 关键优化点: • 描述符预填充(减少CPU干预) • 中断合并(降低中断频率) • 缓存行对齐(提高总线效率)

避坑指南:DMA传输中的常见陷阱

理论说完了,说点实际的。我这些年见过的坑,随便挑几个跟大家聊聊:

  • 缓冲区的Cache一致性——我曾经在一个FPGA项目中,发现DMA写入的数据CPU读出来总是错。查了一天,问题出在CPU缓存上。CPU读取时命中了过时的缓存行。解决办法:要么用DMA内存区域标记为non-cacheable,要么在关键位置手动执行缓存刷新指令。
  • 描述符链表断裂——Scatter-Gather方式依赖描述符链表的物理连续性。如果你的描述符链表本身位于可分页的内存中,并且DMA引擎不支持虚拟地址,那就麻烦了。一定要确保描述符物理连续,或者用IOMMU来做翻译。
  • 中断风暴——高吞吐场景下,每笔传输都触发中断的话,CPU会崩溃在中断处理上。我习惯的做法是:启用中断合并(Interrupt Coalescing),等一定数据量或超时后再触发一次中断。
特别提醒:NVMe的PRP(物理区域页)和SGL(分散聚集列表)机制,本质上就是DMA描述符的不同实现方式。PRP适用于单次传输不超过4KB的情况,超过了就乖乖用SGL。我见过有人非要用PRP传大块数据,结果描述符数量爆增,性能反而下降。

小结

DMA传输的核心价值就是解放CPU。你只要把工作拆成:准备描述符 -> 触发开始 -> 等待完成 这三步,其他的交给硬件去做。对于NVMe场景,尤其要注意描述符的内存布局和Cache处理。记住一句话:DMA擅长的是简单重复劳动,我们要做的就是把这个劳动的说明书写得清楚明白。搞懂这些,后面讲均衡参数调优才有的放矢。