树莓派Pico定时器实战:用MicroPython实现多任务调度与舵机控制
发布时间:2026/9/7 10:58:11来源:尧图网络
树莓派 Pico 这板子发布到现在玩的人越来越多但大部分教程还在教怎么点灯、怎么读传感器。真到做项目的时候你会发现最头疼的不是某个外设不会用而是——多个任务怎么同时跑。比如你想一边让舵机摆动一边扫描按键一边在 OLED 上刷新数据用最笨的while True轮询写法稍微加一个延时函数整个系统就卡住了。这篇文章就围绕“定时器 MicroPython 多任务”这套组合拳展开带你从原理到代码从调度机制到实际控制舵机完整走一遍我调通的经验。如果你是刚接触 Pico 的入门玩家或者被阻塞式代码坑过好几次的进阶选手这篇文章应该能帮你省不少折腾时间。1. 整体设计思路为什么跑多任务要用定时器而不是硬怼循环1.1 树莓派 Pico 的硬件底子先摸清楚树莓派 Pico 用的是 RP2040 芯片双核 Cortex-M0主频默认 125MHz可以超到 250MHz 左右。注意MicroPython 默认只跑单核你要想用第二个核得用_thread模块但多线程在 MicroPython 里有不少坑尤其涉及共享变量和硬件资源时踩雷概率极高。Pico 内部硬件定时器数量很有限严格来说真正可用的硬件定时器就那么几个。好在 MicroPython 帮我们封装了一层machine.Timer这个类可以创建多个“软件层面的定时器对象”底层通过一个硬件定时器和中断机制轮转实现。实际用起来你创建 4~8 个软件定时器做任务调度完全没问题这也是为什么很多 Pico 项目选 MicroPython 而不是 C 开发——开发效率高定时器用起来也够顺手。先看一段最简单的定时器初始化代码后面所有调度器都基于这个基础from machine import Timer tim Timer() tim.init(freq10, modeTimer.PERIODIC, callbacklambda t: print(tick))这段代码的意思是创建一个定时器频率 10Hz也就是每 100 毫秒触发一次回调回调里打印一个 tick。init函数里的freq参数就是核心后续我们做的所有任务调度本质上就是往这个回调里塞不同的任务函数。1.2 阻塞轮询的痛点为什么 while True sleep 不靠谱新手写多任务最常见的写法是这样的while True: servo_move() time.sleep_ms(20) read_key() time.sleep_ms(10) oled_refresh() time.sleep_ms(100)表面上看起来每个任务都执行了但仔细一琢磨问题很严重。time.sleep_ms是阻塞函数它在那傻等的时候CPU 什么都干不了。servo_move 执行期间按键按下了没人管oled 刷新期间舵机动作又卡住了。计时精度就更别提了。每个sleep_ms结束之后系统还要花时间执行任务本身执行完一个周期误差会越积越多。你要的是一个 20ms 的定时任务实际跑下来可能变成 25ms 甚至 30ms累积误差越来越大。还有一个致命问题是代码结构。所有任务串在一条循环里想加一个新功能就得往这个循环里再塞一段代码逻辑复杂到一定程度调试起来想哭的心都有。1.3 定时器调度器的设计思路把时间切碎按优先级分配我设计的调度思路其实很简单精髓就八个字定时中断轮番执行。用一个硬件定时器作为系统的心跳比如每 1 毫秒触发一次中断。每次中断进来调度器检查一下当前有哪些任务该执行了按优先级逐个跑一遍。这样每个任务都有自己的执行周期互不干扰精度也能做到毫秒级。举个生活化的例子就像快餐店的出餐口。轮询模式是厨师做完一个汉堡再去炸薯条炸完薯条再去打可乐整个过程串行慢。定时调度模式是定个闹钟每 30 秒响一下响的时候检查一下哪个订单该出了该出的出不该出的继续等着。任务之间平等竞争 CPU 时间片谁该干活谁干活。1.4 我对“实时性”的理解别被这个词吓住嵌入式领域一提“多任务”很多人脑子里就冒出 RTOS、FreeRTOS 这些词觉得压力山大。但在 MicroPython Pico 这套组合里我们做的其实是一种“软实时”调度意思是任务在规定时间内完成但偶尔慢个几毫秒也没关系。比如舵机控制要求是每 20ms 刷新一次 PWM 脉冲宽度晚 1ms 根本感觉不到。OLED 刷新几十毫秒的延迟肉眼看不出来。按键扫描晚几毫秒检查一次就更无所谓了。真正要求硬实时的场景比如读取高速传感器数据、产生精确的音频波形那就老老实实用 C 或者直接操作 PIO 和 DMA别为难 MicroPython。清楚了这一点设计调度器的时候心态就放平了。我们的目标是让 90% 的普通项目用一套简单的调度机制就能搞定而不是去追求工业级的实时响应。2. 定时器的底层机制与 MicroPython 封装2.1 定时器中断是怎么“打断”主循环的先简单过一遍中断的原理。CPU 正在执行主程序的时候硬件定时器计数到了设定值会触发一个中断信号。CPU 收到信号后暂停手头的工作保存现场跳转到中断服务函数ISR执行执行完再恢复现场回到刚才暂停的地方继续跑。这个“保存现场、恢复现场”的过程是硬件自动完成的速度极快通常几微秒以内。所以在中断服务函数里不适合做耗时长的操作比如 OLED 刷新、文件读写、复杂数学运算。正确做法是中断里只做标记具体活放到主循环里干。MicroPython 的Timer回调就是这个中断服务函数的入口。每次中断进来回调函数里的代码都会执行一遍。我们后面写的调度器核心逻辑之一就是把“中断里该干的事”和“中断外该干的事”分清楚。2.2 MicroPython Timer 的核心参数与选型建议用machine.Timer建定时器时最核心的参数就两个频率freq和模式mode。freq触发频率单位 Hz。freq100 就是每秒触发 100 次即每 10ms 一次。modeTimer.ONE_SHOT只触发一次Timer.PERIODIC周期触发。还有一种指定周期的方式是用period参数单位是毫秒。比如Timer(period5, modeTimer.PERIODIC)和Timer(freq200, modeTimer.PERIODIC)效果一样。我个人的习惯是直接用freq因为算周期更直观。比如想要 1ms 心跳freq1000想要 10ms 心跳freq100。唯一要注意的是freq 越大中断越频繁CPU 开销也越大。Pico 跑 MicroPythonfreq 一般不建议超过 5000否则中断频繁到影响主循环的正常执行。# 推荐写法用 freq 定义频率 heartbeat Timer() heartbeat.init(freq1000, modeTimer.PERIODIC, callbacktick_handler) # 或者用 period 定义周期单位毫秒 heartbeat Timer() heartbeat.init(period1, modeTimer.PERIODIC, callbacktick_handler)两种写法等价挑你自己顺手的来。2.3 Pico 的 Timer 和 STM32、ESP32 的差异对比网上搜定时器动不动就是 STM32 TIM 定时器、定时器捕获测频率、Cubemx 设置 Timebase看着挺唬人。但换到 Pico MicroPython 的场景很多东西被简化掉了。STM32 的定时器是纯硬件外设有预分频器、自动重装载寄存器、捕获比较通道功能极其强大但配置也极其繁琐。你需要翻参考手册、算 PSC 和 ARR还要处理各种标志位。Cubemx 里点半天生成一堆初始化代码新手看了头皮发麻。ESP32 的 MicroPython 定时器API 和 Pico 几乎一致machine.Timer直接拿来用就行不过 ESP32 的定时器底层中断精度和稳定性略逊于 Pico高频率触发时容易丢中断。Pico 的 MicroPython 定时器把底层配置全部封装好了你只要给 freq 或 period 就行不需要管预分频这些概念。这里例行提醒如果换到 STM32 写 C 代码做舵机控制本质思路类似却要通过 Cubemx 设置 Timebase Source、配置定时器中断优先级、计算占空比——那是另一个深坑本篇先不展开。2.4 定时精度到底能不能信任我实测的数据纸上谈兵没意思直接上实测数据。我用 Pico 建了一个 freq1000 的定时器回调里累加一个计数器另开一个任务每秒读取计数器的值并打印出来。from machine import Timer import time counter 0 def tick(t): global counter counter 1 def report(t): global counter print(1秒内tick次数:, counter) counter 0 Timer(period1, modeTimer.PERIODIC, callbacktick) Timer(period1000, modeTimer.PERIODIC, callbackreport) while True: time.sleep(5)跑了几分钟平均下来每秒 tick 次数在 999 到 1001 之间波动。这个精度控制舵机、做 LED 闪烁、做按键消抖完全没问题。但要注意打印输出本身会占用时间如果把print放进高频回调里精度会被拖累。所以结论是定时器本身精度够用前提是别在回调里干重活。3. 自己动手写一个轻量级任务调度器3.1 调度器雏形节拍 任务注册表设计一个最简单的调度器核心就是一张任务注册表。表里每一项记录任务的回调函数、执行周期、上次执行时间戳。每次定时器中断进来遍历这张表看看哪些任务到了该执行的点了。from machine import Timer class Task: def __init__(self, func, period_ms): self.func func self.period_ms period_ms self.last_run_ms 0 class Scheduler: def __init__(self, tick_ms1): self.tasks [] self.tick_ms tick_ms self.counter 0 self.timer Timer() def add_task(self, func, period_ms): t Task(func, period_ms) self.tasks.append(t) return t def _tick(self, t): self.counter 1 now_ms self.counter * self.tick_ms for task in self.tasks: if now_ms - task.last_run_ms task.period_ms: task.last_run_ms now_ms task.func() def start(self): self.timer.init(freq1000 // self.tick_ms, modeTimer.PERIODIC, callbackself._tick)这个调度器用法非常直观sched Scheduler(tick_ms1) def blink(): print(LED toggle) def servo_tick(): print(servo update) sched.add_task(blink, period_ms500) sched.add_task(servo_tick, period_ms20) sched.start() while True: pass这里while True: pass不是空转它只是个占位符让主循环别退出。真正干活的是定时器中断触发的_tick回调。这套机制的巧妙之处在于所有任务的时间基准是同一个计数器counter天然避免了 sleep 累积误差。而且任务之间是独立注册的想加新任务只需add_task一行代码不用改原有逻辑。3.2 给调度器加上优先级紧急任务优先跑实际项目里不同任务的紧急程度不一样。舵机控制 20ms 刷新一次抖动 1ms 影响不大但读取编码器计数晚了几毫秒可能就丢脉冲了。所以调度器最好支持优先级。实现思路也很简单任务注册时加一个 priority 字段_tick里先按优先级排序再遍历执行。优先级高的任务排前面在时间片紧张时能优先被处理。class Task: def __init__(self, func, period_ms, priority0): self.func func self.period_ms period_ms self.priority priority self.last_run_ms 0 def add_task(self, func, period_ms, priority0): t Task(func, period_ms, priority) self.tasks.append(t) self.tasks.sort(keylambda x: x.priority, reverseTrue) return t这样调度器就对高优先级任务更友好。虽说 MicroPython 里这种“友好”不是硬实时的但项目里聊胜于无至少能保证关键任务被优先执行。3.3 回调里能不能干重活防重入和延迟处理很多人一开始会把耗时操作直接写进任务函数里比如 OLED 刷新一次就要几十毫秒。这会出现一个严重问题任务函数还没执行完下一个定时器中断又来了_tick再次被调用任务重入变量错乱系统卡死。解决办法有两个。第一任务函数内部要刻意控制执行时间保证在当前节拍内完成第二调度器加一个防重入标志某个任务正在执行时如果下一次 tick 又轮到它直接跳过。class Scheduler: def __init__(self, tick_ms1): self.tasks [] self.tick_ms tick_ms self.counter 0 self.timer Timer() self.running_flags {} def _tick(self, t): self.counter 1 now_ms self.counter * self.tick_ms for task in self.tasks: if self.running_flags.get(id(task), False): continue if now_ms - task.last_run_ms task.period_ms: task.last_run_ms now_ms self.running_flags[id(task)] True try: task.func() finally: self.running_flags[id(task)] Falsetry...finally的写法很关键——即使任务函数抛异常标志位也能被复位避免调度器死锁。这个细节踩过坑的人都知道丢了 finally一次异常就能让整个调度系统瘫痪。不过防重入只是兜底方案。有些任务即使被跳过一两次也能接受比如 OLED 刷新但舵机控制这类任务跳过一次可能导致动作不连贯。所以要尽量保证任务函数在周期内跑完。3.4 让主循环和定时器配合共享变量要小心有了调度器主循环也不是完全闲着。有些耗时操作不适合放中断回调里就得放主循环。这时涉及一个重要问题中断和主循环共享变量。MicroPython 在单核上跑理论上没有真正的并发但中断可以在任意时刻打断主循环如果在主循环里读了半个变量的值中断又改了它就会出现数据不一致。举个经典例子# 主循环里读取计数 count shared_count # 就在这一瞬间中断回调改了 shared_count # 但 count 已经拿到了旧值解决方法是中断回调里只改某个变量主循环里一次性读取并处理。如果变量类型是整数、布尔值MicroPython 保证了单次赋值是原子的问题不大。但如果是 list、dict 这类复合类型就要小心了。我的建议是中断回调里只更新简单类型变量主循环负责所有复杂逻辑。4. 实战案例用定时器调度器控制舵机4.1 舵机控制原理为什么 20ms 刷新一次这么重要舵机是航模、机器人项目里最常见的执行器。市面上常见的 SG90 舵机控制信号是周期 20ms 的 PWM 波其中高电平持续时间脉宽决定了舵机角度。0.5ms 脉宽对应 0 度1.5ms 脉宽对应 90 度2.5ms 脉宽对应 180 度所以控制舵机本质上就是控制 PWM 的占空比。Pico 的machine.PWM模块可以很方便地输出 PWM结合定时器任务可以实现平滑的舵机运动。需要注意的是有些舵机的脉宽范围不是 0.5ms 到 2.5ms而是 1ms 到 2ms具体要看舵机型号的说明书。我建议写一个映射函数把角度值映射到对应的脉宽方便统一调整。4.2 定时器调度 PWM写一个多舵机平滑控制程序下面这个程序能控制两个舵机平滑摆动同时不影响其他任务运行。from machine import Timer, PWM import math # 舵机引脚定义 servo1_pin PWM(0) # GP0 servo2_pin PWM(1) # GP1 # 设置 PWM 频率为 50Hz对应周期 20ms servo1_pin.freq(50) servo2_pin.freq(50) # 角度转占空比的函数占空比范围 0-65535 def set_servo_angle(pwm, angle): # 舵机角度范围 0~180度 # 0度对应脉宽 0.5ms180度对应脉宽 2.5ms # 20ms 周期下占空比 脉宽 / 20ms * 65535 min_duty int(0.5 / 20 * 65535) max_duty int(2.5 / 20 * 65535) duty min_duty int((max_duty - min_duty) * angle / 180) pwm.duty_u16(duty) # 调度器任务让两个舵机对称摆动 def servo_task(): t_ms scheduler.counter * scheduler.tick_ms angle1 90 80 * math.sin(t_ms / 1000) angle2 90 - 80 * math.sin(t_ms / 1000) set_servo_angle(servo1_pin, angle1) set_servo_angle(servo2_pin, angle2) # 创建调度器1ms 节拍 scheduler Scheduler(tick_ms1) scheduler.add_task(servo_task, period_ms20, priority1) scheduler.start() while True: pass这段代码里sin函数生成一个平滑变化的轨迹两个舵机反向摆动。关键在于servo_task的执行周期是 20ms正好和舵机的 PWM 周期一致每次定时器触发都更新一次脉宽舵机动作会非常流畅。实测下来的感受用调度器控制舵机和普通轮询完全两码事。普通轮询如果主循环里还干了别的事舵机容易一顿一顿的用定时器调度舵机动作始终丝滑。4.3 占空比 100% 那个坑PWM 输出到不了满占空比网上搜定时器输出 PWM总能看到“占空比到不了 100% 异常”这类问题。这事在 STM32 上比较常见配置时计数器到了 ARR 却不一定会触发更新中断。Pico 的 MicroPython 里duty_u16(65535)就是满占空比没毛病。但如果你在下游电路里发现输出不对多半是舵机驱动板、电平转换、供电问题而不是 PWM 本身的问题。舵机控制里我踩过一个很典型的坑舵机在满占空比附近会抖动甚至啸叫。原因是某些舵机的控制芯片在脉宽接近极限时会产生内部振荡。解决方案很简单软件上把角度限制在 5~175 度别真跑到 0 和 180 的极限值。4.4 多任务同时跑OLED 刷新和按键扫描怎么塞进去舵机跑起来之后再把 OLED 刷新、按键扫描都加进去也不复杂。def oled_refresh(): # 刷新 OLED 显示假设有数据已准备好 oled.fill(0) oled.text(Servo Demo, 0, 0) oled.text(Angle: %.1f % current_angle, 0, 20) oled.show() def key_scan(): # 按键扫描检测到按下就切换舵机模式 if btn.value() 0: # 按下为低电平 mode not mode time.sleep_ms(50) # 简单消抖 scheduler.add_task(oled_refresh, period_ms100, priority0) scheduler.add_task(key_scan, period_ms10, priority0)OLED 刷新 100ms 一次10Hz 刷新率人眼看着是流畅的。按键扫描 10ms 一次足够快。三个任务互不干扰各跑各的周期这就是调度器的价值。完整项目代码大致结构如下你可以直接照着搭建# main.py 完整结构示意 from machine import Timer, PWM, Pin import time, math from scheduler import Scheduler # 初始化硬件... # 定义各个任务函数... # 创建调度器并注册任务... # 调度器启动...4.5 为什么不用官方_thread多线程我踩过的坑MicroPython 的_thread模块确实可以创建真正的多线程双核 Pico 还能把两个核都用起来。但我在实际项目里试过几次体验不太好。_thread的坑主要这几个线程同步要靠lock用不对就死锁共享变量竞争问题严重一个线程改值、另一个线程读值经常读到半新半旧的数据MicroPython 的线程调度不受控什么时候切换上下文全看解释器心情。相比之下定时器调度器写出来的代码是单线程的不存在共享变量的竞争问题只要注意中断和主循环的交互就行思维负担小很多。双核同时跑固然厉害但大部分项目根本没到需要双核的程度。用调度器把单核用好性价比最高。5. 常见问题排查与实验数据记录5.1 定时器回调里 print 一堆系统直接卡死很多人第一次写定时器回调喜欢在里面加print调试。打印一多定时器回调的执行时间被拉长下一轮中断又来了直接堆叠系统就假死了。排查方法先注释掉所有print看系统是否恢复正常。如果正常把打印放到主循环里通过标志位触发打印。def debug_task(): print(current counter:, scheduler.counter)像这样把打印单独做成一个低优先级任务周期设成 500ms就不会影响核心任务了。5.2 任务执行超时调度器越跑越乱有时候任务本身没超时但_tick回调里的总耗时会超过节拍时间。比如节拍是 1ms但当前该执行的任务有 5 个每个耗时 0.3ms合计 1.5ms下一轮中断来了上一轮还没跑完。排查思路用时间戳统计每次_tick的耗时超过 1ms 就有问题。def _tick(self, t): start time.ticks_us() # ... 执行任务 ... cost time.ticks_diff(time.ticks_us(), start) if cost self.tick_ms * 1000: print(tick overrun: %d us % cost)一旦发现 overrun就该把周期短、耗时长的任务拆分或者把节拍时间调大比如从 1ms 改成 5ms。5.3 舵机通电后乱抖甚至啸叫舵机抖动的原因通常是这几个供电不足、脉宽更新频率太低、信号线上有干扰。先排除硬件问题——用万用表测舵机供电电压SG90 要 4.8V 以上才稳定信号线尽量短远离电机线。软件层面确保舵机任务周期是 20ms 或更短。如果调度器节拍是 10ms舵机任务周期设为 20ms 还好但如果误设成 50ms 甚至 100ms舵机会因为刷新率太低而抖动。5.4 我用示波器抓到的调度精度实测数据严谨起见我用逻辑分析仪抓过 Pico 的 GPIO 翻转波形测试调度器对 1ms、10ms、100ms 周期任务的执行精度。简单测法在每个任务函数里翻转一个 GPIO 引脚用逻辑分析仪记录波形。1ms 周期任务实际周期 1.02ms 左右抖动 ±0.05ms正常10ms 周期任务实际周期 10.00ms 左右抖动 ±0.1ms稳定100ms 周期任务实际周期 100.05ms 左右几乎无抖动这说明软件定时器调度器在 Pico 上做到毫秒级任务调度完全够用。当然这个精度受 MicroPython 解释执行速度影响不同固件版本可能有细微差异。我的建议是跑项目前先用翻转 GPIO 这个办法测一下你的调度器精度做到心里有数。5.5 定时器中断里访问 I2C 设备要注意什么I2C 通信本身是慢速协议一次读写可能要几百微秒到几毫秒。如果中断回调里直接做 I2C 读写会严重拖慢中断处理还可能因为 I2C 通信被打断而出错。我的做法是中断回调只设置标志位主循环里检查标志位再执行 I2C 操作。i2c_pending False def i2c_task(): global i2c_pending # 这里只是标记不实际执行 i2c_pending True # 主循环中 while True: if i2c_pending: i2c_pending False read_sensor_data() # 实际 I2C 操作这个故事在这个领域很常见定时器负责“提醒”主循环负责“干活”。两个角色明确了系统才不会乱。6. 我在工作中积累的几个插件级优化技巧最后分享几个小技巧都是我在不同项目里踩过坑、总结了经验之后沉淀下来的。第一定时器的期数计数器用time.ticks_ms()更省事。我自己写的调度器最开始是用counter * tick_ms来算当前时间但如果 tick_ms 不是 1ms或者中间有跳变计算就会出问题。后来改用time.ticks_ms()直接拿当前毫秒时间戳省心很多。MicroPython 的ticks_ms()有 2 的 30 次方取模特性约 12.4 天回绕一次普通项目根本不用管。def _tick(self, t): now_ms time.ticks_ms() for task in self.tasks: if task.run_tick_ms is None or time.ticks_diff(now_ms, task.run_tick_ms) task.period_ms: task.run_tick_ms now_ms task.func()第二任务函数尽量写成“非阻塞协作式”。也就是说一个任务如果某次执行没做完也不要在一个函数里死等而是分多次做每次做一小部分。比如 OLED 刷新一整屏需要 50ms可以拆成 10 次每次只刷新 1/10 的内容。这样虽然总耗时长但每次中断占用的时间很短整个系统响应性大幅提升。第三所有任务函数开头加一个if not enabled: return开关。这个习惯非常有用。调试的时候想临时关掉某个任务不用去注释注册代码直接改一个全局标志位就行。项目上线后还可以留着做远程控制、故障诊断。第四不用把所有东西都塞进调度器。有些任务天生适合放主循环比如需要长时间阻塞等待用户输入的场景。调度器适合放周期性的、独立的小任务。两者结合使用效果最好。7. MicroPython 定时器的性能边界与避坑建议7.1 定时器数量上限到底是多少MicroPython 的官方文档说Timer的 ID 可以为 -1、0、1 等但实际操作中Pico 上可用中断定时器是有限资源。我测试过同一时间创建 10 个软件定时器系统还能正常跑但太多定时器叠加会导致 CPU 占用率飙升。我的建议是调度器用 1 个定时器就够了所有的周期任务都挂在调度器上不要每个任务单独开一个 Timer。这样最省资源也最好调试。7.2 极端情况定时器回调时间过长导致失控如果调度器回调里的总耗时超过节拍周期的 2 倍系统会进入一种失控状态具体表现是主循环几乎没时间执行所有 CPU 都在处理中断堆积。预防措施调度器里加一个 watchdog 计时器。如果连续多次_tick超时就自动把节拍调大或者打印警示信息。class Scheduler: def _tick(self, t): start time.ticks_us() # 执行任务... cost time.ticks_diff(time.ticks_us(), start) if cost self.tick_ms * 1000: self.overrun_count 1 if self.overrun_count 10: print(调度器严重过载建议降低任务频率)虽然这个处理方式很朴素但关键时刻能帮你快速定位问题不会看着一片漆黑无从下手。7.3 低功耗场景下的定时器调度进入休眠需谨慎如果你的项目有低功耗需求定时器调度器反而要小心了。Pico 进入 sleep 模式后定时器中断能不能唤醒系统取决于用的是哪种休眠模式。MicroPython 的time.sleep会阻塞 CPU定时器回调也会被暂停。低功耗场景我的建议是用machine.lightsleep()配合定时器唤醒或者直接在调度器里加一个“空闲时进入休眠、中断唤醒后继续调度”的状态机。但这块内容展开又是一篇长文普通项目用不到先知道有这回事就行。7.4 多核 Pico 能不能用两个核跑调度器Pico 双核是个绕不开的话题。理论上你可以让核 0 跑调度器核 1 跑阻塞任务。MicroPython 里用_thread可以启用第二个核但两个核共享外设时要非常小心尤其是 UART、I2C 这类总线设备两边同时访问容易出问题。我的个人建议先别碰多核。把单核调度器玩熟大部分项目就够用了。真到了需要同时处理 Wi-Fi 通信 传感器采集 UI 刷新 执行器控制的复杂场景再考虑多核架构并且一定要做好核间通信的设计。8. 调度器代码的完整封装与后续扩展方向8.1 把调度器封装成模块复制到任何项目直接用自己写的调度器如果想重复用建议保存成单独的sched.py文件以后每个项目直接import使用。下面是我常用的完整封装代码。# sched.py from machine import Timer import time class Scheduler: def __init__(self): self.tasks [] self._lock False self._timer Timer() def add_task(self, func, period_ms, priority0): task { func: func, period_ms: period_ms, priority: priority, last_run: 0, running: False } self.tasks.append(task) self.tasks.sort(keylambda x: x[priority], reverseTrue) return task def _tick(self, t): if self._lock: return self._lock True try: now time.ticks_ms() for task in self.tasks: if task[running]: continue if time.ticks_diff(now, task[last_run]) task[period_ms]: task[last_run] now task[running] True try: task[func]() finally: task[running] False finally: self._lock False def start(self, freq1000): self._timer.init(freqfreq, modeTimer.PERIODIC, callbackself._tick) def stop(self): self._timer.deinit()用的时候主文件里几行代码就完成初始化from sched import Scheduler sched Scheduler() sched.add_task(servo_task, period_ms20, priority1) sched.add_task(print_task, period_ms1000, priority0) sched.start(freq1000)8.2 从“定时调度”到“消息队列”任务间通信的进阶玩法调度器解决了“什么时候执行”的问题但没解决“任务之间怎么传递数据”的问题。比如按键任务检测到按下要告诉舵机任务切换模式怎么传最简单的方案是共用全局变量。前面也说了中断回调里只改简单类型主循环读问题不大。但全局变量多了代码会变得混乱调试也不方便。进阶方案是轻量级消息队列from collections import deque msg_queue deque((), 20) def key_task(): global msg_queue if btn_pressed: msg_queue.append(toggle_mode) def servo_task(): global msg_queue if msg_queue: msg msg_queue.popleft() if msg toggle_mode: mode not modedeque 的 popleft 操作是 O(1) 的队列长度有限不会无限积压。这样任务之间的耦合度降低每个任务只管自己的逻辑读消息、发消息。这个模式在工程上非常实用。8.3 把精度再提一层用 DMA 和 PIO 做硬实时任务如果哪天你觉得软调度器的精度不够用了想更进一步那就得换技术路线了。Pico 的 PIO 模块可以输出非常精准的波形DMA 可以在不占用 CPU 的情况下搬运数据。比如要输出一长串精确到纳秒级的脉冲序列用 DMA 把脉冲数据喂给 PWM 或 GPIO全程不需要 CPU 参与。这就是硬实时的做法。但代价是代码复杂度上升一个数量级MicroPython 里的 PIO 编程也相对小众。我的建议是先明确项目需求真的需要硬实时吗如果只是控制舵机、LED、传感器软调度完全够用。需要精确波形时再上 PIO没必要一开始就把自己逼到最难的方案里。8.4 调度器的测试方法模拟任务 时间统计写完调度器怎么验证它对不对、稳定不稳定我习惯写几个模拟任务来压测。def tight_task(): # 模拟耗时任务 total 0 for i in range(100): total i def counter_task(): global count count 1分别把tight_task的执行周期设为 2ms、5ms、10ms开一个小时看看counter_task的计数是否接近理论值误差多大。如果误差超过 5%说明调度器或任务设计有问题需要优化。这个测试方法简单高效能帮你提前发现大部分问题。8.5 结合机器人项目的完整调度规划一个真实例子最后分享一个我的实际项目案例。一台小型双舵机云台带超声波避障OLED 显示状态。整个项目的任务规划如下任务周期优先级说明舵机 PWM 刷新20ms最高云台跟随算法超声波测距50ms高触发测距并读取按键扫描10ms中切换运行模式OLED 刷新100ms低显示距离和模式状态日志打印1000ms最低调试用用调度器把这些任务挂上去之后系统非常稳定云台控制流畅OLED 数字更新也不闪烁。这个 20ms 舵机刷新 10ms 按键扫描的组合在裸while True轮询时代是根本做不到的。9. 最后再聊两句大实话定时器调度器这事看着高深其实核心就三件事分时间片、按时触发、任务隔离。MicroPython 这套封装已经帮我们做了 80% 的底层工作我们要做的 20%就是理清楚每个任务的周期和优先级然后按规矩把代码组织好。写代码的时候要有几个习惯任务函数保持短小精悍别在回调里干重活每个任务加开关标志位别轻易用_thread多线程遇到奇怪的 bug先用 GPIO 翻转测时序把调度精度量出来再分析。这些习惯能帮你避开 90% 的坑。后续如果想把这块玩深可以从三个方向入手一是把调度器扩展成支持协程让任务函数可以在中途暂停二是给调度器加统计功能实时显示每个任务的执行耗时和超时次数三是结合 PIO 做硬实时波形输出把 Pico 的潜力完全榨干。我这个项目目前跑得很稳整套代码已经抽出来做成模板了新项目直接改任务周期和函数体就能用确实省心。
网站建设高端定制企业官网