FEATURED · 精选文章

树莓派Pico低功耗实战:从35mA到2mA的空闲模式优化指南

发布时间 / 2026/9/10 4:31:13
来源 / 创域科博编辑部
栏目 / 资讯中心
树莓派Pico低功耗实战:从35mA到2mA的空闲模式优化指南 入坑树莓派 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 的WFIWait 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~25mAWFI 空闲停止保持保持任意中断4~6mASleep停止大部分停止保持GPIO边沿/RTC1~2mADormant停止全部停止保持特定GPIO0.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_enter5.5mA关闭无关外设i2c_deinit、uart_deinit、adc_run(false)关闭 GPIO253.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 典型坑2GPIO 唤醒正常但外设“没反应”用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 在工作它就会亮这部分电流省不掉。用户 LEDGPIO25如果写程序时默认驱动为高也会常亮空闲前要主动拉低。最隐蔽的是未使用引脚。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 模块从一个灌溉工程移植到另一个遥控小车工程时只改了几行配置主流程完全没动这种“一次写好、到处能用”的感觉才是可复用代码该有的样子。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻