第15章:深睡眠与低功耗——ULP/EXT1 唤醒

铁面无私:别让任何一粒电流,在该睡的时候还醒着

⚖️

本章导师:包青天

核心方法论:铁面无私,以证据定案

「本官断案,讲的是'铁面无私'四个字——证据面前,没有侥幸。功耗也是一样:每一微安的电流都得有出处,每一毫秒的清醒都得有交代。电子墨水屏号称'静态零功耗',可若主控还在偷电,这块招牌就是空话。这一章,我们就来当一回青天大老爷,把谁该睡、谁该醒、谁在深睡眠里还撑着最后一盏灯,一件一件审个明白。」

15.1 为什么电子书需要深睡眠

先从物理说起:电子墨水屏是双稳态的,画面定格后屏幕本身不再耗电——但屏幕不耗电,不代表整台设备不耗电。ESP32 主控、Flash、SD 卡、触摸芯片都还醒着。而阅读这个场景恰恰是"突发式"的:你盯着某一页看好几分钟,偶尔才翻一页。也就是说,一台阅读器 99% 的时间都在"等待"。如果等待时主控依然全速运转,那么"一页能停几周"就永远只是屏幕的承诺,而不是整台设备的现实。深睡眠(deep sleep,说人话:芯片几乎关停所有模块、只保留极低功耗唤醒能力的休眠模式)要解决的,正是这 99% 的等待时间。

深睡眠的本质是大规模断电:主 CPU 停止运行、Flash 与大部分 SRAM 掉电,只留下一个很小的 RTC 域RTC 内存,说人话就是深睡眠时仍能保留数据的一块小内存;ULP 协处理器,说人话就是 ESP32 里的超低功耗协处理器,能在主核休眠时干点轻活;还有若干 GPIO)持续供电。电流量级随之从"毫安级"掉到"微安级"——三个数量级的差距。代价是清醒代价很高:醒来要重新跑 app_main、重新初始化文件系统、重新构造渲染器。所以深睡眠不是"快进快出"的省电技巧,而是一个"睡之前把家当安置好"的完整流程。看 main.cpp 主循环的尾巴,入睡前的清场动作是一套组合拳:dehydrate() 存画面、stop_filesystem() 停 SD 卡、prepare_to_sleep() 各板收尾、setup_deep_sleep() 配置唤醒,最后 esp_deep_sleep_start() 才真正闭眼。

这章的第一个教学点,恰恰藏在"多长时间不操作就睡"这个问题里。README 的 "Deep sleep" 一节白纸黑字写着 "The code will go to sleep after 30 seconds of inactivity"(30 秒无操作后入睡);但代码里却是 esp_timer_get_time() - last_user_interaction < 120 * 1000 * 1000esp_timer_get_time() 返回微秒,所以 120 * 1000 * 1000 微秒 = 120 秒。文档与代码漂移,这是全书的第 N 次——前几章你已经在注释里见过 100ms vs 50ms、以及这个 30s vs 120s。结论始终不变:永远以代码为准。而且细看这个 while,还有个更隐蔽的节拍:xQueueReceive 只等 60 秒,即使没有按键,每 60 秒循环也会醒来刷新一次电量图标并 flush_display()——也就是说,等待期不是死睡,而是"每 60 秒眨一次眼",直到累计满 120 秒才真正入睡。

/* main.cpp:主循环 + 深睡眠入口(节选) */
int64_t last_user_interaction = esp_timer_get_time();
while (esp_timer_get_time() - last_user_interaction < 120 * 1000 * 1000) {   // 微秒 → 120 秒
  UIAction ui_action = NONE;
  // wait for something to happen for 60 seconds
  if (xQueueReceive(ui_queue, &ui_action, pdMS_TO_TICKS(60000)) == pdTRUE) {
    if (ui_action != NONE) {
      last_user_interaction = esp_timer_get_time();   // 有交互,刷新计时
      handleUserInteraction(renderer, ui_action, false);
    }
  }
  if (battery) { /* 每 60 秒刷一次电量,即使空闲 */ }
  renderer->flush_display();
}
ESP_LOGI("main", "Saving state");
renderer->dehydrate();        // 画面压缩存盘 → /fs/front_buffer.z
board->stop_filesystem();    // SD 卡是耗电大户,睡前必须停
board->prepare_to_sleep();   // 各板收尾(关屏 / 保持主电源)
ESP_ERROR_CHECK(esp_sleep_enable_ulp_wakeup());
button_controls->setup_deep_sleep();   // 按按键极性配置 ULP 或 EXT1
vTaskDelay(pdMS_TO_TICKS(500));
esp_deep_sleep_start();       // 真正入睡
状态谁还醒着电流量级能翻页吗
运行主 CPU + 屏幕 + SD + 触摸全部供电毫安级能,但最费电
等待(主循环)主 CPU 等待队列消息,每 60 秒醒来刷电量毫安级能,等待中持续耗电
深睡眠仅 RTC 域(RTC 内存 + ULP)微安级不能,必须唤醒后重初始化
注意

README 说 30 秒入睡,代码里是 120 * 1000 * 1000 微秒 = 120 秒。这是嵌入式项目"文档与代码漂移"的经典活例,前几章已反复提醒。真正要记住的姿势是:看到超时、单位、引脚这类"硬事实",永远先去 grep 源码核对,而不是背 README。本教程也以代码值为准。

15.2 状态保存:显示走文件系统、进度走 RTC 内存

要跨过深睡眠活下来的状态有两类,各自的"性格"完全不同,存放位置也随之不同。屏幕帧缓冲(约 EPD_WIDTH * EPD_HEIGHT / 2 字节,4 bpp,一块 4.7" 屏就是几十 KB)又大又"一次性"——只在入睡前存一次、醒来读一次;它进不了 RTC 内存(RTC 慢速 RAM 只有约 8 KB),于是走文件系统。入睡前 renderer->dehydrate() 用 miniz 的 tdefl_compress_mem_to_heap 把整帧压成 /fs/front_buffer.z(注释说得明白:写盘很慢,压缩后再写能省空间、提速),醒来 hydrate()tinfl_decompress_mem_to_mem 解压回缓冲、原样刷回屏幕。这一存一取都在 SD 卡上——也正是第 6 章里 README 说 SPIFFS 在深睡眠存显示状态上"有已知问题"的根源。

阅读进度(书单、当前章节、页码)又小又"要活到下次开机",走的是 RTC 内存。深睡眠只断主域的电,RTC 域持续供电,所以挂在 RTC_NOINIT_ATTR / RTC_DATA_ATTR 上的变量在睡眠期间一字不丢。三个关键对象全在这里:RTC_NOINIT_ATTR UIState ui_state(当前在哪一层界面)、RTC_DATA_ATTR EpubListState epub_list_state(书单 + 每本书读到第几节第几页)、RTC_DATA_ATTR EpubTocState epub_index_state(目录里选中了哪一项)。第 5 章你已经见过 EpubListItem 带着 current_sectioncurrent_pagepages_in_current_section 三个字段,而 EpubReader 持有的是 EpubListItem &state引用——翻页时改的本来就是 RTC 内存,天然持久,根本不需要"保存"动作。

醒来之后怎么接上?main.cppbutton_controls->did_wake_from_deep_sleep() 判断自己是不是被按键叫醒的(见 15.3);若是,则 renderer->hydrate() 把上一屏画面读回来,再 get_deep_sleep_action() 把"刚才按的哪个键"翻译成一次 UIAction 直接执行。这里有个很讲究的参数:handleUserInteraction(renderer, ui_action, !hydrate_success)——hydrate 成功就不用重绘(省一次全屏刷新),失败才整页重绘。墨水屏全刷一次几百毫秒还伤屏,能省则省。于是"屏幕画面走文件系统、阅读进度走 RTC 内存",两类状态、两条通道、各按自己的性格选位子——这就是状态保存的完整审案记录。

/* main.cpp:三份持久状态,全挂在不断电的 RTC 域 */
RTC_NOINIT_ATTR UIState ui_state = SELECTING_EPUB;      // 在哪一层界面
RTC_DATA_ATTR EpubListState epub_list_state;           // 书单 + 阅读进度
RTC_DATA_ATTR EpubTocState epub_index_state;           // 目录选中项

/* EpubListItem:进度字段就住在 RTC 内存里,引用它即是持久化 */
int current_section;      // 读到第几节
int current_page;        // 本节第几页
int pages_in_current_section;  // 本节共几页
/* main.cpp:从深睡眠醒来的恢复分支 */
if (button_controls->did_wake_from_deep_sleep()) {
  // restore the renderer state - it should have been saved when we went to sleep...
  bool hydrate_success = renderer->hydrate();       // 读回 /fs/front_buffer.z
  UIAction ui_action = button_controls->get_deep_sleep_action();
  handleUserInteraction(renderer, ui_action, !hydrate_success);  // 成功则不重绘
} else {
  renderer->reset();                                 // 冷启动:整页重绘
  handleUserInteraction(renderer, NONE, true);
}
/* EpdiyFrameBufferRenderer.h:dehydrate 压缩整帧写盘(节选) */
virtual bool dehydrate()
{
  // compress the buffer to save space and increase performance - writing data is slow!
  void *compressed = tdefl_compress_mem_to_heap(
      m_frame_buffer, EPD_WIDTH * EPD_HEIGHT / 2, &compressed_size, 0);
  FILE *fp = fopen("/fs/front_buffer.z", "w");
  if (fp) {
    size_t written = fwrite(compressed, 1, compressed_size, fp);
    fclose(fp);
    ...
    return true;
  }
  return false;
}
状态存放位置为什么
当前屏幕帧缓冲文件系统 /fs/front_buffer.z(miniz 压缩)大、一次性读写,放不进 RTC 内存
书单 + 每本书进度RTC_DATA_ATTRepub_list_state小、长期,要活到下次开机
目录选中项RTC_DATA_ATTRepub_index_state小、长期,目录导航时更新
当前 UI 状态RTC_NOINIT_ATTRui_state决定醒来后走哪个 handler
包青天提示

审状态,先按三个维度分类:多大要多长寿多常访问。大而一次性的(帧缓冲)→ 文件系统;小而长期的(进度)→ RTC 内存;千万别把会跨睡眠活下来的东西放进普通 RAM——一掉电就成冤案。把这个"记忆分布图"画出来,你就能给自己未来的嵌入式项目设计持久化方案。

15.3 唤醒机制:低电平用 ULP、高电平用 EXT1

睡了,还得能被叫醒。要叫醒睡眠中的芯片,得靠一个唤醒源(说人话:把芯片从睡眠叫醒的事件来源,比如按下按键)。项目的答案取决于一个硬件事实——按键的电平极性。按下为低电平的按键,平时靠上拉电阻维持在高电平,按下才拉到 0;而 esp_sleep_enable_ext1_wakeupEXT1(说人话:一种由多个 GPIO 触发的唤醒机制)唤醒是"某 GPIO 变高"才触发,无法感知"由高变低"的按下动作。所以低有效按键在深睡眠里只能靠 ULP(Ultra-Low-Power 协处理器)来守门。反过来,按下为高电平的按键,静态时是低、按下变高,恰好命中 EXT1 的能力范围。看 GPIOButtonControls::setup_deep_sleep() 的两条分支,注释把逻辑挑得很明:"need to use the ULP if we have buttons that are active low" 与 "can use ext1 for buttons that are active high"。第 4 章里 EPDiy V6 板选择高电平有效,正是因为它低电平会在深睡眠里被误唤醒。

EXT1 路线(高有效)是最省事的一种:setup_deep_sleep 把三个引脚用 rtc_gpio_init + rtc_gpio_set_direction(RTC_GPIO_MODE_INPUT_ONLY) 配置成输入,再用 rtc_gpio_pulldown_en 把它们静态拉到低,最后 esp_sleep_enable_ext1_wakeup((1ULL << gpio_up) | (1ULL << gpio_down) | (1ULL << gpio_select), ESP_EXT1_WAKEUP_ANY_HIGH)——任一按键按下变高就唤醒。醒来后,did_wake_from_deep_sleep() 通过 esp_sleep_get_wakeup_cause() == ESP_SLEEP_WAKEUP_EXT1 确认身份,get_deep_sleep_action()esp_sleep_get_ext1_wakeup_status() 读回位掩码,一一比对 UP/DOWN/SELECT 返回对应动作。全程零协处理器、零轮询。

ULP 路线(低有效)要动真格。ULP 是 ESP32(经典款)里一颗超低功耗协处理器,主 CPU 深睡眠时它还能跑一段极简汇编程序——注意经典款 ESP32 的 ULP 是专用的 FSM 协处理器,不是 RISC-V 核心(RISC-V 版 ULP 是 S2/S3 时代的事,本项目面向 esp32dev)。构建上,src/CMakeLists.txtulp_embed_binary(ulp_main "../ulp/main.S" "main.cpp")ulp/main.S 编译成二进制嵌进固件,并自动生成 ulp_main.h 暴露变量与入口。运行时,setup_deep_sleepulp_load_binary 载入程序、把三个 mask 写进 ulp_up_mask / ulp_down_mask / ulp_select_mask(都是 1 << rtc_io_number_get(gpio_x))、ulp_set_wakeup_period(0, 100 * 1000) 让它每 100 ms 醒来采样一次 GPIO,最后 ulp_run(&ulp_entry - RTC_SLOW_MEM) 启动。醒来后 did_wake_from_deep_sleep()ESP_SLEEP_WAKEUP_ULPget_deep_sleep_action() 则读 ulp_gpio_status & UINT16_MAX——这个值就是 ULP 汇编在 wakeup: 段存下的"哪只引脚叫的床"。

最后读一遍 ulp/main.S 本身,它是全书唯一的汇编,但逻辑并不复杂。entry:READ_RTC_REGRTC_GPIO_IN_REG低 16 位,分别与 up_maskdown_maskselect_mask 求与,命中(求与为 0,即某低有效按键被按下)就跳 wakeup:;否则 halt 等下个 100 ms。在 wakeup: 里,ST r1, r0, 0 把引脚掩码存进 gpio_status,然后用 STAGE_INC 加一个计数器、循环读 RTC_CNTL_RDY_FOR_WAKEUP 忙等 SoC 就绪(超过 10 次就 exit 放弃),就绪后一条 wake 把主 CPU 叫醒。源码里还留了一句诚实的注脚:它只读了 GPIO 的低 16 位,"if you need the higher 16 bits then you'll need to adjust this"——又是一个"注释如实交代局限"的标本。

/* GPIOButtonControls.cpp:setup_deep_sleep 的两条唤醒路线(节选) */
void GPIOButtonControls::setup_deep_sleep()
{
  if (active_level == 0) {            // 低有效 → ULP
    rtc_gpio_init(gpio_up);  rtc_gpio_set_direction(gpio_up, RTC_GPIO_MODE_INPUT_ONLY);
    rtc_gpio_pullup_en(gpio_up);        // 静态高,按下拉低
    // ... down / select 相同处理 ...
    ulp_load_binary(0, ulp_main_bin_start, (ulp_main_bin_end - ulp_main_bin_start) / sizeof(uint32_t));
    ulp_up_mask = 1 << rtc_io_number_get(gpio_up);
    ulp_down_mask = 1 << rtc_io_number_get(gpio_down);
    ulp_select_mask = 1 << rtc_io_number_get(gpio_select);
    ulp_set_wakeup_period(0, 100 * 1000);       // 每 100 ms 采样一次
    ulp_run(&ulp_entry - RTC_SLOW_MEM);
  } else {                      // 高有效 → EXT1
    rtc_gpio_init(gpio_up);  rtc_gpio_set_direction(gpio_up, RTC_GPIO_MODE_INPUT_ONLY);
    rtc_gpio_pulldown_en(gpio_up);        // 静态低,按下拉高
    // ... down / select 相同处理 ...
    esp_sleep_enable_ext1_wakeup(
        (1ULL << gpio_up) | (1ULL << gpio_down) | (1ULL << gpio_select),
        ESP_EXT1_WAKEUP_ANY_HIGH);
  }
}
; ulp/main.S:低有效按键的看门程序(节选)
entry:
    STAGE_RST                          ; 清空计数器
    ; 读 RTC GPIO 输入的低 16 位
    READ_RTC_REG (RTC_GPIO_IN_REG, RTC_GPIO_IN_NEXT_S, 16)
    move r2, r0
    move r1, up_mask     ; ld r1, r1, 0
    and r0, r0, r1
    jump wakeup, eq      ; UP 被按下(读 0)→ 唤醒
    move r0, r2
    move r1, down_mask
    ld r1, r1, 0
    and r0, r0, r1
    jump wakeup, eq      ; DOWN 被按下
    move r0, r2
    move r1, select_mask
    ld r1, r1, 0
    and r0, r0, r1
    jump wakeup, eq      ; SELECT 被按下
    halt                 ; 没人按,等下一个 100 ms

wakeup:
    ; r1 里是唤醒引脚位掩码,存进 gpio_status 供主 CPU 读取
    MOVE r0, gpio_status
    ST r1, r0, 0
try_wakeup:              ; 轮询 SoC 是否就绪,最多 10 次
    STAGE_INC  1
    JUMPS exit, 10, GT
    READ_RTC_FIELD(RTC_CNTL_LOW_POWER_ST_REG, RTC_CNTL_RDY_FOR_WAKEUP)
    AND r0, r0, 1
    JUMP try_wakeup, eq
    wake                 ; 主 CPU 醒!
exit:
    halt
/* GPIOButtonControls.cpp:醒来后,判身份、认按键(节选) */
bool GPIOButtonControls::did_wake_from_deep_sleep()
{
  auto wake_cause = esp_sleep_get_wakeup_cause();
  if (active_level == 0 && wake_cause == ESP_SLEEP_WAKEUP_ULP)   return true;
  if (active_level == 1 && wake_cause == ESP_SLEEP_WAKEUP_EXT1) return true;
  return false;
}
UIAction GPIOButtonControls::get_deep_sleep_action()
{
  if (active_level == 0) {
    uint16_t rtc_pin = ulp_gpio_status & UINT16_MAX;   // ULP 存下的掩码
    if (rtc_pin & (1 << rtc_io_number_get(gpio_up)))      return UIAction::UP;
    else if (rtc_pin & (1 << rtc_io_number_get(gpio_down)))  return UIAction::DOWN;
    else                                              return UIAction::SELECT;
  } else {
    uint64_t ext1 = esp_sleep_get_ext1_wakeup_status();     // EXT1 位掩码
    if (ext1 & (1ULL << gpio_up))      return UIAction::UP;
    else if (ext1 & (1ULL << gpio_down))  return UIAction::DOWN;
    else                               return UIAction::SELECT;
  }
}
维度ULP 唤醒EXT1 唤醒
适用极性按下为低电平(active-low)按下为高电平(active-high)
原理ULP 协处理器每 100 ms 采样 GPIORTC 域 GPIO 由低变高直接触发
关键调用ulp_load_binary + ulp_set_wakeup_period + ulp_runesp_sleep_enable_ext1_wakeup(..., ANY_HIGH)
醒来读什么ulp_gpio_status(ULP 写入的掩码)esp_sleep_get_ext1_wakeup_status()
额外成本一条汇编程序 + 100ms 轮询周期零软件,纯硬件
包青天提示

唤醒这件事,本质是"谁在你睡着时替你守门"。EXT1 是硬件门铃——零软件、零轮询,但它只认"信号变高"这一种敲门声,所以只伺候高有效按键。ULP 是会守门的小人——能跑汇编、能认低有效,但得每 100 ms 醒来巡逻一次,还只能蹲在 RTC 域里。选门铃还是选门卫,一看按键极性,二看你愿不愿意写那几行汇编。

注意

ULP 与 EXT1 都走 RTC 域,因此按键必须接在带 RTC 能力的 GPIO 上(ESP32 经典款的 0、2、4、12–15、25–27、32–39 等,三块板用的 34/35/37/38/39 全都在列)。若把按键接到 GPIO 16/17 这类非 RTC 引脚,深睡眠里它们既不能被 ULP 采样,也不能触发 EXT1——唤醒方案就得整个重设计。

15.4 电量与电池电压采样

低功耗的最后一环,是知道自己还剩多少电——这要靠 ADC(说人话:把模拟电压读数转成数字值的模块)来做电压采样(说人话:量电池电压)。电池在 README 里是可选项,代码也处处给它留了"不存在的余地":Board::get_battery()platformio.ini 定义了 BATTERY_ADC_CHANNEL 时才 new ADCBattery(channel),否则返回 nullptr——主程序里 battery 为空就直接跳过采样和绘制。三块板的通道各不相同:lilygo_t5_47ADC1_CHANNEL_0(接 GPIO_NUM_36)、epdiyADC1_CHANNEL_6(接 GPIO_NUM_34)、m5_paperADC1_CHANNEL_7(接 GPIO_NUM_35)——都是"分压电阻接入的 ADC 通道",README 在每个宏注释里写明了对应的 GPIO。

采样本身在 ADCBattery 里。初始化三件套缺一不可:adc1_config_width(ADC_WIDTH_12Bit) 定 12 位分辨率;adc1_config_channel_atten(m_adc_channel, ADC_ATTEN_DB_11) 用 11 dB 衰减把量程撑高,好让 3.5–4.2 V 的电池电压落在可测范围;esp_adc_cal_characterize(ADC_UNIT_1, ADC_ATTEN_DB_11, ADC_WIDTH_BIT_12, 1100, &m_adc_chars) 做 ADC 校准。读取时,get_voltage()adc1_get_raw 拿原始读数,经 esp_adc_cal_raw_to_voltage 换成毫伏,再乘 2 还原分压前的真实电压。百分比则按锂电的放电曲线算:>= 4.20 V 记 100%、<= 3.50 V 记 0%,中间用一条四次多项式拟合(源码注释还诚实地标了灵感来源——一个 GitHub issue)。

电量怎么上屏,看 main.cppdraw_battery_level():先 renderer->set_margin_top(0) 清掉顶部边距,画一个 40×20 的电池框——fill_rect 白色底、黑色按百分比填充、draw_rect 描边,左侧再画 4px 宽的小帽,画完把 margin_top 恢复成 35(这是给电量条预留的高度)。有趣的是,函数头顶还挂着 // TODO - add the battery level 的注释——它其实早已实现,又是一个"注释滞后于代码"的活标本。而刷新时机与 15.1 呼应:主循环每 60 秒超时都会调一次 get_voltage() / get_percentage() + draw_battery_level() + flush_display(),即使你什么都没按,电量条也会每分钟更新一次——这就是"等待期每 60 秒眨一次眼"的具体内容。

/* ADCBattery.h:电压采样 + 百分比拟合(节选) */
virtual void setup()
{
  adc1_config_width(ADC_WIDTH_12Bit);
  adc1_config_channel_atten(m_adc_channel, ADC_ATTEN_DB_11);
  esp_adc_cal_characterize(ADC_UNIT_1, ADC_ATTEN_DB_11, ADC_WIDTH_BIT_12, 1100, &m_adc_chars);
}
virtual float get_voltage()
{
  auto adc_value = adc1_get_raw(m_adc_channel);
  auto voltage = 2 * esp_adc_cal_raw_to_voltage(adc_value, &m_adc_chars); // ×2 还原分压
  return voltage;
}
int get_percentage()
{
  auto voltage = get_voltage() / 1000.0f;
  if (voltage >= 4.20) return 100;
  if (voltage <= 3.50) return 0;
  return roundf(2836.9625 * pow(voltage, 4) - 43987.4889 * pow(voltage, 3)
              + 255233.8134 * pow(voltage, 2) - 656689.7123 * voltage + 632041.7303);
}
; platformio.ini:三块板各自的电池 ADC 通道(节选)
[env:lilygo_t5_47]
; the adc channel ... battery voltage divider - this is GPIO_NUM_36
-DBATTERY_ADC_CHANNEL=ADC1_CHANNEL_0
[env:epdiy]
; this is GPIO_NUM_34 in EPDiy V6
-DBATTERY_ADC_CHANNEL=ADC1_CHANNEL_6
[env:m5_paper]
; this is GPIO_NUM_35
-DBATTERY_ADC_CHANNEL=ADC1_CHANNEL_7

/* Board.cpp:定义了 BATTERY_ADC_CHANNEL 才创建电池对象 */
Battery *Board::get_battery()
{
#ifdef BATTERY_ADC_CHANNEL
  return new ADCBattery(BATTERY_ADC_CHANNEL);
#else
  return nullptr;          // 主程序据此跳过电量采样与绘制
#endif
}
/* main.cpp:draw_battery_level——画 40×20 的电量图标(节选) */
// TODO - add the battery level   ← 注释滞后:函数其实早已实现
void draw_battery_level(Renderer *renderer, float voltage, float percentage)
{
  renderer->set_margin_top(0);          // 清边距好定位
  int width = 40, height = 20, margin_right = 5, margin_top = 10;
  int xpos = renderer->get_page_width() - width - margin_right;
  int ypos = margin_top;
  int percent_width = width * percentage / 100;
  renderer->fill_rect(xpos, ypos, width, height, 255);                        // 白底
  renderer->fill_rect(xpos + width - percent_width, ypos, percent_width, height, 0); // 黑色填充
  renderer->draw_rect(xpos, ypos, width, height, 0);                          // 描边
  renderer->fill_rect(xpos - 4, ypos + height / 4, 4, height / 2, 0);      // 电池小帽
  renderer->set_margin_top(35);       // 恢复给电量条预留的高度
}
板子ADC 通道对应 GPIO
LilyGo T5 4.7"BATTERY_ADC_CHANNELADC1_CHANNEL_0GPIO_NUM_36
EPDiy V6BATTERY_ADC_CHANNELADC1_CHANNEL_6GPIO_NUM_34
M5 PaperBATTERY_ADC_CHANNELADC1_CHANNEL_7GPIO_NUM_35
包青天提示

采样电池,三件套别省:衰减、校准、单位。ESP32 内置 ADC 的线性度一般,直接把 adc1_get_raw 当电压会骗你;esp_adc_cal_characterize 里的 1100 是默认参考电压(mV),真讲究要自己标定。还有分压系数——get_voltage 里那个 2 * 正是分压电阻的比例,换板子改电阻就改这里。最后记住:锂电不是线性耗尽的,3.5–4.2 V 之间用多项式拟合百分比,比"按电压线性算"靠谱得多。

章末练习

练习 1:读代码找真值 入门

打开 src/main.cpp 主循环,回答:深睡眠前的等待超时是 120 * 1000 * 1000,单位是什么?换算成秒是多少?README「Deep sleep」一节写的是多少秒?为什么遇到这种出入应该以代码为准?

提示

esp_timer_get_time() 的返回值单位是微秒;对照 README 原文 "go to sleep after 30 seconds of inactivity"。

参考答案

esp_timer_get_time() 返回微秒,所以 120 * 1000 * 1000 微秒 = 120 秒。README 写的是 30 秒——文档与代码漂移。以代码为准是因为:编译器、硬件、行为都以源码为唯一事实来源,文档可能没跟上代码演进;本教程各章引用的数值也统一以源码为准。

练习 2:给状态分配"床位" 进阶

以下四类状态各应放哪里(文件系统 / RTC 内存)?给出理由:(a) 当前屏幕帧缓冲;(b) 书单 + 每本书进度;(c) 目录选中项;(d) 当前 UI 状态。分别指出源码里对应的变量或文件。

提示

按"多大 + 多长寿"分类:帧缓冲几十 KB 且一次性;其余三个都很小但要跨睡眠存活。

参考答案

(a) 文件系统——入睡前 dehydrate() 压成 /fs/front_buffer.z,醒来 hydrate() 读回;(b) RTC 内存——RTC_DATA_ATTRepub_list_stateEpubReader 持有 EpubListItem &state 引用,翻页即改 RTC;(c) RTC 内存——RTC_DATA_ATTRepub_index_state;(d) RTC 内存——RTC_NOINIT_ATTRui_state。核心:帧缓冲太大进不了 RTC(慢速 RAM 仅约 8 KB),又只需"睡前一写、醒来一读",所以走文件系统;其余小且长期,交给一直供电的 RTC 域。

练习 3:唤醒二选一 进阶

某板按键按下为高电平。回答:(1) setup_deep_sleep() 该走 ULP 还是 EXT1 分支?给出关键调用;(2) 为什么按下为低电平的按键不能直接用 EXT1?

提示

EXT1 是"变高才触发";低有效按键平时是高、按下变低,方向反了。看 GPIOButtonControls.cpp 的 else 分支。

参考答案

(1) 走 EXT1 分支:rtc_gpio_init + rtc_gpio_set_direction(INPUT_ONLY) + rtc_gpio_pulldown_en(静态拉低),再 esp_sleep_enable_ext1_wakeup((1ULL<<up)|(1ULL<<down)|(1ULL<<select), ESP_EXT1_WAKEUP_ANY_HIGH);(2) EXT1 的 ANY_HIGH 只在 GPIO 变高时唤醒,而低有效按键被上拉维持在高、按下才拉低——这个"由高变低"的方向 EXT1 认不出来,所以只能靠 ULP 协处理器每 100 ms 采样、发现某脚读 0(被按下)后再 wake

练习 4:审 ULP 汇编 挑战

逐段解释 ulp/main.S(1) entry 段做了哪几步、判断命中后跳到哪?(2) wakeup 段把什么存进 gpio_status,为什么?(3) try_wakeupSTAGE_INC + JUMPS exit, 10, GT 在干什么?(4) 三个 mask(ulp_up_mask 等)在 GPIOButtonControls.cpp 里是怎么算出来的?

提示

mask = 1 << rtc_io_number_get(gpio_x);STAGE 是忙等计数器;gpio_status 被主 CPU 读走当"哪个键"。

参考答案

(1) STAGE_RST 清计数器后,READ_RTC_REG 读 RTC GPIO 输入的低 16 位,依次与 up_mask/down_mask/select_mask 求与;某个掩码命中(求与为 0,表示该低有效键被按下)就跳 wakeup,全不命中就 halt 等下一个 100 ms 周期。(2) 在 wakeup 段把"唤醒引脚位掩码"(r1)写进 gpio_status,因为主 CPU 醒来后要靠它知道"按的是哪个键"——对应 get_deep_sleep_action() 里读 ulp_gpio_status & UINT16_MAX。(3) 这是忙等 SoC 就绪:每次轮询 STAGE_INC 1,若阶段计数超过 10 就 exit 放弃(说明 SoC 还没准备好被唤醒),否则读 RTC_CNTL_RDY_FOR_WAKEUP,为 0 继续等、为 1 才执行 wake。(4) 三个 mask 在 setup_deep_sleep() 的 ULP 分支里,是 1 << rtc_io_number_get(gpio_up / gpio_down / gpio_select)——把每个按钮的 GPIO 号换算成 RTC 引脚号后移位得到位掩码,ULP 汇编正是拿这些掩码去比对 GPIO 输入寄存器。