ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32F103串口接收:RXNE与IDLE中断区别及三种实战方案

STM32F103串口接收:RXNE与IDLE中断区别及三种实战方案 很多人在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。如果代码一直没去读DRRXNE就会一直保持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 标志位、清除方式与中断向量差异先看一张对比表方便对照记忆维度RXNEIDLE全称Receive data register not emptyIdle line detected置位条件USART_DR收到新数据接收线路上检测到空闲触发频率每收到1字节触发1次每次总线空闲触发1次典型用途逐字节搬运数据判断不定长帧接收结束中断使能位RXNEIEIDLEIE标志清除方式读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微秒触发一次中断频率接近46kHzCPU大量时间耗在中断进出和现场保护上主循环的实时性会明显下降。IDLE中断就不一样它只在帧结束时触发一次触发次数和帧长度基本无关。所以在大批量数据接收场景里IDLE中断天然比RXNE中断省资源。最理想的组合是让DMA负责把数据从USART_DR搬运进内存然后让IDLE中断通知CPU“这一帧结束了来处理吧”这样CPU几乎不用管中间的每个字节。2.3 数据接收场景的选型建议如果只是接收几个指令字节比如控制LED、读取传感器数据量不大帧结构也不复杂用RXNE中断逐字节存到数组再用IDLE判断帧结束是最稳妥的方案。如果一帧数据可能很长比如几十到几百字节还用RXNE逐字节中转功耗和CPU占用都不划算建议上IDLEDMA。如果是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 方案二RXNEIDLE组合接收把IDLE中断加进来后帧结束判断就变得很自然了。在同一个USART1_IRQHandler里同时判断RXNE和IDLEvoid 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直接读DRIDLE标志不一定能被清除。还有两个坑必须提醒。第一中断里如果先处理IDLE再处理RXNE顺序反了可能把最后一个字节丢掉。因为当RXNE和IDLE同时置位时先执行IDLE分支里的读DR操作会把还没读走的数据也顺手清掉然后再进RXNE分支时DR已经空了。所以代码里一定要保证先处理RXNE再处理IDLE。第二IDLE中断里不要做协议解析、printf、延时这类耗时操作最好只置一个标志位。真正处理数据放到主循环里做这样才能保证接收过程不被打乱。3.3 方案三IDLEDMA批量接收当数据量上来了逐字节读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次每个字节都要进中断短帧低RXNEIDLE每字节1次 帧末1次每个字节都要进中断中短帧中IDLEDMA帧末1次CPU几乎不参与搬运长帧/大吞吐较高数据完整性方面如果主循环因为某种原因卡顿了几毫秒纯RXNE方案很容易丢字节因为中断里的数组就那么大后面来的字节没地方放只能覆盖或丢弃。IDLEDMA方案不容易丢因为数据先落在内存里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所以通常要在前面加一行读取SRif (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就会产生竞争。用IDLEDMA时最好关掉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接模块的RXSTM32的RX接模块的TX。另外如果你用的是带自动下载电路的最小系统板有的板子在USB转串口和芯片之间加了三极管和电容下载时通过DTR/RTS控制BOOT0和复位这套时序对CH340芯片本身有要求。有些山寨模块的DTR/RTS电平不标准或者线材过长会导致自动下载失败。逐个排查下来烧写问题基本都能解决。把下载通道弄稳定了后面调试串口中断才能事半功倍。最后再分享一个自己的习惯。如果是做产品原型我通常先用RXNEIDLE方案因为调试方便、逻辑直观出问题也好定位。等协议稳定了、帧长度大起来了再切到IDLEDMA方案。切换时记得要把DMA缓冲区长度设置成大于最大帧长的值否则长帧会被截断。IDLE中断里不要做任何复杂处理只置标志、复位DMA解析放到主循环。这样既能把中断占用压到最低又能避免很多莫名其妙的时序问题。串口接收这件事表面的知识点就这两个中断位但真正考验人的是组合它们时的边界条件和顺序控制。把这些细节都磨平了你在F103上做任何串口通信都会顺手很多。
返回列表