1. 内容整体设计与思路拆解
1.1 为什么树莓派 Pico 的定时器值得单独拿出来写一篇
很多玩单片机的朋友一开始接触树莓派 Pico,都是把它当一块“能跑 MicroPython 的高级 Arduino”来用。点亮 LED、读个温湿度、控制个舵机,确实上手极快。但等到你想做精确的时间控制,比如让一个电机精准地转 0.5 秒、采集 PWM 信号的频率、或者让多路输出严格保持相位关系,单纯靠time.sleep()或者while循环轮询就完全不够用了。
time.sleep()的精度在毫秒级还算凑合,但到了微秒级别就纯粹靠运气,因为 MicroPython 的解释执行本身就有不确定的指令开销。这时候你就需要真正理解 Pico 这颗 RP2040 芯片上的定时器硬件,它是怎么工作的、有哪些工作模式、如何配置才能发挥最大价值。这篇文章我从硬件寄存器层面讲到 MicroPython 的实际调用,把定时器的原理和几种典型工作模式一次说透,希望能帮你省下自己翻手册、试错的时间。
1.2 这颗芯片上到底藏着几个定时器
先说结论:RP2040 芯片上一共有两类和“定时”相关的硬件。
第一类是Timer,它本质是一个 64 位向上计数的计数器,时钟源默认是系统主频(可以配置分频),它的作用偏向“绝对时间测量”,比如记录程序运行了多久、某个事件发生时的精确时间戳。MicroPython 的machine.Timer就是基于它实现的。
第二类是PWM 模块,RP2040 有 8 个 PWM slice,每个 slice 带两个输出通道(A/B),每个 PWM 模块内部都有一个 16 位的计数器。虽然名字叫 PWM,但你完全可以把它当作定时器来用,比如输出指定频率的方波、测量外部信号的脉宽、甚至捕获脉冲计数。很多新手不知道的是,Pico 的 PWM 模块支持电平捕获模式(Input mode),这在用 Pico 做频率计、占空比分析时非常有用。
搞懂这两类硬件的区别,你才能在做项目时选对工具。需要“现在是什么时刻”用 Timer,需要“持续产生精确波形”或者“测量外部信号”用 PWM 模块。接下来我把这两类硬件的底层细节逐个拆开。
2. 核心硬件原理:64 位计数器与 PWM 计数器的本质
2.1 64 位系统定时器的工作机制
RP2040 的系统定时器(Datasheet 里叫 Timer)是一个 64 位递增计数器,从 0 开始一直往上数。它的时钟源是系统主频,默认 125MHz,也就是说每纳秒计数值加 0.125,每 8 纳秒加 1。64 位的计数值意味着它可以连续运行几百年不回绕,所以你完全不需要担心溢出问题——这在做长时间运行的项目时特别省心。
这个计数器有四个报警通道(Alarm),每个定时器报警通道都可以设置一个目标值,当计数器的值到达这个目标时,会触发一次中断。MicroPython 的Timer类本质上就是把其中一个报警通道包装成了软件定时器,每次触发后在中断里重新装载目标值,从而实现周期性回调。
我在实际调试中发现,MicroPython 的machine.Timer.init()直接使用这个硬件报警通道,所以理论上中断触发精度很高。但有个坑是 MicroPython 层的回调函数本身需要执行时间,如果你在回调里做了太多事情,比如打印字符串、操作文件系统,那么回调的执行时间会直接影响下一次触发的准确性。这点在精密时序场景下要特别留意。
提示:如果需要微秒级的定时精度,建议直接在 C SDK 里操作寄存器,绕开 MicroPython 的抽象层。MicroPython 的 Timer 回调本身很适合毫秒级任务,比如每 10 毫秒采集一次传感器、每 1 秒刷新一次 OLED 显示。
2.2 PWM 模块内部的 16 位计数器
再来看 PWM 模块。每个 PWM slice 内部的计数器是一个 16 位递增/递减计数器,频率由系统时钟经过分频后驱动。分频器是 8 位整数分频加 4 位小数分频的组合,所以你可以实现非常细的频率调节。
PWM 模块的计数器不仅用于生成波形,它的工作模式还包括:
- Free-running counter:计数器从 0 数到
WRAP值然后清零重新开始,这是标准 PWM 模式。 - Input mode(输入捕获):计数器受外部引脚电平控制,只有引脚为高电平时才计数。这个模式用来测量外部信号的脉宽特别有用。
- Rising/Falling edge capture(边沿捕获):计数器会在外部信号的上升沿或下降沿记录当前计数值,从而推算出频率或脉宽。
很多做机器人、航模的朋友买 Pico 是为了读取遥控接收机的 PWM 信号。遥控接收机输出的是一路 50Hz 左右、脉宽 1~2ms 的方波信号,用普通 GPIO 外部中断配合time.ticks_us()也可以测,但误差往往有几微秒到几十微秒。而用 PWM 模块的输入捕获模式,可以做到纳秒级的边沿时间戳记录,效果天差地别。
3. 工作模式详解与 MicroPython 配置方法
3.1 周期定时模式:最常用的软件定时器
MicroPython 的machine.Timer用法非常直观。用它实现周期回调,代码就几行:
from machine import Timer timer = Timer() timer.init(mode=Timer.PERIODIC, period=1000, callback=lambda t: print("tick"))这段代码会每 1000 毫秒(1 秒)执行一次回调函数。period的单位是毫秒,如果你要更短的周期,可以传小数,比如period=0.5表示 500 微秒。但实测时需要注意,MicroPython 解释器处理回调本身有开销,过短的周期会导致回调堆积,实际触发频率达不到设定值。
还有一个有力的参数是mode=Timer.ONE_SHOT,它会在指定的延时后只触发一次回调。这个模式在做“延时后关断设备”“等待一定时间后发送心跳包”这类逻辑时非常清爽,不需要自己维护标志位。
3.2 计数器计数模式:统计外部脉冲数量
有些场景需要统计脉冲个数,比如:
- 读取编码器的输出脉冲,计算电机转速
- 统计流量计的脉冲信号,累计流量
- 统计外部事件触发的 GPIO 边沿次数
RP2040 的 PWM 模块天生支持在 GPIO 引脚上计数。在 MicroPython 里,你可以用machine.PWM的input()方法进入输入模式,然后读取计数器的值。代码是这样:
from machine import Pin, PWM pwm = PWM(Pin(16)) # 使用 GP16 作为输入引脚 pwm.freq(1000) # 设置一个基础频率,仅为了初始化 pwm.duty_u16(0) pwm.input() # 切换到输入捕获模式然后你可以通过读取pwm.counter()方法拿到当前的脉冲计数值。这个计数器是 16 位的,最大计数范围是 0~65535,如果脉冲数量可能超过这个值,你需要在软件里处理溢出,每次读完后判断是否回绕。
3.3 PWM 输出模式:精确控制波形的频率和占空比
PWM 输出模式本质上是“定时器 + 比较器”的组合。你需要设置三个关键参数:
- 频率:决定 PWM 波形的周期
- 占空比:决定高电平在一个周期内占的比例
- 相位(可选):多路 PWM 之间的起始相位关系
在 MicroPython 中,最简单的方式是:
from machine import Pin, PWM pwm = PWM(Pin(0)) # GP0 输出 PWM pwm.freq(50) # 50Hz,常用于舵机控制 pwm.duty_u16(3276) # 占空比 = 3276 / 65535 ≈ 5%这里的duty_u16取值范围是 0~65535,对应占空比 0%~100%。对于舵机控制,通常脉宽需求是 0.5ms~2.5ms,在 50Hz(周期 20ms)下,对应占空比 2.5%~12.5%,也就是duty_u16在 1638~8191 之间。我试过直接在上面代码的 Pico 上接 SG90 舵机,把duty_u16(1638)设为 0 度、duty_u16(8191)设为 180 度,转动非常平滑,无抖动。
3.4 输入捕获模式:测量外部信号脉宽和频率
这是 Pico 一个很惊艳的特性。当 PWM 模块处于输入模式时,它内部有个机制可以捕获外部信号上升沿/下降沿时的计数值,从而算出脉宽和周期。
在 MicroPython 中,虽然没有直接的官方库把输入捕获封装得很完整,但你可以用 C 扩展或者直接操作寄存器。对大部分玩家来说,更现实的方案是用machine.Pin.irq()配合time.ticks_us()来做边沿时间戳记录:
from machine import Pin import time last_rise = 0 pulse_width = 0 def pin_handler(pin): global last_rise, pulse_width if pin.value() == 1: last_rise = time.ticks_us() else: pulse_width = time.ticks_us() - last_rise pin = Pin(16, Pin.IN) pin.irq(trigger=Pin.IRQ_RISING | Pin.IRQ_FALLING, handler=pin_handler)这段代码可以测量输入信号的单个高电平脉宽精度在几十微秒量级,对于大多数航模遥控信号(脉宽 1~2ms)来说精度足够。如果你需要更高精度,请走 PWM 输入捕获寄存器或 C SDK 方案。
4. 实操过程:从延时到舵机控制再到频率测量
4.1 用定时器实现高精度延时
不依赖time.sleep(),而是用定时器寄存器来实现短延时。在 MicroPython 里,time.ticks_us()本身是微秒级的时间戳,配合忙等待可以实现相对精确的延时:
import time def delay_us(us): start = time.ticks_us() while time.ticks_diff(time.ticks_us(), start) < us: pass # 使用 delay_us(100) # 延时 100 微秒这种方法比time.sleep(0.0001)准确得多,因为time.sleep在 MicroPython 中延迟精度受解释器调度影响,实测误差可达 10%~30%。而忙等待是用 CPU 循环计数,误差主要来自ticks_us()本身的调用开销和循环判断分支,实测误差在几微秒以内,对大多数传感器时序控制完全够用。
4.2 多路 PWM 输出实现舵机控制项目
来一个完整小项目:用 Pico 同时控制两个舵机,一个做周期性摆动,一个根据电位器角度调整位置。
from machine import Pin, PWM, ADC import time # 初始化两个舵机 PWM servo1 = PWM(Pin(0)) servo1.freq(50) servo2 = PWM(Pin(1)) servo2.freq(50) # 初始化电位器 adc = ADC(Pin(26)) def set_angle(pwm_obj, angle): # 角度范围 0~180,脉宽 0.5ms~2.5ms,50Hz 下对应 duty_u16 pulse_min = 1638 pulse_max = 8191 duty = int(pulse_min + (pulse_max - pulse_min) * angle / 180) pwm_obj.duty_u16(duty) while True: # 舵机1 从 0 度到 180 度循环 for angle in range(0, 181, 2): set_angle(servo1, angle) time.sleep_ms(10) # 舵机2 跟随电位器 val = adc.read_u16() # 0~1023 angle = val / 65535 * 180 set_angle(servo2, angle) time.sleep_ms(20)注意几点:舵机 PWM 频率设置为 50Hz 是标准值,但不是绝对的,很多数字舵机支持更高频率(如 100Hz~330Hz),设置更高频率可以减少舵机抖动,但要确认舵机支持范围。set_angle函数里用了线性映射,不同舵机的脉宽边界略有差异,你可以通过实测微调pulse_min和pulse_max来获得满量程转动范围。
4.3 用定时器周期采集传感器并处理毛刺
在工业级数据采集中,定时器经常用来以固定的时间间隔采样。比如每 10ms 读一次 ADC,做滑动平均滤波:
from machine import Timer, ADC, Pin adc = ADC(Pin(26)) values = [] def sample_cb(t): values.append(adc.read_u16()) if len(values) > 50: values.pop(0) timer = Timer() timer.init(mode=Timer.PERIODIC, period=10, callback=sample_cb) # 主循环可以处理其他任务,比如显示或者通信 while True: if len(values) >= 10: avg = sum(values[-10:]) // 10 print("avg:", avg) time.sleep_ms(50)这样的好处是采样时间间隔严格由硬件定时器保证,主循环里的其他代码不会干扰采样节拍。若直接在while循环里用time.sleep_ms(10)采样,一旦循环里的处理逻辑耗时波动,采样间隔就会抖动,后期做 FFT 或控制算法时会出现杂散噪声。
4.4 通过定时器实现软件串口(Bit-banging)
有些场景下你的串口不够用了,比如多路传感器都要走 UART,这时候可以用定时器配合 GPIO 做个软件串口。虽然 Pico 的 UART 硬件资源有限,但好在 RP2040 主频足够高,用定时器中断模拟 9600 波特率的串口发送完全可行。
基本思路是:
- 引脚配置为输出模式
- 将一字节数据拆成 8 个 bit,加上起始位和停止位
- 用定时器设定每个 bit 的时间间隔(9600 波特率下约 104.2 微秒)
- 每中断一次输出一个 bit
这是个进阶玩法,不过理解了定时器中断的精确性,你还可以做更复杂的协议,比如 WS2812B 灯带的时序控制。WS2812B 要求 bit 周期精确到亚微秒级,用 MicroPython 的time.sleep_us会有不可控误差,实际用下来 800kHz 的数据率很难稳定驱动。这时候就需要把时序敏感的部分写到 C 扩展里,或者使用 PIO 模块。这也是为什么我建议你在掌握定时器之后,顺手了解一下 PIO——它是 RP2040 上另一大神器。
5. 常见问题与排查技巧实录
5.1 定时器回调不执行或执行频率不对
现象:写了Timer.PERIODIC,但回调要么不触发,要么触发的频率远远低于设定值。
排查思路:
- 确认回调函数没有长时间阻塞。如果回调里有
time.sleep()、耗时算法、文件读写,会严重拖慢下一次触发。 - 确认
period的单位是毫秒。写period=1是 1 毫秒,不是 1 微秒。 - 检查是否创建了多个 Timer 实例。MicroPython 的
machine.Timer在 RP2040 上通常只支持有限的实例数,创建过多或者重复init()可能导致冲突。 - 如果你的回调执行时间超过了
period,那么实际上周期会被拉长到“回调执行时间 + 剩余等待时间”,这是正常现象,不是硬件故障。
我在实际项目中遇到过:回调里做了一行print(),在 Mu 编辑器的串口终端里看没问题,但接上某些 USB 转串口工具后打印阻塞了,导致整体节奏被打乱。排查半天才发现是打印的锅。
5.2 PWM 输出频率和理论值对不上
现象:你设置pwm.freq(1000),但用示波器或者逻辑分析仪看,实际频率是 999.5Hz 或 1001Hz 这样的小偏差。
原因:RP2040 的 PWM 分频器是整数分频加 4 位小数分频,不是所有频率都能精确表示。以 125MHz 系统时钟为例,要得到精确的 1000Hz,需要计数器 wrap 值为 125000 - 1,这个数值是整数,因此 1000Hz 可以精确。但如果你设置 333Hz,125000000 / 333 ≈ 375375.375,这个 wrap 值不能精确取整,所以实际频率会有偏差。
解决:在要求精确频率的场景,先反推系统时钟、分频器和 wrap 值,选择可以整除的配置组合。或者使用 C SDK 直接设置寄存器,避免 MicroPython 层的近似处理。
5.3 输入捕获模式下读到的计数器值异常
现象:用 PWM 输入模式测量脉冲频率,输出的值和万用表/示波器差很多。
排查思路:
- 确认 GPIO 引脚是否支持 PWM 输入,RP2040 并非所有引脚都有 PWM 功能,需要通过复用功能表查。
- 输入信号的电压逻辑是否匹配,Pico 的 GPIO 是 3.3V 逻辑,5V 信号需要分压或电平转换。
- 检查计数器回绕问题。16 位计数器在 125MHz 下约 524 微秒回绕一次,如果你测量的信号周期大于这个值,需要扩展计数或者测量多个周期取平均值。
5.4 多路定时任务如何合理安排
当你有多个定时任务,比如同时需要 50Hz 舵机控制、10Hz 传感器采样、1Hz 状态刷新,最自然的方式是创建多个Timer实例。但 Pico 上的硬件报警通道只有 4 个,MicroPython 的Timer类实现会占用其中一部分。
更优雅的做法是使用一个 1ms 周期的 “tick” 定时器,然后维护软件计时器列表:
from machine import Timer import time tasks = [] def tick(t): now = time.ticks_ms() for task in tasks: if time.ticks_diff(now, task['last']) >= task['interval']: task['last'] = now task['func']() timer = Timer() timer.init(mode=Timer.PERIODIC, period=1, callback=tick) tasks.append({'interval': 20, 'func': my_servo_update, 'last': 0}) tasks.append({'interval': 100, 'func': sample_sensor, 'last': 0}) tasks.append({'interval': 1000, 'func': update_display, 'last': 0})这种方式只用了一个硬件定时器,其他任务全部由软件切片管理,避免了多 Timer 实例的资源冲突问题。需要注意的是,tick回调必须非常轻量,只做变量更新和最简判断,复杂的任务逻辑放到主循环去执行。
5.5 定时器中断和 MicroPython 垃圾回收的相互影响
MicroPython 运行在 RP2040 上时,会周期性地执行内存垃圾回收(GC)。如果在定时器中断回调中创建对象(比如list.append()、字符串拼接),那么回调执行时间会因为 GC 波动而变得不稳定。
解决办法:
- 回调函数只设置标志位或简单变量,不做对象分配
- 需要传递数据时,预先分配好 list 或者数组,回调里直接赋值
- 主循环里集中处理数据,避免在中断上下文做复杂逻辑
我在做电机控制时,就发现定时器回调里如果调用了math.sin(),执行时间波动很大,导致 PID 输出抖动。后来我把所有数学计算移到主循环,回调只负责采样和置标志位,效果立竿见影。
6. 从定时器到更多外设:常用配置速查表
为了方便日常开发,我整理了一张常用定时器方案的速查表,覆盖了我在项目中用得最多的几个场景:
| 场景 | 硬件模块 | 核心参数 | MicroPython 示例 |
|---|---|---|---|
| 周期任务调度 | Timer | period 毫秒级 | Timer.PERIODIC, period=10 |
| 短延时 | Timer + busy wait | 微秒级 | time.ticks_us()忙等待 |
| 舵机控制 | PWM | 50Hz,脉宽 0.5~2.5ms | freq(50),duty_u16(1638~8191) |
| LED 呼吸灯 | PWM | 1kHz,占空比渐变 | 配合定时器累加 duty |
| 频率测量 | PWM 输入模式 | 16 位计数器 | 参考上文输入捕获 |
| 脉冲计数 | PWM 输入模式 | 计数器累加 | 读pwm.counter() |
| 软件串口 | Timer + GPIO | 波特率决定中断间隔 | 位定时 104.2us @9600 |
| 多路任务调度 | 单 Timer + 软件列表 | 1ms tick | 参考上文 tasks 列表 |
这张表不是万能手册,但可以作为你构建项目的起点。实际项目里,定时器和 PWM 往往是配合使用的,别把精力全耗在单一外设的细节上,多想想它们之间的组合能力。
7. 最后再分享一个调试技巧
如果你手头没有示波器和逻辑分析仪,可以用一个最“笨”但有效的办法来验证 PWM 输出和定时器中断的实际频率:把待测引脚外接到另一个 GPIO,然后用time.ticks_us()测量两次跳变之间的时间差,打印到串口。这个方法虽然精度有限,但能快速发现明显的问题,比如分频配错导致频率差了一个数量级、回调阻塞导致周期翻倍等。
另外,RP2040 的数据手册(RP2040 Datasheet)第 4 章关于 Timer 和 PWM 的内容值得精读,网上也能找到很多人分享的寄存器操作示例。我在看完数据手册后,很多 MicroPython 层面令人困惑的行为都变得合理了——上层封装再怎么方便,底层逻辑才是定海神针。希望你读完这篇文章后,也可以根据需求灵活选择 Timer 还是 PWM,而不是遇到定时需求就一律sleep,遇到波形输出就硬靠 GPIO 手动翻转。
最后补充一点:如果做的是量产产品,在定时精度要求高的场景建议直接用 C SDK 开发,MicroPython 更适合原型验证和快速迭代。两条路线互补,别把话说死,毕竟工具是死的,人是活的。