MicroPython实现UART空闲中断:树莓派Pico变长帧接收方案
2026/9/7 14:22:42 网站建设 项目流程

如果你之前一直在 STM32 上用“串口空闲中断 + DMA”收变长数据,第一次转到树莓派 Pico 上玩 MicroPython,你大概率会翻遍dir(UART)找那个叫UART.IDLE的东西。我当初就是带着这个惯性来踩坑的,最后发现 MicroPython 在 RP2040 官方固件上并不直接给你一个可以用的硬件空闲中断,真正稳定能用的触发源是UART.RX_ANY

这篇文章不会只丢一句“空闲中断要用定时器模拟”就完事。我会把接收空闲中断、发送空闲中断的硬件本质讲清楚,再给出两套能直接抄的切帧方案,一套基于RX_ANY + 软定时器,一套基于select/poll,最后用一个带 CRC16 的变长二进制协议,配合串口助手完整跑一遍收发验证。适合正在做变长指令解析、Modbus 类协议、传感器数据帧接收的 Pico 玩家,也适合想搞明白 MicroPython 中断边界在哪儿的嵌入式老手。

1. 先讲透:接收空闲和发送空闲在硬件层面对应的具体含义

1.1 接收空闲中断为什么是“变长报文切帧神器”

所谓接收空闲中断,指的是 UART 接收引脚在一段时间内没有出现新的起始位,总线保持闲置状态,硬件就在某个寄存器里置一个空闲标志位。这个“一段时间”通常是连续一个字节传输所需的时间,跟你当前波特率直接挂钩。

在 STM32 这类芯片上,IDLE 中断最经典的应用就是搭配 DMA:DMA 一直把接收到的数据搬运到内存,但 DMA 不知道这一帧数据什么时候结束,因为帧长度不固定。等总线空闲下来,硬件发现没数据来了,就触发 IDLE 中断,告诉你“一帧收完了,赶紧去 DMA 缓冲区里取货”。这是处理 Modbus RTU、AT 指令响应、GPS NMEA 报文这类变长协议时最省 CPU 的做法。

但到了 MicroPython 上,你不可能直接操作 DMA 通道和中断寄存器,除非你用底层 C 扩展。MicroPython 给到用户态的接口已经帮你把 DMA、FIFO、中断标志这些细节全部包起来了。所以我们要讨论的重点不是“怎么开硬件 IDLE 中断”,而是“怎么用 MicroPython 暴露的能力,复现出和空闲中断完全一样的切帧效果”。

1.2 发送空闲中断和“发送完成”是一回事吗

先说结论:在绝大多数 Cortex-M 内核的单片机上,发送方向并没有一个专门叫“发送空闲中断”的东西。你看到的中断标志通常是下面这几个的排列组合:

  • TXE:发送数据寄存器空,也就是数据已经从寄存器挪到移位寄存器里了;
  • TC:发送完成,移位寄存器里的最后一个位已经发出去了,TX 引脚回到空闲高电平;
  • FIFO 空:发送 FIFO 里没有待发送的数据了。

真正跟“发送线路空闲”语义最接近的,是 TC(Transmission Complete)中断。它意味着总线上最后一个停止位已经发完,线路落回高电平。RS485 自动收发转换、发送结束上报这类需求,依赖的其实就是这个时刻。

在 RP2040 的 MicroPython 固件里,uart.txdone()就是用来查询这个状态的。它返回True时,表示发送 FIFO 为空并且发送移位寄存器已经不再忙,相当于“发送线路已经空闲下来”。注意,它是查询函数,不是中断回调。如果你需要在发送结束后马上做动作,可以在主循环里轮询它,或者用定时器去检测。所以这篇标题里说的“发送空闲中断”,落到 MicroPython 里更准确的做法是“发送空闲检测”。后面我会专门演示它在 RS485 方向切换场景里的使用方法。

1.3 位时间、字节时间与空闲判定:先把数字算明白

做空闲检测,必须先会算字节时间。标准的 8N1 格式下,一个字节会占用 1 个起始位 + 8 个数据位 + 1 个停止位,一共 10 个位时间:

  • 9600 波特率:1 个位时间约 104.2 微秒,1 个字节约 1042 微秒,大概 1.04 毫秒;
  • 115200 波特率:1 个位时间约 8.68 微秒,1 个字节约 86.8 微秒。

如果你的协议是 Modbus RTU,规范里对帧间隔有明确要求:字符间空闲时间要大于 3.5 个字符时间,这样才能确认一帧结束。换算下来,9600 波特率时大约是 3.65 毫秒,115200 波特率时因为规范规定超过 19200 波特率后固定取 1.75 毫秒,所以帧间空闲检测阈值一般设 2 到 5 毫秒。

如果是你自己的私有协议,阈值怎么定就有讲究了。设短了,发送端只要中间稍微卡顿一下就会误判成一帧结束;设长了,接收端要等更久才能确认帧结束,实时性变差。我后面的实战代码里会给出一组实测推荐值,这里先记住一条硬规则:空闲时间阈值必须大于发送端连续字节之间的最大间隔,否则一定会切错帧。

2. Pico 官方 MicroPython 的中断能力:先用实测排除几个误区

2.1 machine.UART.irq 在 RP2040 上到底支持什么

在 RP2040 的官方 MicroPython 固件里,machine.UART.irq()我能稳定使用的是UART.RX_ANY这个触发条件。从名字也能看出来,它的语义是“接收 FIFO 里只要有任意字节到达,就触发一次中断”。

这和硬件空闲中断的差别非常关键:RX_ANY是每一个字节到达时都会触发,而空闲中断是“一段时间没有字节到达时”才触发。前者是逐字节的脉冲信号,后者是云消雨歇后的一声惊雷。所以,直接用RX_ANY做协议解析是不可行的,每个字节都打断一次,效率太低;但它非常适合作为“空闲检测”的启动信号,让我知道数据又开始流动了。

另外一个大家容易踩的误区是把UART.IDLE当成所有 MicroPython 固件天然支持的常量。实际上不同端口的实现差异很大:ESP32 等部分端口确实有UART.IDLE触发条件,但在 RP2040 官方固件上,如果你直接写uart.irq(trigger=UART.IDLE, handler=...),你会发现要么报告属性不存在,要么中断根本不会进来。不同社区固件、不同版本差异还不小,所以不要把你的方案建立在UART.IDLE上。

2.2 为什么很多教程里直接贴 UART.IDLE 会翻车

原因很简单:教程面向的平台不一样。很多写串口空闲中断的博主,用的是 ESP32 或者 STM32 移植版 MicroPython,这些平台在底层实现了对空闲中断的映射。而 Pico 的 RP2040,它的 UART 控制器是基于 ARM PL011 的,PL011 本身是有接收超时中断的,但 MicroPython 固件并没有把这个能力完整暴露到 Python 层。

PL011 协议里有一个接收超时中断(Receive Timeout),意思是接收 FIFO 非空,并且在后续一段时间内没有新数据进来时,会产生一个中断。这个行为上与“接收空闲中断”高度接近,但它默认是配合 DMA 中断用的。MicroPython 端口的machine_uart_irq处理逻辑里,官方固件主要实现了RX_ANY分支。所以不管底层硬件支持什么,用户态拿不到就是拿不到。确认一件事,别靠猜,直接查你当前固件的源码,或者先在 REPL 里看一眼UART模块到底暴露了哪些常量。

2.3 真正能落地的三种等效方案对比

既然拿不到硬件空闲中断,我们就得用软件逻辑去等效。我在 Pico 上实际验证过三种方案,各有各的适用场景。

方案触发机制最小空闲判定延迟适用场景
RX_ANY+ 单次软定时器每个字节触发中断,重启定时器定时器周期,约 1-10 毫秒通用变长协议切帧,推荐首选
select/poll轮询超时主循环轮询 UART 是否可读,超时代表空闲轮询周期,约 5-20 毫秒不想用中断回调,协议简单、帧率不高
第三方固件扩展 / C 扩展直接暴露硬件事件微秒级对实时性要求极高,建议换 C 语言

从工程角度,最推荐第一种。它既能吃到硬件中断“及时响应”的好处,又能用软件定时器自由调节空闲判断窗口,而且代码用 MicroPython 写出来以后,拿到其他支持UART.RX_ANY的板子上也能直接跑。

3. 完整实战:RX_ANY + 单次定时器实现接收空闲中断(附代码)

3.1 先看代码:一个可复用的 IdleFrameReceiver 类

下面这段代码我测过可以直接扔到 Pico 上跑。它的核心思路是:RX_ANY中断每收到一个字节就触发一次,我在回调里把字节读进预分配缓冲区,然后重新启动一个单次软定时器;只要数据流一直有数据,定时器就被反复重置;当数据流中断超过我们设定的空闲阈值,定时器回调触发,代表“接收空闲”,于是把缓冲区里的完整一帧交出去。

from machine import UART, Pin, Timer from micropython import alloc_emergency_exception_buf alloc_emergency_exception_buf(256) class IdleFrameReceiver: MAX_FRAME = 512 # 按你的协议最大帧长调整 def __init__(self, uart_id=0, baudrate=115200, tx_pin=0, rx_pin=1, idle_ms=10): self.uart = UART(uart_id, baudrate=baudrate, tx=Pin(tx_pin), rx=Pin(rx_pin)) self.idle_ms = idle_ms self.rx_buf = bytearray(self.MAX_FRAME) self.rx_len = 0 self.frame_ready = False self.on_frame = None self._timer = Timer() self._one = bytearray(1) def start(self): self.uart.irq(trigger=UART.RX_ANY, handler=self._on_rx) def _on_rx(self, uart): # 中断回调:只做读字节、写预分配缓冲区、重置定时器这三件事 while self.uart.any(): try: if self.uart.readinto(self._one): if self.rx_len < self.MAX_FRAME: self.rx_buf[self.rx_len] = self._one[0] self.rx_len += 1 except Exception: pass try: self._timer.init(mode=Timer.ONE_SHOT, period=self.idle_ms, callback=self._on_idle) except Exception: pass def _on_idle(self, timer): # 定时器到期,说明总线上暂时没有新数据 if self.rx_len: self.frame_ready = True if self.on_frame: frame = bytes(self.rx_buf[:self.rx_len]) self.rx_len = 0 self.on_frame(frame) def cancel(self): self._timer.deinit() self.uart.irq(trigger=0, handler=None)

使用的时候,你只需要在主程序里设置on_frame回调,然后循环等待即可:

receiver = IdleFrameReceiver(uart_id=0, baudrate=115200, tx_pin=0, rx_pin=1, idle_ms=10) def handle_frame(data): print("frame:", bytes(data).hex(" ")) receiver.on_frame = handle_frame receiver.start() while True: pass # 所有工作都由中断驱动完成

注意,on_frame是在定时器回调里被调用的,我在回调里执行了bytes(...)转换,这涉及到内存分配。在 MicroPython 里,定时器回调属于软件定时器上下文,比硬件中断回调宽松一些,但我仍然建议你把复杂的协议解析放到主循环里做,不要全塞进回调。如果回调里出现未处理的异常,板子会直接死给你看。

3.2 为什么我不能在中断回调里把字节拼接进 bytearray

这是新手最容易踩的坑。MicroPython 的 GC(垃圾回收)机制决定了它不能在中断上下文中安全地分配内存。如果你在_on_rx里写self.buffer += self.uart.read(),或者self.buffer.append(...),一旦触发垃圾回收,轻则抛出内存错误,重则整个中断回调异常,后续程序直接崩溃。

解决办法就是上面代码里展示的方案:提前申请一块足够大的bytearray,中断里只做“写入固定位置”这种纯内存操作,不产生新的 Python 对象。这里我还专门预分配了一个self._one = bytearray(1),配合readinto使用,避免每次读取都创建一个新的字节对象。

那帧长超过MAX_FRAME怎么办?被丢弃。对一个正规协议来说,一帧超出设计上限本身就是非法数据,直接丢弃是合理行为。你要是担心偶尔超长导致后续数据对不齐,可以在主循环里做更精细的重新同步处理,比如搜索下一个帧头。

3.3 空闲定时器参数怎么定:9600 和 115200 实测对照

很多文章会直接告诉你设 5 毫秒或者 10 毫秒,但脱离场景谈参数都是耍流氓。我实测下来的推荐值是这样的:

波特率单字节时间推荐空闲阈值测试结果
9600约 1.04 毫秒15-20 毫秒稳定切帧,不会误切
115200约 86.8 微秒5-10 毫秒稳定切帧,闲置中断不明显
460800约 21.7 微秒2-5 毫秒对定时器精度要求较高

为什么 9600 波特率不能照搬 115200 的 5 毫秒?因为 9600 波特率下发送端发送两个连续字节的间隔都接近 1 毫秒,如果中间被系统调度或者 USB 转发卡一下,很容易超过 5 毫秒,导致一帧被拆成两帧。保守起见,我建议阈值至少取单字节时间的 10 倍以上,同时还要大于主循环最坏卡顿时间的一半,实测下来更稳妥。

还有一点要注意:这个空闲阈值也得小于你的协议期望的帧间最小间隔。如果帧间间隔本身小于帧内间隔,那说明协议设计有问题,用什么参数都救不回来。

4. 另一种更省心的切帧方案:select/poll 主动查询

4.1 不用中断,select 也能感知 UART 是否有数据

如果你不喜欢中断回调,觉得中断里写代码太容易炸,那你可以试试 MicroPython 里的select.poll()。它就相当于把一个 UART 流对象注册到 poll 实例里,然后轮询它是否可读。重点是poll()方法里的超时参数:如果到了超时时间依然没有可读事件,就说明总线在这个窗口内没有新数据,这就是一次软件层面的“空闲事件”。

这种设计的好处是代码跑在主循环里,可以随便分配内存、随便做协议解析,完全没有中断上下文的限制。坏处是,空闲判定延迟取决于你的轮询周期,实时性大概差一个周期,一般有几十毫秒的话问题不大。

4.2 完整代码:poll + 超时切帧

import select from machine import UART, Pin class PollIdleReceiver: def __init__(self, uart_id=0, baudrate=115200, tx_pin=0, rx_pin=1, idle_ms=10): self.uart = UART(uart_id, baudrate=baudrate, tx=Pin(tx_pin), rx=Pin(rx_pin)) self.idle_ms = idle_ms self._poller = select.poll() self._poller.register(self.uart, select.POLLIN) def read_frame(self): buf = bytearray() while True: events = self._poller.poll(self.idle_ms) if events: chunk = self.uart.read(self.uart.any()) if chunk: buf.extend(chunk) else: if buf: return bytes(buf) # 没有数据也没有超时帧,继续等

使用起来也很简单,主循环里单线程同步调用read_frame(),每次它都会阻塞到收到一帧为止:

recv = PollIdleReceiver(uart_id=0, baudrate=115200) while True: frame = recv.read_frame() handle(frame) # 这里随便写协议解析

read_frame()内部先把POLLIN事件里能读到的所有字节全部读出来,直到poll()超时;一旦超时,就认为上一波数据已经结束,把缓冲区的数据作为一帧返回。这个过程其实就是把“接收空闲中断”从硬件搬到了主循环里。

4.3 什么时候该选 poll,什么时候该选 RX_ANY + Timer

从我实际工程经验来看,这两者没有绝对的优劣,主要看你的程序形态:

如果你的程序本身是一个大循环,需要同时处理十几个传感器、按钮、屏幕刷新,那RX_ANY + Timer更合适,因为它把串口接收的负担从主循环里剥离出去,主循环只需要查frame_ready标志。

如果你的程序非常单纯,就是“收一帧,处理一帧,回一帧”的请求响应模型,那直接用select/poll就够了,代码更好懂,也好调试。特别是你在做上位机联调的时候,用 poll 方案定位问题会很直观。

5. 帧解析实测:带 CRC16 的变长二进制协议完整跑通

5.1 自定义协议设计

为了把前面的接收空闲机制放到一个真实场景里验证,我设计了一个非常简化的二进制协议:

  • 帧头:0xAA 0x55
  • 长度:1 字节,表示后面负载的长度,范围 1-128;
  • 负载:不定长数据;
  • CRC16:2 字节,低字节在前,用 Modbus 多项式0xA001计算负载部分的校验值。

完整帧格式就是AA 55 LEN DATA... CRC_L CRC_H。这是一帧总共 5 个长度可变的数据帧,正好能体现空闲中断切帧的价值。

5.2 CRC16 校验函数

def crc16_modbus(data, crc=0xFFFF): for byte in data: crc ^= byte for _ in range(8): if crc & 1: crc = (crc >> 1) ^ 0xA001 else: crc >>= 1 return crc

这个函数在主循环里调用没有任何压力。对于一帧几十字节的数据,执行时间也就是几百微秒,不足以影响整体响应。

5.3 主程序整合

IdleFrameReceiver和 CRC 校验合在一起:

from machine import UART, Pin, Timer from micropython import alloc_emergency_exception_buf alloc_emergency_exception_buf(256) # 这里直接使用第 3.1 节的 IdleFrameReceiver,省略重复代码 def crc16_modbus(data, crc=0xFFFF): for byte in data: crc ^= byte for _ in range(8): if crc & 1: crc = (crc >> 1) ^ 0xA001 else: crc >>= 1 return crc def handle_frame(frame): if len(frame) < 6: return if frame[0] != 0xAA or frame[1] != 0x55: return length = frame[2] if len(frame) != length + 5: return payload = frame[3:3 + length] crc_recv = frame[-2] | (frame[-1] << 8) crc_calc = crc16_modbus(payload) if crc_recv != crc_calc: print("crc error: exp", hex(crc_calc), "got", hex(crc_recv)) return print("crc ok, len", length, "payload", payload.hex(" ")) rx = IdleFrameReceiver(uart_id=0, baudrate=115200, tx_pin=0, rx_pin=1, idle_ms=10) rx.on_frame = handle_frame rx.start() while True: pass

5.4 用串口助手完整验证一次

准备一个 USB-TTL 模块,接到 Pico 上,注意电平是 3.3V,不能拿 5V 的 RS232 电平直接怼进去。接线是:USB-TTL 的 TX 接 Pico 的 GP1(UART0 RX),USB-TTL 的 RX 接 Pico 的 GP0(UART0 TX),共地。

然后计算一帧能验证通过的测试数据。负载我选A3 01 02 03 04,长度是 5。用上面的 CRC16-Modbus 函数算出来的校验值是0x1641,低字节在前就是41 16。所以完整的十六进制发送内容是:

AA 55 05 A3 01 02 03 04 41 16

在电脑上用任意串口助手,以十六进制模式发送这串数据。Pico 串口会输出:

crc ok, len 5 payload a3 01 02 03 04

这就说明接收空闲检测和 CRC 校验都正常工作了。你可以试着连续点击发送多条同样的帧,速度只要不是快到把帧间间隔压到 1 毫秒以内,Pico 都能一帧一帧正确切开。

如果中间出现crc error,优先检查 USB-TTL 模块的接线是不是松动,或者波特率有没有不一致。如果出现帧完全收不到,十有八九是 RX/TX 接反了,或者 USB-TTL 模块的供电引脚干扰了 Pico。

5.5 注意:给 Pico 发送数据不要用手动点击太狠

串口助手手动发送时,两次点击之间的间隔通常有几百毫秒,这个间隔远大于空闲阈值,所以每点击一次,Pico 就认为是一帧结束,切得很干净。但某些串口助手的“自动发送”功能,如果间隔时间设得太短,比如低于你的空闲阈值,那发送方会连续输出多帧,中间的空闲间隔不够,Pico 会把好几帧拼成一帧,这时 CRC 校验大概率失败。遇到这种情况,先把自动发送间隔调到 100 毫秒以上再测。

6. 和 STM32/py32 系列“空闲中断 + DMA”方案的移植对照

6.1 裸机 C 方案的经典教科书写法

很多刚从 STM32 裸机转过来的人,搜索的关键词是“py32f003 使用串口 DMA 方式接收通讯数据”或者“使用接收空闲中断判断接收结束”。这类方案的经典流程是:配置 UART 的 DMA 接收通道,使能空闲中断,然后在空闲中断回调里关闭 DMA、取走接收长度、重新开启下一次接收。

裸机的优势是响应延迟在微秒级别,几乎不占用 CPU。数据流在 DMA 的搬运下直接进内存,CPU 只需要在空闲中断产生的瞬间处理一下缓冲区,吞吐量远超 MicroPython 方案。

6.2 把同样的逻辑搬进 MicroPython,改造三个关键点

如果你想把 STM32 上的这套逻辑平移到 Pico 上用 MicroPython 实现,不能照搬,要理解三层改造:

第一层,DMA 的部分没有了。MicroPython 的uart.read()底层已经帮你做了 DMA 或者 FIFO 读取,你在 Python 层拿到的是一段已经搬运好的字节流。所以不需要考虑 DMA 缓冲区管理。

第二层,空闲中断改成了“软件定时器”。这就是前面代码里的Timer.ONE_SHOT反复重置的思路。硬件空闲中断是发生在“最后一个字节之后的一个位周期”,而软件方法是发生在“最后一个字节之后的若干个毫秒”,分别对应实时在线。

第三层,内存分配策略变了。裸机 C 里你可以随便 malloc 或者用静态数组,但在 MicroPython 回调里不能乱来,必须预分配缓冲区。这就是前面反复强调的红线。

我给你的建议是,把 MicroPython 方案先当成快速原型来跑,用来验证协议逻辑、联调上位机、做功能演示都非常好用。如果做产品,帧率达到每秒上千帧、或者要求确定性到毫秒以内,那还是老老实实回去写 C,把裸机“空闲中断 + DMA”搬出来。

6.3 什么时候 MicroPython 方案完全够用

大部分物联网网关、传感器采集、测试工装场景,数据量其实就是每秒几帧到几十帧,波特率 9600 到 115200,协议帧长几十字节。这种情况下,MicroPython 的软件空闲检测毫无压力,CPU 占用率可能连 1% 都不到。我之前用 Pico 同时接收一个 GPS 模块的 NMEA 报文和一个传感器的二进制应答,两个 UART 都跑接收空闲检测,板子依然有大量空余时间去处理 LoRa 发送的事情。所以不用一上来就鄙视 MicroPython,方案选择要看需求。

7. 调试记录:我在 Pico 上踩过的 7 个串口中断坑

最后把这阵子调试过程中踩过的坑集中整理一遍,都是实测能复现的教训。

坑一是不要在中断回调里做任何强制类型转换或者引用新对象。包括bytes()str()、列表推导式、print(),全都不要出现在_on_rx里。print 尤其隐蔽,你以为只是打印个日志,实际上它内部会分配内存,触发 GC,然后死机。

坑二是忘了调用alloc_emergency_exception_buf。MicroPython 的硬中断如果没有紧急异常缓冲区,回调一抛异常就直接 panic,板子黑屏没任何提示。所以在程序开头第一行就申请这个缓冲。

坑三是用周期模式定时器代替单次模式。正确的逻辑是每收到一个字节就重新init一个单次定时器,让空闲判断窗口跟着数据流动滑动。如果你用周期定时器,就会每隔固定时间强制切帧,帧稍微发慢点就被切碎。

坑四是UART.irq触发之后没有清标志位。MicroPython 的RX_ANY在你把 FIFO 里的数据读出来之后,硬件自动清标志,所以必须在回调里把能读的字节全部读走。如果只读一个字节就退出,下一次中断可能不触发,或者重复触发,表现就是帧数据残缺。

坑五是不区分 Pico 的 VCC 逻辑电平和 USB-TTL 模块的电平。Pico 的引脚是 3.3V 逻辑,很多 USB-TTL 模块支持 3.3V/5V 跳线,如果你没拨到 3.3V 档位,轻则数据乱码,重则烧引脚。

坑六是串口助手发送十六进制时把0x前缀输入进去。有些新手直接在发送框里写0xAA 0x55,正确做法是只写AA 55,中间可以用空格隔开,且要确认“HEX 发送”模式已选中。

坑七是用了time.sleep()做主循环延时,导致 poll 方案的空闲检测形同虚设。select.poll()的超时是它自己管理的,如果你在循环里插入了很长的sleep,那整个空闲判定都会被拖慢。平时主循环该干活干活,但别阻塞得太离谱,否则实时性约等于零。

这些坑看起来琐碎,但每一条都能让你折腾大半天。尤其是中断回调里不能分配内存这条,我当年刚上手 MicroPython 时是实打实被它坑惨了。建议你把自己写的串口接收代码,严格按“预分配缓冲区 + 中断里只做读写 + 主循环做协议解析”这个规矩来排布,基本能避开九成的问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询