我平时调试嵌入式项目时经常看到有人在中断服务函数里一咬牙开了个 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 连续采样离不开 DMAADC 连续扫描模式会按照规则组顺序自动转换多个通道。每个通道转换完成后数据结果存放在同一个数据寄存器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 处理数组 BDMA 写完 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 占用率下不来”“中断太频繁导致主循环卡顿”这类问题我第一反应是优化算法、降低采样率、调整中断优先级效果却总是不尽如人意。后来把所有周期性数据搬运全部交给 DMACPU 的资源一下子就宽裕了很多棘手的实时性抖动问题随之消失。DMA 本身并不神秘它就是一台没有感情的搬运机器。你只要把源地址、目的地址、数据量、递增方式、传输模式这五件事交代清楚剩下的它自己会干好。最后分享一个小技巧调试 DMA 时不要一开始就接真实外设。先拿两段内存数组做传输用调试器观察 DMA 目标数组是否按预期变化。这一步能确定 DMA 基本配置是对的再去接串口、ADC问题范围会小很多你也能更快定位到是外设配置问题还是 DMA 配置问题。