STM32串口DMA+空闲中断接收不定长数据方案详解
2026/9/10 1:34:42 网站建设 项目流程

简介:面向STM32F103嵌入式开发者的串口空闲中断收发工程模板,用于解决不定长、无结束标志的UART数据高效接收问题,尤其适合工业通信、串口屏交互、帧解析等场景。资源为完整可编译的Keil工程,基于STM32标准外设库,包含UART初始化、空闲中断使能、中断服务函数、数据接收与状态处理等配套模块。压缩包共180个文件,以C源文件、H头文件、启动汇编文件及工程配置文件为主,另有SCT链接脚本、HEX固件与文档说明,包体约2.58MB,目录结构清晰。已有5301人学习,适合需要掌握串口空闲中断原理或直接复用代码的初中级开发者。通过示例工程可快速理解空闲中断的触发条件、中断标志清除时机,并可在此基础上扩展DMA收发或超时处理逻辑,提升开发效率与代码稳定性。

1. 为什么我推荐用空闲中断做串口收发

做嵌入式这几年,接的最多的外设大概就是串口了。以前写串口接收,最粗暴的做法就是开个 RXNE(接收非空)中断,来一个字节进一次中断,把数据扔进环形缓冲区。这种做法在小数据量、低波特率的时候没什么毛病,但一旦数据帧变长、频率变高,问题就来了:每来一个字节都要压栈、跳中断、读数据、清标志,CPU 开销很大,而且在高波特率下还容易丢字节。

后来我开始用 DMA + 空闲中断(IDLE Interrupt)的方案,可以说彻底解决了我之前遇到的大部分串口接收痛点。这个组合的核心逻辑其实很简单:DMA 负责把串口收到的数据自动搬到内存缓冲区里,全程不需要 CPU 干预;而空闲中断负责在“一帧数据发完了”的时候通知 CPU 来处理数据。为什么用空闲中断来判断一帧结束?因为串口数据在总线上是连续发送的,字节之间间隔很短,一旦总线上出现一个字节时间的空闲电平,就说明当前这帧数据发完了,这时候触发一次中断来处理整包数据,效率最高。

这个方案特别适合用在需要不定长数据接收的场景,比如和 GPS 模块通信、接收 4G 模组的 AT 指令响应、Modbus RTU 报文收发、或者和蓝牙模块透传数据。只要数据帧和帧之间有超过一个字节的间隔,空闲中断就能准确地把帧边界切出来。如果你正在被“串口收数据太快丢字节”或者“不知道一帧数据什么时候结束”的问题困扰,这篇内容应该能帮你把整套逻辑理清楚。

2. 空闲中断的核心原理与配置准备

2.1 空闲中断到底是怎么工作的

先把 USART 的 IDLE 中断说透。STM32F103 的 USART 外设里有一个 IDLE 标志位,它检测的是 RX 线上的电平状态。当接收完一个字节后,RX 线上保持高电平的时间超过一个字节传输时间,IDLE 标志就会被置位。这个“一个字节传输时间”包含起始位、数据位、校验位和停止位,所以在 9600 波特率、8 位数据位、无校验、1 位停止位的配置下,大约需要 1.04ms 的空闲时间才能触发。

这里要注意一个点:IDLE 标志被置位后,不会自动清除。标准库或者直接操作寄存器的话,需要先读 USART_SR 寄存器,再读 USART_DR 寄存器,这个顺序不能错。如果你用的是 DMA 接收,读 USART_DR 的操作实际上已经被 DMA 完成了,但 IDLE 中断服务函数里还是要做一次读 SR 再读 DR 的动作来清标志,否则中断会一直触发或者下次触发不了,这个我后面在代码里会具体说。

2.2 为什么一定要配合 DMA 使用

有朋友可能会问:既然空闲中断能判断帧结束,那我不用 DMA,就用普通的接收中断加空闲中断行不行?行是行,但意义不大。因为 IDLE 中断本质上只是告诉你“总线上安静了”,它并不会帮你把数据搬出来。如果你不开 DMA,那你还是得在 RXNE 中断里一个字节一个字节地收,那问题又回到了原点。

DMA 的作用是让串口接收到数据后,硬件直接把数据搬运到你在内存里指定的数组或者缓冲区,搬运完或者搬运过程中不需要 CPU 参与。DMA 有两种模式:Normal 模式和 Circular 模式。做串口接收的话,我强烈建议用 Normal 模式,配合 DMA 传输完成中断和串口空闲中断一起判断数据帧。这样做的好处是每一帧数据的起始位置都是缓冲区的起始地址,逻辑非常清晰。

还有一个重要的细节是 DMA 的数据宽度要配成 Byte。有些初学者会习惯性地把 DMA 外设和内存的宽度都配成 Word,这在串口数据是 8 位的情况下是灾难性的,因为你每收到一个字节,DMA 实际上会搬运 4 个字节进缓冲区,缓冲区里全是错位的数据,而且 DMA 的传输计数会很快变成 0,导致传输完成中断提前触发。我试过这个坑,排查了整整一下午才发现是 DMA 宽度配错了。

2.3 标准库 vs HAL 库的选择建议

STM32F103 现在用的库主要就是标准库 V3.5 和 STM32CubeMX 生成的 HAL 库。如果你手头项目是老的,或者参考代码大多是标准库写法的,直接用标准库就好。如果新开项目,用 CubeMX 配置也可以,但要特别留意 HAL 库对 IDLE 中断的处理方式——HAL 库本身没有完整的空闲中断接收逻辑,它只帮你把 USART_IT_IDLE 这个中断开了,具体在中断回调里怎么用 DMA 拿数据、怎么清标志,还得自己写。

我个人建议是:优先学标准库的写法,把寄存器级别的行为搞清楚。因为你一旦理解了 SR、DR、DMA_CNDTR 这些寄存器的行为,切到 HAL 库只是换了个 API 皮,底层逻辑完全不变。比如 HAL 库里你要拿当前 DMA 还剩下多少数据没搬运,直接读 hdma->Instance->CNDTR,这个寄存器和标准库里的 DMA_GetCurrDataCounter 是同一个东西。

3. 完整实现:DMA + 空闲中断收发不定长数据

3.1 硬件准备与最小系统说明

这篇文章的代码基于 STM32F103C8T6 最小系统板,串口用 USART1,PA9 是 TX,PA10 是 RX,板上自带 CH340 串口转 USB 芯片,可以直接连电脑调试。如果你用的是其他型号的 F103,比如 VET6 或者 ZET6,外设资源更多,代码逻辑完全一样,只需要修改引脚和时钟配置头文件即可。

如果要做 RS485 通信,只需要在 USART1 的 TX 引脚外面加一个 MAX3485 或者 SP3485 芯片,再把 RX 和 TX 接到收发器的对应引脚上。RS485 是半双工通信,所以必须控制 DE/RE 引脚来切换收发方向。这里有一个常见的技巧是用反向逻辑:把 RTS 引脚配置为推挽输出,发送数据前拉高 DE 引脚,数据发送完成后拉低。实测下来,这种手动切换方向比“自动收发电路”更可控,尤其在高速波特率下不容易出现方向切换不及时导致的数据尾巴问题。

3.2 串口和 DMA 的初始化配置

下面是基于标准库的完整初始化代码。先看 USART 部分,波特率设置为 115200,8 位数据位,1 位停止位,无校验位。开着接收 DMA 请求,同时打开 IDLE 中断,但注意不要打开 RXNE 中断。

void USART1_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); USART_InitStructure.USART_BaudRate = 115200; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_RX | USART_Mode_TX; USART_Init(USART1, &USART_InitStructure); USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE); USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); USART_Cmd(USART1, ENABLE); }

串口部分的重点是USART_DMACmd这一句,把 USART1 的接收 DMA 请求打开后,串口每收到一个字节,硬件就会自动向 DMA 控制器发出请求,不需要 CPU 介入。

3.3 DMA 的关键配置细节

接下来是 DMA 的初始化。这里我定义了一个 256 字节的接收缓冲区USART1_RX_BUF,DMA 的目标地址就是这个数组的首地址。

#define RX_BUF_SIZE 256 uint8_t USART1_RX_BUF[RX_BUF_SIZE]; __IO uint8_t USART1_RX_CNT = 0; __IO uint8_t USART1_RX_FLAG = 0; void USART1_DMA_Config(void) { DMA_InitTypeDef DMA_InitStructure; DMA_DeInit(DMA1_Channel5); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)USART1_RX_BUF; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = RX_BUF_SIZE; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode = DMA_Mode_Normal; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel5, &DMA_InitStructure); DMA_Cmd(DMA1_Channel5, ENABLE); }

DMA1 的 Channel5 对应的是 USART1_RX,这个映射关系在参考手册里有明确说明,不同的串口对应的 DMA 通道不同,比如 USART3_RX 用的是 DMA1_Channel3,换串口的时候一定要查手册确认,不然数据进不了正确的缓冲区。

DMA_Mode_Normal是很关键的一项。用 Normal 模式的话,DMA 搬运完设定的DMA_BufferSize个字节后就会停止,搬运计数归零,不会自动重装。这看起来像个缺点,但对于不定长数据接收反而是优点:每一帧数据来了之后,DMA 从头开始搬,帧结束的时候我们从缓冲区的第 0 个字节开始处理数据,逻辑非常直白。如果用了 Circular 模式,数据可能覆盖,或者你要额外维护一个“上次数据裁剪到哪里”的游标,复杂度直接上升。

3.4 空闲中断服务函数的处理逻辑

空闲中断服务函数是整个收发方案的核心。它要做的三件事:记录接收长度、置位接收完成标志、重新配置 DMA 以保证下一帧数据能从头开始收。

void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) != RESET) { USART_ReceiveData(USART1); DMA_Cmd(DMA1_Channel5, DISABLE); USART1_RX_CNT = RX_BUF_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); USART1_RX_FLAG = 1; DMA_Cmd(DMA1_Channel5, ENABLE); } }

这段代码里有一个细节必须解释清楚。USART_ReceiveData(USART1)看起来只是读了一下数据寄存器,但它的本质作用是“读 SR 再读 DR”这个清除 IDLE 标志的标准动作。因为 DMA 已经把 DR 里的数据搬走了,所以我们在这里读到的数据不重要,重要的是这个操作要执行,否则 IDLE 标志不会被清掉。

然后是DMA_GetCurrDataCounter这个函数,它返回的是当前 DMA 通道还剩下多少个数据没搬运。用初始的缓冲区大小减去剩余计数,就是这一帧数据实际收到的字节数。这个计数是硬件寄存器DMA_CNDTR的值,非常可靠,比自己在 RXNE 中断里用变量累加的计数方式要准确得多。

最后要做的就是把 DMA 通道重新使能。为什么要先禁再使能?因为 DMA 在计数归零或者想要从头开始的场景下,必须先失能,然后把CNDTR寄存器重新设置成初始值,再使能。像上面这样通过DMA_Cmd(DISABLE)DMA_Cmd(ENABLE)的方式,内部逻辑会自动重装CNDTR为初始化时设置的DMA_BufferSize,所以不需要手动去操作 CNDTR 寄存器。这一步很关键,漏掉的话,下一帧数据来的时候 DMA 不会动,数据全部丢失。

3.5 主循环里如何处理接收到的数据

中断服务函数只负责标记“收到了一帧”和记录帧长度,具体的数据解析放主循环里完成。主循环的做法如下:

while (1) { if (USART1_RX_FLAG) { USART1_RX_FLAG = 0; for (uint16_t i = 0; i < USART1_RX_CNT; i++) { /* 处理 USART1_RX_BUF[i] */ } printf("Recv %d bytes done\r\n", USART1_RX_CNT); } }

主循环一旦发现接收完成标志置位,就把缓冲区里的数据取出来处理。处理完之后,因为 DMA 已经在中断里重新使能了,所以下一帧数据会继续从缓冲区的第 0 个字节开始存放,这个“无缝衔接”是空闲中断 + DMA 方案最舒服的地方。

3.6 发送部分的两种实现与选择

说完了接收,再聊发送。发送相对简单,如果是 UART 点对点通信,直接用阻塞式发送就行,数据量也不大,比如发个 AT 指令或者返回一小段调试信息,阻塞式发送在 115200 波特率下几百个字节也就是几十毫秒的事,完全够用。

但如果你用的是 RS485 半双工总线,发送就要多一步方向切换的考虑。我的做法是在发送函数里把方向引脚拉高,然后启动 DMA 发送,在 DMA 发送完成中断里把方向引脚拉低。这里要注意的是:DMA 发送完成中断触发时,数据实际上已经从 DMA 搬运到 USART 的数据寄存器了,但最后一个字节可能还在移位寄存器里没有完全送出去。正常来说需要稍微延时一下再切换方向,否则会截断最后一个字节。更稳妥的方式是读取USART_GetFlagStatus(USART1, USART_FLAG_TC),等到发送完成标志置位后再关 DE 引脚。

void RS485_SendData(uint8_t *buf, uint16_t len) { RS485_DE_HIGH; DMA_Cmd(DMA1_Channel4, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel4, len); DMA_Cmd(DMA1_Channel4, ENABLE); } void DMA1_Channel4_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC4)) { DMA_ClearITPendingBit(DMA1_IT_TC4); while (USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); RS485_DE_LOW; } }

一个常见的错误是只在串口发送完len字节后立刻拉低 DE,这样很可能会把最后一个字节的停止位截掉,导致对端收到的是错误帧。加一个等待 TC 标志的 while 循环,会让整个数据帧完整地从 TX 引脚送出去。

4. 常见问题与避坑实录

4.1 问题排查速查表

现象可能原因排查与解决方法
接收不到任何数据DMA 通道配错查参考手册确认串口对应的 DMA 映射,比如 USART1_RX 必须用 DMA1_Channel5
接收数据全是乱码DMA 数据宽度不是 Byte把外设和内存的数据宽度都配成DMA_PeripheralDataSize_Byte,不要用 Word
收到一帧后第二帧不再触发IDLE 标志未清除中断服务函数里必须读一次 DR 寄存器清标志;DMA 需要先失能再使能以重装 CNDTR
数据错位,第一个字节丢没有关 RXNE 中断初始化时不要使能USART_IT_RXNE,否则会和 DMA 抢数据
DMA 传输提前完成BufferSize 设置小于实际接收字节数增大 DMA_BufferSize,或者做拆帧协议处理
485 通信偶发最后一个字节丢失DE 引脚关闭过早切换到发送模式后,等待USART_FLAG_TC置位再拉低 DE

4.2 帧超时机制:一个免费且可靠的改进方案

有些时候,单纯靠空闲中断来判断帧结束还不够。比如两个设备之间的数据流是连续的,没有任何停顿,这时候空闲中断根本不会触发,DMA 缓冲区会被填满,然后数据覆盖,程序就认为“丢帧”了。

针对这种情况,我一般会在定时器里写一个帧超时机制:DMA 每收到一个字节(通过空闲中断触发或者定时查询 CNDTR 的变化),就重置定时器计数值;当定时器超过设定时间(比如 10ms)没有收到新数据,就认为当前帧结束。

void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update)) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); if (frame_timeout > 0) { frame_timeout--; if (frame_timeout == 0) { USART1_RX_FLAG = 1; USART1_RX_CNT = RX_BUF_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); DMA_Cmd(DMA1_Channel5, DISABLE); DMA_Cmd(DMA1_Channel5, ENABLE); } } } }

这个方案的优点是逻辑直观、占用的定时器资源少。它不会和空闲中断冲突,反而是一个补充:空闲中断负责把“短停顿的帧”切出来,超时机制负责把“长时间不停顿的流”切出来。两个逻辑一起吃数据,基本上能覆盖 95% 以上的串口通信场景。

4.3 实测经验分享:关于缓冲区大小和波特率的关系

缓冲区大小怎么定?先看你最长的数据帧是多少字节。比如 GPS 的 NMEA 协议,最长的一行大约 82 个字节,缓冲区设置成 128 就够了。又比如 Modbus RTU 的报文,最长也就是 256 字节,缓冲区设置成 256 比较稳妥。原则很简单:缓冲区要大于等于最大帧长,然后稍微留点余量。

波特率越高,单位时间到达的字节数就越多。9600 波特率下,一秒钟大约 960 字节,而 115200 波特率下,一秒钟大约 11520 字节。DMA 在搬运数据的时候,如果同时有两个外设争抢总线,比如 SPI 也在高速传输,可能会因为总线竞争导致 DMA 跟不上,从而丢字节。解决思路是合理分配 DMA 通道优先级,把串口接收的 DMA 通道优先级调高一点。比如我通常把 DMA1_Channel5 的优先级设为DMA_Priority_High,其他慢速外设的通道用 Medium 或者 Low。

4.4 一个容易被忽视的细节:头部脏数据过滤

用 DMA 接收时,如果上一次接收的数据没有完全处理完,或者 DMA 计数清零之后缓冲区里残留了旧数据,那么下一次USART1_RX_CNT统计的字节数可能比实际新数据少,或者混入旧数据。我在做一个 4G 模组透传项目时就踩过这个坑:模组开机时发了一长串乱码提示信息,我收到后发现里面夹杂着上一次会话的残包。

解决方法是每次处理完数据后,把缓冲区清零。但要注意:直接 memset 一个 256 字节的缓冲区在中断里做是不合适的,应该放在主循环里处理完数据之后做。还有一个更快的做法:不主动清零,而是维护一个帧序号。每一帧数据进来之前,先记录一个起始偏移,处理数据时只处理USART1_RX_BUF[start_offset]USART1_RX_BUF[start_offset + len - 1]之间的内容。这样即使缓冲区里有残留,也不会影响当前帧的完整性。

关于这个方案的最终心得

说实在的,DMA + 空闲中断这套组合,我用了快五年,从 STM32F103 一路用到 STM32H750,中间还尝试过用类似逻辑适配 PY32F003 这种国产小芯片,核心思路完全一致。只要底层串口外设支持在 RX 线上检测空闲电平并触发中断,这套框架就能直接套用。

有一次在一个工业控制项目里,现场的设备正好是 485 总线,好几十个从机挂在一条总线上,主机轮询间隔很短。最开始用传统的逐字节接收方式,经常出现响应超时,因为 CPU 被中断秒杀,完全没法处理其他任务。换成 DMA + 空闲中断后,主机侧彻底解放了,CPU 占用率从 70% 降到了 15% 以内,整个系统的稳定性一下就上来了。

最后再补充一个实际调试中很有用的小技巧:调试串口接收时,手上备一个支持定时发送的串口调试助手,比如 XCOM 或者 SSCOM。它可以在指定时间间隔内自动发送数据帧,方便你验证空闲中断在不同数据间隔下的触发情况。我一般会分别设置 2ms、5ms、10ms 的间隔去测试帧边界切得准不准,这个测试在写 Modbus 协议栈时尤其重要,能帮你快速定位“帧粘连”还是“帧断裂”的问题。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询