先把这块"会定格的屏幕"摸透——原理、配置与刷新机制
核心方法论:工欲善其事,必先利其器
「一块料子值不值得雕,老木匠上手一摸、掂一掂就知道——密度、纹理、脾气全在手里。这一章我们就来'摸'那块电子墨水屏和它背后的驱动库 EPDiy:它为什么能断电定格,为什么刷新这么慢,什么时候该全刷、什么时候该局部刷。脾气摸透了,后面动起刀来才顺手。」
电子墨水屏的像素,本质是一粒粒微胶囊(microcapsule):胶囊里悬浮着带负电的黑色微粒和带正电的白色微粒,浸泡在透明液体里。对每个像素施加一个电场,就把对应颜色的粒子"推"到胶囊朝上的一面,从而呈现黑、白或中间灰。关键在电场撤掉之后:粒子并不会乱跑,而是被静电与介质黏住,稳稳停在原处——这就是双稳态(bistable)。只要不重新施加电场,画面就永远保持,连维持供电都不需要。
由此可以推出三个工程特性。第一,反射式显示:屏幕本身不发光,靠环境光成像,无背光、无频闪,长时间看也接近纸张的观感。第二,静态零功耗:画面定格后不再耗电,只有重新刷新那一下才消耗能量——这正是 1.1 里"一页能停几个星期"的物理根源。第三,刷新慢:要重新驱动胶囊里的粒子运动,全屏刷新需要数百毫秒,切换的瞬间画面还会闪一下。刷新并非瞬间完成——屏幕的显示控制器要按一套精确的时序(也就是波形),把一串串电压脉冲依次加在墨粒上,才能把它们翻到目标灰度。把这三个特性对照"阅读"这个场景,结论很清晰:反射式与零功耗被放大成巨大优势,刷新慢的缺点则被"翻页才刷新"的使用方式完全掩盖。
正因为"刷新慢 + 双稳态",电子墨水屏的编程模型和 LCD 完全不同——不是"每一帧重绘画面",而是"把整页画进帧缓冲 → 一次性刷上屏 → 定格"。所谓帧缓冲(framebuffer),说白了就是一块内存:先把整页内容画好,再一次整屏送显。这也是全书反复出现的代码结构:绘制可以调用很多次,但"上屏"只在需要的时候调用一次。项目自己的 Renderer 抽象把这两件事拆开了——Renderer.h 里 flush_display() / flush_area() 就是那道"上屏"的闸门,默认空实现,由各屏幕驱动覆写。
/* 电子墨水屏的工作节奏:绘制可以很多次,上屏只此一回 */
应用代码
│ 逐词、逐图地调用 draw_text / show_img / fill_rect ……(画进 m_frame_buffer)
▼
帧缓冲 m_frame_buffer(EPD_WIDTH × EPD_HEIGHT / 2 字节,每像素 4 位)
│ 需要让用户看到时,才调用一次
▼
flush_display() / flush_area() ──► 墨水流向屏面
│
画面定格,断电也保持
/* Renderer.h(节选):绘制与上屏是两个动作 */
virtual void clear_screen() = 0;
virtual void flush_display(){}; // 全屏刷新(默认空实现,由驱动覆写)
virtual void flush_area(int x, int y, int width, int height){}; // 局部刷新
uint8_t temperature = 20; // 温度(℃)——墨水响应随温度变化,刷新波形要补偿
| 特性 | 原理 | 对阅读器的影响 |
|---|---|---|
| 反射式显示 | 屏幕不发光,借环境光成像 | 无背光、无频闪,接近纸张 |
| 双稳态 | 断电后像素保持原状 | 静态零功耗,一页可停几周 |
| 刷新慢 | 全刷需数百毫秒 | 翻页有等待;无法播放视频或流畅滚动 |
| 部分刷新 | 可只刷新一个矩形区域 | 小区域更新快;但会积累残影 |
双稳态省的是"定格"的能耗,不是"刷新"的能耗——刷新那一瞬间反而要短暂大电流去驱动所有胶囊。所以电子墨水屏的低功耗策略永远围绕一件事:尽量减少刷新次数。深睡眠、局部刷新、只在必要时报电量,都是这个原则的体现。
EPDiy 是 vroland/epdiy(GitHub)上的开源电子墨水屏驱动库,专门面向 ESP32。它最与众不同的点是并行驱动:普通 SPI 屏一次只推 1 bit 串行位流,而 EPDiy 把 e-paper 的数据总线接到 8 个甚至更多 GPIO 上,一次并行写入一个完整的字节,配合 DMA 与精心调校的波形,刷新速度比串行方案快得多——这正是 README 点名"EPDiy based parallel e-Papers"的原因。
这个仓库实际用的,是 EPDiy 的一个 fork。lib/epdiy 是一个 git 子模块,.gitmodules 里指向 martinberlin/epdiy-rotation(上游是 vroland/epdiy)。2.4 节强调过:克隆必须加 --recursive,否则 lib/epdiy 就是个空目录,编译在 include 第一步就失败。
重要的是,应用代码不直接调 epd_* 函数。项目在 lib/Epub/Renderer/ 里封装了一层:EpdiyRenderer 和 M5PaperRenderer 都继承自 EpdiyFrameBufferRenderer(再往上继承项目自己的 Renderer 抽象),把 epd_init、epd_hl_update_screen 这些底层调用包装成 draw_text、flush_display 之类的接口。也就是说:画文字、画图的逻辑是共享的,只有"把缓冲刷到屏上"这一步因驱动而异。这个分层在第 4 章会被抽象成板级接口,这里先记住它的存在。
; .gitmodules:lib/epdiy 是 EPDiy 的一个 fork
[submodule "lib/epdiy"]
path = lib/epdiy
url = https://github.com/martinberlin/epdiy-rotation.git ; 上游为 vroland/epdiy
/* EpdiyRenderer.h(节选):构造函数里把屏幕初始化好、拿到帧缓冲 */
epd_init(EPD_OPTIONS_DEFAULT); // 初始化 EPDiy 驱动与 GPIO
m_hl = epd_hl_init(EPD_BUILTIN_WAVEFORM); // 高层接口:使用内置波形
epd_hl_set_all_white(&m_hl); // 先把整屏清成白色
m_frame_buffer = epd_hl_get_framebuffer(&m_hl); // 拿到底层帧缓冲指针
// 注意:LilyGo T5 4.7" 的电源由 Board 层 epd_poweron() 打开,
// 这里要跳过,避免重复上电(详见第 4 章)
#ifndef CONFIG_EPD_BOARD_REVISION_LILYGO_T5_47
epd_poweron();
#endif
区分"串行 SPI"与"并行总线"是读懂 EPDiy 的关键。串行方案省引脚(4~6 根),并行方案费引脚(十几根 GPIO 全被屏幕占用),但吞吐量不是一个量级。这也是为什么 4.7" 全屏带灰度刷新能压到几百毫秒级别——数据总线宽,一次能倒进去一整个字节。
先纠正一个容易先入为主的直觉:屏幕型号与板卡修订的配置不在 sdkconfig 里。用 grep 在 sdkconfig.epdiy 里找 CONFIG_EPD,只会得到空结果——那份文件是 ESP-IDF 自己的配置(LwIP、WiFi、Flash 分区等)。EPDiy 的屏幕配置全部由 platformio.ini 的 -D 编译宏喂进去:这些 CONFIG_EPD_* 名字长得像 Kconfig 生成的宏,但在本项目里就是普通的预处理宏,供 EPDiy 源码里的 #ifdef 条件编译判断该用哪套波形与像素参数。
三套 env 的具体取值(以代码为准):[env:lilygo_t5_47] 与 [env:m5_paper] 都用 CONFIG_EPD_DISPLAY_TYPE_ED047TC2(4.7 英寸)+ CONFIG_EPD_BOARD_REVISION_LILYGO_T5_47;[env:epdiy] 用 CONFIG_EPD_DISPLAY_TYPE_ED060XC3(6 英寸)+ CONFIG_EPD_BOARD_REVISION_V6,还多一个 CONFIG_EPD_DRIVER_V6_VCOM=1580(VCOM 电压,毫伏)。型号命名有规律:ED 后面三位数字是英寸数(047=4.7",060=6"),后缀字母是具体模组;BOARD_REVISION 则决定 GPIO 布局、电源时序等板级参数。
这里藏着一个教科书级的文档与代码漂移案例:README 的 "Porting to other boards" 示例写的是 -DCONFIG_EPD_DISPLAY_TYPE_ED047TC1,但 platformio.ini 里实际是 ED047TC2。TC1 与 TC2 不是同一款模组,波形与像素参数有差异——若照 README 移植一块新板,很可能"屏幕显示错乱"却在代码里找不到任何原因。这就是全书反复强调"以代码为准"的现实意义。顺带一个彩蛋:宏名 BOARD_TYPE_LILIGO_T5_47(LILIGO,少了一个 Y)也是照抄源码的拼写,#ifdef BOARD_TYPE_LILIGO_T5_47 照样匹配得上——只要 define 与使用处拼写一致,编译器才不会管你名字对不对。
; platformio.ini:屏幕型号与板卡修订,全部用 -D 编译宏传入
; [env:lilygo_t5_47] 与 [env:m5_paper](4.7" 屏)
-DCONFIG_EPD_DISPLAY_TYPE_ED047TC2
-DCONFIG_EPD_BOARD_REVISION_LILYGO_T5_47
; [env:epdiy](6" 屏 + V6 板卡)
-DCONFIG_EPD_DISPLAY_TYPE_ED060XC3
-DCONFIG_EPD_BOARD_REVISION_V6
-DCONFIG_EPD_DRIVER_V6_VCOM=1580 ; VCOM 电压,单位 mV
# README "Porting to other boards" 里的示例(注意型号)
-DCONFIG_EPD_DISPLAY_TYPE_ED047TC1 ; ← README 写的
# platformio.ini 里实际生效的是 ED047TC2 —— 永远以代码为准
-DCONFIG_EPD_DISPLAY_TYPE_ED047TC2 ; ← 代码里真的
| 环境 | 屏幕型号 | 板卡修订 | 尺寸 | 备注 |
|---|---|---|---|---|
[env:lilygo_t5_47] | ED047TC2 | LILYGO_T5_47 | 4.7" | default_envs |
[env:m5_paper] | ED047TC2 | LILYGO_T5_47 | 4.7" | 复用 T5 屏幕定义,只用 epdiy 的绘制代码 |
[env:epdiy] | ED060XC3 | V6 | 6" | 额外 VCOM=1580(mV) |
换屏幕 ≠ 改 sdkconfig。要同时核对 CONFIG_EPD_DISPLAY_TYPE_*(型号)与 CONFIG_EPD_BOARD_REVISION_*(板卡修订)两个宏,改完重新编译。只改型号不改修订,GPIO 与电源时序对不上,一样会花屏。
EPDiy 刷新的核心逻辑浓缩在 EpdiyRenderer::flush_display() 的一行里:epd_hl_update_screen(&m_hl, needs_gray_flush ? MODE_GC16 : MODE_DU, temperature)。这里有两个刷新模式。MODE_DU(Direct Update,直接更新)快,适合黑白文字翻页,代价是会产生残影(ghosting)——上一张画面的影子会残留,翻多几页尤其明显。MODE_GC16 是 16 级灰度全刷新,慢但干净,能把残影彻底清掉。二者怎么选?靠一个布尔标志 needs_gray_flush 自动判断。
这个标志的置位逻辑在 EpdiyFrameBufferRenderer::needs_gray(color) 里:只要画进去的像素 color 不是 0(纯黑)也不是 255(纯白),就置位 needs_gray_flush = true。换句话说,"我画了一个中间灰"就等于向渲染器承诺:下一次全刷必须走 GC16,因为 DU 只支持二值。项目的字体是两色压缩的,正常文字不会触发;但抗锯齿的图标、图片缩略图这类带灰度的内容会。所以这是一个藏在绘制层、由数据自动驱动的决策——应用代码只需要"画灰像素时打一个标记"。
再看局部刷新——说人话:全刷把整块屏一次性重刷,慢但干净;局部刷新只重画屏上的一小块,快,但会留残影。flush_area(x, y, w, h) 用 epd_hl_update_area 只刷新屏上一个矩形区域,且固定使用 MODE_DU——因为 DU 是唯一支持"部分区域"的模式。沙漏图标(show_busy())、电池电量、触摸反馈这些小块更新都用它,避免整屏闪烁。但局部刷的残影会随次数累积,所以积累到一定程度仍需一次 GC16 或 epd_fullclear() 来清屏。temperature 参数贯穿始终:墨水响应速度随温度变化,波形需要按温度补偿(Renderer 里默认 temperature = 20)。
/* EpdiyRenderer.h(节选):全刷与局部刷,一个在速度与干净之间权衡 */
void flush_display()
{
epd_hl_update_screen(&m_hl,
needs_gray_flush ? MODE_GC16 : MODE_DU, // 有灰度 → GC16 全刷;纯黑白 → DU 快刷
temperature);
needs_gray_flush = false;
}
void flush_area(int x, int y, int width, int height)
{
epd_hl_update_area(&m_hl, MODE_DU, temperature, // 局部刷只用 DU
{.x = x, .y = y, .width = width, .height = height});
}
virtual void reset()
{
epd_fullclear(&m_hl, temperature); // 彻底清屏,消除所有残影
}
/* EpdiyFrameBufferRenderer.h(节选):灰度标记与清屏 */
void needs_gray(uint8_t color)
{
if (color != 0 && color != 255) // 非纯黑白,就要求下一刷用 GC16
needs_gray_flush = true;
}
virtual void clear_screen()
{
memset(m_frame_buffer, 0xFF, EPD_WIDTH * EPD_HEIGHT / 2);
// 4bpp:每字节 2 个像素,0xFF = 两个白像素
}
看到 EPD_WIDTH * EPD_HEIGHT / 2 就想到"每像素 4 位、一字节装俩像素"。4.7" 屏(960×540)的帧缓冲约 960×540/2 ≈ 259 kB,再加解压缓冲,这就是为什么 README 把 PSRAM 列为硬性要求——内部 SRAM 只有约 520 kB,根本兜不住。
用"特性→后果"的句式,写出双稳态给阅读器带来的三条好处;再解释:为什么"画面定格时不耗电",而"刷新那一瞬间"反而最耗电?
从"粒子被黏住"这个物理机制出发;刷新意味着要重新驱动几百万个胶囊运动。
双稳态 → 断电后像素保持 → 静态零功耗,一页可以停几周;双稳态 → 不需要背光维持 → 反射式、无频闪、护眼;双稳态 → 画面长期不变 → 配合深睡眠可做成便携低功耗设备。而刷新要重新给所有胶囊施加电场、驱动带电粒子运动,整屏同时翻转,瞬时电流很大——所以耗电集中在"翻页那一下"。
打开 lib/Epub/Renderer/EpdiyRenderer.h 与 EpdiyFrameBufferRenderer.h,回答:(a) needs_gray_flush 在哪些地方会被置位?(b) show_busy()(沙漏图标)为什么主动置位它?(c) 局部刷新 flush_area() 固定用哪个模式?
注意 needs_gray() 的入参判断,以及 show_busy() 里画图用的透明色 0xE0 是不是纯黑白。
(a) 每当 needs_gray(color) 收到非 0、非 255 的颜色时置位;另外 show_busy() 也会显式置位。(b) 沙漏图标用 0xE0 这种中间灰度绘制,是"非纯黑白",所以必须用 GC16 才能正确显示灰阶。(c) flush_area() 固定用 MODE_DU——DU 是唯一支持部分区域更新的模式,代价是残影。
README 的移植示例写 ED047TC1,而 platformio.ini 实际是 ED047TC2。(a) 假如你照 README 给新板配宏,最可能看到什么症状?(b) 用一条命令核对当前 env 到底用了哪个型号?
型号决定波形与像素参数;grep 只搜 platformio.ini 和 README 即可。
(a) 屏幕刷新异常——波形参数不匹配会导致对比度不对、残影严重甚至画面错乱,而代码本身看不出任何问题,因为宏的值是"对的那一侧"在漂。(b) grep -rn "EPD_DISPLAY_TYPE" platformio.ini README.md,看到 platformio.ini 里是 ED047TC2,README 里是 ED047TC1——以 platformio.ini 为准。
翻页时整页走 DU 快刷,但页码这一小块想用 flush_area() 单独更新,避免整屏闪烁。请设计这套策略:何时能用 DU 局部刷、何时必须回到 GC16/全清,并解释为什么反复局部刷之后残影会越来越明显。
DU 的原理是"只搬动该区域的粒子",但边界处粒子可能没完全归位;想想页码区域的颜色构成。
策略要点:(1) 页码区若保持纯黑白,可长期用 flush_area() + DU;(2) 每次局部刷都会在区域边缘留下少量未完全翻转的粒子,多次累积就是可见残影,所以每隔若干次、或每次全页翻页前做一次 GC16 或 epd_fullclear() 归零;(3) 一旦该区域要画灰度内容(如电池图标),needs_gray 会强制下一次全刷走 GC16。总之,把"快"留给高频小区域,把"干净"留给低频的整页与清屏。