
搞嵌入式这些年跟串口打交道的时间估计占了三分之一。尤其是 STM32F103 这颗芯片串口用得比谁都勤但真正把接收中断用明白的人说实话不多。很多朋友一上来就是USART_IT_RXNE遇到接收不定长数据就头疼要么丢字节要么进不了中断折腾半天搞不清楚为什么。其实问题往往出在你没分清USART_IT_RXNE和USART_IT_IDLE这两个中断标志的本质区别。这篇东西我想好好聊聊这两个标志不光讲区别还把标准库和 HAL 库的配置方式、不定长数据接收的通用套路、以及我在实际项目中踩过的坑都翻出来。适合刚入门还分不清中断流程的新手也适合做了一段时间但一直被接收丢数据、协议拆包困扰的朋友参考。1. 先搞懂这两个中断标志位的本质区别1.1 RXNE一个字节到了就通知你RXNE的全称是 Read Data Register Not Empty翻译过来就是“接收数据寄存器非空”。只要 USART 从 RX 引脚上完整接收到一个字节硬件就会把这个标志位置 1同时自动把数据搬运到数据寄存器DR里。这时候你如果使能了USART_IT_RXNE中断CPU 就会立刻跳到中断服务函数让你把DR里的数据读走。这个中断的特点是“一字节一响应”。发多少字节就进多少次中断。数据量少、波特率不高的时候用着挺顺手逻辑也简单——来一个读一个读完清标志循环往复。但一旦数据吞吐量上来了比如一帧 100 字节波特率 115200CPU 就得高频次地进进出出中断频繁地压栈、跳转、读寄存器很浪费资源。更麻烦的是如果中断响应不够快下一个字节到了而前一个还没读走就会触发溢出错误ORE直接把数据搞丢。我自己的经验是RXNE 适合“少量数据 实时性要求高”的场景比如接收按键命令、控制指令这种短帧数据。你要是拿它去做大块数据透传多半要吃苦头。1.2 IDLE总线空闲才通知你IDLE这个标志就完全不一样了。它检测的是 RX 线上的一整段空闲状态——当接收完一个字节之后RX 线持续保持高电平空闲电平超过一个帧的时间硬件就会认为“这一轮数据传输告一段落”然后把 IDLE 标志位置 1。如果你使能了USART_IT_IDLE中断这时候才进一次中断。所以 IDLE 中断的特点是“一帧数据收完才通知你”而不是来一字节通知一次。它天然适合处理不定长数据帧发送方发完一帧数据后总线必然有空闲时间硬件检测到这个空闲就告诉你“可以处理了”。你只需要在中断里判断当前缓冲区内收了多少字节就知道这一帧的长度是多少不需要提前约定固定长度。1.3 选择思路定长、不定长、高负载低功耗那实际项目里怎么选我一般按需求分三类第一类是命令帧场景比如上位机发 8 字节定长指令控制电机这种用 RXNE 就行逐字节接收并计数到预定长度再处理。第二类是不定长协议帧比如 Modbus RTU、自定义的 AT 指令、传感器数据上传这种帧长不固定用 IDLE 中断做帧结束判断再合适不过。第三类是高速大数据场景比如把传感器采集的数据以 460800 波特率上传这种情况下逐字节中断会严重增加 CPU 负载老实上 DMA IDLE 中断的黄金组合后面我会专门讲。需要注意的是IDLE 不是“接收完一帧数据”的中断它只是告诉你“数据线上空了一段时间”。它不关心你收了几个字节只看有没有空闲。所以配合 RXNE 使用的时候一般思路是RXNE 中断里把数据挨个塞进缓冲区并计数IDLE 中断里判断“帧收完了”然后去解析缓冲区。两者分工明确一个负责“搬砖”一个负责“吹哨”。2. 接收中断的配置流程与实操代码2.1 标准库方式配置 RXNE 中断逐字节接收STM32F103 用标准库的朋友应该不少先来看看最常规的 RXNE 中断接收配置。初始化串口的基本套路不再赘述关键是中断的使能和标志位的清除方式。// 串口中断配置仅使能接收寄存器非空中断 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);中断服务函数里常规写法是void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { uint8_t data USART_ReceiveData(USART1); // 把 data 存入接收缓冲区 buffer[rx_index] data; // 注意读 USART_ReceiveData 这个动作本身就会清除 RXNE 标志 } }有两点必须提醒新手第一读USART_ReceiveData(USART1)这个操作本身就会自动清除 RXNE 标志位所以你不需要再调用USART_ClearITPendingBit。但如果你只是读取了状态寄存器而没有读数据寄存器标志是不会被清掉的那样就会一直在中断里出不来。第二如果你需要手动清除标准库提供的方法是USART_ClearITPendingBit(USART1, USART_IT_RXNE)本质上是通过读 SR、再读 DR 这个顺序实现的。别乱清清错了容易把还没处理完的数据丢掉。2.2 标准库方式配置 IDLE 中断不定长接收IDLE 中断的标准库配置和 RXNE 有一些本质区别。使能方式略有不同清除方式更是关键。// 使能空闲中断 USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); void USART1_IRQHandler(void) { // 处理 RXNE 中断逐字节接收 if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { uint8_t data USART_ReceiveData(USART1); buffer[rx_index] data; if (rx_index RX_BUFFER_SIZE) rx_index 0; // 防止溢出 } // 处理 IDLE 中断一帧数据接收完成 if (USART_GetITStatus(USART1, USART_IT_IDLE) ! RESET) { // 读 SR USART_GetITStatus(USART1, USART_IT_IDLE); // 读 DR这一步操作后 IDLE 标志才会被清除 USART_ReceiveData(USART1); frame_len rx_index; // 记录本次接收长度 frame_ready 1; // 通知主循环处理 rx_index 0; // 索引归零准备下一帧 } }重点来了IDLE 标志的清除方式非常特殊必须按照“先读状态寄存器 SR再读数据寄存器 DR”的顺序执行硬件才会把 IDLE 标志位清零。你在代码里看到的那两行USART_GetITStatus其实就是读 SRUSART_ReceiveData就是读 DR两个操作缺一不可。很多新手在这里直接套用USART_ClearITPendingBit(USART1, USART_IT_IDLE)以为能像清 RXNE 一样清掉 IDLE结果发现根本不工作。这个坑我见得太多了标准库里这个函数对 IDLE 标志的支持在不同固件库版本里表现不一致最稳妥的办法还是手动读 SR、再读 DR。2.3 HAL 库下的 RXNE 与 IDLE 处理差异用 CubeMX 生成 HAL 库工程的同学情况又有些不一样。HAL 库把中断分发封装得比较深接收使用HAL_UART_Receive_IT时内部回调函数处理的是UART_IT_RXNE走的是“一个字节一次回调”的路径// 开启接收中断每次接收一个字节 HAL_UART_Receive_IT(huart1, rx_data, 1); // 当中断接收到一字节数据后会调用这个回调函数 void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { buffer[rx_index] rx_data; // 重新开启下一次接收否则中断只会触发一次 HAL_UART_Receive_IT(huart1, rx_data, 1); } }这里有个新手极易忽略的细节HAL_UART_Receive_IT每次只能接收指定长度的数据接收完成后就会停止并不会持续接收下一批。所以你必须在回调函数里重新调用一次HAL_UART_Receive_IT把接收器重新“挂上”否则数据发过来就再也进不了中断了。这个机制跟标准库中断一进来就常开的状态完全不同很多人第一版代码往往只收一两个字节就再也不进中断多半就是这个原因。HAL 库处理 IDLE 中断稍显繁琐。CubeMX 默认生成的代码不使能 IDLE 中断需要手动开启__HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);然后在串口中断回调中判断标志位void USART1_IRQHandler(void) { uint32_t status huart1.Instance-SR; uint32_t data huart1.Instance-DR; if ((status UART_FLAG_IDLE) ! RESET) { // 清除 IDLE 标志 __HAL_UART_CLEAR_IDLEFLAG(huart1); // 处理帧接收完成逻辑 frame_len rx_index; frame_ready 1; } HAL_UART_IRQHandler(huart1); }在这里判断 IDLE 标志位之后一定要记得调用__HAL_UART_CLEAR_IDLEFLAG(huart1)。HAL 库里这个宏已经帮你完成了“读 SR 再读 DR”的清除流程但前提是你调用了它不调用的话标志位永远挂在那中断会一直在触发。3. 我最常用的不定长接收方案RXNE 逐字节缓冲 IDLE 断帧3.1 业务逻辑拆解我在做工业设备通信时最常用的方案就是 RXNE 中断负责收字节IDLE 中断负责断帧。这样做的好处非常直观无论是 5 字节的查询命令还是 80 字节的参数包上层协议都不需要关心帧长只要总线空闲了这一帧数据就“到了”。主循环只需要检查frame_ready标志然后去解析已经完整接收的缓冲区数据各司其职逻辑清爽。举个实际场景设备作为 Modbus 从机上位机用 ModbusPoll 软件周期读取传感器参数。Modbus RTU 帧的结束标志就是总线空闲 3.5 个字符时间。虽然标准做法是用定时器自己量 3.5 字符时间但在波特率固定、实时性要求不高的场景下直接用串口 IDLE 中断做帧结束判断效果也够用而且省一个定时器资源。很多产品级的简单实现其实这么干的不丢人。3.2 核心代码实现完整性起见贴一个我实际项目里精简过的接收代码架构很清晰标准库版本可以直接改改就能用#define RX_BUFFER_SIZE 256 volatile uint8_t rx_buffer[RX_BUFFER_SIZE]; volatile uint16_t rx_index 0; volatile uint16_t frame_len 0; volatile uint8_t frame_ready 0; void USART1_IRQHandler(void) { uint16_t tmp; // RXNE接收数据寄存器非空逐字节缓存 if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { uint8_t data USART_ReceiveData(USART1); if (rx_index RX_BUFFER_SIZE) { rx_buffer[rx_index] data; } else { rx_index 0; // 缓冲区溢出重置这里可以置一个错误标志 } } // IDLE总线空闲表示一帧接收完成 if (USART_GetITStatus(USART1, USART_IT_IDLE) ! RESET) { // 读 SR tmp USART1-SR; // 读 DR 清除 IDLE 标志 tmp USART1-DR; frame_len rx_index; frame_ready 1; rx_index 0; } } // 主循环中判断是否有完整帧 while (1) { if (frame_ready) { frame_ready 0; parse_frame((uint8_t *)rx_buffer, frame_len); } }这套代码的时序逻辑是上位机连续发一串数据RXNE 中断一个字节一个字节地往rx_buffer里塞每收一个字节rx_index加一。上位机发完这一帧后串口线上出现空闲状态硬件在空闲时刻产生 IDLE 标志进入 IDLE 中断分支把当前长度记录到frame_len置位frame_ready。主循环看到标志就去处理处理完继续等下一帧。整个流程没有用到延时也没有阻塞式的等待接收非常省心。3.3 两个容易踩的坑标志位清除顺序和临界区保护第一个坑是 IDLE 标志的清除顺序问题上面已经反复强调过。这里再补一个细节如果在USART_GetITStatus和USART_ReceiveData之间又来了新数据可能造成标志清除不彻底此时最好在清除前关闭串口中断清完再打开保证时序严格一致。不过实际工程中这种极端情况很少出现我通常直接在中断里顺序读两次寄存器就行不用额外关中断。第二个坑是缓冲区临界区问题。rx_buffer和rx_index在中断里被写入在主循环里被读取。如果主循环正读到一半恰好一帧完整数据到达并触发了 IDLE 中断rx_index已经被清零而主循环还在用旧的frame_len访问缓冲区就会出现数据不一致。稳妥的做法是在主循环里取帧信息时先关中断__disable_irq(); len frame_len; memcpy(tmp_buffer, (uint8_t *)rx_buffer, len); frame_ready 0; __enable_irq();或者只关串口中断影响更小。别觉得这一步多余数据量一上来踩过时序问题的人都知道这个保护多重要。4. 串口中断方案选型什么时候该用 DMA什么时候用 IDLE DMA4.1 中断接收 vs DMA 接收很多网友在群里问“串口接收到底用中断好还是 DMA 好”我的回答永远是看场景没有万能方案。RXNE 逐字节中断适合数据量小、帧短、逻辑简单的场景优点是响应快、实现直观缺点是高频中断浪费 CPU。IDLE 中断本身不搬运数据它只是告诉你“该去处理了”数据搬运还是要靠 RXNE 中断或者 DMA。DMA 接收则完全不同。你只需要配置好接收缓冲区和数据长度然后让 DMA 控制器自己把 USART 接收到的数据搬到内存里全程不占用 CPU。数据传输完成后 DMA 中断才介入处理效率极高。但 DMA 有个天然局限你必须提前知道要接收多少数据否则缓冲区满了它也不会主动停下而是默认覆盖或者溢出。串口 DMA 与 IDLE 中断配合使用可以说是 STM32 串口接收的“黄金组合”。思路很简单DMA 负责把数据从串口搬到缓冲区IDLE 中断负责判断当前这是第几个字节、一帧数据的结束位置在哪。这样既能享受 DMA 不占 CPU 的高效搬运又能解决 DMA 不知道帧长度的缺陷。4.2 IDLE DMA 组合的经典用法配置思路是这样的启用空闲中断让 DMA 以循环模式或正常模式接收数据正常模式设置一个较大的接收长度。当一帧数据到达后DMA 自动搬运接收过程中 CPU 完全不去管。帧数据结束后串口产生 IDLE 中断此时读 DMA 剩余计数寄存器用“缓冲区总长减去 DMA 剩余计数”就能算出来这一帧实际收了几个字节。代码核心部分如下// 假设 DMA 缓冲区长度 256 字节 #define DMA_BUFFER_SIZE 256 uint8_t dma_rx_buffer[DMA_BUFFER_SIZE]; // 开启 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); uint16_t received_len DMA_BUFFER_SIZE - remain; // 记录帧长度和帧数据 frame_len received_len; memcpy(frame_buffer, dma_rx_buffer, received_len); frame_ready 1; } }注意这里有个细节DMA 在正常模式下接收完DMA_BUFFER_SIZE字节后就会停止不会自动重新开始。所以如果你要连续接收多帧数据需要在处理完一帧后重新配置 DMA 通道、重新设置传输长度再开启 DMA。循环模式Circular Mode可以免去这个麻烦缓冲区会被循环写入但循环模式下你必须在 IDLE 中断里算出最新一帧的起止位置逻辑稍微复杂一些。新手建议先从正常模式练手。4.3 CAN 总线常见做法参考顺便回答一个经常被问的问题CAN 总线到底用中断接收还是 DMA 接收CAN 外设和串口不同F103 的 CAN 控制器带有硬件 FIFO先进先出队列接收报文时硬件会自动把数据压入 FIFO你可以选择中断方式在收到报文时立刻处理也可以选择查询方式在空闲时批量读取。实际项目中CAN 接收中断才是主流做法优先级高、响应快。F103 的 CAN 中断按邮箱/队列触发每次中断读取一帧报文开销很小根本不需要 DMA。DMA 在 F103 上反而很少用于 CAN 接收因为 CAN 报文最多 8 字节数据逐帧中断处理完全扛得住。串口因为数据有可能是大体积连续流所以才有 DMA 的需求。5. 串口开发避坑实录从烧写到驱动再到调试5.1 串口 1 和串口 3 的差异很多初学者做板子发现同一个程序放在串口 1 上一切正常换到串口 3 上就不对劲就开始怀疑是不是串口 3 坏了。其实 F103 的串口 1 和串口 3 硬件特性基本一致差异主要体现在时钟配置和引脚映射上。串口 1 挂载在 APB2 总线上时钟频率是 72MHz串口 2 和串口 3 挂载在 APB1 总线上时钟频率是 36MHz。波特率计算公式为BAUD fck / (16 * USARTDIV)计算波特率寄存器值时要注意用各自的时钟频率。很多人直接用串口 1 的配置方法去算串口 3 的波特率结果全错通信乱码甚至完全不通。同时要注意F103 的串口 3 重映射功能支持 PB10/PB11 和 PD8/PD9 两组引脚默认是 PB10/PB11。如果你用了重映射别忘了调用GPIO_PinRemapConfig(GPIO_Remap_USART3, ENABLE)同时使能对应引脚的时钟和复用功能这一步漏掉引脚输出就是错乱的。5.2 LIN 模式下发送数据会不会触发接收中断这个问题很有代表性群友经常问“我开启了 LIN 模式发送出去的数据会不会自己触发 RXNE 中断”答案是正常情况下不会前提是你没有把 TX 和 RX 外部短接硬件也没有配置回环模式。LIN 模式本质上是单线串口F103 的 LIN 控制器在半双工通信时有自己的收发切换机制。当主机发送字节时发送移位寄存器把数据送到 TX 线上接收路径同时也会做一定的采样但硬件会判定这些字节为“自我发送”而不去置位 RXNE。只有外部设备确实回发了数据到 RX 线上RXNE 才会被触发。但有一个非常容易踩的坑有些最小系统板为了提高兼容性在板载电路里把 TX 和 RX 用 0 欧电阻做了短接尤其是某些串口小模块这种板子上你发数据就会自己收一份看上去就像发送触发了接收中断。排查方法很简单只发送一个字节看 RXNE 标志是否被置位如果置位了查硬件回环和外部短接。5.3 串口烧写失败和 CH340 驱动问题新板子第一次用串口烧写程序失败这是 F103 开发中最常见的问题。串口 ISP 下载的基本流程是BOOT0 拉高、BOOT1 拉低然后复位芯片进入系统存储器模式这时候用串口工具给 PA9/PA10 发固件。烧写失败十有八九是这几个原因第一是 BOOT0 没有在复位前拉高。有些板子 BOOT0 是拨码开关如果你在程序运行状态拨过去但不复位芯片不会进入 ISP 模式。第二是串口芯片驱动不对比如用了 CH340 芯片但驱动装的是 CP210x 的驱动识别出来的端口号是乱的。CH340 驱动一定要去正规渠道下装完看设备管理器里是否多出一个“USB-SERIAL CH340”端口。第三是 PA9/PA10 被外部电路干扰某些板子如果串口引脚上接了其他设备也会导致下载中途失败。另外 DAP 下载失败跟串口烧写失败是两码事。DAP 下载失败通常是 SWD 引脚被占用或者 BOOT0 拨到了 1 导致芯片进不了调试模式。串口烧写失败则主要排查 Boot 引脚和驱动。5.4 调试环境配置与工具推荐调试串口接收一个趁手的上位机工具能省一半时间。Windows 下我最常用的是 SSCOM 串口调试助手和友善串口助手胜在轻量、支持定时发送和 ASCII/Hex 切换。想要更专业的抓包分析可以用虚拟串口工具配合串口监听工具在不改变目标设备连接的情况下旁路监听串口数据流。做 Modbus 调试时ModbusPoll 几乎是标配它不仅能主动轮询从机还支持报文错误提示配合 F103 从机调试非常方便。在 Linux 环境下调试串口则需要重点检查 CH340 的驱动兼容性。很多 Linux 发行版从内核 4.x 开始就原生集成了 CH340 驱动插上就能识别为/dev/ttyUSB0。如果识别不到大概率是内核模块没加载手动执行modprobe ch341或重新编译内核驱动即可。调试建议方面第一步永远是用串口工具跟板子做最基础的回环测试板子收到什么就原样发回什么。发送端用随机长度的数据帧反复模拟通信场景看是否丢帧、乱码、误帧。别一上来就联调完整协议先把链路稳定性验证好能省下大量排查时间。6. 写在最后的一点经验多写几个串口项目就会发现接收中断的核心痛点永远不只是“能不能收到数据”而是“能不能稳定地、不丢地、正确地收到完整帧”。USART_IT_RXNE 和 USART_IT_IDLE 其实就是一对互补的搭档RXNE 让你不丢字节IDLE 让你知道帧在哪结束。把它们搭配好再辅以 DMA 搬运数据基本上覆盖了绝大多数串口通信需求。我个人在实际项目中还有一个习惯所有串口接收逻辑都尽量写到独立模块缓冲区、帧标志、解析函数彻底分离。这样做的好处是无论是响应上位机指令、对接蓝牙模块、还是采集传感器数据只需替换协议解析层底层中断和缓存逻辑永远不动非常稳。串口这东西看起来简单但一旦项目跑起来稳定和可维护比什么都重要。希望这篇总结能帮你在 F103 串口接收的路上少走几步弯路。