我一直跟刚接触STM32F103的朋友强调:串口接收,你把USART_IT_RXNE和USART_IT_IDLE这两个中断吃透了,整个串口通信的底子就稳了。很多人在第一步就搞混了,写出来的代码要么一个字节进一次中断被拖死,要么不定长帧永远卡不准边界。我之前调一个红外测距模块的串口协议,就因为在两者之间选了错的那个,数据对不上、帧边界飘忽不定,折腾到后来还是回到参考手册,从SR寄存器的比特位开始一点点抠,才算把问题彻底理顺。
这篇文章就把这两个中断从寄存器层到实战代码讲透,包括它们各自的触发时机、适合的场景、标准库和HAL库的写代码差异,以及几个我实测踩过的高频坑。不管你是在做传感器数据读取、RS485总线通信,还是调试第三方模块,这篇文章都能让你少走弯路。
1. 两个中断的触发机制:从SR寄存器看本质差异
在写代码之前,先把两者在硬件层面的“触发逻辑”搞清楚。USART的状态寄存器USART_SR里,第5位是RXNE,第4位是IDLE,这两位都对应着中断标志,但置位的时机完全不同。
1.1 RXNE:每收到一个字节就触发一次的事件
RXNE的含义是Receive data register not empty,也就是接收数据寄存器非空。它的置位过程是这样的:USART的接收移位寄存器把一帧数据(起始位+8位数据+停止位)完整移位结束后,硬件会把整个字节搬运到接收数据寄存器USART_DR里,同时把RXNE置1。如果你使能了USART_IT_RXNE,中断控制器就会立刻收到请求,进入中断服务函数。
这句话翻译成人话就是:串口每进来一个字节,RXNE就置位一次,中断就触发一次。来一个字节进一次中断,再来一个字节再进一次中断。如果你在115200波特率下接收数据,算下来大约每86.8微秒就有一个字节进来,也就是说你的CPU几乎每100微秒就要被打断一下。如果这时候你还在中断里做数据处理、协议解析、甚至打印日志,那系统基本就废了。
正因为这样,RXNE中断适合的数据特征是“字节数量少、数据到达频率低、需要逐字节即时响应”的场景。比如接收一个单字节命令、拿串口当控制接口点个灯、读取一个状态寄存器,这种需求用RXNE最直接也最省事。
1.2 IDLE:总线空闲这个“停顿”才是信号
IDLE的含义是Idle line detected,也就是空闲线路检测。它不关心你到底收到了几个字节,它关心的是“总线上有没有出现一段空闲状态”。
具体置位过程是:串口在一直接收数据的过程中,如果检测到总线上出现连续的高电平,并且这段高电平的时长超过了一个完整字符帧的时间(包括起始位、数据位和停止位),硬件就认为线路进入了空闲状态,于是把IDLE位置1。如果你使能了USART_IT_IDLE,就会触发一次中断。
关键点来了:IDLE不是每来一个字节就触发的,它是在一段连续数据传输结束、总线“没人说话”的时候触发一次。这个特性天然适合做不定长协议的帧结束判断。比如说设备A给设备B发了一串命令,什么时候算发完了?看有没有idle,也就是总线停顿了,停顿那一刻就是这一帧数据的边界。你在IDLE中断里去看接收缓冲区,拿到的就是一整包完整的数据。
这里有个容易忽略的细节:如果串口刚使能接收,总线上就处于空闲状态,IDLE标志是有可能被立即置位的。所以在初始化代码里使能IDLE中断后,建议马上读一次USART_DR,把这个误触发的标志清掉,否则你会在上电后收到一帧长度为0的空数据。
1.3 一张表看清触发条件、标志位与中断服务频率
我把两个中断最核心的差异整理成一张表,方便你对照着做选型:
| 对比项 | USART_IT_RXNE | USART_IT_IDLE |
|---|---|---|
| SR寄存器标志位 | Bit5 RXNE | Bit4 IDLE |
| 触发时机 | 接收数据寄存器非空 | 检测到总线空闲 |
| 触发频率 | 每收到1个字节触发1次 | 每收到1整段数据后的空闲才触发1次 |
| 核心用途 | 逐字节接收、定长协议拼帧 | 不定长协议帧结束判断、配合DMA接收 |
| CPU负担 | 高,高波特率下很容易被拖垮 | 低,一帧数据只进出中断一次 |
| 是否附带数据 | 有,读USART_DR即可取到该字节 | 无,IDLE中断本身不携带数据 |
| 清除方式 | 读USART_DR自动清除 | 先读USART_SR再读USART_DR |
| 典型搭配 | 单字节命令、低速、少量数据 | DMA、大缓冲、不定长帧 |
这张表看明白了,下面讲代码才有意义。记住一句话:RXNE是“有货了,快来取”,IDLE是“货送完了,来结账”。
2. 场景选型:什么项目用RXNE,什么项目必须上IDLE
我见过很多初学者,拿到项目就默认用RXNE接收,结果数据量一大就出现丢字节、乱码、忙不过来等各种问题。其实选型没那么玄乎,主要就看你的协议帧长不固定、数据量多大、CPU有没有别的事要干。
2.1 定长协议用RXNE逐字节拼装帧
如果你的协议是固定长度的,比如每次通信都是6个字节,或者8个字节,那用RXNE逐字节接收是完全没有问题的。做法也比较朴素:在中断里把每个字节收下来存进缓冲区,同时用一个计数器累加,等到计数器达到预期的帧长度,就置一个“帧接收完成”的标志,主循环里看到标志后做解析。
这种做法对CPU的消耗取决于波特率和帧长。假设115200波特率下你一次发8个字节,大约需要0.7毫秒,在此期间中断触发8次,每次中断服务函数执行几十个时钟周期,整体开销还能接受。但要注意,如果协议里还有应答、超时重发机制,主循环必须能在两帧数据间隔内完成处理,否则缓冲区会乱掉。
2.2 不定长协议用IDLE卡帧边界
再来看不定长协议。很多工业级设备、传感器模块、GPS模块走的就是这种协议:帧头+长度字段+数据+校验,但总长度可能每次都不同,甚至有时断言固定长度的数据也没法保证准时到达。这种情况下,用RXNE逐字节接收最大的难点是:你怎么知道这一帧结束了?
你可以用超时判断,比如每收到一个字节后启动一个定时器,超过5毫秒没有新字节到达就认为这一帧结束。这个方案能工作,但它额外占用一个定时器资源,而且超时时间设置得不好还会把两帧连续的数据误判成一帧。相比之下,硬件自带的IDLE检测简直就是为这种情况量身定做的——总线一空闲,IDLE标志置位,中断立刻触发,你只需要在中断里读取缓冲区长度即可。
这里要注意一个取舍:如果你既想用RXNE逐字节处理,又想在帧结束的时候用IDLE做判断,那就要同时使能两个中断。但我的实际经验是,RXNE和IDLE同时使能后,中断服务函数里要分清两个逻辑分支,代码结构会稍微复杂一点,而且高波特率下RXNE仍然会频繁打断CPU。所以如果数据长度比较大,我更推荐下面这种黄金组合。
2.3 为什么IDLE和DMA是串口接收的黄金搭档
这是我在实际项目中使用最多的方案:DMA + IDLE。DMA负责把USART接收寄存器里的数据自动搬运到内存缓冲区,不需要CPU参与,一字节一字节地搬;而IDLE中断负责在一帧数据接收完成后通知CPU去处理。这套组合的好处有三点:
- CPU彻底解放。数据搬运过程完全由DMA硬件完成,CPU只有在整帧数据到齐后才进一次中断,CPU占用率几乎可以忽略不计。
- 不会因为中断响应不及时而丢数据。
RXNE方式下,如果CPU被更高优先级的中断抢占,USART_DR里的数据没来得及读走,下一个字节就会覆盖旧数据,造成Overrun错误。DMA方式下,数据会按顺序被搬到内存,只要你DMA缓冲区空间够大,就不会丢。 - 帧边界判定非常干净。IDLE触发时,通过DMA当前剩余计数器和缓冲区总大小的差值,可以精确计算这一帧的长度。
我后面第3章会给出这套方案的完整标准库示例代码,你照着抄就能用。
提示:如果你的数据帧长度可能超过DMA缓冲区大小,比如一个超长协议,IDLE不会在DMA缓冲填满时自动帮你分帧。这种情况建议把DMA缓冲区开大一些,或者在DMA传输完成中断里做数据转储,然后把缓冲清零继续接收。别让DMA缓冲溢出,否则前边收到的数据会被覆盖。
3. 标准库代码实现:从最小RXNE到DMA+IDLE
下面给出标准库的完整实现。说实话,用标准库操作F103其实是学习串口底层机制最好的方式,因为你能直观看到寄存器的变化;等你把逻辑吃透了,再切到HAL库也就没障碍了。
3.1 RXNE中断的最小可用工程
先来最简单的RXNE中断接收。初始化部分大家应该都会,我用最常用的USART1、PA9/PA10引脚做示例:
void USART1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; /* TX */ 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; /* RX */ 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_ITConfig(USART1, USART_IT_RXNE, 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); } uint8_t rx_buffer[64]; volatile uint16_t rx_index = 0; void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { /* 读DR的同时会自动清除RXNE标志 */ rx_buffer[rx_index++] = USART_ReceiveData(USART1); } }这段代码的逻辑就是把每个收到的字节压进缓冲区。实际项目里你可以在rx_index达到某个阈值时置一个“帧完成”标志,主循环去处理。但这有个隐患:如果上位机发来的数据长度不确定,“收满多少算一帧”这个条件就很难写,这也是我前面说的RXNE适合定长协议的原因。
3.2 IDLE中断的标志位清除:最大的坑
接下来看IDLE中断。初始化部分和RXNE版本几乎一样,只把中断类型改为USART_IT_IDLE,然后再加上一条“使能后立即清一次标志”的操作,防止串口刚上电就误触发。
USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); /* 清掉上电后可能误置位的IDLE标志 */ USART_ReceiveData(USART1);中断服务函数写法如下:
void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) != RESET) { /* 清除IDLE标志:先读SR,再读DR */ USART_ReceiveData(USART1); /* 此时rx_len是上一次累计的有效字节数,可以在这里处理整帧数据 */ process_frame(rx_buffer, rx_len); rx_len = 0; } }这里最核心的坑就在清标志这一行。很多新手以为清中断标志就是调用某个USART_ClearITPendingBit函数,但实际上IDLE标志的清除必须满足“读USART_SR,然后读USART_DR”这个顺序。USART_GetITStatus内部已经帮你读了USART_SR,所以之后再调用一次USART_ReceiveData(USART1)读DR,标志就清了。如果你直接调用USART_ClearITPendingBit(USART1, USART_IT_IDLE),你会发现中断一直在触发,CPU被卡死在中断里出不来,别问我怎么知道的。
顺便说明一下,IDLE中断本身不携带数据,所以这里读DR只是为了清标志,读出来的值是无效的,不用保存。
3.3 加DMA之后刷卡整帧数据
接下来是重头戏,DMA + IDLE不定长接收。DMA通道复用关系先记好:USART1_RX对应DMA1的Channel5,USART3_RX对应DMA1的Channel3,USART2_RX对应DMA1的Channel6。这里以USART1为例。
#define RX_BUFFER_SIZE 256 uint8_t rx_buffer[RX_BUFFER_SIZE]; void USART1_DMA_Init(void) { DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel5); /* 外设基地址是USART1的数据寄存器 */ DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR; /* 内存基地址是接收缓冲区 */ DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)rx_buffer; /* 外设到内存 */ DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; /* 缓冲区大小 */ DMA_InitStructure.DMA_BufferSize = RX_BUFFER_SIZE; /* 外设地址不递增,因为每次都是读同一个DR寄存器 */ DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; /* 内存地址递增,数据依次存到缓冲区 */ DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; /* 8位数据宽度 */ DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; /* 普通模式,接收完指定长度后DMA通道自动关闭 */ 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通道 */ DMA_Cmd(DMA1_Channel5, ENABLE); /* 开启USART1的DMA接收请求,硬件会把DR里的数据自动搬到缓冲区 */ USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE); /* 同时使能USART1的IDLE中断 */ USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); /* 清除上电误触发标志 */ USART_ReceiveData(USART1); }中断服务函数:
void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) != RESET) { uint16_t len; /* 清除IDLE标志 */ USART_ReceiveData(USART1); /* DMA当前剩余计数器表示缓冲区里还有多少空位, 用总大小减去剩余空位,就是这一帧实际收到的字节数 */ len = RX_BUFFER_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); if (len > 0) { process_frame(rx_buffer, len); } /* 重新开始下一轮接收 */ DMA_Cmd(DMA1_Channel5, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel5, RX_BUFFER_SIZE); DMA_Cmd(DMA1_Channel5, ENABLE); } }这套代码的逻辑非常清晰:DMA一直在后台往rx_buffer里搬数据;总线上出现空闲时,IDLE中断触发,CPU去查DMA计数器,算出这一帧的实际长度,然后交给process_frame处理,最后把DMA重置到初始状态,等着下一帧到来。
有个细节必须重点提示:DMA模式用Normal而不是Circular。如果用循环模式,DMA在缓冲区写满后会自动跳回开头继续写,这时候计算“长度”就不能简单用总数减当前计数了,因为你不知道当前指针在哪、已经绕了几圈。普通模式下,只要你收到一帧后及时重置DMA,长度计算永远准确。
这个方案我自己在多个项目里验证过,115200波特率下CPU占用率几乎为零,哪怕你同时在跑LCD刷新、按键扫描、传感器采集,串口都不会丢字节。一句话,能用硬件做的事,永远比用中断频繁打断CPU要优雅得多。
4. 高频踩坑与完整排查链路
写代码容易,排错难。我在F103的串口上踩过的坑,大部分都集中在这几个地方,每一个都是拿实际项目的时间换来的教训。
4.1 ORE溢出:高波特率大数据量下RXNE的致命伤
先讲一个最常见的现象:程序跑着跑着,串口收到的数据突然中断了,或者收到一堆乱码。你去查USART_SR寄存器,发现ORE位(Overrun Error)被置1了。
ORE是怎么回事?前面说过,RXNE=1意味着USART_DR里有一个数据等着你去读。如果你没及时读走,下一个字节又接收完成了,硬件没办法把新数据放进一个还是满的DR寄存器,于是就把新数据丢了,同时置ORE位。而且注意,ORE置位后,哪怕你之后才去读SR、读DR,RXNE标志也不会自动恢复,USART相当于进入了“消化不良”的状态。
高波特率下这个问题尤其严重。115200波特率下,每字节间隔80多微秒,普通中断响应是来得及的;但如果你用的是460800甚至921600,或者系统里还有定时器、ADC等中断抢占,一个小小的调度延迟就可能导致溢出。
排查链路大致是这样的:
- 先用示波器或逻辑分析仪抓串口波形,确认实际发出的字节数确实被MCU接收了,排除发送端问题。
- 在中断服务函数里加一个统计变量:
volatile uint32_t overrun_cnt,在ORE置位时自增,最后看这个值是不是在增长。 - 如果确认是ORE,立刻想到是不是中断响应不及时;如果是,就把中断优先级调高,或者果断换
DMA + IDLE方案。
我的经验是,只要波特率超过230400,或者对方设备突然连续发送一大包数据,就不要再用RXNE硬扛了,直接上DMA。这不是代码能力问题,而是硬件设计上就已经天然决定了RXNE模式的瓶颈。
4.2 IDLE清标志流程对中断响应的影响
再来细说IDLE清标志的操作顺序。标准库中,USART_GetITStatus的实现是读取USARTx->SR,并把SR的值和对应中断的掩码做与运算,所以它本质上完成了“读SR”这一步。你只需要在这个判断成立之后,调用一次USART_ReceiveData(USART1)也就是“读DR”,两个步骤凑齐,IDLE标志就被清掉了。
如果你用的是HAL库,逻辑类似:__HAL_UART_CLEAR_IDLEFLAG(&huart1)宏会帮你完成读SR再读DR的操作。千万不要自己手写寄存器操作时,只读DR不读SR,或者只读SR不读DR,那样IDLE标志永远清不掉,中断会一直触发。
这个标志清除还有一个连带影响是:如果你在中断服务函数里做的事太耗时,比如process_frame里做了协议解析、数据拷来拷去、甚至调用了printf,中断服务函数执行时间过长,DMA在后台仍然在搬数据,下一帧数据又来了,照样可能引发问题。所以正确做法是中断里只做“标记+简单复制”或“标记+挪指针”,真正的解析全部放到主循环里。这也是我在4.1里说过的同一个原则:中断越短越好。
4.3 USART1和USART3在F103上的时钟、引脚与配置差异
这个坑特别隐蔽,因为代码看起来一模一样,但换到USART3上就是用不了。F103的USART1挂在APB2总线上,时钟最高72MHz;USART2和USART3挂在APB1总线上,时钟最高36MHz。如果你是用标准库的USART_Init,库函数会根据RCC得到的PCLK自动计算分频系数,问题不大。但如果你在某些代码里手动算波特率,比如直接写USART1->BRR = 0x2710之类的硬编码,或者从老项目复制了一段自己的波特率计算函数,那就很容易出问题——同一个PCLK参数,在USART1和USART3上的取值差了整整一倍,算出来的波特率也差一倍,结果就是串口完全乱码。
引脚差异也值得注意。USART1默认引脚是PA9( TX )/PA10( RX ),可以重映射到PB6/PB7;USART3默认引脚是PB10( TX )/PB11( RX ),重映射到PC10/PC11只在部分大容量型号上才能使用,而像F103C8T6这种中容量单片机,USART3基本就只有PB10/PB11这一组可用。我之前在一个项目里想当然地以为C8T6能把USART3重映射到PC10/PC11,结果编译能过,下载到板子上引脚就是没有波形,翻数据手册才发现问题。
DMA通道也有差异:USART1_RX在DMA1的Channel5,USART3_RX在DMA1的Channel3,USART2_RX在DMA1的Channel6。如果你把USART1的DMA代码改成USART3,只改外设地址和串口号,不改DMA通道,数据根本进不了缓冲区。这组对应关系建议截图保存,太容易记混了。
4.4 LIN模式/单线半双工下发送数据会误触发接收中断?
有人在网上问过“在LIN模式下串口发送出去的数据会触发接收中断吗”,这个问题其实问到点子上了。STM32的USART支持单线半双工模式,也就是把TX和RX合并到同一个引脚上。你使能这个模式后,发送数据时,硬件会把发送移位寄存器的输出回环到接收移位寄存器的输入,结果就是你发出去的数据,自己也会收到一份,自然就会触发RXNE中断。
这在RS485通信里尤其常见。RS485本质也是半双工,主设备发完数据后,总线上会立即回显自己发出的内容,如果你不处理,这个回显数据会被当成接收数据进缓冲区,污染协议解析。
处理办法主要有两种。一种是在发送期间暂时关闭RXNE中断或DMA接收,发送完毕后再打开,这是大多数RS485驱动的做法。另一种是发送完之后,延时一小段等待总线稳定,然后读一次USART_DR,把回显数据清掉,再重新启动接收。第一种做法更干净,不让多余数据进缓冲区,我强烈推荐。
这里插一句,如果只是普通全双工模式,不使能单线半双工,TX和RX是完全独立的两个引脚,只要你不在外部把这两个引脚短接,发送数据是绝不会触发接收中断的。所以“发送会不会误收”这个问题的本质,取决于你的硬件线路和软件配置有没有形成回环通路。
5. 几个实测中沉淀下来的使用习惯
文章最后,分享几个我调了几年串口后沉淀下来的使用习惯,不算什么高深理论,但每一条都是实打实省过时间的经验。
第一,新项目里串口接收我默认就是DMA + IDLE,除非协议非常简单、数据量极小,否则不再考虑纯RXNE中断。原因前面说过:CPU占用率低、不丢字节、代码结构也清晰。很多同事刚接手我的代码时会觉得多了一个DMA初始化“很复杂”,但看完逻辑后基本都会改口说真香。
第二,中断服务函数里坚决不做耗时操作。数据处理、协议解析、打印日志,这些全部放到主循环或任务里做。中断只负责置标志、算长度、重置DMA。这个习惯帮我避开了一堆“幽灵BUG”——最典型的就是程序运行一段时间后串口无响应,复位一下又好了,多半就是中断里处理太久导致溢出。
第三,调试串口接收问题时,第一步永远是先看USART_SR寄存器的值,而不是猜代码逻辑。RXNE、IDLE、ORE这几个位一亮,问题基本就定位了一半。学会看寄存器,比乱改代码有效得多。
第四,善用逻辑分析仪。如果波特率不对、波形不对,逻辑分析仪一眼就能看出来。一次完整的数据帧是啥样的、空闲时间有多长、数据位有没有错乱,这些东西靠肉眼盲调很难,但抓一次波形立刻明明白白。
串口这东西说难不难,说简单也不简单,关键在于把硬件机制理解透,然后基于硬件特性去设计软件方案。RXNE和IDLE不是竞争关系,而是互补关系——一个管字节,一个管帧,配合DMA之后才能真正做到又稳又省心。希望这篇东西能帮你少走点弯路。