
如果你搜到這篇文章八成已經在 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()里有什么外設初始化拖了后腿逐段打點排查比靠猜快得多。功耗優化這件事本質是“芯片-代碼-板級設計”三者的合力先把代碼這一層做到可復用、可測量后面每一步優化才扎實。