做嵌入式这几年,串口一直是个绕不开的活儿。尤其现在很多模块都靠串口交互——蓝牙、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_TX |
| Direction | 数据传输方向 | 接收是Peripheral To Memory,发送是Memory To Peripheral |
| Mode | Normal循环模式或者Circular循环模式 | 收发完整数据包建议先用Normal,需要环形缓存再考虑Circular |
| Priority | DMA通道优先级 | 串口高速收发建议High或VeryHigh |
| Data 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收发、PWM+DMA驱动灯带,会发现它们是完全一样套路——CubeMX里加一个DMA请求,代码里启动一次传输,回调整理结果。底层机制一点没变。
还有个小技巧值得分享:调试串口DMA的时候,不要直接拿外设联调,先做自发自收回环测试。把发送脚和接收脚用一根杜邦线连起来,代码里调用一次DMA发送,同时用DMA接收,看收到的数据和自己发的是否一致。链路一旦通了,再对接外部模块,能省下大量排查时间。这套方法我用了很多年,从来没让我失望过。