1. 为什么说 DMA 是给 CPU 找的“搬运工”
如果你点进这篇文章,大概率已经看过那个经典的场景:CPU 忙得要死,一遍遍把数据从外设搬到内存,再从内存搬到外设,而真正该算的算法反而没时间跑。很多初学者一开始并不理解 STM32 的 DMA 到底能省多少事,总觉得“反正数据量不大,CPU 顺手搬一下就行了”。等你真正把 4 串口同时收数据、ADC 连续采样、SPI 刷屏幕这些需求堆一起时,CPU 的主频再高也会被“搬砖”拖到崩溃。这篇文章就是从“为什么必须用 DMA”开始,讲到怎么配置、怎么处理中断、怎么避开 Cache 一致性和空闲中断这些经典坑,最后把我的调试方法直接拿出来给你抄作业。
这个内容适合正在学 STM32 的朋友,也适合已经用 HAL 库写了不少外设驱动、但一到 DMA 就总是“能跑但不稳定”的开发者。我不会只贴代码,而是把每个配置背后“为什么这么做”讲清楚,这样你换一个型号、换一个外设也能自己推出来。
先给一个结论:DMA 不是把传输变快,而是把“谁来做传输”换了个人。传输本身还要占用总线周期,但它让 CPU 从重复劳动里脱身,去处理真正需要判断和计算的任务。理解了这一点,后面所有配置逻辑都顺了。
1.1 CPU 搬砖到底有多亏
我见过不少刚上手的朋友,串口接收用中断,一个字节进一次中断服务函数,在里面把数据丢进环形缓冲区。开始的几百字节没问题,但一旦数据量大、中断频率高,CPU 大部分时间都在“进中断-读DR-存内存-退出中断”这个循环里打转。这种情况我称之为“中断风暴”,时间都花在现场保护和恢复上了,核心算法反而没资源跑。
用数据说话:假设串口波特率 115200,满负荷每秒约 11520 字节。每个字节进一次中断,就算每次中断服务函数只花 20 个周期,每秒就要多花 23 万个周期。看着不多,但如果你同时开 5 个串口、每秒钟几十万次 ADC 采样,这数字立刻就变得恐怖。更麻烦的是,高频中断会不断抢占 CPU,导致其他任务的实时性抖得很厉害。
DMA 解决的就是这个问题:搬运数据这件事完全由 DMA 控制器完成,搬运完毕才产生一次中断。CPU 从“每字节打扰一次”变成“一批数据结束才打扰一次”,压力完全不是一个量级。
1.2 DMA 到底帮你干了啥
DMA(Direct Memory Access)直接存储器访问,本质是一个独立于 CPU 的硬件搬运引擎。它的工作就是把数据从源地址搬到目标地址,不需要 CPU 逐字节介入。你可以把它理解成仓库里专门开叉车的师傅:CPU 是仓库主管,只需要说“把那批货从A区搬到B区”,然后就可以去处理订单,搬完了叉车师傅按个铃告诉你一声就行。
在 STM32 里,DMA 能做三类典型搬运:外设到内存(比如串口接收、ADC采样)、内存到外设(比如串口发送、DAC输出)、内存到内存(比如从一个大数组拷贝到另一个数组)。前两种最常用,第三种在协议栈处理里偶尔会用到。
用 DMA 最大的收益不是“数据更快”,而是“CPU 更闲”。系统实时性变好,功耗也能降下来,因为 CPU 可以更快进入睡眠模式,数据搬运交给 DMA 做就行。搞嵌入式如果只看“主频高不高”,很容易忽略这种更重要的调度资源。
2. DMA 的核心机制与配置前必须搞懂的那些事
我见过太多人一上来就照着例程抄,结果换个通道、换个数据宽度就懵了。DMA 配置虽然就那么几个选项,但每个选项背后都有硬件限制。下面这几个概念,是你在打开 CubeMX 之前必须想清楚的。
2.1 三个关键词:传输方向、数据宽度、循环模式
先说传输方向。STM32 DMA 的传输方向由外设是发送方还是接收方决定,而判断标准很简单:看数据从哪边发起。串口接收是外设产生数据,所以方向是“外设到内存”;串口发送是 CPU 准备好数据让外设发出去,方向是“内存到外设”。方向配置错了最典型的症状就是 DMA 一直触发,但缓冲区永远不变。
再说数据宽度。字节、半字、字分别对应 8 位、16 位、32 位。这个配置必须和外设寄存器以及内存缓冲区的实际类型对齐。举个例子,串口数据寄存器 USART_DR 是 8 位有效的,你用 32 位宽度去搬运,一次就会搬进 4 个字节,缓冲区瞬间错乱。ADC 如果是 12 位分辨率,对齐方式选择右对齐后,数据本质上是 16 位半字,那就用半字宽度。很多奇怪的数据“中间多了一堆零”,就是宽度没对齐造成的。
最后是循环模式,这个好理解,但很多人不敢用它。所谓循环模式(Circular Mode),就是 DMA 搬完一整轮缓冲区长度的数据后,自动把地址指针重置回头继续搬。串口不定长接收、ADC 连续采样、定时器触发采样这类场景几乎都必须开循环模式。它最大的好处是你不用反复重新配置 DMA,数据会一直在缓冲区里滚动更新。代价是你要自己维护“读指针”,否则可能读到正在被覆盖的旧数据。
2.2 通道、请求和仲裁:别忽略的隐藏坑
STM32 的 DMA 控制器有好几个通道,每个通道可以服务多个外设请求,但同一时间只能选一个。关键是,每个外设的 DMA 请求是固定映射到某些通道的,比如串口1的发送可能是通道4,串口1的接收可能是通道5。你用 CubeMX 点选外设后,它会自动分配通道,这没问题。
真正容易踩坑的是你手动改代码时,想当然地把 DMA 通道随便换了一个。结果传输完全不动,查半天发现外设的请求信号根本没接到你选的这个通道上。所以我的建议是:能用 CubeMX 或 CubeIDE 生成初始化就用工具生成,不要手动去拼 DMA_MAP 表,除非你把参考手册里那几张 DMA 请求映射表背下来了。
仲裁则是另一个隐蔽问题。多个 DMA 通道同时请求总线时,硬件会根据通道优先级和 FIFO 配置来决定谁先走。如果你把两个高吞吐外设放在同一个 DMA 控制器且都设为最高优先级,低优先级通道可能被饿死,表现就是数据传输断断续续。实际调试中,我一般把实时性要求最高的外设设为最高优先级,其他挪到中低优先级,基本能避免总线抢占导致的偶发丢数据。
3. 从零配置一个串口接收 DMA 的完整实操
接下来我以最常用的场景为例:STM32 串口通过 DMA 配合“空闲中断”实现不定长数据接收。这个方案在物联网模组通信、RS485 总线解析、蓝牙透传里到处都是,几乎是嵌入式串口开发的标准答案。我按实际工程的步骤来写,从初始化到代码实现,每个环节都给到位。
3.1 硬件准备与 CubeMX 初始化
硬件方面,任意一款带 DMA 的 STM32 都可以,我这里用 F1 系列举例,但逻辑同样适用于 L4、F4、H7。准备工作很简单:一块开发板、一个 USB 转串口工具,以及一个愿意陪你在波特率上折腾的串口助手。
打开 CubeMX,先配置串口。波特率根据你的模块需求来,比如 115200,数据位 8,无校验,停止位 1。然后在 DMA Settings 里添加两个 DMA 请求:一个是 USART_RX,方向是 Peripheral To Memory,模式选择 Circular;另一个是 USART_TX,方向是 Memory To Peripheral,模式选择 Normal。注意 RX 用循环模式,TX 用正常模式。原因后面说。
接着在 NVIC Settings 里打开串口全局中断,因为我们需要用到串口空闲中断,它属于串口中断的一部分。DMA 的中断要不要开?如果只做接收,可以不开 DMA 通道中断,而是用“串口空闲中断”来判断一帧数据结束,这样代码更简洁,也避免 DMA 每满一缓冲长度就中断一次。如果你要做的恰好是“固定长度接收”,那可以把 DMA 传输完成中断打开,两者思路不同,不要混。
生成代码后,重点检查MX_DMA_Init()是否被调用,并且它在串口初始化之前。这个顺序非常关键,因为串口初始化时会尝试关联 DMA 请求,如果你把 DMA 初始化放在后面,那个请求关联就会失败,运行时会直接卡住。
3.2 代码层面的启动与连接
HAL 库的 DMA 接收启动函数是HAL_UART_Receive_DMA(&huart1, rx_buffer, BUFFER_SIZE)。这个函数的意思是:让 DMA 把串口收到的数据源源不断搬运到rx_buffer里,一共搬BUFFER_SIZE个字节后,在循环模式下会自动从头开始。
但你光调用这个函数还不够。一个常见问题:程序跑起来后,第一次数据能收到,第二次再调用同一个函数却失效。原因是 HAL 库内部状态机还停留在HAL_UART_STATE_BUSY_RX。解决办法是在每次调用前先HAL_UART_AbortReceive(&huart1),或者用__HAL_UART_CLEAR_FLAG(&huart1, UART_FLAG_IDLE);清标志,再重新开启。我调试时更喜欢封装一个函数,把“停止上一次接收、清空闲标志、重新开启 DMA 接收”三步打包在一起,逻辑清晰,后面不会改乱。
串口发送 DMA 的启动则是HAL_UART_Transmit_DMA(&huart1, tx_buffer, len)。这里有个细节:如果是频率比较高的发送,要确保前一帧数据已经发完,否则 DMA 会直接覆盖发送缓冲区。实用做法是用HAL_UART_GetState()判断是否忙,或者用发送完成回调HAL_UART_TxCpltCallback维护一个“允许发送”标志位。
3.3 中断、空闲中断与不定长接收实战
接下来是重头戏:不定长接收。在很多协议里,你不知道对方一帧数据到底有多长,直到串口线上安静下来。此时串口的“空闲中断”就派上用场了:接收线在一个字节时间内没有新数据进来,硬件会置 IDLE 标志位,触发串口中断。
先放一段我常用的中断回调处理逻辑,在HAL_UART_IRQHandler里,HAL 库其实已经帮我们过滤了事件,但空闲中断这块需要你自己处理。标准套路是写一个UART_IdleCallback,然后在HAL_UART_IRQHandler(&huart1)执行完后,单独判断__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)。
void UART_IdleCallback(UART_HandleTypeDef *huart) { if (huart->Instance != USART1) return; if (__HAL_UART_GET_FLAG(huart, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart); // 当前 DMA 已经搬运了多少字节 uint16_t rx_len = BUFFER_SIZE - __HAL_DMA_GET_COUNTER(huart->hdmarx); if (rx_len > 0) { process_rx_frame(rx_buffer, rx_len); // 重新开启下一轮 DMA 接收 HAL_UART_DMAStop(huart); HAL_UART_Receive_DMA(huart, rx_buffer, BUFFER_SIZE); } } }这段代码的核心是利用__HAL_DMA_GET_COUNTER读取 DMA 剩余未搬运次数,然后拿缓冲区总长度减去剩余值,得到这一轮实际接收了多少字节。这个计数器是 DMA 编程时非常常用的“水位尺”,我几乎所有 DMA 接收解析都要靠它来算长度。
不要忘记在主循环的中断处理里调用这个函数:
void USART1_IRQHandler(void) { HAL_UART_IRQHandler(&huart1); UART_IdleCallback(&huart1); }到这里,不定长接收就基本成型了。你在process_rx_frame里做帧解析、协议处理、应答发送,剩下的数据搬运全交给 DMA。我自己实测过,115200 波特率下,一帧 200 字节的数据,CPU 解析耗时只有几十微秒级别,和以前逐字节处理完全两个体验。
4. 常见问题与排查技巧实录
这部分是我最想写的。DMA 的报错往往不是“立即崩溃”,而是“偶尔丢数据”、“跑一段时间才出问题”、“第一次正常之后再也不动”。下面这些坑,全部是我自己在项目里踩过、用示波器和调试器一点点查出来的。
4.1 第一个坑:缓冲区明明改了数据,却没收到
先检查最简单的:DMA 到底有没有配到正确的通道上?USART1_RX 在 F1 上通常对应 DMA1 通道5,但不同系列可能不同。你可以在调试器里看huart1.hdmarx->Instance的值,直接跟参考手册的映射表对一下。
然后是 DMA 的启动次数问题。如果你只在初始化时调用了一次HAL_UART_Receive_DMA,第一次接收可能正常,第二次 DMA 计数器已经滚到零了,硬件停止工作。循环模式也有这种表现,因为你可能在某个地方手动调用了HAL_UART_DMAStop,导致循环模式被关掉了。排查办法是在空闲中断回调里重新调用启动函数,并且确认“停止-启动”的顺序没有把 DMA 配置清掉。
另外,别忘了检查串口的 DMA 接收请求是否被使能。有时候你用 CubeMX 重新生成代码,会不小心把串口里的USART_CR3_DMAR位置清掉。没有这个位,DMA 配置得再对也不会启动。
4.2 第二个坑:DMA 和 Cache 一致性问题
F4 系列还好,但如果你用带 Cache 的 H7 系列,DMA 和 CPU 之间有一个很经典的“看不到新数据”的问题。原因是 DMA 直接把数据写进内存,但 CPU 读的时候可能命中了 Cache 里老的缓存行,导致你看到的数据还是旧的。
处理方法有几个。最简单粗暴的是在 CPU 读取 DMA 缓冲区前,先执行SCB_InvalidateDCache_by_Addr使该区域 Cache 失效,强制从内存重新加载。在 DMA 写数据到内存的场合,你需要的是“使 Cache 失效(Invalidate)”,注意不是“Clean”也不是“Clean and Invalidate”。
如果你嫌每次手动操作麻烦,可以在配置 MPU 的时候把 DMA 缓冲区所在的内存区域设置为“非 Cacheable”或“Write-Through”。这样 CPU 读数据不走 Cache,虽然读取速度略有下降,但对 DMA 缓冲区这种频繁被外设改写的区域,安全性远大于那一点点性能损耗。
4.3 第三个坑:外设优先级和半字/字节对齐
有次做 AD7606 数据采集,发现 DMA 搬运的数据偶发错位,现象是通道值串了。查了半天,问题出在外设数据宽度上:外部 ADC 是 16 位并行输出,我用 DMA 的字节模式去读,结果一次只读到一个字节,另一次读低字节还是高字节完全随机。改成半字模式后问题立刻消失。
还有一个很隐蔽的问题:缓冲区地址没有对齐。STM32 DMA 在 32 位宽度传输时,如果源和目的地址没有按 4 字节对齐,在一些系列上会出现“未对齐访问”的错误,直接触发 HardFault。哪怕没有 HardFault,性能也会打折。所以我给 DMA 的缓冲区定义时,都习惯加__ALIGN_END(4)或者使用内存对齐编译器指令,明确把数组对齐到 4 字节边界。
4.4 四种最快定位 DMA 问题的方法
方法一是看 DMA 计数器的值。进调试器,挂起程序,看hdma->Instance->NDTR还有多少值。如果它一直不变化,要么 DMA 没启动,要么请求信号没来;如果它变化了但和预期速率不符,说明触发源或者时钟配置有异常。这个方法可以帮你区分“DMA 没搬”和“搬的方向不对”。
方法二是看外设标志位。比如串口的 RXNE 标志在 DMA 接收模式下,正常情况下会由硬件自动清掉。如果你看到 RXNE 一直为 1,说明 DMA 请求没有接管外设数据,多半是通道映射错了或者 DMA 没开启。
方法三是用一个 GPIO 翻转做时间观测。设置一个测试引脚,在 DMA 中断服务函数里翻转电平,然后用示波器看中断频率。如果频率跟你的数据速率完全对不上,说明中断配置或标志位清除有问题。这个方法很土,但特别有效。
方法四是检查时钟树。DMA 总线时钟不使能这个低级错误,在 F1 上表现得很迷惑:寄存器读写正常,但 DMA 就是不传输。原因是 DMA 的时钟树里 APB1 或 AHB1 对应的时钟门没打开。CubeMX 一般会自动处理,但如果你手动精简代码,很容易漏掉__HAL_RCC_DMA1_CLK_ENABLE()。
5. 从串口到 ADC 和 SPI:DMA 的常见扩展场景
很多人学会串口 DMA 之后,就把 DMA 局限在串口上了。实际上 DMA 最该用的地方,是那些高频、大批量、模式固定的数据搬运。下面这几个扩展用法,我建议你只要有对应外设就优先考虑。先说 ADC 连续采样。ADC 每次采样结束都产生一个数据,开启 DMA 循环模式后,采样结果直接写入内存数组,CPU 可以攒够一批再处理,工作效率翻倍。这里要注意的是 ADC 校准、采样时间和触发源的配合,但 DMA 部分基本套路和串口一样:方向是外设到内存、宽度半字、循环模式。
SPI 场景就比较多元了。刷屏的时候,SPI 发送数据量大,DMA 发送可以省去 CPU 逐像素搬运;从 SPI Flash 读取数据时,DMA 接收能让 CPU 同时处理别的任务。SPI 的 DMA 配置难度主要在于片选信号的控制——DMA 不会帮你拉片选,所以你要在 DMA 传输完成中断里把片选拉高。我见过很多人 SPI DMA 数据总是多一字节或少一字节,最后发现都是片选时机不对。
定时器也能干这事。比如定时器触发 PWM 输出,或者做电机编码器计数,DMA 用于“定时器触发 ADC 采样”这种联动场景很有优势。我之前做三相电流采样,就是用定时器更新事件触发 ADC 启动,ADC 用 DMA 循环采样,完全不用 CPU 干预,采样窗口非常均匀。用 DMA 还能避免因为中断延迟导致的采样抖动,这个在电机控制里非常重要。
如果你的需求是内存到内存,那就配置方向为 Memory To Memory。这个模式主要用于协议转换、数据重组。比如从网络缓冲里拆分出一段数据到另一块内存,或者把字节序调整一下。内存到内存模式下没有外设请求线,DMA 一旦使能就会立刻开始搬,所以一定要把源、目的地址都设置好再启动。
6. 最后再分享一个我常用的 DMA 调试小技巧
写代码写了这么多年,遇到 DMA 问题我很少去反复改代码碰运气。我的习惯是先在初始化之后、启动传输之前,用调试器查看 DMA 控制寄存器的值,确认CR里EN位为 0,然后单步执行启动函数,再立即查看NDTR是否等于缓冲区长度、CMAR是否指向目标地址、CPAR是否指向外设数据寄存器。三步一查,八成问题都能暴露。
如果你要反复排查“偶发丢帧”,建议在空闲中断里记录一帧数据的长度和校验结果,放在一个环形日志结构里,出错时统一打印出来。不要每次都打断点,因为 DMA 是异步的,打乱执行时序后问题可能根本不出现。
根据我个人的实操体会,DMA 这功能学会并不难,难的是建立起一种“硬件搬运工”的思维方式。一旦你把“让 CPU 少干重复活”变成默认设计习惯,再去写多外设并发、高吞吐的嵌入式应用,心里会踏实很多。希望这篇文章能帮你把那些曾经玄学一样的 DMA 现象变成清清楚楚的原理和步骤,以后不管换什么型号的 STM32,都能快速定位、快速搞定。