入坑树莓派 Pico 之后,我第一个正经项目是给阳台上的自动灌溉系统做控制器。硬件很简单:一块 Pico、一个继电器、一个土壤湿度传感器,还有一节 18650 电池通过 LDO 供电。第一版代码参考主流教程框架写完,功能全部能跑,但电池三天就没电了。后来一测待机电流,整整 35mA,全耗在主循环轮询和没必要的 GPIO 驱动上了。从那天起我才意识到,Pico 不是不能做低功耗项目,而是大多数人写的默认代码根本没把功耗当回事。这篇主要讲我怎么把 Pico 的空闲模式代码(准确说是 Cortex-M0+ 的 WFI 等待机制)做成一个可复用的模块,并且把整机待机电流从 35mA 压到 2mA 以内,同时把功耗优化的测试方法、排查路径和容易踩的坑讲透。适合用 Pico 做电池供电设备、遥控小车、传感器节点、便携仪表的朋友拿来做参考。
1. 先搞清楚RP2040的功耗档位:idle、sleep、dormant分别能省多少电
1.1 三个状态和它们的硬件行为
RP2040 的官方手册里其实没有idle这个单词,它把系统状态分成 Run、Sleep、Dormant 三档。我们平时说的“空闲模式”,落到代码上就是 Cortex-M0+ 的WFI(Wait For Interrupt)指令:CPU 停止取指、停止执行,但内核、SRAM、外设时钟全部保持原样,任意中断来临后立即恢复执行。这一档最轻量,适合“系统状态不动,只想省掉 CPU 空转的电流”的场景。
再深一档是 Sleep 模式,对应 SDK 里 pico_sleep 库的sleep_goto_sleep_until_edge。它会把 PLL 和大部分外设时钟停掉,SRAM 内容保持,依靠 GPIO 边沿、RTC 等少数唤醒源把系统拉起来。Dormant 模式是最低的一档,连振荡器、所有时钟都会停住,恢复后系统相当于从最近的唤醒源重新开始运行,代价是恢复时间明显变长。三者的关系有点像电脑的锁屏、睡眠、休眠:锁屏时后台还在跑,睡眠时内存数据还在但大部分设备断电,休眠则是连内存状态也要重新加载。
我用同一块 Pico 板子、同一套外设条件做过大概的电流对比,结果如下表。需要说明的是,具体数值会受板载 LDO、LED、外设类型影响,但数量级趋势是一致的。
| 状态 | CPU | 外设时钟 | SRAM | 典型唤醒源 | 实测电流参考 |
|---|---|---|---|---|---|
| Run | 运行 | 全部开启 | 保持 | - | 15~25mA |
| WFI 空闲 | 停止 | 保持 | 保持 | 任意中断 | 4~6mA |
| Sleep | 停止 | 大部分停止 | 保持 | GPIO边沿/RTC | 1~2mA |
| Dormant | 停止 | 全部停止 | 保持 | 特定GPIO | 0.1~0.3mA |
看这个表就能明白一件事:光靠__wfi()省电,顶多是把电流砍一半,真正的大头在于“把不需要的时钟和外设关掉”。这也是为什么很多人写了__wfi()却仍然觉得 Pico 功耗不理想——他们只是让 CPU 歇了,外设和 GPIO 还在满负荷待命。
1.2 为什么很多人用了空闲模式还是耗电:动态负载与静态负载的区别
MCU 的功耗来源可以粗略分成两部分:动态功耗和静态功耗。动态功耗主要来自时钟翻转、总线活动、外设工作,比如你在主循环里每几十毫秒轮询一次传感器,传感器和 I2C 总线就会不停动作,电流自然降不下来。静态功耗则是漏电流、LDO 静态损耗、上拉电阻、板载 LED 这些不管程序跑不跑都在消耗的部分。
低功耗优化的顺序很有讲究。我的经验是:先把动态负载降下来,再处理静态负载。如果你写了一个华丽的低功耗状态机,但 GPIO 25 上的板载 LED 还在亮着,或者 I2C 外设始终挂在工作模式,那一切空闲模式代码都是白搭。用大白话说,你得先把房间里所有亮着的灯关掉,再讨论“人躺下不动能不能更省电”,而不是人躺下了灯还开着。
1.3 WFI 和 WFE 的选择
Cortex-M0+ 提供了两条等待指令:WFI等待中断,WFE等待事件。在 Pico 的 C 代码里,可以直接用__wfi()和__wfe()。大多数低功耗项目用__wfi()就够了,因为外设唤醒本身会产生中断;WFE更多用在多核同步或者希望避免中断服务函数开销的场景。SDK 里还有一个best_effort_wfe_or_timeout函数,它会内部处理“超时”和“事件唤醒”的竞争条件,比手写 WFI 循环更稳健,后面写模块时会用到。
2. 可复用空闲模式代码:接口设计比实现更重要
2.1 模块应该暴露什么接口
把一个功能做成可复用,不是简单地把代码复制到另一个工程,而是把“变化的部分”和“固定的部分”拆开。低功耗模块里,变化的是“用什么唤醒源、唤醒后要做什么”;固定的是“安全进入空闲、处理唤醒条件、返回后统一回调”的流程。所以接口只需暴露三样东西:配置结构体、进入函数、唤醒回调。
配置结构体用来描述唤醒源。比如一个传感器节点,通常需要“定时超时唤醒”和“外部 GPIO 中断唤醒”两种方式同时生效;一个可穿戴按钮设备,可能只需要 GPIO 唤醒。把这些都塞进结构体,模块内部统一处理,调用方就不用关心中断怎么注册、标志怎么管理。
2.2 一个可以直接抄的 pm_idle 模块
下面这段代码是我在实际项目里精简出来的版本,依赖 Pico SDK 标准库,没有额外外设依赖。先看头文件。
/* pm_idle.h */ #ifndef PM_IDLE_H #define PM_IDLE_H #include "pico/stdlib.h" typedef enum { PM_IDLE_WAKE_TIMER = 0x01, PM_IDLE_WAKE_GPIO = 0x02, } pm_idle_wake_src_t; typedef struct { uint32_t wake_src; // PM_IDLE_WAKE_TIMER | PM_IDLE_WAKE_GPIO uint32_t timeout_ms; // 当启用 TIMER 时有效 uint32_t gpio_pin; // 当启用 GPIO 时有效 uint32_t gpio_event_mask; // GPIO_IRQ_EDGE_FALL / GPIO_IRQ_EDGE_RISE } pm_idle_config_t; void pm_idle_init(pm_idle_config_t *cfg); void pm_idle_enter(pm_idle_config_t *cfg); void pm_idle_set_wake_callback(void (*callback)(void)); #endif然后是实现文件。
/* pm_idle.c */ #include "pm_idle.h" #include "hardware/gpio.h" #include "hardware/timer.h" static volatile uint32_t pm_wakeup_flag = 0; static void (*pm_wake_callback)(void) = NULL; static void pm_gpio_wake_handler(uint gpio, uint32_t events) { pm_wakeup_flag = gpio; } void pm_idle_set_wake_callback(void (*callback)(void)) { pm_wake_callback = callback; } void pm_idle_init(pm_idle_config_t *cfg) { if (cfg->wake_src & PM_IDLE_WAKE_GPIO) { gpio_set_irq_enabled_with_callback( cfg->gpio_pin, cfg->gpio_event_mask, true, &pm_gpio_wake_handler ); } } void pm_idle_enter(pm_idle_config_t *cfg) { absolute_time_t expire_at = make_timeout_time_ms(cfg->timeout_ms); pm_wakeup_flag = 0; while (true) { if (pm_wakeup_flag != 0) { break; } if ((cfg->wake_src & PM_IDLE_WAKE_TIMER) && time_reached(expire_at)) { break; } best_effort_wfe_or_timeout(expire_at); } if (pm_wake_callback != NULL) { pm_wake_callback(); } }pm_idle_enter的工作逻辑很直白:循环检查是否有 GPIO 唤醒标志、是否到达超时时间,两者都没发生就调用best_effort_wfe_or_timeout睡过去。唤醒后先不急着返回,而是统一执行一次回调,调用方可以在回调里判断这次醒来是因为定时器还是因为 GPIO,再做对应处理。
这里有个容易被忽略的细节:best_effort_wfe_or_timeout和__wfi()的区别。手写__wfi()时,如果中断在检查标志之后、WFI 之前到来,你会错过这次唤醒,然后 WFI 继续睡下去,直到下一个事件把你拉起来。best_effort_wfe_or_timeout内部对这种边界情况做了处理,能保证“要么及时返回,要么直到超时才返回”,不至于白白多睡几百毫秒。对低功耗系统来说,这种边界情况直接决定唤醒实时性。
2.3 为什么不要直接用 sleep_ms() 或者忙等
最开始写低功耗的时候,我也犯过懒,想着sleep_ms(1000)不就能让系统歇一会儿吗?确实能,但实现效率很低。Pico SDK 的sleep_ms底层依赖定时器 Alarm 和 WFI,定时器每毫秒产生一次中断让系统醒来执行调度,再睡回去。也就是说,你以为睡了一秒,实际上 MCU 在这一秒内醒了一千次。每醒一次都要进出中断、保存恢复寄存器,电流自然压不下去。
真正的低功耗等待,应该像上面那个模块一样:要么被需要的事件唤醒,要么被明确的超时唤醒,期间不参与任何周期性活动。如果你要等的是一个 GPIO 事件,就用 GPIO 中断;如果你要等的是传感器就绪,就让传感器把就绪脚接到 GPIO 中断上,而不是每隔几毫秒去轮询一次。事件驱动是低功耗的核心思路,比死记任何函数都重要。
2.4 多唤醒源的合并思路
工程里往往同时存在好几个唤醒源:传感器中断、按钮、定时上报、RTC 闹钟。我把所有唤醒源统一写进同一个pm_wakeup_flag变量,中断服务函数只负责置位,主循环回到 C 代码后统一检查。这样模块外部永远不知道中断服务的细节,调用pm_idle_enter的人只需要关心配置和回调。
这种设计的好处是扩展性。以后项目要多加一个唤醒源,只需要在pm_idle_config_t里加一个枚举字段,中断回调里多写一行置位,主流程完全不用动。可复用模块的生命力就来自这种“把变化挡在外面”的接口设计。
3. 休眠策略的统一调度:把模块真正融进工程
3.1 应用状态机是低功耗工程的骨架
低功耗代码不能只靠一个空闲函数撑起来。如果你的主循环里到处是阻塞延时、轮询等待,任何模块都没法让系统真正闲下来。我习惯把所有应用逻辑组织成一个状态机:初始化、测量、动作、空闲,如此循环。
状态机的好处是,每个状态都短小精悍,能快速执行完并进入空闲。最关键的一点是,代码里不允许出现无意义的忙等。比如等待传感器数据,不再用while (!sensor_ready) {},而是把“传感器就绪”转换成 GPIO 中断,中断来了才唤醒 CPU。这样 CPU 在绝大部分时间都是沉睡的,电流才能压下去。
3.2 一个电池供电温控舵机项目的完整流程
之前有朋友问树莓派 Pico 怎么控制舵机,同时还希望电池供电,我就用这个例子来演示状态机结构。假设场景是一个自动开窗器:每 10 秒醒来读一次温度,温度超过阈值就旋转舵机打开窗户,然后回到空闲状态。
typedef enum { APP_STATE_INIT, APP_STATE_MEASURE, APP_STATE_ACT, APP_STATE_IDLE } app_state_t; static app_state_t app_state = APP_STATE_INIT; static pm_idle_config_t pm_cfg = { .wake_src = PM_IDLE_WAKE_TIMER | PM_IDLE_WAKE_GPIO, .timeout_ms = 10000, .gpio_pin = TEMP_SENSOR_INT_PIN, .gpio_event_mask = GPIO_IRQ_EDGE_FALL }; int main(void) { stdio_init_all(); servo_init(); temp_sensor_init(); pm_idle_init(&pm_cfg); while (1) { switch (app_state) { case APP_STATE_INIT: app_state = APP_STATE_MEASURE; break; case APP_STATE_MEASURE: temp_sensor_wakeup(); float current = temp_sensor_read(); if (current > 28.0f) { app_state = APP_STATE_ACT; } else { app_state = APP_STATE_IDLE; } break; case APP_STATE_ACT: servo_move_to(90); app_state = APP_STATE_IDLE; break; case APP_STATE_IDLE: default: pm_idle_enter(&pm_cfg); app_state = APP_STATE_MEASURE; break; } } }注意这里几个细节。第一,状态切换要快,绝不把舵机转动这种几十毫秒的事放在测量状态里做,动作完成立刻回到APP_STATE_IDLE。第二,pm_idle_enter内部已经包含了超时和 GPIO 中断唤醒,所以主循环不需要额外判断唤醒原因,醒来后重新走一遍测量流程即可。第三,舵机在不动作时必须断电。
3.3 外设和 GPIO 在休眠前后的状态保存与恢复
进入空闲模式之前,必须把所有外设和 GPIO 状态“交代清楚”,否则功耗优化无从谈起。我整理了一个固定动作清单:
- 对于 I2C、SPI、UART 这类外设,空闲前调用
i2c_deinit、spi_deinit、uart_deinit,唤醒后再按需重新初始化。deinit 不只是释放引脚,更重要的是停掉相关时钟域。 - 对于 ADC,调用
adc_run(false)停止采样,空闲前把 ADC 输入引脚配置成普通 GPIO 输出低,避免模拟采样电路形成漏电路径。 - 对于所有未使用的 GPIO,要么设成输出低,要么使能内部下拉。浮空输入是最隐蔽的耗电源,后面会专门讲。
- 对于舵机这类大电流外设,务必用外部 MOS 管或三极管切断电源,GPIO 不能直接控制舵机供电。舵机待机电流几十毫安,比整个 Pico 还高,不断电的话前面所有优化都白做。
唤醒回调里也要做对应的恢复操作。恢复顺序一般是:先恢复系统时钟,再初始化外设,最后处理业务逻辑。如果跳过时钟恢复直接访问外设,会出现总线频率不对、外设无响应的诡异问题,这点在第 5 章还会展开。
4. 从35mA到2mA:一次功耗调优的完整链路
4.1 优化前先把电流测准
没有准确的电流数据,低功耗优化就是盲人摸象。测量工具尽量别用便宜的 USB 电流表,那东西显示误差大,而且对几毫安量级的变化不敏感。我用的是普通数字万用表,把表笔串联在电池正极或者电源输入线之间,用 mA 档测量。更讲究一点,可以用 INA219 或者 INA226 电流检测模块,挂在 I2C 上实时记录电流走势。
测量时有三条纪律:第一,必须拔掉 USB,断开调试器,因为 USB VBus 会提供额外电流,调试器的供电会自动掩盖板子本身的功耗变化;第二,不要在板载 LED 还在闪烁的状态下测,LED 闪一下就是多几毫安;第三,连续测 10 秒以上取平均值,不要只读一个瞬时值。
4.2 逐项关停的优化清单与实测数据
下面是我在 400 倍速下跑完的调优链路,每一步都对应一个明确的代码改动。电流值是我这块板子上的参考值,不同批次 Pico 会有浮动,但优化层次是可以复制的。
| 步骤 | 操作 | 实测电流参考 |
|---|---|---|
| 初始状态 | 133MHz 全速运行,LED 常亮,stdio 开启,主循环轮询 | 35mA |
| 降频 | set_sys_clock_khz(48000, true) | 22mA |
| 进入 WFI 空闲 | 把轮询主循环替换成pm_idle_enter | 5.5mA |
| 关闭无关外设 | i2c_deinit、uart_deinit、adc_run(false),关闭 GPIO25 | 3.8mA |
| 管脚治理 | 所有未用 GPIO 设成输出低或内部下拉 | 2.1mA |
| 进入 sleep/dormant | 使用 pico_sleep 库,保留 GPIO 唤醒 | 0.2~0.8mA |
从表格能看到,降频只解决动态功耗的部分,所以电流从 35mA 降到 22mA 之后,真正的大转折发生在“进入 WFI 空闲”这一步。而这还只是基础,后面关闭外设和 GPIO 治理又砍掉了将近一半。每一步都是叠加效果,顺序不要乱。
set_sys_clock_khz(48000, true)这个 API 会把系统时钟降到 48MHz。48MHz 不是随便选的,它是 Pico 支持 USB 功能的最低频率,保持这个频率能兼容 USB 外设。如果项目完全不需要 USB,还可以尝试切到 12MHz XOSC,但那样sleep_ms等依赖时间基准的 API 需要额外校准,一般不建议新手碰。
4.3 进阶方向:用 pico_sleep 库进入 sleep/dormant 状态
想再往下压电流,就要用 pico_sleep 库了。它是 Pico SDK 提供的扩展库,需要在 CMakeLists.txt 里加上target_link_libraries(你的项目名 pico_sleep),然后包含头文件。
#include "pico/sleep.h" void enter_sleep_mode(void) { sleep_run_from_dormant_source(); sleep_goto_sleep_until_edge(WAKE_UP_PIN); } void wake_up_reinit(void) { set_sys_clock_khz(48000, true); // 重新初始化外设 }sleep_run_from_dormant_source()的作用是把系统时钟切换到一个休眠后仍能工作的时钟源,并记录当前时钟状态,这样唤醒后才能恢复。sleep_goto_sleep_until_edge会真正进入 sleep 模式,等到指定 GPIO 产生边沿再醒来。dormant 模式对应的函数是sleep_goto_dormant_until_edge,功耗更低,但恢复时间更长,对时钟源的要求也更严格。
我自己实际用下来的体会是:如果电池容量足够撑过整个项目生命周期,那么做到 WFI 空闲加外设关闭这档就够了;如果产品需要长时间待机,才值得上 sleep/dormant。因为进入深睡眠后,调试器会断连,串口输出也会因为时钟恢复问题出现乱码,排查问题会麻烦不少。
5. 低功耗调试中的坑与验证方法
5.1 典型坑1:__wfi() 之后功耗没降
这是我见过最多的情况,代码明明调用了__wfi(),电流却纹丝不动。排查时我会按下面的链路走一遍:
先是确认电流表串联位置对不对,排除测量问题。然后检查是不是双核在跑。Pico 的 RP2040 是双核,Core0 睡过去了,Core1 还在主循环里跑轮询,整个芯片照样以全速功耗运行。解决办法是让 Core1 也进入空闲,或者用multicore_reset_core1()把 Core1 停掉,只在需要时再启动。
接着看中断频率。如果有 1ms 定时器中断、串口发送中断、ADC 完成中断等高频中断在跑,WFI 就会反复被唤醒,每毫秒醒一次,平均电流自然降不下来。排查方法是把所有外设中断先关掉,只保留唤醒源中断,再看电流变化。
还有一个隐蔽点:有些代码在__wfi()之前手动关闭了总中断,比如__disable_irq(),那 WFI 会直接纹丝不动地睡死过去,因为没有任何中断能唤醒它。正确做法是像 pm_idle 模块里那样,让唤醒源中断保持使能,由模块统一管理唤醒条件。
5.2 典型坑2:GPIO 唤醒正常但外设“没反应”
用pico_sleep进入 sleep/dormant 后,唤醒回来经常遇到外设不工作的问题。比如 I2C 扫描不到设备,UART 输出乱码。这时候不用急着怀疑硬件坏了,多半是系统时钟还没恢复。低功耗唤醒后,PLL 和时钟源可能还停留在低功耗状态,外设的总线时钟频率不对,自然无法正常通信。
解决方法是唤醒回调里第一件事就恢复主时钟:
void wake_callback(void) { set_sys_clock_khz(48000, true); i2c_init(i2c0, 100000); // 重新初始化其他外设 }顺序不能反。先恢复时钟,再初始化外设,最后读取数据。这个坑在第一次用 pico_sleep 时几乎人人都会遇到,踩过一次之后,就会记住“唤醒回调必须从恢复时钟开始”。
5.3 典型坑3:浮空引脚和板载元件在偷偷耗电
有时候代码写得没问题,电流还是比预期高,就要检查板级因素。Pico 板载的电源 LED 始终接在 3.3V 电源上,只要 LDO 在工作它就会亮,这部分电流省不掉。用户 LED(GPIO25)如果写程序时默认驱动为高,也会常亮,空闲前要主动拉低。
最隐蔽的是未使用引脚。GPIO 在外设 deinit 之后往往保持浮空输入状态,引脚电压不确定,内部 CMOS 电路会在高低电平之间反复翻转,形成不小的漏电流。处理方法是把每一个不用的引脚显式配置成输出低,或者使能内部下拉。我排查时会写一个循环,把剩余 GPIO 逐个配置并观察电流变化,往往能抓到一两个漏电大户。
还要特别注意 ADC 引脚,GPIO26 到 GPIO28 没有内部下拉,如果悬空更会漏电。GPIO29 在 Pico 板上接了 VSYS 检测电阻分压,不建议随便配置成输出,保持原样就好。
5.4 验证手段:用LED、逻辑分析仪和示波器做低功耗探针
低功耗代码最怕“看起来在睡,实际不知道在干什么”。我的验证方法是在pm_idle_enter前后加一个 GPIO 翻转,用一个外接 LED 或者逻辑分析仪观察高电平持续时间和低电平持续时间。如果逻辑分析仪显示高电平几乎低不见,说明系统大部分时间都在空转,空闲模式没有生效;如果低电平占 99% 以上,说明 CPU 确实在沉睡。
void debug_toggle(void) { gpio_put(DEBUG_PIN, 1); pm_idle_enter(&pm_cfg); gpio_put(DEBUG_PIN, 0); }逻辑分析仪还能顺便数一数唤醒次数。如果一秒被唤醒几百次,说明有高频中断在反复打断空闲,需要回头排查中断源。示波器的作用类似,可以更清晰地看到唤醒脉冲的边沿抖动,判断 GPIO 唤醒是否因为接触抖动产生了连续多次中断。这个方法成本低、见效快,比盲目改配置高效得多。
低功耗优化这件事,说到底是把系统的动态负载一个个拆掉,让 CPU 尽可能长时间待在最省电的状态。我个人的建议是,第一次上手不要急着上 pico_sleep 深睡眠,先把 WFI 空闲、事件驱动、GPIO 管脚治理这几个基本功吃透,再去挑战更低功耗。代码模块化和状态机骨架这套思路,也不只适用于 Pico,换成别的单片机同样能复用。至少我在把 pm_idle 模块从一个灌溉工程移植到另一个遥控小车工程时,只改了几行配置,主流程完全没动,这种“一次写好、到处能用”的感觉,才是可复用代码该有的样子。