STM32 DMA从原理到实战:配置、中断、Cache一致性与调试技巧
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 到底帮你干了啥DMADirect 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 和 SPIDMA 的常见扩展场景很多人学会串口 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都能快速定位、快速搞定。