DMA传输类型:内存到内存、内存到外设、外设到内存、循环链(LLI/Scatter-Gather)详解

咱们做嵌入式开发的,天天跟数据搬来搬去打交道。DMA 就是那个专门干搬砖活的“搬运工”。但不同场景下,搬运工干活的方式不一样。这一章我就把这四种传输类型给你讲透。

我个人习惯先看一张总图,再拆开细讲。你打开脑子里的缓存,先收下这张图:

DMA 传输类型总览 DMA 传输类型 内存 → 内存 (数据拷贝/搬运) 内存 → 外设 (发送/输出) 外设 → 内存 (接收/采集) 循环链 (LLI/SG) 核心特征对比 • 内存↔内存:无需外设参与,纯数数据搬移 • 内存↔外设:需要与外设握手信号配合 • 循环链:支持不连续内存块,链表描述传输任务 • 适用于:大块数据、网络包、音视频流、批量采集

好,咱们一个一个拆。


1. 内存到内存

这种最简单也最基础。说白了就是让DMA把数据从一个内存地址搬到另一个内存地址。CPU只用发个命令,然后就可以去干别的事。我印象很深,有一次处理摄像头采集的图像做颜色空间转换,8位图转24位图,CPU算的话每帧要好几毫秒。后来换成DMA内存到内存搬运加查表法,一帧降到几百微秒。

特点说明
源和目标都在内存地址空间
触发方式软件触发(写触发寄存器或链式触发)
传输单元字节/半字/字/双字,可配置
典型场景内存拷贝、缓冲搬迁、图像重排、数字信号处理前处理
提示: 虽然是内存到内存,但有些DMA控制器要求源和目标的地址对齐到传输宽度。比如你传32位数据,地址必须4字节对齐。否则可能产生硬件错误或性能下降。所以写代码时最好把buffer按cache line对齐。

2. 内存到外设

这个场景你肯定熟悉——往外设送数据。比如用SPI发送一帧波形、用UART打印日志、用I2C给传感器写配置。DMA负责把内存里的数据源源不断喂给外设,CPU只管准备Buffer。

但要注意,外设的接收端通常是一个固定深度的FIFO。如果DMA传太快,FIFO满的时候外设会拉反压信号;如果传太慢,外设会空等。所以我个人的习惯是:一定开启DMA的传输完成中断,并且在外设的FIFO半满中断里做流量控制。碰到过一个坑:某款MCU的UART DMA在波特率115200下连续发送大包,FIFO满了之后DMA不停止,直接覆盖数据。后来我在DMA描述符里加了DST_REQ位,等外设请求信号才发下一段。

注意: 内存到外设传输中,外设的请求信号(比如TXE、TXFIFO_NOT_FULL)必须正确连接。如果DMA控制器和外设的请求线没配对,数据会卡死。我曾在调试音频输出时花了半天才发现是外设的DMA请求映射错了通道。所以先看芯片参考手册,“DMA Request Mapping”那张表一定要看清楚。

3. 外设到内存

反过来,外设采集数据往内存塞。典型的:ADC连续采样、摄像头像素流、USB接收、网卡收包。这时的挑战是——数据来得快且不确定。DMA要能及时把外设FIFO里的数据搬到内存,否则FIFO溢出丢数据。

我建议你用“双缓冲”或者“环形缓冲”配合DMA。比如ADC用双缓冲:当DMA填满第一块内存后,自动切换到第二块,同时触发中断让CPU处理第一块的数据。这样采集和处理完全流水线。

对比项内存→外设外设→内存
数据流向内存 → 外设外设 → 内存
主要风险外设FIFO满,DMA停等内存写覆盖或FIFO溢出
中断设计传输完成+外设错误传输完成+半传输(半满)+溢出
常用硬件UART TX、SPI MOSI、I2C写ADC、I2S RX、UART RX、SPI MISO
核心原则: 外设到内存的DMA,外设的请求速率必须小于等于DMA服务速率。否则再好的中断也救不了FIFO溢出。可以用逻辑分析仪实际抓外设请求信号和DMA响应信号对比验证。

4. 循环链(LLI / Scatter-Gather)

这个最强大,也最容易踩坑。循环链(Linked List Item, LLI)也叫分散收集(Scatter-Gather, SG)。原理很简单:DMA不是只完成一个传输任务,而是通过链表描述多个不连续的源或目标内存块。每完成一块,自动加载下一个描述符,直到链尾。

典型的LLI描述符结构体长这样:

struct dma_lli {
    uint32_t src_addr;   // 源地址
    uint32_t dst_addr;   // 目标地址
    uint32_t transfer_len; // 本次传输长度(字节)
    uint32_t next_lli;   // 下一个描述符的物理地址(0表示链尾)
    uint32_t ctrl;       // 控制位(完成中断使能、传输方向等)
};

上电后,你只需把第一个描述符的地址告诉DMA控制器的链表基址寄存器,然后启动。DMA就会按链表自动跑。我做过一个网络加速器,用LLI把收到的多个以太网帧分别放到不同存储区,每个帧大小不同,照样一次触发搞定。不需要CPU逐个处理。

经验之谈: LLI描述符本身必须放在非cacheable的内存区域,或者DMA能直接访问的区域(比如SRAM、片上RAM)。如果放在cacheable的DDR里,要小心cache一致性问题。我之前一次系统挂掉,就是因为描述符在写完后被CPU cache持有,DMA读到了旧数据,指向了错误的地址。解决办法:用dma_alloc_coherent分配内存。

Scatter-Gather 和循环链其实是同一个概念的不同叫法。有些厂商叫LLI(比如ST、NXP),有些叫SG(比如TI、Xilinx)。内核里统一叫sg_table。但要注意:循环链既可以做成单向链表(每个描述符指向下一个),也可以做成环形链表(最后一个指向第一个),实现持续循环传输。

避坑指南: 循环链的最后一个描述符中next_lli一定要清0,否则DMA会读到随机地址并执行非法操作。更高级的用法是设置最后一块描述符的int_en位,在传输完成时触发中断,而中间的描述块不触发中断,减少中断次数。我见过有人把每个描述符都开中断,结果中断风暴直接把系统拖垮。

几种类型的选择策略

讲到这里你可能要问了:“那我到底该用哪种?” 我自己的选择法则很简单,列成表格你参考:

传输需求推荐类型原因
两块连续内存之间的拷贝内存→内存简单,资源消耗少
单次发送/接收固定长度数据到外设内存↔外设(一次传输)配置快,中断少
连续采集/输出,且数据块非对称内存↔外设 + 循环链灵活,无中断停顿
大文件或网络包,内存不连续循环链(Scatter-Gather)避免拷贝,节省内存带宽
高速音视频流,需要极低延迟循环链(环形模式)连续传输,无中断gap

最后提醒一句: 不管你用哪种传输类型,中断绑定和缓存一致性是两个永远绕不开的坎。下一章我们就要正式开始讲中断绑定——为什么在AMP(非对称多核)系统中,DMA中断必须绑定到指定核?因为如果随意分配,两个核同时处理同一个DMA中断,数据可能被乱改。我后来养成一个习惯:每个DMA通道在初始化时,明确指定它的target_cpu亲和性。

最后提醒一句: 不管你用哪种传输类型,中断绑定和缓存一致性是两个永远绕不开的坎。我开始做多核项目时,就吃过“DMA中断到处乱跑”的亏。所以建议你从一开始就在设计文档里标清楚每个DMA通道的核亲和性,后面能省很多debug时间。


这四种传输类型,我用了好多年。每次接触新SoC,我都会先翻DMA控制器的datasheet,看看它支持哪几种模式,然后快速做个benchmark。内存到内存和内存到外设基本是标配,循环链则在高性能场景下不可或缺。如果你打算做音视频、网络或者工业采集,循环链一定是你的好帮手。但记住——LLI描述符的cache一致性必须处理好,否则DMA跑起来扑朔迷离,调试到你怀疑人生。