说实话,树莓派 Pico 的定时器看起来不难,官方文档翻几页就能上手,可真到项目里用的时候,你会发现它和 STM32、51 那种传统定时器的思路完全不是一个路子。最近好几个从单片机转过来的朋友都在问我同一个问题:Pico 的定时器到底怎么用?API 有哪些?为什么我照着 STM32 的写法总是卡住?
这篇文章就把树莓派 Pico 定时器的概要信息和 API 从头到尾捋一遍,从硬件资源、MicroPython 侧到 C SDK 侧,再到实际场景里的坑,一次说透。适合刚接触 Pico 的嵌入式新手,也适合想快速评估 Pico 能不能承担定时任务的硬件工程师,内容尽量做到“极简”,能查表的绝不多写废话。
1. 先把定时器这件事说清楚:Pico 的硬件时间从哪来
1.1 为什么 MCU 需要定时器,而不是一直 delay
很多新手写代码喜欢用sleep_ms(100)来控制节奏,比如让 LED 每 100ms 翻转一次。这个写法在只有单个任务的场景下没什么问题,可一旦你要同时处理按键扫描、OLED 刷新、串口收发、舵机脉宽输出,阻塞式的延时就立刻变成灾难——某个传感器等待响应的时候,其他任务全部卡死。
定时器的本质就是“把时间这个事情从 CPU 里拆出去”。CPU 设定好定时参数之后可以继续干别的事,时间一到,硬件产生中断或标记事件,程序再回过头来处理。这种“非阻塞 + 回调/事件标志”的结构,是构建复杂嵌入式系统的基本功。
Pico 解决这个问题的方式很特别:它不是传统意义上的“预分频 + 自动重装载计数器 + 比较寄存器”,而是用了一个 64 位的微秒计数器,一直在后台跑,所有定时功能都围绕这个单调递增的时间戳展开。这个设计理念和 STM32 的 TIM 外设差异很大,理解它之后,很多 API 的命名逻辑你就秒懂了。
1.2 Pico 的定时器硬件资源到底有多少
树莓派 Pico 使用的 RP2040 芯片内部有两个硬件定时器,编号分别是 Timer 0 和 Timer 1,每个定时器又带有 4 路独立的报警比较通道。也就是说,整个芯片最多可以同时设置 8 个独立的硬件定时报警点。
每个报警通道都对应一个绝对时间戳(absolute time),当 64 位微秒计数器到达这个时间戳时,就会触发一次中断。由于时间戳是 64 位的,理论可以表示的时间范围极长(大约 58 万年),所以你完全不用担心它“溢出回绕”,这在用绝对时间做任务调度的时候非常省心。
主频方面,Pico 默认系统时钟是 125MHz,也就是说微秒计数器每 125 个时钟周期加 1。你可以在pico_set_clock里调整主频,但微秒计数器会自动根据时钟频率校准,不需要你手动做除法。这个细节在项目里非常实用,你只管调用time_us_64()读时间戳就行,底层已经处理好了。
1.3 与 STM32、51 定时器的思维差异对比
很多从 STM32 和 51 转过来的朋友,一上来就习惯性地找“预分频器”“自动重装值”“PWM 输出模式”,结果发现 Pico 定时器 API 里根本没有这些东西。我们先花两分钟把这个差异掰开,后面看代码就顺了。
| 对比项 | STM32 / 51 定时器 | Pico (RP2040) |
|---|---|---|
| 计数宽度 | 16 位或 32 位 | 64 位微秒计数器 |
| 时间基准 | 预分频 + 自动重装 | 固定的 1us 时间戳 |
| PWM 输出 | 定时器直接输出 | 由独立的 PWM 外设负责 |
| 多路定时 | 多组 TIM 外设 | 2 个 Timer,4 路报警通道 |
| 中断回调 | 优先级别多,需自己写 NVIC 配置 | 一个回调函数指针,参数传递用户数据 |
也就是说,在 Pico 上,“PWM 输出”这个功能被彻底拆分给了 PWM 外设,定时器只负责“在某个时间点提醒你”。你要输出舵机波形,请去找machine.PWM或者 C SDK 的pwm_set_wrap;你要定时做任务调度,才来找定时器。这个思维转换很重要,否则你会在错误的 API 里翻半天找不到想要的功能。
2. MicroPython 下的定时器 API:一句话记住一个函数
2.1 machine.Timer 的两种模式:一次性与周期执行
MicroPython 侧使用定时器非常简单,核心就一个类:machine.Timer。它支持两种模式,Timer.ONE_SHOT是一次性定时,到达时间后只执行一次回调;Timer.PERIODIC是周期执行,每隔固定时间调用一次回调。
from machine import Timer # 一次性:1 秒后执行一次 def once_cb(t): print("time up") t1 = Timer(mode=Timer.ONE_SHOT, period=1000, callback=once_cb) # 周期:每 100ms 执行一次 def loop_cb(t): print("tick") t2 = Timer(mode=Timer.PERIODIC, period=100, callback=loop_cb)这里的period单位是毫秒,回调函数接收一个参数t,就是触发本次回调的 Timer 对象本身。我实测下来,这种写法在捣鼓小型状态机的时候特别顺手,比如按键长按检测、非阻塞轮询调度,都能用很短的代码实现。
有一点要提醒,MicroPython 的定时器回调是在中断上下文中执行的。你可以在里面翻转引脚、修改全局变量、发送极短的串口数据,但绝对不要在里面做延时等待、磁盘读写、大块内存分配这类操作,否则轻则卡顿,重则直接触发异常导致程序崩溃。正确做法是让回调尽快结束,把复杂的处理扔给主循环里的标志位。
2.2 初始化、启动、停止和释放:完整生命周期
在实际项目里,定时器通常不是配好就不管了,你经常需要动态地启停和释放。MicroPython 的 Timer 对象支持init()和deinit()两种方法,deinit()之后可以再用init()重新配置,不需要重新创建对象。
from machine import Timer, Pin led = Pin(25, Pin.OUT) state = False def blink(t): global state state = not state led.value(state) # 创建并启动 t = Timer(mode=Timer.PERIODIC, period=200, callback=blink) # 运行 3 秒后停止 time.sleep(3) t.deinit() # 重新配置为 500ms 周期 t.init(mode=Timer.PERIODIC, period=500, callback=blink) # 最终释放 t.deinit()这里有个特别容易踩的坑:如果你用Timer(...)创建对象后没有保存引用,比如直接写成Timer(mode=Timer.PERIODIC, period=100, callback=cb)然后后面再也没用到这个对象,MicroPython 的垃圾回收机制可能会把这个 Timer 对象回收掉,导致回调莫名其妙停止。我在 Pico 上实测过,确实会发生。
所以建议所有定时器对象都用一个全局变量或者类成员明确持有,别图省事匿名创建。如果是 C SDK 开发,这个问题的表现方式不同,但思路一样——你要保证报警回调注册之后,相关的上下文结构体一直存活。
2.3 用 time 模块做时间戳与延时测量
除了 Timer 外,MicroPython 还提供了基于系统时间的time模块,里面有几个函数非常适合做时间测量和超时判断。我用得最多的是time.ticks_ms()、time.ticks_us()和time.ticks_diff()。
import time start = time.ticks_us() # 执行某段代码... delta = time.ticks_diff(time.ticks_us(), start) print("elapsed us:", delta)注意,这里的微秒精度取决于 MicroPython 固件的实现,实测在 RP2040 上,ticks_us()的精度可以到几微秒级别,日常测性能完全够用。它跟定时器回调是两条线,一个是主动测量的工具,一个是被动通知的机制,两者配合使用能覆盖大多数开发场景。
3. C SDK 定时器 API:底层控制力的真正来源
3.1 sleep 系列与 alarm 系列:阻塞和非阻塞怎么选
如果项目用 C/C++ 开发,Pico SDK 提供的定时器 API 更接近硬件,控制力也更强。核心分为两大类:一类是阻塞式的sleep_us()、sleep_ms()、sleep_until(),另一类是非阻塞的add_alarm_in_us()、add_alarm_at()。
#include "pico/stdlib.h" #include "hardware/timer.h" // 阻塞延时 100ms sleep_ms(100); // 非阻塞:100ms 后执行回调 struct repeating_timer timer; add_repeating_timer_ms(100, repeat_callback, NULL, &timer); // 一次性报警 absolute_time_t target = make_timeout_time_ms(500); add_alarm_at(target, one_shot_callback, NULL, false);add_repeating_timer_ms是最常用的周期定时接口,回调函数返回值决定是否继续周期执行,返回true继续,返回false停止。这个接口底层自动分配一个硬件报警通道,所以你在应用层不需要关心 Timer 0 还是 Timer 1。
如果你对硬件有洁癖,也可以直接调用hardware_alarm_claim()、hardware_alarm_set_callback()和hardware_alarm_set_target()来手动管理报警通道。好处是你能精确控制每个通道,坏处是代码量变多,还要自己处理通道抢占。我的建议是,绝大多数项目用add_alarm_*系列就够了,等真的出现通道冲突或者极端时序需求再往下折腾。
3.2 回调函数里的“中断上下文”约束
无论你用 MicroPython 还是 C SDK,定时器回调都运行在中断上下文。这个听起来很基础,但很多新手就是在这里翻车的,最典型的错误是在回调里调用printf。
bool repeat_callback(struct repeating_timer *t) { printf("tick\n"); // 危险操作! return true; }为什么说危险?因为printf涉及串口输出,串口驱动本身可能依赖中断,在中断里再触发中断相关操作,轻则输出卡顿,重则死锁。我在调试时甚至遇到过用printf输出后整个定时器回路直接不工作的诡异现象。正确做法是:回调里只设置一个全局标志位,主循环检测到标志后再做串口输出和复杂处理。
volatile bool flag = false; bool repeat_callback(struct repeating_timer *t) { flag = true; // 尽快返回 return true; } int main() { // 主循环轮询标志 while (true) { if (flag) { flag = false; printf("main loop sees tick\n"); } } }另外,malloc这类动态内存操作在中断里也尽量别用,容易造成内存碎片和不确定的延迟。Pico 的资源很有限,宁可提前分配好静态缓冲区,也不要在回调里临时申请。
3.3 为什么不建议用定时器直接做 PWM
我在群聊里经常看到有人问“能不能用定时器直接输出 PWM”,这个问题放到 STM32 上确实可以,因为 STM32 的定时器外设自带 PWM 模式。但 Pico 不一样,它的 PWM 由独立的 PWM 外设实现,定时器只管定时提醒。
Pico 的 PWM 外设同样基于系统时钟,能够输出频率和占空比都可调的方波。推荐的做法是:
#include "hardware/pwm.h" // 初始化 GPIO 0 的 PWM 通道 gpio_set_function(0, GPIO_FUNC_PWM); uint slice = pwm_gpio_to_slice_num(0); pwm_set_wrap(slice, 12500); // 频率 = 125MHz / 12500 = 10kHz pwm_set_chan_level(slice, PWM_CHAN_A, 6250); // 占空比 50% pwm_set_enabled(slice, true);定时器在 PWM 场景下的角色是“节奏控制”,比如让 PWM 占空比按照一定时间间隔平滑变化,实现呼吸灯效果或舵机渐变。这两个外设各司其职,组合起来才是一个完整的控制系统。
4. 实操案例:三个真实场景从零写完
4.1 场景一:非阻塞 LED 呼吸灯
很多教程教你用sleep_ms写呼吸灯,那种写法会导致整个程序卡在循环里,其实用定时器可以非常优雅地实现。
from machine import Pin, PWM, Timer led = PWM(Pin(25)) # Pico 板载 LED led.freq(1000) duty = 0 direction = 1 def tick(t): global duty, direction duty += direction * 100 if duty >= 65535: duty = 65535 direction = -1 elif duty <= 0: duty = 0 direction = 1 led.duty_u16(duty) Timer(mode=Timer.PERIODIC, period=1, callback=tick)代码的思路是每 1ms 更新一次 PWM 占空比,变化步长是 100,整个呼吸周期大约 1.3 秒。由于定时器回调只是改一个寄存器的值,整个过程非常平滑。更重要的是,这个方案下主循环是完全空闲的,你可以在里面继续跑其他逻辑。
如果把占空比变化步长从 100 改成 500,呼吸速度会明显变快,你可以根据自己的视觉效果微调这个参数。
4.2 场景二:按键消抖和长按检测
按键消抖是嵌入式开发绕不开的经典话题。用定时器做周期扫描,比用delay消抖要可靠得多。
from machine import Pin, Timer btn = Pin(16, Pin.IN, Pin.PULL_UP) counter = 0 level = 1 last_level = 1 press_count = 0 def scan(t): global counter, level, last_level, press_count now = btn.value() if now != last_level: counter += 1 if counter >= 3: # 连续 3 次扫描都不同,认为电平稳定 level = now counter = 0 if level == 0: press_count += 1 else: counter = 0 last_level = now Timer(mode=Timer.PERIODIC, period=5, callback=scan)这里的核心逻辑是每 5ms 扫描一次按键状态,连续 3 次读到相同电平才确认状态变化,相当于 15ms 的去抖窗口。这样做的好处是消抖过程完全在后台进行,主循环随时可以读取press_count来执行快捷键逻辑。
注意我给scan函数里那些变量都加了global声明,MicroPython 里如果忘了声明全局变量,函数内部会默认创建局部变量,按键状态就永远更新不到了。这是新手最容易忽略的坑。
4.3 场景三:Pico 控制舵机平滑转动
结合很多人搜的“树莓派 pico 控制舵机”,这里用一个 SG90 舵机做演示。SG90 需要 50Hz(周期 20ms)的 PWM 信号,高电平时间 0.5ms~2.5ms 对应 0 度到 180 度。换算成duty_u16的范围是 1638~8192。
from machine import Pin, PWM, Timer servo = PWM(Pin(15)) servo.freq(50) duty = 1638 step = 15 # 每步变化量 def tick(t): global duty, step duty += step if duty >= 8192: duty = 8192 step = -step elif duty <= 1638: duty = 1638 step = -step servo.duty_u16(duty) # 每 20ms 更新一次占空比 Timer(mode=Timer.PERIODIC, period=20, callback=tick)把时间周期设为 20ms,正好是舵机一个 PWM 周期。每次回调让占空比步进一点,舵机就会在 0 度和 180 度之间来回平滑摆动,不会出现跳跃感。
这里要特别提醒:Pico 的 PWM 外设和定时器是两个独立模块,上面代码里其实用到了 PWM 外设输出脉宽波形,而定时器只是控制“什么时候改占空比”。很多新手以为舵机的周期是靠定时器生成的,其实不是,舵机的 20ms 周期是 PWM 外设自己保证的,定时器只是介入调整角度。
5. 常见问题与排查技巧实录
5.1 定时器回调不执行或只执行一次
这是出现频率最高的问题,通常有三个原因:
- 定时器对象被垃圾回收。MicroPython 里没保存引用,回调跑几次后对象被回收。
- 回调函数内部发生异常。比如在回调里访问了不存在的变量,MicroPython 会把异常打印到串口但不重启,看起来就像定时器“停了”。
- 一次性定时器模式用成了周期模式,或者反过来,回调返回值导致周期停止。
排查方法很简单:在回调第一行加一个print或者翻转一个 LED 引脚,先确认回调本身有没有被调用,再逐步注释代码定位异常点。
5.2 定时精度不够,周期抖动大
用哪种开发环境、哪种调度方式,直接影响定时精度。我实测过一组对比:
| 使用方式 | 典型精度 | 适合场景 |
|---|---|---|
| MicroPython Timer | ±0.1ms 到 ±0.5ms | 呼吸灯、扫描、超时判断 |
| MicroPython ticks_us 测量 | 几微秒级 | 耗时测量 |
| C SDK repeating_timer | 微秒级,抖动较小 | 精密波形、数据采集 |
| C SDK hardware_alarm 手动 | 最精确,可控性最高 | 极端时序要求 |
如果你用 MicroPython 发现定时器周期抖动明显,优先检查是不是回调里做了耗时操作,比如打印。MicroPython 的 GC 垃圾回收也会偶尔造成数百微秒的停顿,这在时序严苛的场景下是致命的。对策是:高精度需求一律上 C SDK,别跟 MicroPython 的软实时特性较劲。
5.3 回调里发生 HardFault 或系统卡死
C SDK 开发时,HardFault 多半是回调里干了不该干的事。我见过最典型的错误就是在定时器回调里调用sleep_ms,结果中断一直嵌套,系统直接挂死。
另一个常见原因是没有正确保存报警回调的用户数据指针。当你传入一个指向局部变量的指针给add_alarm_in_us,函数返回后局部变量销毁,等回调触发时访问这个悬空指针,轻则数据错误,重则崩溃。正确做法是传一个静态变量或者全局结构体的地址。
5.4 官方资料速查:这些文档位置要记住
每次写 Pico 定时器代码,我必看的资料就两个:
- C SDK:
pico-sdk/src/rp2_common/hardware_timer/include/hardware/timer.h和pico-sdk/src/rp2_common/hardware_pwm/include/hardware/pwm.h - MicroPython 文档:
machine.Timer部分
RP2040 的芯片数据手册讲得更底层,里面有 64 位计数器、报警通道、中断标志的寄存器级说明,我现在写到这里,想精确控制中断标志时会去查一下。这些资料全部是英文的,但结构很清晰,配合本文的速查表就能快速定位到你需要的 API。
6. 定时器 API 速查表:一句话记住常用的
6.1 MicroPython 侧速查
| 需求 | 写法 |
|---|---|
| 创建一次性定时器 | Timer(mode=Timer.ONE_SHOT, period=ms, callback=cb) |
| 创建周期定时器 | Timer(mode=Timer.PERIODIC, period=ms, callback=cb) |
| 停止并释放 | t.deinit() |
| 重新配置 | t.init(mode=..., period=..., callback=...) |
| 取系统毫秒时间 | time.ticks_ms() |
| 测量耗时 | time.ticks_diff(time.ticks_us(), start) |
6.2 C SDK 侧速查
| 需求 | 函数 |
|---|---|
| 阻塞延时 | sleep_us()/sleep_ms() |
| 一次性报警 | add_alarm_in_us(us, cb, user_data, false) |
| 周期报警 | add_repeating_timer_ms(ms, cb, user_data, &timer) |
| 读取时间戳 | time_us_64() |
| 手动配置 PWM 频率 | pwm_set_wrap()+pwm_set_chan_level() |
| 底层手动设置报警 | hardware_alarm_set_target() |
6.3 回调函数注意点清单
再强调一次,无论用 MicroPython 还是 C SDK,定时器回调里都不能做这些事:阻塞延时、动态内存分配、复杂计算、长时间的串口打印、文件系统操作。这些操作会让中断响应时间拉长,甚至触发未知异常。需要复杂逻辑时,在回调里设置标志位,主循环去处理。
我个人在实际调试中还有一个习惯:定时器回调里只做三件事,读时间戳、改标志位、改 GPIO 电平。所有需要经过多层函数调用的逻辑,一律放到主循环。这样写出来的代码不但稳定,排查问题也快——定时器这边出问题,基本就锁定在“标志位有没有置位”这个环节。
这套定时器的用法,够你在 Pico 上跑起绝大多数小项目了。如果后面你在实际项目里遇到更刁钻的时序需求,比如多路高精度采样的同步触发,我建议直接研究硬件报警通道的手动配置,那才是 RP2040 定时器真正发力的地方。