做电池供电项目的时候,我第一块 Pico 板几乎是被自己亲手“玩没电”的:逻辑倒是简单,DHT22 每隔十分钟读一次数据,然后给舵机发一个开合信号,剩下时间原地空转。结果一块 18650 只撑了不到一天。问题不在树莓派 Pico 本身,而在代码里根本没有调用任何低功耗 API。RP2040 是一颗性能相当不错的双核 M0+ 芯片,但如果你只是写一个 while True 空转,电流轻松走到 20mA 级别,这在电池场景下基本不可接受。这篇内容想分享的就是我后来整理的一套从 API 到实践的低功耗软件控制方法,包括 MicroPython 的 lightsleep/deepsleep、官方 C SDK 的 sleep/dormant 接口,以及在实际调试中踩过的各种文档里根本不会写的坑。适合正在用 Pico 做电池设备的同学,也适合刚从 STM32/ESP32 转过来、对 RP2040 功耗模式不熟的开发者。
1. 先搞清楚 Pico 平时到底把电花在哪了:功耗基线实测
1.1 运行模式下 20mA 从哪来的
很多人对 MCU 功耗的预期还停留在“单片机应该很省电”的阶段,但 RP2040 是一颗为功能密度设计的芯片,不是为微安级待机设计的。它内部有双核 Cortex-M0+、USB 控制器、PLL、ADC、PIO 等一堆外设,运行时所有模块都在吃电。即使你的代码只是空转,芯片也要做三件事:维持 CPU 时钟、从外部 QSPI Flash 取指令、维持系统时钟树。
这里有个反直觉的点:RP2040 没有内部 Flash,代码是放在外部 QSPI Flash 里的,CPU 每次取指都要通过 XIP 桥读 Flash,这一部分的电流开销比很多人以为的大。当你把主频拉满 125MHz 时,空转电流可能到 26mA 以上;降到 48MHz,也要 18mA 左右。如果程序里还顺手点了一颗板载 LED、开着 USB 或者 ADC,那功耗更是肉眼可见地涨。
再加上 Pico 开发板上还有板载电源芯片、USB 口的 VBUS 检测电路、用户 LED 的限流电阻,这些板级器件自身也有静态损耗。也就是说,即便 RP2040 芯片本身进入低功耗状态,整块 Pico 板的电流也不是芯片手册上那个漂亮的数字。做项目前一定要先建立一个认知:你优化的电量,是“整块板子”的电量,不是“核心芯片”的电量。
1.2 实测环境与基线数据
我在测试时的接法很简单:USB 不插,拿两节 18650 串联经过一个低压差稳压给 VIN 供电,万用表用毫安档串在电池正极回路上。固件是 MicroPython 1.22 和官方 C SDK 的 pico-examples 两种都测了。实测下来的基线数据大概是这样的:
| 状态 | 条件 | 实测电流 |
|---|---|---|
| 运行 125MHz | 空转 while True,无外设 | 26~30mA |
| 运行 48MHz | 空转,无外设 | 18~22mA |
| light sleep | 仅定时器唤醒,GPIO25 LED 关闭 | 0.9~1.2mA |
| deep sleep | 复位唤醒,各引脚已处理 | 0.5~0.8mA |
| dormant | C SDK,等待 GPIO 边沿唤醒 | 0.2~0.3mA |
| dormant | 唤醒引脚带内部上拉 | 增加 10~30uA |
注意最后一行,别小看这个“增加 10~30uA”,在 uA 级的世界里,它可能就是电池寿命从一年变半年的差距。这也是后面要专门写一节讲 GPIO 状态管理的原因。
2. 软件控制低功耗的 API 全景:MicroPython 与 C SDK 两条路线
2.1 MicroPython 端:machine.lightsleep / machine.deepsleep
用 MicroPython 开发时,低功耗的核心 API 就两个:machine.lightsleep()和machine.deepsleep()。lightsleep是“浅睡”,进入睡眠后 CPU 暂停,但是时钟、内存内容都保留,定时器也可以继续工作,到了指定的毫秒数就会自动唤醒,唤醒后从睡眠位置的下一条指令继续执行。这是绝大多数周期性采集场景的主力 API,写法也最简单:
import machine import time led = machine.Pin(25, machine.Pin.OUT) led.off() # 关掉板载 LED,这一步忘掉会多吃好几个 mA while True: # 采集逻辑 print("wake") # 睡 60 秒 machine.lightsleep(60000)machine.deepsleep()则是把芯片带到更深的状态,唤醒后整个 MicroPython 解释器会重新启动,程序从头开始跑。所以如果你的项目需要区分“上电首次运行”和“deepsleep 唤醒恢复”,就要用machine.reset_cause()去判断复位来源,再把状态存到 Flash 文件系统里,比如用一个标志文件记录上一次的执行进度。
这里有一个容易误解的地方:很多从 ESP32 转过来的朋友习惯性认为 deepsleep 之后还能继续跑“唤醒后的代码”,但 Pico 的 MicroPython 没有 ESP32 那样的 RTC memory 概念,所有变量清零。这个行为差异一定要在设计状态机时提前考虑。
2.2 C SDK 端:sleep 库的调用链
如果追求更低的功耗,就要下沉到 C SDK。RP2040 官方提供了一套 sleep 库,头文件是pico/sleep.h。它不是简单的sleep_goto_dormant()一个函数搞定,而是要按顺序做三件事:先把系统时钟切到低功耗时钟源,再进入睡眠/休眠模式,唤醒后再恢复高频时钟。
典型流程是这样:
#include "pico/stdlib.h" #include "pico/sleep.h" int main() { stdio_init_all(); // 1. 把时钟从 PLL 切换到外部晶振 XOSC,避免 PLL 继续耗电 sleep_run_from_xosc(); // 2. 进入 dormant 模式,等待 GPIO2 下降沿唤醒 sleep_goto_dormant_until_edge(2, false); // 3. 唤醒后恢复 PLL 高频时钟 sleep_run_from_pll(&pll_sys, clock_get_hz(clk_xosc), 125000000, 125000000, 62500000); // 继续你的业务逻辑 while (true) { tight_loop_contents(); } }sleep_run_from_xosc()的作用是让系统只靠板上的 12MHz 晶振运行,停掉 PLL,因为 PLL 本身也是一个持续的耗电来源。sleep_goto_dormant_until_edge(gpio, edge)会把 RP2040 置入 dormant 状态,这个状态下大部分时钟都停了,只有 GPIO 边沿检测电路还在工作,所以只能靠外部信号唤醒。唤醒后系统会从这条函数调用处继续往下执行,但时钟源还停留在低速状态,需要显式把 PLL 重新配置回来。
2.3 两条路线怎么选
| 维度 | MicroPython | C SDK sleep 库 |
|---|---|---|
| 开发效率 | 高,几行代码 | 低,要处理时钟树 |
| 最低功耗 | 0.5mA 左右(板级) | 0.2mA 以下(板级) |
| 唤醒方式 | 定时器/GPIO/复位 | GPIO 边沿/dormant,灵活度更高 |
| 状态保持 | deepsleep 会重置解释器 | 可在函数调用后继续执行 |
| 适合场景 | 原型验证、中小项目 | 产品化、长时间电池供电 |
我的建议是:原型阶段先拿 MicroPython 把功能和功耗验证跑通,确认方案可行后再决定要不要下沉到 C SDK。很多时候 lightsleep 的毫安级几乎够用了,不用一上来就为难自己。
3. 从 lightsleep 到 dormant:四档功耗逐级压低的实测过程
3.1 第一档:降低主频 + 关外设
如果不想立刻用睡眠,最直觉的做法是把 CPU 主频降下来。MicroPython 里一条machine.freq(48000000)就能把主频从 125MHz 拉到 48MHz,实测空转电流从约 28mA 降到约 20mA。这个操作对外设基本无影响,适合纯计算、不需要高频处理的任务。不过降频只是“省着花”,不是“停下来”,所以它只能算一个基础动作,不能替代睡眠。
在这档里还应该顺手把用不到的外设关掉。MicroPython 没有像 C 那样逐外设关时钟的接口,但你可以保证不启用的外设不初始化。特别要检查的就是 GPIO25 上的板载 LED,很多示例代码里默认点亮,一个 LED 加上限流电阻就是几个毫安。
3.2 第二档:lightsleep 定时唤醒
machine.lightsleep(ms)是性价比最高的一档。从代码上看,它只是把屏幕停在半路,但电流直接掉到 1mA 左右。实测流程是这样的:
import machine import time last_time = time.ticks_ms() print("before sleep") machine.lightsleep(5000) print("after sleep, cost ms:", time.ticks_diff(time.ticks_ms(), last_time))这个模式适用于大部分周期性采集场景:传感器上电、延时稳定、读取、关闭传感器、进入 lightsleep,到点再醒来。由于定时器在浅睡状态下仍然工作,lightsleep的唤醒时间是比较精确的,我实测 5 秒的误差在几十毫秒内。另外,GPIO 中断也能把它提前唤醒,这是后面要讲的“睡眠中被外部事件触发”能力的基础。
3.3 第三档:MicroPython deepsleep 复位唤醒
MicroPython 的machine.deepsleep()会把 RP2040 带到更深的低功耗状态。从整板实测看,电流能再往下走一点,到 0.5~0.8mA。代价是唤醒后解释器重启,代码从main.py第一行重新执行。如果你要在这种情况下区分“冷启动”和“深度唤醒”,可以这样处理:
import machine cause = machine.reset_cause() print(cause) # 不同固件版本对唤醒原因的常量命名略有差异 # 建议先打印出来对比,再决定分支逻辑由于解释器重启,所有全局变量都丢了。实际项目里,我会把状态写进一个小 JSON 文件,唤醒后先读文件再决定是继续工作还是重新初始化。这个模式的电流虽然只比 lightsleep 低了一点点,但它最大的价值是让 CPU、时钟树几乎全部停摆,为更深的 dormant 做了铺垫。
3.4 第四档:C SDK dormant 边沿唤醒
如果项目对功耗的追求已经很极致,那就用 C SDK 的 dormant 模式。前面提到过,sleep_goto_dormant_until_edge()会把系统带入几乎所有时钟都停止的状态,只有 GPIO 边沿检测电路在监听唤醒引脚。实测 Pico 整板电流能到 0.2~0.3mA,这个数字里其实还包含板载 LDO 的静态电流,如果直接用裸芯片做产品,电流会低一个数量级。
这档的使用门槛主要在时钟恢复:从 dormant 唤醒后,外设的高频时钟可能是不对的状态,UART、ADC 这类依赖时钟树的外设需要重新初始化。我在项目里是这样处理的:唤醒后先恢复 PLL 时钟,再重新初始化自己用到的外设,最后回到主循环。用代码表示就是:
void wake_and_recover(void) { sleep_run_from_pll(&pll_sys, clock_get_hz(clk_xosc), 125000000, 125000000, 62500000); // 重建 I2C/UART 等外设 }哪种唤醒源用哪一档,可以对着需求选:有定时需求用 lightsleep;接受重启逻辑用 deepsleep;追求极致功耗且有外部唤醒信号源,再考虑 dormant。
4. 低功耗唤醒与 GPIO 状态管理:最容易翻车的几个细节
4.1 悬空 GPIO 的漏电流比想象中更严重
这是我在实测中踩过最深的一个坑。第一次用 dormant 模式时,我以为只要程序休眠,电流就应该自己掉下去,结果实测还挂着 2mA。排查了很久,发现罪魁祸首是 Pico 上大量悬空的 GPIO 引脚。
这个道理其实很简单:CMOS 输入引脚如果没有被外部电路稳定在一个确定电平,输入缓冲区域会因为外部环境噪声不断在高低电平间抖动,内部电路为了跟随这种抖动持续切换,电流就上去了。悬浮引脚越多,电流越可观。处理办法是进入睡眠前把所有没用到的 GPIO 统一配置成输出低电平,或者输入下拉。MicroPython 中可以用类似这样的循环处理:
import machine unused_pins = [0, 1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 26, 27, 28] for gp in unused_pins: p = machine.Pin(gp, machine.Pin.OUT, value=0)这里有个原则要记住:不是把所有引脚都设成低就安全了,如果引脚外部恰好有上拉,那设成输出低反而会形成持续灌电流。正确做法是逐个引脚确认外接电路,没有外部连接的引脚设输出低,有外部上拉的引脚要单独算漏电。这个工作唠叨,但是值得做。
4.2 关不掉的 LED 和 USB 连接
板载 LED 的问题前面提过,这里再细说一次:GPIO25 控制的这颗 LED 在 MicroPython 启动时并不一定默认灭,很多库或者 REPL 操作会把它点亮。进入睡眠前不只是要led.off(),最好在每次睡眠前都强制置低,因为唤醒后的代码可能会再次把它点亮。
USB 的问题更隐蔽。调试低功耗时,很多人习惯性用 USB 供电顺便开个 REPL,这时候测出的电流不是真实数据:USB 连接本身会改变 Pico 的供电路径,同时 REPL 的 CDC 串口设备在工作时会有额外功耗,就算你没打印任何东西,USB 主机也在一端持续轮询。我后来做功耗标定的规矩很简单:电脑 USB 拔掉,换独立电池供电,REPL 不连,一切以万用表读数说话。
4.3 唤醒边沿与内部上拉的搭配
唤醒引脚的选择直接影响睡眠电流。比如sleep_goto_dormant_until_edge(2, false)等待 GPIO2 下降沿,如果你的外部设备是开漏输出,那引脚空闲时要靠上拉维持高电平。这个上拉可以用 RP2040 内部的上拉实现,但内部上拉本身有十几到几十微安的漏电流。所以在低功耗场景,如果外部已经用了合适的电阻上拉,内部上拉就要关掉,别让两份电流重复叠加。
另一个坑是边沿抖动。休眠时唤醒引脚如果接触到电磁干扰,持续快速抖动,会让 MCU 反复被唤醒再睡下,功耗根本没降下来,还会破坏业务逻辑。工业场景下最好在唤醒引脚上加一个简单的 RC 滤波,比如 1k 电阻串 100nF 电容到地。唤醒后第一件事也应该是去配置中断边沿,防止 GPIO 在睡眠期间已经积累的边沿事件再次触发一次无效唤醒。
4.4 ADC 与 PWM 残留
MicroPython 的 ADC 类没有显式的 deinit 方法,但 RP2040 的 ADC 在没有发生转换时内部会自动降功耗。我遇到过的问题是:某些代码在采集完电池电压后没有退出 ADC 模式,导致后续进入低功耗时部分外设仍被“维持”在活跃状态。保险做法是采集完立刻把引脚恢复成普通 GPIO 并置低,不要让它一直挂在 ADC 模式上。
PWM 也一样。舵机信号引脚如果用 PWM 模式,即使不给角度输出,PWM 模块的时钟可能仍在跑。在 MicroPython 中可以直接调用pwm.deinit()把它关掉,或者把引脚重新配成普通输出并置低。这个细节在休眠前很容易被忽视,但它确实是电流表上那几十微安差异的来源之一。
5. 实战:电池供电的 Pico 周期采集 + 舵机动作节点
5.1 系统结构与供电方案
这里用一个我实际做过的例子来串起上面的 API 和方法。项目需求:室外一个小闸口,Pico 每 10 分钟用 DHT22 采集一次温湿度,温度高于设定值时,舵机带动挡板动作 30 度,然后继续睡眠。整体由两节 18650 串联加 LDO 供电,要求尽量少换电池。
硬件结构上我做了一个关键设计:传感器和舵机的电源都是可控的。传感器电源由 GPIO14 通过一个 MOSFET 控制,只在采集前上电;舵机电源由 GPIO13 通过另一个 MOSFET 控制,只在需要动作时上电,动作完成立刻断电。原因是 DHT22 在上电后需要 1 到 2 秒稳定才能正确输出数据,让它一直通电会白吃电流;舵机待机时虽然不转,但驱动电路仍有静态损耗,切断电源最干净。
舵机信号线从 Pico 的 PWM 引脚引出,但舵机供电不能直接用 Pico 板上的 3.3V 或 5V,哪怕 SG90 这类小舵机,启动瞬间的电流也远超 Pico 板上稳压器能稳稳定提供的范围。我这里用外部电池电压直接供给舵机,Pico 只负责发信号,两者共地即可。
5.2 完整代码流程
核心思路:把空闲引脚全部置低,传感器、舵机电源全部关闭,PWM 关掉,LED 关掉,然后machine.lightsleep(600000)。定时唤醒后,先恢复用到的硬件,再执行采集和动作,最后再次进入睡眠。
import machine import time from machine import Pin, PWM # 引脚规划 LED = Pin(25, Pin.OUT) SENSOR_PWR = Pin(14, Pin.OUT) # 传感器电源 MOS SERVO_PWR = Pin(13, Pin.OUT) # 舵机电源 MOS SERVO_SIG = PWM(Pin(12, Pin.OUT)) # 舵机信号 SERVO_SIG.freq(50) # 50Hz,周期20ms def servo_angle(pwm_inst, angle): # 20ms 周期,0.5~2.5ms 对应 0~180 度,这里用 1ms~2ms 实际脉宽区间 pulse_ms = 1.0 + (angle / 180.0) # 0度:1ms, 180度:2ms duty_u16 = int((pulse_ms / 20.0) * 65535) pwm_inst.duty_u16(duty_u16) def read_dht22(): import dht d = dht.DHT22(Pin(15)) d.measure() return d.temperature(), d.humidity() def adc_voltage(pin_no): import machine adc = machine.ADC(pin_no) v = adc.read_u16() * 3.3 / 65535 return v def prepare_sleep(): # 关闭 PWM,避免输出占空比维持 SERVO_SIG.deinit() # 关闭舵机和传感器电源 SERVO_PWR.value(0) SENSOR_PWR.value(0) # 板载 LED 关闭 LED.value(0) # 统计所有未用引脚,统一设为输出低 unused = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 16, 17, 18, 19, 20, 21, 22, 26, 27, 28] for gp in unused: try: Pin(gp, Pin.OUT, value=0) except Exception: pass # 恢复 PWM 为下一次动作准备 SERVO_SIG = PWM(Pin(12, Pin.OUT)) SERVO_SIG.freq(50) def main_cycle(): # 打开传感器电源,等 DHT22 稳定 SENSOR_PWR.value(1) time.sleep_ms(1500) temp, hum = read_dht22() SENSOR_PWR.value(0) # 温度超过 30 度,执行动作 if temp is not None and temp > 30: SERVO_PWR.value(1) time.sleep_ms(200) # 等舵机上电稳定 servo_angle(SERVO_SIG, 90) time.sleep_ms(500) # 让舵机走到位 SERVO_PWR.value(0) # 引脚重新配置为输出低,防止悬空 Pin(13, Pin.OUT, value=0) LED.value(0) while True: main_cycle() prepare_sleep() machine.lightsleep(600000)注意代码里的prepare_sleep():PWMdeinit之后又把引脚重新初始化为 PWM,是为了下一次动作时还能发信号。每次deinit与重新初始化之间,信号的引脚状态会短暂变化,如果你对时序有严格需求,可以把这个过程再细化。
5.3 功耗测算与电池寿命
我把这套系统实际跑了一周,用万用表分别测了各阶段的电流,然后用积分估算平均功耗。数据大致是这样:
| 阶段 | 持续时间 | 电流 |
|---|---|---|
| 睡眠阶段 | 597 秒 | 0.5mA |
| 传感器上电采集 | 3 秒 | 20mA |
| 舵机动作 | 0.5 秒 | 200mA |
每 10 分钟一个周期,电量消耗粗算:
- 睡眠:597 秒 × 0.5mA = 298.5 mAs
- 采集:3 秒 × 20mA = 60 mAs
- 动作:0.5 秒 × 200mA = 100 mAs
- 单周期合计约 458.5 mAs,换算约 0.127 mAh
每小时 6 个周期,一天约 18.3mAh。5000mAh 的电池组,理论续航约 273 天,算上 LDO 自身损耗和电池自放电,实际半年以上没什么问题。对比最开始那个 20mA 空转的版本,等于直接把设备寿命从 10 天拉长了 20 倍。
6. 软件低功耗控制的边界:时钟恢复、外部 RTC 与调试期误导
6.1 从 dormant 醒来后,时钟树一定要重建
sleep 库的 dormant 流程里,sleep_run_from_xosc()和sleep_goto_dormant_until_edge()要配合使用,但醒来之后做的事同样关键。系统从 dormant 恢复时,PLL 并没有自动回到原来的状态,如果你唤醒后马上跑 UART 串口或者 ADC 转换,可能发现数据错乱或者外设不反应。这就是因为外设时钟频率跟代码预期不一致。
稳妥的做法是唤醒后统一执行一次时钟恢复函数,把 PLL 重新配置到期望频率,再初始化依赖时钟的外设。如果是 MicroPython 的 lightsleep,这套事解释器已经替我们做了,所以大部分用户感觉不到;但下沉到 C SDK 后,这个责任就回到自己身上。
6.2 小时级唤醒:外部 RTC 定时中断
RP2040 的 dormant 模式功耗低,但坏消息是它只能靠 GPIO 唤醒,内部定时器/RTC 在 dormant 下是不工作的。如果你希望设备每小时醒来一次,且不能接受让定时器一直跑带来的额外电流,最省电的方案是外挂一颗 I2C 实时时钟芯片,比如 DS3231 或者 PCF8523。它的闹钟输出接到 Pico 的唤醒引脚,到点后拉一个边沿把 MCU 从 dormant 叫醒。
我在低功耗面板项目里用过 PCF8523,整板待机电流能控在 0.3mA 以内,其中不少是 Pico 板载电源转换在消耗。外接 RTC 本身在保持闹钟工作时只有微安级电流,比用 RP2040 内部定时器在 lightsleep 里跑 1mA 的功耗经济得多。前提是唤醒逻辑要走到 C SDK 的 dormant 路线,MicroPython 里要同时控制 I2C 和 GPIO 中断,也能实现,只是要注意 I2C 外设在睡眠期间必须释放总线。 至于是否需要,取决于你的目标续航:如果只做夜间采集,lightsleep 足以;如果要求几个月换一次电池,还是上外部 RTC 更稳妥。
6.3 低功耗调试时最容易误导自己的三件事
第一件是 USB 供电测出来的电流不能信。USB 口一插,板内电源路径就变了,线性稳压器的输入侧会引入 USB 接口的额外偏置,这时候测出的数值既不是真实电池电流,也不是模式真实功耗。我后来养成的习惯是:测功耗一定用独立电池,万用表串在电池正极,并且断开所有 USB 线。
第二件是 REPL 和串口打印很可能会阻止芯片睡下去。MicroPython 的 USB CDC 在 lightsleep 时会尽量保持连接,但这不代表它不吃电。调试低功耗时如果开着 REPL,你看到的电流可能比真实高 0.3~0.5mA。更准确的方法是:在程序里临时去掉所有print(),退出 REPL,再测一版。
第三件是面包板和杜邦线在潮湿天气会变成一个微安级“漏电器”。我有一次在同一个面包板项目上怎么测都比预期高 0.1mA,后来换成焊好的洞洞板,数值立刻下来了。低到微安级的测试,最好把关键电路直接焊接固定,不要靠面包板走线。
最后分享一个我自己的习惯:每次改完低功耗配置,我会连续记录三组电流值,并且把进入睡眠前后的引脚状态表打印出来核对。因为低功耗的坑常常不是某一个 API 没调用,而是某个引脚漏电、某块外设没关、某条线悬空,三个小问题叠加出一个大电流。逐项排查看起来很慢,但这也是做低功耗最可靠的方法。