1. 树莓派 Pico 的定时器并不只是"延时":先理清全家桶
如果你是从 Arduino 或者 STM32 阵营转过来的,第一次翻开树莓派 Pico 的 SDK 文档时,多半会被几个名字搞蒙:sleep_ms、add_alarm_in_ms、add_repeating_timer_ms、还有藏在硬件层里的TIMER_IRQ。它们都叫"定时器",但工作方式、适用场景、坑点完全不是一回事。
先说一个容易误解的地方:很多人以为树莓派 Pico 的定时器就是指"延时函数",其实 SDK 里至少有三套完全不同层次的定时机制。第一套是阻塞型的sleep_ms/sleep_us,本质是让 CPU 在那空转,适合初始化阶段的等待,绝对不适合在主循环里做周期控制。第二套是硬件报警定时器(Hardware Alarm),rp2040 内置的定时器外设会维护一个 64 位微秒计数器,你可以设置一个绝对或相对的触发时间点,到点后触发中断回调。第三套是建立在硬件报警之上的重复定时器(Repeating Timer),SDK 帮你把周期触发封装成repeating_timer_t结构体,用起来类似 Arduino 的millis加回调。
除此之外还有几个容易被忽略的隐藏角色:time_us_64()/time_us_32()这类读取当前微秒计数的函数,虽然不是"定时器",但所有精确测时、轮询超时判断都靠它们;还有rtc模块,虽然服务于日历时间,但内部同样依赖定时器外设的计数基准。
我的建议是,在你决定"用哪个定时器 API"之前,先想清楚一个问题:你的任务需要的是"阻塞等待"、"单次延时动作",还是"周期性重复触发"?这三类需求对应的 API 完全不同,选错了轻则浪费 CPU,重则把中断回调写成死循环导致系统卡死。下面我会逐个拆开讲,并给出可以直接抄的代码骨架。
2. 硬件定时器的工作边界:为什么它能做到微秒级
RP2040 的定时器外设本质上是一个 64 位计数器,时钟源来自系统时钟(默认 125MHz)经分频后得到 1MHz 的计数脉冲,也就是说计数器每 1 微秒加一。这个设计的好处很明显:时间基准确、分辨率高、不需要频繁重载初值——因为它是 64 位的,按微秒计算大约可以连续运行 58 万年才溢出。相比 STM32 那种 16/32 位定时器需要不断处理更新事件,Pico 在"长时间定时"这个场景下有天然优势。
但这套硬件设计也带来一个需要注意的点:它不像 STM32 那样有多个独立的定时器外设可以各自配成不同时基,RP2040 只有一个定时器,所有报警(Alarm)请求都挂在同一个计数总线上。SDK 在实现上支持多个报警请求排队,但报警数量有限,硬件上只有 4 个报警槽位(ALARM0~ALARM3),虽然 SDK 用软件链表把超过 4 个的定时请求做了排队模拟,但底层中断仍然是共享的。
实际编程时,很少有人直接操作 ALARM 寄存器,而是使用 SDK 封装好的add_alarm_in_ms()/add_alarm_in_us()系列函数。这些函数返回一个alarm_id_t类型的 ID,用来标识一个定时请求。你需要注意一个细节:定时回调函数是在中断上下文中执行的,不是普通线程。这意味着回调里不能调用sleep_ms()这类阻塞函数,不能做太耗时的运算,也不能触碰需要加锁保护的共享数据,只能快速处理一下标记位、往队列里塞数据,然后立即返回。
我在最初写 Pico 程序时踩过一个大坑:在报警回调里直接操作 SSD1306 OLED 屏幕刷新,结果屏幕刷新一次要几毫秒,而我的定时周期是 1ms,中断还没退出新的中断又来了,最后整个系统卡死。调试了很久才发现是回调函数执行时间超过了定时周期。后来我把显示刷新丢到主循环,定时器只负责置一个"需要刷新"的标志位,问题立刻消失。这个经验很重要,后面遇到占空比输出、传感器轮询时同样适用。
2.1 绝对时间 vs 相对时间:理解 add_alarm 的两种用法
SDK 提供两类报警添加接口:add_alarm_in_*是相对时间,从调用时刻开始往后数多少微秒触发;add_alarm_at_*是绝对时间,指定一个未来的时间戳触发。日常使用中 90% 场景都用相对时间就够了,但如果你需要多个报警在时间上严格同步(比如两个传感器分别在第 100ms 和第 200ms 采样),建议用add_alarm_at配合time_us_64()计算绝对触发点,避免逐个调用add_alarm_in时因函数调用本身耗时造成累计误差。
需要注意add_alarm_in_*系列函数的返回值:alarm_id_t在 pico-sdk 中实际上是指向内部链表节点的指针,如果你不需要取消报警,可以忽略返回值;如果需要取消,则要保存好并传给cancel_alarm()。取消一个已经触发的报警返回 false,取消一个还在排队的报警返回 true。这里有个文档没明确写的行为:如果回调已经在执行中,你调用cancel_alarm会得到 false,但回调可能仍然在跑,千万不要在回调里对自己调用 cancel 然后释放回调用的内存,这是悬垂指针的经典来源。
3. 定时器 API 的核心使用逻辑:一次看懂五个函数
时间基准这块,SDK 提供的 API 数量不多,但每个函数都有明确的适用边界。我按常用到不常用的顺序整理了一张表,方便你对照。
| 函数 | 类型 | 最小精度 | 典型场景 | 注意事项 |
|---|---|---|---|---|
sleep_ms() | 阻塞 | 1ms | 初始化等待、调试 | 被中断打断会提前返回吗?不会,它是忙等待 |
sleep_us() | 阻塞 | 1us | 短延时、时序模拟 | 实际精度受中断影响,不保证严格准时 |
add_alarm_in_ms() | 非阻塞 | 1ms(包装) | 单次定时任务 | 回调在中断上下文,不可阻塞 |
add_alarm_in_us() | 非阻塞 | 1us | 微秒级单次任务、波形生成 | 触发精度高,但回调执行必须极短 |
add_repeating_timer_ms() | 非阻塞 | 1ms(包装) | 周期任务、轮询、状态机 | 可实现动态修改周期,需及时处理回调 |
time_us_64() | 同步读 | 1us | 测时间戳、超时判断 | 无开销,建议主循环配合使用 |
repeating_timer_t字段操作 | 非阻塞 | 1us | 动态调周期、暂停恢复 | 修改结构体字段需注意原子性 |
我特别想说说time_us_64()这个函数。它没有中断,没有回调,看起来不起眼,但它是调试定时逻辑的利器。比如你想确认一个任务循环到底跑了多久,只需要在循环开头和结尾各取一次时间戳相减即可。更妙的是它天然支持超时判断:if (time_us_64() - last_trigger_time > 1000000),这种写法比用add_alarm更轻量,适合那些周期不固定、完全由主循环数据驱动的场景。
3.1 回调函数的执行环境:为什么必须"快进快出"
如果你之前只写过裸机单片机的定时器中断,可能会习惯在中断里大干一场——清标志位、读传感器、算 PID、调 PWM。但 Pico 的报警回调虽然也是中断上下文,却有更严格的隐性约束:因为 RP2040 的中断优先级和嵌套机制比较简朴,如果在回调里停留时间过长,不仅会错过新的定时事件,还会影响同时挂在这个中断上的其他报警(包括系统 tick、pico-sdk 内部机制)。
我的经验是,"借花献佛"式的轻量处理最好用:回调里只做两件事,一是把volatile标志位置 1,二是往预分配好的环形缓冲区里塞一条消息,然后立刻返回。具体的数据处理统统放到主循环。如果数据量实在太大,宁可加长定时周期,也不要试图在中断里批量处理。我实测过,在 125MHz 主频下,回调里做一次乘法除法混合运算大约耗时几百纳秒到 1 微秒,而完整跑一遍浮点 PID 可能会到几十微秒,这个量级在微秒级定时任务里是致命的。
4. 三种典型定时任务的代码骨架:直接拿去改
聊完原理,上实战。下面三个示例覆盖了最常见的需求场景,你可以直接参考并修改成自己的业务逻辑。
4.1 场景一:每 100ms 读取一次温度传感器并做显示刷新
这大概是很多 Pico 项目的第一步。用add_repeating_timer_ms()是最自然的选型,周期 100ms 不会给 CPU 造成压力,回调里只需要标记显示需要刷新。
#include "pico/stdlib.h" #include "hardware/timer.h" volatile bool g_display_need_refresh = false; bool temperature_timer_callback(struct repeating_timer *t) { // 这里只置标志位,具体读数放到主循环做 g_display_need_refresh = true; return true; // 返回 true 表示继续重复;返回 false 则停止 } int main(void) { stdio_init_all(); struct repeating_timer timer; // 参数1:周期(ms) 参数2:回调函数 参数3:传给回调的上下文(可NULL) 参数4:定时器结构体 add_repeating_timer_ms(100, temperature_timer_callback, NULL, &timer); while (true) { if (g_display_need_refresh) { g_display_need_refresh = false; // 在这里进行真正的温度采集和 OLED/LCD 刷新 // read_temperature(); // draw_screen(); } // 其他非实时业务逻辑 tight_loop_contents(); } }这段代码有几个值得强调的地方:回调函数返回true意味着定时器继续跑,返回false则停止。这个设计非常巧妙,你可以在某个条件下让定时器"自毁"。另外struct repeating_timer必须在整个定时器生命周期内保持有效,如果你在函数里定义局部变量然后返回了,回调会访问到悬垂指针,这是新手最容易踩的坑。
4.2 场景二:延时 5ms 后打开继电器,只执行一次
单次延时任务不需要用repeating_timer,直接用add_alarm_in_ms就够。
#include "pico/stdlib.h" #include "hardware/timer.h" typedef struct { uint8_t relay_pin; } relay_ctx_t; int64_t relay_on_callback(alarm_id_t id, void *user_data) { relay_ctx_t *ctx = (relay_ctx_t *)user_data; gpio_put(ctx->relay_pin, 1); // 注意:不需要手动释放 user_data,因为回调执行完后, // SDK 会释放定时链表节点,但 user_data 需要你自己管理 free(ctx); return 0; // 返回值表示是否重新排队(0 表示不重复) } int start_relay_delay(uint8_t pin, uint32_t delay_ms) { relay_ctx_t *ctx = malloc(sizeof(relay_ctx_t)); ctx->relay_pin = pin; gpio_init(pin); gpio_set_dir(pin, GPIO_OUT); return add_alarm_in_ms(delay_ms, relay_on_callback, ctx, true); }这里有一个很容易忽略的细节:user_data不会在回调结束后被 SDK 自动释放,你需要在自己定义的场景里管理内存。但注意,上面代码里我直接在回调里free(ctx),这种做法有个隐患——如果定时器被取消了但回调还没执行,ctx就可能泄漏。更稳妥的方式是利用cancel_alarm的返回值判断定时器是否还在排队,如果返回true(已取消),则需要自行释放内存。另外,add_alarm_in_ms的最后一个参数是fire_if_past,如果设为true且定时时间已经过去,回调会被立即执行;设为false则回调会被无限推迟,这个参数在系统睡眠唤醒后特别有用,需要你根据业务决定。
4.3 场景三:微秒级精确延时——用硬件报警定时器翻转 GPIO
如果你要用 Pico 产生一个精确的方波,比如驱动 WS2812 灯带或者模拟某个传感器时序,sleep_us在主循环里忙等精度尚可,但如果有中断干扰就不够看了。这种情况下可以用硬件报警定时器来实现高速翻转,但需要注意回调的"快进快出"原则。
#include "pico/stdlib.h" #include "hardware/timer.h" #define OUTPUT_PIN 2 volatile uint32_t toggle_count = 0; int64_t toggle_callback(alarm_id_t id, void *user_data) { gpio_xor_mask(1u << OUTPUT_PIN); toggle_count++; // 需要单次执行时返回 0 // 如果需要周期性翻转,可以在回调内重新添加报警,实现动态周期调整 return 0; } int main(void) { stdio_init_all(); gpio_init(OUTPUT_PIN); gpio_set_dir(OUTPUT_PIN, GPIO_OUT); // 5 微秒后翻转一次 add_alarm_in_us(5, toggle_callback, NULL, true); while (true) { // 主循环可以做别的,比如显示翻转次数 tight_loop_contents(); } }如果你需要的是周期性方波,单纯靠单次报警在回调内部重新add_alarm_in_us也能实现,但要注意抖动问题。因为回调执行本身需要时间(中断响应 + 函数调用开销),你设置 10us,实际翻转间隔可能是 10.5us 或者 11us,这个抖动在大部分传感器驱动场景可接受,但如果你要做高精度时钟输出,建议改用 PWM(Pulse Width Modulation,脉冲宽度调制)硬件模块,而不是定时器中断翻转 GPIO。PWM 的精度和稳定性远高于软件翻转,这个我在第五节详细展开。
5. 精度、抖动与实测数据:定时器不是"绝对准"
很多人第一次测量 Pico 定时器的实际触发间隔时都会吃惊:说好的 100ms,示波器量出来怎么是 100.2ms?其实这不是 Pico 的问题,而是所有基于中断的定时器都存在的固有抖动(jitter)。抖动来源主要有三个:
第一是中断响应延迟。CPU 正在执行某些不可中断的指令(比如 Flash 读取、总线操作)时,定时器中断会被挂起,延迟几个微秒才能进中断服务函数。125MHz 主频下每个机器周期 8ns,这个延迟通常在微秒级,但因为不可预测,所以产生抖动。
第二是回调执行时间。如果你在回调里做的事越多,执行时间越长,下一次报警的触发点就会被顺延。repeating_timer的实现逻辑里,周期的计时基准是"从上次触发时刻开始算",如果你回调跑了 5ms 而周期是 10ms,实际间隔是 15ms(10ms 周期 + 5ms 执行时间),不是严格的 10ms。这也是为什么我一直强调回调必须短小精悍。
第三是sleep_us和add_alarm_in_us的参数本身是微秒级,但 SDK 内部可能有对齐操作。实际测量中,10us 的单次报警,抖动可能在 ±1us 到 ±2us;1ms 的重复定时器,抖动通常在 ±50us 以内。如果你需要更高精度,唯一的办法是改用硬件 PWM 或 DMA。
| 定时方式 | 典型精度 | 抖动范围 | CPU 占用 | 适用场景 |
|---|---|---|---|---|
sleep_ms(1) | ±1ms | 高(受系统中断影响) | 忙等 100% | 简单演示 |
add_alarm_in_us(10) | ±1~2us | 有 | 极低 | 单次精确动作 |
add_repeating_timer_ms(10) | ±0.1ms | ±50us | 低 | 周期轮询 |
add_repeating_timer_us(100) | ±2~5us | 有 | 低 | 快速采样 |
| PWM 硬件输出 | 周期级精确 | 几乎无 | 0(硬件) | 波形生成 |
从表格可以看出,如果你的应用对时间精度要求达到"严格周期性"(比如音频采样率、步进电机脉冲),不要用定时器中断,直接上 PWM 或者 PIO 状态机才是正路。定时器的优势在于灵活性和多功能性,而非极致精度。
5.1 实测:用 GPIO 翻转加逻辑分析仪测抖动
我用手头的一台便宜的 24MHz 逻辑分析仪做过一次简单测试:配置repeating_timer周期为 1ms,回调里gpio_put(pin, !gpio_get(pin))翻转引脚,连续采集 10 万次翻转,用 PulseView 统计间隔分布。结果显示,大部分间隔落在 1000us ± 10us 范围内,但偶尔会出现 1010us 左右的偏离,这通常发生在系统 USB 通信或者 Flash 写入时。如果你在主循环里频繁调用printf,抖动会更严重,因为 UART 输出本身会占用不少 CPU 时间,且可能关闭中断。这个测试给我最大的启发是:定时器的稳定性,很大程度取决于你"别在关键时刻打扰它"——中断里别做耗时的事,主循环里别动不动关中断,抖动自然就小了。
6. 定时器和外围模块的搭配:PWM、舵机、输入捕获
很多搜索"树莓派 Pico 控制舵机"的朋友,点进来其实是想知道定时器和 PWM 的关系。这里必须澄清一个容易混淆的概念:舵机控制用的是PWM(脉宽调制),不是定时器中断。PWM 是由 RP2040 的 PWM 硬件外设产生的,它内部有自己的计数器和比较寄存器,一旦启动,不需要 CPU 干预就能持续输出波形。你不需要写任何定时器回调,只需要设置好周期(通常是 20ms)和占空比(对应舵机角度)即可。
那定时器在这里扮演什么角色?答案是"多路舵机需要分时处理时,定时器用来做调度"。比如你需要同时控制 6 路舵机,但不想用 6 个 PWM 切片(虽然 RP2040 有 8 个 PWM 切片,理论上够用),你可以用定时器每 2ms 触发一次中断,在中断里更新下一路舵机的 PWM 占空比。这种"分时复用"的做法本质上是用软件在扩展硬件资源,适合 PWM 切片不够用的极端场景。
另一个经典搭配是输入捕获。RP2040 本身没有像 STM32 那样的输入捕获模式,但你可以用定时器 + GPIO 中断来实现。做法是:在 GPIO 上升沿中断里调用time_us_64()记录当前时间戳,下次上升沿再记录一次,两次相减就是信号周期。这种方法的精度取决于 GPIO 中断响应延迟,一般在几微秒以内,测量频率低于 100kHz 的方波信号绰绰有余。如果频率更高,建议用 PIO(Programmable I/O,可编程输入输出)实现,PIO 可以在完全不需要 CPU 参与的情况下精确采样,现代 PIO 程序在 GitHub 上有现成库。
6.1 定时器配合 PWM 的高阶玩法:呼吸灯与动态调光
呼吸灯是一个很经典的教程级项目,它恰好能用上 Pico 定时器和 PWM 的组合。做法是:定时器每 10ms 触发一次回调,在回调里递增或递减 PWM 的占空比寄存器,实现亮度渐变。因为 10ms 更新一次对人眼来说足够平滑,而 PWM 本身以 1kHz 频率输出,不会产生人眼可见的闪烁。
这个例子的价值在于它演示了"定时器做调度、PWM 做执行"的分层思想。很多复杂的嵌入式系统,比如四轴飞行器的电机控制、机械臂的关节驱动,本质上都是这种模式:一个高频率的硬件模块负责精确输出,一个较低频率的定时器负责策略计算。理解了这个分层,你写代码时自然就知道"这件事该用定时器还是 PWM"。
7. 容易被忽略的定时器细节:取消、动态调周期和并发安全
最后聊几个平时不容易注意的细节,每一个都是我在实际项目中付出过教训换来的。
第一是取消定时器时要考虑竞态。cancel_alarm()的返回值只有在定时器还在排队且未触发时才是true。如果回调已经在执行中了,cancel 会失败,此时如果你在主线程里释放了回调要用到的资源,回调里再用就是未定义行为。安全做法是:在回调里设置一个volatile bool标志通知主线程"我已经完成了,可以释放了",或者在主线程里 先通过阻塞队列同步,再释放资源。
第二是动态修改重复定时器的周期。add_repeating_timer_ms()创建定时器后,如果你想改变周期,最直接的办法是直接在回调里修改repeating_timer->delay_us字段并返回true。这个字段在下一次重新装载时会被读取,所以"下一次"周期就变了。但要注意:如果主线程和回调线程同时修改这个字段,可能产生不一致。万无一失的做法是不要同时改,或者用一个锁保护。RP2040 是单核(Pico 1 代),不存在真正意义上的"多线程",但中断上下文和主循环确实可以并发访问同一个变量,所以volatile或者临时关中断仍是必要的。
第三是pico-sdk 的定时器在 USB 低功耗模式下会变慢。如果你的项目启用了 USB 设备模式并进入休眠,定时器报警会被推迟到唤醒后补偿执行。如果你依赖定时器做设备心跳检测,这个行为会导致误判。解决方案是尽量让 Pico 保持唤醒状态,或者使用 RTC 模块做长定时。
7.1 一个自查清单
在发布自己的 Pico 定时器代码之前,我建议你快速过一遍这个清单:
- 回调函数做到三行以内吗?(最多:置标志位、入队、清标志)
- 回调里有没有调用
sleep_ms、printf、malloc?如果有,请立刻移除 - 所有传给回调的上下文指针,生命周期覆盖到定时器结束了吗?
- 定时器结构体(
repeating_timer_t)是全局变量或静态变量吗? - 需要取消定时器时,你考虑过竞态问题吗?
- 定时周期是否远大于回调的最坏执行时间?(建议至少大于 5 倍)
printf等调试输出是否只放在主循环?
满足这些条件,你的定时器代码基本可以稳定运行很久。我见过很多"奇怪"的 bug,最后排查下来都是回调里多写了一行调试输出导致时序崩溃。
我个人在实际项目里的习惯是:能用主循环轮询加时间戳解决的,绝不轻易上定时器中断;真需要定时器时,优先选repeating_timer;微秒级需求直接上 PWM 或 PIO。这个习惯帮我省掉了大量调试时间,也推荐给你试一试。