3. DMA传输原理
3.1 DMA控制器核心工作流程
直接内存访问(DMA)是一种允许硬件设备直接读写系统内存而无需CPU干预的数据传输技术。在块设备驱动中,DMA是消除PIO(Programmed I/O)瓶颈、实现高吞吐量I/O的关键机制。
3.1.1 DMA传输的基本阶段
一个完整的DMA传输周期包含以下四个阶段:
- 请求阶段:外设(如NVMe控制器、AHCI控制器)通过硬件信号线向DMA控制器发出DMA请求(DREQ)。
- 响应阶段:DMA控制器向CPU发出总线请求(HRQ),请求接管系统总线控制权。
- 传输阶段:CPU释放总线控制权(HLDA应答),DMA控制器直接在外设与内存之间搬运数据,每传输一个字节/字后递增源/目的地址并递减计数器。
- 结束阶段:当传输计数器归零时,DMA控制器向CPU发送中断信号,通知传输完成。
关键指标: 现代DMA控制器支持突发传输(Burst Transfer)模式,一次总线仲裁可连续传输多个数据单元,将总线获取开销分摊到多个字节上,有效带宽提升可达5-10倍。
3.1.2 DMA控制器内部架构
下图展示了DMA控制器的核心组件及其在传输过程中的协作关系:
3.2 DMA引擎与CPU的协同机制
DMA并非完全取代CPU,而是与CPU形成分工协作的关系。理解两者的协同模式是优化块设备驱动性能的前提。
3.2.1 协同工作模式对比
| 协同模式 | CPU职责 | DMA职责 | 总线占用 | 典型场景 |
|---|---|---|---|---|
| 周期窃取 | 执行指令间隙让出总线 | 窃取一个总线周期传输1字节 | 低(微秒级) | 低速外设(UART、SPI) |
| 突发传输 | 释放总线控制权,等待完成 | 连续传输整个数据块 | 高(毫秒级) | 块设备(NVMe、SATA) |
| 描述符链 | 构建DMA描述符链表,启动后休眠 | 按链表顺序自动执行多次传输 | 间歇性高 | 高性能存储、网络 |
3.2.2 描述符链模式下的协同流程
现代块设备驱动普遍采用描述符链(Descriptor Chain)模式,这是实现异步I/O和IO_uring等高性能接口的基础。其协同流程如下:
性能要点: 描述符链模式下,CPU只需在启动时构建链表并写入DMA控制器的描述符地址寄存器,之后即可处理其他任务。DMA控制器自动遍历链表执行传输,全部完成后才触发一次中断。这种方式将中断频率从每传输一次中断一次降低为每批传输中断一次,中断开销降低90%以上。
3.3 Scatter/Gather DMA与Block DMA的区别
在块设备驱动中,DMA传输面临一个核心挑战:物理内存碎片化。文件系统和块层分配的缓冲区可能分散在多个物理页中,而DMA控制器需要连续的物理地址。Scatter/Gather DMA和Block DMA是解决这一问题的两种不同策略。
3.3.1 核心概念对比
| 对比维度 | Block DMA(传统DMA) | Scatter/Gather DMA(SG-DMA) |
|---|---|---|
| 内存要求 | 要求源/目的缓冲区物理连续 | 允许缓冲区物理不连续(分散在多个页) |
| 传输单元 | 一个连续的地址块 | 多个不连续的地址段(Scatter/Gather列表) |
| CPU干预 | 每次传输前需CPU确保内存连续(可能需内存拷贝) | CPU只需构建SG列表,DMA自动遍历 |
| 内存利用率 | 低(需预留连续大块内存,易产生碎片) | 高(可利用任意空闲物理页) |
| 典型硬件 | 旧式IDE控制器、部分低端SoC | NVMe、AHCI(SATA)、virtio-blk、大多数现代DMA引擎 |
| 驱动复杂度 | 低(简单地址+长度) | 中(需管理SG表、处理表项溢出) |
3.3.2 两种DMA模式的架构对比
3.3.3 实际应用中的选择策略
在Linux块设备驱动中,Scatter/Gather DMA已成为主流选择,但Block DMA在特定场景下仍有价值:
- 小数据量传输(< 4KB):Block DMA的简单性使其在传输小数据块时开销更低,无需构建SG列表。
- 硬件不支持SG:部分老旧或低功耗DMA控制器只支持Block模式,此时驱动需在软件层面做SG合并(SG coalescing)或反弹缓冲区(bounce buffer)。
- 高性能NVMe驱动:几乎全部使用SG-DMA,配合PRP(Physical Region Page)或SGL(Scatter Gather List)描述符格式,单次I/O可引用多达64K个分散段。
避坑指南: 在实现SG-DMA驱动时,必须注意SG表项数量限制。硬件DMA引擎的SG列表深度是有限的(如AHCI支持最多32个SG表项,NVMe支持更多但仍有上限)。当块层下发的bio包含过多物理段时,驱动需要进行SG合并或分段提交,否则DMA控制器会拒绝传输。
3.3.4 Linux内核中的SG-DMA API
Linux内核提供了一套标准的SG-DMA API,块设备驱动开发者应熟练掌握:
/* 1. 分配SG表 */
struct scatterlist *sg;
int nents = blk_rq_nr_phys_segments(rq);
sg = kmalloc_array(nents, sizeof(*sg), GFP_KERNEL);
/* 2. 初始化SG表 */
sg_init_table(sg, nents);
/* 3. 将bio中的页映射到SG表 */
struct req_iterator iter;
struct bio_vec bv;
int i = 0;
rq_for_each_segment(bv, rq, iter) {
sg_set_page(&sg[i++], bv.bv_page, bv.bv_len, bv.bv_offset);
}
/* 4. 映射SG表用于DMA传输(返回实际使用的SG表项数) */
int mapped = dma_map_sg(dev, sg, nents, dir);
/* 5. 遍历映射后的SG表,获取总线地址和长度 */
for_each_sg(sg, sgl, mapped, i) {
dma_addr_t addr = sg_dma_address(sgl);
unsigned int len = sg_dma_len(sgl);
/* 将addr和len写入DMA控制器的SG列表寄存器 */
}
/* 6. 传输完成后解映射 */
dma_unmap_sg(dev, sg, nents, dir);
关键函数说明:
dma_map_sg() 是SG-DMA的核心函数,它执行两个重要操作:① 将物理页地址转换为DMA总线地址(可能经过IOMMU转换);② 合并物理上连续的相邻SG表项,减少硬件SG列表的深度。返回值 mapped 可能小于 nents,驱动必须使用 for_each_sg 宏遍历映射后的表项。