
我一直跟刚接触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_RXNEUSART_IT_IDLESR寄存器标志位Bit5 RXNEBit4 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到DMAIDLE下面给出标准库的完整实现。说实话用标准库操作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的Channel5USART3_RX对应DMA1的Channel3USART2_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是怎么回事前面说过RXNE1意味着USART_DR里有一个数据等着你去读。如果你没及时读走下一个字节又接收完成了硬件没办法把新数据放进一个还是满的DR寄存器于是就把新数据丢了同时置ORE位。而且注意ORE置位后哪怕你之后才去读SR、读DRRXNE标志也不会自动恢复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总线上时钟最高72MHzUSART2和USART3挂在APB1总线上时钟最高36MHz。如果你是用标准库的USART_Init库函数会根据RCC得到的PCLK自动计算分频系数问题不大。但如果你在某些代码里手动算波特率比如直接写USART1-BRR 0x2710之类的硬编码或者从老项目复制了一段自己的波特率计算函数那就很容易出问题——同一个PCLK参数在USART1和USART3上的取值差了整整一倍算出来的波特率也差一倍结果就是串口完全乱码。引脚差异也值得注意。USART1默认引脚是PA9( TX )/PA10( RX )可以重映射到PB6/PB7USART3默认引脚是PB10( TX )/PB11( RX )重映射到PC10/PC11只在部分大容量型号上才能使用而像F103C8T6这种中容量单片机USART3基本就只有PB10/PB11这一组可用。我之前在一个项目里想当然地以为C8T6能把USART3重映射到PC10/PC11结果编译能过下载到板子上引脚就是没有波形翻数据手册才发现问题。DMA通道也有差异USART1_RX在DMA1的Channel5USART3_RX在DMA1的Channel3USART2_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之后才能真正做到又稳又省心。希望这篇东西能帮你少走点弯路。