简介STM32串口通信DMA例程是一份面向嵌入式开发者的完整工程示例聚焦利用DMA与空闲中断实现高效串口收发解决大数据量传输时CPU负担过重的问题。压缩包共793个文件约35.8MB以C源码、H头文件、汇编启动文件及链接配置脚本为主同时包含编译生成的库文件、调试信息以及CubeMX的IOC配置文件便于直接编译或二次修改。例程基于NUCLEO-F401RE开发板覆盖USART参数配置、DMA通道映射、空闲中断处理及中断服务函数编写等关键环节并展示发送与接收两条DMA链路的构建方法。目前已有1063人学习下载适合正在学习STM32串口通信、想要掌握DMA与空闲中断用法的初中级开发者。通过该例程可快速理解DMA传输的触发流程与中断协作机制并在实际项目中复用这套低CPU占用率的串口通信框架。 串口DMA这套东西玩STM32的人早晚都要碰。不管是调试传感器、对接ESP8266模块还是做上位机通信只要涉及数据收发DMA就能帮你把CPU从繁重的搬运工作中解放出来。我自己刚接触时也一头雾水翻了不少手册、踩了不少坑才理顺所以整理了这篇完整的串口DMA例程讲解从原理到实操、从发送到接收不定长数据一次说透。1. 串口DMA到底解决了什么问题1.1 从一次“卡死”的经历说起有次调试一块驱动的板子串口每毫秒要上报几十字节数据我用的是阻塞式发送——就是调HAL_UART_Transmit把HAL_MAX_DELAY当成超时时间。结果主循环里稍微加个复杂点的逻辑整个系统就明显卡顿更夸张的是一旦串口对端设备没接或者接线不良发送函数会一直卡在等待发送完成的死循环里压根出不来。这就是串口通信不带DMA、纯靠CPU干预的典型症状。发送时要CPU逐字节往外搬接收时来一个字节进一次中断数据量稍大、频率稍高CPU的大部分时间就耗在中断进出和内存拷贝上了。换用DMA之后外设和内存之间的数据搬运完全由DMA控制器完成CPU只需要在接收完成或发送完毕时得到一次通知就行一进一出节省的开销非常可观。1.2 DMA传送的基本原理DMA全称Direct Memory Access直译是直接存储器访问。可以把它想象成一条专门运数据的传送带你把“从哪里搬、搬到哪去、搬多少件”这三个参数告诉它它自己就一趟一趟地搬搬完喊你一声“完事”。CPU在这期间该干嘛干嘛不需要盯着每一个字节倒来倒去。放到串口DMA场景里核心配置就两件事发送DMA把内存中某段缓冲区里的数据由DMA自动搬运到串口的数据寄存器USART_DR每搬完一个字节硬件自动触发一次发送直到整个缓冲区的数据全部发完。接收DMA串口每收到一个字节硬件自动把数据从USART_DR搬到内存缓冲区接收过程完全不占用CPU处理。实际工程中接收端通常会叠加“串口空闲中断”用来判断一帧数据什么时候结束这能解决DMA接收不定长数据的问题后面详细讲。2. 工程搭建与初始化配置2.1 CubeMX里的关键设置我习惯用STM32CubeMX生成初始化代码再用HAL库做应用层省事且不容易漏配置。新建工程后在Connectivity里打开UART把Mode设为Asynchronous异步模式然后在DMA Settings标签页分别添加两个DMA请求——一个对应USARTx_TX一个对应USARTx_RX。DMA通道配置要重点看几项Mode选择Normal还是Circular。发送一般用Normal发完就停接收建议用Circular循环模式因为串口数据随时可能来循环模式扛满整个缓冲区配合空闲中断才能实现不定长接收。Data Width数据宽度发送和接收都设为Byte因为串口寄存器本身是8位宽设成Word反而出问题。Memory Increment内存地址自增必须开启否则数据不断覆盖同一个地址前面收到的全被冲掉。优先级如果多个外设共用同一个DMA控制器串口接收可以给一个较高的优先级避免高负载下丢数据。接着把串口的全局中断USARTx global interrupt打开。很多人以为用DMA接收就不需要串口中断了这是误解——空闲中断检测、错误中断处理都依赖串口全局中断。2.2 缓冲区和超时参数怎么选缓冲区大小没有标准答案取决于你的单帧数据量和业务场景。比如对接GPS模块一条NMEA句子最长也就一百字节左右那你接收缓冲区128字节就够了如果做串口转WiFi或透传数据流比较长建议直接用512或1024字节的环形缓冲思路。这里有一个容易踩的坑接收缓冲区定义成局部数组。DMA是硬件后台搬运数据局部数组在函数退出后就无效了正常情况下DMA会写入一段已经被回收的内存轻则数据错乱重则直接HardFault。接收缓冲区必须定义为全局数组或者静态数组生命周期足够长这条要牢记。超时参数方面如果用的是HAL_UART_Receive_DMA这类基础接收接口它本身不处理超时想判断一帧数据是否收完要么用超时计数器要么用空闲中断。后者更高效、更通用我不建议在主循环里不停查询接收计数来判断超时忙等太浪费CPU。3. 发送端的正确打开方式3.1 发送前要不要等上一轮完成这个问题的答案很明确要等而且要等得很小心。DMA发送是异步的你调用HAL_UART_Transmit_DMA之后函数立刻返回数据还在DMA搬运过程中。如果此时再次调用这个接口新的请求会覆盖之前的DMA配置导致上一条数据还没发完就被掐断甚至缓冲区内容错乱。那怎么判断上一轮发完了HAL库的句柄结构体里有个gState字段它表示UART的全局状态。当huart-gState ! HAL_UART_STATE_READY时说明发送正在进行此时不应该发起新的发送。靠谱的做法是在发送完成回调HAL_UART_TxCpltCallback里清零状态标志应用层根据这个标志决定能否发送新数据。另外HAL_UART_Transmit_DMA函数的超时参数在HAL库里其实没用到因为它本身是异步接口真正需要超时处理的是你的业务层逻辑。比如发送缓冲区还没就绪、设备还没上电不能让调用方无限等下去通常设一个用户态的等待超时时间超时直接返回失败方便上层决策。3.2 一段能用在项目里的发送封装我在项目里常用下面这种发送封装把“是否忙”“是否超时”都包进去调用方拿到的只有成功或失败#define UART1_SEND_TIMEOUT 100 // 单位ms static volatile uint8_t uart1_tx_busy 0; void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { uart1_tx_busy 0; // 发送完成释放忙标志 } } uint8_t UART1_SendDMA(uint8_t *data, uint16_t len) { uint32_t tick HAL_GetTick(); while (uart1_tx_busy) { if ((HAL_GetTick() - tick) UART1_SEND_TIMEOUT) { return 0; // 超时返回失败 } } uart1_tx_busy 1; if (HAL_UART_Transmit_DMA(huart1, data, len) ! HAL_OK) { uart1_tx_busy 0; return 0; } return 1; }使用这套封装要注意data缓冲区在DMA搬运完成之前也不能被改写所以应用层最好把要发送的数据放在静态或全局数组中或者在上层保证在收到TxCpltCallback之前不动这块缓冲区。如果频繁发送短数据可以直接把发送缓冲区定义成一个大数组按需填内容再调用简单可靠。4. 接收不定长数据空闲中断是关键4.1 为什么固定长度接收不好用很多人一开始用DMA接收就是简单调用HAL_UART_Receive_DMA设置一个固定接收长度比如一次收10个字节。麻烦的是串口数据是流式的你根本不知道对端什么时候发数据、发多少字节。如果对端只发了5个字节那你永远等不到接收完成回调因为目标长度是10如果对端发了20个字节那前10个字节到达时触发一次回调后10个字节又触发一次逻辑就乱了还得自己去拼接。用空闲中断就顺理成章了串口总线上一个字节发完后总线保持空闲电平超过一个字节的时间硬件就判定这次数据帧结束触发一次空闲中断。这时候去读DMA接收计数就能知道刚才这一帧到底收了多长完美解决不定长问题。4.2 空闲中断DMA的实现实现方式在HAL库里做了封装用HAL_UARTEx_ReceiveToIdle_DMA这个接口它内部把空闲中断和DMA接收联动起来了。基本流程启动一次接收数据不断通过DMA写入缓冲区一旦检测到总线空闲触发接收事件回调在回调里根据参数Size得到本次接收的数据长度然后立刻重新启动下一轮接收。#define RX_BUF_SIZE 256 uint8_t uart1_rx_buf[RX_BUF_SIZE]; volatile uint16_t uart1_rx_len 0; volatile uint8_t uart1_rx_complete 0; void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART1) { uart1_rx_len Size; uart1_rx_complete 1; // 重新启动接收 HAL_UARTEx_ReceiveToIdle_DMA(huart1, uart1_rx_buf, RX_BUF_SIZE); } }主循环或业务代码里一旦检测到uart1_rx_complete 1就取出uart1_rx_buf中的前uart1_rx_len字节进行处理处理完后清标志。使用这个接口有一点要注意不同版本的HAL库回调名字可能不同老版本用的是HAL_UART_RxCpltCallback配合手动处理空闲中断新版本才统一到RxEventCallback。用CubeMX生成代码时先看生成的HAL库版本再写回调别机械照搬。4.3 接收缓存溢出与优先级问题DMA接收有个显而易见的隐患如果对端疯狂发数据而你的业务处理来不及缓冲区会被写满之后的数据就会丢失。环形缓冲区模式虽然会循环覆盖旧数据但轮到你读的时候可能已经分不清哪些是新数据、哪些是旧数据。解决办法有两个思路一是加大缓冲区二是引入环形队列。我自己常用的是在RxEventCallback里先把DMA缓冲区的数据整体搬到另一个更大的应用缓冲区交给应用层排队处理DMA侧以最快的速度复位并投入下一轮接收降低溢出概率。如果你一次性收到的数据帧非常长比如几百字节建议把DMA接收缓冲区设成等于最大帧长同时用环形队列做二级缓存。另一个容易忽略的坑是中断优先级设置。串口空闲中断如果优先级太低在系统忙的时候可能被其他中断长时间打断导致空闲中断响应不及时下次DMA接收启动也会有延迟。一般建议把串口中断优先级设为比主循环任务高、但比实时性要求更高的定时器中断略低的中断级别具体要看你的系统里还有哪些中断源原则是接收相关的处理不能长时间被阻塞。5. 常见问题排查与避坑记录5.1 疑难杂症速查表现象常见原因排查方向DMA发送一次后后续再发送没反应没有等上一轮发送完成gState处于Busy查询gState等待TxCpltCallback后再发起新发送接收缓冲区数据全是0xFF或者乱码缓冲区被多次重复配置或配置了Word宽度核对Data Width是否ByteMemory Increment是否开启数据少收或多收总是拼不完整空闲中断回调设置错误或重新启动接收不及时检查使用的是RxEventCallback还是旧回调确认重新调用ReceiveToIdle的位置在中断回调里处理耗时业务导致丢帧空闲中断回调里面做了大量处理回调里只拷贝数据和重置接收业务处理放到主循环程序无法进入接收事件回调没有开启串口全局中断或DMA的NVIC未配置检查CubeMX中断配置确认USART、DMA中断都已Enable5.2 两个绕不开的工程问题环境搭建这块也容易卡壳。好多人用ST-Link调试时点击下载突然报error: no stm32 target found! if your product embeds debug authentication这行字看着唬人实际原因往往很简单目标板没供电、SWD线序不对、或者芯片被读保护了。排查顺序建议先确认板子电源和GND连通然后用万用表量SWDIO和SWCLK是否通到目标芯片的对应引脚最后再用ST-Link Utility或者CubeProgrammer连接一下看看芯片能不能识别、有没有检测到读保护。还有一类典型问题是Windows设备管理器里STM32 Virtual COM Port带黄色叹号。这通常是驱动没装好或者USB线只通电不通数据。换成自带USB转串口芯片的板子反而省心直接识别成CH340或CP210x如果用的是STM32自带的USB虚拟串口需要安装STM32CubeProgrammer的驱动包或者使用Zadig这类通用驱动工具更新驱动。串口调试时我的习惯是先用回环测试把TX和RX短接发一串已知数据看能不能原样收回来。回环通了说明DMA送、DMA收这条链路没问题回环不通再从时钟配置、引脚复用、DMA映射三个方向逐一排查。尤其引脚复用很多人配置了GPIO但忘了把GPIO的AF模式设成对应的复用功能导致数据根本出不来。折腾完串口DMA之后我最大的体会是与其在调试器里单步追数据不如把发送和接收的边界条件想清楚。数据从哪来、放到哪个缓冲区、搬运由谁完成、结束如何判定这四个问题想透了串口通信的大坑基本都能绕开。这套方案不光是串口能用后续做SPI、I2C、甚至ADC多通道采样时DMA加中断的思路完全可以平移过去一通百通。本文还有配套的精品资源点击获取