ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

MicroPython实现UART空闲中断:树莓派Pico变长帧接收方案

MicroPython实现UART空闲中断:树莓派Pico变长帧接收方案 如果你之前一直在 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 中断最经典的应用就是搭配 DMADMA 一直把接收到的数据搬运到内存但 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 里没有待发送的数据了。真正跟“发送线路空闲”语义最接近的是 TCTransmission 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(triggerUART.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_id0, baudrate115200, tx_pin0, rx_pin1, idle_ms10): self.uart UART(uart_id, baudratebaudrate, txPin(tx_pin), rxPin(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(triggerUART.RX_ANY, handlerself._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(modeTimer.ONE_SHOT, periodself.idle_ms, callbackself._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(trigger0, handlerNone)使用的时候你只需要在主程序里设置on_frame回调然后循环等待即可receiver IdleFrameReceiver(uart_id0, baudrate115200, tx_pin0, rx_pin1, idle_ms10) 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_id0, baudrate115200, tx_pin0, rx_pin1, idle_ms10): self.uart UART(uart_id, baudratebaudrate, txPin(tx_pin), rxPin(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_id0, baudrate115200) 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负载不定长数据CRC162 字节低字节在前用 Modbus 多项式0xA001计算负载部分的校验值。完整帧格式就是AA 55 LEN DATA... CRC_L CRC_H。这是一帧总共 5 个长度可变的数据帧正好能体现空闲中断切帧的价值。5.2 CRC16 校验函数def crc16_modbus(data, crc0xFFFF): 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, crc0xFFFF): 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_id0, baudrate115200, tx_pin0, rx_pin1, idle_ms10) rx.on_frame handle_frame rx.start() while True: pass5.4 用串口助手完整验证一次准备一个 USB-TTL 模块接到 Pico 上注意电平是 3.3V不能拿 5V 的 RS232 电平直接怼进去。接线是USB-TTL 的 TX 接 Pico 的 GP1UART0 RXUSB-TTL 的 RX 接 Pico 的 GP0UART0 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 时是实打实被它坑惨了。建议你把自己写的串口接收代码严格按“预分配缓冲区 中断里只做读写 主循环做协议解析”这个规矩来排布基本能避开九成的问题。
返回列表