4. DMA 传输优化:描述符链、Scatter-Gather、环形缓冲区与 Cache 一致性
DMA 这玩意儿,说白了就是让外设直接跟内存打交道,CPU 不用一趟趟去搬数据。但很多工程师把 DMA 用成了「高级中断」,性能根本没榨出来。我这些年调过不少 DMA 相关的坑,今天把几个核心优化点掰开揉碎讲清楚。
4.1 DMA 描述符链:告别单次传输
早期 DMA 控制器一次只能处理一个传输任务。传完一个 buffer,就得进中断重新配置。中断一多,CPU 又被拖下水了。
描述符链(Descriptor Chain) 就是解决这个问题的。每个描述符里存着下一次传输的地址、长度、控制标志,DMA 硬件自己会顺着链表往下走,传完一个自动取下一个。
核心收益: 一次配置,多次传输。中断次数从 N 次降到 1 次。
我习惯把描述符链做成静态数组,在初始化时一次性建好。举个例子:
// 描述符结构(以某款常见 DMA 控制器为例)
struct dma_desc {
uint32_t src_addr;
uint32_t dst_addr;
uint32_t length;
uint32_t ctrl; // 控制位:链使能、中断使能等
uint32_t next_desc; // 指向下一个描述符的物理地址
};
// 静态描述符池
struct dma_desc desc_pool[8] __attribute__((aligned(64)));
void dma_chain_init(void) {
for (int i = 0; i < 7; i++) {
desc_pool[i].src_addr = (uint32_t)src_bufs[i];
desc_pool[i].dst_addr = (uint32_t)dst_bufs[i];
desc_pool[i].length = BUF_SIZE;
desc_pool[i].ctrl = DMA_CHAIN_EN;
desc_pool[i].next_desc = (uint32_t)&desc_pool[i + 1];
}
// 最后一个描述符:链结束,触发中断
desc_pool[7].ctrl = DMA_CHAIN_EN | DMA_INT_EN;
desc_pool[7].next_desc = 0;
}
避坑: 描述符必须放在 DMA 可访问的内存区域,且地址对齐到 DMA 控制器的要求(通常是 32 字节或 64 字节)。我曾经因为对齐不对,DMA 读描述符读到一半数据错位,整个链直接崩了。
4.2 Scatter-Gather DMA:零拷贝的基石
实际项目中,数据很少整整齐齐排在一块连续内存里。网络协议栈的 sk_buff、文件系统的 page cache,都是分散的。这时候 Scatter-Gather DMA 就派上用场了。
Scatter(分散): 从外设读数据,DMA 自动写到多个不连续的 buffer。
Gather(聚集): 从多个不连续的 buffer 读数据,DMA 自动拼好发给外设。
你想想看,如果没有 Scatter-Gather,你得先 memcpy 把数据拼到一块连续区域,再启动 DMA。这一来一回,延迟和带宽都浪费了。
| 场景 | 传统方式 | Scatter-Gather DMA |
|---|---|---|
| 网络收包 | DMA → 连续 buf → memcpy → sk_buff | DMA 直接写入 sk_buff 的 frag 列表 |
| 文件系统写 | 收集各 page → 拼连续 buf → DMA | DMA 直接从 page 列表读取 |
| 视频采集 | DMA → 大连续 buf → 裁剪到各帧 | DMA 分散写入各帧 buffer |
实现上,Scatter-Gather 通常也是靠描述符链。每个描述符指向一个片段,DMA 硬件依次处理。区别在于描述符的 src/dst 地址可以任意跳变。
注意: 不是所有 DMA 控制器都支持 Scatter-Gather。选型时一定要看 datasheet 里有没有「SG mode」或「Multi-block transfer」。我踩过这个坑——项目做到一半发现芯片不支持,只能软件模拟,性能直接腰斩。
4.3 环形缓冲区设计:生产者与消费者的默契
DMA 和 CPU 之间怎么传数据?最经典的做法就是环形缓冲区(Ring Buffer)。DMA 往里面写,CPU 从里面读,或者反过来。
环形缓冲区的核心设计要点就三个:
- 头尾指针: 生产者写 head,消费者读 tail。指针绕回时取模。
- 空满判断: 常用「留一个空位」法——当 (tail + 1) % size == head 时认为满。
- 描述符回环: DMA 描述符链的最后一个指向第一个,形成硬件环形。
我见过不少新手把环形缓冲区做成软件 memcpy 版本——DMA 写完一个位置,CPU 把数据拷走,再把位置腾出来。这其实失去了 DMA 的意义。正确的做法是:
// 硬件环形缓冲区 + 描述符回环
#define RING_SIZE 16
struct dma_desc ring[RING_SIZE] __attribute__((aligned(64)));
void ring_init(void) {
for (int i = 0; i < RING_SIZE; i++) {
ring[i].src_addr = (uint32_t)buf_pool[i];
ring[i].length = BUF_SIZE;
ring[i].ctrl = DMA_CHAIN_EN;
ring[i].next_desc = (uint32_t)&ring[(i + 1) % RING_SIZE];
}
// 最后一个描述符使能中断
ring[RING_SIZE - 1].ctrl |= DMA_INT_EN;
}
这样 DMA 会一直循环写 buffer,每写完一圈触发一次中断。CPU 在中断里处理已经填满的 buffer,同时更新 tail 指针。DMA 和 CPU 各干各的,互不阻塞。
个人习惯: 环形缓冲区的大小最好是 2 的幂,这样取模可以用位运算 & (size-1),比除法快一个数量级。在中断处理函数里,每一条指令都金贵。
4.4 DMA 与 Cache 一致性处理:最隐蔽的坑
嗯,这里要注意。DMA 直接访问物理内存,而 CPU 访问的是 Cache。如果 DMA 往内存写了数据,但 Cache 里还存着旧值,CPU 读到的就是脏数据。反过来,CPU 写了数据但还在 Cache 里没写回内存,DMA 去读就会读到垃圾。
这就是 Cache 一致性问题。 处理方式分两种:
| 方案 | 做法 | 适用场景 |
|---|---|---|
| Cache 刷新/无效化 | 手动调用 cache_flush / cache_invalidate | 小数据量、非频繁传输 |
| DMA 使用非 Cache 内存 | 将 buffer 分配到非 Cache 区域 | 大数据量、流式传输 |
| 硬件 Cache 一致性 | SoC 内部有 snoop 机制自动同步 | 高端 SoC(如 ARM CCI/CMN) |
我建议在嵌入式 Linux 里用 dma_alloc_coherent 分配 buffer,它自动给你非 Cache 的内存。裸机环境下,就得自己维护了:
// DMA 传输前:CPU 写完数据,刷 Cache 让 DMA 看到
cache_flush_dcache(buf, size);
dma_start(desc);
// DMA 传输完成后:CPU 读数据前,无效化 Cache
dma_wait_done();
cache_invalidate_dcache(buf, size);
process_data(buf);
我曾经: 在一个音频驱动里忘了做 Cache 无效化,播放出来的声音断断续续还夹杂爆音。查了两天才发现是 DMA 写完了 buffer,CPU 读的却是 Cache 里的旧数据。从那以后,我每次 DMA 操作前后都会检查 Cache 操作有没有漏掉。
4.5 知识体系总览
下面这张图把四个优化点的关系串起来了。你可以把它当作 DMA 优化的检查清单:
这四个点不是孤立的。描述符链是实现 Scatter-Gather 和环形缓冲区的基础;环形缓冲区又依赖 Cache 一致性处理来保证数据正确。实际项目中,我通常把这四者组合使用:用描述符链实现 Scatter-Gather 环形缓冲区,buffer 分配在非 Cache 区域,彻底避免一致性问题。
一句话总结: DMA 优化的本质,就是让硬件多干活、CPU 少操心。描述符链减少中断,Scatter-Gather 减少拷贝,环形缓冲区减少等待,Cache 一致性减少 bug。