很多人在STM32F103上做串口通信时,都会卡在同一个地方:中断到底选USART_IT_RXNE还是USART_IT_IDLE?数据发过来了,两个标志看起来都能触发中断,但接收效果却完全不一样。我早期也被这个问题坑过,后来逐个打断点、看寄存器,把两者的触发条件、清除方式摸了一遍,才算彻底弄明白。这篇文章就把这些经验写清楚,主要解决一个核心问题:RXNE和IDLE到底有什么区别,实际工程里该选哪个、怎么用。
内容主要面向用STM32F103做串口通信的开发者,不管是刚学中断的新手,还是已经在用中断但没搞懂IDLE的老手,都能在这篇文章里找到可以直接套用的代码和排查思路。我不会只丢给你一堆寄存器名,而是把数据到达、总线空闲这两个事件在整个串口接收链路里的位置讲明白,再给你三种实战方案,最后把串口1和串口3、LIN模式、串口烧写失败这些周边坑一起收拾掉。
1. 先搞清楚接收路径上的两个“哨兵”
1.1 RXNE:每到一个字节就喊一声
STM32F103的USART接收端,数据从RX引脚进来后,会先经过移位寄存器完成采样,再由硬件把完整字节搬进数据寄存器USART_DR。当这个搬运动作完成的瞬间,状态寄存器USART_SR里的RXNE位会被置1,意思是“数据寄存器里有新数据了,赶紧来读”。如果同时使能了RXNEIE,也就是接收中断使能,芯片就会立刻跳进USART_IRQHandler中断服务函数。
这里有一个容易理解偏的地方。RXNE不是“收到一个字节”这个抽象事件本身,而是“接收数据寄存器非空”这个硬件状态位。只是因为STM32F103的接收路径里,移位寄存器和数据寄存器是同步联动的,所以RXNE置位的时刻基本就等于一个字节接收完成。另一个要注意的是,RXNE标志在读走USART_DR之后会被硬件自动清除,你不需要手动写0。如果代码一直没去读DR,RXNE就会一直保持1,后续新到的字节就可能把旧数据覆盖掉,造成丢数据。所以标准做法是进入中断后马上读DR,把数据存到自己的缓冲区。
实际调试时,你可以利用串口调试助手的“定时发送”功能,连续发多个字节,观察RXNE中断的进入次数。只要波特率合理,每发一个字节,RXNE就触发一次。这也是我最开始验证这个标志位用的笨办法,效果很直观。
1.2 IDLE:线路空下来才喊一声
IDLE位代表的是“总线空闲”。也就是说,USART接收线路RX在接收到一个完整字节之后,又检测到RX线上持续了一段时间的高电平,硬件就判断线上没有数据活动了,于是把USART_SR里的IDLE位置1。如果使能了IDLEIE,同样会进入中断。
关键点在于,IDLE不是“这一帧数据接收完成”的标志,而是“线路由忙变闲”的事件标志。它和RXNE最大的区别是:RXNE可以每字节触发一次,IDLE只会在一次数据传输结束后的空闲瞬间触发一次。举个例子,上位机一次性发过来5个字节,如果字节间隔非常短,小于一个字节的时间,硬件会连续接收,这期间RXNE会置位5次,但IDLE只在第5个字节接收完成后的空闲时刻置位1次。如果上位机发完5个字节后停顿了超过一个字节时间,然后又发了3个字节,那么IDLE可能触发两次。
理解这个“可能触发两次”特别重要。很多人把IDLE简单理解成“一帧数据结束”,然后用它来做帧尾判断,但如果通信双方没有做协议层面的一帧间隔约束,IDLE就有可能在一帧数据中间触发。比如上位机程序里两个字符之间不小心delay了一下,你的下位机就会误判成两帧。
1.3 一个字节和一段空闲的时间线
为了把RXNE和IDLE的区别看明白,可以在脑子里画一条时间线。假设串口格式是8N1,即1个起始位、8个数据位、1个停止位。
T0时刻,RX引脚检测到起始位的下降沿,硬件开始采样。大约经过10个比特时间后,这个字节接收完成,RXNE置位。如果RXNEIE打开,马上进入中断。随后RX线继续保持高电平,硬件开始检测空闲状态。当RX线上的高电平持续超过一个比特时间,IDLE置位,如果IDLEIE打开,再次进入中断。如果两个字节之间的间隔特别短,也就是前一个字节的停止位还没结束多久,后一个字节的起始位就到了,那么IDLE不会在这两个字节之间触发。
从这条时间线可以很自然地得出一个结论:RXNE适合做逐字节处理,IDLE适合做帧边界判断。两者组合起来,既能知道每个字节来了,又能知道这一束数据暂时结束了。这也是后面实战方案的底层逻辑。
2. 它们的中断处理流程和工程选型
2.1 标志位、清除方式与中断向量差异
先看一张对比表,方便对照记忆:
| 维度 | RXNE | IDLE |
|---|---|---|
| 全称 | Receive data register not empty | Idle line detected |
| 置位条件 | USART_DR收到新数据 | 接收线路上检测到空闲 |
| 触发频率 | 每收到1字节触发1次 | 每次总线空闲触发1次 |
| 典型用途 | 逐字节搬运数据 | 判断不定长帧接收结束 |
| 中断使能位 | RXNEIE | IDLEIE |
| 标志清除方式 | 读USART_DR | 先读USART_SR,再读USART_DR |
| 中断服务函数 | 同一个USART_IRQHandler | 同一个USART_IRQHandler |
有一个事实被很多人忽略:RXNE和IDLE共用同一个USART中断向量。比如串口1,不管是RXNE还是IDLE触发,最终都会进入USART1_IRQHandler。区别只在于进入中断后,你通过读取USART_SR来判断到底是哪个标志置位了。所以你的中断服务函数里要同时判断两个标志位,并且一定要注意清除顺序,否则就会出现“标志一直为1,程序反复进中断”的经典故障。
具体到清除操作,RXNE在读取USART_DR后由硬件自动清除。IDLE则需要先读USART_SR,再读USART_DR,才能清除。这背后是一个历史设计问题:IDLE位不支持软件写0清除,只能通过这个“先读SR再读DR”的序列来复位。很多人在HAL库里看到__HAL_UART_CLEAR_IDLEFLAG,以为是写0,其实它内部也是做了类似寄存器读取操作,只是封装得比较隐晦。
2.2 中断响应频率与系统开销
工程选型离不开对系统开销的判断。我们可以简单算一下中断频率。以115200波特率、8N1格式为例,每传输一个字节实际需要10个比特(起始位+8数据位+停止位),那么一秒钟最多传输11520个字节,也就是说大约每86.8微秒触发一次RXNE中断。STM32F103主频72MHz,一次中断进出加上读DR、存数组的操作,大约1到2微秒,CPU占用在可接受范围内。但如果你把波特率提高到460800,每字节约21.7微秒触发一次,中断频率接近46kHz,CPU大量时间耗在中断进出和现场保护上,主循环的实时性会明显下降。
IDLE中断就不一样,它只在帧结束时触发一次,触发次数和帧长度基本无关。所以在大批量数据接收场景里,IDLE中断天然比RXNE中断省资源。最理想的组合是让DMA负责把数据从USART_DR搬运进内存,然后让IDLE中断通知CPU“这一帧结束了,来处理吧”,这样CPU几乎不用管中间的每个字节。
2.3 数据接收场景的选型建议
- 如果只是接收几个指令字节,比如控制LED、读取传感器,数据量不大,帧结构也不复杂,用RXNE中断逐字节存到数组,再用IDLE判断帧结束,是最稳妥的方案。
- 如果一帧数据可能很长,比如几十到几百字节,还用RXNE逐字节中转,功耗和CPU占用都不划算,建议上IDLE+DMA。
- 如果是Modbus RTU这种有帧间隔要求的协议,IDLE中断可以辅助判断3.5个字符时间的静默期,但要注意IDLE触发时间和波特率相关,可能需要配合定时器做更精确的帧超时判断。
- 如果系统对实时性要求极低,也可以不开RXNE中断,只轮询RXNE标志,用IDLE中断来唤醒CPU做批量处理。这种组合在一些极简项目里很常见。
3. 实战:三种不定长数据接收方案
3.1 方案一:纯RXNE中断逐字节拼帧
先看最基础的写法。用标准外设库配置串口1并打开RXNE中断,然后在中断服务函数里读DR存数组:
volatile uint8_t rx_buffer[256]; volatile uint16_t rx_len = 0; volatile uint8_t rx_complete = 0; void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t data = USART_ReceiveData(USART1); if (rx_len < sizeof(rx_buffer)) { rx_buffer[rx_len++] = data; } else { rx_len = 0; // 溢出保护,从头覆盖 } } }这个方案最大的问题一眼就能看出来:你根本不知道一帧数据什么时候结束。rx_len一直在涨,但什么时候该处理这帧数据?纯RXNE中断里没有帧结束的概念,你只能另外约定帧结构,比如固定长度,收到指定数量字节就代表一帧完成;或者用硬件定时器做超时判断,比如距离最后一个字节超过若干毫秒就认为帧结束。这两种做法都能用,但前者不灵活,后者要额外维护一个定时器,代码量并不少。
所以在实际项目里,纯RXNE中断只适合非常简单的“收到一个字节就反应”的场景,比如单个指令控制,不适合做不定长协议解析。
3.2 方案二:RXNE+IDLE组合接收
把IDLE中断加进来后,帧结束判断就变得很自然了。在同一个USART1_IRQHandler里同时判断RXNE和IDLE:
void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t data = USART_ReceiveData(USART1); if (rx_len < sizeof(rx_buffer)) { rx_buffer[rx_len++] = data; } else { rx_len = 0; } } if (USART_GetITStatus(USART1, USART_IT_IDLE) != RESET) { // 清除IDLE标志:先读SR,再读DR USART_ReceiveData(USART1); rx_complete = 1; } }这里的清除方式我用的是标准库的USART_ReceiveData(USART1),它本质上就是读一次DR。严格来说,要完整清除IDLE标志,必须先读SR再读DR。因为在这个分支里,进入之前已经通过USART_GetITStatus(USART1, USART_IT_IDLE)读了一次SR,所以紧接着读DR就能把IDLE清掉。这个逻辑是成立的。但如果你之前没有调用过USART_GetITStatus,直接读DR,IDLE标志不一定能被清除。
还有两个坑必须提醒。第一,中断里如果先处理IDLE再处理RXNE,顺序反了,可能把最后一个字节丢掉。因为当RXNE和IDLE同时置位时,先执行IDLE分支里的读DR操作,会把还没读走的数据也顺手清掉,然后再进RXNE分支时DR已经空了。所以代码里一定要保证先处理RXNE,再处理IDLE。第二,IDLE中断里不要做协议解析、printf、延时这类耗时操作,最好只置一个标志位。真正处理数据放到主循环里做,这样才能保证接收过程不被打乱。
3.3 方案三:IDLE+DMA批量接收
当数据量上来了,逐字节读DR的模式还是显得不够优雅。DMA可以在不占用CPU的情况下,把USART_DR里的数据连续搬运到内存数组。配合IDLE中断,CPU只需要在帧结束时来接收内存里的数据就行了。
初始化配置大致这样:
#define RX_DMA_SIZE 256 volatile uint8_t rx_dma_buf[RX_DMA_SIZE]; void USART1_DMA_RX_Init(void) { DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel5); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)rx_dma_buf; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = RX_DMA_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); USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE); DMA_Cmd(DMA1_Channel5, ENABLE); }IDLE中断函数里做的事情就不一样了,需要算一算这一帧到底收了多少字节:
void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) != RESET) { // 清除IDLE标志 USART_ReceiveData(USART1); // 计算当前DMA还剩多少没搬 uint16_t remain = DMA_GetCurrDataCounter(DMA1_Channel5); rx_len = RX_DMA_SIZE - remain; rx_complete = 1; // 处理完数据后复位DMA DMA_Cmd(DMA1_Channel5, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel5, RX_DMA_SIZE); DMA_Cmd(DMA1_Channel5, ENABLE); } }这段代码用的是Normal模式,也就是DMA搬到缓冲区满后自动停止。每帧数据来了,IDLE触发,计算当前长度,复位DMA,准备好接收下一帧。这种方式比纯RXNE方案好了很多,一帧100字节的数据,只进一次中断。
有一种更激进的做法是用Circular模式,让DMA像一个环形缓冲区一样一直转,数据满了自动覆盖。IDLE触发后通过DMA当前计数器的值判断最新数据在哪个位置。这个方案适合收发数据没有明确帧长度、但流量很大的场景,但对协议设计和内存管理要求更高,容易出现旧数据被覆盖后还没处理的尴尬。新手建议先从Normal模式开始,跑通了再考虑环形缓冲。
3.4 三种方案实测对比
我在一块STM32F103C8T6最小系统板上实测过三种方案,波特率115200,一帧50字节,主循环里什么都不做,只统计中断进入次数和接收完成时间:
| 方案 | 中断触发次数 | CPU参与方式 | 适合帧长 | 实现难度 |
|---|---|---|---|---|
| 纯RXNE | 每字节1次 | 每个字节都要进中断 | 短帧 | 低 |
| RXNE+IDLE | 每字节1次 + 帧末1次 | 每个字节都要进中断 | 中短帧 | 中 |
| IDLE+DMA | 帧末1次 | CPU几乎不参与搬运 | 长帧/大吞吐 | 较高 |
数据完整性方面,如果主循环因为某种原因卡顿了几毫秒,纯RXNE方案很容易丢字节,因为中断里的数组就那么大,后面来的字节没地方放,只能覆盖或丢弃。IDLE+DMA方案不容易丢,因为数据先落在内存里,CPU慢一点再去取也不影响DMA搬运。当然DMA缓冲区也会满,但缓冲区大小可以做很大,比手动数组更抗压。
如果你问我推荐哪个,我的建议是:调试阶段用方案二,逻辑简单,方便打日志;产品化阶段用方案三,省心省资源。方案一除非是极简单的单字节命令控制,否则不建议用在需要协议解析的地方。
4. 串口工程中的常见问题与排查技巧实录
4.1 串口1和串口3的使用差异
很多人把串口1的代码复制到串口3,改了个宏定义就以为完事了,结果发现根本不通。串口1和串口3至少有四处明显差异。第一,时钟总线不同。串口1挂在APB2总线上,串口3挂在APB1总线上,使能外设时钟时要分别调用RCC_APB2PeriphClockCmd和RCC_APB1PeriphClockCmd。第二,引脚位置不同。串口1默认在PA9/PA10,串口3默认在PB10/PB11,重映射后还可以到PC10/PC11。第三,中断号不同。串口1对应USART1_IRQn,串口3对应USART3_IRQn。第四,如果默认波特率配置里使用的是系统主时钟,但APB1分频器和APB2分频器不同,你计算波特率时传进去的PCLK就不一样。标准库的USART_Init会根据传入的USART_TypeDef来读对应的时钟,但前提是你初始化时钟时没有搞错总线。
我遇到过最隐蔽的坑是:用了串口3,但忘了开GPIOB时钟,结果引脚不输出数据。后来开着调试器跟踪才发现,GPIOB的时钟根本没使能,PB10/PB11处于浮空状态,自然发不出去。
4.2 发送出去的数据会触发接收中断吗
这个问题看起来有点反直觉,但答案是:在特定工作模式下,会的。普通的全双工模式,RX和TX是两个独立引脚,发送的数据不会回到接收路径。但在单线半双工模式下,USART的TX和RX在芯片内部被连接到了同一个引脚,发送数据时,这个引脚的电平变化会同时被接收模块采样到,于是你发出去的数据会触发RXNE中断。
LIN模式也有类似现象。LIN总线的收发器是单线制,很多实现里发送数据回环到接收路径是正常行为。如果你在LIN模式下还开着接收中断,那串口发送出去的数据确实会触发接收中断,这不是硬件坏了,也不是代码问题。解决思路很简单:发送期间临时关闭RXNE中断,等发送完成后再打开;或者在接收处理里对数据做过滤,区分哪些是自己发的,哪些是总线对端发的。调试RS485时尤其要注意,因为RS485半双工模式下,自发自收是常见现象,如果处理不好,收发同时来一堆中断,程序很容易乱。
4.3 IDLE中断标志清不掉的典型案例
IDLE清不掉是后台社区里最常见的求助帖内容。现象是:程序进入IDLE中断后,怎么也跳不出去,或者每发一帧数据进两次中断。排除掉使能配置问题外,九成原因是清除方式不对。
IDLE标志的清除序列是“先读USART_SR,再读USART_DR”。如果你的代码里直接对SR位操作想清零,或者只读DR,标志可能纹丝不动。用标准库时,USART_ReceiveData只读DR,所以通常要在前面加一行读取SR:
if (USART_GetITStatus(USART1, USART_IT_IDLE) != RESET) { volatile uint32_t tmp; tmp = USART1->SR; // 读SR tmp = USART1->DR; // 读DR rx_complete = 1; }有些人在中断里同时处理RXNE和IDLE,他们担心读DR会顺便清掉RXNE。这个担心是有道理的,但如果代码里已经先执行了RXNE分支,把数据读走了,RXNE状态已经清掉,再读DR就不会影响数据了。所以关键在于先RXNE后IDLE的顺序。如果反过来,就可能出现最后一个字节丢失,或者RXNE标志被误清的情况。
4.4 串口数据错乱、丢字节的排查思路
串口数据错乱的原因五花八门,但排查路径有章可循。我通常按下面这个顺序来:
- 第一步,确认波特率误差。用示波器看波形是最直接的办法,量一下一位的宽度,对照理论值。如果误差超过2%,在一些恶劣环境下就容易出乱码。
- 第二步,检查中断优先级。如果串口中断优先级设置偏低,被其他高频中断打断,就会出现接收期间的字节丢失。可以试着把串口中断优先级提高,或者减少其他中断ISR里的耗时逻辑。
- 第三步,查中断服务函数里的耗时操作。很多新手在串口中断里直接做数值运算、字符串拼接,严重拖长了中断服务时间。即使只有一个字节在等,也可能来不及读DR,导致数据被覆盖。原则上中断服务函数只做收数据和置标志,所有解析都放主循环。
- 第四步,检查DMA和中断是否冲突。如果开了DMA接收,又在串口中断里处理RXNE,两者同时在读DR,就会产生竞争。用IDLE+DMA时,最好关掉RXNE中断,让DMA全权负责数据搬运。
4.5 串口烧写失败、CH340驱动等外围问题
最后聊一个和接收中断无关、但每个用串口调试的人都会遇到的话题:串口烧写失败。很多朋友拿着CH340USB转串口模块给STM32F103下载程序,一接上就提示连接失败,问题通常是下面几个。
BOOT0和BOOT1引脚的电平配置不当。串口下载需要把BOOT0拉高、BOOT1拉低,然后复位芯片,这样芯片才会进入系统存储器引导模式。下载完要恢复BOOT0为低电平时再复位。如果模块上没有一键下载电路,你可能要手动跳线帽和复位按键,时序没配合好就容易失败。
CH340驱动方面,Windows 10以上系统一般能自动识别,但有时候会识别成未知设备,这时候需要去官网下载对应驱动手动指定安装,安装完最好重启一下设备。驱动的波特率参数也和程序烧录波特率有关,老版本驱动在某些国产CH340模块上对特殊波特率支持不好,可以试试把烧录用波特率调低到38400,成功率会高很多。
还有一种隐蔽情况是:模块的TXD和RXD接反了。ESP8266、树莓派这类板子通常标了RX/TX,但很多CH340模块上丝印是TXD/RXD,而且标注的是模块自身的引脚方向。不少人看了别人的接线图,直接把STM32的TXD接模块的TXD,结果数据发了个寂寞。正确接法是交叉连接:STM32的TX接模块的RX,STM32的RX接模块的TX。
另外,如果你用的是带自动下载电路的最小系统板,有的板子在USB转串口和芯片之间加了三极管和电容,下载时通过DTR/RTS控制BOOT0和复位,这套时序对CH340芯片本身有要求。有些山寨模块的DTR/RTS电平不标准,或者线材过长,会导致自动下载失败。逐个排查下来,烧写问题基本都能解决。把下载通道弄稳定了,后面调试串口中断才能事半功倍。
最后再分享一个自己的习惯。如果是做产品原型,我通常先用RXNE+IDLE方案,因为调试方便、逻辑直观,出问题也好定位。等协议稳定了、帧长度大起来了,再切到IDLE+DMA方案。切换时记得要把DMA缓冲区长度设置成大于最大帧长的值,否则长帧会被截断。IDLE中断里不要做任何复杂处理,只置标志、复位DMA,解析放到主循环。这样既能把中断占用压到最低,又能避免很多莫名其妙的时序问题。串口接收这件事,表面的知识点就这两个中断位,但真正考验人的,是组合它们时的边界条件和顺序控制。把这些细节都磨平了,你在F103上做任何串口通信都会顺手很多。