尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

STM32CubeMX串口DMA收发配置指南:原理、代码与避坑

发布时间:2026/9/17 3:52:17

资讯中心
01
ARTICLE

STM32CubeMX串口DMA收发配置指南:原理、代码与避坑

STM32CubeMX串口DMA收发配置指南:原理、代码与避坑
做嵌入式这几年串口一直是个绕不开的活儿。尤其现在很多模块都靠串口交互——蓝牙、GPS、4G模组、外挂MCU数据一会儿来一包中断开多了主循环就被打断得七零八落。后来我把串口收发逐步切换到DMA配合STM32CubeMX做配置代码量少了一大截CPU占用也明显降下来这个操作基本成了我做项目时的老套路。这篇文章以STM32CubeMX为主线把DMA的实际配置拆开讲清楚再重点演示串口怎么结合DMA收发数据。适合刚接触CubeMX的初学者也适合已经在用HAL库、但被“串口DMA发送不连续”“接收不到完整一包数据”这类问题困扰的开发朋友。看完之后你可以照着配一遍然后把DMA的思路平移到ADC采集、SPI通信上一通百通。1. DMA到底帮你省了什么1.1 没有DMA的时候CPU在干什么先回忆一下最基础的串口收发串口每收到一个字节硬件就会触发一次中断CPU停下当前的事跳进中断服务函数从数据寄存器里把字节读走存到数组里再退出中断。发送也是一样每发一个字节要么轮询等待发送完成标志要么开发送中断CPU一点一点往外送。这套做法在数据量小的时候问题不大一旦波特率拉高或者数据包变密代价就非常明显。我用一个具体数字说话假设波特率115200一帧串口数据一般是1位起始位 8位数据 1位停止位也就是10个bit那么传一个字节大约需要1 / 115200 × 10 ≈ 86.8 微秒也就是说大约每87微秒就要进一次接收中断。一次中断从压栈、读寄存器、存数组到出栈怎么也得几十个周期看起来好像不多但如果把波特率换成921600甚至2M这个时间直接缩到11微秒、5微秒CPU几乎就是在“不停进中断”和“刚出中断又进中断”之间反复横跳。主循环里的业务逻辑被严重挤压按键响应变慢、显示刷新变卡都是这么来的。1.2 DMA就是那个专职搬运工DMA的全称是Direct Memory Access直接存储器访问。它的核心能力就是在不占用CPU的情况下把数据从外设寄存器搬到内存或者从内存搬到外设寄存器。搬运多少、从哪搬、搬到哪全部由DMA控制器自己完成搬完了给你一个完成中断CPU去处理结果就行。这里有个很形象的类比CPU是老板中断收发方式等于老板每次都亲自去仓库取一箱货再搬回来放下数据多了老板体力耗尽啥也干不了。DMA相当于雇了一个专职搬运工老板只需要告诉他“从3号库取256箱放到A区搬完喊我一声”然后就可以去处理别的正事。搬运工干活期间老板完全不操心。所以串口结合DMA的本质就是把“逐字节中断搬运”改成“一整批数据由DMA连续搬运”把CPU从琐碎的搬运中彻底解放出来。这也是为什么做项目一旦涉及大数据量收发DMA几乎是必选项。2. CubeMX里的DMA配置每一项都要看懂2.1 添加DMA请求串口发送和接收要分开用STM32CubeMX配置的时候先正常配置好串口比如USART1选好异步模式、波特率115200、8位数据位、无校验、1位停止位。然后点开DMA Settings标签页点击Add会发现里面列了USART1_RX和USART1_TX两个请求分别对应接收方向和发送方向。这里建议两个都加上不要只加接收或者只加发送。有些人觉得“我平时不太用发送DMA发数据直接HAL_UART_Transmit阻塞发送就行”这样当然也能跑但既然CPU都被占用过一次了发送方向顺手也交给DMA会更统一后面扩展协议交互也方便。添加之后可以看到每个DMA请求下面有几项我不建议直接保持默认每一行都值得理解清楚因为它们直接影响行为。配置项含义串口场景建议Request触发DMA工作的外设请求源保持USART1_RX / USART1_TXDirection数据传输方向接收是Peripheral To Memory发送是Memory To PeripheralModeNormal循环模式或者Circular循环模式收发完整数据包建议先用Normal需要环形缓存再考虑CircularPriorityDMA通道优先级串口高速收发建议High或VeryHighData Width数据宽度串口按字节收发选择Byte即可Memory决定内存地址是否自增串口收发场景保持默认Increment即可Peripheral外设地址是否自增串口外设地址是固定的保持默认Fixed即可2.2 Normal和Circular到底选哪个这是串口DMA配置里最让人纠结的一项。先明确两种模式的差别Normal模式DMA搬运完设定的次数比如收到256字节之后自动停止通道变为禁用状态。想再次接收必须重新调用启动函数。Circular模式DMA搬完设定的次数之后自动把内存地址绕回起点继续搬运一直循环下去直到你手动停止。串口接收到底选哪个我的建议是如果你用的是“DMA 空闲中断”这套方案也就是我后面第四章详细讲的方案接收方向用Normal模式就够了。每次收到一帧数据空闲中断触发你在回调里处理数据然后重新调用一次接收函数DMA再次启动逻辑非常清晰。Circular模式适合另一种场景你不想频繁重新启动DMA让数据一直在缓冲区里循环写入由程序随时判断哪些区域是新数据。这需要配合缓冲区读位置、写位置的管理复杂度会高一些好处是不怕数据帧长度超过单次配置大小。我个人建议新手先把Normal 空闲中断跑通再考虑Circular。2.3 DMA Continuous Requests这个选项是什么有些版本的CubeMX在串口DMA配置里会出现一个“DMA Continuous Requests”复选框。它控制的是外设是否持续发出DMA请求。举个例子如果这个选项关闭只有在串口接收缓冲区有数据、或者发送数据寄存器为空时才产生一次请求如果打开外设会持续请求DMA工作一般配合定时器触发DMA或者某种“始终等待搬运”的场景。串口普通收发场景Continuous Requests通常不是强制要求保持默认状态即可。如果发现数据搬运老是停在一个位置不动可以回来看一眼这个选项是不是被某些默认配置影响了。真正经常用到它的是定时器触发DMA、ADC连续采样这类场景串口用得少。3. 串口DMA发送从能发到发得聪明3.1 最简单的DMA发送用CubeMX生成工程之后发送方向已经自动注册了DMA通道。HAL库提供了一条现成的发送函数HAL_UART_Transmit_DMA(huart1, tx_buf, len);参数很简单串口句柄、要发送的数据地址、发送长度。调用这个函数后函数会立刻返回数据由DMA在后台搬运CPU不等待。注意这里不是阻塞发送你不能再像HAL_UART_Transmit那样发出去了就在原地等。可以在发送完成之后去查询串口句柄的发送状态或者注册发送完成回调。HAL库里发送完成的回调函数是void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { // 这一帧数据已经发完了在这里置标志 uart_dma_tx_done 1; } }这里有个细节回调名字是TxCplt不是TxComplete手写代码的时候容易写错编译也不报错但永远不会被调用这个坑我踩过。3.2 发送不连续、只能发一次问题出在哪很多人在串口DMA发送上遇到的最典型问题是第一次调用HAL_UART_Transmit_DMA能发出去第二次调用就“卡住”了数据发不出去。原因是HAL库对外设状态的管理逻辑很直接上一个DMA发送还没完成时串口句柄状态不是READY再次调用Transmit_DMA函数直接就返回HAL_BUSY根本不会排队帮你发下一帧。明白这个机制就好办了。最简单的方案是发送前检查状态等待上一帧发送完成while (huart1.gState ! HAL_UART_STATE_READY) { // 等待当前发送结束 } HAL_UART_Transmit_DMA(huart1, tx_buf, len);或者用发送完成回调里的标志位来判断void uart_dma_send_buf(uint8_t *buf, uint16_t len) { while (uart_dma_tx_done 0) { // 等待上一帧发送完成 } uart_dma_tx_done 0; HAL_UART_Transmit_DMA(huart1, buf, len); }这两种方式都行。第一种更直接第二种方便你在同一套代码里管理多个串口。我再强调一点等待是必要的不要想着“我就快速连续调用两次试试”DMA不是排队系统它默认就是一次干一件事。还有一个隐藏很深的问题DMA发送是异步的HAL_UART_Transmit_DMA返回时数据可能还在内存里没被读走。你如果在函数里传了一个局部数组函数一退出栈空间被回收DMA再去读这部分内存数据已经变成别的乱七八糟的内容了。所以发送缓冲区必须是全局变量、静态变量或者至少保证在发送完成之前一直有效。3.3 日志打印和协议帧发送的小心得我把DMA发送用在实际项目里之后最大的感受就是日志打印变得特别“顺滑”。之前用阻塞发送打印一行日志主循环卡顿明显尤其波特率不高的时候特别难受。改成DMA之后printf只管往某个全局缓冲区里格式化格式化完了调一次uart_dma_send_buf然后主循环该干嘛干嘛打印本身完全不阻塞。不过要注意DMA发送太快也会有麻烦。比如大量日志在短时间内连续产生DMA还没来得及发完上层又要发送新的一批就会触发等待逻辑。这种情况我会做一个发送队列把待发送的日志先缓存起来等DMA完成回调后再取下一批效果非常稳定。4. 串口DMA接收配合空闲中断才是最舒服的姿态4.1 定长接收的局限串口接收用DMA最容易踩的坑是只调用了HAL_UART_Receive_DMA然后等着接收完成回调。但这个函数是“定长接收”——它默认必须要接收满你设置的长度才会触发回调。比如你设置接收256字节结果设备实际只发来一帧20字节的数据那这个回调永远不触发数据就一直躺在缓冲区里你根本不知道它已经来了。这在很多实际业务里是不可接受的因为串口数据往往都是不定长的。那怎么办标准答案就是配合空闲中断Idle Interrupt。空闲中断不是DMA的东西它是串口外设自带的功能当接收线上出现一个字节周期更准确说是帧周期的“静默”也就是没有新数据再过来时硬件就认为一帧数据结束了触发一个空闲中断。这样配合DMA就完美实现了“DMA负责把数据搬到内存空闲中断负责告诉CPU这一帧结束快来处理”。4.2 CubeMX里面怎么开启空闲中断配置上其实很简单不用单独找什么“使能空闲中断”的复选框。你只要在CubeMX里确保串口的NVIC中断被勾上也就是开启了USART1 global interrupt然后调用HAL库的HAL_UARTEx_ReceiveToIdle_DMA这个函数库内部就会自动把空闲中断和DMA收尾逻辑串起来。有些网上老教程会教你自己去中断服务函数里读SR寄存器、判断IDLE标志然后手动处理。HAL库已经把这件事封装好了建议直接用HAL_UARTEx_ReceiveToIdle_DMA省心很多。4.3 完整代码DMA 空闲中断接收不定长数据CubeMX生成工程后主循环初始化之前启动一次接收#define RX_DMA_BUF_SIZE 256 static uint8_t rx_dma_buf[RX_DMA_BUF_SIZE]; int main(void) { // CubeMX生成的初始化代码... HAL_UARTEx_ReceiveToIdle_DMA(huart1, rx_dma_buf, RX_DMA_BUF_SIZE); while (1) { // 主循环处理业务... } }然后实现接收事件回调void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART1) { // Size就是本次实际接收到的字节数 process_frame(rx_dma_buf, Size); // 重新开启下一轮接收 HAL_UARTEx_ReceiveToIdle_DMA(huart1, rx_dma_buf, RX_DMA_BUF_SIZE); } }这个回调的触发时机有两种一是空闲中断触发表示一帧数据接收完毕此时Size是已接收的字节数二是接收缓冲区满达到了你设置的256字节此时也会触发回调Size等于缓冲区大小。所以在回调里拿到Size之后统一按照“收到了一帧数据”来处理是没问题的两种情况都能覆盖。有一点必须注意回调执行完之后DMA通道其实已经停止了所以你必须在回调里重新调用一次HAL_UARTEx_ReceiveToIdle_DMA否则下一帧数据永远不会被接收。这一点和发送那边等待发送完成是一个道理都是HAL库的“非排队”机制。4.4 缓冲区满和数据处理时长的问题上面的方案在大多数场景下很好用但有个边界情况要处理如果对方连续发送的数据正好超过256字节或者一帧数据刚好填满了缓冲区那么“空闲中断”可能不会按你预想的时机触发回调里拿到的Size可能等于256但数据其实还在继续来。你必须在业务层面定义好最大帧长或者把缓冲区设置得比实际最大数据帧大一些留出余量。另外回调是在中断上下文里执行的不能在里面做耗时操作。数据拷贝、解析、响应发送都应该通过标志位或者消息队列丢给主循环去处理。很多人在回调里直接跑协议解析导致中断执行时间过长反而干扰了DMA和串口后续接收这个习惯要改。4.5 想要更稳可以上双缓冲如果你对实时性要求高比如一边要持续接收高速数据一边又要花时间处理上一帧数据那么单缓冲区就有个明显问题你在处理缓冲区数据的时候DMA已经重新启动并往同一个缓冲区里写新数据了新旧数据就混了。解决办法是双缓冲也叫乒乓缓冲。定义两个缓冲区一个给DMA写入一个给业务层处理处理完之后交换角色。参考结构#define RX_DMA_BUF_SIZE 256 static uint8_t rx_dma_buf[2][RX_DMA_BUF_SIZE]; static uint8_t rx_dma_active 0; static uint8_t rx_dma_processing 1; void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART1) { // 当前DMA写完的是rx_dma_active这个缓冲区 // 让业务层去处理它同时立刻把DMA切到另一个缓冲区 uint8_t *completed rx_dma_buf[rx_dma_active]; rx_dma_active ^ 1; HAL_UARTEx_ReceiveToIdle_DMA(huart1, rx_dma_buf[rx_dma_active], RX_DMA_BUF_SIZE); process_buffer(completed, Size); } }注意回调里先切换缓冲区再启动DMA然后才去处理数据这样DMA不会等你处理完而是立刻就有新缓冲区可用。双缓冲其实并不复杂核心就一句话让DMA永远有一个“干净”的缓冲区可以写入。5. 常见问题排查与避坑速查5.1 串口DMA接收不到数据这是最常遇到的问题。配置看起来全对但串口就是收不到东西或者收了一次就再也不动了。按我自己踩过的坑排查顺序大概是这样检查串口全局中断有没有开启。DMA 空闲中断方案依赖串口中断去触发回调如果NVIC里没勾选USART1 global interrupt接收永远不会有反应。检查有没有在初始化之后调用启动函数。生成代码只是配置了DMA真正让DMA跑起来必须调用HAL_UARTEx_ReceiveToIdle_DMA。检查上一次接收完成后回调里有没有重新启动接收。漏掉这一句第一帧数据能收到后续全部石沉大海。检查DMA通道优先级是不是太低。极端情况下如果系统里同时跑着多个DMA任务串口DMA优先级被其他高优先级任务长期抢占也可能表现成“偶尔收到、经常丢失”。5.2 串口DMA发送不完整或者只能发一次前面已经详细说过核心原因是HAL库的busy机制。排查时重点看这几个点发送前是否检查了huart-gState或者用标志位保证上一帧发送完成。发送缓冲区是否是局部变量。如果用了局部数组发给DMA数据在函数返回后可能已经被覆盖表现就是发送内容乱码或发一半就断。发送完成回调是否写错函数名。HAL库相关回调不止一个不要写成HAL_UART_ErrorCallback或者自己发明的名字函数名不匹配时编译器不会报警但回调永远不执行。5.3 接收数据错位、偶发丢字节大多是两个原因。一是缓冲区定义没有对齐某些STM32的DMA对内存地址有对齐要求建议给缓冲区加上对齐属性CubeMX生成的代码里可以看到类似__ALIGN_BEGIN的宏自己定义缓冲区时也建议这样写__ALIGN_BEGIN static uint8_t rx_dma_buf[RX_DMA_BUF_SIZE] __ALIGN_END;还有一个原因是处理速度跟不上。回调里做了解析、拷贝等耗时操作导致DMA重新启动晚了接口数据已经溢出。这个只能靠优化回调逻辑、缩短中断处理时间或者使用双缓冲方案解决。5.4 一个经验速查表现象可能原因排查/解决方向DMA收到第一帧后不再接收回调里没有重新启动接收检查回调末尾的ReceiveToIdle调用一次都收不到串口全局中断没开CubeMX里检查NVIC配置发送一次后卡死HAL库busy状态未解除发送前查询gState或使用发送完成标志发送内容乱码缓冲区被提前覆盖改用全局/静态缓冲区保证生命周期偶发丢字节数据宽度配置错误或内存对齐问题Data Width选Byte缓冲区加对齐处理不过来导致溢出回调执行时间太长回调里只置标志业务扔到主循环5.5 和串口烧写失败偶尔撞车的坑还有一个和CubeMX工程关系不大的问题但我在调试时经常遇到很多人改完工程准备下载发现“串口烧写失败”“连接不上”。如果用的是ST-LINK下载这通常和串口DMA无关更多是接线、驱动、BOOT引脚状态的问题。如果是串口ISP下载要专门确认USB转串口芯片驱动是否正常比如常见的CH340驱动以及下载软件的波特率选择是否合适。这个属于工程环境层面的坑别和DMA配置纠缠在一起分开排查会快很多。6. 一点个人经验我在实际项目里把DMA配置用到顺手之后最大的体会是DMA这东西不是“外设的一个高级选项”而是嵌入式系统设计的思维方式。学会了串口DMA这套配置和回调逻辑后面再看ADC多通道采样、SPI收发、PWMDMA驱动灯带会发现它们是完全一样套路——CubeMX里加一个DMA请求代码里启动一次传输回调整理结果。底层机制一点没变。还有个小技巧值得分享调试串口DMA的时候不要直接拿外设联调先做自发自收回环测试。把发送脚和接收脚用一根杜邦线连起来代码里调用一次DMA发送同时用DMA接收看收到的数据和自己发的是否一致。链路一旦通了再对接外部模块能省下大量排查时间。这套方法我用了很多年从来没让我失望过。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。