铁面无私:别让任何一粒电流,在该睡的时候还醒着
核心方法论:铁面无私,以证据定案
「本官断案,讲的是'铁面无私'四个字——证据面前,没有侥幸。功耗也是一样:每一微安的电流都得有出处,每一毫秒的清醒都得有交代。电子墨水屏号称'静态零功耗',可若主控还在偷电,这块招牌就是空话。这一章,我们就来当一回青天大老爷,把谁该睡、谁该醒、谁在深睡眠里还撑着最后一盏灯,一件一件审个明白。」
先从物理说起:电子墨水屏是双稳态的,画面定格后屏幕本身不再耗电——但屏幕不耗电,不代表整台设备不耗电。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 * 1000。esp_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。本教程也以代码值为准。
要跨过深睡眠活下来的状态有两类,各自的"性格"完全不同,存放位置也随之不同。屏幕帧缓冲(约 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_section、current_page、pages_in_current_section 三个字段,而 EpubReader 持有的是 EpubListItem &state 的引用——翻页时改的本来就是 RTC 内存,天然持久,根本不需要"保存"动作。
醒来之后怎么接上?main.cpp 用 button_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_ATTR 的 epub_list_state | 小、长期,要活到下次开机 |
| 目录选中项 | RTC_DATA_ATTR 的 epub_index_state | 小、长期,目录导航时更新 |
| 当前 UI 状态 | RTC_NOINIT_ATTR 的 ui_state | 决定醒来后走哪个 handler |
审状态,先按三个维度分类:多大、要多长寿、多常访问。大而一次性的(帧缓冲)→ 文件系统;小而长期的(进度)→ RTC 内存;千万别把会跨睡眠活下来的东西放进普通 RAM——一掉电就成冤案。把这个"记忆分布图"画出来,你就能给自己未来的嵌入式项目设计持久化方案。
睡了,还得能被叫醒。要叫醒睡眠中的芯片,得靠一个唤醒源(说人话:把芯片从睡眠叫醒的事件来源,比如按下按键)。项目的答案取决于一个硬件事实——按键的电平极性。按下为低电平的按键,平时靠上拉电阻维持在高电平,按下才拉到 0;而 esp_sleep_enable_ext1_wakeup 的 EXT1(说人话:一种由多个 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.txt 用 ulp_embed_binary(ulp_main "../ulp/main.S" "main.cpp") 把 ulp/main.S 编译成二进制嵌进固件,并自动生成 ulp_main.h 暴露变量与入口。运行时,setup_deep_sleep 先 ulp_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_ULP,get_deep_sleep_action() 则读 ulp_gpio_status & UINT16_MAX——这个值就是 ULP 汇编在 wakeup: 段存下的"哪只引脚叫的床"。
最后读一遍 ulp/main.S 本身,它是全书唯一的汇编,但逻辑并不复杂。entry: 用 READ_RTC_REG 读 RTC_GPIO_IN_REG 的低 16 位,分别与 up_mask、down_mask、select_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 采样 GPIO | RTC 域 GPIO 由低变高直接触发 |
| 关键调用 | ulp_load_binary + ulp_set_wakeup_period + ulp_run | esp_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——唤醒方案就得整个重设计。
低功耗的最后一环,是知道自己还剩多少电——这要靠 ADC(说人话:把模拟电压读数转成数字值的模块)来做电压采样(说人话:量电池电压)。电池在 README 里是可选项,代码也处处给它留了"不存在的余地":Board::get_battery() 在 platformio.ini 定义了 BATTERY_ADC_CHANNEL 时才 new ADCBattery(channel),否则返回 nullptr——主程序里 battery 为空就直接跳过采样和绘制。三块板的通道各不相同:lilygo_t5_47 是 ADC1_CHANNEL_0(接 GPIO_NUM_36)、epdiy 是 ADC1_CHANNEL_6(接 GPIO_NUM_34)、m5_paper 是 ADC1_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.cpp 的 draw_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_CHANNEL | ADC1_CHANNEL_0 | GPIO_NUM_36 |
| EPDiy V6 | BATTERY_ADC_CHANNEL | ADC1_CHANNEL_6 | GPIO_NUM_34 |
| M5 Paper | BATTERY_ADC_CHANNEL | ADC1_CHANNEL_7 | GPIO_NUM_35 |
采样电池,三件套别省:衰减、校准、单位。ESP32 内置 ADC 的线性度一般,直接把 adc1_get_raw 当电压会骗你;esp_adc_cal_characterize 里的 1100 是默认参考电压(mV),真讲究要自己标定。还有分压系数——get_voltage 里那个 2 * 正是分压电阻的比例,换板子改电阻就改这里。最后记住:锂电不是线性耗尽的,3.5–4.2 V 之间用多项式拟合百分比,比"按电压线性算"靠谱得多。
打开 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 秒——文档与代码漂移。以代码为准是因为:编译器、硬件、行为都以源码为唯一事实来源,文档可能没跟上代码演进;本教程各章引用的数值也统一以源码为准。
以下四类状态各应放哪里(文件系统 / RTC 内存)?给出理由:(a) 当前屏幕帧缓冲;(b) 书单 + 每本书进度;(c) 目录选中项;(d) 当前 UI 状态。分别指出源码里对应的变量或文件。
按"多大 + 多长寿"分类:帧缓冲几十 KB 且一次性;其余三个都很小但要跨睡眠存活。
(a) 文件系统——入睡前 dehydrate() 压成 /fs/front_buffer.z,醒来 hydrate() 读回;(b) RTC 内存——RTC_DATA_ATTR 的 epub_list_state,EpubReader 持有 EpubListItem &state 引用,翻页即改 RTC;(c) RTC 内存——RTC_DATA_ATTR 的 epub_index_state;(d) RTC 内存——RTC_NOINIT_ATTR 的 ui_state。核心:帧缓冲太大进不了 RTC(慢速 RAM 仅约 8 KB),又只需"睡前一写、醒来一读",所以走文件系统;其余小且长期,交给一直供电的 RTC 域。
某板按键按下为高电平。回答:(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。
逐段解释 ulp/main.S:(1) entry 段做了哪几步、判断命中后跳到哪?(2) wakeup 段把什么存进 gpio_status,为什么?(3) try_wakeup 的 STAGE_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 输入寄存器。