树莓派Pico低功耗实战:从time.sleep到lightsleep的功耗优化指南
2026/9/11 11:56:58 网站建设 项目流程

如果你给树莓派Pico写过电池供电的小设备,八成也会踩中这个坑:明明在代码里写了time.sleep(10),以为这十秒里功耗很低,结果整机电流照样十几毫安。我第一次实测Pico的lightsleep时,主循环从time.sleep换成machine.lightsleep,电流直接从17mA掉到1.3mA,REPL随即失联——那一刻我才意识到,低功耗不是少干活,而是让硬件真正停下来。这篇不聊空泛的概念,就记录我手把手做的一个可复用lightsleep模块,以及串口调试和功耗优化过程中踩过的实实在在的坑,给准备把Pico塞进电池盒里的朋友做个参考。

1. 一次电池翻车:为什么我原来的"省电"代码没省电

1.1 翻车现场还原

事情是这样的。我做了一个用电池供电的温湿度采集节点,硬件是树莓派Pico、一个DHT11、一个串口模块,逻辑非常简单:上电后循环读传感器,从串口打印一条日志,然后休息10秒再继续。当初设计时想得很美:两节5号电池,2500mAh容量,一天打印8640条日志,怎么也能撑个一两周吧。

实际使用结果让我傻眼:早上装好电池,晚上回来设备已经没电了,连日志都只写了不到半天。我开始怀疑是DHT11坏了、串口模块漏电、电池质量不行,唯独没怀疑代码本身——毕竟time.sleep(10)都执行了,CPU总该休息了吧?

1.2 电流表一卡,问题就藏不住了

拿万用表串进电源负极,把电流档打在20mA量程,结果非常打脸:

运行状态实测电流
上电后纯跑while True: pass约20mA
主循环里time.sleep(1)约15~18mA
machine.lightsleep(1000)约1.2mA

也就是说,我原来写的time.sleep(10)那10秒里,RP2040根本没有进入任何低功耗模式,它只是让MicroPython解释器暂时不往下执行,CPU还在高速空转,电流表上的数字几乎没降。那感觉就像加班到半夜,人坐在工位上刷手机摸鱼,看着是没干活,但电脑屏幕亮着、空调开着,电费一分不少。

这件事给我的教训很直接:MicroPython里的time.sleep不是硬件睡眠,只是"任务挂起",真正的低功耗必须调用machine.lightsleepmachine.deepsleep。这也是这篇文章所有内容的地基。

1.3 本文会帮你解决的三件事

基于这次翻车,我想清楚了三件事,也是这篇要展开的内容:

  1. 写一个可以在多个项目里直接复用的lightsleep封装模块,而不是每次把睡眠逻辑散落在业务代码里。
  2. 解决串口调试在低功耗模式下的几个致命问题,不然板子一睡就失联,连代码写对了没有都不知道。
  3. 把功耗从"能跑"做到"尽可能低",给出每一步的实测数据作为参考。

下面先从原理说起,搞清楚lightsleep到底在硬件层面做了什么,后面的代码才不会写得稀里糊涂。

2. time.sleep、lightsleep、deepsleep到底差在哪:从时钟树看省电本质

2.1 三种模式的直观理解

很多人分不清time.sleeplightsleepdeepsleep,我找个生活化的类比:

time.sleep相当于你人坐在工位上发呆,工位灯亮着、电脑开着、空调开着,人确实没干活,但公司该付的电费一分不少。对应到Pico上,就是CPU时钟照转、外设照常供电、电流十几毫安。

lightsleep相当于晚上你在家准备睡觉,灯关了、电视关了、门反锁了,但人还在床上,随时有动静就能醒过来。对应到Pico上,就是处理器核心的时钟停摆,大多数外设的时钟也停掉,但保留GPIO中断、定时器等唤醒通道,电流能压到1mA级别。

deepsleep相当于直接断电上床睡,第二天闹钟响了才开机。对应到Pico上,几乎所有东西都停了,唤醒后MicroPython会重新启动脚本(不是接着睡前的代码继续跑),电流可以到0.5mA以下甚至更低。

2.2 时钟树视角下的lightsleep

RP2040是一颗双核Cortex-M0+芯片,正常运行时主时钟大概133MHz,所有外设都挂在这棵时钟树上。进入lightsleep后,芯片会切断处理器核心的时钟源,CPU不再取指执行,外设的时钟也可以按需关闭,只剩下你配置好的唤醒源(比如GPIO外部中断、定时器)继续工作。

这里有个关键点:省电的本质不是"代码少干活",而是"时钟不再翻转"。CMOS电路里,动态功耗和时钟翻转频率基本成正比,时钟停了,功耗自然大幅下降。所以lightsleep才能真正把电流降下来,而time.sleep只是让Python解释器空转。

2.3 谁能叫醒一个睡着的Pico

lightsleep之后,唤醒源有这几类:

唤醒方式怎么配置典型用途
GPIO外部中断Pin.irq(trigger=Pin.IRQ_FALLING)按键唤醒、传感器中断唤醒
定时器machine.lightsleep(1000)周期性采集上报
RTC闹钟较复杂,需要底层SDK支持长时间定时唤醒
其他外设事件取决于外设是否保留时钟特殊场景

特别要提醒一句:UART串口收到数据不能直接唤醒lightsleep,这是很多人踩坑的地方。后面串口调试那章我会专门展开。

从原理回到实践,如果你写代码的时候脑子里有这张"时钟树"的图,就会知道进入睡眠之前要把哪些东西关掉、保留哪些唤醒通道,写出来的代码才是有意识的,而不是照抄别人的片段。

3. 可复用不是把sleep藏进类里:先划清楚三层再动手

3.1 别把睡眠代码写进业务循环里

我第一次写低功耗代码时,直接在主循环里写:

while True: read_sensor() uart_send(data) time.sleep_ms(30) machine.lightsleep(10000)

看起来没问题,实际上一旦项目复杂起来,这个写法就是灾难。比如唤醒后要重新初始化传感器、按键按下时要用不同方式唤醒、串口调试时要跳过睡眠,这些逻辑全部塞在主循环里,代码会越来越乱,而且每个新项目都要重新复制粘贴一遍。

可复用的关键,是把"睡眠策略"从"业务逻辑"里拆出来。睡眠策略关心的是:用什么方式唤醒、睡眠前关掉哪些外设、唤醒后怎么恢复环境。业务逻辑关心的是:采集什么数据、上报给谁、怎么处理。两者不应该混在一起。

3.2 一个睡眠管理器该有的四个接口

我最终设计的SleepManager模块只暴露四个能力,非常简单:

  1. 初始化的时候告诉它:用什么引脚唤醒、触发边沿是什么、正常运行频率是多少。
  2. 注册"睡眠前回调":进入lightsleep之前要执行的操作。
  3. 注册"唤醒后回调":醒来之后要执行的操作。
  4. 调用sleep():把前面所有配置组合起来,进入低功耗,并返回唤醒原因。

这样业务代码只需要关心两件事:什么时候该睡了,醒来后做什么。至于引脚怎么配、频率怎么降、外设怎么关,都由SleepManager统一处理。

3.3 钩子函数:把"睡前做什么"交给业务层

也许你会问:为什么不直接在SleepManager里把外设关掉?因为不同项目用的外设完全不一样,有人接I2C传感器,有人接舵机,有人接GPS模块。SleepManager没法知道你的业务外设是什么,所以正确的做法是用"钩子函数"(callback)的方式,把睡眠前后要做的事留给上层业务去注册。

这就像一个酒店的入住流程:你只需要打电话告诉前台"晚上8点叫醒我、早上7点送早餐",酒店自己会去安排线路、安排人员。你的业务代码就是那个打电话的人,SleepManager是酒店前台。

4. SleepManager代码逐段拆解:从打开到接进你的业务

4.1 完整代码:SleepManager

下面是我在实际项目里用的一版,基于MicroPython,兼容Pico和Pico W:

# sleep_manager.py import machine from machine import Pin class SleepManager: """把进入睡眠前后要做的脏活集中起来,业务逻辑不用关心低功耗细节。""" def __init__(self, wake_pin=None, wake_trigger=Pin.IRQ_FALLING, run_freq=133_000_000, sleep_freq=31_000_000): self.wake_pin = None if wake_pin is not None: # 唤醒引脚一般接按键或外部信号,默认上拉,等待拉低触发 self.wake_pin = Pin(wake_pin, Pin.IN, Pin.PULL_UP) self.wake_trigger = wake_trigger self.run_freq = run_freq self.sleep_freq = sleep_freq self.reason = "cold_boot" self._before_sleep = [] self._after_wake = [] def on_before_sleep(self, fn): """注册睡眠前回调,参数 fn 是无参数函数。""" self._before_sleep.append(fn) def on_after_wake(self, fn): """注册唤醒后回调。""" self._after_wake.append(fn) def _set_wake_irq(self, enable): if self.wake_pin is None: return if enable: self.wake_pin.irq(handler=self._wake_isr, trigger=self.wake_trigger, hard=True) else: self.wake_pin.irq(handler=None) def _wake_isr(self, pin): # ISR里只做标记,不要print、不要跑耗时的Python逻辑 self.reason = "gpio:%d" % (pin.id(),) def _prepare(self): # 睡眠前先把频率降下来,减少"醒来后到再次睡去"正常运行的电流 if self.sleep_freq and machine.freq() > self.sleep_freq: machine.freq(self.sleep_freq) for fn in self._before_sleep: fn() def _restore(self): if self.run_freq and machine.freq() < self.run_freq: machine.freq(self.run_freq) for fn in self._after_wake: fn() def sleep(self, timeout_ms=None): """进入低功耗睡眠。timeout_ms 不传则只有GPIO能唤醒。""" self.reason = "unknown" # 先做睡眠前的准备工作,再开唤醒中断 self._prepare() self._set_wake_irq(True) # 真正的硬件睡眠 machine.lightsleep(timeout_ms) # 醒来后恢复 self._set_wake_irq(False) self._restore() if self.reason == "unknown": self.reason = "timer:%s" % (timeout_ms,) return self.reason

这段代码核心就一个sleep()方法,顺序是:执行睡眠前回调 → 开启唤醒引脚中断 → 进入machine.lightsleep→ 醒来后关闭中断 → 恢复频率 → 执行唤醒后回调 → 返回唤醒原因。

4.2 用法示例一:定时器唤醒的采集节点

如果你做的是一个定时上报的传感器节点,不需要按键唤醒,就这么用:

import time from sleep_manager import SleepManager sm = SleepManager() # 不传wake_pin,纯定时器唤醒 def before_sleep(): print("[bus] sensors off") # 在这里真正把I2C/SPI/传感器电源关掉 def after_wake(): print("[bus] sensors on") # 重新初始化传感器 sm.on_before_sleep(before_sleep) sm.on_after_wake(after_wake) while True: print("[app] read temp: 26.3") time.sleep_ms(30) # 留30ms给串口把数据发完 reason = sm.sleep(timeout_ms=10000) print("[app] wakeup reason:", reason)

这里有个细节:time.sleep_ms(30)不是多余的。如果你用的是硬件UART,进入睡眠前要给UART一点时间把FIFO里的数据真正发出去,否则日志可能没发完就睡过去了,这就是后面串口坑二的前奏。

4.3 用法示例二:GPIO按键唤醒

如果你做的是类似遥控器、门铃这种"平时睡着、按键才干活"的设备,可以这样:

import time from sleep_manager import SleepManager # 按键接在GPIO16和GND之间,按下为低电平 sm = SleepManager(wake_pin=16, wake_trigger=machine.Pin.IRQ_FALLING) def do_work(): print("[app] button pressed, do work") time.sleep_ms(50) while True: # 不传timeout,只有按键能唤醒 reason = sm.sleep() print("[app] wakeup by", reason) do_work()

按键唤醒这里有个容易踩的坑:如果按键按下时持续拉低,唤醒后代码开始跑,但按钮还没松手,wake_trigger配置的是下降沿触发,同一时刻只有一个边沿,所以不会重复触发。等你松手再按下,会再产生一次下降沿,逻辑上是正常的。但如果你希望"按住不松手只触发一次",就需要在after_wake里等引脚恢复高电平后再进入下一次睡眠。

4.4 使用这些代码前要注意的关键点

这套代码是"可复用"的,但不代表拿过去就能直接跑,有几个关键点必须说清楚:

第一,唤醒引脚在睡眠前千万不要处于有效电平状态。比如配置了下降沿唤醒,但睡眠前引脚已经被外部拉低了,那么睡眠过程中不会产生边沿,自然也就唤醒不了。这不是代码bug,是边沿触发的固有特性。

第二,硬中断回调里不要干活。上面代码里_wake_isr只记录了一个字符串,这是有意为之。MicroPython的硬中断里跑Python代码本身就有限制,更别说什么延时、打印、复杂计算。唤醒后发现reasonunknown,大概率是中断没触发或者被其他事件抢先了。

第三,调试阶段先别用无限期睡眠。不管最终产品是怎样的,调试时先传一个timeout_ms=5000,让板子每5秒自己醒一次,确认流程正常了,再改成真正的无限期睡眠。不然你代码写错一个地方,板子睡死过去,就要重新插拔USB线,效率极低。

5. 串口在低功耗下为何"翻车":三次真实排查过程

5.1 坑一:USB CDC在lightsleep下"失联"

我第一次把machine.lightsleep接进代码后,用Thonny运行,下一秒整个REPL就没反应了。敲Ctrl+C无效,点"停止"也无效,板子像死机了一样。当时第一反应是代码崩溃了,但把USB线拔了重插,板子又正常运行,说明程序没崩,只是串口没了。

后来排查确认:lightsleep会把USB外设的时钟停掉,USB CDC的连接就断了。换句话说,你不能指望在lightsleep期间还通过USB串口看日志、敲命令。

这个坑的解决办法有两个方向。一是调试阶段不要用USB串口,而是用一个USB转TTL模块(CH340或CP2102之类的),接到Pico的GPIO0(UART0 TX)和GPIO1(UART0 RX)上,这样可以一边睡眠一边看到日志。二是在睡眠代码前后加上GPIO状态指示,用板载LED或外接LED显示当前处于什么状态,这样即使没有串口,也能判断板子是不是真的睡过去了。

我自己现在的习惯是:只要涉及低功耗,USB CDC只用来烧录程序,调试输出一律走硬件UART。CH340几块钱一个,却能省掉一堆"板子是不是死了"的纠结。

5.2 坑二:唤醒后串口突然吐出一堆旧日志

用硬件UART调试后,出现了另一个怪现象:板子睡10秒,唤醒后串口不是打印一条"wakeup"日志,而是噼里啪啦吐出来好几条睡眠前的日志。比如睡眠前明明只打印了一次[app] read temp,唤醒后却把之前三四次的日志一股脑发了出来。

后来我想明白了:UART外设本身有FIFO缓冲区,MicroPython的print只是把数据写进缓冲区,硬件逐字节往外发是需要时间的。我睡眠前刚print完就立刻进入lightsleep,UART的时钟停了,缓冲区里还没发完的数据就整个冻住了。等唤醒后UART恢复工作,这些"陈年旧日志"才开始往外送。

解决办法有两个。一是在进入睡眠前加一个小延时,比如time.sleep_ms(30),让缓冲区里的数据发完;二是更彻底的做法,在SleepManager._prepare()里把UART清空或等待发送完成。MicroPython的machine.UART不一定都有flush()方法,所以我的做法是留固定延时,简单可靠。

5.3 坑三:想用UART RX唤醒,结果第一次唤不醒

这是我最想吐槽的坑。需求是这样的:外部设备通过串口发一个字节过来,Pico要从睡眠中醒来处理。我一开始很自然地想:UART收到数据会触发接收中断,那lightsleep应该能被UART数据唤醒吧?结果实际测试,数据发过来Pico纹丝不动。

排查链路我完整记录一下,方便你以后遇到类似问题有迹可循:

  1. 先怀疑代码逻辑:是不是SleepManager根本没进入睡眠?于是把reason日志打出来,确认lightsleep已经执行。
  2. 再怀疑是引脚配置问题:用示波器看RX引脚波形,外部设备发送的0xAA确实把TX线拉低再拉高,下降沿清清楚楚。
  3. 接着怀疑是中断没配好:直接用一个按键接到同一个引脚,按一下Pico能醒,说明GPIO中断唤醒本身是通的。
  4. 最后疑问聚焦在"为什么UART数据不能唤醒"——查了RP2040的电源管理和唤醒机制,结论是lightsleep状态下,UART外设的时钟默认是关闭的,外设收不到数据,自然无法产生中断。

最终方案是:把RX引脚同时配置为GPIO下降沿中断(作为唤醒源),唤醒后立刻重新初始化UART。但这里有个非常实际的限制:用GPIO边沿唤醒只能告诉你"有数据来了",第一个字节大概率已经丢了。因为从GPIO唤醒到UART重新初始化完成,中间有几十毫秒的间隙,数据早过去了。

如果应用对首字节不敏感,比如外部设备发送的是一条完整指令,唤醒后可以从后续字节里解析,那这个方案勉强可用。如果要求一字节不漏,就必须单独用一个唤醒引脚(比如接到外部设备的INT引脚),让外部设备在发数据之前先把唤醒引脚拉低一下,再发数据。这也是我现在推荐的做法:唤醒信号和数据信号分开,别指望UART自己能把设备叫醒

5.4 低功耗调试的基本盘:日志带时间戳 + 状态LED

经历过上面三个坑之后,我给自己定了一套低功耗调试的规范,分享出来:

第一,所有日志必须带时间戳。MicroPython里用utime.ticks_ms()打一个毫秒级的起点,这样可以清楚地看到睡眠经过了多长时间、唤醒后多长时间内完成了外设初始化。没有时间戳的日志在低功耗调试里几乎没有分析价值。

第二,用LED指示状态,而不是依赖串口。我在睡眠前把LED灭掉,唤醒后点亮50ms再灭掉,这样即使串口暂时不可用,用眼睛就能确认板子的睡眠周期是否正常。状态LED的颜色和闪法可以编码,比如快闪代表正常跑业务、灭代表已睡眠、慢闪代表唤醒后初始化失败。

第三,所有睡眠日志坚持"进睡眠前打一条、唤醒后打一条"的规矩。进睡眠前那条日志放在before_sleep回调里,唤醒后那条放在after_wake回调里,这样串口日志能形成完整的证据链,排查问题效率高很多。

6. 功耗实测与优化三板斧:从20mA压到1mA以内

6.1 我用的测量方法

说功耗优化之前,先交代下测量方法,不然数据没法参考。

我的测量环境很简单:万用表电流档串在电池正极和Pico的VSYS或者3V3供电之间,USB不供电。这里提醒一句,用USB供电测电流是不准的,因为USB转串口芯片、LDO静态电流都会混进去。Pico板载的电源电路本身也有一部分静态电流,这部分没法完全绕开,但至少能测出代码层面的优化效果。

如果想要更精细的数据,可以用INA226这种I2C电流传感器,把数据通过串口记录下来,然后画一条24小时的电流曲线。不过大多数场景万用表就够了,关键是保持同一个测量位置、同一个供电方式,对比才有意义。

6.2 一组有代表性的功耗数据

以下数据来自我手头的一块Pico,MicroPython固件版本是1.20+,供电为两节5号电池经升压到5V后接VSYS。不同板子、不同固件会有差异,但数量级和趋势是有参考价值的:

测试状态实测电流
上电后while True: pass约20mA
主循环里time.sleep(1)约15~18mA
machine.lightsleep(10000)约1.2mA
关闭板载LED、未用引脚全部输出低后sleep约0.9mA
machine.deepsleep()约0.4mA

注意time.sleep(1)那行的数值,它比纯while True: pass略低,但远高于lightsleep。这说明time.sleep确实让CPU没那么紧绷了,但并没有真正"睡过去"。如果你只想记住一个数字,那就是Pico在lightsleep下的理论功耗比time.sleep低一个数量级

6.3 三板斧之一:管教好每一根引脚

功耗优化第一件事不是改代码,而是把所有GPIO引脚的状态过一遍。浮空引脚在睡眠中会因为电平不确定导致CMOS输入级反复翻转,产生额外的漏电,这是很多人忽略的隐性功耗来源。

我的做法是:进入睡眠前,把所有不用的GPIO统一配置为输出低电平。为什么是输出低而不是输入下拉?输出低是确定的逻辑电平,不会因为内部上下拉电阻产生额外的电流路径,而输入下拉的每个引脚都会有几微安到几十微安的漏电,引脚一多就是笔额外的消耗。

具体到代码,可以在before_sleep回调里做:

def before_sleep(): for pin_id in [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 17, 18, 19, 20, 21, 22, 26, 27, 28]: p = machine.Pin(pin_id, machine.Pin.OUT) p.value(0)

这里有个注意事项:如果某个引脚正在驱动外部设备(比如舵机信号线、MOS管栅极),直接输出低可能会触发外部设备动作,所以这组引脚列表要根据你的实际电路来填写,不能被动的无脑照抄。

6.4 三板斧之二:用完的外设一定要关

第二个优化点是外设的生命周期管理。MicroPython里,I2C、SPI、PWM、ADC这些对象在创建时会初始化对应的硬件模块,而这些硬件模块在睡眠期间如果不关闭,会继续保持时钟或产生静态功耗。

比较稳妥的做法是:在before_sleep回调里,把不需要的外设对象设置为None或调用对应的deinit()方法。比如:

i2c = None # 让I2C对象可以被垃圾回收 pwm.deinit() # 如果PWM对象支持deinit

ADC这块要注意,machine.ADC对象在部分固件上没有deinit()方法,但你可以确保没有正在进行的采样任务,然后直接把对象置None。另外Pico板载的LED在GPIO25上,如果之前点亮过,睡眠前一定要Pin(25, Pin.OUT).value(0),不然这颗LED会一直耗电,虽然功耗不大,但它不该亮。

如果你用的是Pico W,还有一个更大的电老虎:WiFi芯片。即使你的程序没有主动连接WiFi,只要network.WLAN()被激活过,WiFi芯片就会持续工作。睡眠前务必执行:

import network wlan = network.WLAN() wlan.active(False)

不关WiFi就想实现低功耗,在Pico W上是行不通的,实测电流会高出好几毫安。

6.5 三板斧之三:频率、电源路径和Pico W的WiFi

第三板斧是频率管理。machine.freq()可以动态调整RP2040的运行频率,我把正常运行频率设为133MHz,睡眠前降到31MHz。不过这里要说明白:lightsleep本身会把主时钟停掉,降频对睡眠中的功耗贡献很小,它的意义在于缩短"醒来后到再次睡去"期间的高频运行时间。一个常见的场景是:唤醒后要赶紧完成传感器读取和日志发送,这几十毫秒内如果把频率降到31MHz,能省下一些电流。但如果你的业务处理很短,这个优化就不是重点。

电源路径也是值得注意的。Pico板载的电源管理电路本身有静态电流,不少在0.5mA左右。如果你追求极致低功耗,可以考虑绕开板载的线性稳压,用一颗低静态电流的外部LDO直接给Pico的3V3供电。但这会带来供电顺序、电压跌落等一系列问题,不是每个项目都值得这么做,我只提出来提醒一下,别为了省那0.5mA把板子搞不稳定。

6.6 优化后别忘了回归测试

功耗优化完,最后一步是回归测试。我的方法是:让板子连续跑一天一夜,用串口日志记录每次唤醒的时间戳,然后检查是否有异常唤醒、是否出现某个外设初始化失败、是否出现某次睡眠时间异常短的情况。

低功耗优化经常会引入"偶发性bug"。比如某个传感器在唤醒后初始化失败一次,可能导致整个系统就一直处于忙碌状态——功耗反而比优化前更高。这时候把日志里的时间戳连起来看,误差一瞬间就暴露了。所以优化功耗不是把电流表数字降下来就完事了,能稳定地长期运行才是最终目标。从20mA压到1mA不难,难的是一直保持住。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询