搞嵌入式这么多年,DMA 这个话题我写过代码、调过 bug、也被它坑过无数次。很多朋友一提到 DMA 就觉得是个“高级功能”,总觉得配置复杂、时序难懂、出了问题也不知道从哪里查。实际上 DMA 的本质特别朴素:它是一个不需要 CPU 参与的数据搬运工。你告诉它“从哪搬、搬到哪、搬多少”,它自己就能把活干了,干完再喊你一声。就这么简单。
这篇文章我想把 DMA 从工作流程、传输模式到典型应用场景完整串一遍。里面会穿插大量我在 STM32、PY32、串口不定长接收、ADC 连续采样这些实战中踩过的坑和验证过的套路。不管你是在调一个串口接收卡住的问题,还是在纠结 DMA 发送要不要等上一轮完成,或者想搞清楚 UFS DMA、分布式 DMA 这些偏门概念到底是怎么回事,这篇文章都能给你一个相对完整的答案。
1. DMA 是什么:先从一次“手动搬砖”说起
1.1 没有 DMA 的时候,CPU 有多累
想象一个场景:串口每收到一个字节,CPU 就要停下来,去读数据寄存器,然后把数据写到内存数组里。波特率不高的时候还好,如果是 921600 的波特率,大约每 10 微秒就有一个字节进来,CPU 大部分时间都在“读寄存器、写内存、读寄存器、写内存”这个循环里。更麻烦的是,如果这段代码优先级不够高,来得太快的数据还可能被漏掉,直接导致丢帧。
中断方式稍微好一点,本质上也是每来一个字节打断一次 CPU。数据量小看不出问题,可一旦数据量上来,比如一秒钟几万个字节的传感器数据流,中断就会像连续敲门一样,CPU 根本没法干正事。轮询更不用说了,纯属浪费。
1.2 DMA 就是那个“专职快递员”
DMA 的全称是 Direct Memory Access,中文叫直接存储器访问。它的核心价值就是让数据在外设和内存之间直接搬运,整个过程不需要 CPU 逐字节干预。你可以把它理解成一个专职快递员:CPU 只需要把发货地址、收货地址、货品数量告诉它,然后就回去忙自己的事情了。快递员搬完货,按一下门铃告诉 CPU“货搬完了”,整个过程就结束了。
这个“按门铃”的动作,在硬件上就是 DMA 传输完成中断。CPU 收到中断后,只需要处理搬运完成后的数据就行。比如串口接收,DMA 把一整包数据搬进内存后,CPU 再一次性处理,效率完全不一样。
1.3 DMA 控制器本身也是一个“小处理器”
很多人忽略了一个关键点:DMA 控制器本身就是一个独立的小处理器,它有自己的寄存器、自己的状态机,甚至有的芯片 DMA 控制器还不止一个通道。比如 STM32F1 有两个 DMA 控制器,DMA1 和 DMA2,每个控制器下有多个通道,不同通道可以映射到不同的外设请求。这意味着 DMA 的搬运能力和 CPU 是并行的,CPU 在跑主逻辑,DMA 在搬数据,两者互不干扰。
这也是为什么 UFS DMA、SDIO DMA 这些高速存储场景里离不开 DMA。UFS 的理论带宽能到几千 MB/s,靠 CPU 逐字节搬运根本不可能,必须用专门的 DMA 引擎把数据从闪存控制器搬到内存,再交给 CPU 做高层处理。
2. DMA 工作流程拆解:一次完整搬运是怎么发生的
2.1 请求、响应、传输、结束:四个动作一台戏
一次 DMA 传输,从硬件角度看,大概经历这么几个阶段:
外设请求:外设(比如串口、ADC、定时器)准备好数据后,会拉高一根请求线,告诉 DMA 控制器“我有数据要搬”。
DMA 响应:DMA 控制器收到请求后,如果通道空闲,就会接管总线,向总线仲裁器发起访问请求。这个总线仲裁很重要,因为 CPU 也要用总线,如果两者同时访问内存,就得有优先级仲裁机制。
数据传输:DMA 从源地址读取数据,写到目的地址。对于串口接收,源地址是串口的数据寄存器,目的地址是内存数组。对于内存到内存拷贝,源和目的都是内存地址。
传输结束:当传输计数器减到 0,DMA 会置一个传输完成标志位,如果使能了中断,就会向 CPU 发出中断请求。如果配置的是循环模式,计数器会重新加载,继续下一轮搬运。
2.2 关键参数不是随便填的
配置 DMA 的时候,通常会碰见这些参数:
源地址和目的地址:要注意地址是固定的还是递增的。比如串口接收,源地址(串口 DR 寄存器)是固定的,目的地址是递增的,数据要从寄存器依次写入内存数组。比如内存到内存拷贝,源和目的都需要递增。
传输宽度:字节、半字、字。串口数据寄存器一般是 8 位,ADC 转换结果如果是 12 位,一般用半字(16 位)来搬运。这个一定要匹配好,否则数据会错位。
传输计数:一次 DMA 传输多少个数据单元。串口接收里就是期望接收多少个字节,ADC 连续采样里就是采样次数。
突发长度:这是很多新手困惑的地方。突发模式(Burst)指 DMA 一次申请总线后连续搬运多个数据,而不是搬一个就释放总线。突发长度越大,总线占用时间越长,但搬运效率越高。不过要注意,外设那边的 FIFO 深度得配合得上,否则数据会溢出。
2.3 CPU 在传输过程中做了什么
很多人以为 DMA 传输过程中 CPU 完全不管,这也是个误解。CPU 在 DMA 传输期间还是能执行指令的,只是如果 CPU 恰好也要访问总线,就得和 DMA 竞争。对于大多数场景,这种竞争影响不大,但在一些实时性要求很高的任务里,DMA 的突发传输可能会导致 CPU 的某次内存访问被延迟,进而影响实时响应。
这就是为什么我在很多实时控制项目里,DMA 传输的数据量会合理控制,不会一上来就配置 64KB 的大块搬运。经验值是多用几个小块的 DMA 搬运,配合中断逐块处理,对实时性的影响会小很多。
2.4 中断回调里到底能干什么
DMA 传输完成中断触发后,你可以在回调里处理数据,但要注意几个禁忌。首先不要在里面做耗时的操作,比如 printf、延时、大数据处理,这些都放到主循环里做。其次,如果需要重新启动下一轮 DMA 接收,建议先看一下 DMA 的状态寄存器,确保上一轮确实结束了,再改配置。
我见过很多人在 DMA 传输完成中断里直接调用 HAL_UART_Receive_DMA 来开启下一轮接收,然后发现数据错乱。原因很简单:第一次接收还没完全停止,你又开启了新的接收,两个操作相互干扰了。正确的做法是先调用 HAL_UART_DMAStop 停掉当前的 DMA,再重新开启。
3. 传输模式怎么选:单次、突发、循环与连续请求
3.1 单次传输 vs 突发传输
DMA 的单次传输模式,CPU 每次响应外设请求只搬运一个数据单元。这种方式响应快、总线占用时间短,但是效率低,因为每次搬运都需要重新申请总线、仲裁、释放。
突发传输则是一次响应连续搬运多个数据。比如配置突发长度为 4,一次响应就搬 4 个数据单元。这种方式效率高,适合大数据块搬运,比如从 UFS 读一段数据到内存、从 ADC 连续采样缓存到内存。
选择哪种模式,主要看数据量和实时性要求。数据量小、实时性要求高的用单次;数据量大、实时性要求相对宽松的用突发。
3.2 循环模式与 DMA continuous requests
循环模式在 ADC 连续采样、串口不定长接收这些场景里非常常用。配置成循环模式后,DMA 搬完一轮数据,计数器自动重载,继续下一轮搬运,整个过程不需要 CPU 干预。
DMA continuous requests这个概念,主要和循环模式有关。它指的是 DMA 在传输完成后是否继续响应外设的请求。如果开启了 continuous requests,DMA 会在完成一轮后立刻准备接收下一轮外设请求,相当于保持“随时待命”的状态。如果不开,DMA 完成一轮后就停了,必须软件重新触发才能开启下一轮。
以 STM32 的 HAL 库为例,HAL_ADC_Start_DMA 开启后,如果配置了循环模式,DMA 会一直在后台搬运 ADC 转换结果。你只需要在传输完成中断里读数据,甚至可以直接从内存数组里读最新的数据,彻底实现“后台采集、前台使用”。
3.3 直接模式 vs FIFO 模式
直接模式就是 DMA 收到一个数据就搬运一个数据,没有中间缓存。FIFO 模式下,DMA 先把数据存到内部的 FIFO 里,攒够了再一次性搬运出去。
FIFO 模式的好处是能减少总线访问次数,还能在不同数据宽度之间转换。比如源数据是 8 位的,目的数据是 32 位的,通过 FIFO 做数据宽度转换就非常方便。缺点是会引入延迟,不适合对实时性要求特别高的场合。
实际项目中,串口 DMA 接收我一般用直接模式,因为串口数据是一字节一字节来的,FIFO 反而容易造成接收延迟。而 ADC 连续采样,如果采样率很高、数据量很大,用 FIFO 模式可以显著降低总线占用率。
4. 串口 DMA 实战:不定长接收与发送等待
4.1 不定长接收:空闲中断才是主角
串口 DMA 接收不定长数据,是很多人的入门必修课,也是最容易出问题的地方。核心配置就一句话:DMA 负责把数据搬进缓冲区,串口的空闲中断负责告诉你“一帧数据收完了”。
所谓空闲中断,就是串口在一段时间内没有收到新数据时会触发的中断。配合 DMA 循环接收,可以做到:DMA 一直在后台收数据,收到哪算哪;一旦串口空闲了,就进入空闲中断,通过比较 DMA 当前计数器和初始计数器的差值,就知道这一帧收了多长。
具体配置上,用 STM32 HAL 库的话,开启串口 DMA 接收之后,再使能串口的 IDLE 中断。在中断回调里,先读取串口的 SR 寄存器确认是空闲中断,然后读取 DMA 剩余的未传输个数,通过计算得出本次接收到的数据长度。
4.2 DMA 发送到底要不要等上一轮完成
这个问题我专门拿出来说,因为太典型了。DMA 发送和 DMA 接收不一样,接收是外设往内存搬数据,发送是内存往外设搬数据。发送的时候,数据从内存搬到串口的发送数据寄存器,再由串口硬件逐字节发出去。两者之间是有一个“搬运完成”和“实际发送完成”的差异的。
DMA 搬运完成,不代表串口已经把数据发出去了。这是最大的坑。如果你在 DMA 发送完成中断里立刻修改发送缓冲区的内容,或者关闭 DMA,很可能导致数据发了一半被截断。因为 DMA 只是把数据搬到了串口的发送移位寄存器里,移位寄存器里的数据还没完全发出去。
我验证过的可靠做法是:在 DMA 发送完成中断里,再检查一下串口的 TC(Transmission Complete)标志位。等 TC 置位了,才表示这一帧数据真正发送完毕。如果 DMA 发送完成后你还要发下一帧,务必等 TC 清掉后再开始下一轮发送,否则可能出现帧与帧之间粘连。
4.3 PY32F003 串口 DMA 接收的一个实际例子
我也在 PY32F003 这类小资源单片机上做过串口 DMA 接收。PY32F003 的 DMA 资源比较紧张,只有一个 DMA 控制器,通道数量也有限,所以配置的时候要精打细算。
我的做法是:开启串口 DMA 循环接收,缓冲区大小设成 256 字节。DMA 把收到的数据循环写入缓冲区,同时使能串口空闲中断。每收到一帧完整数据(以空闲中断为标志),我就计算数据长度,然后做协议解析。
一个需要注意的细节是:在 PY32 上启用 DMA 时钟的时机很重要。如果初始化顺序不对,比如先配置 DMA 通道再开启外设时钟,会导致 DMA 配置丢失,表现就是数据永远进不了缓冲区。我的习惯是:先开启 DMA 和串口的时钟,再初始化 DMA 通道,最后初始化串口。
5. DMA 的更多典型应用场景
5.1 ADC 多通道连续采样:让 DMA 帮你攒数据
STM32 HAL 库的 ADC 单通道 DMA 多次采样,是我用得最多的场景之一。比如需要采集一个模拟传感器的波形,采样率 10kHz,采 1000 个点。如果没有 DMA,你得在定时器中断里一个个读 ADC 转换结果;有了 DMA 后,初始化一次,硬件就会自动把 1000 个采样值搬进内存数组,搬完了再通知你处理。
关键参数有这几个:ADC 扫描模式、连续转换模式、DMA 循环模式。多通道采样时还要注意,ADC 的通道顺序要和 DMA 缓冲区里的数据顺序对应上。我踩过的一个坑是:配置了多通道扫描,结果 DMA 缓冲区里数据顺序和通道配置顺序不一致,分析数据时老是错位。后来我养成了一个习惯,在初始化的时候把通道顺序映射表写清楚,调试时先在缓冲区里打印前几个值,验证顺序没问题再继续。
5.2 UFS DMA:高速存储背后的搬运引擎
UFS 是高端手机和 SSD 里常用的闪存接口标准,它的数据吞吐量非常高。UFS 控制器内部就集成了 DMA 引擎,负责把闪存里读出的数据搬到系统内存,或者把内存里的数据写到闪存。
UFS DMA 的工作方式和 MCU 里的 DMA 本质一样,都是把源地址的数据搬到目的地址,但是它的传输宽度更大、突发长度更长、通道数更多。而且 UFS DMA 往往需要支持多队列,也就是同时有多块数据要搬运,DMA 引擎需要按优先级依次处理。
很多人只关注 UFS 的协议层,忽略了 UFS DMA 的性能调优。比如 DMA 的 burst 长度设置,直接决定了 UFS 的实测读写带宽。如果 burst 长度设得太小,DMA 频繁申请总线,带宽上不去;设得太大,FIFO 又可能不够用。这个需要根据具体的控制器和内存带宽去调。
5.3 DMA 测速软件怎么看带宽
DMA 测速软件的原理,其实就是让 DMA 持续搬运大数据块,然后统计单位时间内搬运了多少数据,算出实际带宽。这类工具在评估存储设备性能、验证 DMA 配置是否最优时很有用。
比如用 PC 上的磁盘测速软件,本质上也是在触发存储控制器的 DMA 引擎搬运数据,通过测量搬运时间来计算速率。嵌入式上类似的做法是:配置 DMA 循环搬运一块已知大小的缓冲区,同时开启一个定时器,搬运一段时间后统计搬运次数,就能算出实际带宽。
一个人容易忽略的问题是:DMA 的理论带宽和实际带宽之间是有差距的。因为总线仲裁、FIFO 等待、外设响应延迟都会消耗时间。所以测出来的实际带宽如果只有理论带宽的 60%~70%,不一定是配置有问题,可能只是总线上其他设备也在占用带宽。这时候可以通过调整 burst 长度、通道优先级来优化。
5.4 分布式 DMA:把搬运扩展到多节点
分布式 DMA 是我早期接触服务器开发时才遇到的。普通 DMA 只能在一个处理器系统内部搬运数据,而分布式 DMA 允许跨节点进行数据搬运,每个节点都有本地的 DMA 引擎,通过高速网络(比如 RDMA 网络或者 NVMe over Fabric)协同工作。
简单理解,普通 DMA 是“同一间屋子里的快递员”,分布式 DMA 是“跨城市快递网络”,每个城市有自己的快递分拣中心。这种架构的好处是大大减少了远程数据访问的延迟,因为不需要把数据从远端搬到本地再处理,而是直接在远端处理完再把结果传回来。
分布式 DMA 在分布式存储、AI 训练集群里应用很广。比如在 GPU 集群里,每个 GPU 节点通过 RDMA 访问远端内存,实际上底层就依赖分布式 DMA 的搬运能力。理解普通 DMA 的原理后,再去看分布式 DMA 就不难了,核心思想都是“减少 CPU 参与、让数据自己流动”。
6. 常见问题与排查技巧实录
6.1 疑难杂症速查表
下面这份速查表,是我这些年调 DMA 遇到过的典型问题,整理成表格方便大家快速定位。
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 数据错位、顺序不对 | 数据宽度配置错误,或多通道顺序映射不一致 | 核对源、目的地址的数据宽度,检查多通道配置顺序 |
| 只收到第一帧,后续收不到 | DMA 传输完成中断里没有重新开启接收 | 在完成中断里先停 DMA,再开启新一轮接收 |
| 数据能收,但丢最后一个字节 | 没有等待串口 TC 标志位 | DMA 发送完成后检查 TC 状态 |
| 乱码、偶尔多几个字节 | 波特率误差大,或 FIFO 配置不合理 | 检查时钟配置,改用直接模式测试 |
| ADC 值全都是同一个数 | DMA 搬运方向和 ADC 数据寄存器地址配置错误 | 检查 DMA 通道映射和外设地址是否可以访问 |
| DMA 搬运超时或卡死 | 总线死锁或 DMA 通道被其他外设占用 | 检查 DMA 通道映射表,确认没有冲突 |
| 低功耗唤醒后 DMA 不工作 | DMA 时钟被关闭后未重新初始化 | 在低功耗恢复流程里重新配置 DMA |
6.2 几个值得记住的经验
第一件:DMA 调试时先把中断关了,用标志位轮询来验证配置。如果轮询模式下数据搬运都正常,再开中断;如果轮询模式就不对,别浪费时间去查中断逻辑,肯定是 DMA 本身的配置问题。
第二件:DMA 缓冲区尽量定义成全局变量或者静态变量。很多人把 DMA 缓冲区定义成局部变量,结果数据直接错乱。因为局部变量在栈上,地址可能随着函数调用变化,DMA 控制器访问的是固定地址,一旦栈被回收,数据就写到别的地方去了。
第三件:用逻辑分析仪或者串口打印关键节点的标志位。在我不知道数据是在哪一步丢的时候,我喜欢在传输开始前、DMA 完成中断里、主循环处理时分别打印标志位,通过观察标志位出现的顺序,能快速定位问题在哪一层。
第四件:DMA 完成中断里不要做的事。不要在中断里做耗时操作,不要调用 printf,不要在中断里等待另一个中断或外设事件。这些都是血的教训,即使强如 DMA,也扛不住 CPU 在中断里磨蹭太久。
第五件:配置 DMA 前先把所有相关外设时钟打开。很多“DMA 不工作”的问题,排查到最后发现是 DMA 控制器本身的时钟没使能。特别是在某些低功耗芯片上,DMA 时钟默认是关闭的,忘了开就直接没反应。
7. 项目源码里那些值得抄的小技巧
7.1 环形缓冲区与 DMA 的结合
串口 DMA 不定长接收时,我建议把 DMA 循环接收和环形缓冲区结合起来。DMA 直接写入一个固定大小的数组,然后通过维护读索引和写索引,实现数据流的连续处理。
具体做法是:DMA 循环接收时,每触发一次空闲中断,就更新写索引;主循环里处理数据时,维护一个读索引。当读索引追上写索引时,说明暂时没有新数据,可以等待。这种方式比每收到一帧就拷贝一次缓冲区更省内存,也适合高频数据流。
要注意的是,环形缓冲区的读写索引操作要做好临界保护。因为写索引是在中断里更新的,而读索引在主循环里更新,如果主循环正在读数据时中断又更新了写索引,可能逻辑上会出差错。我的做法是:在主循环里先关中断读取写索引,再开中断,然后再处理数据。
7.2 DMA 完成回调里的状态机设计
DMA 完成中断适合做什么?我的答案是触发状态机转换,而不是直接处理数据。比如一个数据采集系统,DMA 搬完一批 ADC 数据后,应该在中断里设置一个标志位,通知主循环“数据已就绪,可以处理了”。主循环检测到标志位后,再把状态机从“等待数据”切换到“分析数据”。
这种做法的好处是:DMA 中断处理极快,不会影响后续传输;主循环有充足的时间处理复杂逻辑,不会被中断挤占。更重要的是,状态机的描述让代码逻辑非常清晰,一个阶段一个阶段地推进,不容易乱。
7.3 用 DMA 实现内存拷贝
DMA 不光能搬外设数据,还能做内存到内存的拷贝。在需要频繁拷贝大数据块的场景下,用 DMA 比用 memcpy 更合适。STM32 的 DMA 里,内存到内存模式需要手动触发一次传输,而不是由外设触发。
配置成内存到内存模式后,DMA 从源地址读取数据,写到目的地址。我实测过,对于 1KB 以上的数据块,DMA 拷贝的效率比 CPU 逐字节拷贝高不少,因为 CPU 可以在这段时间里做其他事情。不过要注意,内存到内存拷贝时 CPU 还是要参与发起的,所以它只能和 CPU 的执行并行,不能完全替代 CPU 的工作。
7.4 优先级与仲裁怎么配
DMA 通道的优先级配置,直接决定了多个 DMA 请求同时到来时的处理顺序。实际项目中,我一般把实时性要求最高的外设设置成最高优先级。比如一个系统里既有串口接收、又有 ADC 采样,还有定时器触发的 DMA 搬运,那么优先保证串口接收不丢数据,因为 ADC 采样数据丢几个点还能靠算法补偿,串口丢一个字节可能整帧就废了。
优先级也不是越高越好。如果所有通道都是最高优先级,仲裁就失去了意义。建议合理分配,关键通道用高优先级,次要通道用低优先级。另外,还要注意 DMA 控制器本身的总线优先级。在 STM32 上,DMA1 和 DMA2 共用总线,如果 DMA2 的数据量很大,会挤占 DMA1 的总线访问时间,导致 DMA1 的外设数据来不及搬运。
7.5 数据宽度不匹配时的处理
有时候 DMA 的源数据宽度和目的数据宽度不一样。比如 ADC 转换结果是 16 位的,但你要把数据发送到某个 8 位的外设接口上。这种情况下,要么在软件里做数据转换,要么利用 DMA 的 FIFO 功能。
STM32 的 DMA 在开启 FIFO 模式后,可以把数据宽度从 16 位转换成 8 位,DMA 会自动处理数据拆分。但要注意,FIFO 模式下会有额外的延迟,而且必须配置正确的 FIFO 阈值。如果发现转换后的数据顺序不对,重点检查 FIFO 的阈值设置和数据宽度匹配关系。
7.6 低功耗场景下的 DMA 处理策略
低功耗嵌入式设备里,DMA 是个双刃剑。一方面,它能在 CPU 睡着的状态下搬运数据,比如让 MCU 进入睡眠模式,DMA 继续通过串口接收数据,等收到一定量数据后靠 DMA 中断唤醒 CPU。另一方面,一旦低功耗模式把 DMA 时钟关了,就可能出现数据丢失。
我的建议是:需要在低功耗模式下保持通信的场景,优先选择支持 DMA 唤醒的芯片,或者使用外设自带的 FIFO 缓存数据。如果芯片不支持 DMA 在低功耗模式下工作,那就要设计好唤醒策略,比如先靠中断唤醒 CPU,再开启 DMA 批量搬运数据,而不是让 DMA 一直在低功耗下运行。
8. 写在最后的几句心里话
做嵌入式这些年,我最大的感受是:DMA 不难,难的是理解数据流在整个系统里是怎么走的。DMA 的每个参数背后,都对应着一次总线的申请、一次存储器的访问、一次外设的状态切换。只有把“数据从哪里来、到哪里去、中间经过谁”这条链路想清楚,DMA 才能真正成为你的利器,而不是一个让你加班到深夜的调试对象。
我自己踩过最深的一个坑,就是早期做串口 DMA 发送的时候,想当然地以为 DMA 传输完成就等于数据发出去了,结果在高速波特率下反复丢最后一两个字节。后来老老实实查参考手册,才发现“搬运完成”和“发送完成”是两回事。从那之后,我再也不敢凭直觉写 DMA 代码了,每个细节都必须从芯片手册里找到依据。
如果你现在正在为 DMA 的某个问题头疼,我的建议是:先关掉中断,用轮询的方式验证 DMA 配置是否正常。然后逐条核对源地址、目的地址、数据宽度、传输计数这四个关键参数。最后再开中断,确认中断回调里的操作足够轻量。这套方法虽然朴素,但每一条都来自真实的调试经历,照着做,绝大多数问题都能自己定位到根因。