尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

树莓派Pico RP2040定时器详解:从硬件原理到C SDK与MicroPython实战

树莓派Pico RP2040定时器详解:从硬件原理到C SDK与MicroPython实战 我们做嵌入式项目天天跟时间打交道。点亮一颗 LED 要延时按键要消抖舵机要 PWM传感器采样要周期触发后台任务要按节拍切换——这些全都离不开定时器。树莓派 Pico 这颗 RP2040 芯片里的定时器资源我在实际项目里用了两年多从一开始只会sleep_ms()瞎等到后来把系统定时器、PWM 定时器、闹钟中断全部用起来踩过不少坑也攒了不少经验。这篇就把 RP2040 的定时器从硬件原理到工作模式一次讲透看完你能明白定时器在芯片里到底是怎么跑的也知道在 C SDK 和 MicroPython 里分别怎么写最顺手。这篇文章适合正在用树莓派 Pico 做项目的朋友尤其是从 STM32、51 单片机转过来的——你会发现 Pico 的定时器思路跟传统 MCU 很不一样但用好了反而更简单。1. 定时器的主体RP2040 里藏着哪些时间源很多人拿到 Pico 第一反应是“一个板子而已定时器不就是 delay 吗”。其实 RP2040 里能当定时器用的硬件至少有四套系统定时器、PWM 外设里的定时器、Cortex-M0 内核自带的 SysTick以及看门狗定时器。搞清楚它们分别干什么后面写代码才不会乱。1.1 64 位系统定时器一整条微秒时间轴先看最核心的一套芯片内部有一个 64 位的自由运行计数器官方叫法就是系统定时器System Timer。它在芯片上电复位后就开始从 0 往上涨时钟源经过内部 PLL 分频后正好是 1MHz也就是说每过一个微秒计数器就加 1。64 位意味着什么就算从 0 开始数也要数上几十万年才会溢出所以你可以把它当成一条永不断电的时间轴来用。这套硬件对应 C SDK 里的hardware/timer库平时最常用的time_us_64()读到的就是这条时间轴。它的妙处在于你不需要像 51 单片机那样“开定时器、装初值、开中断、等溢出”而是任何时候直接读一下当前值就知道从开机到现在过了多少微秒。两个时间点一减就是耗时。注意这个 64 位计数器本身并不产生中断。真正能产生中断的是挂在它身上的四个闹钟寄存器ALARM0ALARM3等计数器走到你设置的某个绝对时间点触发电平翻转继而触发中断。这跟“定时器溢出中断”的经典思路完全不同但是逻辑上更干净。1.2 引脚外的第三条路PWM 外设中的定时器除了系统定时器RP2040 还有 8 组 PWM 外设官方叫 PWM Slice每组带两个输出通道 A/B总共能输出 16 路 PWM。很多人只把它当 PWM 用忽略了它本质上也是一个定时器每个 PWM Slice 内部都有一条 16 位计数器按照可配置的分频系数做递增计数计数到某个预设的 wrap 值后清零重新开始。这条计数器比系统定时器“多功能”在哪第一它有两个比较寄存器A 通道和 B 通道分别一个计数器走到比较值时可以把引脚电平翻转这就是 PWM 占空比的来源第二它可以直接驱动 GPIO 引脚不需要 CPU 介入第三它也能触发中断——不只是 wrap 归零时能中断比较匹配时也能中断。这意味着你完全可以用 PWM 外设来“定时”把 wrap 设成 999时钟 1MHz 下每 1ms 触发一次中断。这就是“PWM 定时器”的实际用法。1.3 配角也要说清SysTick 与看门狗RP2040 的 CPU 核心是 Cortex-M0内核自带一个 SysTick 定时器24 位递减计数器时钟可以配置为处理器时钟或其分频。SysTick 最大的价值是在 RTOS 里做时间片轮转调度FreeRTOS 在 Pico 上跑起来就是靠它产生节拍。如果你裸机开发SysTick 也可以当普通定时器用但 24 位、递减模式、要自己管重装载值用起来不如系统定时器顺手。看门狗则是另一套独立硬件它只有一个向下计数功能超时后会把整个芯片复位有时候也会触发中断。它主要的用途是防止程序跑飞一般不建议拿来当普通定时器用——毕竟它的失效惩罚是重启不是回调。芯片里还有 RTC 外设但那个更偏日历场景跟微秒级定时关系不大项目里基本用不上。2. 工作模式拆解从“数数”到“闹钟”再到“捕获”现在进入正题。定时器的“工作模式”说白了就是你要它怎么跑是一直数数让 CPU 随时来读还是数到某个点叫 CPU 一声又或者把这个定时能力接到引脚上直接输出波形。RP2040 的定时器模式大致可以分成四类下面一个个拆。2.1 自由运行计数最简单的读取模式系统定时器默认就是自由运行模式从上电开始永不停止地数微秒。你不需要初始化它直接time_us_64()就能拿值。这种模式下 CPU 不被打扰想在什么时机读就什么时机读适合用来记录事件发生的时间戳。实战中有个很常见的场景要测量一段代码的执行耗时就在代码前后各读一次时间相减。我之前优化一段传感器滤波算法时就是这么干的——不用逻辑分析仪直接把两次time_us_64()的差打上串口哪个分支慢一目了然。注意 64 位计数器读起来有个小坑它的存储分为高 32 位和低 32 位两个寄存器CPU 读一次只能读其中一个。如果读完低 32 位后高 32 位刚好进位读到的值就整体偏了。SDK 内部其实处理过这个问题time_us_64()会用三段读取法保证结果正确所以你只要用官方 API 就不用担心。自己直接操作寄存器的话就要注意了这个问题我在第 4 章详细说。2.2 闹钟与单次触发定时到点干一件事跟自由运行相对的是闹钟模式。你要设置一个绝对时间点等计数器走到那个点硬件拉高一条中断线CPU 在中断服务函数里干活。这个过程很像家里定闹钟现在时间是 T过 500ms 后响铃你先把 T500ms 写进闹钟寄存器等时间到了它自然叫你。RP2040 一共有 4 个闹钟对应两组中断向量ALARM0 专用的中断TIMER_IRQ_0ALARM1ALARM3 共用的TIMER_IRQ_1。为什么共用因为芯片的设计者觉得大部分应用用不了那么多独立的定时中断共用一组就够用了还能省中断向量资源。在 C SDK 里直接用底层 API 做闹钟中断会绕一点要先注册中断回调、再使能中断、再设置闹钟值。SDK 还提供了更高层的alarm_pool概念把一个或者多个硬件闹钟包装成“定时器池”池里能同时挂几十个逻辑闹钟那个用起来更省心后面实操章节细说。2.3 重复定时器周期调度的两种实现单次闹钟不够用怎么办很多需求是“每 10ms 采集一次传感器”“每 500ms 翻转一次 LED”这种就是周期定时。实现方式主要有两种第一种是在单次闹钟的回调函数末尾重新申请下一次闹钟。相当于闹钟响了之后你马上再定一个同样的闹钟循环往复。SDK 的add_repeating_timer_ms()底层就是这套逻辑非常简单。第二种是利用 PWM 外设的 wrap 中断PWM 计数器每数到 wrap 就归零重来同时触发中断天然就是周期性的。而且你还可以顺手在这个 PWM Slice 输出引脚上看到方波正好能当波形发生器用。两种我都用过经验是如果周期在毫秒级以上、还要兼顾多个任务调度优先用系统定时器的重复闹钟如果周期短到了几十微秒级、或者你还想同时输出一路 PWM 信号那必须用 PWM 外设。原因很简单系统定时器闹钟的处理有软件开销加回调、判断溢出、重装载微秒级周期会把自己的 CPU 时间吃光。2.4 PWM 与捕获模式定时器的“输出端”和“输入端”严格来说PWM 外设算广义定时器家族的一员。对它设置wrap就是定时周期设置level就是比较值。输出波形频率公式是PWM频率 PWM时钟频率 / (wrap 1) / 分频系数假设 PWM 时钟是 125MHz分频系数设成 125wrap 设 19999那么 PWM 频率就是 50Hz周期 20ms。这里每一个计数单位正好等于 1 微秒计算脉冲宽度的时候就特别直观。除了输出PWM 外设还能做输入捕获把引脚配置成 PWM B 通道输入它可以在输入信号边沿到来时记录当前计数器的值。两次边沿的计数值相减配合分频系数就能测出外部信号的周期和脉宽。测频率、测占空比都可以这么干相当于定时器又多了一双“眼睛”。这部分寄存器操作比较多数据手册 PWM 那章写得很细有需要可以按手册实操。3. 实操配置C SDK 与 MicroPython 两条路线原理讲完直接上代码。我用过 C SDK 也用过 MicroPython两条路线的能力边界不一样根据项目需求选择不要盲目迷信某一种。3.1 C SDK 快速起步三个函数搞定定时闹钟先用底层 API 感受一下系统定时器的中断是怎么工作的。第一步引入头文件#include hardware/timer.h #include hardware/irq.h写一个最简单的 1 秒单次闹钟volatile bool alarm_fired false; void alarm_callback(void) { alarm_fired true; } int main() { // 注册中断处理函数注意这是 ALARM0 专用中断 irq_set_exclusive_handler(TIMER_IRQ_0, alarm_callback); irq_set_enabled(TIMER_IRQ_0, true); // 获取当前时间 uint64_t now time_us_64(); // 设置 1 秒后触发 hardware_alarm_set_target(0, now 1000000); while (!alarm_fired) { tight_loop_contents(); } // 到这里说明 1 秒闹钟已经响应 return 0; }这段代码里有个细节值得解释hardware_alarm_set_target(0, now 1000000)的第一个参数是闹钟编号 0对应中断TIMER_IRQ_0。它设置的绝对时间点是以微秒为单位的。虽然now是 64 位但闹钟寄存器底层只有 32 位SDK 会帮你处理高位比较的逻辑你传 64 位绝对时间就行。比起直接操作寄存器我更推荐用 SDK 的重复定时器 API写周期任务非常快#include pico/stdlib.h #include hardware/timer.h bool repeating_timer_callback(struct repeating_timer *t) { // 每 100ms 翻转一次板载 LED gpio_put(PICO_DEFAULT_LED_PIN, !gpio_get(PICO_DEFAULT_LED_PIN)); return true; // 返回 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; // 注意这里传的是负数-100 表示每 100ms 触发一次 add_repeating_timer_ms(-100, repeating_timer_callback, NULL, timer); while (true) { sleep_ms(1000); } }向add_repeating_timer_ms传负数是什么意思这是 SDK 的一个约定如果 delay 是负数表示从调用这个函数的那一刻开始计时之后每 |delay| 毫秒触发一次回调如果 delay 是正数则表示从调用时刻起延迟 delay 毫秒后触发第一次之后再每隔 delay 毫秒触发。日常用都是传负数语义符合直觉。3.2 用 PWM 外设驱动舵机从定时器到方波信号说到 PWM 输出最容易上手的实例就是驱动舵机。舵机的控制信号本质就是周期 20ms、脉冲宽度 0.5ms~2.5ms 的方波所以我们需要在 Pico 上产生 50Hz 的 PWM。计算过程是这样Pico 默认系统时钟 125MHzPWM 外设的计数时钟也来自这个频率。想要每个计数单位是 1 微秒就把分频系数设成 125。然后 wrap 值设成 19999这样计数范围是 0~19999正好 20000 微秒一个周期即 20ms。#include hardware/pwm.h #include hardware/clocks.h #define SERVO_GPIO 15 void servo_init(void) { gpio_set_function(SERVO_GPIO, GPIO_FUNC_PWM); uint slice pwm_gpio_to_slice_num(SERVO_GPIO); // PWM 时钟分频125MHz / 125 1MHz每个 tick 1us pwm_set_clkdiv(slice, 125.0f); // 周期20000us对应 50Hz pwm_set_wrap(slice, 19999); // 初始脉宽1.5ms对应舵机中位 pwm_set_chan_level(slice, PWM_CHAN_A, 1500); pwm_set_enabled(slice, true); } // pulse_us 范围建议 500~2500单位微秒 void servo_set_pulse(uint16_t pulse_us) { uint slice pwm_gpio_to_slice_num(SERVO_GPIO); pwm_set_chan_level(slice, PWM_CHAN_A, pulse_us); }这个代码最妙的地方在于因为分频后计数频率正好是 1MHz所以pwm_set_chan_level的数值直接用微秒就行完全不用再做换算。舵机角度和脉冲宽度的关系不同型号略有差异一般 0 度对应 1ms90 度对应 1.5ms180 度对应 2ms你可以用servo_set_pulse()传对应值实测调整。注意RP2040 的 PWM 计数器和比较寄存器的位宽都是 16 位所以 wrap 不能超过 65535。如果你想输出 50Hz 又不想分频太大这个值没问题但如果想要低频信号必须配合分频不能一个劲儿把 wrap 往上加。3.3 MicroPython 方案time 库与 PWM 类的取舍MicroPython 在 RP2040 上有个常见的坑它没有实现machine.Timer类。如果你从 ESP32 转过来习惯写machine.Timer(0)定时中断在 Pico 上直接会报错。这是官方固件的选择不是 bug。那 MicroPython 下怎么做定时任务我有三种方案第一种用time.ticks_us()配合time.ticks_diff()做非阻塞延时。比如我要每 50ms 扫一次按键import time from machine import Pin key Pin(16, Pin.IN, Pin.PULL_UP) last_time time.ticks_us() while True: now time.ticks_us() if time.ticks_diff(now, last_time) 50000: last_time now if not key.value(): print(key pressed)这种写法不占用中断逻辑简单适合任务不多的小项目。注意一定要用ticks_diff()不要直接拿两个ticks_us相减因为取模运算和溢出问题容易出 bug。第二种用machine.PWM的周期特性做波形输出适合舵机、LED 呼吸灯这类场景。API 很直接from machine import Pin, PWM servo PWM(Pin(15)) servo.freq(50) servo.duty_u16(4915) # 这个值是 16 位的4915 大约对应 1.5ms 脉宽需要换算第三种如果确实需要微秒级的精确中断MicroPython 做不到那就得回到 C SDK或者用 C 写一个小的库通过固件桥接。我的建议是项目里需要大量精确时基的时候直接用 C只是脚本化验证思路MicroPython 够用。3.4 用定时器做按键消抖与延时一个工程案例前面都是单个功能最后组合起来看一个完整案例用系统定时器加状态机做按键消抖。经典消抖思路是延时 20ms 再确认但time.sleep()会把整个流程阻塞住。利用 64 位微秒时间轴可以改成非阻塞式的#define DEBOUNCE_US 20000 typedef struct { uint32_t last_edge_us; bool stable_state; } button_t; bool button_scan(button_t *btn, bool raw_level) { uint32_t now (uint32_t)time_us_32(); // 两次边沿间隔小于消抖时间忽略 if ((now - btn-last_edge_us) DEBOUNCE_US) { return btn-stable_state; } if (raw_level ! btn-stable_state) { btn-stable_state raw_level; btn-last_edge_us now; } return btn-stable_state; }这里用 32 位时间戳来比较now - btn-last_edge_us利用了无符号数溢出的特性即使now回绕了也能正确算出时间差这是嵌入式里非常经典的处理技巧。主循环里每次循环都调用button_scan按键状态更新与循环耗时无关多个按键共用一个消抖函数性能很好。4. 常见问题与排查技巧实录用定时器写代码大部分 bug 都不是语法错误而是对硬件的理解偏差。我在项目里遇到过的典型问题整理成清单分享出来你照着排查能省很多时间。4.1 读 64 位计数器怎么读才安全前面说过64 位计数器物理上分成两个 32 位寄存器。CPU 读取时先读低 32 位再读高 32 位如果低 32 位快满的时候刚好进位读出来的结果就会“低 32 位是新值、高 32 位是旧值”拼出来的 64 位数差了几十亿微秒。SDK 官方的time_us_64()实现是先读高 32 位再读低 32 位再读一次高 32 位如果前后两次高 32 位不一致说明读取过程中发生了进位就用第二次的值的低 32 位与第二次高值组合。这种三段读取法本质上是靠“重读到值稳定”来避免撕裂如果你自己直接操作寄存器照这个套路写就行。4.2 回调里千万别干重活定时器回调是在中断上下文里执行的。我在项目初期犯过一个错误在每秒一次的定时回调里去驱动 OLED 全屏刷新结果主循环直接卡顿还伴随偶发重启。原因很简单中断服务函数里执行时间太长把其他中断的响应全堵住了。经验法则是定时器回调里只做两件事——置标志位、记录时间戳。具体的数据处理放到主循环里做。如果确实要在中断里快速处理少量数据比如 DMA 搬运也要控制代码执行时间最好在微秒级别。4.3 PWM 占空比输出到不了 100% 或为 0这个问题在 STM32 的社区里被问烂了Pico 上同样存在。很多人设置 PWM 时发现把 level 设成 wrap 值输出却不是 100% 高电平把 level 设成 0输出也不是严格的 0%。原因是 PWM 比较器的比较逻辑在不同芯片上机制不同RP2040 的比较匹配发生在counter level的那个时刻要理解输出电平翻转规则。Pico 上想要 100% 占空比比较稳妥的方式是把 level 设为wrap 1让比较匹配永远不发生不对——电平和 wrap 一样都受 16 位限制wrap 最大 65534 时可以设 65535但 wrap 本身设 65535 就没法再往上加了。实际还是要查数据手册看匹配逻辑。我踩坑后的经验是项目里需要接近 100% 输出时我在电路上直接用三极管开关电路而不是依赖 PWM 自身那个临界值。这也是个很实用的思路。4.4 乱改时钟导致定时不准RP2040 核心支持动态调频比如用set_sys_clock_khz()把系统时钟从 125MHz 调到 250MHz。但很多定时器的时钟源并不直接跟随系统主频系统定时器的 1MHz 基准来自 USB 内部 PLLPWM 外设的时钟则来自系统时钟树。这就导致同一个程序在 125MHz 下 PWM 输出正常改成 250MHz 后 PWM 频率翻倍而系统定时器time_us_64()几乎不受影响。排查这类问题时一定要搞清楚你的定时器时钟到底是从哪条路径来的改主频前先看数据手册的时钟树图。经验是凡是跟频率相关的周期信号最好用外部晶振或专用的分频路径不要依赖不知道什么时候会被改掉的系统时钟。5. 与 51/STM32/SysTick 的横向对比很多读者是从其他平台转过来的这里做一个小结对比方便你知识迁移。5.1 板级定时器 vs 内核定时器51 单片机的定时器是两个 16 位计数器需要手动装初值工作模式有定时、计数、PWM 等但都得自己维护中断标志。STM32 的定时器芯片内部资源丰富有 TIM1TIM14 多个不同规格的定时器支持输入捕获、输出比较、编码器接口、PWM 等多种模式功能强但配置繁琐用库函数也要处理一堆结构体字段。RP2040 的思路完全不同它把“计时间”和“输出波形”拆成了两个外设。系统定时器只管单调递增数微秒四个闹钟负责到点上报PWM 外设管波形输出和比较捕获。你不需要去选哪个定时器更合适64 位系统定时器拿来统一管时间PWM 外设管输出和捕获各司其职代码写起来反而更不容易乱。SysTick 则是内核级的东西跟外设无关主要给 RTOS 提供心跳。裸机编程里用它也能做延时但 Pico SDK 很少直接用 SysTick 做应用定时因为它本身只有 24 位而且时钟与内核耦合度太高。5.2 怎么选看到项目需求直接对号入座我把选择逻辑整理成了一张速查表需求场景推荐方案原因测量代码耗时、记录事件时间戳time_us_64()系统定时器64 位微秒时间轴直接读值周期唤醒任务毫秒级调度add_repeating_timer_ms()多路逻辑定时器回调易用微秒级周期中断如高频采样PWM Slice wrap 中断硬件周期触发无软件重装载开销输出舵机、LED 等 PWM 信号PWM 外设 level/wrap硬件自动翻转引脚不占 CPU测量外部信号频率和脉宽PWM B 输入捕获硬件边沿记录精度远高于 GPIO 轮询RTOS 时间片轮转SysTick内核标准节拍源FreeRTOS 直接支持这张表也是我自己选型时的思维框架。核心原则一句话系统定时器管时间点PWM 管时间波形SysTick 管系统调度三套东西配合好了基本覆盖全部需求。我在实际使用中还有一个体会用 Pico 写定时器代码不要总想着“设计一个万能的定时器框架”RP2040 的外设资源本身就很精简裸机开发时直接在需要的地方调用时间函数和闹钟回调往往比抽象一层定时器管理器更可靠。如果你后面要跑 RTOS那直接依赖内核对定时器的管理就行自己造轮子反而容易跟内核节拍打架。最后再分享一个小技巧调试定时器问题时我经常在回调开头翻转一个 GPIO拿逻辑分析仪或者示波器看那个引脚的波形一眼就能看出定时周期准不准、回调有没有丢、执行耗时多少。这个方法帮我快速定位过不少诡异问题比反复打串口日志高效得多。
返回列表