3. DMA基本原理:CPU解放的起点

各位好,咱们今天聊聊DMA。说实话,刚入行那会儿,我对DMA的理解就是“CPU不管数据搬运了”。但真正动手调PCIe DMA驱动,才发现事情没那么简单。

DMA,全称Direct Memory Access。说白了,就是让外设和内存之间直接传数据,不用CPU一字节一字节去搬。你想想看,如果每次传几百MB数据,CPU都得亲自盯着每个字,那CPU还干不干别的了?

3.1 直接内存访问概念:谁在干活?

传统PIO模式,CPU像搬运工。举个例子:

// PIO方式:CPU亲自搬
for (i = 0; i < 1024; i++) {
    *dest++ = *src++;   // CPU参与每一次读写
}

DMA模式下,CPU只负责“下命令”,剩下的事交给DMA引擎。我在项目中遇到过,某块FPGA加速卡需要从设备内存搬10GB数据到主机内存。如果用PIO,CPU占用率100%且性能惨不忍睹。切到DMA后,CPU几乎零开销,只管发起传输就好。

核心区别我总结在这张表里:

特性 PIO模式 DMA模式
CPU参与度 每次传输都参与 仅发起和完成时介入
吞吐量 受限于CPU频率 线速可达设备极限
CPU占用 持续占满 几乎为零
适用场景 小量控制数据 大数据块传输

3.2 DMA传输流程:从发起到结束

一个典型的DMA传输,我习惯把它分成四个步骤。嗯,这里要注意,每个步骤都有可能踩坑。

  1. 描述符准备:CPU在内存中构建DMA描述符,包含源地址、目的地址、传输长度。这是双方“暗号”,必须对齐。
  2. 发起传输:CPU写门铃寄存器 (doorbell),告诉设备“描述符已就绪,开始搬吧”。
  3. 设备执行:DMA引擎从内存读取描述符,然后自己拉着数据在PCIe总线上跑。CPU此时可以睡觉或者干别的活。
  4. 完成通知:设备写完数据发MSI-X中断,告诉CPU“活干完了,该你处理了”。

为什么设备需要用描述符?直接指定地址不行吗?

你想想看,PCIe设备访问主机内存在事务层是有开销的。每次读取描述符里的地址,都要走一次PCIe读请求。如果每次传输都用寄存器指定,那CPU得写很多次寄存器,效率极低。描述符能一次指定多段传输,做链式DMA(scatter-gather list),这是PCIe DMA高性能的基石。

核心要点:描述符的地址对齐很重要。我调试过一块卡,由于描述符地址没有做64字节对齐,每次DMA引擎取描述符都要拆分两次PCIe读事务,吞吐直接减半。

我会在内核驱动中这样初始化DMA引擎:

static int dma_engine_start(struct pci_dev *pdev, dma_addr_t desc_phy)
{
    // 1. 把描述符物理地址写入BAR空间
    writel(lower_32_bits(desc_phy), bar + DMA_DESC_ADDR_LO);
    writel(upper_32_bits(desc_phy), bar + DMA_DESC_ADDR_HI);

    // 2. 写门铃,通知设备开始
    writel(DMA_GO_BIT, bar + DMA_DOORBELL);
    return 0;
}

3.3 与CPU交互方式:中断还是轮询?

DMA完成后,怎么通知CPU?我见过两种主流做法:中断和轮询。

  • 中断 (MSI-X):设备写完成状态后,发一个MSI-X消息。CPU响应中断,处理完成队列。优点是CPU空闲时零开销。缺点是高并发下中断风暴可能让CPU崩掉。
  • 轮询 (Polling):CPU不断检查完成队列尾指针。写代码简单,但CPU得空转,100%占住一个核。

我个人习惯混合使用。正常流量用中断,压力测试或低延迟场景切轮询。我在优化NVMe SSD驱动时遇到过,4核CPU每秒50万IOPS,每个IO都触发中断,结果一半CPU时间花在了中断上下文的切换上。换成轮询后,延迟降了30%。

避坑指南:我曾经在ARM平台上遇到中断响应延迟不稳定的情况。后来在中断处理函数中加了统计,发现最坏情况有50微秒的抖动。解决方案是把数据搬运交给CPU在中断底半部做,不让中断顶半部阻塞。

这里用一张SVG图把整个流程串起来,你们感受下:

DMA传输全流程 CPU 准备描述符 主机内存 描述符列表 / 数据缓冲区 PCIe设备 DMA引擎 ①写描述符 ②写Doorbell 取描述符 ③PCIe读 数据传输 MemRd/Wr 写完成状态 ④完成标记 MSI-X中断 CPU处理完成 ⑤中断通知 💡 说明: CPU在步骤①和②中仅做控制面操作,真正的数据搬运由PCIe设备在步骤③④完成。步骤⑤使用MSI-X中断通知CPU,CPU只处理完成队列。

3.4 CPU和DMA的并发模型

DMA执行期间CPU在干嘛?这个问题的答案决定了整个系统性能的好与坏。

我刚做PCIe驱动时想当然地认为DMA开始后CPU睡眠就好了。后来发现,合理的做法是让CPU去准备下一批描述符。这就是Pipeline传输的思路。

实际编码中,我会用描述符环来搞。环上有多个槽位,CPU填一个槽,DMA吃一个槽。像生产者消费者模型:

// 描述符环生产消费示意
static void dma_requeue(struct desc_ring *ring)
{
    while (ring->prod_idx - ring->cons_idx < RING_SIZE) {
        // CPU生产一个描述符
        fill_one_desc(ring, ring->prod_idx);
        ring->prod_idx++;
        // 更新门铃
        writel(ring->prod_idx, bar + DMA_DOORBELL);
    }
}

这样做的好处是:CPU和DMA引擎高度并行。PCIe总线上数据流不断,不会出现“CPU在等DMA,DMA在等CPU”的死锁状态。我测过一块卡,在这种并发模型下,4KB数据块传输吞吐比串行方式高了3倍。

性能调优建议:如果你发现DMA传输期间CPU有大量空闲时间,八成是描述符深度不够。我建议至少准备256个描述符槽位,让DMA引擎一直有活干。描述符深度调大后,PCIe总线利用率和平均延迟都会改善。

嗯,DMA基本原理就讲到这儿。理解这几个核心点,后面看PCIe的TLP报文、地址转换、中断亲和性,会有更清晰的脉络。