MicroPython定时器任务调度:在树莓派Pico上实现软多任务
2026/9/9 8:11:44 网站建设 项目流程

树莓派 Pico 这颗小板子,配上 MicroPython 之后,最大的坑往往不是硬件本身,而是“你以为写完的代码其实只跑了一个 while True”。做嵌入式开发的朋友应该都有同感:当项目里既要扫按键、又要刷屏幕、还要控制舵机,再加个串口日志,一个死循环里全塞进去,迟早会出事。这也就是为什么我想写一篇关于 Pico 任务调度器的文章,用定时器把 MicroPython 的多任务这件事彻底讲透。

这篇文章核心就是解决一个问题:在裸机 MicroPython 环境下,怎么用定时器机制实现稳定、可扩展的“软多任务”。不需要引入 RTOS,不需要额外硬件,只需要 Pico 自带的 hardware timer,配合一个简单的调度循环,就能让多个任务“看起来”同时运行。适合正在做小项目、想在单片机上写出更工程化代码的开发者,哪怕你只有基本 Python 基础,跟着做也能跑通。

1. 为什么需要在 Pico 上做任务调度

1.1 裸机 while True 循环的痛点

很多 MicroPython 新手一开始写代码都是这样的:一个主循环里面,检测传感器、处理逻辑、控制输出,按顺序排好。刚开始项目小,一个传感器一个 LED 啥问题没有,但当任务多起来,问题就冒出来了。

我用一个真实场景来说。假设你要做一个智能小车:需要每 20ms 读一次超声波,每 10ms 更新一次 PID 控制输出,每 50ms 刷新 OLED 显示,还要随时响应按键。如果全部写进 while True,最糟的情况就是这样:

while True: dist = read_ultrasonic() pid_out = pid.calculate(dist) set_motor(pid_out) oled.show(dist, pid_out) key = read_key() if key == 1: do_something()

这段代码有几个致命问题。第一,oled.show()如果内部有延时,超声波读取和处理就会被卡住,等你读完回来,距离已经变了好几轮,PID 控制自然不准。第二,按键响应依赖整个循环跑完一遍的时间,任务一多,按键延迟可能达到几十毫秒,手感极差。第三,逻辑耦合严重,想加一个任务就得往主循环里插代码,代码很快变成“意大利面条”。

这种结构本质上就是“顺序执行”,而真实世界的交互需求是“并发”的。你需要的是让不同任务按照各自的时间表运行,互不阻塞。

1.2 定时器方案 vs 其他多任务方案

面对这个需求,有几种常见路线。第一种是最简单的:在循环里硬算时间,用time.ticks_ms()做非阻塞延时。比如:

while True: if time.ticks_diff(time.ticks_ms(), last_sonar_time) > 20: last_sonar_time = time.ticks_ms() read_ultrasonic() if time.ticks_diff(time.ticks_ms(), last_oled_time) > 50: last_oled_time = time.ticks_ms() oled.refresh()

这种方案能解决一部分问题,但有一个核心缺陷:如果一个任务执行耗时较长,其他任务的执行点会被推迟。比如read_ultrasonic()内部如果有个 30ms 的阻塞延时,那 OLED 的 50ms 周期实际会变成 80ms,时间节拍完全乱了。而且这个方案要求你在主循环里手工维护每个任务的状态,任务一多容易漏、容易乱。

第二种方案是上 RTOS,比如 FreeRTOS。这条路在小工程里有点“杀鸡用牛刀”,而且 MicroPython 环境下引入 FreeRTOS 意味着要换固件、改架构,学习成本陡增,对大多数 Pico 项目来说没必要。

第三种就是我推荐的做法:基于定时器中断 + 主循环轮询的协作式调度器。让硬件定时器每 1ms 产生一次中断,在中断里只做一件事:把“节拍标志”置位。主循环拿到标志后,遍历任务表,检查每个任务的周期是否到期,到期就执行。这样时间基准是准的,任务之间按调度器规则切换,既不阻塞,又不需要引入重型 RTOS,非常适合 MicroPython。

这种方式的设计思路其实借鉴了 RTOS 里的时间片轮转思想,只不过把“上下文切换”简化成了“按需调用”,所以叫它“软多任务”或者“协作式多任务”。

2. 核心准备:Pico 环境与 MicroPython 定时器基础

2.1 开发环境与固件版本选择

开始写调度器之前,先把手上的工具准备好。树莓派 Pico 用的主控是 RP2040,双核 Cortex-M0+,频率最高 133MHz。MicroPython 官方固件对 Pico 支持得非常好,直接去官网下载.uf2文件,按住 Pico 上的 BOOTSEL 键插入 USB,然后把这个文件拖进弹出的虚拟 U 盘即可完成刷机。

版本上,我建议优先选官方稳定版,不要一上来就追新测试版。定时器相关的 APImachine.Timer在官方固件里一直比较稳定,没必要为了几个新特性去承担稳定性风险。至于“支持 USB Host 的 MicroPython 固件”这类特殊编译版,如果是普通定时器项目,完全用不上,老老实实用官方版就对了。

开发工具方面,Thonny 依然是 Pico 新手最友好的选择,打开就能选解释器、传文件、看串口输出。有经验的开发者也可以用 VS Code + MicroPico 插件,补全和调试体验更好。我个人习惯用 Thonny 快速验证,用 VS Code 写正式工程,二者共存没有任何冲突。

2.2 Pico MicroPython timer 常见 API 与参数

MicroPython 的定时器在 Pico 上由machine.Timer提供,硬件上 RP2040 有多个定时器,但 MicroPython 固件默认暴露了 4 个软件定时器 ID(-1 表示虚拟定时器)。实际使用中,我们一般不会同时开很多定时器,好的调度器甚至只需“一个定时器就够了”。

最常用的构造方式:

from machine import Timer timer = Timer() timer.init(period=1, mode=Timer.PERIODIC, callback=tick_isr)

period单位是毫秒,表示定时器触发的周期;mode可以选Timer.ONE_SHOTTimer.PERIODICcallback是触发后执行的回调函数。Pico 的 MicroPython 定时器回调和普通中断一样,是跑在“中断上下文”的,这一点很关键,后面会专门讲。

还需要知道Timer对象的deinit()方法。当你要停止定时器或者重新初始化时,先调用deinit()再把旧对象释放掉,否则会报OSError: Timer already used之类的错误。这是新手最容易踩的坑之一。

我之所以强调“定时器回调里只做简单操作”,是因为 MicroPython 在中断上下文里能做的事有限:不能分配内存(gc可能被触发)、不能阻塞延时、不能调用复杂的打印。正确姿势是给主循环传递一个“节拍信号”,所有实际工作都放到主循环去干。

3. 定时器多任务调度器的设计与核心原理

3.1 调度器整体结构:任务表、节拍与任务状态

设计一个调度器,不需要多复杂的架构,但思路必须清晰。我习惯把它拆成三个部分:任务表、节拍源、调度主循环。

任务表是一个普通的 Python 列表,每个元素是一个“任务对象”,最简单的形式就是用字典或自定义类:

@dataclass class Task: func: Callable period_ms: int last_run_ms: int = 0

func是要执行的任务函数,period_ms是任务期望的周期,last_run_ms记录这个任务上一次执行的时间。调度主循环每次检查当前时间与last_run_ms的差值,达到周期就调用func(),然后更新last_run_ms

节拍源就是我们前面提到的那个 1ms 硬件定时器。在回调中断里,我们可以维护一个tick_count变量,每次中断加 1。主循环里用ticks_ms或者直接用这个tick_count读取当前时间。

调度主循环的逻辑非常直观:

while True: now = timer.get_tick_ms() for task in tasks: if now - task.last_run_ms >= task.period_ms: task.last_run_ms = now task.func()

这个设计其实就是把“非阻塞延时”的思路结构化、模块化了。每个任务都觉得自己在独立运行,而实际上他们是在同一个主循环里被按时间片地“轮询”。

为什么要用定时器中断做节拍源,而不是直接在主循环里用time.ticks_ms()?因为time.ticks_ms()的精度完全取决于主循环什么时候执行到那一行,如果某个任务阻塞了,所有任务的时间基准都会后移。而硬件定时器中断是异步产生的,不管主循环在干什么,每 1ms 都会触发一次,这样我们维护的“系统时间”始终是准的。

3.2 核心代码实现:一个最小可用的调度器

把上面的思路落成代码,我会写一个小巧的SimpleScheduler类。目标是不引入外部依赖,复制粘贴就能用:

from machine import Timer import time class SimpleScheduler: def __init__(self, tick_ms=1): self.tick_ms = tick_ms self.tasks = [] self._tick_count = 0 self._timer = Timer() def _isr(self, timer): # 中断里只做一件事:累加节拍 self._tick_count += 1 def start(self): self._timer.init(period=self.tick_ms, mode=Timer.PERIODIC, callback=self._isr) def stop(self): self._timer.deinit() def add_task(self, func, period_ms): self.tasks.append({ 'func': func, 'period_ms': period_ms, 'last_run_ms': 0, }) def run(self): while True: now = self._tick_count * self.tick_ms for task in self.tasks: if now - task['last_run_ms'] >= task['period_ms']: task['last_run_ms'] = now task['func']()

这里有个细节:_isr里没有做任何复杂操作,只做自增,这是刻意为之。把节拍累加放到中断里,把任务判断放到主循环里,既保证了时间精度,又避免了中断上下文限制。

再看run()方法:now是通过_tick_count * self.tick_ms计算出来的“逻辑毫秒数”。因为_tick_count是整型自增,乘法也是整型运算,速度非常快。任务判断直接用减法比较,也比用time.ticks_diff更干净。

这个调度器最核心的设计哲学是:中断管时间,主循环管逻辑。两件事彻底分离,各司其职,这才是它稳定的根本原因。后面我们会在这个基础上扩展优先级、看门狗、时间戳归一化等能力。

4. 完整实操:从任务定义到跑通演示项目

4.1 演示项目整体设计:LED 闪烁 + 舵机控制 + 串口日志

光讲理论容易飘,我专门搭一个综合演示来亲手跑一遍需求:三个任务同时运行,互不干扰。

任务 A:每 200ms 翻转一次板载 LED,直观看到调度器是否在工作。任务 B:每 500ms 让舵机在 0 度和 90 度之间来回转动,验证周期任务对时间敏感硬件控制的效果。任务 C:每 1000ms 打印一行带“当前时间”的日志,方便在串口监视器上观察调度节拍。

硬件接线很简单:Pico 板载 LED 在 GP25,舵机信号线接 GP15,电源用外部 5V(舵机电流大,不建议直接吃 Pico 的 3V3 引脚),GND 与 Pico 共地。

这个组合非常典型:LED 是“纯逻辑任务”,舵机是“带硬件时序的任务”,日志是“低速 IO 任务”。三种不同类型的任务放在同一个调度器里,能验证调度器处理它们的通用性。

4.2 实操步骤:定义任务、注册任务、启动调度器

先把舵机控制封装成一个任务函数。注意 MicroPython 控制舵机通常用 PWM,Pico 的 PWM 频率设为 50Hz,占空比映射到 0.5ms~2.5ms 的脉宽。这里我直接用machine.PWM实现:

from machine import Pin, PWM class Servo: def __init__(self, pin, min_us=500, max_us=2500, freq=50): self.pwm = PWM(Pin(pin)) self.pwm.freq(freq) self.min_us = min_us self.max_us = max_us self.current_angle = 0 def angle(self, degree): value = int((degree / 180) * (self.max_us - self.min_us) + self.min_us) duty = int(value / 20000 * 65535) self.pwm.duty_u16(duty)

然后定义三个任务函数:

import time from machine import Pin led = Pin(25, Pin.OUT) servo = Servo(15) servo_target = 0 def task_led(): led.toggle() def task_servo(): global servo_target if servo_target == 0: servo_target = 90 else: servo_target = 0 servo.angle(servo_target) def task_log(): print("scheduler alive, tick:", scheduler._tick_count)

这里task_log里引用了外部变量scheduler,执行顺序上要保证先创建调度器对象,再定义任务。或者更规范一点,把调度器实例传进来。

最后是这样组织的:

scheduler = SimpleScheduler(tick_ms=1) scheduler.add_task(task_led, 200) scheduler.add_task(task_servo, 500) scheduler.add_task(task_log, 1000) scheduler.start() scheduler.run()

运行之后,你应该能看到 LED 以 200ms 稳定翻转,舵机以 500ms 周期来回摆,串口每 1000ms 打印一条日志。三者相互之间没有任何阻塞干扰,这就算跑通了。

4.3 参数选择与调优:节拍周期、任务周期和抖动控制

调度器里tick_ms的选择直接影响时间精度和 CPU 负载。这个值是全局的“心跳”,越大则主循环检查任务的频率越低,任务周期误差可能越大;越小则精确度越高,但中断触发越频繁,主循环需要更频繁唤醒。

对于大多数 Pico 项目,1ms 节拍是甜点。理论上主循环每次遍历任务表只要几十微秒,1ms 的周期足够应付绝大多数场景。如果项目里没有毫秒级精度需求,也可以把tick_ms调到 5ms,减少中断次数,省电也有好处。

任务的实际周期误差主要来自两个来源:一个是tick_ms的量化误差,比如任务周期是 333ms,而节拍是 1ms,那它实际执行周期就是 333ms 或 334ms,误差 ±1ms 以内,完全可以接受;另一个是任务执行本身耗时,如果某个任务内部阻塞 50ms,那么同一轮其他任务都会被延迟。所以任务函数内部原则上不要用time.sleep(),而是用调度器机制来做定时。

还有一个关键参数是任务的“准时性” vs “公平性”。如果两个任务的周期相同,后注册的任务永远排在前一个执行后面,先注册的则可能一直抢占。我的做法是在任务函数里尽量快速返回,耗时任务拆成多个状态机步骤,这样公平性问题就不会暴露。这也是协作式调度器的基本修炼:任务要自觉、要短小

5. 实际运行中的问题和排查技巧

5.1 定时器不准或者漂移是怎么回事

跑了几天调度器之后,有人可能会发现日志里的时间逐渐对不上,或者 LED 闪烁肉眼可见不均匀。最常见的原因有三个。

第一,MicroPython 的定时器回调如果执行时间过长,会导致后续中断堆积或者被跳过。虽然我们只在回调里做加法,但如果你不小心在回调里写了复杂逻辑,整个系统时间基准就会乱。排查方法很简单,在_isr里加一个计数器,主循环里打印它的增长率,如果异常说明中断被阻塞了。

第二,主循环里某个任务耗时太长,导致主循环“饿死”——虽然中断还在跑,但系统的对外行为全部卡住。这时检查所有任务函数,看谁里面有sleep或者等待。我之前遇到过oled.wait_vsync()这类阻塞调用,直接把调度器卡了几十毫秒。

第三,Pico 主板本身可能有温漂,但这对普通项目来说完全不用担心。如果你确实需要秒级甚至更长时间的精准定时,建议用time.time()结合rtc模块做校时,而不是只依赖 tick 计数。

5.2 中断回调里的禁忌:内存分配与时间漂移

这个坑值得单独拿出来说。MicroPython 在定时器回调(中断上下文)里,如果尝试创建新的 Python 对象、字符串拼接、或者执行任何可能触发内存分配的操作,可能会导致内存碎片甚至卡死。

举个例子,绝对不要这么写:

def _isr(self, timer): self._tick_count += 1 print("tick:", self._tick_count) # 不推荐

print本身在中断里执行很慢,而且可能触发字符串格式化分配内存。正确的做法是把打印放在任务函数里,用调度器的日志任务去读_tick_count再打印。

另外,MicroPython 的Timer回调是软中断还是硬中断也有讲究。Pico 上machine.Timer默认是走 MicroPython 的调度器,所以相对安全,但依然不推荐做重活。如果追求极致实时性,可以用rp2库的PIO或者 C 扩展,但那是另一个级别的话题了,普通项目没必要。

5.3 任务超时、优先级与看门狗的思路

协作式调度器有个天然弱点:一个坏任务可以拖垮所有任务。比如某次传感器读取因为 I2C 总线锁死而阻塞,整个调度就停了。这时候就需要一些保护机制。

我习惯给每个任务加上“最长执行时间”的粗略度量。在调度循环里记录每次任务调用的起始 tick 和结束 tick,如果超出预设值,就打日志甚至禁用该任务。实现思路是把任务注册信息扩展一下:

scheduler.add_task(task_i2c, period_ms=50, max_time_ms=10)

调度器在执行任务前后各取一次 tick,如果差值超过max_time_ms,说明这个任务异常,可以标记它,下次不再调度。这种方式不是硬实时看门狗,但对定位问题是真有用。

如果系统里有些任务确实更紧急,比如安全停机、急停按钮,那就要引入优先级。最简单的做法是维护两个任务列表,高优先级任务先执行,低优先级任务后执行。这样即使低优先级任务耗时,高优先级任务也至少在下一轮准时抢占资源。注意这只是“协作式优先级”,不是抢占式,任务一旦开始执行,只能等它自己返回。

真正硬实时场景,我会在板级加一个外部看门狗定时器(比如独立 IC),主循环里定时喂狗,如果死机超过预设时间就硬件复位。在嵌入式里,软件 watchdog 永远不如硬件 watchdog 可靠。

6. 一些值得继续扩展的方向

用这个调度器跑通基础任务后,你会发现它像一个“多功能口袋”,往里装什么都能干。

比如把舵机控制换成 PID 闭环控制:控制任务周期设 10ms,传感器读取任务周期设 20ms,日志任务周期设 500ms,整个控制环路自然就搭起来了。再比如把串口日志升级为简单的命令解析器:一个任务负责收串口,一个任务负责执行命令,一个任务负责反馈状态,很像一个小型的微型控制台。

如果项目复杂度继续上升,任务数量超过十几个,且任务间有复杂的等待、信号量、队列等需求,那确实可以考虑换一个更工程化的方案。一个思路是上uasyncio,用协程和事件循环做异步调度,语法和这里的写法差异较大,但思想同源。另一个思路是用完整的 RTOS,比如 FreeRTOS 移植版MicroPython,那就要开始学任务优先级、互斥锁、信号量这些概念了。

但无论如何,我始终觉得在 95% 的 Pico 小项目里,一个基于定时器的轻量调度器足够用了。它让你用最少的代码量获得接近 RTOS 的开发体验,而且排查问题的时候心智负担非常小。

我在实际做项目的时候,这个调度器已经成为我的默认起点。每次新开一个 Pico 工程,先把它铺好,后面的功能就是往任务表里“填任务”,再也没有回头去重构主循环的烦恼。如果你第一次写多任务代码,建议也从三四个简单任务开始,跑熟了再慢慢加复杂度。这个量级的项目,最怕的不是任务多,而是没有一个清晰的调度骨架。

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

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

立即咨询