简介这份资源面向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转串口模块的RXPA10接TXGND对接。注意是交叉连接开发板的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是通道4RX是通道5。如果两个通道配置时用了同一个通道或者中断优先级配置不当导致DMA请求冲突就会出现数据串扰。解决确认TX用DMA1_Channel4RX用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的时候主循环处理BIDLE中断触发时切换。实现上可以用两个标志位加一个指针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下的连续帧压力测试。这三步走完基本不会再翻车。希望帮到你。本文还有配套的精品资源点击获取