简介: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 Resonator,Clock Configuration里把系统主频配到最高(F407就是168MHz)。
第二步,在左侧Categories里找到USART1,Mode选择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,以后换芯片、换平台,只需要重新实现这一层,上层业务代码一行都不用改。这个习惯,在你看惯了那几块板子之后就会明白有多值钱了,尤其是当你后面要面对的芯片型号越来越杂的时候。
本文还有配套的精品资源,点击获取