1. 先用一句话回答:Pico 上 MicroPython 的中断,比你想的多,也比你想的“小”
作为一个从裸机 C 语言转到 MicroPython 的开发者,我最开始对树莓派 Pico 上的中断是持怀疑态度的。毕竟传统单片机的 EXTI、TIM_IRQHandler、NVIC 优先级这些概念在 MicroPython 里几乎看不到,文档里经常就是一句Pin.irq()就带过了。但是实际用起来才发现,MicroPython 在 RP2040 上提供的中断能力,覆盖面并没有想象中那么窄,只是它的组织方式和裸机差别很大。
这篇文章主要想和大家彻底梳理一遍:树莓派 Pico 跑 MicroPython 时,哪些中断能用、哪些“假”的不能用、哪些是能触发但千万别在回调里做的操作。适合两类人看:一类是从 Arduino/MicroPython 入门、想给按键和传感器加中断的新手;另一类是用 C 写过 RP2040、想在 MicroPython 里快速验证逻辑但怕踩坑的老手。
先说结论:GPIO 外部中断、定时器中断是绝对能用的;PWM 没有自己的中断回调,但能用定时器配合达到类似效果;UART 的irq()在 RP2040 的 MicroPython 里支持,但坑不少;硬件向量中断、DMA 完成中断、中断嵌套和优先级控制,在 MicroPython 层基本是“不存在”的。
2. 能用列表一:GPIO 外部中断,最常用的重量级选手
GPIO 中断是所有项目里出场率最高的,按键、编码器、红外传感器、流量计脉冲输出,全靠它。microPython 里对应的接口是Pin.irq(),底层走的是 RP2040 的 IO 中断控制器,这部分是硬件真实支持的,不是轮询伪装。
2.1 Pin.IRQ 的完整参数与触发方式
在 Pico 的 MicroPython 固件里,Pin.irq()可以这样用:
from machine import Pin def btn_handler(pin): print(pin) btn = Pin(16, Pin.IN, Pin.PULL_UP) btn.irq(handler=btn_handler, trigger=Pin.IRQ_FALLING, hard=True)这里有几个参数值得逐一说清楚。
trigger支持Pin.IRQ_RISING、Pin.IRQ_FALLING、Pin.IRQ_LOW、Pin.IRQ_HIGH。常规文档会说前两个是边沿触发,后两个是电平触发。但实测下来,RP2040 的 MicroPython 固件对高低电平触发支持得不算好,所以如果你要用电平触发,建议先在小范围测试一下。边沿触发则非常可靠,实时性和裸机写法几乎没差别。
hard参数是很多新手容易忽视的。hard=True表示用硬件中断回调,回调运行在中断上下文;hard=False或省略,表示用软件中断,回调会被底层调度器延迟处理。从实测来看,按键这种低频率信号,软中断和硬中断体感没区别,但编码器这种高频信号,必须设置hard=True,否则丢脉冲。
另外,pin参数会传给回调函数,这是很有用的设计。你可以多个引脚用同一个回调,然后在回调里判断pin来决定逻辑,省去写一堆重复 handler。
2.2 按键中断实测:消抖怎么做才不拖垮回调
很多人用按键中断写出来的程序,刚上电没事,按几下就开始误触发或者直接死机。原因基本都是把消抖放在了回调函数里。
def btn_handler(pin): time.sleep_ms(20) # 严重错误 if pin.value() == 0: print("press")在 MicroPython 的硬中断回调里执行time.sleep_ms(),等于直接阻塞中断上下文。RP2040 的实际表现是:轻则导致后续中断延迟重入,重则触发RuntimeError: schedule queue full或直接软复位。
正确做法有两种。
第一种是中断只做标记,主循环消抖:
flag = False def btn_handler(pin): global flag flag = True while True: if flag: flag = False time.sleep_ms(20) if btn.value() == 0: print("press")第二种是使用Timer实现定时消抖,但代码量会多不少。
我的建议是:按键这类低频设备优先用轮询加延时消抖,真的没必要为按键上中断。中断适合那种“信号来了必须立刻处理,否则信息丢失”的场景,比如编码器。
2.3 边沿触发与电平触发的选择陷阱
电平触发在消费级设备里并不常用,但在某些传感器输出上会遇到。比如一个报警输出脚,平时是低电平,报警时拉高并保持高电平,你期望的是只要报警状态存在就持续触发。这种场景你用Pin.IRQ_RISING就漏掉了持续报警状态,用Pin.IRQ_HIGH又可能因为信号在阈值附近抖动导致频繁触发。
MicroPython 对电平触发的实现,RP2040 底层采用的是LEVEL_LOW和LEVEL_HIGH中断,这部分硬件是支持的。但问题是 MicroPython 回调模型默认是“边沿事件触发一次”,电平触发时如果不清除中断标志,回调会一直重入。因此我强烈建议:非特殊场景,不要用IRQ_LOW/HIGH;如果非用不可,在回调里立刻关闭中断,进入主循环轮询处理,处理完再重新使能。
3. 能用列表二:定时器中断,精确任务的“心脏”
如果 GPIO 中断是“外部信号来了告诉我”,那定时器中断就是“每过一段时间强制我去做一件事”。MicroPython 里对应machine.Timer,在 Pico 上它不是模拟出来的,而是基于 RP2040 的硬件定时器,可靠性很高。
3.1 machine.Timer 的初始化和回调模式
Pico MicroPython 最常用的用法是周期触发:
from machine import Timer def tick(timer): print("1s passed") tim = Timer() tim.init(period=1000, mode=Timer.PERIODIC, callback=tick)这里mode支持Timer.PERIODIC和Timer.ONE_SHOT。period单位是毫秒,回调会在定时器中断里执行。
如果以后要改变周期,重新调用tim.init()会覆盖旧配置;停止定时器用tim.deinit()。
定时器中断适合做几件事:LED 闪烁、周期性采集传感器、看门狗喂狗、超时检测。但不适合做高精度频率输出,因为 MicroPython 回调的抖动能达到几十微秒甚至更多,对频率稳定性要求高的任务还是用 PWM 硬件输出更靠谱。
3.2 定时器实现非阻塞扫描
有一个很典型的开发场景:板上接了好几个传感器,每个传感器的数据更新时间不同。你可以为每个传感器分配一个定时器,但 RP2040 的 MicroPython 定时器实例是有限制的,实际可用的硬件定时器并没有你想象的那么多。这时更推荐的做法是只用一个周期定时器作为时基,在主循环里根据计数分发任务。
counter = 0 def base_tick(timer): global counter counter += 1 def main_loop(): global counter last_dht = 0 last_display = 0 while True: now = counter if now - last_dht >= 20: last_dht = now read_dht() if now - last_display >= 50: last_display = now refresh_display()这段代码的好处是:定时器中断只做一个最简单的计数,不会因为传感器读取阻塞而影响时基;多个任务之间通过时间差判断是否执行,不依赖time.sleep()。
3.3 RP2040 定时器底层的真实限制
如果你查 RP2040 的数据手册,会发现它有 5 个通用定时器,但 MicroPython 的machine.Timer层实际给你用的只有一部分,而且固件版本不同,个数有差异。网上有爱好者做过测试,新手直接写tim2 = Timer(2)在某些固件上会报OSError: Timer 2 doesn't exist。
此外,MicroPython 的定时器回调默认运行在最高优先级的中断上下文,所以回调里同样不能做耗时操作。如果你在回调里调用machine.freq()调整主频,或者做gc.collect(),很可能造成不可预期的死机。
4. 能用列表三:PWM、ADC 与串口,哪些有“准中断”?
很多从 Arduino 转过来的用户都会找attachInterrupt对应的东西,然后发现 MicroPython 里并不是所有外设都有irq()方法。这一节单独把几个热门外设说清楚,避免大家盲目翻 API。
4.1 PWM 没有中断回调,但可以用定时器辅助
Pico 的machine.PWM是用来输出方波的,比如控制舵机、电机调速、LED 调光。PWM 本身没有“中断”概念,不会在占空比变化或周期结束时触发回调。
但舵机控制场景里,你需要每隔一段时间更新一次占空比,这是靠定时器中断完成的。网上搜“树莓派 Pico 控制舵机”能搜到大量例程,本质上就是:
while True: tim.deinit() tim.init(period=20, mode=Timer.ONE_SHOT, callback=lambda t: set_angle(0)) time.sleep_ms(1000) tim.deinit() tim.init(period=20, mode=Timer.ONE_SHOT, callback=lambda t: set_angle(90)) time.sleep_ms(1000)这里Timer.ONE_SHOT模式非常关键:每次触发完就自动关闭,不需要你手动deinit(),直接重新init()就能更新参数。实测下来舵机运行挺稳的,但要注意一点:如果你追求多路舵机同步运动,MicroPython 的定时器抖动可能会导致各舵机之间有几毫秒的相位差,要求高的场景还是得用硬件 PWM 加额外 IC,或者换 C 语言。
4.2 ADC 轮询与“模拟中断”
machine.ADC在 Pico 上有 4 个通道,但没有中断引脚。你不能设置“电压超过 2V 就中断”。这是硬件决定的,RP2040 的 ADC 本身没有比较器式中断输出。
要做比较器功能,最靠谱的方案是:把信号同时接到一个 GPIO 引脚,并设置 GPIO 中断来唤醒逻辑,然后读取 ADC 获取具体电压值。这种“模拟比较中断+ADC 确认”的写法在低速传感器场景里非常实用。
4.3 UART 的 irq 支持情况(RP2040 的坑)
很多项目要处理 GPS、蓝牙模块、串口摄像头,大家都想用uart.irq()在接收到数据时立刻触发回调。MicroPython 官方文档里,UART.irq()并不是所有平台都有。RP2040 的 MicroPython 固件在某个版本后加入了UART.irq(),可以绑定UART.RX_ANY或UART.TX_DONE事件。
但我实测后发现它有两个坑:
第一,UART.irq(trigger=UART.RX_ANY)触发条件是接收 FIFO 里达到设定的字节数,默认是 1 字节就触发。如果你的串口一次接收大量数据,回调频率会非常高,容易把主循环卡死,同时丢数据概率增大。
第二,回调里直接用uart.read()读数据是可行的,但如果你在回调里处理字符串拼接、格式化,内存分配会导致固件异常。更稳的方式是回调里只把数据塞进一个bytearray队列,主循环再去解析。
如果你对串口实时性要求很高,其实更推荐直接用 UART 的read()加轮询,外加定时器超时判断,代码反而更容易控制。
5. 不能用的中断清单:这些雷区千万不要踩
很多时候程序不崩,是因为还没踩到雷;一旦踩到,MicroPython 的报错信息又特别含蓄,容易让人一头雾水。下面直接把我在 Pico 上试过的“不能用”列出来,供大家避坑。
5.1 硬件中断向量与底层裸机中断别想了
MicroPython 是一门解释性语言,它不会让你直接去写ISR的中断服务函数。虽然底层确实调用了 RP2040 的中断控制器,但用户层的回调实际上不是真正意义上的裸机 ISR,而是解释器包装过的 Python 函数调用。这意味着:
- 不能自己注册
machine.mem32来处理某个 GPIO 的 IRQ 向量。 - 不能直接操作 NVIC 中断优先级寄存器。
- 不能通过
micropython.schedule()安排高优先级紧急任务,它只是把函数放进调度队列,延迟执行,不是硬实时。
如果你需要微秒级响应、精确到单个时钟周期的信号,MicroPython 做不到,这是语言和解释器的固有限制,不是代码优化能解决的。
5.2 回调函数里的禁忌清单
这一条值得所有 MicroPython 开发者背下来。无论你用的是 GPIO 中断、定时器中断还是 UART 中断,回调函数里都不应该出现以下操作:
| 禁止操作 | 原因 |
|---|---|
time.sleep_ms()/time.sleep_us() | 阻塞中断上下文,导致后续中断无法执行,甚至触发 watchdog |
| 创建 list / dict / 字符串拼接 | 内存分配发生在中断上下文,可能触发垃圾回收,导致崩溃 |
print()高频调用 | print需要锁和缓冲,在中断里大量输出会造成死锁 |
| 控制 DHT11/DS18B20 等单总线传感器 | 单总线时序要求严格,中断环境很容易破坏时序 |
| 访问网络、文件系统 | I/O 阻塞时间不可控,会直接拖死整个系统 |
使用machine.freq()调整主频 | 会重新配置时钟,中断调用有概率死机 |
如果你真的要在中断里做“紧急”的事,正确姿势是只设置标志位,或者调用micropython.schedule()把真正耗时的函数挂到主循环里去执行。
5.3 中断嵌套、优先级设置:现实很骨感
传统 ARM 中断有个重要功能是抢占优先级和子优先级。但是 MicroPython 层不会暴露这些。你在Pin.irq()或者Timer.init()里都没有设置优先级的参数。RP2040 的 MicroPython 默认把所有硬中断回调都放在同一个中断优先级,基本不会有“高优先级打断低优先级”的复杂嵌套发生。
因此如果你写了一个 GPIO 中断回调很耗时,同时又有一个定时器中断正在等待执行,定时器中断只能等 GPIO 回调跑完才能执行。这个“排队”是隐性的,不会报错,但你会观察到定时器周期抖动。
所以我的经验是:中断回调代码控制在 50 微秒以内,能一句话完成的事绝不放第二句。
5.4 为什么“某些中断”在 MicroPython 下会直接崩
有时候你在回调里看到RuntimeError: schedule queue full,这不是你的逻辑问题,而是 MicroPython 事件队列满了。当你用hard=False时,中断触发后会向调度队列投递一个任务;如果主循环太忙,没时间处理队列,积压超过最大长度,就会直接报错。类似情况还出现在“回调里再次触发同一个引脚中断”时,导致递归重入。轻则程序暂停,重则看门狗复位。
解决办法有三个:尽量用hard=True减少队列压力;回调函数执行完之前不要再触发中断;主循环里加一个轻量的time.sleep_ms(1)让出调度器。
6. 实战案例:把中断用好的三个典型场景
光说不能做,容易打击积极性。下面贴三个我在实际项目里跑得通的例子,覆盖编码器、舵机、报警检测,直接用中断做了,效果不错。
6.1 编码器脉冲计数
旋转编码器在 MicroPython 里最烦的就是丢脉冲。很多人用while True轮询,转速一快就丢。用 GPIO 边沿中断加计数器就能很好解决。
from machine import Pin counter = 0 def enc_handler(pin): global counter counter += 1 enc_a = Pin(10, Pin.IN, Pin.PULL_UP) enc_a.irq(handler=enc_handler, trigger=Pin.IRQ_RISING, hard=True) while True: print(counter) time.sleep_ms(100)注意我只用 A 相上升沿计数,没有判断方向和 B 相。如果你需要方向信息,必须同时接 A、B 两相,并在回调里读取另一相的电平状态。实测在普通手动旋转速度下,A 相中断频率大约几百赫兹,这个写法不会丢。
如果你要接高速编码器,比如电机轮子转速很快,单路中断可能达到几万赫兹。MicroPython 在这种频率下依然处理不过来,丢脉冲是必然的,必须换 PIO 或者 C 固件。
6.2 舵机控制中的中断配合
控制舵机需要周期 20ms、脉宽 0.5~2.5ms 的 PWM 信号。用machine.PWM加定时器配合,可以做到多路舵机分时控制。
from machine import Pin, PWM, Timer servo = PWM(Pin(0)) servo.freq(50) def set_angle(angle): duty = 1966 + int(angle / 180 * 7864) servo.duty_u16(duty) def move_timer(timer): set_angle(90) tim = Timer() tim.init(period=1000, mode=Timer.PERIODIC, callback=move_timer)这里duty_u16()的数值换算公式是:RP2040 PWM 计数范围 0~65535,50Hz 周期对应一个周期计数约 65535,0.5ms 对应65535 * 0.5 / 20 ≈ 1638,实际我常用 1966 作为起始值,2466 左右对应 1.5ms,具体数值要看你舵机型号。
6.3 低频传感器报警系统(检测上升沿)
很多报警传感器的输出平时是低电平,触发时拉高。用 GPIO 上升沿中断就能实现“立刻响应”,很适合做家庭安防、门窗感应。
from machine import Pin alarm_triggered = False def alarm_handler(pin): global alarm_triggered alarm_triggered = True sensor = Pin(2, Pin.IN, Pin.PULL_DOWN) sensor.irq(handler=alarm_handler, trigger=Pin.IRQ_RISING, hard=True) while True: if alarm_triggered: alarm_triggered = False print("ALARM!") # 这里可以接蜂鸣器、发通知这种设计里,中断回调只负责“翻转标志位”,主循环负责处理具体响应,两者职责分离,后续扩展非常方便。
7. 我的调试经验与性能分析
7.1 中断频率上限估算
很多人关心 MicroPython 在 Pico 上到底能扛多少赫兹的中断。我实测过用 GPIO 中断输入,不同hard模式下最大可靠频率不同:
| 配置 | 实测最大频率 |
|---|---|
hard=False+ 空回调 | 约 5kHz |
hard=True+ 简单标志位 | 约 30kHz |
hard=True+print() | 约 1kHz 就开始丢 |
这只是我手头固件的测试结论,不同版本可能有出入,但趋势很明显:回调越简单,能扛的频率越高。如果你要处理 10kHz 以上的脉冲信号,MicroPython 基本告别实时可靠性,建议用 C 或 PIO 写一个计数器,MicroPython 只负责读结果。
7.2 用逻辑分析仪看中断是否丢失
在调试中断程序时,我强烈建议不要只依赖打印。用一个引脚在回调里翻转电平,再用逻辑分析仪观察翻转频率,这是最直观的验证方式。
test_pin = Pin(15, Pin.OUT) def handler(pin): test_pin.toggle()然后测量 15 脚上的方波频率,和输入信号频率对比,如果输出只有输入的一半甚至更低,说明你的中断回调开销太大,或者发生了重入堵塞。这个方法比看串口日志靠谱得多,因为串口打印本身就有延迟和缓冲。
7.3 最后的建议
从 MicroPython 开始学中断是很容易的,但它也有个“蜜糖效应”:大概率能跑通,但等到真正复杂项目尤其是多中断源、高实时性场景时,就会暴露出语言层的短板。我个人的做法是:原型验证用 MicroPython,量产后如果发现中断频繁处理不过来,再考虑把核心逻辑下放给 C 例程或 PIO,MicroPython 只做配置和状态展示。
如果你刚开始接触 Pico 的中断,我的建议是老老实实把 GPIO 外部中断和定时器中断这两个最基本的玩熟。先把回调里“不 sleep、不分配内存、不打印”这三条铁律刻在脑子里,再逐步尝试 UART 中断、PWM 配合定时器这类进阶组合。中断不是炫技,而是为了让系统更快地感知世界;保持回调轻量,MicroPython 能给到的体验绝对比你想的好。