
如果你搜到这篇文章八成已经在 Pico 上做了几个小项目然后和我一样被同一个问题卡住过电池供电的设备跑一次任务之后总不能一直让 CPU 空转干耗电吧。Pico 的空闲模式看着不复杂就是让芯片“睡一会儿”可真要写出一份能直接拿到下个项目里复用的代码还要把功耗从毫安级压下去“睡”得有质量这里面的坑比想象中多。我手头正好在做一个电池供电的温度采集节点一开始照着网上的零散例子写 WFI结果要么睡下去起不来要么醒来之后 USB 不认了更头疼的是换了引脚、换了唤醒源代码就得大改一遍。后来我把睡眠逻辑单独抽成一个模块用配置结构体把唤醒源、回调、恢复动作全部收口这个问题才彻底解决。这篇文章就把这套代码的设计思路、完整实现和功耗优化的实测过程都摊开讲内容包括RP2040 的三种睡眠深度到底差在哪里、怎么把睡眠模块写成“配一下就能用”、WFI 睡眠和 dormant 深度休眠的具体写法以及我在万用表上一步步测出来的功耗优化效果。适合已经会用 Pico SDK 点灯、但还没系统做过低功耗设计的开发者参考。1. 先把“空闲模式”这个词拆开你打算让 Pico 睡到哪一层很多教程把“空闲模式”当成一个单一概念其实在 RP2040 上睡眠是有明确层级的。写代码之前不搞清楚这层关系后面所有的功耗优化都是瞎调。1.1 三种可选的睡眠深度到底怎么选第一种是最轻量的CPU 执行__wfi()Wait For Interrupt之后暂停取指和执行但系统时钟、外设、Flash 全部保持原样。中断一来CPU 几个周期内就能恢复干活。这种模式适合“等一个事件但随时要响应”的场景功耗节省有限因为时钟树还在满负荷跑着。第二种是 SDK 里说的 sleep 模式进入前需要先把系统时钟切换到低速源比如内部振荡器 ROSC关掉 PLL然后再执行 WFI。这样动态功耗会明显降低外设时钟默认也会慢下来。唤醒之后要重新初始化时钟树不然 UART 波特率、PWM 频率全都会因为时钟源变了而错乱。第三种是 dormant 模式对 RP2040 来说算是“深度睡眠”了。它会进一步切断更多时钟路径只留 RTC 这类独立计时单元和选定的唤醒源RTC 报警、GPIO 中断保持工作。这个模式下芯片本身的电流可以降到微安级但唤醒后的恢复工作也最麻烦所有依赖时钟的外设都得重新初始化。我把这三层之间的关系用一个例子来解释WFI 像开会时走神隔壁同事戳你一下马上就能接话sleep 像趴桌上打盹被叫醒还要几秒钟找状态dormant 像下班回家只有专门打了提醒电话才会赶过来。也就是说睡眠越深功耗降得越多唤醒代价也越大。1.2 荷包账RP2040 的功耗都花在哪了要优化功耗先得知道电都流去了哪里。RP2040 的动态功耗和系统时钟频率基本成正比125MHz 全速运行和 12MHz 低速运行的电流差距非常明显。除了 CPU 本身还有几个容易被忽略的耗电项外设时钟树clk_peri给 SPI、I2C、PWM、UART、ADC 等外设提供时钟即使某个外设没被调用只要时钟树还在跑就有对应的动态功耗。未用引脚的输入缓冲GPIO 配成输入但悬空时CMOS 输入级的电压可能停在中间区域造成穿透电流这个在整板上可能贡献几百微安到毫安级。板载外设原厂 Pico 板上的电源 LED 常亮电流大概 1~2mA板载 LEDGPIO25如果被点亮又是几毫安。USB 相关电路如果初始化了 USB CDC即使没连电脑PHY 和上拉电阻也会持续耗电。这些耗电项叠加起来就能解释一个现象你照着教程写了__wfi()测出来电流还是居高不下往往不是代码不对而是没把“CPU 停转”之外的那些耗电大户管起来。下面的章节从可复用代码设计开始再到逐项优化就是按“先睡对人再睡到位”的顺序来的。2. 可复用代码的第一步把睡眠策略和业务逻辑拆开动手写代码前必须先谈设计因为这个模块一旦写死后面每次换方案都会让你想把板子扔了重做。2.1 我见过最可惜的写法睡眠逻辑写死在 main 里很多入门教程喜欢把睡眠写得特别“直接”main 函数里初始化完外设然后while(1)里直接__wfi()唤醒源、恢复逻辑、时钟切换全堆在一起。这种代码第一次跑通特别有成就感但等到要换一个唤醒引脚或者从 GPIO 唤醒改成 RTC 定时唤醒就得在 main 里到处找哪里要改一不小心把别的功能误伤了。我自己第一次做低功耗时就是这样为了一个按键唤醒的需求把 GPIO 中断配置写进了业务循环后来自检的时候发现一旦按键没接整个定时任务都会被这个睡眠逻辑卡住。原因其实很简单睡眠逻辑里耦合了“为什么睡”“什么时候醒”“醒了之后干什么”这些业务信息而这些问题应该由上层配置来决定而不是由底层模块写死。2.2 pm 模块的接口长什么样把睡眠能力抽出来我最核心的做法是定义一个pm_config_t配置结构体把下面几类信息收进去唤醒源GPIO 中断还是 RTC 报警。唤醒参数GPIO 用哪个引脚、什么边沿RTC 用哪个报警时间。唤醒回调睡醒之后要恢复什么业务动作由上层传入。模块对外只暴露两个入口pm_enter_sleep()和pm_enter_dormant()。一个负责普通睡眠一个负责深度休眠。上层怎么用这些函数并不需要知道底层是操作 WFI 还是切时钟只需要把配置填好、回调挂好。这样设计的好处在于换一个项目时你完全不需要改 pm.c/pm.h 内部实现只要在 main 里重新填一份pm_config_t就行。GPIO 按键唤醒、RTC 定时唤醒、甚至以后要加看门狗唤醒都只是“填表”的区别。2.3 为什么坚持用配置结构体而不是一堆全局变量有一部分开发者会问搞个结构体不是多此一举吗我直接定义几个全局变量不也一样区别在可维护性上。全局变量方案最大的问题是你没法一眼看出哪几个变量属于同一个睡眠配置而且一个函数可能会偷偷修改另一个函数的唤醒状态排查起来非常痛苦。用配置结构体会强制调用方一次性把参数给全同时也很自然地和回调函数绑定在一起。你要实现“按键唤醒”和“RTC 周期唤醒”两个模式时只需要定义两个pm_config_t实例按需使用不需要给每个组合写一个分支。这种“数据驱动”的写法在嵌入式里看起来好像多包了一层但长期维护的成本低得多代码也更像一份能长期积累的资产。3. 手写实现pm.c/pm.h 完整代码拆解设计定了接下来就是真刀真枪的代码。我在这里给出一个可以直接搬进 Pico SDK 工程使用的实现并会逐段解释关键操作的原因。3.1 头文件与配置结构体定义先看pm.h#ifndef PM_H #define PM_H #include pico/stdlib.h #include hardware/rtc.h typedef enum { PM_WAKEUP_GPIO 0, PM_WAKEUP_RTC, } pm_wakeup_source_t; typedef struct { pm_wakeup_source_t source; uint32_t gpio_pin; uint32_t gpio_event; datetime_t alarm; void (*on_wakeup)(void); } pm_config_t; void pm_enter_sleep(const pm_config_t *cfg); void pm_enter_dormant(const pm_config_t *cfg); #endif这个头文件没有暴露任何硬件寄存器相关的细节上层拿到这份头文件唯一需要关心的就是我准备用哪种唤醒方式唤醒后要干什么。datetime_t是 pico-sdk 自带的 RTC 时间结构体字段包含year、month、day、hour、min、sec等直接复用能省掉不少类型转换的麻烦。3.2 睡眠实现GPIO 唤醒和 RTC 唤醒的统一入口再看pm.c的核心部分#include string.h #include pm.h #include hardware/clocks.h #include hardware/gpio.h #include hardware/pll.h #include hardware/rosc.h static void wakeup_handler(void) { // 空实现即可作用是让 CPU 从 WFI 返回 } void pm_enter_sleep(const pm_config_t *cfg) { // 1. 根据配置绑定唤醒源 if (cfg-source PM_WAKEUP_GPIO) { gpio_set_irq_enabled(cfg-gpio_pin, cfg-gpio_event, true); } else if (cfg-source PM_WAKEUP_RTC) { rtc_init(); rtc_set_alarm(cfg-alarm, wakeup_handler); rtc_enable_alarm(); } // 2. 执行 WFICPU 在这里停住 __wfi(); // 3. 醒来后清理唤醒源 if (cfg-source PM_WAKEUP_GPIO) { gpio_set_irq_enabled(cfg-gpio_pin, cfg-gpio_event, false); } // 4. 调用上层的恢复回调 if (cfg-on_wakeup) { cfg-on_wakeup(); } }有几个细节要特别说明第一__wfi()是内核指令不是你随便调用的库函数它会让 CPU 停在这里直到有中断触发。GPIO 中断和 RTC 报警中断都能唤醒它。第二wakeup_handler是一个空函数这是因为 RTC 报警要求中断服务程序存在否则中断标志处理不干净可能导致睡眠结束条件异常。第三按照我们模块的设计pm_enter_sleep并不负责恢复时钟。对于普通 WFI 睡眠系统时钟没有切换所以不需要恢复操作对深度休眠来说恢复动作放在pm_enter_dormant内部处理因为那是底层分内的事。3.3 深度休眠的实现和唤醒后时钟恢复深度休眠的关键在于先切到低速时钟源再执行 WFI醒来后重新初始化时钟树。代码如下void pm_enter_dormant(const pm_config_t *cfg) { // 1. 进入 dormant 前先切换到低速时钟关 PLL set_sys_clock_khz(12000, true); // 2. 配置唤醒源 if (cfg-source PM_WAKEUP_GPIO) { gpio_set_irq_enabled(cfg-gpio_pin, cfg-gpio_event, true); } else if (cfg-source PM_WAKEUP_RTC) { rtc_init(); rtc_set_alarm(cfg-alarm, wakeup_handler); rtc_enable_alarm(); } // 3. 执行 WFI此时系统频率已经降为 12MHz __wfi(); // 4. 清理唤醒源 if (cfg-source PM_WAKEUP_GPIO) { gpio_set_irq_enabled(cfg-gpio_pin, cfg-gpio_event, false); } // 5. 恢复全部时钟到默认状态 clocks_init(); // 6. 通知上层做外设重建 if (cfg-on_wakeup) { cfg-on_wakeup(); } }这里用set_sys_clock_khz(12000, true)把系统时钟切到 12MHz这是 RP2040 从外部晶振直接分频可以得到的低频值适合休眠场景。如果你嫌 12MHz 还不够低还可以用 SDK 里的sleep_run_from_rosc()切到内部 ROSC但那样代码会更依赖具体 API我这里先用set_sys_clock_khz保证通用性。唤醒之后调用clocks_init()是所有时钟恢复的“总开关”它会把系统时钟、外设时钟、USB 48MHz 时钟全部恢复到 SDK 启动时的默认状态。千万别省这一步否则你会发现醒来后 UART 输出乱码、I2C 时序不对排查起来非常痛苦。GPIO 唤醒和 RTC 唤醒在 dormant 模式下都能工作但对硬件有一点要求如果选用 GPIO 唤醒要保证引脚的电平状态能产生对应边沿比如按键接地就是下降沿唤醒设置成GPIO_IRQ_EDGE_FALL。3.4 main.c 中的两种调用方式一个按键唤醒的例子#include stdio.h #include pico/stdlib.h #include pm.h static void on_wakeup(void) { puts(wake by gpio!); } int main(void) { stdio_init_all(); // 按键接在 GP14 和 GND 之间 gpio_init(14); gpio_set_dir(14, GPIO_IN); gpio_pull_up(14); pm_config_t cfg { .source PM_WAKEUP_GPIO, .gpio_pin 14, .gpio_event GPIO_IRQ_EDGE_FALL, .on_wakeup on_wakeup, }; while (true) { printf(enter sleep...\n); pm_enter_sleep(cfg); // 业务代码比如读传感器、上报数据 busy_wait_ms(200); } return 0; }RTC 定时唤醒的例子只需把配置结构体换掉pm_config_t cfg { .source PM_WAKEUP_RTC, .alarm { .year 2025, .month 3, .day 10, .dotw 1, .hour 0, .min 1, .sec 30, }, .on_wakeup on_wakeup, };用 RTC 唤醒时需要特别注意报警时间写的是一个具体的绝对时刻而不是“30 秒后”。如果你做的是周期唤醒每次醒来后都要根据当前时间重新计算下一次报警时间我会在后面的避坑部分专门讲这个。4. 功耗优化技巧让电流从毫安级掉到微安级代码能睡能醒只是第一步。真正让这块板子适合电池供电还需要把各个耗电细节逐个处理干净。我按自己实测过程中收益从大到小的顺序来整理。4.1 时钟频率与电流的关系不用的资源就别供着RP2040 在 125MHz 全速运行时的电流大概在 20~25mA视负载和板子批次略有差异而把系统时钟降到 48MHz 或更低电流会近似线性地降下来。所以进睡眠前把频率切到 12MHz 甚至更低是立竿见影的优化。除了系统时钟还要注意外设时钟clk_peri。很多开发者会忽略这一点初始化过 SPI、I2C、PWM 之后即使不再使用这些外设的时钟路径仍是打开的。进睡眠前把没在用的外设时钟关掉能再省下一笔开销。一个简单的寄存器操作如下#include hardware/clocks.h void pm_periph_sleep(void) { // 关闭外设时钟树醒来后由 clocks_init() 恢复 clocks_hw-clk_peri_ctrl ~CLOCKS_CLK_PERI_CTRL_ENABLE_BITS; }这个函数可以直接放在pm_enter_dormant的 WFI 之前调用但要注意关掉clk_peri后GPIO 中断还能不能唤醒实测下来GPIO 在 dormant 模式下仍可以作为唤醒源因为它走的是单独的中断路径。不过我会建议你优先用 RTC 做周期唤醒GPIO 做外部事件唤醒双源配置最灵活。4.2 GPIO 悬空是隐形漏电大户这个坑我是在测功耗时发现的配置好休眠代码后电流始终在 3~4mA 降不下去后来逐个引脚排查发现有好几个没接任何器件的 GPIO 被初始化成了输入模式而且没开上下拉。电压悬在空中输入缓冲器内部形成半导通状态白白多耗了几百微安。正确的做法是所有不用的 GPIO要么配置成输入并开启下拉电阻要么配置成输出低电平。在 Pico SDK 里一行代码就能搞定gpio_set_pulls(pin, false, true); // 下拉输入 // 或者 gpio_set_dir(pin, GPIO_OUT); gpio_put(pin, 0);但我必须提醒一个反向的坑如果某个引脚外部已经接了上拉电阻比如 I2C 的 SDA/SCL 通常有 10k 上拉你在内部又配置成下拉就会形成分压反而额外耗电。所以正确的做法不是对所有引脚一刀切而是根据实际电路图逐个确认。我的习惯是未使用引脚统一配成输入下拉有外部上拉的引脚保持输出高或输入不上拉有外部下拉的引脚保持输入不上拉。4.3 板载 LED 和 USB 对功耗的影响接着看板子本身。原厂 Pico 上有一颗电源 LED3V3 指示灯只要板子通电它就一直亮着大约消耗 1~2mA。这颗灯的电流是没办法用软件关掉的想极致省电只能物理拆除或者换用裸芯片/RP2040 最小系统板。板载的可控 LEDGPIO25记得睡前拉低gpio_put(25, 0);别小看这一颗灯亮着的时候也有几毫安。USB 这边如果调试期用的是stdio_usb_init()它会维持 USB PHY 的工作状态也会产生额外电流。低功耗运行时建议改用 UART 输出或者干脆不初始化 stdio。在做电池设备时我的选择是睡眠前把 USBCLK 关闭唤醒后才调用stdio_init_all()。这样一个简单的条件下整体电流就少了将近 1mA。4.4 实测数据不同睡眠策略下的电流对照下面这组数据是我在开发板上用万用表串联 VSYS 测量得到的会受到具体板型、元器件批次、供电电压的影响不能当作绝对标准但可以帮你建立一个数量级的概念运行状态实测电流范围说明125MHz 满速运行20~25mA正常开发状态降频到 12MHz WFI 睡眠8~12mACPU 停转但外设时钟还开着降频 关闭外设时钟 GPIO 处理 睡眠2~4mA软件能优化的常规水平dormant 深度休眠芯片本身0.2~0.5mA不含原厂板电源 LED 的损耗原厂 Pico 板 dormant 深度休眠1.5~2.5mA主要被电源 LED 拖累从这张表能看出一个关键结论软件优化在芯片级别可以做到很漂亮但板卡级别的硬件设计往往才是最后那道坎。如果你的产品是自定义板dormant的实测效果会明显好于原厂开发板。5. 测试与排坑别让睡眠代码在关键时刻翻车睡眠代码最怕的不是不省电而是睡下去起不来或者醒来后整个系统状态错乱。我在这个项目里踩过几个很典型的坑直接放在一起说。5.1 RTC 报警时间的两个经典坑第一个坑是datetime_t的月份和星期几的编号。SDK 里month是从 1 到 12dotw是从 0周日到 6周六。如果你按 C 语言数组的习惯把month写成 0rtc_set_alarm往往不会立刻报错但报警行为就会异常。第二个坑是报警时刻是一个绝对时间不是相对延时。做周期唤醒时必须自己去算下一次报警时间。我这里提供一个简单的“从当前时间加 N 秒”计算函数可以直接放进 pm.c 里复用static const uint8_t pm_days_in_month[] {31,28,31,30,31,30,31,31,30,31,30,31}; static bool pm_is_leap(uint16_t y) { return (y % 4 0 y % 100 ! 0) || (y % 400 0); } static void pm_rtc_add_seconds(datetime_t *dt, uint32_t secs) { // 先把 RTC 时间转成自 1970 年以来的“粗略秒数” uint32_t total 0; for (uint16_t y 1970; y dt-year; y) { total pm_is_leap(y) ? 366UL * 86400UL : 365UL * 86400UL; } for (uint8_t m 1; m dt-month; m) { total pm_days_in_month[m - 1] * 86400UL; if (m 2 pm_is_leap(dt-year)) total 86400UL; } total (dt-day - 1) * 86400UL; total dt-hour * 3600UL dt-min * 60UL dt-sec; total secs; // 再转回 datetime_t uint32_t days total / 86400UL; uint32_t rem total % 86400UL; uint16_t y 1970; while (true) { uint32_t ydays pm_is_leap(y) ? 366UL : 365UL; if (days ydays) break; days - ydays; y; } dt-year y; uint8_t m 1; while (true) { uint8_t dim pm_days_in_month[m - 1]; if (m 2 pm_is_leap(y)) dim 29; if (days dim) break; days - dim; m; } dt-month m; dt-day days 1; dt-hour rem / 3600UL; dt-min (rem % 3600UL) / 60UL; dt-sec rem % 60UL; }这个函数没处理 1970 年以前的时间对绝大多数嵌入式场景已经够用。写完之后你要做的就是用rtc_get_datetime()读当前时间调用pm_rtc_add_seconds(now, 60)得到下一分钟报警再把结果填进配置结构体。5.2 WFI 入睡失败中断 pending 导致的秒醒问题还有一个特别容易踩的坑WFI 本质是“有中断就返回”如果在你执行__wfi()之前已经有一个中断请求挂起但还没来得及处理WFI 会立刻返回导致你感觉代码像是没睡一样一直在空转。规避办法是在执行 WFI 前禁用中断等 WFI 真正进入睡眠后再恢复中断。以我们的 pm 模块为例可以在pm_enter_sleep里这样做__disable_irq(); __wfi(); __enable_irq();但这里有个细节__disable_irq()会把所有中断屏蔽掉包括你配置的 GPIO 唤醒中断。如果 GPIO 中断触发时 CPU 正被禁用中断标志会挂起WFI 依然能因为 pending 中断被唤醒。等到 WFI 返回后再__enable_irq()中断服务程序才会真正执行。这个顺序需要多测几次特别是你的回调函数里有依赖中断优先级的逻辑时更要谨慎。5.3 dormant 唤醒后外设重建清单clocks_init()能恢复时钟但它不会自动重建 UART、I2C、PWM 这些外设的初始化状态。这些外设的寄存器可能在休眠期间被复位或者因为时钟切换丢失了配置。所以 wakeup 回调里至少要处理这些事重新调用stdio_init_all()特别是用了 USB 时重新初始化用到的 I2C/SPI/UART 外设重新配置 GPIO 中断和 PWM如果跑了 FreeRTOS还要注意内核心跳在深度休眠后的补偿。我习惯的做法是把“外设初始化”抽成一个app_periph_init()函数在开机和 dormant 唤醒后都调用它这样能保证两个入口的初始化路径完全一致减少漏配的情况。5.4 电流测量的正确姿势最后说说怎么测电流测量方法不对数据会误导你调半天。最直接的方式是把 Pico 的 VSYS 供电那段断开把万用表切换到电流档串联进去测。不要直接把表笔搭在 3V3 和 GND 两端量“电流”那样等于短路。有几个细节值得注意测量时不要同时给 USB 供电和外部电源供电防止电流从两个源串扰测出来的数完全没参考价值。万用表电流档的内阻虽然小但测微安级电流时仍会影响系统尤其是深度休眠后电流本来就低。高精度的表会好一些普通万用表测出来的值可以看趋势但别迷信绝对值。睡眠进入和退出瞬间会有尖峰最好用示波器电流探头并联观察或者用记录型万用表抓一段时间的平均电流。只盯稳定状态读数很容易漏掉周期唤醒时的高脉冲而高脉冲的平均贡献才是电池寿命的关键。我自己调试时是把 Pico 接了一个 3.7V 锂电池串了一块基于 INA219 的电流监测模块用串口打印实时电流变化曲线。这样每当进入睡眠、唤醒、执行任务每个阶段电流多少都看得清清楚楚比拿万用表反复搭方便得多。最后说点实际的上面这套 pm 模块后来被我复制到了两个完全不同的项目里一个是按键唤醒的手持工具一个是 RTC 周期唤醒的环境监测节点。除了换配置结构体、换唤醒回调pm.c 和 pm.h 一个字都没动过。这种“配置驱动”带来的复用体验比一开始用全局变量写死睡眠逻辑强太多了。如果你准备把这个模块用到自己的项目里我的建议是先把单个唤醒源的流程跑通比如 GPIO 唤醒观察 WFI 前后的电流变化和唤醒行为然后再切换成 RTC 唤醒最后再做 dormant 深度休眠。不要一上来就追求最低功耗因为同时引入多个变量出了问题根本没法判断是唤醒源没配好还是时钟恢复有遗漏。另外可以留意一下唤醒后的执行时间用time_us_32()记录从 WFI 返回到业务代码恢复执行的总耗时如果这个时间远大于预期多半是中断处理或者clocks_init()里有什么外设初始化拖了后腿逐段打点排查比靠猜快得多。功耗优化这件事本质是“芯片-代码-板级设计”三者的合力先把代码这一层做到可复用、可测量后面每一步优化才扎实。