
1. 为什么 Pico 的定时器值得单独写一篇很多人拿到树莓派 Pico第一件事就是点灯、跑个串口打印再进阶一点就是玩 PWM 舵机、读编码器。等真到要同时处理多路传感器采样、精确控制步进电机脉冲、或者做低功耗周期性唤醒的时候才发现延时函数 主循环轮询这套组合拳越来越力不从心。这个时候定时器就该登场了。Pico 的定时器和其他单片机比如 STM32、ESP32不太一样它既没有一堆 TIM1/2/3/4 外设让你挨个配预分频和重装载值也没有那么多互补输出和死区插入这种高级功能。它走的是精简 灵活的路线一个 64 位系统定时器SysTick 只是它的其中一小块加一组硬件报警器Alarm再加一组可复用的 PWM 计数器。理解这套结构基本就理解了 Pico 定时的全部底层逻辑。这篇文章的定位是吃透而不是入门跑通所以我会把 Pico 官方 SDK 里定时器相关的寄存器、数据结构、回调机制全部拆开讲一遍同时给出实际项目里真正能用的代码示例和避坑经验。适合正在做机器人控制、数据采集、低功耗设备的朋友参考也适合那些已经能跑通 C SDK 示例、但想搞清楚底层到底怎么工作的人。2. 先从硬件层面看清定时器的源头2.1 一个时钟源三套分发路径Pico 用的是 RP2040 芯片它的定时器核心是挂在 APB 总线上的一个 64 位递增计数器时钟源来自系统时钟默认 125MHz可以配置分频。这个计数器从芯片上电开始就一直跑从不停止直到你关掉系统时钟或者断电。硬件上它把定时功能拆成了三条独立的路径第一条是SysTickCortex-M0 内核自带的 24 位递减计数器主要给 RTOS 做系统节拍用SDK 里的time_us_64()不依赖它所以就算你把它关了也不影响延时函数。第二条是Timer 外设的四个 Alarm报警器每个 Alarm 对应一个 32 位比较寄存器计数器低 32 位等于 Alarm 值时触发中断。这条路径是真正用来做定时中断的。第三条是PWM 模块里的计数器每个 PWM slice 都有一个 16 位计数器可以自由配置计数模式和周期。虽然它名义上叫 PWM但完全可以当定时器用而且能输出到 GPIO 引脚这是 Alarm 做不到的。很多人容易把这三者搞混尤其是把 SysTick 当成系统唯一的定时器来源。实际上 SDK 的sleep_us()、time_us_64()、add_alarm_in_ms()用的根本不是同一个硬件资源这也是为什么同一个工程里可以一边用延时、一边挂回调互不干扰。先把这个 Dispatch 关系记住后面理解工作模式就轻松了。2.2 64 位计数器为什么重要32 位定时器在 125MHz 时钟下溢出周期大约是 34 秒这意味着你必须定期处理溢出中断否则时间会跳变。Pico 用了 64 位计数器理论上可以连续跑几千年不溢出所以在读取时间戳的时候你不用像 STM32 那样去判断有没有进过溢出中断。这个设计对写代码的影响很直接time_us_64()返回的就是一个从不回绕的 64 位微秒时间戳你拿它做超时判断、做时间戳标记、做事件间隔计算完全不用担心回绕问题。我在做多传感器时间同步的时候直接拿这个时间戳做数据融合省掉了一大堆溢出处理代码。2.3 定时器寄存器速览SDK 把寄存器封装得很好但为了理解底层我还是把关键寄存器罗列一下寄存器功能关键位TIMER_TIMEHR/TIMER_TIMELR64 位计数器高/低 32 位只读TIMER_ALARM0~TIMER_ALARM3四个报警比较值写触发匹配TIMER_ALARM0_RUN~TIMER_ALARM3_RUN对应 Alarm 是否已启用置 1 启动TIMER_INTR中断状态对应位为 1 表示触发TIMER_INTE中断使能对应位置 1 开启中断TIMER_INTF强制中断调试用这里有个细节TIMER_ALARM0_RUN是只写寄存器写 1 表示启动匹配写 0 是无效的。你不能通过读它来判断 Alarm 是否在跑这是个常见的坑。SDK 内部用alarm_pool结构体里的链表来追踪已注册的回调所以实际写应用代码时不太需要碰寄存器。3. 中断回调模式Alarm 的工作逻辑与使用陷阱3.1 add_alarm_* 系列 API 背后的调度机制SDK 提供了一组封装好的接口add_alarm_in_us()、add_alarm_in_ms()、add_alarm_at()。这些函数本质上都是在操作同一个由alarm_pool管理的中断回调节点池。我推荐的用法是直接初始化一个默认的 alarm pool然后调用add_alarm_in_ms()传入回调函数指针和用户数据指针。底层流程是把当前时间加上延时得到绝对触发时间。把回调信息挂到 alarm pool 的链表中。找到四个 Alarm 中空闲的那个设置比较值。使能对应中断。等计数器的低 32 位追上比较值进入中断服务程序。在中断里按链表顺序取出到期回调执行完后清除状态继续检查下一个。这段流程里最容易被忽略的一点是回调是在中断上下文里执行的。也就是说你在回调里写串口、分配内存、调用比较耗时的函数都会阻塞其他中断严重时会导致系统时钟抖动。我在实际项目中踩过这个坑在回调里用printf调试结果定时周期漂了将近 20%。后来改成回调里只设置标志位主循环里再处理问题立刻消失。3.2 回调函数的安全写法正确的回调模式是这样volatile bool sensor_ready_flag false; int64_t sensor_alarm_callback(alarm_id_t id, void *user_data) { // 只做最轻量级操作置标志位 sensor_ready_flag true; // 返回 0 表示一次性定时不再次触发 return 0; }如果你需要周期性的定时有两种方案方案一在回调里重新调用add_alarm_in_us()但这样会引入抖动因为每次回调执行到重新注册的这段时间也算在周期外了。方案二使用hardware_alarm_claim()直接绑定回调配合hardware_alarm_set_target()在回调里精确计算下一次触发时间。这才是真正适合高频定时的硬核方案。我做过一个 1kHz 的 PID 控制器周期抖动要求控制在 10 微秒以内。用方案一实测抖动约 40 微秒改用方案二后抖动压到 5 微秒以内。原因很简单方案一每次都要走一遍链表查找和注册流程方案二只在固定的硬件 Alarm 上直接修改比较值。3.3 定时器中断与 FIFO、DMA 的协同在数据采集类应用里经常需要定时器触发采样然后通过 DMA 把数据搬走。Pico 的硬件 Alarm 不能直接触发 DMA但可以通过在中断里写 DMA 控制寄存器来间接实现。更实用的方式是使用 PIO 或者 PWM 的 DMA 请求这属于定时器之外的另一个话题。如果只是做固定间隔采一次 ADC建议把 ADC 采样放在回调里数据放进一个环形缓冲区主循环负责处理和上报。采集中断占用的时间极短一次 ADC 转换大约 2 微秒完全可以在定时器回调里完成。4. PWM 计数器模式把定时器输出到引脚上4.1 PWM slice 当定时器用的结构RP2040 有 8 个 PWM slice每个 slice 有两个通道A/B本质上就是一个 16 位计数器加两个比较器。这个计数器和我们前面说的 64 位系统定时器是独立的它有自己的时钟源可由系统时钟分频得到有自己的计数方式向上计数或上下计数可以输出波形到 GPIO。用 PWM 计数器做定时的思路有两种一种是把计数器配置成固定周期等它溢出时触发中断这是一个纯软件定时器的变体和 Alarm 的区别是它的频率可以通过wrap和clkdiv精细控制。另一种是直接输出硬件波形比如给舵机输出 50Hz 的 PWM 信号或者给步进电机驱动器输出精确数量的脉冲。4.2 三步配出一个可调周期的定时任务第一步初始化 GPIO 和 PWM slicegpio_set_function(0, GPIO_FUNC_PWM); uint slice pwm_gpio_to_slice_num(0); pwm_config config pwm_get_default_config(); pwm_config_set_clkdiv(config, 125.0f); // 125MHz / 125 1MHz pwm_config_set_wrap(config, 999); // 1MHz / 1000 1kHz pwm_init(slice, config, true); pwm_set_enabled(slice, true);第二步使能 PWM 中断。默认情况下 PWM 中断并没有映射到系统中断向量你需要调用pwm_clear_irq(slice); pwm_set_irq_enabled(slice, true); irq_set_exclusive_handler(PWM_IRQ_WRAP, pwm_interrupt_handler); irq_set_enabled(PWM_IRQ_WRAP, true);第三步在中断处理函数里判断是哪个 slice 触发void pwm_interrupt_handler(void) { for (int i 0; i 8; i) { if (pwm_get_irq_status(i) 1u) { pwm_clear_irq(i); // 你的周期任务代码 } } }注意PWM 中断触发条件是计数器达到wrap值相当于一次完整的计数周期结束。它的频率下限可以做到非常低通过提高分频系数上限最高就是系统时钟频率比 Alarm 灵活得多。4.3 输出固定数量脉冲的方法控制步进电机经常需要给 N 个脉冲这种需求。实现方式有很多我常用的是借助 PWM 的wrap中断。首先配置好 PWM 周期然后开启中断在中断里每次对脉冲计数达到目标次数后关闭 PWM 输出volatile uint32_t pulse_count 0; volatile uint32_t pulse_target 0; void pwm_interrupt_handler(void) { if (pwm_get_irq_status(slice) 1u) { pwm_clear_irq(slice); pulse_count; if (pulse_count pulse_target) { pwm_set_enabled(slice, false); pwm_clear_irq(slice); } } }这种做法的精度取决于 PWM 中断的响应时间空载情况下误差在几微秒以内对于绝大多数步进应用完全够用。如果你需要更苛刻的时序PIO 才是最合适的方案定时器本身做不到硬件级精确停发。这也是我反复强调先搞清楚需求精度再选外设的原因。5. 64 位时间戳的工程化利用5.1 用 time_us_64 做超时不阻塞的判断在写通信协议的时候最长用的就是超时判断。比如等待传感器返回数据不能一直阻塞在那里要有一个超时机制。用time_us_64()实现非常简单uint64_t start_time time_us_64(); uint64_t timeout_us 5000; // 5ms while (!data_ready) { if (time_us_64() - start_time timeout_us) { // 超时处理 break; } }这段代码的关键点在于time_us_64() - start_time的运算利用了无符号数的回绕特性所以不需要额外处理 64 位溢出这在 C 标准里是定义良好的行为。我在树莓派 Pico 与 PC 串口通信时就是靠这个机制保证不因某次接收异常而卡死整个程序。5.2 时间戳设置与 RTC 的关系很多朋友会问Pico 有没有硬件 RTC答案是没有真正的 RTC只有靠 64 位定时器模拟出来的软件 RTC。SDK 提供了rtc_init()和datetime_to_timestamp()这样的函数但底层仍然是靠系统定时器的微秒计数换算日期时间。也就是说断电之后这个时间就丢了必须每次上电手动设置。如果你需要长期离线记录带时间戳的数据建议外接一个带电池的 RTC 模块定时器只负责提供亚秒级的微秒精度RTC 提供年月日时分秒。数据记录时可以把两者组合成一个浮点型时间戳或者结构体保存。6. 实测四种定时方式对比与选型建议我做了几组基准测试分别测试了sleep_us阻塞延时、Alarm 回调、PWM 中断、以及纯轮询读取时间戳四种方式在 1kHz 周期下的表现结果如下定时方式最小周期实测抖动是否阻塞适用场景sleep_us约 1ms受中断影响较大是简单顺序流程Alarm 回调任意约 10-20us否中低频任务回调PWM 中断任意约 2-5us否高频周期任务时间戳轮询取决于主循环波动大否非精确的多任务调度选型建议很简单只是让 LED 闪烁、按键扫描用sleep_us完全没问题。需要真正的异步定时中断用 Alarm但回调里别干重活。需要定时输出波形或者极低抖动高频中断用 PWM 计数器。需要多个任务无阻塞并发用time_us_64()做超时切片配合循环调度。在这个基础上你还可以利用 Pico 的第二个核心 core1 把定时任务和业务逻辑分开core0 跑通信和显示core1 专门跑定时器回调两核之间通过队列传递数据。这个玩法适合更复杂的应用但要做好共享内存的并发保护推荐使用critical_section或者无锁队列。7. 调试定时器时的几个实用技巧7.1 用逻辑分析仪验证周期开发阶段不要迷信计算值直接拿逻辑分析仪或者示波器测 GPIO 输出。我习惯在每个定时任务里翻转一个 GPIOgpio_xor_mask(1u 25); // 翻转 GPIO25然后用逻辑分析仪看这个引脚的波形周期误差一目了然。这个方法比在代码里打印时间戳高效得多也不会影响定时器时序。7.2 用调试寄存器定位问题如果觉得定时器没有按预期触发先用timer_hw-intr看看中断状态是否为 1。如果为 1 但你的回调没执行说明是中断向量配置的问题如果为 0说明比较值都没匹配上去查时钟分频或者 Alarm 号是否冲突。SDK 里有几个定时器相关的断言开关可以在调试时把CONFIG_DEBUG_POOL之类的宏打开它能帮你查出重复释放回调、定时器溢出这类问题。7.3 小心低功耗模式Pico 进入sleep模式后定时器的时钟源会被关掉time_us_64()不再递增Alarm 也不会触发。这一点极容易踩坑你以为睡了 10 秒醒来一读时间戳发现只过了 0 秒。如果要做低功耗定时唤醒必须用 RTC 的外部 32.768kHz 晶体或者用看门狗定时器来做唤醒源SDK 自带的sleep函数不能同时满足深睡 精确计时这两个需求。我在一个电池供电的数据采集器项目里被迫放弃了 Pico 内置的睡眠定时改为外接 TPL5010 这样的低功耗定时器芯片来做周期性唤醒效果稳定得多。8. 结合常见问题卡死、失效与误触发8.1 定时器中断进不去的排查链路这类问题通常有四个排查点是否使能了全局中断、是否使能了对具体定时器源的中断、是否注册了正确的中断服务函数、以及回调函数是否有残留占用。我遇到过最隐蔽的一个场景是用 PWM 中断时忘了pwm_clear_irq()导致中断标志一直为 1程序反复进中断看起来像卡死了。实际上不是死机而是中断风暴。先查intr状态和中断服务函数的清标志操作往往能快速定位。8.2 定时器触发了两次的原因与解决四个 Alarm 是共用一个中断入口的如果同时对ALARM0和ALARM1注册了回调中断服务函数里必须区分是哪个 Alarm 产生的触发。SDK 的alarm_pool已经封装了这一步但如果你用原生寄存器操作就要记得在中断服务函数里同时处理ALARM0到ALARM3四个状态位否则某个 Alarm 触发了但你没清它的标志等下一次中断它还会再次触发造成幽灵回调。另外要注意Alarm 比较寄存器是 32 位的而系统定时器是 64 位的。如果你设置的延时超过了UINT32_MAX / 125000000秒约 34 秒SDK 会自动做分段处理但在某些极端写法下可能让比较值错位表现为明明延时 10 分钟结果几秒钟就触发了。这种情况不常见但遇到时可以先查 SDK 的alarm_pool实现里有没有处理 64 位比较的代码。8.3 遮罩了中断导致定时不准的连带问题如果你在正点原子或者其他移植代码中看到__disable_irq()或irq_set_mask_enabled()这类全局关中断的操作要特别小心。它会把定时器中断也一并关了如果关中断的时间超过定时周期之后的定时任务会全部堆积在重新开中断的瞬间一次性执行导致系统响应剧烈波动。改进方法是只在必要的临界区里关中断并且尽量缩短关中断时间或者改用无锁编程的队列来保护共享数据而不是粗暴地关全局中断。这一条对任何需要精确延时的项目都适用。9. 从定时器到整个系统我的个人总结定时器说白了就是一个硬件记账员系统时钟每跳一下计数器就加一到了你设置的某个值它就喊你一声到点了。理解了这个底层逻辑不管 Pico 还是其他单片机换汤不换药。我个人的经验是拿到一个新的开发板第一件事不是点灯而是写一个 100ms 定时回调让一个 GPIO 翻转。这个最简单的测试能把芯片的时钟、中断、定时器外设链路全部串起来一旦跑通后面加 PWM、加 DMA、加多核协作都是顺理成章的事。真到了复杂的项目阶段我建议你把定时器的使用场景先写清楚是输出波形是定时中断是时间戳超时然后用本文提到三种硬件资源分别去对号入座大部分纠缠不清的定时器不好用的问题其实都是选错工具造成的。如果非要说还有哪一块值得再单独研究我会推荐去读一遍 SDK 里hardware_timer.c和hardware_pwm.c的源码尤其是定时器中断服务函数里的链表操作部分。看懂那几段代码你对中断是如何被管理的这个问题的理解会上升一个层次以后排查任何定时相关问题都能少走很多弯路。