ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

树莓派Pico低功耗实战:lightsleep封装与功耗优化指南

树莓派Pico低功耗实战:lightsleep封装与功耗优化指南 最近在折腾一个电池供电的传感器节点盯着万用表上的电流数值蹲了半天终于把整机休眠电流从十几毫安压到了微安级别。这个过程中最大的感悟是树莓派 Pico 跑 lightsleep 本身不难难的是把休眠逻辑写得能复用、好调试以及搞清楚真正的耗电大头在哪里。这篇就用一个实际项目来复盘整个思路包括可复用的 lightsleep 封装代码、串口调试的注意事项以及实测功耗优化过程中踩过的那些坑。如果你手里也有一个“每隔一段时间干点活、然后长时间闲着”的 Pico 项目比如温湿度采集、环境监测、低频率状态上报这篇文章应该能帮你少走不少弯路。我会把可直接照抄的基础代码、调试方法以及如何一步步把功耗从“能休眠”优化到“真省电”的过程都拆开讲。1. 先搞清楚 lightsleep 到底“睡”到了什么程度很多新手一上来就用time.sleep()然后发现电池几天就没电了。这里必须先厘清一个概念time.sleep()只是让 CPU 空转它不会进入低功耗状态。真正干活的是machine.lightsleep()和machine.deepsleep()但两者差别非常大容我一个个说。1.1 三种 sleep 模式不只是“睡多久”的区别MicroPython 里常见的三种延时/睡眠方式我直接做了一张表方便对照方式CPU 状态RAM 内容外设状态功耗量级唤醒后的行为time.sleep(10)继续运行空转保留全部保持十几 mA 量级下一行继续执行machine.lightsleep(10)停止运行保留大部分停止引脚状态保持0.5~3 mA 量级下一行继续执行machine.deepsleep(10)完全断电不保留重新启动脚本彻底关闭几 uA 量级从脚本开头重新执行关键点在于lightsleep相当于“暂停”醒过来之后代码接着往下走所有变量还在。这对需要保存运行状态、临时数据的场景非常友好。deepsleep相当于“关机重启”醒过来是冷启动脚本从头跑一遍。省电是真的省但代价是没法保留运行时的临时状态除非你把需要保留的数据写到RTC.memory()或者外部 Flash。我在实际测试中Pico 跑 3.3V/40MHz 左右频率配合没有任何外设挂载的状态下lightsleep电流大约在 0.8 mA 到 1.5 mA 之间波动deepsleep可以做到 5 uA 左右。但别高兴太早这都是在“引脚全部处理好、LED 没有亮、USB 不插”的理想前提下测的。真实项目中外设漏电往往才是大头。1.2 什么样项目才值得上 lightsleep并不是所有项目都适合用lightsleep。我判断的标准很简单如果任务的周期大于几百毫秒并且大部分时间都在“等”一个事件或一个时间点就值得用。比如每隔 1 分钟读一次温湿度传感器然后上报靠按键唤醒平时待机的遥控器按 RTC 闹钟定时记录数据的采集器反过来如果是需要毫秒级响应、或者一直有数据流处理的任务比如实时采集加速度计数据做姿态识别那lightsleep反而会帮倒忙因为唤醒延迟和上下文切换会拖慢系统。还有一个容易被忽略的场景外设事件唤醒。比如一个震动传感器、一个门磁开关平时 Pico 保持lightsleep引脚电平变化时立刻唤醒干活干完再睡回去。这种场景特别适合低功耗电池节点我后面封装的代码也会把 GPIO 唤醒加进去。1.3 唤醒后代码从哪里继续跑理解唤醒后的代码流非常关键它直接决定你的程序结构怎么写。我用一段简单代码演示import machine import time print(before sleep) machine.lightsleep(5000) print(after sleep)这段代码执行到lightsleep之后Pico 进入休眠5 秒后唤醒然后打印after sleep程序继续往下走。整个过程变量不丢任务堆栈也不丢这叫做“原地恢复”。而deepsleep如果触发复位唤醒MicroPython 会重新执行脚本。这时候要想区分“冷启动”和“deepsleep 唤醒”通常靠machine.reset_cause()来判断import machine if machine.reset_cause() machine.DEEPSLEEP_RESET: print(woke up from deepsleep) else: print(cold boot)这个能力在做可复用代码的时候尤其有用因为你可以让代码在深睡唤醒后跳过初始化动作直接进入业务逻辑。2. 可复用代码把“休眠逻辑”从“业务逻辑”里拆出来很多人的 Pico 低功耗代码都是一次性脚本一个while True里面塞了采集、上报、睡眠换一个项目就得全部重写。这种写法不是不能用但重复劳动太多而且很容易在移植的时候漏掉引脚状态处理、外设电源控制这些细节点。更好的做法是把“节点如何睡、如何醒、醒后怎么恢复外设”封装成一个通用的基类业务代码只关心“本周期我要做什么”。2.1 为什么你的 lightsleep 代码老是要“重写”因为大多数人把休眠相关的动作散落在业务逻辑里。比如# 不推荐的写法业务和休眠混在一起 while True: value sensor.read() print(temp:, value) led.value(0) sensor_power.value(0) machine.lightsleep(60_000) sensor_power.value(1) led.value(1) time.sleep_ms(500) # 等传感器稳定这段代码在单个项目里能跑但一旦你要更换休眠策略比如从定时唤醒改成 GPIO 唤醒、或者换一个传感器板载电源控制引脚就得把所有业务函数全部翻一遍很容易改漏。更合理的做法是把“睡眠管理器”独立出来负责睡眠前关闭外设电源、处理好所有 GPIO 状态设置唤醒源定时器 / GPIO / RTC唤醒后恢复外设电源、等待器件稳定提供回调接口让业务代码挂载自定义逻辑2.2 一个能直接拿去改的 SensorNode 封装下面这份代码是我反复用了很久的基类放在sensor_node.py里使用的时候直接from sensor_node import SensorNode。为了可读性我加了详细注释你可以根据自己的板子调整引脚。import machine import time from machine import Pin, RTC class SensorNode: def __init__(self, wake_reasonNone, wake_pinNone, led_pinLED, power_pinNone, rtc_alarm_secs60): wake_reason: rtc 或 gpio wake_pin: GPIO 唤醒引脚编号如 14 表示 Pin(14) led_pin: 板载 LED 引脚默认 Pico 的 LED power_pin: 外设电源控制引脚低电平关断 rtc_alarm_secs: RTC 周期唤醒秒数 self.wake_reason wake_reason self.wake_pin wake_pin self.rtc_alarm_secs rtc_alarm_secs # LED 默认开启方便调试时看到状态上线前会手动关闭 self.led Pin(led_pin, Pin.OUT) self.led.value(0) # 外设电源开关引脚默认为 None 表示没有开关控制 self.power_pin None if power_pin is not None: self.power_pin Pin(power_pin, Pin.OUT) self.power_pin.value(0) # 回调对象默认是空函数 self.on_startup lambda: None self.on_cycle lambda: None self.on_before_sleep lambda: None self.on_after_wake lambda: None def _prepare_for_sleep(self): 进入睡眠前统一处理关 LED、关外设电源、恢复/设置 GPIO 状态 self.led.value(0) if self.power_pin is not None: self.power_pin.value(0) # 调用用户挂载的钩子比如保存状态、关闭 I2C 等 if self.on_before_sleep: self.on_before_sleep() def _on_wake(self): 唤醒后统一恢复 if self.power_pin is not None: self.power_pin.value(1) time.sleep_ms(50) # 给传感器上电稳定时间按需调整 if self.on_after_wake: self.on_after_wake() def _enter_light_sleep(self): if self.wake_reason rtc: machine.lightsleep(self.rtc_alarm_secs * 1000) elif self.wake_reason gpio: # 通过 Pin 的 irq 唤醒需要在机器休眠前注册低电平/上升沿中断 pin Pin(self.wake_pin, Pin.IN, Pin.PULL_UP) pin.irq(handlerlambda p: None, triggerPin.IRQ_FALLING, wakemachine.lightsleep) machine.lightsleep() else: machine.lightsleep(self.rtc_alarm_secs * 1000) def run(self): # 首次冷启动做一个完整初始化 self.led.value(1) if self.on_startup: self.on_startup() while True: # 每个业务周期先必然做采集/上报/任何用户逻辑 if self.on_cycle: self.on_cycle() # 然后进入睡眠的准备工作 self._prepare_for_sleep() self._enter_light_sleep() # 唤醒后的恢复处理 self._on_wake()需要注意几个设计细节_prepare_for_sleep()里我专门把“关 LED、关外设电源”放在最前面是因为这两个是电磁干扰和功耗的大头而且非常容易在移植时忘记。LED 在run()开头点亮是为了调试时一眼看出系统启动了正式上线时把self.led.value(1)去掉或者直接删了这行。_enter_light_sleep()里把唤醒源做了区分定时器和 GPIO 用的是完全不同的唤醒方式。2.3 用回调注册业务逻辑封装才算真正解耦基类做好之后业务代码就非常清爽了。以“每 60 秒读一次板载温度传感器这里用模拟温度传感器演示通过 print 输出”为例from sensor_node import SensorNode import machine import time # 假设模拟传感器接在 ADC0 上即 GP26 adc machine.ADC(26) def read_sensor(): val adc.read_u16() voltage val / 65535 * 3.3 # 例如 LM35 或者 NTC 换算逻辑这里简化为电压值 print(sensor voltage: %.3f V % voltage) def do_cycle(): read_sensor() # 这里可以是 LoRa 发送、写 SD 卡、网络上报等 # 重点是这个函数只关心“业务”本身 node SensorNode(wake_reasonrtc, rtc_alarm_secs60, power_pin6) node.on_cycle do_cycle node.run()这样休眠策略、外设电源控制、唤醒处理全部在SensorNode内部完成业务函数只负责“干活”。以后要把定时 60 秒改成 300 秒只需要改rtc_alarm_secs300要把定时唤醒改成 GPIO 唤醒只需要把wake_reason改成gpio并配置wake_pin业务代码一行都不用动。如果你愿意还可以在这个基类里增加“收集唤醒次数”、“统计本轮实际运行时长”等功能。我实际项目里就加了一个wake_count属性用来判断是不是第一次冷启动从而决定要不要执行完整初始化这个在后文踩坑部分会再提到。3. 串口调试在低功耗场景下的特殊之处低功耗代码最让人头疼的就是调试。插着 USB电流测不准不插 USB怎么打印日志这里分享一下我的调试链路以及在串口调试中必须避开的几个问题。3.1 USB 口调试的坑休眠唤醒会“断连”刚开始我是直接用 Pico 的 USB 口接 Thonny 调发现一个很诡异的现象只要代码进入lightsleepThonny 右下角的 MicroPython 连接状态就掉线了唤醒之后要等一两秒端口才会重新出现而且经常出现“port already in use”或者打印日志丢失的情况。原因在于lightsleep会让 RP2040 的 USB 控制器也进入低功耗状态USB 枚举连接会断开。唤醒后 USB 设备重新枚举你的串口助手/IDE 需要重新打开端口。如果代码在唤醒后立刻打印大量日志很可能在串口重新连接之前就打印完丢掉了。解决办法有两个如果坚持用 USB 调试唤醒后加一个time.sleep(2)等终端重新连上再打印。但这会拖慢整个系统而且实测不稳定。更推荐用硬件串口UART做调试。这也是我目前主力方案。3.2 用硬件 UART 接调试助手日志才稳定Pico 的 UART0 默认映射在 GP0TX和 GP1RX上。你可以用一个 USB 转 TTL 模块比如常见的 CH340 模块把 Pico 的日志转发到电脑上的串口调试助手比如 SSCOM、XCOM 这些工具都行。接线如下Pico GP0 (UART0 TX) - CH340 模块 RX Pico GP1 (UART0 RX) - CH340 模块 TX GND - CH340 模块 GND然后给 Pico 单独供电电池或 3.3V不要用 CH340 模块上的 5V 给 Pico 供电这样才能边测电流边看日志。在代码里启用 UART 日志import machine from machine import UART, Pin # 初始化 UART0TXPin(0), RXPin(1), 波特率 115200 uart machine.UART(0, baudrate115200, txPin(0), rxPin(1))如果之前你的 MicroPython 终端默认是复用 USB 的这里还需要把 UART 上的 REPL 关掉否则当你用 UART 打印的时候会混入 Python 交互提示符的乱码。我在 Pico 上用下面这行来关闭 UART0 的 REPL 占用import os os.dupterm(None, 1) # 关闭 UART0 上的终端然后在业务代码里把print()改成自定义的log()函数统一走 UARTdef log(msg): uart.write([%d] %s\r\n % (time.ticks_ms(), msg))电脑端打开 SSCOM 或 XCOM选对 COM 口号CH340 通常是 COM3/COM5 之类的具体看设备管理器波特率设成 115200就能看到稳定的日志了。这样即便 Pico 进入lightsleepUART 外设本身也不参与功耗计算只要你注意 UART 的 TX 引脚在休眠时别被外部电平锁住电流整个调试过程非常顺畅。3.3 调试时先别急着优化把这三个量打出来很多朋友拿到功耗优化任务上来就改代码结果改了半天不知道电流降没降。我的习惯是先在进入睡眠前和唤醒后各打三组信息把“对象”量化了再说进入睡眠前的时间戳和电池电压如果有 ADC 采集用来判断系统是否按预期时间醒来、压降是否正常。本次唤醒的原因是 RTC 定时唤醒还是 GPIO 唤醒打印出来能快速发现“莫名其妙被唤醒”的问题。实际睡眠时长在lightsleep前记录time.ticks_ms()唤醒后立即记录一次算出差值和设定的周期对比。举个例子# 睡眠前 t_start time.ticks_ms() log(enter sleep at %d ms % t_start) machine.lightsleep(60_000) # 唤醒后 t_end time.ticks_ms() log(wake at %d ms, slept %d ms % (t_end, time.ticks_diff(t_end, t_start)))如果发现实际睡眠时间远小于设定值说明产生了意外唤醒源如果远大于设定值可能是某个外部中断/RTC 配置有问题。日志在手排查效率立刻翻倍。4. 功耗优化从“能休眠”到“真省电”的六个细节进入lightsleep只是第一步真正能把待机电流压下去靠的是下面这些小细节。我把它总结成六个维度每个都踩过坑按重要性排序。4.1 外设断电比任何 API 都管用很多传感器模块比如 OLED 屏幕、LoRa 模块、SD 卡读写模块在“待机”状态下依然会耗电有的甚至达到 mA 级别。你即使让 Pico 进入lightsleep外设仍然在吃电整机电流根本降不下来。最通用的解法是用一个 GPIO 控制 P-MOS 管或负载开关给外设统一断电。接线示意图大体是这样GPIO 控制引脚 - MOS 管 Gate 电池/3.3V - MOS 管 Source 传感器 VCC - MOS 管 Drain GND 统一共地控制逻辑很简单低电平断电高电平上电。在SensorNode里我已经预留了power_pin你只需要把传感器 VCC 接到受控电源轨上。休眠前power_pin.value(0)唤醒后power_pin.value(1)再等几十毫秒让传感器稳定再进行读写操作。这里有个容易踩的坑有些 I2C 传感器的 SDA/SCL 引脚即使在 VCC 断电后仍然会通过 Pico 的内部上拉或者外部上拉电阻反向供电。排查办法是断电后用万用表测传感器 VCC 引脚如果还有电压就需要在 I2C 线上也串联 MOS 管开关或者改用 GPIO 控制外部上拉到关断电源轨。4.2 GPIO 悬空和浮空引脚是漏电大户Pico 所有未使用的 GPIO在默认状态下是输入浮空。浮空输入引脚会因为电平不确定而在引脚保护二极管上产生漏电流量级虽然只有微安到十几微安但在追求低功耗的项目里这已经相当可观了。解决方法在进入睡眠前把所有未使用的 GPIO 设置为输出低电平或者设置为输入下拉。from machine import Pin # 假设板子把 GP2~GP9 都留空了 unused_pins [2, 3, 4, 5, 6, 7, 8, 9] for gpio in unused_pins: p Pin(gpio, Pin.OUT) p.value(0)注意如果某个引脚连到了外部按钮、传感器中断脚那就不能把它强制设成输出低否则会影响外部信号。务必要按项目实际接线区分。我一般把“引脚初始化/睡眠前处理”写成一个独立的函数每次换硬件就只改这一个函数避免在业务代码里东一榔头西一棒子。4.3 用 RTC 定时唤醒不要靠“猜时间”用machine.lightsleep(60_000)做定时唤醒简单直观但它有缺陷MicroPython 的lightsleep(ms)在内部用的是系统 tick长时间运行会有累积误差而且如果你中间被 GPIO 意外唤醒过整个定时周期就乱了。更稳妥的方式是利用 Pico 的 RTC 闹钟。Pico 在 MicroPython 里支持machine.RTC().alarm()可以设置周期或一次性闹钟from machine import RTC rtc RTC() # 每 60 秒周期唤醒 rtc.alarm(time60_000, repeatTrue) # 进入 lightsleep等待 RTC 闹钟 machine.lightsleep()RTC 闹钟唤醒后从lightsleep()继续执行而且省去了软件定时器的累计误差稳定很多。如果项目要求长时间待机几个月外接一颗独立的低功耗 RTC 芯片比如 DS3231更靠谱用它的 ALARM 引脚接 Pico 的 GPIO 唤醒这样 Pico 本体的 RTC 可以完全不用省电还能避免内部时钟漂移。4.4 实测电流的目标值和量测方法做功耗优化一定要有测量手段没有数据支撑全在瞎调。我常用两种方式万用表串联测电流把万能表拨到 mA 档串进电池正极回路里测静态待机电流。注意从大档位开始测比如 200mA 档避免电流超出量程烧保险丝。如果要测微安级别再切到 uA 档。INA219/INA3221 电流传感器接在电源正极用另一块板子/另一个串口每秒采样一次电流能画出电流随时间变化的曲线比万用表直观得多。调试时特别好用能看到“睡眠-唤醒-闪灯-再睡眠”整个过程的电流尖峰。不同版本/不同固件的电流也有细微差别我手里的 Pico 在满速 125MHz 下运行电流约 20mAlightsleep时约 1.2mAdeepsleep时可以到 8uA 左右。注意板载 LED 一但点亮立刻多出 3~5mA 的电流所以测量前一定要把 LED 关了。4.5 别忘了测一下不同固件版本/不同 Pico 型号如果是 Pico 标准版RP2040提到lightsleep基本上就是上面那些门道。但如果你用的是 Pico W情况又会复杂一些WiFi 芯片不管有没有联网只要上电激活就稳定吃几十毫安电流。所以用 Pico W 做低功耗项目务必在休眠前执行import network wlan network.WLAN(network.STA_IF) wlan.active(False)另外不同 MicroPython 固件版本对lightsleep的实现细节会有微调。比如某个版本里machine.lightsleep()在带参数和带中断唤醒时返回后的引脚状态恢复行为有所不同。建议固定一个你测试过的固件版本用于生产不要随便升级。5. 我实际调试中踩过的一些坑最后讲几个我在项目里实际遇到、并且花了很长时间才定位的坑希望对你有帮助。坑一插着 USB 调试电流永远降不下来。Pico 通过 USB 连接电脑时即使进入deepsleepUSB 转接芯片和 VBUS 检测电路仍然会从 USB 口吃电所以你测到的电流完全不代表真实电池场景。正确做法是用电池或独立 3.3V 电源供电同时通过 UART 转 USB 模块看日志。坑二唤醒后立刻读传感器拿到的是上一次的数据。I2C/SPI 传感器从掉电状态恢复需要时间有的要几十毫秒甚至上百毫秒。我在SensorNode里已经加了time.sleep_ms(50)但如果你使用外部传感器最好根据数据手册调到 100~200ms。否则读出来要么是 0xFF 要么是旧缓存。坑三RTC 闹钟在某些固件版本的兼容问题。我测试发现某些 MicroPython 版本下rtc.alarm()的repeat参数在连续调用时表现不稳定。如果你要长期跑建议每次唤醒后重新设置一次闹钟并且用一个状态变量记录当前是否需要重设避免重复触发导致睡眠周期缩短。坑四GPIO 控制外设电源时上电瞬间可能会把 Pico 复位。这是因为 MOS 管开关瞬间会产生电压跌落/电磁干扰尤其是负载电容较大的传感器模块。解决办法是在 MOS 管的 Gate 和地之间加上拉电阻到 VCC用高电平控制导通时是下拉到 GND同时在外设 VCC 和 GND 之间并一个 100uF 电容缓冲。坑五deepsleep 唤醒后被硬复位初始化代码会重复执行。如果项目用deepsleep唤醒后脚本从头执行。你要靠machine.reset_cause()区分冷启动和深睡唤醒避免每次唤醒都重新做一次重量级初始化比如挂载 SD 卡、扫描 I2C 总线这些操作既费时又费电。我通常在main.py最前面做这样的判断import machine if machine.reset_cause() machine.DEEPSLEEP_RESET: # 深睡唤醒跳过大部分初始化直接进入业务 pass else: # 冷启动完整初始化 setup_all()以上是我在这套可复用lightsleep框架和功耗优化过程中比较有代表性的经验。低功耗调试就是这样看起来都是很小的细节但每个细节叠加起来最终的电流差异会非常大。你在做自己的 Pico 低功耗项目时也可以先从“硬件串口日志 电流测量”搭起调试基础再逐步套用上面的封装思路应该能省下不少排查时间。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进