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传输,我习惯把它分成四个步骤。嗯,这里要注意,每个步骤都有可能踩坑。
- 描述符准备:CPU在内存中构建DMA描述符,包含源地址、目的地址、传输长度。这是双方“暗号”,必须对齐。
- 发起传输:CPU写门铃寄存器 (doorbell),告诉设备“描述符已就绪,开始搬吧”。
- 设备执行:DMA引擎从内存读取描述符,然后自己拉着数据在PCIe总线上跑。CPU此时可以睡觉或者干别的活。
- 完成通知:设备写完数据发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图把整个流程串起来,你们感受下:
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报文、地址转换、中断亲和性,会有更清晰的脉络。