你是不是也有过这种经历:想让树莓派 Pico 上的 LED 呼吸一下,或者控制舵机转个角度,再或者测一下外部信号的频率,结果一搜资料,不是直接甩给你一段 MicroPython 代码让你复制,就是丢给你一堆寄存器手册让人头皮发麻。说真的,定时器这件事,本质上不复杂,但很多人一开始没把“硬件原理”这层窗户纸捅破,后面就总在“能跑”和“懂它为什么对”之间反复摇摆。
这篇博文,咱们就用从业者的视角把树莓派 Pico 的定时器彻底聊透:从 RP2040 芯片内部的时钟结构讲起,把定时器的每一种工作模式拆开揉碎,再带着你做几个有实际意义的案例,比如呼吸灯、舵机控制、频率测量,最后把那些“看文档根本不会告诉你”的坑和排查经验也一并整理出来。如果你是刚接触嵌入式的小白,这篇文章能帮你少走三个月弯路;如果你已经在用 STM32 或 51,想快速上手 Pico,那这篇正好可以帮你做一次思路上的对照和迁移。
1. 先搞清楚定时器在硬件层到底是个什么东西
很多初学者最容易犯的毛病,就是上来就啃 SDK 里的 API,结果连“定时器计数用的时钟从哪里来”“计数器是多少位的”“计数到顶之后怎么办”都没搞明白。知根知底,才能用得踏实。这一章我们先不讲代码,只聊硬件。
1.1 从一颗芯片的内部结构说起:RP2040 的时钟树与定时器位置
树莓派 Pico 的核心是 RP2040 芯片,它内部集成了两颗 ARM Cortex-M0+ 内核,主频最高能跑到 133MHz。这颗芯片和 STM32 这类传统单片机一样,所有的外设都需要时钟驱动,定时器也不例外。
RP2040 的时钟源头通常有三种:内部振荡器(ROSC,频率大约 6.5kHz 到 38.4MHz)、外部晶振(XOSC,Pico 板载一般是 12MHz)、以及 PLL 锁相环倍频出来的系统时钟。我们平时代码里配置的System Clock(系统时钟),一般就是通过 PLL 把 12MHz 外部晶振倍频得到的 125MHz 或 133MHz。这个系统时钟会经过一个叫clk_peri的外设时钟树,再分发给各个外设,包括我们要聊的定时器。
RP2040 上比较常用的定时器相关硬件有这么几类:
- Timer 外设:这是芯片内置的通用定时器,有 4 个独立的 16 位计数器通道,分别叫 TIMER0 到 TIMER3,另外还有一个 64 位的系统滴答计数器(SysTick 也在这里面)。这 4 个通道可以配置为定时、计数、PWM 输出、捕获输入。
- PWM 模块:严格来说它在 RP2040 里是一个独立的外设,有 8 个 PWM 切片(slice),每个切片有两个输出通道。很多人会把 PWM 和定时器混为一谈,实际上 PWM 的底层确实用了定时计数机制,但从编程模型和工作模式上看,它比普通定时器更专注于“产生波形”。
- Watchdog 看门狗:本质也是一种定时器,但它只管复位系统,一般不参与功能逻辑。
这里有一个容易混淆的点:在裸机编程或者 C SDK 中,我们说的hardware/timer.h主要操作的是那 4 个 16 位通用定时器;而hardware/pwm.h操作的是 PWM 切片。到了 MicroPython 的machine.Timer、machine.PWM这样的高抽象层,两者又被包装成不同的类。这个底层区分非常重要,因为很多人在配置 PWM 的时候,下意识会想“PWM 是不是也在定时器中断里处理”——不是的,PWM 切片有自己的中断和计数器,它们不走通用定时器的那套中断向量。
从时钟到外设的整体路径,你可以简单理解成:12MHz 晶振 → PLL 倍频到 125MHz 系统时钟 → 外设时钟树分频 → TIMER/PWM 计数器的时钟源 → 分频器 → 计数器递增/递减 → 比较器触发事件。这条链路任何一环出了问题,比如外设时钟没使能、分频系数算错、比较值比周期还大,都会让定时器表现异常。
1.2 计数器、分频器、比较寄存器:定时器的三大核心部件
无论什么型号的单片机,定时器硬件都跑不出这三样东西:时钟分频器、计数器、比较寄存器。理解了三者关系,你就理解了所有定时器工作模式的底层逻辑。
时钟分频器(Prescaler)的作用是把输入时钟降频,让计数器数得慢一点。RP2040 的 Timer 外设支持 1:1 到 1:256 的分频比,PWM 模块支持更灵活的整数分频,可以到 1:255,另外还有一个额外的 9.6MHz 时钟源可以绕过系统时钟直接用。分频不是随便配的,它直接决定了定时精度和你需要的计数范围之间的平衡。比如你要一个 1 秒的定时,如果分频配小,计数器会很快顶满 16 位上限(65535),那就得用软件多次累加中断来凑满 1 秒,多了一步,误差也多一点。
计数器(Counter)是真正在时钟边沿驱动下递增或递减的那个寄存器。16 位计数器能数的范围是 0 到 65535。不过要注意,RP2040 的通用定时器是向上计数的,也就是说从 0 开始,每来一个时钟脉冲加 1,加到设定值(比较寄存器)或最大值时触发事件。PWM 切片则支持两种计数方向:向上、向下,甚至“三角波模式”(先加后减),这个特性在做变频、调光、电机控制的时候很有用。
比较寄存器(Compare Register)用来设定“触发点”。通用定时器的报警(Alarm)机制,就是比较寄存器的值和计数器值一致时,拉起中断标志位;PWM 模块里则是计数器值和WRAP比较决定周期,和CC比较决定占空比。这里有一个新手常踩的坑:比较值设得比计数器当前值还小,计数器一启动就会立刻触发中断,导致“突然进中断”的幻觉。所以初始化时要么先关中断使能,配置完所有寄存器后再打开,要么处理一下初始比较值,这个我们后面案例里会说到。
1.3 定时器的精度极限和误差来源,别被纸面参数骗了
做嵌入式的人应该都有体会:说明书上的精度和实测的精度是两回事。RP2040 的定时器精度,理论上就是时钟源的精度。如果用内部 ROSC,温度漂移和个体差异可能导致频率偏差达到几个百分点,这用来做呼吸灯、舵机控制没问题,但如果你要做高精度的频率计或者和时间相关的测量,就最好用外部晶振。
Pico 板载的 12MHz 晶振稳定度大约在几十 ppm 级别,经过 PLL 倍频到 125MHz 后,定时器的最小时基可以到 8ns(1/125MHz),也就是 125MHz 计数。这个精度已经相当可观,超过了大部分 8 位单片机,但和 STM32 的定时器(可以跑在 72MHz 或更高,配合输入捕获)相比,Pico 在捕获高频率方波时还是有一些局限——16 位计数器在 125MHz 下 0.5 毫秒就溢出一次,你得非常勤快地在中断里处理计数值的累加。这一点在做频率测量时尤其重要,后面第 3 章的案例我会专门演示怎么规避溢出问题。
影响定时精度的另一个现实因素,是中断响应延迟。你在定时器中断里如果写了比较耗时的代码,比如printf串口打印、浮点数运算,那么下一次计数值已经跑过去好远了,表现出来就是定时周期变长或者抖动变大。我见过不少人拿着逻辑分析仪量波形,发现 Pico 输出的 PWM 频率总是差一点点,最后定位到问题是中断服务函数里有个float运算,换成查表或者整数运算后,波动立刻小了很多。
2. 工作模式全解:定时、计数、PWM、捕获,一个都不能少
了解了硬件底座,我们进入正题:工作模式。RP2040 的定时器模式,从功能上可以分成四类,每一类背后对应着不同的硬件配置和不同的应用场景。我分别拆开讲,每讲一种模式,就补一个直观的实际应用例子。
2.1 定时模式(Alarm):闹钟响了,该干活了
定时模式是大多数人使用定时器的第一站,用途就是“每隔一段时间,触发一次事件”。在 RP2040 里,这个模式的硬件名字不叫Timer,而叫Alarm,非常形象——你设定一个将来的时刻,计数器走到那个时刻,就会触发一个中断,相当于“闹钟响了”。一个 Timer 外设可以同时设置最多 4 个独立的 Alarm,也就是同时维护 4 个闹钟,这个特性在日常开发里很实用。
实现定时中断,在 C SDK 里有两种写法:
第一种,用add_repeating_timer_ms(),这是 Pico SDK 提供的高层 API,封装好了周期触发,适合“每 1ms 扫描一次按键”这种周期性任务。
#include "pico/stdlib.h" #include "hardware/timer.h" bool repeating_timer_callback(struct repeating_timer *t) { // 每 500ms 翻转一次 LED gpio_put(PICO_DEFAULT_LED_PIN, !gpio_get(PICO_DEFAULT_LED_PIN)); return true; // 返回 true 表示继续重复,false 表示停止 } int main() { stdio_init_all(); gpio_init(PICO_DEFAULT_LED_PIN); gpio_set_dir(PICO_DEFAULT_LED_PIN, GPIO_OUT); struct repeating_timer timer; add_repeating_timer_ms(500, repeating_timer_callback, NULL, &timer); while (1) { tight_loop_contents(); } }第二种,用hardware_alarm_*系列的底层 API,控制更细。比如,想一次性执行一个延时动作,我们可以写成:
#include "pico/stdlib.h" #include "hardware/timer.h" int64_t alarm_callback(alarm_id_t id, void *user_data) { // 一次性闹钟触发后执行的代码 gpio_put(25, false); return 0; // 返回 0 表示只执行一次 } int main() { stdio_init_all(); gpio_init(25); gpio_set_dir(25, GPIO_OUT); gpio_put(25, true); // 设置 2 秒后触发 hardware_alarm_set_target(0, make_timeout_time_ms(2000), alarm_callback); while (1) { tight_loop_contents(); } }要注意hardware_alarm的中断优先级默认是不太高的,如果同一个中断优先级中有别的长时间 ISR,就会挤占闹钟的响应时间。严重实时性要求下,得手动设置中断优先级(irq_set_priority)。
MicroPython 下的定时器代码就简单很多了,但封装的代价是你对底层控制力变弱:
from machine import Timer def tick(timer): print("alarm fired") tim = Timer() tim.init(period=1000, mode=Timer.PERIODIC, callback=tick)对于做产品原型验证来说,MicroPython 确实够用;但你要是发现定时不准、抖动大,第一反应应该去看底层 SDk 的配置,甚至考虑是不是 Python 解释器的 GC(垃圾回收)卡顿导致回调延迟了。
2.2 计数模式:数一数外部来了多少个脉冲
定时模式是自己数自己的时钟,计数模式则是数外部信号,往往用来做脉冲计数、流量计、编码器位置统计等。RP2040 的通用定时器和 PWM 模块里的某些通道,都可以配置成外部时钟/引脚触发计数,但说实话,Pico 在这方面支持的灵活性不如 STM32 的定时器(STM32 的定时器可以从多个 GPIO 引脚复用外部时钟,还有编码器接口模式)。不过,这不意味着不能做,只是要做一些小适配。
一种思路是,用 GPIO 中断 + 软件计数来模拟。比如把一个引脚配置成下降沿触发中断,在中断服务函数里把一个全局变量加一。这种方法在脉冲频率不高的时候(比如人按键、低速传感器信号)完全没问题,还能顺便做滤波,又方便又稳。
但 GPIO 中断在高频场景下就不行,原因是中断开销太大,一次进出中断可能要几十个周期,脉冲一密就会漏。高频场景更适合用硬件 PIO(可编程输入输出)或者 DMA,不过那已经是另一套玩法了,超出“定时器”的范畴,这里点到为止。
如果你确实要用定时器自带的计数能力,可以去查一下 RP2040 数据手册里 Timer 外设的部分,它其实只支持内部时钟计数,不像 STM32 那样天然支持外部时钟模式。所谓的“计数”,在 RP2040 的通用定时器层面,更多体现在“从复位开始经过了多少个时钟周期”,而不是“数外部脉冲”。这算是 Pico 定时器的一个先天限制,但用 PIO 的mov、jmp指令加轮询逻辑也能做出一个高性能计数器,大家如果有兴趣,我之后可以单独写一篇 PIO 计数器的案例。
2.3 PWM 模式:输出一个高电平时间可调的方波
说句实话,Pico 的 PWM 模块比它的通用定时器还要常用,因为 LED 调光、蜂鸣器音调、舵机控制、电机调速,全都离不开 PWM。PWM 的全称是脉冲宽度调制,简单来说就是周期性输出一个方波,方波的高电平宽度可以调节。我们把一个周期里高电平占的比例叫做占空比,PWM 改变的就是这个比例。
RP2040 的 PWM 模块有 8 个 slice(切片),每个 slice 有一个 16 位计数器和一个周期寄存器(WRAP)、两个电平比较寄存器(CC)。每个 slice 对应两路 PWM 输出,所以最多 16 路 PWM。这个硬件结构的优点是,只要配置好了,计数器自己循环跑,CPU 完全不用干预,中断占用的额外开销几乎没有。
在 MicroPython 里配置 PWM 就是几行代码:
from machine import Pin, PWM pwm = PWM(Pin(15)) # 使用 GPIO15 pwm.freq(1000) # 频率 1kHz pwm.duty_u16(32768) # 占空比 50%,16 位范围 0-65535在 C SDK 里稍复杂一点,需要手动设置 GPIO 的 PWM 功能,然后配置 slice:
#include "pico/stdlib.h" #include "hardware/pwm.h" int main() { stdio_init_all(); gpio_set_function(15, GPIO_FUNC_PWM); uint slice_num = pwm_gpio_to_slice_num(15); pwm_set_clkdiv(slice_num, 1.0f); // 时钟不分频 pwm_set_wrap(slice_num, 12500); // 周期 = 12500 / 125MHz = 100us,频率 10kHz pwm_set_chan_level(slice_num, PWM_CHAN_A, 6250); // 占空比 50% pwm_set_enabled(slice_num, true); while (1) { tight_loop_contents(); } }这里要注意pwm_set_wrap和pwm_set_chan_level的关系:wrap设定的是计数器的最大值,chan_level设定的是翻转输出的比较点。如果chan_level比wrap还大,那输出就会一直为高,变成 100% 占空比;如果chan_level是 0,那就一直为低。很多人在网上问“为什么我的 PWM 占空比到不了 100%”,多半就是没搞清楚这两个寄存器的约束关系。
PWM 还可以配置成“双通道互补输出”,也就是同一 slice 的两路输出一个高电平一个低电平、彼此反相,这在控制半桥驱动电路时很常用。RP2040 的 PWM 也支持相位校正模式,也就是三角波计数模式,用在对波形对称性有要求的场合,比如一些音频应用。
2.4 捕获模式:测量外部信号的脉宽和频率
最后一种模式是捕获,它的作用是把定时器当秒表,记录外部信号变化发生的时刻,从而计算脉宽、周期和频率。这是 STM32 定时器的一大强项,但对 RP2040 来说,通用定时器本身并不直接支持输入捕获功能,这一点要特别注意。查找 RP2040 数据手册会发现,Timer 外设的引脚捕获功能其实是通过 PIO 或者 PWM slice 的输入检测来实现的。
那网上那些“用 Pico 测频率”的方案是怎么做的?我目前看到的主流做法有两种:
第一种,利用 PWM slice 的 B 通道输入(PWM_B)作为边沿检测源。RP2040 的 PWM slice 可以配置成在引脚输入信号上计数,当计数器在外部信号驱动下运行时,你读取计数器的变化率就能推算出频率。具体来说,就是把 PWM 切片配置成上升沿计数模式,然后在固定时间窗口(用系统定时器产生)内读取计数器值,两次读数的差值除以时间窗口,就是信号的频率。这个方案可以测到几百 kHz 的信号,比 GPIO 中断靠谱得多。
第二种,用 PIO 写一个脉冲采集状态机。PIO 是 RP2040 的独门利器,两个 PIO 模块各有 4 个状态机,可以全硬件实时采集边沿。PIO 能测到几十 MHz 的信号,但这属于进阶玩法,代码量也不小。
如果你只是需要一个中低精度的频率计,用 PWM 切片就够:
#include "pico/stdlib.h" #include "hardware/pwm.h" #include "hardware/timer.h" int main() { stdio_init_all(); gpio_set_function(2, GPIO_FUNC_PWM); uint slice_num = pwm_gpio_to_slice_num(2); pwm_config c = pwm_get_default_config(); pwm_config_set_clkdiv(&c, 1.0f); pwm_init(slice_num, &c, false); // 不启用PWM输出模式 pwm_set_enabled(slice_num, true); // 用系统滴答或硬件闹钟定时100ms,读取计数器值变化 absolute_time_t start = get_absolute_time(); uint16_t first = pwm_hw->slice[slice_num].ctr; sleep_ms(100); uint16_t second = pwm_hw->slice[slice_num].ctr; uint32_t count = (second >= first) ? (second - first) : (65535 - first + second + 1); uint32_t freq = count * 10; // 因为时间窗口是0.1s,乘以10得到每秒的脉冲数 printf("Signal frequency: %lu Hz\n", freq); }需要注意,假如被测信号比 65535 / 时间窗口还高,计数器就会回绕,上面的求差值代码用了无符号回绕的特性去修正,但单次窗口内多圈回绕仍可能算错。简单做法是把窗口时间缩短到足够小,保证一个窗口内计数器只回绕一次,或者开中断进行累加。
捕获模式的精度同样受到中断延迟影响,不过好在读计数器这个动作很快,实测下来误差通常能控制在几个微秒以内,做一般的频率测量足够了。
3. 硬核实操:三个案例带你吃透 Pico 定时器
原理归原理,最终还得落到“代码写得顺手、逻辑跑得通”上。这一章我会带你做三个从简到繁的案例,每个案例的代码都直接考虑到了实际应用场景,不是那种只能让 LED 眨眼的玩具 demo。
3.1 用 PWM 实现呼吸灯:理解占空比与频率的配合
呼吸灯是学习 PWM 最经典的入门案例,因为它同时涉及两个关键参数:频率和占空比。频率决定了人眼看到的闪烁感,频率太低就会看到“一亮一灭”,频率太高则可能超出某些 LED 驱动电路的响应范围。一般呼吸灯用 1kHz 左右的 PWM 频率就可以了,这个范围人眼不会感知到闪烁,驱动电路也能正常响应。占空比则决定亮度,从 0% 慢慢升到 100%,再从 100% 降下来,就形成呼吸效果。
在 C SDK 中,我们可以把占空比放在一个循环里微调。最简单的做法是每 10ms 将某个全局变量的占空比值加 1 或减 1,然后更新 PWM 比较寄存器。这里不建议在循环里用pwm_set_chan_level太频繁,因为频率太高的更新会造成亮度阶跃感,适中一点会平滑很多。
#include "pico/stdlib.h" #include "hardware/pwm.h" #include "hardware/timer.h" int main() { stdio_init_all(); gpio_set_function(25, GPIO_FUNC_PWM); uint slice = pwm_gpio_to_slice_num(25); pwm_set_wrap(slice, 255); // 8位分辨率,比较值0-255 pwm_set_enabled(slice, true); int duty = 0; int direction = 1; while (1) { pwm_set_chan_level(slice, PWM_CHAN_A, duty); duty += direction; if (duty >= 255) direction = -1; if (duty <= 0) direction = 1; sleep_ms(5); } }这段代码的核心就是pwm_set_chan_level的使用,它设置的比较值决定了占空比。呼吸一次的时间大约 255 * 5ms * 2 = 2.55 秒,效果非常自然。如果你觉得呼吸太快或太慢,调节sleep_ms(5)就行。
在 MicroPython 中,呼吸灯写法更简洁,但有一个性能陷阱:MicroPython 的 Python 循环本身开销不小,最好用duty_u16一次设置一个 16 位值,不要频繁转换成浮点数。
from machine import Pin, PWM import time pwm = PWM(Pin(25)) pwm.freq(1000) duty = 0 direction = 1 while True: pwm.duty_u16(duty) duty += direction * 100 if duty >= 65535: direction = -1 if duty <= 0: direction = 1 time.sleep_ms(5)3.2 控制舵机:50Hz 周期和 500~2500us 脉宽的配合
舵机控制是定时器/PWM 应用里最典型也最容易出问题的场景。标准舵机的控制信号就是一个周期为 20ms(50Hz)的方波,其中高电平脉宽在 500us 到 2500us 之间,对应舵机从 0 度到 180 度的转动范围。很多人直接把 PWM 频率设成 50Hz,然后占空比按百分比去换算,结果发现舵机要么不动,要么抽搐。原因是控制器一般看的是脉宽,不是占空比本身。
我以 0 度对应 500us、180 度对应 2500us 来算,周期 20ms。占空比在高电平 500us 时是 500/20000 = 2.5%,高电平 2500us 时是 12.5%。如果你用 8 位分辨率去配置 PWM,整个有效调节范围只占 2.5% 到 12.5%,也就是只用了 256 个等级里的大约 26 个等级,分辨率太低了,舵机会一顿一顿地动。
所以要尽量提高 PWM 分辨率。在 RP2040 的 PWM 模块里,我们可以把wrap值设得尽量大,比如 25000,这时计数器时钟 125MHz 下,每个计数对应 0.2us,500us 脉宽就是 2500,2500us 脉宽就是 12500,1080 度的划分等级充裕得多。
#include "pico/stdlib.h" #include "hardware/pwm.h" void set_servo_angle(uint slice, uint chan, float angle) { // 角度 0~180 -> 脉宽 500~2500us float pulse_us = 500.0f + (angle / 180.0f) * 2000.0f; uint16_t level = (uint16_t)(pulse_us / 0.2f); // 0.2us per tick pwm_set_chan_level(slice, chan, level); } int main() { stdio_init_all(); gpio_set_function(15, GPIO_FUNC_PWM); uint slice = pwm_gpio_to_slice_num(15); pwm_set_clkdiv(slice, 1.0f); pwm_set_wrap(slice, 24999); // 计数100000个刻度,周期=100000*0.2us=20ms pwm_set_enabled(slice, true); while (1) { set_servo_angle(slice, PWM_CHAN_A, 0); sleep_ms(1000); set_servo_angle(slice, PWM_CHAN_A, 90); sleep_ms(1000); set_servo_angle(slice, PWM_CHAN_A, 180); sleep_ms(1000); } }这里我特意没有用近乎满格的 65535 作为wrap,而是设成 24999。原因是 125MHz / 25000 = 5kHz,不是 50Hz。等等,是不是算错了?我们再来捋一下:如果wrap设成 24999,计数范围是 25000 个刻度,PWM 频率 = 125MHz / 25000 = 5kHz,这显然不对,周期只有 0.2ms。但舵机要求 20ms 周期,所以正确的做法是让计数器在一个周期里数 100000 个刻度。25000 个刻度根本不够。
所以要改配置。把分频系数设为 2,即计数器时钟变成 62.5MHz,然后wrap设为 50000,这样计数范围是 50001,PWM 频率 = 62.5MHz / 50001 ≈ 1250Hz,离 50Hz 还差得远。要得到 50Hz,只需要让计数器时钟变成 1MHz 再 wrap 到 19999(或 20000),也就是在 125MHz 的主频下,分频系数设成 125。每 tick 1us,wrap设 19999,就得到周期 20ms 的方波,完美匹配舵机控制信号频率。
代码修正为:
pwm_set_clkdiv(slice, 125.0f); // 计数器时钟 = 1MHz pwm_set_wrap(slice, 19999); // 周期 = 20000us = 20ms // 500us脉宽对应level = 500,2500us脉宽对应level = 2500这样一来,舵机控制的分辨率是 1us,足够平滑了。这个案例想说明一个核心经验:PWM 配置不要光想着用默认的时钟不分频,要把“需要的周期时间”除以“计数器时钟周期”,得到 wrap 值,再反推哪个分频系数最合适。
在 MicroPython 中,因为duty_u16的分辨率只有 16 位,控制舵机时最好用duty_ns直接写 ns 脉宽,或者duty_u16换算成 16 位的脉宽比例。假如freq=50,duty_u16(3277)就对应约 1ms 脉宽,120 度时约 2ms 脉宽。反正记住一个原则:控制舵机,核心是脉宽,不是频率。
3.3 使用硬件闹钟实现高精度秒表:读取时间和计算间隔的秘诀
有些项目需要记录事件发生的绝对时间,比如按下一个按键后记录当前时刻,或者统计两次外部事件之间的时间差。这时候就需要读系统时钟,而不是单纯依赖重复定时器中断。
RP2040 的 SDK 提供了一套非常顺手的绝对时间函数:get_absolute_time()、absolute_time_diff_us()、to_us_since_boot()等等。它们的底层都基于那个 64 位微秒计数器,由系统时钟驱动,不会因为 16 位计数器的溢出而丢失信息。从 Boot 开始到现在的微秒数存在一个 64 位寄存器里,溢出需要几千年,完全不用担心。
一个很常见的场景是做一个“按秒表功能”:按下按键 A 记录当前时间,按下按键 B 打印两个时间点之差。代码逻辑很简单:
#include "pico/stdlib.h" #include "hardware/timer.h" int main() { stdio_init_all(); gpio_init(20); gpio_set_dir(20, GPIO_IN); gpio_pull_up(20); absolute_time_t last_time = get_absolute_time(); while (1) { if (gpio_get(20) == 0) { // 按键按下,假设低电平有效 absolute_time_t now = get_absolute_time(); int64_t diff = absolute_time_diff_us(last_time, now); printf("Delta: %lld us, %lld ms\n", diff / 1000, diff / 1000000); last_time = now; sleep_ms(50); // 简单消抖 } } }这里有一个容易理解的细节:absolute_time_diff_us返回int64_t,单位是微秒,可以表示正负差值。如果你只是做累加计时,完全可以用to_us_since_boot()拿到一个永不停歇的微秒计数,这个数值和墙上时间没关系,只表示系统上电后的运行时长,但它足够稳定、足够精确。
这个秒表的实际精度受限于中断和 GPIO 读取延迟,但对于人按按键这种毫秒级事件,误差完全可以忽略。如果你想统计两路数字信号之间的时间差,比如超声波模块的 ECHO 高电平持续时间,建议还是结合 PIO 去捕获边沿,单纯靠轮询 GPIO 误差会比较大。
4. 常见问题与排查技巧实录:定时器不准、PWM 不动、中断失效怎么办
这部分是整篇文章里我个人认为含金量最高的章节。定时器编程的坑,往往不是语法问题,而是硬件行为不符合直觉。下面这些坑,我从做过的项目里一个个踩出来的,整理成一个速查表,方便你先收藏再对照排查。
4.1 定时器周期不准,偏大或偏小、经常跳变
定时器周期不准通常有三个原因:
- 时钟源配置问题:如果你用的是 internal ROSC,频率本身就不准,漂移可能有好几个百分点。解决方法是切换到外部晶振或者 PLL 时钟源。
- 分频系数和 wrap 值算错:上面舵机案例里就演示过,先确认主频是多少(默认 125MHz),再确认你要的分频后时钟是多少,最后确认计数器到顶需要多少刻度。拿计算器一步步按,不要心算。
- 中断服务函数里耗时操作太多:中断回调里一
printf,千辛万苦攒出来的时序就全毁了。定时器周期不准时,可以先用GPIO toggling测一个最小的中断任务,再看波形准不准,用排除法定位。
4.2 PWM 输出没反应,或者一直是高/低电平
PWM 输出没反应的排查顺序,我建议是:
- 引脚映射对不对:RP2040 的 PWM 引脚不是任意复用的,只有特定的 GPIO 能映射到特定 slice 的特定通道。查一下数据手册里的引脚复用表,比如 GPIO0 对应 PWM channel A of slice 0,GPIO1 对应 channel B of slice 0。别把 GPIO15 配到别的 slice 号上面去了。
- 引脚功能有没有设置对:在 C SDK 里调
gpio_set_function(pin, GPIO_FUNC_PWM),在 MicroPython 里用Pin(pin, Pin.OUT)以后再转 PWM,如果你跳过了这个步骤,GPIO 可能还在做普通 IO 输出,PWM 寄存器根本接管不了引脚。 - wrap 值是否比 chan_level 小:如果周期寄存器的值小于比较值,计数器永远到不了比较值,输出会一直保持高电平。反过来,如果你设置的比较值是 0,输出则一直为低。
- slice 是否使能:
pwm_set_enabled(slice, true)不能漏,不要以为配置了 wrap 和 chan 就自动开始输出了,PWM 切片默认是关闭的。
4.3 定时器中断不触发或只触发一次
两个非常常见的原因:
- 回调函数返回值不对:在 C SDK 的
repeating_timer回调里,返回true表示继续重复,返回false表示停止。很多人第一次写,忘了这个返回值,或者返回了一个变量,结果定时器跑了一次就再也不进中断了。 - 中断优先级被别的外设抢占:如果项目里同时用了 DMA、UART 中断,且占用时间很长,定时器中断可能迟迟响应不过来。可以尝试调高定时器中断优先级,比如
irq_set_priority把硬件闹钟的中断优先级提升到最高,同时在 ISR 里保持“快进快出”。
MicroPython 里还有一种情况:如果 Python 解释器正在执行一段长时间阻塞代码(比如time.sleep或者大循环的浮点运算),定时器回调可能被推迟到当前 Python 操作结束后才执行。此时测出来的周期自然忽长忽短。所以 MicroPython 合适的场景是原型验证和交互控制,不适合做硬实时任务。
4.4 脉冲计数不准确,数字总是比实际少
这多半不是定时器的问题,而是计数方式的问题。前面说过 Pico 的通用定时器对外部计数不友好,很多人直接用 GPIO 中断计数,那就必然受中断响应时间的限制。高频情况下建议换 PWM 切片计数或 PIO 方案。
另一个常见原因是在中断里做了太多工作。比如你在 GPIO 下降沿中断里不仅做了计数,还顺带读了传感器、做了滤波、更新了 OLED 显示,那中断执行期间错过的下一个边沿就永远回不来了。计数值偏少几乎是必然的。正确的做法是中断里只做counter++,其余功能全部放到主循环里处理。
5. 影响范围与选型建议:Pico 定时器能覆盖哪些场景
聊完实操,我们把视角抬起来,看看 Pico 定时器到底能在哪些项目里撑起一片天,以及什么时候你应该换别的方案。
5.1 定时器能力决定项目边界,别拿 Pico 当硬实时控制器
RP2040 的定时器能力,整体定位是在“低成本、双核、灵活 IO”这条线上的。它和 STM32 定时器对比,差距主要体现在几个方面:STM32 的定时器数量更多(很多型号有 8 个以上),还有高级定时器支持互补输出和死区插入,适合电机驱动;STM32 的输入捕获也更直接,编码器接口模式是标配。反观 RP2040,通用定时器只有 4 个 16 位通道,PWM 切片虽然多但捕获能力一般,所以在一些对“多路精确脉宽测量”和“复杂电机控制”要求高的项目里,STM32 会更有优势。
但如果你的项目属于这几类,Pico 非常顺手:
- 多路 LED 调光/氛围灯/舞台灯光控制:PWM 切片数量多,可以同时驱动十几路 LED,而且 CPU 几乎不用管。
- 舵机控制与机械臂原型:用 50Hz PWM 驱动舵机,分辨率足够,双核跑起来还有余力做逆运动学计算。
- 音频/蜂鸣器发声:PWM 加外部功放就能出声音,配合 DMA 可以玩简单的和弦。
- 传感器数据采集与网络上传:定时器用来做周期性采样,PIO 做并行数据读取,低功耗特性也合适。
就我个人的经验来说,Pico 定时器最适合的定位是“项目的中枢节拍器”,而不是“硬实时的现场设备”。它负责给整个系统提供心跳,比如每 10ms 扫一次按键、每 100ms 刷一次屏幕、每 1s 往云端上报一次数据,这些任务它跑得非常出色。
5.2 从 51/STM32 迁移到 Pico,定时器思路的不同点
如果你是 51 或 STM32 的老用户,首次上手 Pico 定时器,有几点要提前转变心态:
- 51 的定时器经典配置是“一个定时器搭配两个计数器寄存器(TH/TL)”,工作模式比较简单,适合练手。Pico 的定时器本质是“多路比较闹钟 + PWM 切片”,概念上更接近 STM32 的通用定时器,但又被封装得更友好。
- STM32 的定时器分基本、通用、高级三类,每类有不同功能;RP2040 则是把定时和 PWM 功能拆成了两个外设,PWM 独立性强。你以前的 HAL 库使用习惯可能要做调整。
- STM32 的输入捕获可以直接拿超时中断算脉宽,Pico 则要借用 PWM 输入边沿采样或 PIO,思路不同,但都能实现。
这种差异不是优劣问题,而是芯片设计哲学的不同。RP2040 更强调“灵活的外设互联”,PIO 可以模拟出很多传统外设的行为。所以如果你只在 Pico 上做标准定时器项目,其实只发挥了它一小部分功力;真想发挥它的潜力,记得把 PIO 和 DMA 一起纳入工具箱。
5.3 和热词里的“树莓派 5/STM32 定时器”对照一下,避免选择焦虑
最近很多人纠结树莓派 5 和树莓派 Pico 的区别,其实这两种设备定位相差极大:树莓派 5 是完整 Linux 系统,跑 Python、跑 Web 服务、部署 YOLO 模型都没问题,但它不适合做硬实时 IO 控制,哪怕给它接了 GPIO,也走的是 Linux 的调度的延时流程,做不到微秒级时序。Pico 则相反,性能有限,但裸机或者 RTOS 运行,定时器完全可控。如果你只是想控制舵机、读取编码器,没必要上树莓派 5。
另外,STM32 的定时器之所以在网上被讨论得最多,是因为工业控制、电机驱动、逆变电源这些领域大量依赖它。Pico 在这类领域并不擅长,但如果你做的是创客项目、教学演示、快速验证、边缘计算与控制混合的场景,Pico 反而是更顺手的选择。记住一个原则:选型先看定时器能力能不能覆盖项目最严苛的时间需求,而不是先看芯片主频有多高。
6. 一些长期实践下来的心得
这章算是我私心的总结,不写大纲,不讲理论,就聊聊我在频繁使用 Pico 定时器过程中的一些真实体会。
第一,建议你从一开始就习惯读 《RP2040 Datasheet》 的 PWM 和 Timer 章节,即使第一遍看不懂也没关系,带着问题去读,比如“为什么 wrap 是 49999 而不是 50000”“为什么 GPIO 25 的 slice 号是 2”,多查几次,芯片的行为模式就刻在脑海里了。网上资料再多,也依赖别人消化过的二手信息,而 PDF 是不会骗你的。
第二,无论用 C 还是 MicroPython,都要有一个“示波器思维”。手头没有逻辑分析仪,就开一个 GPIO 翻转来测量时间,这比你看一百遍打印日志都管用。我自己排查“定时器不准”问题时,最常用的就是开两个 GPIO:一个在定时器中断里翻转,一个在主循环里翻转,然后观察两个波形的时间关系,问题很快定位。没有仪器的时候,也要学会自建探针。
第三,定时器的代码要尽量和业务逻辑解耦。把定时器中断里的事保持在最小范围,比如只负责置位一个标志、计数一个变量,而工作逻辑放到主循环条件判断里执行。这样不仅避免中断嵌套的复杂性,也方便以后通过操作系统把任务拆到两个核上去。RP2040 有双核,利用 Core1 跑主循环,Core0 维持定时器和实时响应,是性价比极高的架构。
第四,热词里提到的“定时器 cnt 加到 car 为什么没有产生中断”,这在前几年 STM32 的论坛里也特别常见。其实想明白就好了:计数器加到顶(wrap 或者重载值)不会自动产生中断,你得手动使能相应的更新中断标志。RP2040 的 Alarm 机制也类似,不会说计数器到顶了就自动跑回调,必须明确调用hardware_alarm_set_target。这类问题没有一个统一的灵丹妙药,就是熟能生巧,多写几遍就形成了肌肉记忆。
我把这些心得体会放在文章最后,倒不是想当什么“教学总结”,而是希望大家在把代码抄进 IDE 之前,先停下来想一想:下一行代码会改变硬件哪块寄存器的状态?它会带来什么副作用?如果能形成这种思维习惯,定时器乃至整个单片机开发在你眼里,都会从“玄学”变成“逻辑”。祝你的下一次 Pico 项目,一次点亮,漂亮收工。