“节能不是让设备少做事,而是让它只在需要的时候才全力工作。”
手机一天一充,你可能已经习惯了。但一个阅读器如果也一天一充,那它和手机有什么区别?续航是电子书阅读器最核心的体验指标之一——Kindle 号称可以续航数周,CrossPoint Reader 靠着一块 1000~2000mAh 的小电池,也要尽可能撑得久。 这一章我们来拆解 CrossPoint Reader 的电源管理策略:怎么在”干活”和”省电”之间找到最佳平衡。

13.1 为什么电源管理重要

先算一笔账。ESP32-C3 全速运行时的功耗约 150mA,假设电池容量为 1500mAh:
1500mAh / 150mA = 10 小时
也就是说,如果 CPU 一直以最高频率运转,即使什么都不显示,电池也只够撑 10 小时。而一个阅读器的绝大部分时间其实都在”等待用户翻页”——用户读一页可能要 30 秒甚至几分钟,这段时间 CPU 几乎无事可做。 如果我们能在”等待”时大幅降低功耗,在”干活”时才全速运行,续航就能从几小时提升到几天甚至几周。这就是电源管理的核心思路:按需供电,能省则省。

13.2 CPU 动态调频

ESP32-C3 的 CPU 主频可以在运行时动态调整。CrossPoint Reader 利用这一点实现了自动降频:
// lib/hal/HalPowerManager.cpp
static constexpr int LOW_POWER_FREQ = 10;   // 省电模式:10MHz
// 正常频率:160MHz

void HalPowerManager::setPowerSaving(bool enabled) {
  // WiFi 开着时强制全速
  if (WiFi.getMode() != WIFI_MODE_NULL) {
    enabled = false;
  }
  if (enabled && !isLowPower) {
    setCpuFrequencyMhz(LOW_POWER_FREQ);  // 降到 10MHz
    isLowPower = true;
  } else if (!enabled && isLowPower) {
    setCpuFrequencyMhz(normalFreq);       // 恢复 160MHz
    isLowPower = false;
  }
}
从 160MHz 降到 10MHz,CPU 频率降低了 16 倍,功耗大约降低 10 倍——因为芯片功耗近似与频率成正比。

降频时机

回忆第 7 章介绍的主循环:
// src/main.cpp - loop()
if (millis() - lastActivityTime >= HalPowerManager::IDLE_POWER_SAVING_MS) {
  powerManager.setPowerSaving(true);   // 3 秒无操作 → 降频
  delay(50);
} else {
  delay(10);
}
逻辑很简单:如果用户 3 秒内没有按任何按键,CPU 就降到 10MHz 进入”省电巡航”。一旦检测到按键输入,立即恢复 160MHz 全速响应。用户几乎感知不到这个切换——从 10MHz 恢复到 160MHz 只需要几微秒。

为什么 WiFi 开着不能降频

代码的第一行就检查了 WiFi 状态:WiFi 开着时,强制 enabled = false,不允许降频。 原因在于 WiFi 协议栈对时序有严格要求。WiFi 通信需要在精确的时间窗口内完成数据包的收发、加密解密、信道切换等操作。如果 CPU 降到 10MHz,这些操作可能来不及完成,导致 WiFi 连接不稳定甚至断开。
类比:降频就像把机场跑道从”高速运转”切换到”低速维护”模式。如果跑道上没有飞机(没有 WiFi 通信),低速模式没问题。但如果有飞机在起降(WiFi 正在工作),就必须保持高速运转,否则会出事故。

13.3 电池电量检测

用户需要知道”还剩多少电”。CrossPoint Reader 的 X3 和 X4 两个硬件版本用了完全不同的方案来检测电量:
// lib/hal/HalPowerManager.cpp
uint16_t HalPowerManager::getBatteryPercentage() const {
  if (_batteryUseI2C) {
    // X3:BQ27220 电量计芯片(I2C),直接读百分比
    Wire.beginTransmission(I2C_ADDR_BQ27220);
    Wire.write(BQ27220_SOC_REG);        // 寄存器地址 0x2C
    Wire.endTransmission(false);
    Wire.requestFrom(I2C_ADDR_BQ27220, (uint8_t)2);
    const uint8_t lo = Wire.read();
    const uint8_t hi = Wire.read();
    return (hi << 8) | lo;              // 0-100%
  }
  // X4:GPIO0 ADC 读模拟电压,然后换算
  static const BatteryMonitor battery = BatteryMonitor(BAT_GPIO0);
  return battery.readPercentage();
}

两种方案对比

方案X4:ADC 直读电压X3:I2C 电量计 (BQ27220)
原理用 ADC 读电池引脚的模拟电压,再根据电压-电量曲线换算百分比芯片内部持续追踪充放电电流,通过库仑计算精确计算剩余容量
精度较低(电压和电量不是线性关系,中间段特别平坦)高(直接追踪进出的电荷量)
成本几乎为零(GPIO 引脚自带 ADC)需要额外芯片(BQ27220 约几元)
难点需要校准电压曲线,受温度影响大初始化配置比较复杂
类比:ADC 方案就像通过看油箱浮标来估算剩余油量——便宜但不精确,尤其是油量在中间水位时浮标几乎不动。I2C 电量计方案就像用流量计记录每一滴进出的油——精确但需要额外硬件。

13.4 深度睡眠:真正的省电杀招

降频可以把功耗从 ~150mA 降到 ~15mA,但对于一个可能几小时不被使用的设备来说,这还不够。CrossPoint Reader 的终极省电手段是深度睡眠(Deep Sleep)——直接关掉 CPU。

进入深度睡眠的完整流程

// src/main.cpp
void enterDeepSleep() {
  HalPowerManager::Lock powerLock;  // RAII:保证 CPU 全速运行

  APP_STATE.lastSleepFromReader = activityManager.isReaderActivity();
  APP_STATE.saveToFile();           // 保存状态到 SD 卡

  activityManager.goToSleep();      // 渲染睡眠画面(墨水屏断电保持!)
  display.deepSleep();              // 墨水屏进入低功耗
  powerManager.startDeepSleep(gpio);// MCU 关机
}
几个关键步骤值得注意:
  1. 保存状态:睡眠前把当前的阅读进度、设置等信息写到 SD 卡。因为深度睡眠后 RAM 会清零,所有运行时数据都会丢失。
  2. 渲染睡眠画面:在墨水屏上画一个”已关机”的画面。还记得第 5 章讲的墨水屏双稳态特性吗?断电后画面会一直保持,所以用户看到的是一个静态的关机画面,而不是黑屏。
  3. MCU 关机:CPU 完全停止运行。

硬件层面的深度睡眠

// lib/hal/HalPowerManager.cpp
void HalPowerManager::startDeepSleep(HalGPIO& gpio) const {
  // 等电源键松开(防止立即重新唤醒)
  while (gpio.isPressed(HalGPIO::BTN_POWER)) {
    delay(50);
    gpio.update();
  }
  // GPIO13 → MOSFET → 切断电池供电
  gpio_set_direction(GPIO_NUM_13, GPIO_MODE_OUTPUT);
  gpio_set_level(GPIO_NUM_13, 0);
  gpio_deep_sleep_hold_en();
  gpio_hold_en(GPIO_NUM_13);
  // 设置唤醒源:电源键按下时唤醒
  esp_deep_sleep_enable_gpio_wakeup(
    1ULL << POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
  esp_deep_sleep_start();  // CPU 停止运行
}
这段代码做了三件关键的事情: 第一,等待电源键松开。 用户是长按电源键进入睡眠的。如果不等松开就进入睡眠,电源键还处于”按下”状态,设备会立刻被唤醒——等于没睡成。 第二,通过 MOSFET 切断电池。 GPIO13 控制一个 MOSFET 电子开关。把 GPIO13 拉低,MOSFET 断开,电池和其他外设(墨水屏、SD 卡等)之间的供电被物理切断。gpio_hold_en 确保在深度睡眠期间 GPIO13 的电平保持不变——否则 CPU 停止后引脚可能会浮空,MOSFET 状态不确定。 第三,设置唤醒源并进入睡眠。 esp_deep_sleep_start() 之后,CPU 完全停止运行。只有 RTC(Real-Time Clock)模块还在工作,它消耗的电流只有微安级别。当用户按下电源键时,RTC 检测到 GPIO 电平变化,重新启动 CPU——注意,这不是”恢复”,而是从头开始执行 setup() 函数,就像第一次开机一样。

深度睡眠 vs 普通 delay

普通 delay:  CPU 空转等待,RAM 保留,外设照常运行
             功耗:~15mA(降频后)

深度睡眠:    CPU 完全停止,RAM 清零,外设断电
             功耗:~5uA(微安级别,低了约 3000 倍)
代价是醒来后一切从零开始——程序从 setup() 执行,之前的变量、任务状态全部丢失,需要从 SD 卡重新加载。

13.5 开机流程与防误触

既然唤醒后从 setup() 开始,固件需要区分”第一次开机”和”从睡眠中唤醒”:
// src/main.cpp - setup()
switch (gpio.getWakeupReason()) {
  case HalGPIO::WakeupReason::PowerButton:
    gpio.verifyPowerButtonWakeup(SETTINGS.getPowerButtonDuration(), ...);
    // 验证电源键被长按了足够久,短按直接回去睡觉
    break;
  case HalGPIO::WakeupReason::AfterUSBPower:
    powerManager.startDeepSleep(gpio);  // USB 引起的冷启动,直接睡
    break;
}

为什么需要验证长按

想象设备放在口袋里。口袋里的钥匙或其他物品可能会意外碰到电源键。如果碰一下就开机,设备会白白消耗电量——更糟糕的是,可能会频繁地开机、超时、再睡眠,反复折腾。 verifyPowerButtonWakeup 的逻辑是:被唤醒后,先不急着启动,而是持续检测电源键是否仍然被按住。只有当按住时间超过设定阈值(比如 1 秒),才真正完成开机流程。如果用户只是短按了一下就松开了——对不起,回去继续睡。

USB 供电的特殊情况

当用户插入 USB 充电线时,设备会被通电启动(冷启动)。但用户插充电线可能只是想充电,并不想使用设备。所以 AfterUSBPower 分支的处理是:直接进入深度睡眠。设备在睡眠状态下也能充电,不需要开机。

13.6 PowerLock:RAII 防降频

第 7 章介绍过 RAII 锁模式。电源管理中也有一把类似的锁——HalPowerManager::Lock
// 在渲染任务中
void ActivityManager::renderTaskLoop() {
  while (true) {
    ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
    RenderLock lock;
    if (currentActivity) {
      HalPowerManager::Lock powerLock;  // 获取电源锁:强制 CPU 全速
      currentActivity->render(std::move(lock));
    }
    // powerLock 析构:允许降频
  }
}
墨水屏刷新通过 SPI 总线传输数据。SPI 的时钟信号由 CPU 时钟分频产生——如果 CPU 突然从 160MHz 降到 10MHz,SPI 时钟也会骤变,导致传输时序混乱,墨水屏可能显示乱码甚至无响应。 PowerLock 在构造时通知电源管理器”现在不能降频”,在析构时解除限制。这样只要刷屏操作还没完成,CPU 就不会被降频——渲染结束后锁自动释放,CPU 又可以回到省电模式。 RenderLock 内部其实嵌套了 PowerLock,所以每次获取渲染锁时都自动保证了 CPU 全速,不需要手动操心。

13.7 功耗预算估算

有了以上所有优化手段,我们可以算一笔完整的功耗账:
阅读模式(翻页间隔约 30 秒):
  待机 10MHz:   ~5mA  x 29s    = 145 mAs
  翻页 160MHz:  ~80mA x 0.5s   = 40 mAs
  墨水屏刷新:    ~30mA x 0.5s   = 15 mAs
  ──────────────────────────────
  一个周期(30s)总消耗:          200 mAs
  平均电流:                       ~6.7mA
  1500mAh 电池:                   ~224 小时 ≈ 9.3 天

WiFi 模式(OTA 升级、传书等):
  WiFi 活跃:    ~130mA
  1500mAh 电池: ~11.5 小时

深度睡眠:
  功耗:          ~5uA
  1500mAh 电池:  ~300,000 小时 ≈ 34 年(理论值,实际受电池自放电限制)
可以看到,电源管理优化的效果是巨大的。从不优化时的 10 小时续航,到阅读模式下接近 10 天——差了将近 20 倍。而在深度睡眠状态下,电池的寿命几乎取决于它自身的自放电速率,而不是设备的功耗。

本章要点

  1. 电源管理的核心思路:按需供电。CPU 在空闲时降频(160MHz -> 10MHz),在不用时彻底关机(深度睡眠),只在需要干活时才全速运行。
  2. 动态调频:3 秒无操作自动降到 10MHz,功耗降低约 10 倍。WiFi 开着时不能降频,因为协议栈依赖高频时钟。
  3. 电池检测的两种方案:ADC 读电压(便宜但不精确)和 I2C 电量计(精确但要额外芯片)。CrossPoint Reader 在 X3 和 X4 上分别采用了这两种方案。
  4. 深度睡眠:CPU 完全停止,通过 MOSFET 物理切断外设供电,功耗低至微安级别。代价是醒来后从 setup() 重新开始,需要从存储中恢复状态。
  5. 防误触机制:唤醒后验证电源键长按时长,短按直接回去睡。USB 供电导致的冷启动也直接进入睡眠。
  6. PowerLock(RAII):防止在 SPI 通信(刷屏)期间 CPU 被降频,确保时序正确。

下一章预告:第 14 章我们将看看固件发布后的生命——用户怎么通过 WiFi 在线升级固件?如果新固件有 bug 导致崩溃怎么办?双分区安全网和崩溃捕获机制是怎么确保设备”不变砖”的。