简介STM32串口通信DMA例程是一份面向嵌入式开发者与STM32初学者的完整工程资源围绕USART、DMA与空闲中断的结合解决大量数据收发时CPU占用过高的问题提升系统实时性。资源共793个文件整体约35.8MB以C源码368个.c与144个.h为主另含.s启动文件、.icf链接脚本、.uvproj工程文件及.ioc图形化配置等可直接在STM32CubeMX中打开并对照学习。已有1000余人学习下载。例程基于NUCLEO-F401RE开发板演示如何配置串口参数、指定DMA通道、开启空闲中断并编写中断服务函数帮助读者掌握无CPU干预的自动收发流程。通过阅读源码与工程结构可深入理解DMA传输完成中断和空闲中断的配合方式为后续嵌入式实时通信项目提供可复用的代码模板与配置思路。1. 项目概述与核心价值干嵌入式这行串口大概是陪伴我们最久的外设没有之一。调试打印要串口、传感器数据采集要串口、和上位机通信还是要串口。但绝大多数人用串口的时候都是走最传统的中断接收或者轮询接收——来一个字节进一次中断CPU被频繁打断数据量大一点就直接把系统拖垮。这个问题我之前在STM32F103上就深有体会用中断方式跑115200波特率每收一个字节就要进一次中断体感上就像你在专心写代码的时候旁边有个人每隔几秒钟就拍你一下肩膀嘿来活了。虽然每个中断很短但架不住频率高上下文切换损耗叠加起来是非常可观的。这篇博文想和你聊的是把串口和DMA结合起来使用的完整方案。DMA的全称是Direct Memory Access直译过来就是“直接内存访问”核心作用就是在外设和内存之间搬运数据而且这个过程完全不需要CPU参与。你只要在启动时把任务交代给它它就能像一个不知疲倦的搬运工自己一趟一趟地把数据从串口寄存器搬到内存缓冲区里搬完再通知你一声。这样一来CPU就解放出来了可以去处理液晶刷新、按键扫描、传感器计算这些真正需要算力的活儿。这篇文章会从最底层的原理讲起带着你把一套完整的串口DMA工程跑通送收两端都会给你可直接抄作业的HAL库写法。重点会放在“不定长数据接收”这个大家都特别头疼的场景——毕竟实际项目中你很难保证每次收到的一定是固定长度的报文。无论你是刚接触STM32的初学者还是已经写过几年固件但一直用中断方式处理串口的老手这篇文章都应该能帮你把这部分知识补齐。我把话放在这里看完并敲完这套代码之后你大概率再也不想回到“一字节一中断”的老路子上去了。2. 整体设计思路拆解2.1 为什么是DMA而不是中断要理解DMA的价值先要理解传统串口接收的问题出在哪里。串口的中断接收流程是这样的每个字节到达后硬件会触发一次接收中断CPU暂停手头的工作跳到中断服务函数里把数据从数据寄存器读出来、存进自己的缓冲区然后返回被中断的地方继续干活。这个过程本身不算慢但是要命的是它“每字节一次”一帧100字节的数据CPU就要被打断100次。DMA模式下的流程就完全不一样了。你需要做的只是在接收开始前配置一次DMA通道告诉它“把串口收到的数据搬到这片内存里搬满N个字节之后通知我”然后就可以放手干别的了。之后每一个字节到达都是由DMA硬件自动完成搬运动作CPU完全无感知。直到缓冲区满了或者你设置的某个特定条件满足DMA才会产生一次中断来告诉你这批货搬完了过来收货。这个场景可以类比成收发快递中断方式相当于每一次包裹到达快递员都要给你打电话让你下楼取DMA方式则相当于你事先给了快递员一把储物柜的钥匙他到了之后自己打开柜子把包裹放进去攒到一定数量再通知你“来取一下”。你不需要一直守在门口等电话中间的时间完全可以用来做自己的事。2.2 这套方案的适用边界我见过不少人看了DMA的介绍之后就无比兴奋恨不得把所有外设都挂上DMA。但实事求是地讲DMA不是银弹它有自己适用的边界。适合用DMA的场景波特率比较高比如460800、921600这种、数据量比较大、每次传输长度较长比如几十上百字节的协议帧、主控MCU同时还要做其他实时性要求较高的任务。这时候DMA能把CPU占用率从百分之几十直接压到接近零。没必要上DMA的场景串口只是用来偶尔打印几条调试日志数据量很小、频率很低用阻塞式发送或者简单中断接收完全够用。这种场景强行上DMA反而要花精力处理缓冲区管理、状态同步这些额外复杂度属于给自己找事。不适合用DMA的场景RS485这种需要实时控制收发方向切换的半双工通信。因为你在发送过程中要精确控制DE引脚电平切换的时机DMA的异步特性反而会让这件事变得难搞。这种场景老老实实回退到中断方式反而更稳。所以正确的姿势是先评估需求再决定要不要上DMA。如果你拿不准自己的场景我的建议是只要波特率在115200以上、单次传输超过16字节或者CPU负载已经超过50%就可以考虑用DMA了。2.3 方案选型标准库还是HAL库现在STM32的开发方式大概有三派标准外设库Standard Peripheral Library、HAL库Hardware Abstraction Layer、LL库Low Layer。我个人推荐新项目一律走HAL库原因有三个第一HAL库是ST当前官方主推的库新出的芯片型号基本只提供HAL和LL支持标准库已经停止维护了。你用标准库开发老芯片还行万一哪天要换个新芯片代码迁移工作量非常大。第二HAL库配合STM32CubeMX这个图形化配置工具初始化代码是自动生成的。串口和DMA的配置在图形界面里面勾选几下就搞定了比手写标准库那一大坨结构体初始化要省事太多。第三HAL库的异步接口设计对DMA这类操作非常友好。比如HAL_UART_Transmit_DMA和HAL_UART_Receive_DMA这两个函数你把缓冲区指针和长度传进去函数立刻返回剩下的搬运全部交给DMA后台执行。执行完毕会触发回调函数通知你完全符合典型的“非阻塞式”编程模型。当然HAL库本身也是有争议的——不少老工程师觉得它封装太厚、效率低、debug起来麻烦。但以我个人经验来看对于串口DMA这个场景HAL库带来的便利远大于它引入的效率损失。因为串口本身就不是什么高速外设那一点点的库里函数开销完全无关紧要。3. 核心细节解析与关键难点3.1 空闲中断实现不定长接收的关键如果你去搜索“STM32串口DMA接收”一定会频繁看到一个词空闲中断IDLE Interrupt。这是整个不定长接收方案里最重要的一个硬件机制。空闲中断的触发条件非常简单粗暴串口接收线路上在一段连续时间内没有收到新的数据。ST官方的定义是在“一个字节的时间”内没有检测到新的起始位就认为总线上产生了空闲。也就是说当对方发送完一帧数据没有紧接着继续发下一帧时串口硬件就会产生一个IDLE中断。这个特性有什么用呢我们可以这样设计接收逻辑DMA负责把所有到达的数据不断搬进我们准备的缓冲区当接收线上出现空闲中断时就说明“当前这一帧已经收完了”。这个时候我们去读DMA的当前计数寄存器就能知道这一帧到底收到了多少个字节然后做对应的处理再把DMA重新配置到接收状态等下一帧。整个过程不需要预先知道帧的长度也不需要约定一个特殊的结束字节无论对方发4个字节还是400个字节都能被正确识别为一帧。实测下来在115200波特率下如果发送端每帧之间间隔超过大概1个字节的时间约87微秒IDLE中断就能稳定可靠地触发。3.2 数据拷贝为什么必须“抢”数据在使用串口DMA接收时有一个非常容易踩的坑当你收到IDLE中断去读缓冲区的时候这个缓冲区并不完全“干净”。怎么理解呢DMA搬运数据是一个连续的、一直进行的过程。假如接收缓冲区的大小是256字节对方连续发来了两条不同的命令每条各100字节那么它们会被紧挨着存进缓冲区第一条命令占据0~99号位置第二条命令紧接着占据100~199号位置。如果你只用“收到的字节数”来取数据那一次只能读到第一条命令第二条命令要到下一次处理时才能被读到但那时候你并不知道第二条命令的起始位置在哪。更麻烦的是当缓冲区被填满之后DMA会自动回绕到缓冲区开头继续写。如果此时前一个帧的数据还没来得及处理新来的数据就会直接把旧数据覆盖掉。解决这个问题的唯一可靠方法就是在IDLE中断触发的瞬间用memcpy把当前这一帧的数据从DMA缓冲区“抢”出来拷贝到自己的处理缓冲区里。而且拷贝完成之后要立刻清空或者重置相关标志位保证下一帧可以从一个已知的初始状态开始接收。你有没有发现这其实有点像在高峰期挤地铁车厢门打开的瞬间你必须果断挤上去或者退出来犹犹豫豫的结果就是被夹在门中间。同样的道理也适用于处理DMA缓冲区的数据该抢就要抢等下一拍再动手就来不及了。3.3 缓冲区大小与溢出保护DMA接收缓冲区的大小是整个工程里一个需要认真设计而不仅仅是随便填的参数。选太大浪费宝贵的RAM——MCU的SRAM本来就紧巴巴选太小一旦高频数据涌入很可能出现缓冲区溢出导致数据丢失。缓冲区大小的设计原则是大于或等于业务层可能收到的最大帧长同时留出至少20%~30%的余量。比如你的通信协议中最大报文是128字节那么缓冲区设到256字节就是比较稳妥的。除了缓冲区大小溢出保护机制也非常重要。HAL库的主结构体UART_HandleTypeDef中有一个ErrorCode字段当DMA接收过程中发生了溢出Overrun这个字段会被置上HAL_UART_ERROR_ORE的标记。代码里每次接收完成之后都应该检查一下这个字段如果发现溢出必须做复位重初始化处理。否则很容易陷入一种诡异的状态DMA已经不工作了但你的代码看起来一切正常就是收不到数据。这个坑我已经见过不少新人在里面卡了很久了。注意在处理DMA接收时收到数据之后对DMA的“重启”操作必须在主循环或回调函数中完成而且要注意先把HAL_UART_Receive_DMA停掉再重新启动顺序错了会导致不可预知的错误。4. 实操过程与核心代码实现4.1 硬件平台说明我在这个例程中使用的是STM32F407VET6这颗芯片主频168MHz板载的串口1通过USB转TTL模块连接电脑。你手头如果是其他型号的STM32比如F103、F429、H743整个代码流程也是一样的——CubeMX配置界面里的选项基本是通用的。接线方面没有特殊要求正常连接TXD、RXD、GND三根线就能工作。不过有一点要提醒你强烈建议用CH340或者FT232这类带隔离的USB转串口模块别用那种几块钱的“九块九包邮”小板子。那种模块在波特率比较高时误码率感人你会分不清是代码问题还是硬件问题白白浪费时间调一个根本不存在的问题。4.2 CubeMX 配置全程图解用STM32CubeMX配置串口DMA的过程其实就是点几个选项的事。下面我把关键步骤和每个参数的含义讲清楚。第一步选择芯片型号并初始化时钟。RCC配置里把HSE选为Crystal/Ceramic ResonatorClock Configuration里把系统主频配到最高F407就是168MHz。第二步在左侧Categories里找到USART1Mode选择Asynchronous异步模式。下面的Parameter Settings里波特率设为115200数据位8位无校验1位停止位——也就是经典的8N1配置。这是绝大部分通信场景的默认配置。第三步还是在USART1的配置界面里切到DMA Settings标签页点击Add添加两个DMA请求一个是USART1_RX一个是USART1_TX。两个DMA通道的模式都要选择Circular循环模式。这个模式的意思是DMA搬运完指定长度之后自动回到缓冲区起始地址继续搬运形成环形缓冲区的效果。如果不选Circular而选Normal那每次收满缓冲区长度后DMA就停了必须重新启动才能继续收对于收不定长数据是不够的。第四步在NVIC Settings标签页中把USART1 global interrupt这一项勾上。你可能要问都让DMA干活了还需要串口中断吗需要的。因为空闲中断IDLE是串口外设产生的中断不是DMA产生的中断。我们要在串口中断服务函数中判断IDLE标志位才知道“这一帧结束了”。同时DMA传输完成中断和串口错误中断也是通过串口中断入口统一处理的。小提示如果你用的是HAL库其实HAL_UART_IRQHandler这个函数内部会自动处理DMA和各种错误的中断回调不需要你在中断里写任何业务代码。你要做的只是实现对应的回调函数即可。4.3 发送端非阻塞式数据发送发送端的代码非常简单。我把初始化后的基本用法整理如下// 发送缓冲区 uint8_t tx_buffer[] Hello, STM32 DMA UART!\r\n; // 使用DMA发送 HAL_UART_Transmit_DMA(huart1, tx_buffer, sizeof(tx_buffer) - 1);HAL_UART_Transmit_DMA这个函数的特点是调用后立即返回实际的数据发送由DMA在后台完成。发送完成后HAL库会调用回调函数HAL_UART_TxCpltCallback你可以在这里做“发完数据处理”的逻辑比如发送完成后释放缓冲区、翻转一个LED等。void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { // 发送完成可以在此处设置标志位或者释放缓冲区 tx_complete_flag 1; } }有一个特别容易出问题的细节要提醒你如果你连续两次调用HAL_UART_Transmit_DMA而且第二次调用时第一次的发送还没完成会导致数据错乱。因为底层寄存器状态被第二次调用覆盖了。常见的解决方案是设置一个“忙”标志位如果上一次发送还没结束就把新的数据先排队等发送完成回调里再处理排队的数据。这就是很多项目中需要实现一层简易“发送队列”的原因。4.4 接收端不定长数据接收完整实现接收端是整个例程的核心我们直接上代码。这个实现的基本逻辑是主循环里第一次调用HAL_UART_Receive_DMA启动DMA接收之后一旦产生串口空闲中断IDLE就在中断处理中判断这一帧收到多少数据用memcpy拷贝出来设置一个“数据就绪”标志位主循环检测到标志位后处理数据然后重新启动DMA接收。/* 定义DMA接收缓冲区注意要定义到全局变量区 */ #define RX_BUF_SIZE 256 uint8_t rx_buffer[RX_BUF_SIZE]; /* 用户处理缓冲区用来拷贝接收到的有效数据 */ uint8_t user_rx_buffer[RX_BUF_SIZE]; volatile uint16_t user_rx_len 0; volatile uint8_t user_rx_complete_flag 0; /* 启动DMA接收 */ HAL_UART_Receive_DMA(huart1, rx_buffer, RX_BUF_SIZE); // 在串口中断服务函数里对IDLE事件的处理单独实现 // 因为HAL库默认的回调不直接暴露IDLE事件。 // 可以参考下面的方式在uart中断函数中叠加处理 void USART1_IRQHandler(void) { /* HAL库标准处理负责接收、发送、错误等基础中断 */ HAL_UART_IRQHandler(huart1); /* 处理空闲中断 */ if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { /* 必须先读SR再读DR这是清标志位的标准操作 */ __HAL_UART_CLEAR_IDLEFLAG(huart1); /* 计算本次收到了多少字节 * 总配置接收长度 - DMA当前剩余计数 已接收字节数 */ uint16_t rx_len RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); /* 将有效数据拷贝到用户缓冲区 */ if (rx_len 0 rx_len RX_BUF_SIZE) { memcpy(user_rx_buffer, rx_buffer, rx_len); user_rx_len rx_len; user_rx_complete_flag 1; /* 可选清空原缓冲区便于调试观察 */ memset(rx_buffer, 0, RX_BUF_SIZE); } /* 重新启动DMA接收 */ HAL_UART_Receive_DMA(huart1, rx_buffer, RX_BUF_SIZE); } } /* 在主循环中处理接收到的数据 */ while (1) { if (user_rx_complete_flag) { user_rx_complete_flag 0; /* 在这里写你的业务处理逻辑 */ process_protocol_frame(user_rx_buffer, user_rx_len); } }有几个细节值得展开讲讲。第一个是清IDLE标志的顺序。STM32的清IDLE标志位操作比较奇特你需要先读一遍状态寄存器SR再读一遍数据寄存器DR顺序错了或者读少了都可能导致标志位清不掉然后就会反复进入中断。在HAL库中__HAL_UART_CLEAR_IDLEFLAG这个宏内部已经帮你完成了这个操作所以直接用宏就没问题。第二个是计算接收长度的公式。DMA有一个计数器CNDTR记录的是“还剩多少字节没搬完”。用配置的总长度减去当前计数器的值得到的就是已经搬运了多少字节。注意这个公式在“缓冲区未回绕”的情况下才成立。如果数据量特别大DMA已经饶了缓冲区一圈甚至几圈这个公式就不准了。对付这种情况简单粗暴的做法就是把缓冲区开大到不会回绕的程度——只要确保一帧数据不会超过缓冲区大小就永远不需要面对回绕的问题。对于绝大多数物联网设备通信来说256字节甚至128字节的缓冲区都完全够用了。第三个是重新启动DMA接收的时机。很多人在这里犯的错误是在拷贝和处理数据之前就重启了DMA接收。这样做的后果是如果下一帧数据来得很快它可能在你还处理上一帧时就已经写入了rx_buffer然后当你memcpy的时候拷出来的可能是新旧数据混杂的一团。正确顺序永远是先拷走数据再处理该帧的所有逻辑最后才重启DMA接收。4.5 ADC 多通道 DMA 采样的延伸思路聊完了串口我想顺便提一句ADC的DMA多通道采样因为这两个场景共用一套底层思维。如果你要用STM32的ADC同时采样多个通道比如同时采集三路模拟量电压、电流、温度传统方式是在ADC转换完成中断里逐个通道读取数据寄存器。这种方式在采样率低时问题不大但如果ADC配置为较高的采样率比如1MHz以上中断频率就会非常高几乎把CPU整个占满。用DMA的方案是把ADC配置成扫描模式Scan Mode加连续转换模式Continuous Conversion Mode然后开启DMA循环模式。这样ADC每转换完一个通道DMA就自动把结果搬运到内存数组里。你只需要定期从数组里读取最新数据即可。数组的第0、1、2个元素就分别对应三个通道的最新转换值完全不用CPU参与。在CubeMX里配置的思路和串口DMA一模一样在ADC的配置页面打开DMA请求选择Circular模式设置数据宽度为Half Word因为ADC的数据寄存器是16位。底层原理完全一致——都是外设数据自动搬运到内存这一套逻辑。理解了串口DMA的原理之后ADC DMA就是水到渠成的事。5. 常见问题与排查技巧实录5.1 常见问题速查表这些问题是新手甚至是部分老手在实际开发中最常遇到的我整理成表格方便你对照排查。问题现象可能原因解决方法串口完全无输出GPIO复用功能没配好DMA没使能CubeMX重新生成初始化代码检查GPIO和DMA配置是否勾选第一次发送正常之后发不了上一次DMA发送还没结束就被第二次调用覆盖加发送忙标志位发送完成回调中清标志DMA接收启动后一直没有中断串口总中断或DMA中断未在NVIC中使能检查CubeMX中NVIC Settings两个中断是否都勾上IDLE中断一直重复触发清除标志位的操作顺序不对确保使用__HAL_UART_CLEAR_IDLEFLAG宏来清标志收到的数据少几个字节或者多几个字节波特率误差太大或用的是劣质USB转串口模块实测波特率换CH340/FT232模块降低波特率到9600测试对照DMA收满缓冲区后停止接收DMA配置成了Normal而不是Circular模式改为Circular循环模式Keil下载时报error: no stm32 target found!连接线松动、复位电路有问题、芯片锁死检查SWD接线按住复位键下载必要时用ST-Link Utility执行全片擦除设备管理器里STM32 Virtual COM Port出现黄色叹号驱动没装好或驱动版本过旧安装ST官方STM32 Virtual COM Port驱动重启电脑5.2 一个排查实录DMA计数寄存器的坑有一次我在调试一个基于STM32F103的网关设备遇到一个非常隐蔽的问题串口DMA接收到的数据最后两个字节总是错误的。排查过程是这样的。一开始我以为是波特率误差问题但用示波器去看波形发现字节时序完全正常。后来我怀疑是接收缓冲区越界但检查后发现缓冲区开得足够大。折腾了很久之后我才注意到一个细节我在IDLE中断处理里先调用了HAL_UART_Receive_DMA重启接收然后再去读取DMA计数器来计算已接收字节数。问题就出在这个顺序上调用了HAL_UART_Receive_DMA之后DMA的计数器已经被重置为满值了。这时候再去读计数器得到的是重置后的值而不是接收到IDLE中断那一刻的值。计算出来的长度自然就是错的。解决办法也很简单先读DMA计数器计算长度拷贝数据处理逻辑最后再重启DMA接收。顺序对了问题立刻消失。所以你看有些问题看起来像玄学其实就是代码执行顺序的问题。把这些经验记录在这里希望你能少走这个弯路。5.3 给新手的调试建议最后给刚接触串口DMA的朋友几条调试建议都是我自己趟过的坑换来的经验。第一先跑通阻塞式发送和接收再切DMA。如果你直接在工程上堆DMA代码出了问题你很难分清是DMA的问题还是原有串口设置的问题。先确保HAL_UART_Transmit和HAL_UART_Receive接口工作正常再改造成DMA版本。每一步都验证过再走下一步这是嵌入式开发最简单的调试哲学。第二在缓冲区里填充“调试标记”。比如把rx_buffer初始化为0xAA或者0x55这种有辨识度的值。当接收完成后你可以在调试器里直接查看缓冲区内容通过看哪些位置被改写、哪些位置保持原值就能大致推断出数据接收的情况和DMA回绕的情况特别直观。第三善用调试器的实时变量查看功能。Keil的Debug窗口可以在程序运行中实时观察全局变量的值。你把user_rx_len和user_rx_complete_flag这两个变量添加到Watch窗口然后向上位机发送一条测试数据立刻就能看到IDLE中断有没有触发、接收长度是否正确。这比在代码里加一堆printf要高效得多。6. 写在最后的几点体会这套串口DMA的方案我前后在好几个不同项目里都用过F103和F4系列都跑过一两年稳定性是经过实践检验的。最初从传统中断切换过来的时候确实不适应总觉得“没有进中断就说明没收到数据”心里不踏实。但用得久了你就慢慢摸清了DMA的脾气知道数据什么时候到、什么时候该处理、什么时候该重置代码写起来也越来越有把握。如果让我给一个后续扩展的建议我会说一旦你掌握了串口DMA这套组合拳下一步可以把它封装成一层简单的驱动层向上提供类似uart_send(frame, len)和uart_on_frame_received(callback)这样的接口。这样应用层代码不需要关心底层用的是中断、轮询还是DMA以后换芯片、换平台只需要重新实现这一层上层业务代码一行都不用改。这个习惯在你看惯了那几块板子之后就会明白有多值钱了尤其是当你后面要面对的芯片型号越来越杂的时候。本文还有配套的精品资源点击获取