我平时调试嵌入式项目时,经常看到有人在中断服务函数里一咬牙开了个 while 循环:“直接把数据拷完再出中断”。程序确实能跑通,但 CPU 的命是真的苦。尤其是设备里同时挂了两路串口、一路 ADC 连续采样的时候,中断一多,主循环里稍微跑个复杂点的滤波算法,响应就开始拖泥带水。
STM32 的 DMA 就是专门来解决这个问题的。你可以把 DMA 理解成一个“专职搬运工”,负责在内存和外设之间、或者内存和内存之间批量搬数据。CPU 只需要告诉他“从哪搬、搬到哪、搬多少、什么时候搬”,然后就可以回去干自己的正事。数据搬完了,DMA 再打个中断告诉 CPU 一声,整个过程完全不需要 CPU 逐个字节地参与。
这篇博文我会从为什么 CPU 亲自搬数据很亏讲起,然后拆解 DMA 的核心参数和工作流程,再分别用内存到内存拷贝、串口不定长接收、ADC 多通道连续采样三个实战场景,把 DMA 的配置思路、代码要点和常见坑位全部过一遍。适合刚接触 STM32 DMA 的开发者,也适合已经写过一些 DMA 代码、想搞清楚底层逻辑和避坑细节的老手。
1. 先算一笔账:CPU 亲自搬数据,到底亏了多少?
很多人觉得 DMA 是“高级功能”,其实它的出发点特别朴素:数据搬运这种机械劳动,让 CPU 一个字节一个字节地来干,太浪费了。我们先认真算一笔账,看看 CPU 亲自搬数据的代价有多大。
1.1 轮询模式:一个字等到底
最原始的串口接收方式是轮询。程序在一个循环里反复读串口状态寄存器,如果发现接收标志置位,就读数据寄存器,然后写到缓冲区。
while (1) { if (USART1->SR & USART_SR_RXNE) { ring_buf_push(USART1->DR); } // 其他任务 }这种方式代码最简单,但 CPU 几乎是被迫围着串口转。115200bps 波特率下,每接收一个字节大约需要 87 微秒(1 / 11520 字节每秒)。你觉得 CPU 只是顺手读了一下?问题在于,为了不错过字节,CPU 必须不断回来检查标志位。这期间它没法安心执行复杂的 DSP 运算,因为一旦算得太投入,串口就溢出了。
轮询模式下,CPU 的有效算力被“等字节”这件事切割得支离破碎。很多开发者遇到性能问题时第一反应是开优化、降波特率,实际上真正的原因就是数据通路全压在 CPU 身上。
1.2 中断模式:搬一个字节叫一次人
后来大家学聪明了,用中断替代轮询:串口每收到一个字节,硬件自动暂停当前程序,跳转进中断服务函数,读数据、写缓冲、清标志、跳出中断。
void USART1_IRQHandler(void) { if (USART1->SR & USART_SR_RXNE) { ring_buf_push(USART1->DR); } }看起来没问题,但每个字节都要走一遍完整的“进中断、压栈、处理、出中断、恢复现场”流程。以常见的 168MHz 主频估算,假设每次中断服务程序完整跑完需要约 120 个主频周期,一路 115200bps 串口每秒会到 1.15 万个字节,每秒就要烧掉约 138 万个周期,占 CPU 总能力约 0.8%。
单看 0.8% 似乎不多,可项目里往往不止一路串口。再来两路串口、一路 ADC 转换中断、几个定时器中断、一个无线模块的数据接收,这些中断源的消耗一叠加,可用 CPU 能力可能就被切掉 10% 甚至更多。再加上中断优先级反转、响应延迟抖动,问题根本不是“CPU 算不过来”,而是“CPU 被频繁打断,导致调度和实时性变得很难控制”。
1.3 DMA 的省法到底省在哪
DMA 做的事情,本质上就是把上述“搬数据”这一步从 CPU 手里拿掉。外设的接收标志一旦置位,DMA 控制器直接通过总线去读外设数据寄存器,再写到内存地址;整个过程 CPU 不需要被中断,不需要进中断服务函数,也不需要关心搬运过程。
最终效果是:同样一路 115200bps 串口,CPU 只在 DMA 配置时花几百个周期,之后完全零开销,直到数据缓冲区快满时 DMA 才产生一次完成中断。把频繁的小中断合并成低频次的大中断,这就是 DMA 省性能的核心逻辑。
2. DMA 的工作方式:谁在搬、往哪搬、搬多大、搬几次
理解了“为什么要用”,接下来要看“DMA 到底怎么搬”。不少开发者直接照着 CubeMX 的界面配置 D MA,能跑通但换个外设就懵了,原因就是没搞懂 DMA 的几个核心参数。
2.1 DMA 传输的四个关键参数
DMA 本质上就是一个状态机,每次搬运都围绕这四个问题展开:
- 源地址:数据从哪里来。可能是外设的数据寄存器,比如
USART1->DR、ADC1->DR,也可能是一块内存地址。 - 目的地址:数据到哪里去。通常是内存缓冲区,也可能是外设数据寄存器。
- 传输数据量:搬运多少个数据单元。DMA 内部有一个计数器,每搬一个单元就减一,减到零就表示传输完成。
- 数据单元大小和地址递增:每次搬运是 8 位字节、16 位半字还是 32 位字;源地址和目的地址是否每次搬运后自动递增。
最后一个参数经常被忽略。比如串口接收是一个字节一个字节连续进入内存,源地址(外设数据寄存器)不需要递增,目的地址(内存缓冲区)必须递增;而内存到内存拷贝时,源和目的地址通常都需要递增。
DMA 还有一个“传输模式”的概念。普通模式下,数据量搬完就停止,需要软件重新启动;循环模式下,数据搬完后计数器自动重新加载,源地址和目的地址回绕,可以连续不断传输。串口接收和 ADC 连续采样用的基本都是循环模式。
2.2 请求映射:外设怎么找到 DMA 通道
STM32 的 DMA 不是“随便哪个通道都能服务任何外设”。每个 DMA 通道/流与什么外设请求绑定,通常在芯片参考手册里有一张请求映射表。比如某条 DMA 流可以响应定时器更新事件,另一条流可以响应某路串口的发送请求。
这块最容易踩的坑就是:代码配置了 DMA 和外设,但忘记查请求映射,导致外设的请求根本给不到目标 DMA 通道。项目里我一般会先做一件事:翻开对应 MCU 参考手册的 DMA 请求连接表,把要用到的外设请求和 DMA 通道/流对应关系抄在注释里,再开始写代码。
// 例如:UART1 接收请求映射到 DMA1_Stream5 __HAL_RCC_DMA1_CLK_ENABLE(); hdma_uart1_rx.Instance = DMA1_Stream5; hdma_uart1_rx.Init.Request = DMA_REQUEST_USART1_RX;如果是带 DMAMUX 的型号,配置会更灵活,但也意味着你要注意外设请求连接器是否配置正确。CubeMX 里通常帮你绑定了,手工开发时要格外小心。
2.3 一个完整的传输流程
抛开具体寄存器,DMA 一次完整的数据搬运流程是这样的:
- 软件配置好源地址、目的地址、数据量、递增模式、传输模式和中断开关。
- 外设产生请求事件,例如串口接收寄存器非空、ADC 转换完成、定时器更新。
- DMA 收到请求后,通过 AHB 总线矩阵申请总线使用权,再执行一次源地址读取和目的地址写入。
- DMA 内部计数器减一,源/目的地址按配置递增或保持不变。
- 重复上述搬运,直到计数器减到零。
- 如果开启了传输完成中断,DMA 会产生一个完成事件,CPU 进入中断处理。
整个过程中,CPU 可以在自己的指令流上继续执行,只有在 DMA 和 CPU 同时竞争总线时,CPU 会被插入少量等待周期。这个等待周期很短,和“每个字节都要进一次中断”相比完全不是一个量级。
3. 内存到内存的搬运实验:让 DMA 把数组从一个地方挪到另一个地方
要说 DMA 最直观、最容易验证的场景,一定是内存到内存拷贝。两个数组之间复制数据,用普通 C 语言的memcpy也能做,但换成 DMA 之后,你能很清晰地看出“CPU 启动完传输就去干别的了”这件事。
3.1 实验场景:把一个大数组原样拷走
假设我有一块传感器采集的原始数据数组src[1024],需要在初始化时把里面的标定参数拷贝到工作缓冲区dst[1024]。老办法是memcpy,在单片机主循环里执行 1024 次数据传输,CPU 忙一小段时间,然后用起来也毫无问题。
但如果拷贝的数据量很大,或者拷贝动作发生在系统实时性要求较高的时刻,让 CPU 去搬这一大坨数据就不太合适了。DMA 版本的拷贝可以做到:配置好源地址src、目标地址dst、数据长度 1024 个 32 位数据单元,然后HAL_DMA_Start启动,CPU 立即返回主循环继续跑其他逻辑,等 DMA 搬完再通过回调通知。
3.2 核心代码示例
以 STM32 HAL 库为例,一个最小内存到内存 DMA 实验长这样:
#define BUF_SIZE 1024 uint32_t src[BUF_SIZE] __attribute__((aligned(4))); uint32_t dst[BUF_SIZE] __attribute__((aligned(4))); DMA_HandleTypeDef hdma_m2m; void MX_DMA_M2M_Init(void) { __HAL_RCC_DMA1_CLK_ENABLE(); hdma_m2m.Instance = DMA1_Stream0; hdma_m2m.Init.Request = DMA_REQUEST_MEM2MEM; hdma_m2m.Init.Direction = DMA_MEMORY_TO_MEMORY; hdma_m2m.Init.PeriphInc = DMA_PINC_ENABLE; // 源地址递增 hdma_m2m.Init.MemInc = DMA_MINC_ENABLE; // 目标地址递增 hdma_m2m.Init.PeriphDataAlignment = DMA_PDATAALIGN_WORD; hdma_m2m.Init.MemDataAlignment = DMA_MDATAALIGN_WORD; hdma_m2m.Init.Mode = DMA_NORMAL; hdma_m2m.Init.Priority = DMA_PRIORITY_HIGH; HAL_DMA_Init(&hdma_m2m); } void Start_M2M_Copy(void) { HAL_DMA_Start(&hdma_m2m, (uint32_t)src, (uint32_t)dst, BUF_SIZE); }注意这里有一个容易疑惑的点:为什么内存到内存模式也要配置PeriphInc?因为在 DMA 内部概念里,“外设端口”这个角色在内存到内存传输中被用来映射源地址。如果把PeriphInc设置成不递增,DMA 就会反复读src[0],结果整个目标数组里全是第一个数的副本。
在启动 DMA 前,还要确保源数组内容已经准备好了。如果源数据还在计算过程中就启动搬运,DMA 可能搬走一半旧数据、一半新数据,这种数据撕裂问题在工程里很常见。
3.3 为什么内存到内存也要谨慎使用
DMA 并非在所有场景都比 CPU 拷贝有优势。对于小数据量,比如几十个字节,DMA 的初始化开销(关配置、启动、等完成中断)可能比 CPU 直接循环拷贝还要慢。所以我的经验是:小批量数据用memcpy,大块连续数据、对 CPU 时间敏感的场景才用 DMA。
内存到内存 DMA 还有一个容易被忽略的副作用:它会占用总线带宽。如果这时候有一个高优先级的外设 DMA 传输也在等待总线,可能会产生竞争。具体项目里,把内存到内存 DMA 的优先级设成低于实时外设传输,避免影响串口或 ADC 的实时数据流。
4. 串口 DMA 接收:用空闲中断实现不定长报文完整接住
串口接收是 DMA 用得最多的场景,也是最值得讲透的场景。因为很多通信协议是不定长帧,你不能固定知道对方一次会发多少字节。这时候只靠普通 DMA 传输完成中断是不够的,需要结合串口空闲中断。
4.1 经典方案:DMA 接收 + 空闲中断
思路很简单:把 DMA 配置成循环模式,缓冲区设为协议允许的最大帧长。串口每收到一个字节,DMA 自动把它搬进缓冲区。当串口总线上出现一段空闲时间(也就是没人继续发数据)时,串口硬件会置位空闲标志并产生 IDLE 中断。
这个“一段空闲”通常意味着接收完毕,此时我去读 DMA 的当前计数器,就能算出这一帧实际收了多长:
uint16_t remain = __HAL_DMA_GET_COUNTER(&hdma_uart1_rx); uint16_t rx_len = RX_BUF_SIZE - remain;代码上,开启 DMA 接收后单独使能空闲中断:
HAL_UART_Receive_DMA(&huart1, rx_buf, RX_BUF_SIZE); __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);在串口中断服务函数里判断空闲标志,然后处理一帧数据:
void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 关 DMA,取长度 __HAL_DMA_DISABLE(&hdma_uart1_rx); uint16_t len = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_uart1_rx); process_frame(rx_buf, len); // 重新配置数据长度并开启 DMA __HAL_DMA_SET_COUNTER(&hdma_uart1_rx, RX_BUF_SIZE); __HAL_DMA_ENABLE(&hdma_uart1_rx); } HAL_UART_IRQHandler(&huart1); }关键步骤是重新使能 DMA 前,必须先把计数器和地址指回缓冲区起点。循环模式下 DMA 地址会自动回绕,但在空闲中断里手动关断了传输,重新启动时就要自己把计数器复位。
4.2 环形缓冲区怎么设计
上面的方案是“每次空闲中断把一帧数据交给process_frame处理”,适合一帧一处理的协议。但很多项目希望主循环按自己的节奏读取数据,这时候就需要环形缓冲区。
环形缓冲区的实现我没必要再贴完整代码,核心就是一个数组加两个读写下标。接收中断里往写下标处填数据,主循环里从读下标处取数据,读写下标相等表示缓冲区为空。
有一种做法是把 DMA 直接接到环形缓冲区上,但 DMA 地址递增是线性的,数组到尾部之后不会自动绕回开头,处理起来很绕。更省心的做法是:DMA 先写一个线性缓冲区,空闲中断触发时,再把这一帧有效数据按字节拷贝进环形缓冲区。虽然多了一次内存拷贝,但逻辑清晰,出错概率低。
4.3 HAL 库下必须注意的边界情况
只依赖空闲中断有一个漏洞:如果接收的数据长度刚好等于 DMA 缓冲区长度,数据填满缓冲区时 DMA 会产生传输完成中断,但这时可能还没有空闲标志。如果没做处理,这一帧数据就会一直留在缓冲区里,直到下一帧数据到来把旧数据覆盖掉。
所以完整的实现必须同时处理两个中断:
- DMA 传输完成中断:缓冲区填满,立即读取整段数据。
- 串口空闲中断:缓冲区没满但总线空闲了,读取当前长度。
在 HAL 库里,传输完成中断对应HAL_UART_RxCpltCallback。我在实际项目中会把两边的长度计算写成一个公共函数,避免重复代码。
另外,重新开启 DMA 接收时,如果之前开启了 DMA 中断并触发了完成回调,要注意回调函数里不要再调用HAL_UART_Receive_DMA重复启动。否则可能导致 DMA 在未完全复位时又一次启动,数据错位。
5. ADC 多通道连续采样:DMA 让数据自己排好队进内存
串口之外,ADC 连续采样是 DMA 的另一个主战场。很多震动监测、音频采集、电源纹波分析项目里,都需要上百千赫兹的采样率,同时采多个通道。如果每次转换完成都让 CPU 进中断读数据,系统基本干不了别的事。
5.1 为什么 ADC 连续采样离不开 DMA
ADC 连续扫描模式会按照规则组顺序自动转换多个通道。每个通道转换完成后,数据结果存放在同一个数据寄存器ADCx->DR里。问题来了:如果不及时把上次的结果读走,下一次转换结果就会把旧值覆盖掉。
用 DMA 后,ADC 每次转换结束产生的 DMA 请求,会把ADCx->DR的数据自动搬到内存数组里的下一个位置。规则组的通道顺序直接对应数组下标顺序。例如开了 3 个通道,规则组顺序是IN0、IN1、IN2,那么 DMA 搬进内存的数组就是adc_buf[0] = IN0 结果、adc_buf[1] = IN1 结果、adc_buf[2] = IN2 结果。
代码也很简洁:
HAL_ADC_Start_DMA(&hadc1, adc_buf, 3);之后每完成一轮规则组转换,DMA 会触发放置完成回调。CPU 在大部分时间里不需要关注 ADC,专心跑算法就行。
5.2 乒乓缓冲实现无丢失采样
ADC 连续不断产生数据,主循环读取adc_buf时,可能 DMA 正在往同一个数组写入新数据,造成数据一半旧一半新。解决方法是乒乓缓冲:准备两个数组,DMA 正在写数组 A 时,CPU 处理数组 B;DMA 写完 A 再去写 B,同时 CPU 开始处理 A。
实现思路不复杂:在 DMA 传输完成中断回调里,切换下一次启动 DMA 的目标地址。HAL 库里可以手动调用停止再启动,但更好的方式是用 DMA 的半传输/全传输中断配合双缓冲机制。
不过要冷静看待乒乓缓冲:如果你只是做低速环境监测,几赫兹到几十赫兹的刷新率,单缓冲加临界区保护完全够用;但如果是音频级采样率,主循环轮询数组肯定来不及,乒乓缓冲几乎是必须的。
5.3 定时器触发采样
ADC 还有一个进阶玩法:让定时器触发 ADC 启动转换,DMA 负责搬数据。这样采样频率由硬件决定,不依赖 CPU 的定时器中断精度。比如定时器更新事件作为 ADC 的外部触发源,每次更新就启动一次规则组转换,DMA 把结果搬走。
这个方案在实现上有几个细节:定时器的周期要和采样率匹配;ADC 的触发源要配置成对应的定时器;DMA 的数据量要覆盖你一轮处理需要的采样点数。整体配置看起来复杂,但本质仍然是“定时器发令枪、ADC 干活、DMA 搬砖、CPU 享福”的组合。
6. 踩过的坑与优化建议:DMA 不复杂,复杂的是细节
DMA 的上手成本其实不高,真正让开发者头疼的是那些“配置都对,但数据就是不对”的隐蔽问题。我把自己反复踩过的几个坑整理在下面,每一个都值得你在代码里加上注释。
6.1 缓存一致性:Cortex-M7 的特殊问题
如果你的 MCU 内核带数据缓存,比如部分带 L1 缓存的型号,DMA 和 CPU 之间的数据一致性问题是最大的坑。CPU 写入内存的数据可能还留在缓存里,没有真正回写到物理内存;这时候 DMA 去读物理内存,读到的可能是旧数据。反过来,外设 DMA 写入内存的数据也可能没有更新缓存,CPU 直接读这个地址时,读到的可能是缓存里的旧快照。
解决方案有三种:
- 将 DMA 缓冲区配置到非缓存内存区域,通过 MPU 设置属性。
- 每次 DMA 传输前执行缓存回写操作。
- 对地址和长度做对齐操作后,执行缓存失效操作。
在代码里,常见的写法是:
SCB_CleanDCache_by_Addr((uint32_t *)tx_buf, len); // 然后启动 DMA 发送其中缓存行大小通常是 32 字节,地址和长度必须按 32 字节对齐才有效。我一开始没注意对齐,导致数据总是第一段正确、后面偶尔错,排查了很久才定位到是缓存行部分失效的问题。
6.2 地址对齐与数据宽度
DMA 对内存地址的对齐要求比普通变量更严格。配置数据宽度为字传输时,源地址和目标地址必须按 4 字节对齐;使用 16 位半字传输时,至少按 2 字节对齐。很多奇怪的 DMA 错误,比如标志位不置位、数据错位,根源就是缓冲区地址没对齐。
我建议把 DMA 缓冲区统一声明成uint32_t数组,或者显式加上对齐属性。另外,数据宽度的选择要和外设实际寄存器宽度匹配。ADC 结果寄存器是 16 位有效数据,如果你把 DMA 数据宽度配成字节,一次只会搬低 8 位,结果完全不对。
6.3 中断优先级
DMA 传输完成中断和外设中断的优先级分配也值得讲究。串口 DMA 接收时,如果 DMA 传输完成中断优先级太低,而系统里恰好有长时间占用中断的中断源,DMA 请求状态可能得不到及时处理,造成数据覆盖。
但也不是说 DMA 中断优先级越高越好。如果优先级过高,它会抢占其他实时性要求更高的中断,结果按下葫芦浮起瓢。我个人习惯是:对高速数据流(如大波特率串口、ADC 高速采样)的 DMA 中断设置较高抢占优先级;把真正耗时的协议解析放到主循环里,中断里只做标记和最短的数据拷贝。
6.4 完成中断与空闲中断的先后顺序
串口不定长接收里,如果一帧数据正好填满 DMA 缓冲区,硬件首先触发的是 DMA 传输完成中断,空闲中断可能稍后才到。这时候如果你的代码只在空闲中断里取数据,就漏掉这一帧了。
正确的判断逻辑是:两个中断都处理,但是要防止重复处理。我通常用一个标志变量标记“当前缓冲区的数据已经被取走”,两个回调里先检查并设置这个标志,保证一帧数据只被消费一次。
还有一点容易被忽略:关闭 DMA 重新配置计数器前,要先确保 DMA 已经完全失能。有些系列需要软件确认 DMA 停止,否则立刻重新配置可能有残留状态。稳妥的做法是调用 HAL 库的停止接口后,等待相关的传输标志清除。
我在自己的项目里,把 DMA 接收的所有边界条件整理成了一张排查表,遇到数据异常时按顺序查:
| 现象 | 可能原因 | 检查方向 |
|---|---|---|
| 数据全 0 | 缓冲区地址配置错误 | 检查源/目的地址 |
| 数据错位 | 数据宽度不匹配 | 检查对齐和数据宽度 |
| 丢帧 | 缓冲恰好满时没处理 | 检查 DMA 完成回调 |
| 旧数据残留 | 缓存一致性问题 | 检查 DCache 回写失效 |
| 寄存器配置失败 | DMA 未完全失能 | 检查关闭等待流程 |
这张表对我排查问题帮助很大,建议你写代码时也把类似场景记录下来。
7. 最后说点实际的
回看这些年做过的采集设备,我发现一个规律:早期遇到“CPU 占用率下不来”“中断太频繁导致主循环卡顿”这类问题,我第一反应是优化算法、降低采样率、调整中断优先级,效果却总是不尽如人意。后来把所有周期性数据搬运全部交给 DMA,CPU 的资源一下子就宽裕了,很多棘手的实时性抖动问题随之消失。
DMA 本身并不神秘,它就是一台没有感情的搬运机器。你只要把源地址、目的地址、数据量、递增方式、传输模式这五件事交代清楚,剩下的它自己会干好。
最后分享一个小技巧:调试 DMA 时,不要一开始就接真实外设。先拿两段内存数组做传输,用调试器观察 DMA 目标数组是否按预期变化。这一步能确定 DMA 基本配置是对的,再去接串口、ADC,问题范围会小很多,你也能更快定位到是外设配置问题还是 DMA 配置问题。