☰
STM32F103串口DMA+空闲中断收发实战:告别HAL_UART_Receive_IT丢包
2026/10/16 2:37:39 网站建设 项目流程

简介:这份资源面向STM32F103嵌入式开发者,聚焦UART串口空闲中断收发这一实用技能,解决不定长度、无固定结束标志数据帧的高效接收问题。内容围绕空闲中断的配置思路展开,涵盖UART初始化、空闲中断开启、中断服务函数编写、NVIC优先级设置以及中断标志清除等关键环节,适合已掌握串口基础、希望进阶掌握中断接收机制的工程师与学习者参考。压缩包共180个文件,以35个h头文件、34个c源文件、33个s汇编文件为主,另含o、d、crf等编译中间文件及uvprojx、uvoptx等Keil工程配置,整体约2.58MB,工程结构完整,可直接用于对照调试。目前已有5302人学习下载,读者可借助其中的示例代码与工程模板,快速理解空闲中断的触发逻辑与数据搬运方式,并在此基础上完成不定长数据的解析与业务处理,减少自行摸索的时间成本。

1. 串口空闲中断收发:为什么我劝你先别用HAL_UART_Receive_IT

刚接触STM32F103串口通信的开发者,十有八九踩过同一个坑:用HAL库的HAL_UART_Receive_IT收不定长数据,结果要么丢包,要么在回调里拼字符串拼到怀疑人生。问题出在“逐字节中断”这个机制上——每来一个字节进一次中断,115200波特率下CPU有相当比例的时间在进出中断现场,稍微加点其他任务就翻车。

STM32F103的UART有个被很多人忽略的硬件特性:空闲中断(IDLE)。总线在一帧数据结束后保持空闲状态超过一个字节传输时间,硬件自动置位IDLE标志。这意味着你不需要知道对方发了多少字节,只要在IDLE中断里把DMA搬好的数据取走就行。这套“DMA+空闲中断”的组合,是F103上处理不定长串口帧最稳的方案,没有之一。下面这份资源就是围绕这个机制展开的完整收发实现,适合正在做Modbus、GPS解析、上位机通信的嵌入式开发者。

2. 架构拆解:DMA搬运加IDLE断帧的完整链路

2.1 为什么是DMA加IDLE,而不是逐字节中断

先算一笔账。假设波特率115200,一个字节连起始位停止位共10位,传输时间约86.8微秒。如果用逐字节接收中断,每86.8微秒CPU就要响应一次中断,中断里还要把数据从DR寄存器搬到缓冲区。F103主频72MHz,一次中断进出加上搬运大概消耗几十个时钟周期,看似不多,但串口持续高速收发时,这个开销会挤占其他外设的处理窗口。

DMA的思路是把“搬运”这件事从CPU手里拿走。配置好DMA通道后,USART收到一个字节,硬件自动触发DMA请求,DMA控制器把DR寄存器的数据搬到你指定的内存缓冲区,全程不打扰CPU。CPU只需要在数据收完之后被通知一次。

那怎么知道“收完了”?这就是IDLE中断的价值。它不关心你收了多少字节,只关心总线什么时候空闲下来。一帧数据发完,总线空闲,IDLE标志置位,进中断,此时DMA已经把所有字节搬到了缓冲区,你只需要读一下DMA的剩余计数(NDTR寄存器),就能算出这一帧收了多少字节。

两者配合的逻辑链路是这样的:

// 初始化阶段:DMA循环模式 + 开启IDLE中断 // 运行阶段:数据到达 -> DMA自动搬运 -> 总线空闲 -> IDLE中断 -> 计算长度 -> 处理数据 -> 重置DMA

这里有个关键设计决策:DMA用普通模式还是循环模式?循环模式下DMA搬满缓冲区后自动回到起点继续搬,好处是不用每次手动重启DMA,坏处是如果一帧数据超过缓冲区大小会覆盖前面的内容。普通模式则需要每次IDLE中断处理后手动重置DMA的NDTR和地址。资源里用的是循环模式配合足够大的缓冲区,这是工程上更省心的做法。

2.2 目录结构与模块划分

拿到这份资源后,先看目录结构,心里有个全局图:

project/ ├── Core/ │ ├── Inc/ │ │ ├── usart_dma.h // 串口DMA收发接口声明 │ │ └── main.h │ └── Src/ │ ├── usart_dma.c // 核心实现:初始化、IDLE中断处理、发送 │ ├── main.c // 主循环与业务逻辑 │ └── stm32f1xx_it.c // 中断向量,USART1_IRQHandler ├── Drivers/ │ ├── CMSIS/ // 内核头文件 │ └── STM32F1xx_HAL_Driver/ // HAL库 └── MDK-ARM/ // Keil工程文件

核心文件就一个usart_dma.c,把串口的初始化、DMA配置、IDLE中断处理、数据发送全部封装在里面。main.c里只做业务处理,不碰底层寄存器。这种分层的好处是,你换一个串口或者换一个项目,把usart_dma.c/h拷过去改几个宏定义就能用。

2.3 初始化配置:从GPIO到NVIC的完整参数

初始化分四步走:GPIO复用配置、USART参数配置、DMA通道配置、NVIC中断优先级配置。资源里的初始化函数大致长这样:

#define USART_DMA_BUF_SIZE 256 uint8_t g_rx_buf[USART_DMA_BUF_SIZE]; // DMA接收缓冲区 uint8_t g_tx_buf[USART_DMA_BUF_SIZE]; // 发送缓冲区 volatile uint16_t g_rx_len = 0; // 本次收到的数据长度 volatile uint8_t g_rx_flag = 0; // 收到完整帧标志 void USART_DMA_Init(uint32_t baudrate) { // 1. GPIO配置:PA9复用推挽输出(TX),PA10浮空输入(RX) GPIO_InitTypeDef gpio; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE); gpio.GPIO_Pin = GPIO_Pin_9; gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); gpio.GPIO_Pin = GPIO_Pin_10; gpio.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &gpio); // 2. USART参数:8位数据、1位停止、无校验、收发模式 USART_InitTypeDef usart; usart.USART_BaudRate = baudrate; usart.USART_WordLength = USART_WordLength_8b; usart.USART_StopBits = USART_StopBits_1; usart.USART_Parity = USART_Parity_No; usart.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; usart.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_Init(USART1, &usart); // 3. DMA接收通道:USART1_RX对应DMA1_Channel5 DMA_InitTypeDef dma; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel5); dma.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR; dma.DMA_MemoryBaseAddr = (uint32_t)g_rx_buf; dma.DMA_DIR = DMA_DIR_PeripheralSRC; dma.DMA_BufferSize = USART_DMA_BUF_SIZE; dma.DMA_PeripheralInc = DMA_PeripheralInc_Disable; dma.DMA_MemoryInc = DMA_MemoryInc_Enable; dma.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; dma.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; dma.DMA_Mode = DMA_Mode_Circular; // 循环模式 dma.DMA_Priority = DMA_Priority_High; dma.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel5, &dma); DMA_Cmd(DMA1_Channel5, ENABLE); // 4. 开启IDLE中断和DMA接收请求 USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE); USART_Cmd(USART1, ENABLE); // 5. NVIC配置 NVIC_InitTypeDef nvic; nvic.NVIC_IRQChannel = USART1_IRQn; nvic.NVIC_IRQChannelPreemptionPriority = 1; nvic.NVIC_IRQChannelSubPriority = 1; nvic.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&nvic); }

几个参数值得单独说。DMA_Mode_Circular让DMA在缓冲区满后自动回卷,配合IDLE中断使用,省去了每次手动重置DMA的麻烦。DMA_Priority_High保证串口数据搬运不被其他DMA请求打断。NVIC抢占优先级设为1,比系统滴答定时器高,避免串口数据在中断排队时被延迟处理。

缓冲区大小256字节是个经验值。太小了容易在高速连续帧场景下被覆盖,太大了浪费RAM。如果你的协议单帧不超过64字节,128也够用。关键是缓冲区要大于你预期的最长单帧长度,留出余量。

2.4 IDLE中断处理:长度计算与数据提取

中断服务函数是整个方案的核心,逻辑不复杂但细节容易翻车:

void USART1_IRQHandler(void) { // 判断是否为IDLE中断 if (USART_GetITStatus(USART1, USART_IT_IDLE) != RESET) { // 必须读一次SR和DR来清除IDLE标志 // 这是F1系列的硬件要求,少一步标志清不掉 volatile uint32_t tmp; tmp = USART1->SR; tmp = USART1->DR; (void)tmp; // 计算本次接收长度:缓冲区总大小 - DMA剩余计数 g_rx_len = USART_DMA_BUF_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); // 置标志,通知主循环处理 g_rx_flag = 1; // 循环模式下不需要重置DMA,但需要记录当前写指针位置 // 如果用的是普通模式,这里要重新配置DMA } }

清IDLE标志这一步是F1系列的经典坑。手册里写的是“读SR寄存器后再读DR寄存器”来清除,但很多人只读SR不读DR,结果中断反复触发。代码里用volatile变量接一下,防止编译器优化掉这次读取。

长度计算用的是缓冲区大小 - DMA剩余计数。循环模式下,DMA的NDTR寄存器在搬运过程中递减,搬完一个字节减一。IDLE中断触发时,NDTR的值就是“还没被覆盖的剩余空间”,用总大小减去它就是本次收到的字节数。这个计算在循环模式下有个前提:一帧数据不能超过缓冲区大小,否则NDTR会回卷,算出来的长度就错了。

主循环里的处理逻辑:

while (1) { if (g_rx_flag) { g_rx_flag = 0; // 处理g_rx_buf中前g_rx_len个字节 ProcessFrame(g_rx_buf, g_rx_len); // 处理完后清空长度 g_rx_len = 0; } // 其他任务 }

这里有个并发问题要注意:主循环处理数据的时候,DMA可能正在往缓冲区后面写新数据。如果处理时间较长,新数据可能覆盖正在处理的内容。解决办法要么是处理前先把数据拷到另一个缓冲区,要么用双缓冲机制。资源里用的是拷贝方式,简单可靠。

3. 跑起来:从Keil工程到串口助手的验证流程

3.1 编译下载与硬件接线

拿到工程后,用Keil MDK打开MDK-ARM目录下的工程文件。编译前确认两件事:一是器件型号选的是STM32F103C8还是其他型号,在Options for Target的Device页签里核对;二是调试器配置,ST-Link还是J-Link,在Debug页签里选对。

硬件接线就三根线:PA9接USB转串口模块的RX,PA10接TX,GND对接。注意是交叉连接,开发板的TX接模块的RX,别接反了。供电可以用ST-Link的3.3V,也可以用USB转串口模块的3.3V,但不要同时接两个电源。

编译下载后,打开串口助手,波特率设115200,数据位8,停止位1,无校验。发送任意长度的一串字符,比如“HelloSTM32”,开发板应该原样回传。如果回传的数据少了几个字节或者多了乱码,先检查波特率是否匹配,再检查晶振频率设置是否正确——F103的外部晶振是8MHz,系统时钟倍频到72MHz,如果晶振配置错了,波特率会偏。

3.2 用逻辑分析仪验证时序

串口助手能通不代表时序没问题。如果要做产品,建议用逻辑分析仪抓一下TX和RX的波形。重点看两个地方:一是IDLE中断触发的位置是否在停止位之后,二是DMA搬运的数据和总线上实际传输的数据是否一致。

逻辑分析仪的采样率至少设到1MHz以上,115200波特率下每个位宽约8.68微秒,采样率太低会看不清位边界。触发条件设成RX下降沿,抓一帧完整数据。正常的波形是:起始位低电平,8个数据位,停止位高电平,然后总线保持高电平进入空闲状态。IDLE中断应该在停止位结束后不久触发。

如果发现IDLE中断触发太早,数据还没收完就进了中断,大概率是波特率不匹配导致硬件误判空闲。检查USART的BRR寄存器值,用72000000 / 115200算一下,应该是625,即0x271。如果算出来不是这个值,说明时钟配置有问题。

3.3 回环测试与压力测试

基础功能通了之后,做两轮测试。第一轮回环测试:短接PA9和PA10,开发板自己发自己收,验证收发链路是否自洽。第二轮压力测试:用串口助手以10毫秒间隔连续发送100帧不同长度的数据,观察是否有丢帧或错帧。

压力测试时可以在ProcessFrame里加一个计数器,每处理一帧加一,最后打印总帧数。如果收到的帧数少于发送的帧数,说明有丢帧。丢帧的原因通常是主循环处理太慢,下一帧数据已经覆盖了缓冲区。解决办法是提高主循环的处理速度,或者加大缓冲区,或者用双缓冲。

提示:压力测试时把串口助手的发送间隔调到5毫秒以下,能更快暴露问题。但注意USB转串口模块本身也有缓冲,间隔太短可能模块先丢数据,不一定是STM32的问题。

4. 避坑指南:IDLE中断翻车现场与排查手册

4.1 中断反复触发,程序卡死在IRQHandler

现象:串口一收到数据就反复进中断,主循环完全跑不起来,调试器暂停后发现PC指针一直在USART1_IRQHandler里打转。

原因:IDLE标志没有正确清除。F1系列的IDLE标志清除条件是“先读SR寄存器,再读DR寄存器”,两个操作缺一不可。很多人只调用了USART_GetITStatus,这个函数内部只读了SR,没有读DR,标志清不掉。

解决:在中断处理里手动加一次DR读取。代码里用volatile变量接一下,防止编译器优化。注意读DR会清掉RXNE标志,如果你同时开了接收中断,要确认这个操作不会影响其他逻辑。

4.2 第一帧数据丢失,后面正常

现象:上电后发送的第一帧数据收不到,从第二帧开始一切正常。

原因:初始化顺序问题。USART使能之后、DMA使能之前,如果总线上有数据到达,RXNE标志会置位但DMA还没准备好搬运,这个字节就丢了。另外IDLE中断使能太晚也会导致第一帧的IDLE事件被漏掉。

解决:调整初始化顺序,先配DMA并使能,再配USART并开启IDLE中断,最后使能USART。这样USART一开始工作,DMA就已经在待命了。如果还是丢第一帧,可以在初始化完成后先清一次SR和DR,把上电时的杂散标志清掉。

4.3 数据错位,收到的内容整体偏移一个字节

现象:发送“ABCDEF”,收到的是“BCDEF”或者“XABCDE”,数据整体偏移。

原因:DMA的搬运起点和IDLE中断的长度计算不匹配。循环模式下,DMA的写指针在缓冲区里循环移动,如果主循环处理数据时没有记录当前帧的起始位置,直接用g_rx_buf的头部去读,读到的可能是上一帧的尾部。

解决:在IDLE中断里记录DMA的当前写指针位置,主循环根据写指针和长度反推起始位置。或者更简单的方式:每次IDLE中断后把数据拷到一个独立的处理缓冲区,DMA缓冲区只负责接收。资源里用的是拷贝方式,多花一点内存但逻辑清晰。

4.4 高波特率下丢数据,低波特率正常

现象:9600波特率一切正常,调到115200就频繁丢帧。

原因:主循环处理速度跟不上数据到达速度。115200下每86.8微秒来一个字节,一帧100字节的数据在8.68毫秒内传完。如果主循环里ProcessFrame耗时超过这个时间,下一帧数据就会覆盖还没处理完的缓冲区。

解决:三个方向。一是优化ProcessFrame,把耗时操作挪到中断外或者用状态机分步处理。二是加大DMA缓冲区,给主循环争取更多时间。三是用双缓冲,DMA写缓冲A的时候主循环处理缓冲B,处理完交换。双缓冲是终极方案,但实现复杂度也最高。

4.5 DMA发送和接收冲突

现象:同时使用DMA发送和DMA接收时,发送的数据出现在接收缓冲区里,或者接收的数据被发送覆盖。

原因:USART1的TX和RX分别对应DMA1的不同通道,TX是通道4,RX是通道5。如果两个通道配置时用了同一个通道,或者中断优先级配置不当导致DMA请求冲突,就会出现数据串扰。

解决:确认TX用DMA1_Channel4,RX用DMA1_Channel5,两个通道独立配置。发送和接收使用不同的缓冲区,不要复用同一块内存。如果发送频率很高,发送完成中断和IDLE中断的优先级要合理安排,避免发送中断阻塞接收中断。

5. 二次开发:把串口收发改造成协议解析器

5.1 从裸数据到协议帧的改造思路

资源里的代码收到的是裸字节流,实际项目中你需要把它变成有意义的协议帧。常见的改造方向是加一层帧解析:在ProcessFrame里判断帧头帧尾,提取有效载荷,做校验。

以Modbus RTU为例,帧格式是“地址+功能码+数据+CRC”,帧与帧之间靠3.5个字符时间的空闲来分隔。IDLE中断天然适合这种协议,因为空闲就是帧边界。改造时在ProcessFrame里先校验CRC,再根据功能码分发处理。

void ProcessFrame(uint8_t *buf, uint16_t len) { // 最小帧长校验:地址1 + 功能码1 + CRC2 = 4 if (len < 4) return; // CRC校验 uint16_t crc_calc = Modbus_CRC16(buf, len - 2); uint16_t crc_recv = (buf[len-1] << 8) | buf[len-2]; if (crc_calc != crc_recv) { SendError(0x01); // CRC错误 return; } // 按功能码分发 switch (buf[1]) { case 0x03: HandleReadHolding(buf, len); break; case 0x06: HandleWriteSingle(buf, len); break; case 0x10: HandleWriteMultiple(buf, len); break; default: SendError(0x02); break; // 不支持的功能码 } }

改造的关键点是:不要在中断里做耗时解析。IDLE中断只负责标记“收到一帧”,把数据拷到处理缓冲区,真正的解析放在主循环里做。这样中断响应快,不会因为解析耗时导致后续数据丢失。

5.2 双缓冲机制的实现与验证

如果你的应用场景数据量大、帧间隔短,单缓冲迟早会翻车。双缓冲的思路是准备两块接收缓冲区,DMA写A的时候主循环处理B,IDLE中断触发时切换。

实现上可以用两个标志位加一个指针:

uint8_t rx_buf[2][USART_DMA_BUF_SIZE]; volatile uint8_t active_buf = 0; // DMA当前写入的缓冲区索引 volatile uint8_t process_buf = 0; // 主循环当前处理的缓冲区索引 volatile uint8_t buf_ready[2] = {0, 0}; // IDLE中断里切换 void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) != RESET) { volatile uint32_t tmp; tmp = USART1->SR; tmp = USART1->DR; (void)tmp; uint8_t cur = active_buf; g_rx_len[cur] = USART_DMA_BUF_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); buf_ready[cur] = 1; // 切换到另一块缓冲区 active_buf = 1 - active_buf; DMA_SetCurrDataCounter(DMA1_Channel5, USART_DMA_BUF_SIZE); DMA1_Channel5->CMAR = (uint32_t)rx_buf[active_buf]; } }

验证双缓冲是否生效,可以在压力测试时观察buf_ready标志。如果两个缓冲区的ready标志交替置位,说明切换正常。如果某个缓冲区的ready标志一直是1没被清零,说明主循环处理太慢,需要优化。

5.3 从轮询标志到RTOS消息队列

裸机环境下用g_rx_flag轮询没问题,但如果项目上了FreeRTOS,更好的做法是在IDLE中断里发一个消息队列或信号量,让专门的串口任务去处理。

改造步骤:在IDLE中断里调用xQueueSendFromISR把帧长度和缓冲区指针发到队列,串口任务阻塞在xQueueReceive上,收到后处理。这样串口任务和其他任务通过RTOS调度器协调,不用在主循环里轮询标志位。

注意中断里调用的RTOS API必须是FromISR版本,且中断优先级不能高于configMAX_SYSCALL_INTERRUPT_PRIORITY,否则会触发断言。F103的NVIC优先级分组要设成4位抢占优先级,串口中断的抢占优先级数值要大于等于5(数值越大优先级越低)。

从那以后我每次配串口DMA,都强制走一遍检查清单:先确认DMA通道号对不对,再确认IDLE标志清除有没有读DR,最后跑一轮115200下的连续帧压力测试。这三步走完,基本不会再翻车。希望帮到你。

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

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

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

立即咨询