简介:这份资源面向使用STM32F103进行嵌入式开发的工程师与学习者,聚焦UART串口空闲中断收发这一实用技术,解决不定长度、无固定结束标志数据帧的高效接收问题。压缩包共180个文件,约2.58MB,以c源文件、h头文件、s汇编启动文件为主,另含o目标文件、d依赖文件、crf交叉引用文件及uvprojx、uvoptx等Keil工程配置,并附带hex、axf、map等编译产物,构成一套可直接参考的完整工程模板。资源围绕空闲中断的初始化、中断开启、服务函数编写、优先级配置与数据解析等环节展开,示例代码可帮助读者理解中断触发时机、标志清除与接收缓冲管理,并体会将耗时处理移出中断的优化思路。目前已有5302人学习下载,适合希望掌握串口不定长数据接收、提升通信稳定性的开发者参考借鉴。
1. 串口空闲中断到底解决了什么问题
如果你用 STM32F103 的 UART 收过不定长数据,大概率写过这种代码:在接收中断里一个字节一个字节往缓冲区塞,再靠定时器超时判断一帧结束。这套方案能跑,但定时器阈值调大了反应慢,调小了长帧被腰斩,遇上波特率抖动更是玄学。串口空闲中断(IDLE)就是来终结这种纠结的——它利用总线在帧间隔期间保持空闲电平的特性,在一帧数据收完、线路重新空闲时触发一次中断,天然帮你切出完整的数据帧边界。
这份资源围绕 STM32F103 的 UART 空闲中断收发展开,核心是用 IDLE 中断配合 DMA 搬运,实现不定长数据的整帧接收与回发。它适合正在做串口通信、Modbus 从机、上位机指令解析的嵌入式开发者,尤其是被「怎么判断一帧收完」卡过的人。下面从原理到代码,把配置、搬运、清标志、踩坑一条条拆开。
2. 空闲中断加 DMA 的接收链路:从寄存器到回调
2.1 为什么是 IDLE 加 DMA,而不是纯接收中断
纯接收中断(RXNE)每来一个字节就进一次中断,115200 波特率下每秒上万次中断,CPU 基本被绑死在串口上。DMA 接手搬运后,CPU 只在「一帧结束」时被 IDLE 中断叫醒一次,中间的数据搬运动作由 DMA 控制器完成,不占内核周期。这是这套方案最核心的选型理由:把高频的字节搬运交给硬件,把低频的帧处理留给软件。
STM32F103 的 USART 支持 IDLE 中断,触发条件是接收数据寄存器在收到一个字节后,总线在一个完整帧传输时间内没有新数据到来。注意它检测的是「线路空闲」,不是「缓冲区满」,所以帧长可以任意,从 1 字节到几百字节都能正确切分。DMA 通道方面,USART1 的 RX 对应 DMA1 通道 5,TX 对应通道 4;USART2 的 RX 是 DMA1 通道 6,TX 是通道 7。通道选错,数据一个都进不来,这是新手最常见的翻车点。
配置顺序也有讲究:先初始化 GPIO 和 USART,再配 DMA,最后开 IDLE 中断。如果先开了 IDLE 中断再去配 DMA,可能在 DMA 就绪前就触发一次空闲中断,读到的是空缓冲区。
2.2 初始化代码:GPIO、USART、DMA 三段配置
下面这段是 USART1 的完整初始化,PA9 做 TX、PA10 做 RX,波特率 115200。代码基于标准外设库,寄存器操作思路一致。
#include "stm32f10x.h" #define RX_BUF_SIZE 256 uint8_t rx_buf[RX_BUF_SIZE]; // DMA 接收缓冲区 volatile uint8_t rx_len = 0; // 本帧实际长度 volatile uint8_t rx_flag = 0; // 帧完成标志 void uart1_init(uint32_t baud) { GPIO_InitTypeDef gpio; USART_InitTypeDef usart; DMA_InitTypeDef dma; NVIC_InitTypeDef nvic; // 1. 时钟:GPIOA、USART1、DMA1、AFIO RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1 | RCC_APB2Periph_AFIO, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); // 2. PA9 复用推挽输出(TX),PA10 浮空输入(RX) 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); // 3. USART1 参数 usart.USART_BaudRate = baud; usart.USART_WordLength = USART_WordLength_8b; usart.USART_StopBits = USART_StopBits_1; usart.USART_Parity = USART_Parity_No; usart.USART_HardwareFlowControl = USART_HardwareFlowControl_None; usart.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, &usart); // 4. DMA1 通道5:USART1_RX DMA_DeInit(DMA1_Channel5); dma.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR; dma.DMA_MemoryBaseAddr = (uint32_t)rx_buf; dma.DMA_DIR = DMA_DIR_PeripheralSRC; // 外设->内存 dma.DMA_BufferSize = RX_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_Normal; // 单次模式,收完一帧手动重启 dma.DMA_Priority = DMA_Priority_High; dma.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel5, &dma); // 5. 开 IDLE 中断 USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); nvic.NVIC_IRQChannel = USART1_IRQn; nvic.NVIC_IRQChannelPreemptionPriority = 1; nvic.NVIC_IRQChannelSubPriority = 1; nvic.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&nvic); // 6. 使能 DMA 和 USART DMA_Cmd(DMA1_Channel5, ENABLE); USART_Cmd(USART1, ENABLE); USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE); }逻辑说明:DMA 配成 Normal 模式而非 Circular,是因为每帧结束后我们要手动重置搬运计数,这样能精确算出本帧长度。如果配成 Circular,DMA 会自己绕回缓冲区头部,长度计算就得靠取模,反而容易出错。DMA_PeripheralBaseAddr指向USART1->DR,这是数据寄存器地址,DMA 每次从它读一个字节。DMA_BufferSize设成 256,意味着单帧最大 256 字节,超了会覆盖或截断,这个值按你协议里最长帧来定。
参数上,DMA_Mode_Normal和DMA_Mode_Circular的选择是分水岭:Normal 适合「一帧一处理」,Circular 适合「连续流式采集」。串口指令解析用 Normal 更直观。
2.3 中断服务函数:清标志的顺序决定成败
IDLE 中断的处理有个经典陷阱:进中断后必须先读 SR 再读 DR,才能清掉 IDLE 标志位。只读 SR 不清,中断会反复触发,程序卡死在中断里出不来。
void USART1_IRQHandler(void) { // 判断是否是 IDLE 中断 if (USART_GetITStatus(USART1, USART_IT_IDLE) != RESET) { // 关键:先读 SR,再读 DR,硬件自动清 IDLE 标志 volatile uint32_t tmp; tmp = USART1->SR; tmp = USART1->DR; (void)tmp; // 关闭 DMA,停止搬运,防止处理期间新数据混入 DMA_Cmd(DMA1_Channel5, DISABLE); // 计算本帧长度 = 总容量 - 剩余未搬运数 rx_len = RX_BUF_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); rx_flag = 1; // 通知主循环处理 // 重置 DMA 计数,准备下一帧 DMA_SetCurrDataCounter(DMA1_Channel5, RX_BUF_SIZE); DMA_Cmd(DMA1_Channel5, ENABLE); } }逻辑说明:DMA_GetCurrDataCounter返回的是「还剩多少字节没搬」,用总容量减去它,就是已经搬进来的字节数,也就是本帧长度。这个计算方式比维护一个计数器变量更可靠,因为它直接读硬件寄存器,不受中断延迟影响。清标志那两行tmp = USART1->SR; tmp = USART1->DR;是血泪经验,用库函数USART_ReceiveData也行,但必须保证读 DR 的动作发生,否则标志清不掉。
参数上,rx_flag用volatile修饰,因为它在中断里改、在主循环里读,不加编译器可能优化掉。rx_len同理。处理完数据后记得把rx_flag清零,否则主循环会重复处理同一帧。
3. 发送链路与主循环:把收到的帧原样回发
3.1 DMA 发送配置与阻塞式发送的取舍
接收用 DMA,发送也可以走 DMA。USART1_TX 对应 DMA1 通道 4,配置和接收类似,只是方向反过来(内存->外设)。但发送有个细节:DMA 发送完成中断(TC)要开,否则你不知道什么时候能发下一帧。
void uart1_send_dma(uint8_t *data, uint16_t len) { DMA_InitTypeDef dma; DMA_Cmd(DMA1_Channel4, DISABLE); while (DMA1_Channel4->CCR & DMA_CCR_EN); // 等通道真正关闭 dma.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR; dma.DMA_MemoryBaseAddr = (uint32_t)data; dma.DMA_DIR = DMA_DIR_PeripheralDST; // 内存->外设 dma.DMA_BufferSize = len; 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_Normal; dma.DMA_Priority = DMA_Priority_Medium; dma.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel4, &dma); USART_DMACmd(USART1, USART_DMAReq_Tx, ENABLE); DMA_Cmd(DMA1_Channel4, ENABLE); }逻辑说明:发送前先关通道再重新初始化,是因为DMA_BufferSize在通道使能状态下不可写。while (DMA1_Channel4->CCR & DMA_CCR_EN)这行是等硬件真正把通道关掉,有些情况下DMA_Cmd返回了但通道还没停,直接改寄存器会失效。发送方向DMA_DIR_PeripheralDST表示外设是目标,数据从内存流向 DR。
如果对发送实时性要求不高,用阻塞式USART_SendData逐字节发也行,代码更简单,但发长帧时会占住 CPU。我一般对短应答用阻塞,对长数据用 DMA。
3.2 主循环里的帧处理与回发
主循环不碰中断里的缓冲区,只检查rx_flag,处理完再清标志。这样中断和主循环职责清晰,不会出现处理到一半被新数据覆盖的情况。
int main(void) { uart1_init(115200); while (1) { if (rx_flag) { // 此时 rx_buf 里是完整一帧,rx_len 是长度 // 示例:原样回发 uart1_send_dma(rx_buf, rx_len); rx_flag = 0; // 处理完再清,防止丢帧 } } }逻辑说明:rx_flag在中断里置 1,主循环检测到后处理数据,处理完清 0。这里有个时序问题:如果主循环处理期间又来了一帧,中断会再次置rx_flag,但rx_buf可能被新数据覆盖。稳妥做法是加双缓冲,或者处理速度足够快。对 115200 波特率、帧间隔几十毫秒的场景,单缓冲通常够用。
参数上,uart1_send_dma的data指针指向rx_buf,发送期间rx_buf不能被改写。如果主循环紧接着又收到新帧,DMA 发送还没完成,就会冲突。所以要么等发送完成再允许接收,要么用独立的发送缓冲区拷贝一份。
4. 避坑排查:IDLE 中断收不到数据的五种情况
4.1 现象:中断进不去,rx_flag 永远是 0
原因通常是 NVIC 没配、IDLE 中断没开,或者 DMA 通道选错。排查顺序:先确认USART_ITConfig(USART1, USART_IT_IDLE, ENABLE)调用了,再确认NVIC_Init里USART1_IRQn使能了,最后查 DMA 通道号——USART1_RX 必须是 DMA1 通道 5,写成通道 4 或 6 都收不到。用调试器在USART1_IRQHandler入口打断点,进不去就是这三处之一。
4.2 现象:中断反复触发,程序卡死
这是 IDLE 标志没清干净。只读 SR 不读 DR,标志位不会自动清。解决就是进中断后先读 SR 再读 DR,两行都要有。用库函数的话,USART_GetITStatus读的是 SR,之后还得调一次USART_ReceiveData读 DR。有些人只调了USART_ClearITPendingBit,对 IDLE 标志无效,因为 IDLE 不是普通的中断挂起位,它靠读 DR 序列清除。
4.3 现象:收到的数据长度不对,总是差一两个字节
多半是DMA_GetCurrDataCounter读取时机问题。如果在 DMA 还在搬运时就读,剩余计数在变,算出的长度就不准。正确做法是先DMA_Cmd(DISABLE)停掉通道,再读计数。另外,如果帧尾有 CRC 或换行符,长度会包含它们,解析时要按协议扣掉。还有一种情况是波特率不匹配,收到的字节数看着对但内容是乱的,用示波器量一下 TX 波形周期确认波特率。
4.4 现象:第一帧正常,后续帧全乱
典型原因是 DMA 没有重置计数。Normal 模式下 DMA 搬完BufferSize个字节就停了,如果不调DMA_SetCurrDataCounter重新装填,下一帧数据进不来。解决就是在 IDLE 中断里处理完长度计算后,立刻重置计数并使能通道。注意重置要在关闭通道的状态下做,顺序是:关通道 -> 读计数算长度 -> 重置计数 -> 开通道。
4.5 现象:高速率下丢帧或数据错位
115200 以上波特率时,如果中断处理里做了耗时操作(比如串口打印调试信息),可能错过下一帧的起始。IDLE 中断服务函数要尽量短,只做长度计算和标志置位,数据处理丢到主循环。另外,DMA 和 CPU 同时访问rx_buf时,如果主循环正在读而 DMA 正在写,会读到半新半旧的数据。用双缓冲或者处理前先关 DMA 能规避。还有一种隐蔽情况:rx_buf定义成局部变量,DMA 访问的是栈地址,函数返回后栈被复用,数据就花了,必须定义成全局或静态。
5. 进阶:用双缓冲和超时兜底把稳定性拉满
单缓冲在大多数场景够用,但如果你做的是连续数据采集,帧间隔可能短到几毫秒,主循环还没处理完下一帧就来了。这时候双缓冲(Ping-Pong)是标准解法:准备两块缓冲区,DMA 收满一块后自动切到另一块,IDLE 中断里只切换指针,主循环处理另一块。STM32F103 的 DMA 支持双缓冲模式(DMA_Mode_Circular配合DMA_MemoryBaseAddr切换),但更通用的做法是手动管理两个缓冲区和状态标志。
uint8_t rx_buf_a[RX_BUF_SIZE]; uint8_t rx_buf_b[RX_BUF_SIZE]; uint8_t *active_buf = rx_buf_a; // DMA 当前写入的缓冲 uint8_t *ready_buf = NULL; // 待主循环处理的缓冲 volatile uint16_t ready_len = 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; DMA_Cmd(DMA1_Channel5, DISABLE); uint16_t len = RX_BUF_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); // 切换缓冲:当前写入的变成待处理,另一块接管接收 ready_buf = active_buf; ready_len = len; active_buf = (active_buf == rx_buf_a) ? rx_buf_b : rx_buf_a; // 把 DMA 目标地址指向新缓冲 DMA1_Channel5->CMAR = (uint32_t)active_buf; DMA_SetCurrDataCounter(DMA1_Channel5, RX_BUF_SIZE); DMA_Cmd(DMA1_Channel5, ENABLE); } }逻辑说明:DMA1_Channel5->CMAR是内存地址寄存器,直接改它就能切换 DMA 写入目标,不用重新DMA_Init。切换后ready_buf指向刚收满的缓冲,主循环处理它时,DMA 已经在往另一块写了,互不干扰。ready_len记录该缓冲的有效长度。
但双缓冲也有边界:如果主循环处理速度跟不上接收速度,ready_buf还没处理完就被下一次切换覆盖。这时候需要加一个「缓冲忙」标志,或者用环形缓冲队列。我一般会在主循环处理前检查ready_buf是否非空,处理完置空,中断里发现ready_buf非空就丢弃当前帧或覆盖,具体策略看业务能不能容忍丢帧。
另一个兜底手段是超时检测。IDLE 中断依赖线路空闲,如果对方发送时字节间有微小间隔但没到空闲阈值,可能一帧被拆成两段。常见做法是在 IDLE 中断里启动一个定时器,如果超时时间内没有新帧,才认为一帧真正结束。STM32F103 的 SysTick 或 TIM 都能做,阈值设成 3 到 5 个字节传输时间。这个技巧在对接某些时序不规范的设备时特别管用。
验证方法上,我习惯用串口助手发递增长度的测试帧,从 1 字节到 200 字节,看回发长度是否一致;再发连续帧,间隔从 100ms 逐步压到 1ms,观察有没有丢帧或错位。示波器抓 TX 和 RX 波形,确认空闲中断触发点正好在帧尾。从那以后我每次配串口,都强制走一遍「短帧、长帧、连续帧、高速率」四组测试,少一组都不敢说调通了。希望帮到你。
本文还有配套的精品资源,点击获取