树莓派 Pico 的定时器,表面上就是芯片里那几个“数时钟节拍的计数器”,可真要在项目中稳定跑起来,很多人会被它折腾到怀疑人生。我最早接触 Pico 是因为一个桌面机械臂项目,四路舵机需要按时间轴配合动作,当时我对定时器的理解就是“delay 一下、最多开个中断”,结果整条动作线不是卡顿就是舵机抖动,最后把 RP2040 的定时器硬件结构、时钟来源、中断机制和各工作模式完整梳理一遍,问题才算彻底解决。
这篇文章就是那次排查之后的沉淀。我会从硬件原理讲到代码实现,覆盖定时器中断、延时、PWM、脉冲计数、看门狗这些常用工作模式,并给出 C SDK 和 MicroPython 两种写法。不管你是刚入手 Pico 的嵌入式新手,还是已经写了大量裸机代码、但一直被“定时不准”困住的开发者,这篇文章应该都能帮你少走弯路。
1. 为什么死磕 Pico 的定时器:一个舵机项目逼出来的总结
1.1 从舵机控制说起:定时不准带来的连锁反应
先说我那个舵机项目的具体情况。机械臂有四个舵机,需要一个“先抬臂、再转腕、最后夹爪闭合”的动作序列,每个舵机在不同时间段到达不同角度,动作之间还要有平滑过渡。最开始方案是顺序执行:让每个舵机 PWM 占空比变化,然后 sleep 100ms 再读下一个角度。结果舵机从头到尾都在抖,动作看起来就像抽筋。
问题出在哪?舵机控制要求稳定的 50Hz 信号,也就是每 20ms 刷新一次脉冲宽度。如果主循环里用延时,任何阻塞都会导致脉冲间隔抖动,舵机内部会持续纠偏,表现就是发抖。后来我把所有舵机的脉冲刷新抽到一个固定周期定时器中断里,每隔 10ms 统一刷新一次所有通道的占空比,主循环只负责计算目标角度和状态切换,抖动立刻消失。这个改动让我意识到,Pico 的定时器不能只当作“延迟工具”用,把它理解成系统的心跳源,才能把控制逻辑和硬件时序彻底解耦。
1.2 延时方案的两个致命短板:阻塞与累积误差
很多人写裸机代码时,第一反应就是用sleep_ms或者delay来安排时序,因为看起来简单直观。但延时方案有两个先天短板,在稍微复杂一点的项目里就会暴露。
第一个是阻塞。sleep_ms(100)会让 CPU 在这段时间里什么事都干不了,按键扫描、传感器读取、通信处理全部停摆。比如你一边用软件方式刷 PWM 脉冲,一边还要等待蓝牙串口数据,那数据一多,时序就会完全错乱。第二个是累积误差。假设你要定时 50ms 执行一次动作,常规做法是“执行任务 + sleep 50ms”,但任务本身可能要耗时 3ms 或者 8ms,最终实际周期就会变成 53ms 甚至 58ms。循环次数一多,误差就滚雪球,你根本没法保证系统的时间一致性。
正确做法是设置一个硬件定时器作为时间基准,到点自动触发中断或设置标志位,主循环检测到标志后再执行任务。这样定时周期完全由硬件计数保证,任务耗时多少不影响下一次触发时刻。
1.3 定时器应该被看作“系统心跳”,而不是延时函数
我后来总结了一个原则:在 Pico 这类 MCU 上做任何需要时间维度的功能,第一件事就是想清楚“谁在提供时间基准”。如果你的时间基准是主循环里的某个sleep,那系统永远只能处理单线程顺序任务;如果你把硬件定时器当作心跳,主循环就变成了一个“状态机驱动器”,每个周期检查一次该做什么,不仅时序准确,代码结构也会清晰很多。
这篇内容的阅读主线很简单:先搞懂 RP2040 里有哪些时间资源,再逐个拆工作模式,然后用 C SDK 和 MicroPython 把关键模式跑起来,最后是高频问题排查。如果你对寄存器不熟也没关系,我会尽量从概念层面讲明白,再给可直接用的代码。
2. RP2040 定时器硬件全景:双核 MCU 里的时间资源到底有哪些
2.1 SysTick 和通用定时器:别把两个概念搞混
在聊代码之前,得先把 RP2040 芯片内部的时间资源理清楚。很多人一翻开 datasheet 就晕,因为“定时器”这个词被用得太多,实际上 RP2040 里至少有四类和时间相关的硬件:内核自带的 SysTick、片上的通用定时器外设、PWM 模块里的计数器,以及看门狗 WDT。
SysTick 是 Cortex-M0+ 内核自带的 24 位递减计数器,主要服务于 RTOS 的系统时钟节拍。比如你跑 FreeRTOS 时,vTaskDelay的“心跳”就是 SysTick。它也可以被当作普通定时器用,但因为它属于内核,配置方式依赖 CMSIS 的SysTick_Config函数,与 Pico 的外设体系不太一样,裸机开发中我更建议用通用定时器来管理业务时序。
通用定时器才是 Pico 定时器的主角。RP2040 内部有一组硬件定时器外设,它维护着一个 64 位微秒计数器,同时提供多个独立比较器/警报通道,可以做到“某个时刻到了——产生中断”。C SDK 里的time_us_64()、sleep_us(),以及add_repeating_timer_ms()这类 API,统统是在这组硬件上面封装的。它的特点是时间精度高、不占用 CPU、可同时注册多个定时任务。
2.2 时钟树:125MHz 从哪来到哪去,和定时精度有什么关系
定时器要工作,必须有时钟源。Pico 默认的系统时钟clk_sys是 125MHz,由内部 PLL 倍频得到。通用定时器就是基于这个节拍来计数的,所以理论上它每个 tick 是 8ns,精度非常高。PWM 模块的时钟源同样来自系统时钟,但要经过一个可编程分频器之后才会真正驱动 PWM 计数器。
这里有个关键点:定时器精度并不是越高越好,而是要看你的需求。比如你用 PWM 输出 50Hz 舵机信号,计数频率设定在 1MHz 就已经足够,计算好的 wrap 值是 20000,这样每 tick 是 1μs,脉冲宽度可以精确到微秒级别。如果你把计数频率设成 125MHz,wrap 就得定成 2500000,占空比微调起来反而更难算。所以正确姿势是:先确定你要的时序粒度,再反推分频系数和 wrap 值。后面实操章节我会给完整计算过程。
另一个容易忽略的地方是,SDK 的默认系统时钟可以被改写。有人为了省电把主频降到 50MHz,这时若 PWM 分频系数没改,输出频率就会整体变化。所以代码里凡是依赖时间的外设,最好通过宏定义统一管理时钟频率和分频,避免牵一发动全身。
2.3 PWM、看门狗、RTC 和定时器的边界:一张表看清分工
很多初次接触 Pico 的人会误以为“能产生周期信号的东西都叫定时器”,其实 PWM 模块、看门狗、RTC 和通用定时器是完全不同的外设,各自解决的问题也不一样。
| 资源名称 | 核心作用 | 典型应用 | 与定时器的关系 |
|---|---|---|---|
| 通用定时器 Timer | 维护 64 位微秒计时,提供多个比较器中断 | 延时、周期性任务、超时判断 | 这是本文主角,做系统心跳 |
| SysTick | 内核 24 位递减计数器 | RTOS 心跳、简单周期调度 | 独立于外设,由内核控制 |
| PWM 模块 | 可编程分频 + 自由运行计数器,输出方波 | 舵机控制、LED 调光、蜂鸣器 | 计数器是定时基础,但侧重点在波形输出 |
| 看门狗 WDT | 独立计数,超时触发复位 | 程序跑飞检测 | 可以视为一种“只进不退”的特殊定时器 |
| RTC | 后台日历时钟 | 记录时间戳、定时唤醒 | 与通用定时器互补,常用于低功耗场景 |
搞清楚边界之后,你就不会出现“在 PWM 里找中断”或者“用 RTC 做毫秒延时”这种错位用法。接下来的工作模式拆解,也是围绕这些硬件资源及其应用展开的。
3. 工作模式逐项拆解:选对模式,问题就解决一半
3.1 定时器中断模式:精准回调的底层逻辑
定时器中断是最常被使用的模式,它的本质是:硬件计数器到达预设值后,自动触发一个中断标志,CPU 跳转到回调函数执行,然后再恢复原来的上下文。因为整个触发过程由硬件完成,所以周期非常稳定,不会像主循环里软件计数那样受其他代码影响。
在 Pico C SDK 里,定时器中断被封装成“警报”和“重复定时器”两类。add_alarm_in_ms()是一次性的,比如让某个 LED 在 5 秒后亮起;add_repeating_timer_ms()是周期性的,比如每 5ms 采集一次传感器并更新滤波结果。底层实现都会用到一个结构体repeating_timer来保存状态。
使用这个模式时,最需要注意的是“不要在中断回调里做耗时操作”。中断回调运行在内核中断上下文中,如果执行时间过长,会延迟其他中断的响应。比如你有一个 1ms 的定时器中断用于处理编码器计数,回调里却去做浮点运算或者串口打印,那整个系统的实时性就毁了。正确的做法是:回调里只做“置位标志、翻转引脚、变量累加”这类微秒级操作,真正耗时的处理放到主循环里完成。
3.2 延时模式与轮询:什么时候该用 sleep,什么时候绝对不能用
延时模式看起来最基础,但使用场景要分清。sleep_ms()和sleep_us()在初始化、通信握手、等待外设就绪这些场景里非常好用,因为此时 CPU 确实没有别的事可做,阻塞并不会带来问题。
但在实时控制场景里,比如舵机刷新、步进电机脉冲、传感器采样节拍,延时模式就是灾难。原因不仅是阻塞,还有“上下文切换”的缺失。你用sleep等待时间时,CPU 一直在空转检查时间戳,其他模块根本没有机会运行。
我的建议是:主循环采用“轮询 + 标志位”的方式管理非实时任务,用定时器中断驱动实时任务。比如每 10ms 定时器中断置一个update_flag,主循环里检查到该标志后,再去处理舵机角度计算、串口发送等任务。这样既有时间基准,又不会阻塞中断响应,是裸机开发里比较合理的模式。
3.3 PWM 输出模式:从舵机到呼吸灯,核心是分频与占空比的计算
PWM 模式的本质,是定时器计数到指定值之后自动翻转输出电平,从而不需要 CPU 干预就能生成连续方波。Pico 的 PWM 模块有 8 个切片(slice),每个切片有 A、B 两个通道,一共可以输出 16 路 PWM。每个切片都有自己的频率控制寄存器和占空比控制寄存器。
PWM 的频率公式是:
freq = clk_sys / (clkdiv × (wrap + 1))其中clk_sys默认 125MHz,clkdiv是时钟分频系数,wrap是计数上限。比如要输出 50Hz 舵机控制信号,可以设clkdiv = 125,则 PWM 计数频率 = 1MHz,wrap = 20000,最终 freq = 50Hz,一个完整周期正好 20ms。
占空比的控制则是设定比较值:当计数器小于比较值时输出高电平,大于比较值时输出低电平。舵机 0° 对应 0.5ms 高电平,即计数到 500;180° 对应 2.5ms,即计数到 2500。通过调整这个值,就能让舵机转到任意角度。呼吸灯、LED 调光、蜂鸣器音调也都是同一个原理,只是把频率和占空比换成相应的目标值。
PWM 模式最大的优势是“零 CPU 开销”。一旦配置好频率和占空比,硬件会持续输出波形,主循环可以去做别的事,这比用定时器中断手动翻转 GPIO 高效得多,也是我在多路舵机项目里最终采用 PWM 而不是 GPIO 翻转的原因。
3.4 脉冲计数与频率测量:用 PWM 模块的边沿计数实现
除了输出波形,Pico 的 PWM 模块还能用来数外部脉冲。每个 PWM 切片在 B 通道可以被配置成“边沿计数”模式,说白了就是把 B 引脚作为输入,外部信号每出现一个上升沿(或下降沿),内部计数器就加 1。这非常适合做流量计、编码器脉冲统计、方波频率测量。
为什么不用 GPIO 中断来数脉冲?因为 GPIO 中断在高频信号下很容易丢事件,每个脉冲都要进一次中断,CPU 负担很大,而且中断响应需要若干微秒,频率一高就忙不过来。PWM 计数模式是纯硬件行为,不占用 CPU,只要脉冲频率不超过模块本身的时钟范围,基本不会丢数。
具体实现思路是:配置某个 PWM 切片的 B 通道为上升沿计数,启动后读取pwm_get_counter()就能得到脉冲数;配合定时器做一个 1 秒窗口,前后两次计数差值就是频率。相比 STM32 的输入捕获模式,Pico 这种方式更直接,适合大多数外部信号测量的场景。
3.5 看门狗模式:防跑飞的第一道防线
看门狗是一个很容易被新手忽略的定时器功能。它本质上是一个递减计数器,启动后如果在设定时间内没有被“喂狗”,就会强制复位整个芯片。这个功能在无人值守的场景中非常有用,比如远程设备宕机、程序卡死在死循环里,看门狗能在几秒内把系统拉回来。
Pico C SDK 里启用看门狗非常简单:watchdog_enable(2000, 1)表示设置 2 秒超时,第二个参数表示在调试暂停时是否停止看门狗。启用后,主循环里要周期调用watchdog_update()来喂狗。MicroPython 也有对应的machine.WDT,调用wdt.feed()即可。
需要注意的是,喂狗操作应该在主循环的关键路径上,而不是在定时器中断里。如果放在中断里,即使主循环已经死锁,中断依然在喂狗,看门狗就失去意义了。我自己的习惯是:主循环每完整执行一轮业务逻辑就喂一次狗,如果单轮耗时接近看门狗超时时间,就说明代码性能有问题,应该先优化业务逻辑,而不是单纯把超时调大。
4. 实操:C SDK 与 MicroPython 双线实现定时器
4.1 C SDK 实现周期性定时器中断
先看 C SDK 的最简定时器中断示例,功能是让板载 LED 每 500ms 翻转一次。
#include "pico/stdlib.h" #include "hardware/timer.h" // 重复定时器回调,返回 true 表示继续周期性触发 bool repeating_timer_callback(struct repeating_timer *t) { gpio_put(PICO_DEFAULT_LED_PIN, !gpio_get(PICO_DEFAULT_LED_PIN)); return true; } int main() { stdio_init_all(); gpio_init(PICO_DEFAULT_LED_PIN); gpio_set_dir(PICO_DEFAULT_LED_PIN, GPIO_OUT); struct repeating_timer timer; // 注册一个 500ms 的重复定时器 add_repeating_timer_ms(500, repeating_timer_callback, NULL, &timer); while (true) { tight_loop_contents(); } }这段代码里有两个细节值得注意。第一,回调函数必须返回true,否则定时器只会触发一次;如果你想要一次性定时,返回false即可。第二,struct repeating_timer timer的生命周期必须保持有效,如果在函数内声明并提前返回,定时器会失效。
如果你需要更高精度的周期,SDK 还提供了add_repeating_timer_us(),单位是微秒。底层都依赖那个 64 位微秒计数器,所以精度非常可靠。
4.2 C SDK 实现舵机 PWM:分频、周期与占空比计算
舵机控制是 PWM 模式最典型的应用。这里把分频和占空比的计算过程完整写出来。
#include "hardware/pwm.h" #include "hardware/clocks.h" #define SERVO_PIN 0 #define PWM_FREQ 50 // 50Hz #define CLK_SYS 125000000 // 默认系统时钟 125MHz // 将引脚号映射到 PWM slice 和通道 uint slice = pwm_gpio_to_slice_num(SERVO_PIN); uint channel = pwm_gpio_to_channel(SERVO_PIN); // 1. 设置分频,使计数频率为 1MHz,即每 tick = 1us float clkdiv = (float)CLK_SYS / (PWM_FREQ * 20000); // 125000000 / (50 * 20000) = 125,符合预期 pwm_config config = pwm_get_default_config(); pwm_config_set_clkdiv(&config, clkdiv); pwm_config_set_wrap(&config, 19999); // wrap + 1 = 20000,即 20ms 周期 pwm_init(slice, &config, true); gpio_set_function(SERVO_PIN, GPIO_FUNC_PWM); // 2. 设置占空比,0° 对应 0.5ms,即 500 个 tick pwm_set_chan_level(slice, channel, 500);这里最关键的是分频计算。我先把目标频率确定下来,再选择一个容易计算的 wrap 值,反推分频系数。wrap = 19999意味着整个周期被分成 20000 份,结合 1MHz 计数频率,周期正好 20ms。调整角度时,只要改最后一行pwm_set_chan_level的值:180° 对应2500,理论上分辨率可以做到 0.1° 左右,实际因为舵机机械精度,通常不需要这么细。
如果你想让舵机平滑旋转,可以在主循环里用定时器中断作为更新节拍,每隔 20ms 把目标占空比值递增或递减一小步,就能实现类似航模舵机那种匀速转动的效果。
4.3 MicroPython 快速实现定时任务
MicroPython 的封装让定时器使用门槛低很多,适合快速验证思路。下面是用machine.Timer实现每秒打印一次计数的例子。
from machine import Timer, Pin count = 0 led = Pin(25, Pin.OUT) def on_timer(t): global count count += 1 led.toggle() print("tick", count) timer = Timer() timer.init(period=1000, mode=Timer.PERIODIC, callback=on_timer)period单位是毫秒,mode可以是Timer.PERIODIC或Timer.ONE_SHOT。MicroPython 同样不建议在回调里做耗时操作,但它的解释器执行效率比 C 低很多,所以这一点更加重要。比如你在回调里做浮点运算,执行时间可能直接超过定时周期,导致回调堆叠和系统卡顿。
舵机 PWM 在 MicroPython 里也很简单:
from machine import Pin, PWM servo = PWM(Pin(0)) servo.freq(50) servo.duty_u16(1638) # 0.5ms / 20ms * 65535 ≈ 1638,约 0°duty_u16是 16 位占空比表示,65535对应 100% 高电平。0.5ms 在 20ms 周期里占 2.5%,换算成 16 位就是65535 × 0.025 ≈ 1638。日常用 MicroPython 做原型验证非常方便,但如果你要做高实时性的多路控制,还是建议切回 C SDK。
4.4 实时任务与非实时任务的拆分:一个实际的三层结构
在我那个机械臂项目里,最终代码结构分成了三层,这里分享出来供参考。
第一层是定时器中断,频率 10ms,只做一件事:把所有舵机的 PWM 输出按当前目标角度刷新一遍。这个动作是纯硬件寄存器操作,耗时在微秒级别,不会影响系统响应。第二层是主循环,每隔一个节拍检查一次有没有新的控制指令,如果有就更新四个舵机的目标角度表,同时更新状态机。第三层是非实时的串口打印、日志记录和蓝牙通信,只有在主循环空闲时才会执行。
这种结构的核心是:每个舵机的 PWM 波形由硬件自己维持,定时器只负责“刷新目标值”,而主循环只负责“算目标值”。计算再慢,也只是让舵机动作晚几个毫秒,而不会让波形本身抖动,最终控制效果就会稳定很多。
5. 高频问题排查与调试实录
5.1 定时器中断不触发:先查这三个地方
很多人写完定时器中断后发现回调根本没执行,其实大部分问题出在三个地方。第一,没有初始化对应的 GPIO 或外设时钟,导致中断标志永远不产生;第二,回调函数返回了false,执行一次之后定时器自动停止,看起来就像“后来不触发了”;第三,多个中断同时发生时,优先级没有被正确配置,定时器中断一直被其他中断抢占。
在 C SDK 里,可以通过irq_set_priority调整中断优先级。如果你有多个重复定时器,还要确认它们是不是共用同一个底层中断向量,必要时得检查 SDK 的hardware_timer文档。
5.2 回调执行卡顿与时间漂移:最常见的实时性杀手
定时器回调卡顿,绝大多数原因是回调里做了耗时操作。我自己踩过的坑是在回调里调用了printf,串口输出一慢,整个定时周期都被拉长,LED 闪烁看起来就不规律了。
另一个容易搞混的是“时间漂移”和“抖动”的区别。抖动是单次触发时间忽早忽晚,通常由中断优先级或 CPU 负载导致;漂移是整体周期逐渐偏差,例如你用主循环计数替代硬件定时器,每轮任务耗时不固定,累加之后周期就偏了。解决抖动要优化中断优先级和回调耗时,解决漂移要把时间基准交给硬件定时器,而不是软件计数。
5.3 PWM 占空比 100% 异常与频率偏差
PWM 输出出现 100% 占空比异常,常见原因是分频和 wrap 配错导致计数频率极低,看起来像恒高电平。另一个原因是pwm_set_chan_level的计数值超过了 wrap,比如你设wrap = 19999,但通道电平设了25000,计数器永远小于比较值,输出就是满占空比。
频率偏差则通常和系统主频有关。如果你通过set_sys_clock_khz修改了主频,但 PWM 分频系数还是按 125MHz 算的,输出频率就会按比例偏掉。建议在主频配置处定义一个宏,所有分频计算都基于这个宏,避免硬编码。
5.4 看门狗反复复位:喂狗位置比频率更重要
看门狗反复复位,最直接的原因就是没有在主循环里按时喂狗。如果你的业务逻辑里有某个分支会长时间阻塞,比如等待传感器返回数据超时、死循环等待串口字符,都会导致喂狗延迟。解决办法是:在关键阻塞点也加入喂狗操作,或者把阻塞改成超时轮询。
还有一点要提醒,看门狗超时时间不是越大越好。超时太短,正常慢任务会被误杀;超时太长,程序死锁后要等很久才能恢复。一般取主循环最坏耗时的 2 到 3 倍比较合理。
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 定时器中断不触发 | 回调返回 false、GPIO 未初始化、优先级被抢占 | 检查回调返回值、初始化代码、中断优先级 |
| LED 闪烁不均匀 | 回调里执行耗时操作 | 缩短回调任务,仅置位标志 |
| PWM 输出恒高 | 通道比较值大于 wrap | 检查wrap和pwm_set_chan_level取值 |
| PWM 频率偏差 | 系统主频被修改,分频未同步 | 用宏统一管理时钟频率 |
| 看门狗反复复位 | 主循环阻塞点未喂狗 | 在耗时分支加入watchdog_update |
| 舵机持续抖动 | PWM 刷新间隔不稳定 | 改用硬件 PWM,不手动翻转 GPIO |
6. 一些值得长期保留的定时器使用习惯
聊完了原理、模式和排错,最后分享几个我个人项目中一直在用的习惯,这些不是官方文档里能直接查到的,但确实能减少很多返工。
第一条,设计阶段先画好“时间轴图”。不用很正式,纸上画一下哪个任务占用哪个时间窗,中断频率是多少,主循环最坏耗时是多少。很多定时问题在画完图之后其实自己就浮出来了。
第二条,回调里永远不 sleep。不管是 C SDK 还是 MicroPython,中断回调里都不要调用延时函数。如果需要“过一段时间再做什么”,可以在回调里重新注册一个一次性定时器,或者设一个时间戳变量,让主循环轮询判断。
第三条,硬件定时器是时间基准,软件计数只是辅助。不要用循环变量自减来模拟定时,一旦编译器优化级别改变,或者主循环出现分支跳转,软件计时的准确性就会崩盘。
第四条,Pico 的多路 PWM 非常适合做多舵机控制和 LED 效果,遇到需要同时输出多路稳定信号时,优先考虑 PWM 硬件,而不是定时器中断里软件翻转 GPIO。实测下来,硬件 PWM 的稳定性比软件方案高一个数量级,代码也更简洁。
最后再提一个小技巧:把常用的定时参数抽成宏或者配置文件,比如 PWM 频率、分频系数、重复定时器周期、看门狗超时,统一管理。这样之后换主频、改舵机型号、调整刷新率时,只改一处就行,不会因为到处硬编码而踩坑。定时器这东西,理解透了就是可靠的队友,理解不透就是玄学问题制造机。希望这篇内容能帮你把 Pico 的时间资源真正用明白。