3. DMA控制器基础:DMA工作原理、SG列表、循环模式、中断与轮询

DMA,全称 Direct Memory Access,直接存储器访问。说白了,就是让数据绕过 CPU,直接在内存和外设之间搬砖。CPU 只需要告诉 DMA:「从哪搬,搬到哪,搬多少」,然后就可以去干别的事了。

我刚开始做嵌入式时,总觉得 DMA 是个黑盒子。后来调一个高速 ADC 采集的项目,CPU 被中断淹没了,系统几乎卡死。嗯,从那以后我才真正重视 DMA。这一章,我就把 DMA 的核心机制掰开揉碎讲给你听。

3.1 DMA 工作原理:一次完整的搬运

一个典型的 DMA 传输,包含三个要素:

  • 源地址:数据从哪来(比如 UART 接收寄存器)
  • 目的地址:数据搬到哪(比如内存缓冲区)
  • 传输长度:搬多少字节

DMA 控制器内部有专门的地址寄存器和长度计数器。启动后,每个时钟周期搬一个单元,直到计数器归零。整个过程 CPU 零参与。

关键点:DMA 传输的单位可以是字节、半字(2字节)或字(4字节)。具体用哪种,要看外设寄存器的位宽。UART 通常按字节传输。

我画了一张 DMA 传输的简化流程图,帮你理解数据流向:

DMA 传输数据流向图 CPU DMA 控制器 内存 (RAM) UART 外设 ① 配置 ② 读请求 ③ 写入 ④ 传输完成中断

3.2 SG 列表:散列-收集的艺术

SG 列表,全称 Scatter-Gather List,散列-收集列表。为什么需要它?

你想想看,如果一次 DMA 传输只能搬一整块连续内存,那太死板了。实际项目中,数据可能分散在多个缓冲区里。比如网络协议栈,一个数据包的头和负载可能在不同的内存位置。

SG 列表就是用来解决这个问题的。它是一个描述符数组,每个描述符包含:

  • 源地址指针
  • 目的地址指针
  • 传输长度
  • 下一个描述符指针(链表结构)

DMA 控制器自动遍历这个链表,逐个完成每个描述符的传输。全部完成后,再通知 CPU。

我的经验:在 Linux 内核里,SG 列表用 struct scatterlist 表示。驱动开发中,申请 DMA 缓冲区时尽量用 dma_alloc_coherent 或 dma_map_sg,保证物理地址连续。我曾经踩过一个坑:用 kmalloc 分配的内存做 DMA,结果在高内存压力下物理页不连续,DMA 传着传着就崩了。

3.3 循环模式:永不停歇的数据流

循环模式,也叫环形缓冲区模式。DMA 传输完一轮后,自动回到起始地址重新开始。适合音频采集、UART 连续接收这类场景。

我举个例子:UART 接收 1024 字节的环形缓冲区。DMA 配置好源地址(UART 数据寄存器)、目的地址(缓冲区)、长度(1024)。启动后,DMA 从 0 开始写,写到 1023 后自动跳回 0。CPU 只需要定期检查缓冲区,取走数据。

特性 普通模式 循环模式
传输完成后地址 停在末尾 自动回到起始
CPU 干预频率 每次传输完都要处理 只需处理缓冲区半满/全满
适用场景 单次数据块传输 持续数据流
中断开销 高 低

注意:循环模式下,CPU 和 DMA 同时访问同一个缓冲区,必须处理好读写指针的同步。我曾经见过一个产品,DMA 写速度比 CPU 读速度快,导致数据覆盖,音频出现爆音。解决方案是使用「半传输中断」和「全传输中断」双指针机制。

3.4 中断与轮询:两种通知方式

DMA 传输完成后,怎么通知 CPU?两种方式:中断和轮询。

3.4.1 中断方式

DMA 控制器在传输完成时,向 CPU 触发一个中断。CPU 暂停当前任务,进入中断服务程序处理数据。

  • 优点:响应及时,CPU 利用率高
  • 缺点:频繁中断会拖慢系统(中断风暴)

3.4.2 轮询方式

CPU 定期检查 DMA 的状态寄存器,看传输是否完成。

  • 优点:实现简单,没有中断上下文切换开销
  • 缺点:CPU 空转,浪费算力

我个人习惯:数据量小、频率低时用中断;数据量大、频率高时用轮询或混合模式。比如 UART 115200 波特率,每秒约 11.5KB,用中断完全没问题。但如果是高速 SPI 或摄像头数据流,我建议用轮询加 DMA 循环模式。

避坑指南:我曾经在一个项目里,UART 中断频率太高,导致系统响应变慢。后来我把中断改成「DMA 半满中断 + 轮询读取剩余数据」,问题解决了。具体做法是:DMA 配置为循环模式,设置半传输中断。每次中断处理一半缓冲区,另一半留给 DMA 继续写。这样中断频率降低了一半,CPU 有更多时间处理其他任务。

3.5 本章小结