嵌入式DMA详解:从数据搬运原理到串口、ADC应用与调试
2026/9/7 20:51:37 网站建设 项目流程

做嵌入式开发这几年,“数据搬运”这四个字几乎每天都在打交道。串口收数据、ADC采样、SPI读传感器,哪一样不是在搬数据?搬得不好,CPU就要被中断淹没;搬得好了,CPU只做最后一步分析和处理。而DMA,Direct Memory Access,直接存储器访问,正是干这个的。

这篇文章我把DMA的工作流程、传输模式、典型应用场景,还有调试中踩过的坑一次性聊透。无论你是刚学STM32想搞明白串口DMA接收不定长数据的新手,还是被ADC多通道采样、UFS DMA这类问题折腾过的老手,都能从这里找到能直接抄作业的思路。

1. DMA 到底是什么:从“数据搬运工”说起

1.1 为什么偏偏是DMA

如果没有DMA,外设要往内存放数据,只能靠两种方式:轮询和中断。轮询就是CPU不停去问外设“你有没有数据”,浪费CPU大量时间;中断稍微好一点,外设有数据了通知CPU,CPU进中断服务函数里搬一个字节,再出来。波特率不高还好,一旦数据吞吐量上去了,比如100万波特率的串口,每秒10万字节,每字节一次中断,CPU基本就耗在进出中断和现场保护上了,其他任务全被卡死。

DMA的思路很简单,CPU别管数据搬运了,找一个专门的硬件来干这个活。这个硬件就是DMA控制器。CPU只需要把源地址、目的地址、搬运多少个数据告诉DMA控制器,说一声“开工”,然后就干自己的事。数据搬完了,DMA控制器拉一下中断通知CPU:“活干完了。”这就是DMA的基本工作模式。

类比一下:CPU就像一个全栈工程师,既要写代码又要做设计。搬数据这种事一直是亲自搬,每次一个箱子,从A仓库搬到B仓库,搬完一个还要等下一个箱子。突然来了个DMA这个专职物流专员,CPU只需要说一句“把A仓库的一百个箱子搬到B仓库”,然后就可以去写代码了,物流专员自己搬,搬完汇报一下。你说这种设计香不香。

1.2 DMA核心工作流程拆解

DMA完整的工作流程一般来说分这么几步:

第一步,软件配置。CPU配置DMA控制器的源地址、目的地址、传输方向、数据宽度、传输计数、是否地址增量、中断使能等参数,然后启动DMA。

第二步,外设触发或软件触发。大多数DMA传输不是DMA自己主动跑的,而是被“事件”踢一脚才动。比如串口接收寄存器收到一个字节,DMA检测到这个事件,申请总线控制权。

第三步,总线仲裁。如果同一时刻有多个DMA请求,DMA控制器内部按固定优先级和可编程优先级仲裁,选中一个通道。

第四步,单次数据传输。选中的DMA通道握有总线控制权,从源地址读数据,写到目的地址。数据宽度可以是字节、半字或字。

第五步,更新控制信息。传输计数器减一,如果配置了地址增量,源地址和目的地址会加对应的步长。然后DMA回到等待状态,等待下一次触发。

第六步,计数器归零后结束。如果是一次性Normal模式,DMA会停在那里,同时置传输完成标志,可产生中断通知CPU。如果是循环模式,计数器会自动重新装载到初值,继续等下一次触发,形成连续循环。

这个流程听着简单,但有几个点特别容易误解。第一,DMA搬一个数据要占用总线。如果CPU同时也要访问内存,二者会竞争总线。多数MCU都设计了合理的仲裁,把DMA访问安排在CPU总线空闲周期,但如果数据传输量特别大、总线繁忙,CPU确实会感觉到“卡顿”。第二,DMA不是免费的,它需要初始化配置,占用RAM缓冲区、占用一个中断。数据量小的搬运任务用DMA反而得不偿失,因为配置开销可能比中断处理还大。

1.3 DMA控制器的关键寄存器

不同的MCU实现细节略有差别,但DMA控制器基本都有这几类核心寄存器:

寄存器作用配置时要注意什么
源地址寄存器存放数据来源地址外设到内存时填外设数据寄存器地址,而不是寄存器里的值
目的地址寄存器存放数据目的地地址内存到外设时填外设寄存器地址,注意地址空间是否可访问
传输计数寄存器记录待传输数据个数每传一次减一,注意是“次数”不是“字节数”
控制寄存器决定方向、数据宽度、地址增量、优先级、模式方向和增量最容易配错
状态寄存器存放传输完成、半传输、传输错误等标志中断里用完要把标志清掉,否则会一直误触发

配置时核心就是把这几个寄存器填对。曾经有人调DMA半天不出数据,最后发现是源地址寄存器里填的是外设寄存器的值而不是外设寄存器的地址,这类细节真的值得注意。

2. 传输模式怎么选:Normal、Circular还是Burst

2.1 正常模式与循环模式的选择逻辑

最常用的两套模式是Normal(正常)和Circular(循环)。

Normal模式就是一次性搬运,计数器从初值减到0,DMA停止。典型场景:批量存储数据,比如把传感器读回来的1024个字节一次性搬进Flash。或者你在SD卡里读了一个块的数据到内存,这种任务一次结束,不需要DMA一直跑。

Circular模式是无限循环的,DMA搬满一个缓冲区之后自动回到缓冲区起始位置继续搬。典型场景:ADC连续采样、串口不定长数据接收。数据不停地进来,DMA永远不停止,CPU只需要在合适的时候去缓冲区里取数据。理解Circular模式最好的方式就是环形缓冲区,DMA负责不停往缓冲区里写,CPU按照DMA的进度去读,两边各管各的,互不打扰。

我实际见过不少人把这两种模式搞混。有人一直用Normal模式接收串口不定长数据,每次收完一帧数据都要软件重新启动一次DMA,启动晚了就丢数据。也有人一直用Circular模式做一次性内存拷贝,DMA搬完一轮还在那里无限重传,完全停不下来,最后缓冲区越写越乱。所以选模式之前先问自己:数据是一次性的,还是持续的?一次性用Normal,持续用Circular。

2.2 三种传输方向与数据宽度

DMA传输方向在大多数MCU上有三种:外设到内存、内存到外设、内存到内存。

外设到内存是最常见的,串口接收、ADC采样、SPI读数据都用它。外设数据寄存器当作源地址,RAM缓冲区当作目的地址。

内存到外设负责把数据从内存发出去。比如串口发送、DAC输出、SPI写屏。源地址是RAM缓冲区,目的地址是外设寄存器的地址。

内存到内存则是把一块内存的数据搬到另一块内存,比如把暂存区的内容搬到帧缓冲区。这种传输一般由软件触发,也就是不需要外设事件,CPU一配置完DMA就自己跑。要注意的是,内存到内存每搬一个数据都要占用总线,对实时性影响比外设到内存更大,尤其是搬运数据量特别大的时候,系统里其他对时间敏感的任务很容易被挤掉,所以能不用就不用。

数据宽度则是每次搬运的“车厢大小”。一般支持字节、半字、字三种。判断标准很简单:外设数据寄存器有多宽,就选多宽。串口寄存器一字节,选字节;ADC数据寄存器16位,选半字;USB专用的缓冲区如果按32位组织,选字。

如果源和目的的数据宽度不一致,很多MCU的DMA控制器的行为会相当微妙,有的会丢弃多余位,有的会把数据零扩展。不要轻易让源和目的的数据宽度不一致,除非你非常清楚具体芯片的行为,否则后患无穷。我就见过一位同事把串口的8位数据宽度配成16位,结果每个接收字节后面多出一个零字节,整个协议解析直接乱掉。

2.3 突发传输、连续请求和优先级

有些DMA支持突发传输,一次总线仲裁连续搬运多个数据,比如4字节、8字节、16字节。有的人觉得突发传输就是快,但实际上突发传输的意义主要是减少总线仲裁的次数,搬运大块数据时确实能降低总线开销。另外,突发传输要求地址对齐,如果没对齐,有些芯片直接不干活,有些则是发送错误异常。所以用突发传输之前,确认缓冲区地址是否对齐,否则踩坑踩到怀疑人生。

连续请求(DMA continuous requests)在一些MCU的资料里翻译成“持续请求”或“连续请求”。普通模式下,DMA要等外设的触发信号才动作。开启连续请求后,DMA不需要等外设触发,自己会持续产生传输请求,一路搬到底。这种模式比较适合内存到内存的批量初始化,比如把一整个帧缓冲区快速清零。但是注意,一旦忘记关掉连续请求,DMA会一直霸占总线的访问带宽,其他任务就会明显变慢。这个坑我有一次排查了快两天,最后DMA停不下来,原因就是连续请求位被不小心打开了。

多个DMA通道同时有请求时,优先级决定谁先搬。每个通道一般有可编程优先级,配置相同优先级时按硬件固定的初始优先级裁决。配置优先级的原则是:实时性要求高的通道优先,比如音频数据;吞吐量大的通道也尽量给高优先级,比如高速串口。

2.4 配传输参数时最容易忽略的细节

几个小的细节,出问题的时候极难排查。

第一,地址增量。外设到内存时,外设地址通常不增量,因为外设就一个寄存器;内存地址必须增量,否则数据全写到一个位置。忘了给内存地址开增量,是新手最常见的错误之一,表现就是缓冲区第一个字节一直在变,后面全是空的。

第二,缓冲区大小和传输计数之间的关系。DMA传输计数器记录的是“次数”,不是“字节数”。每次搬一个半字,传100次就是200字节。缓冲区数组定义成多少、DMA计数器填多少,一定要先算清楚。我习惯在代码里用宏定义统一管理,比如#define BUF_SIZE 128,所有地方引用同一个宏,就不会出现数组大小和计数不一致的问题。

第三,缓存一致性。如果MCU带D-Cache(比如Cortex-M7),DMA搬运的数据从外设进内存之后,CPU读到的可能还是Cache里的旧数据。这个时候需要在DMA传输完成中断里做Cache Clean/Invalidate。没有Cache的设备不用管,但有Cache不看这一条,写出来的程序就是偶尔灵偶尔不灵,非常诡异,而且这种bug复现率还不稳定,特别难定位。

第四,编译器优化等级。DMA缓冲区如果没加volatile修饰,在某些优化等级下,编译器可能把读缓冲区的代码重排或优化掉,导致你明明看到DMA在搬数据,程序逻辑却总是用旧值。缓冲区声明成volatile,或者用内存屏障宏,都能解决。

3. 典型应用场景:串口、ADC与存储里的DMA

3.1 串口DMA接收不定长数据:空闲中断是关键

串口接收是最常见、也最能体现DMA价值的场景,尤其是不定长数据。传统方式每收一个字节触发一次接收中断,软件判断是不是一帧结束,这种方式在数据量大时CPU压力极大。DMA方案的基本思路是:让DMA把收到的字节持续搬进一个大缓冲区,CPU不参与中间过程,等检测到一帧数据结束再统一处理。

问题来了,怎么知道一帧数据结束?嵌入式圈子里最常用的方案是“接收空闲中断”。很多串口外设都有空闲检测功能,当总线上连续一段时间没有新的起始位,就认为线路空闲了,触发空闲中断。对应到DMA接收就是:DMA还在循环搬运,空闲中断一响,说明对方发完了一帧,这时候CPU去看DMA当前还剩多少个计数,用缓冲区总大小减去当前计数,就能算出这一帧的实际长度。

这个方案在STM32 HAL库和PY32F003这种Cortex-M0+平台上的配置思路完全一致。PY32F003这类入门级MCU虽然资源有限,但串口的空闲中断加DMA功能不少型号都是具备的,直接照着抄就能用。

初始化代码大体上是这样的:

先开启串口RX的DMA,方向外设到内存,模式用循环模式,缓冲区定义足够大。然后使能串口空闲中断。之后就不用管了,数据一直在DMA搬运。空闲中断触发后,回调函数里去读DMA计数寄存器,算出本次数据长度,然后处理这一帧。

关键代码骨架我贴一下HAL库版本:

#define RX_BUF_SIZE 256 uint8_t rx_buf[RX_BUF_SIZE]; void uart_rx_dma_init(void) { __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); HAL_UART_Receive_DMA(&huart1, rx_buf, RX_BUF_SIZE); __HAL_DMA_DISABLE_IT(&hdma_usart1_rx, DMA_IT_HT); } void HAL_UART_IDLECallback(UART_HandleTypeDef *huart) { uint16_t remain, len; remain = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); len = RX_BUF_SIZE - remain; // 这时 rx_buf[0] 到 rx_buf[len-1] 就是一帧数据 process_frame(rx_buf, len); }

这里把DMA半传输中断关掉是经验之谈,不定长接收时半传输中断基本用不上,留着反而增加复杂度。

实际项目中坑也不少。第一,如果一帧数据长度正好等于缓冲区大小,或者数据流一直不停,空闲中断不会触发,这时候需要额外机制,比如配合定时器超时或者用半传输中断辅助处理。第二,循环模式下DMA地址会回绕,如果上一帧数据跨越缓冲区结尾,处理的时候要特别小心,否则拿到的是拼错位的数据。稳妥做法是配合DMA半传输中断做双缓冲,数据落在前半段时处理前半段,落在后半段时处理后半段。

3.2 ADC多通道DMA连续采样配置

ADC采样是另一个典型的DMA应用场景,尤其是多通道连续采样。如果不使用DMA,每个通道转换完成都要进中断,取一次数据,再做下一次转换;一旦通道数多、采样频率高,中断开销非常可观。用DMA之后,ADC每转换完一个通道,结果就自动搬到内存数组里对应的位置,CPU只等整批数据就绪再处理统计计算。

STM32 HAL库的配置一般是这样:先配置ADC为扫描模式、连续转换,然后启动DMA,数据目的地址指向自己定义的数组。比如两个通道各采10次,缓冲区数组大小就是20,用半字类型。

代码骨架:

#define ADC_CH_NUM 2 #define SAMPLE_COUNT 10 uint16_t adc_buf[ADC_CH_NUM * SAMPLE_COUNT]; void adc_dma_init(void) { // ADC1 配置为扫描模式、连续转换 // DMA配置为循环模式、外设到内存、半字 HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, ADC_CH_NUM * SAMPLE_COUNT); }

这里有个细节,DMA启动时第三个参数传的是“总共要搬运的样本数”,不是“每个通道的采样次数”。我见过有人把两个概念搞混,导致缓冲区只用了一半,另一半永远是零。另外,多通道DMA采样时,数据的排列顺序一般和ADC扫描顺序一致,也就是先第一个通道的结果,再第二个通道的结果。如果你想的是“第一个通道采10次,再第二个通道采10次”这种分块排列,那是错的,DMA给出的通常是一轮一轮交错排列,处理统计时要按通道间隔取数。

首采数据不稳定也是一个老问题。ADC刚上电或者刚切换通道后的第一次采样经常不准,多通道扫描时尤其明显。我一般会在统计时丢弃第一批数据,或者让DMA缓冲区留一点余量,等第二个完整周期再拿来做平均值。别小看这一点,采样不准导致后续控制抖动的案例我遇到过好几起。

3.3 UFS DMA:存储子系统里的搬运工

串口和ADC属于微控制器层面的DMA,存储领域里的UFS DMA则是另一种形态。

UFS即通用闪存存储,在手机、平板和车载存储里很常见。UFS协议栈内部有一套自己的DMA引擎,负责主机内存和UFS设备队列之间的数据搬运。主机侧要发起一次读写操作,系统会把命令描述符、数据传输描述符组织到内存里,然后由UFS主机的DMA引擎批量搬给UFS设备控制器。它解决的问题和单片机里一模一样:数据量太大,不能依赖CPU逐个搬运。

分布式DMA则是把这些引擎分散在系统的多个节点,每个节点都管好自己的本地搬运任务,减少单一中心DMA引擎的瓶颈。嵌入式工程师如果未来接触多核、大存储的芯片,这个概念会越来越常碰到。但不管形态怎么变,DMA的本质仍然是“用硬件搬运、按描述符工作、完成之后发通知”,理解了应用处理器层面这一套,看到UFS控制器里的DMA同样不会陌生。

顺带说一句,DMA这个缩写在不同行业里的含义不一样。硬件领域说的是Direct Memory Access。但在某些工程管理模式里,DMA可能指别的含义,比如权责转移。所以看到有人讨论“结构工程师的工作流程DMA”,也别急着代入内存搬运,先确认上下文再聊。做技术最怕张冠李戴,名词对不上,讨论半天全白搭。

4. 常见问题与排查技巧实录

4.1 串口DMA发送要不要等上一轮发完

结论再明确一点:要等,而且等的是串口的TC标志,不是DMA的完成标志。

串口发送数据时实际上有两个环节:DMA把数据从内存搬到串口的发送数据寄存器,串口硬件再把发送数据寄存器里的数据通过移位寄存器一位一位发出去。DMA的发送完成中断只是说“数据都搬进发送数据寄存器了”,但这时候移位寄存器可能还有最后一个字节没发完。如果立即启动新一轮DMA发送,新的数据可能覆盖还没发出去的数据,效果就是数据错乱。

稳妥的写法是用串口的TC(Transmission Complete)标志判断上一次真正发送完毕,再启动下一轮DMA。HAL库参考写法:

while (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_TC) == RESET); HAL_UART_Transmit_DMA(&huart1, buf, len);

还有一个细节,HAL库的HAL_UART_Transmit_DMA接口内部会清除TC标志,所以在调用前等待TC标志置位是安全的。如果用的是寄存器级别的代码,也要注意先清标志再启动新的DMA,否则残余的标志状态会干扰下一次传输的状态判断。

4.2 DMA连续请求模式的正确打开方式

“DMA连续请求”这个模式,在不同芯片上有不同的叫法,但是思路类似:普通DMA是等外设触发的被动模式,连续请求是DMA自己主动产生请求的自触发模式。

什么时候用这种自触发模式?可以用在内存到内存的批量搬运上。比如你要把一块缓冲区全部清零,或者把一张图片数据在内存里复制一份,配置好源地址、目的地址、传输长度,开启连续请求,DMA就一直搬,搬完这轮才停。配置这样一个任务只需要一条CPU指令,后续就不用管了。

什么时候别用?外设到内存的正常接收场景千万别开。一旦开了,DMA根本不理会外设有没有新数据,会一直重复读取外设寄存器往内存里写,数据全乱套。而且这种自触发模式如果配置成循环模式,DMA会永远忙个不停,系统其他任务全部卡顿。我调试时曾遇到过DMA一直在跑、CPU几乎被拖垮的现象,最后把连续请求位一关,整个世界清净了。

4.3 DMA疑难杂症排查方法论

调了几年DMA,我总结了一个排查套路,遇到问题先别乱改代码,按顺序查。

第一步查时钟。DMA控制器和外设的时钟必须都打开,而且如果外设时钟没开,外设寄存器读出来全是不确定值,DMA会从错误地址搬数据。这个级联问题经常伪装成“DMA配置错误”。

第二步查配置。源地址、目的地址、地址增量、数据宽度、方向,逐一对照寄存器手册确认。尤其注意外设寄存器地址是地址不是值,填充的时候用取地址符号,别直接填数据值。

第三步查中断和处理函数。DMA传输完成中断有没有正确清标志,半传输中断和传输完成中断的优先级配置有没有问题,还有DMA错误中断有没有触发。传输错误标志如果长期没人清,DMA会一直卡在错误状态。

第四步查缓存一致性。带D-Cache的芯片必须处理Cache一致性,否则就是“数据看起来没变”的灵异事件。我之前遇到过一版程序在开了Cache加速之后串口DMA收到的数据永远是旧值,清掉Cache就好了,就是这个原因。

第五步查缓冲区地址。有些芯片DMA不能访问全部内存区域,或者要求缓冲区地址按数据宽度对齐。比如半字传输要求地址偶数对齐,字传输要求4字节对齐。缓冲区声明成uint16_t数组有时地址不对齐,用__attribute__((aligned(4)))指定一下更稳妥。

这五步基本上能解决九成的DMA问题。剩下的那一成,大概率是芯片参考手册里某个特定模式的隐藏限制,这个时候不要猜,直接查勘误表。

4.4 Modbus与DMA:自由协议和DMA的矛盾

最后聊一个不太常被提到的坑,Modbus RTU配DMA。

Modbus RTU协议要求两帧之间必须有3.5个字符时间的间隔,接收方靠这个间隔来判断一帧结束。如果用DMA加空闲中断来接收,看似完美,但实际上有个隐患:3.5个字符时间在高波特率下非常短,有的芯片的空闲中断检测粒度根本来不及在这个时间内可靠触发。更麻烦的是,Modbus帧内部如果刚好有一段比较长的电平静止区间,空闲中断可能提前触发,把一帧拆成两半。

我在实际项目中处理Modbus RTU和DMA的组合,一般是两种思路:要么不用DMA,直接用串口接收中断加定时器超时判断一帧结束,虽然CPU占用多一点,但协议时序可靠;要么必须用DMA时,DMA只负责把数据搬到缓冲区,帧边界判断交给定时器,定时器超时了才认为一帧结束,再取DMA缓冲区里的数据。这个方案牺牲了一点实时性,换来了协议解析的可靠性,对于一个工业总线协议来说非常值得。

我在实际调DMA的过程中,最大的体会是:DMA本身并不复杂,复杂的是“你以为DMA配置对了,但它偏偏不出数据”的那一刻。遇到这种情况,别急着怀疑硬件,拿起参考手册,把寄存器配置、时钟、中断、缓存一致性一项项排过去,大多数问题都能水落石出。最后再分享一个我自己的习惯,DMA缓冲区全用volatile修饰,并且声明地址对齐,凡是涉及DMA的代码宁可多写几行防御性初始化,也别等着线上出bug再头疼。希望这篇能把DMA聊透,省去你一周的翻手册时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询