文学时钟硬件制作 | 项目复盘

起源
闲的无聊买了个墨水屏面板。于是就开始 brainstorm 在静态内容显示能力上面,可以承载什么有趣的业务或者制作一个好玩的艺术装置。考虑到嵌入式设备不一定有随时联网的条件,可选的一种形态是随机显示一些离线的文库。比如
- 随机的 linux man 词条
- 一些经典文学作品的片段
刚好,我刷到了这么一个有意思的项目,叫做文学时钟。参考链接
这个项目最早是英国卫报发起的一个搜集活动。公众在线提交包括时间文本的文学选段,经过一段时间积累,卫报沉淀出了一个完整包含了 1440 个时间点的文学时钟文本库。这个项目的核心创意在于,每一个时间点都对应一个文学作品的片段,且这个片段中包含了该时间点的文字描述。比如 3:15 对应的文本片段中会包含 "three fifteen" 这样的字样。
比如

我觉得这个创意非常有趣,于是决定基于这个项目的文本库,结合我手头的墨水屏面板,制作一个实体的文学时钟装置。
硬件选型
基本上,手头有什么料就选什么。主要器件包括
- ESP32-S3 模块
- 微雪 2.9 寸黑白面板
里程碑 I | 跑通显示链路
其实一开始对着参考设计直接打了个板,结果没点亮。后来复盘才发现是用了下接座子,拿来金手指往上插了。还好联通,不然反了的针脚定义会导致升压高压干进 IC 烧掉。
后面升压部分单独打了个小板,额外买了个 FPC 转杜邦线来做验证。用杜邦线搭通电路之后,顺利点亮。
分析一下业务需求,可以列出几个关键功能点
- 基本框架: PIO 做构建系统, Arduino 作为开发框架
- 墨水屏驱动: GxEPD2 库, 内部依赖 Adafruit_GFX 库做绘图
- 文本库: SPIFFS 文件系统,存储另外做的二进制
- 时间获取: 用 BLE 做极致低能耗的信息传递
下面我们一个个点聊。
绘图部分其实没有太多特别的内容,Adafruit 的图形库很成熟,GxEPD2 库也封装的很好。绘图库支持显示点阵字体,内置的衬线体和加粗字体效果还不错。
我想要的效果是简单排版内容,而且表示时间的部分加粗显示。排版逻辑比较简单,按空格扫描文本内容,维护当前行宽。如果超过了排版宽度,就把最后一个空格替换为换行符。图形库会自动根据字体里的字形配置自动换行。
计算字符宽度需要对字形和绘制有一点了解。Adafruit_GFX 库里,字体是以 bitmap 的形式存储的。每个字符的字形信息存储在一个 bitmap 里,字形的元信息存储在一个 GFXfont 结构体里。字形的元信息包括字符宽度、高度、偏移量等。
为啥要有分开的概念? 一些字符显然有空白的部分。为了节约存储空间,字形 bitmap 只存储实际有内容的部分。比如空格,自己压根没有 bitmap 数据。字形的元信息里会存储这个字符的宽度。绘制的时候,会根据偏移量把字形绘制到正确的位置。
所以就有几个概念
- width: 表示 bitmap 里实际有内容的宽度
- advance: 表示绘制这个字符后,光标应该移动的距离。也就是真正字符占用的屏幕宽度
格式化显示方面,我们的文库既然是完全静态的,我们完全可以在生成文库的时候用特殊符号标记需要加粗的部分。然后在显示的时候,扫描文本内容,遇到加粗标记就切换到加粗字体。这里我们随便用了没有字形的 ASCII 控制字符 0x01 和 0x02 来表示加粗开始和结束。
文库生成这一块,我用 Python 写了一个脚本。Python 刚好对写 C 结构体很友好。脚本会把文本库解析,转化为 C 结构体数组,然后输出二进制用来烧到固件里。我的数据结构设计如下
- 索引: 每个时间点对应一个索引。定长,保存每个时间点文本在数据区的偏移和长度
- 数据区: 按顺序存储每个时间点的文本内容。文本内容用 ASCII 编码。
当然,文本内容里有一些非 ASCII 字符需要用库清洗。我用的是 unidecode 库,把非 ASCII 字符转化为近似的 ASCII 字符。
时间获取方面,我想要一个低功耗的方案。ESP32-S3 本身有 BLE 功能,可以用来做低功耗的时间同步。客户端侧,按钮可以触发 BLE 广播一个可写的特征,手机端 App 写入当前时间戳就可以了。简单又快捷。
里程碑 II | 整体产品打磨
在写里程碑 I 的文件系统部分的时候,我就觉得有点奇怪。乐鑫文档里已经表明 SPIFFS 已经较少维护,而且推荐使用 LittleFS 作为替代方案。然而,把官方的分区表实例抄下来,构建系统编译并没有过。说是配置用了不支持的类型字段。
这就很奇怪。我花了一点时间排查,最后确定: PIO 里 Arduino 框架自带的乐鑫 SDK 版本是 4.4.7, 虽然 SDK 有相关的支持,但是构建这块可能 bundle 了旧版的工具。另外,PIO 的 Arduino 框架也快一整年没更新了。
迁移到 ESP-IDF 构建系统,以及电池测量
对于跑通显示链路,SDK 老一点似乎也没什么影响。产品最终是电池供电的形态,为了提高可靠性,我决定做一个电池电压监测功能。
为什么要用 ADC 采样电池电压?作为一个由锂电池驱动的产品,电源管理是绕不开的话题。虽然锂电池通常配备有保护芯片,但它们往往在电压低至 2.x V 时才会拉闸切断电路。
然而,我们的主控 ESP32-S3 工作在 3.3V,通过 LDO 稳压供电。LDO 本身存在压降(Dropout Voltage),这意味着电池电压必须高于 3.3V 加上压降值,LDO 才能维持稳定的 3.3V 输出。一旦电池电压跌破这个临界值,虽然还没触发保护板的硬件关断,但 LDO 输出的电压已经不足以支撑主控正常工作,可能会导致设备无法启动或运行不稳定(Brownout)。
因此,通过 ADC 主动采样电池电压,可以在电压下降到危险区间(例如 3.4V 左右)之前,由软件接管进行低电量报警或主动进入休眠,避免系统处于欠压的不可控状态。
在实现电压采样的过程中,我也遇到了 ESP32 系列 ADC 的典型“坑点”。为了验证采样精度,我特意接上了可调电源进行测试。
测试发现,如果只是简单地读取 ADC 原始值(Raw Data),然后用“古法”线性映射(比如以为 0-4095 对应 0 - Vref 均匀分布)去换算电压,误差会非常大。这是因为 ESP32 内部的 ADC 并不是理想线性的,且不同芯片之间存在制造差异。
实际上,乐鑫官方在芯片出厂时都会进行校准,并将校准数据烧录在 eFuse 中。理论上,我们应该使用官方提供的校准方案(ADC Calibration Scheme)来获取精确电压。
然而,尴尬的事情又发生了。我查阅了最新的乐鑫编程指南,发现完善的 ADC 采样和自带校准 API 主要是在 ESP-IDF v5.x 版本中重构和提供的。而如前所述,PlatformIO 目前集成的 Arduino 框架仍然停留在基于 ESP-IDF v4.4.7 的旧版本。这意味着不仅新的文件系统用不了,连厂方的校准结果也无法直接调用。
所以我决定迁移到 ESP-IDF 的 CMake 构建系统。为了维持对原有库方案的兼容性,我用了第一方推荐的 Arduino as component 架构。这样既能用上最新的 SDK 功能,又能最大限度复用之前的代码。
花了一点测试了新的构建系统,还有一些问题。Adafruit 系列的库虽然自带乐鑫风味的 CMake 组件配置,但是对 Arduino 的依赖包名和实际没有对齐。而 GxEPD2 则完全没有提供 CMake 支持。为了降低项目的耦合度,我们不能以源码形式依赖这些库,而是用常规的 git submodule + CMake 导入的方式。这样,我们只能用 Cmake wrapper 的形式,在项目里重新手写一份 CMakeLists.txt 来导入这些库。
ESP-IDF 的 ADC 校准 API 使用起来相对简单。按照实例代码抄就行。经过校准之后,我手上的片子测可调电源的电压,精度能有一位多伏。完全够用。
后面验证的时候发现,用功率 NMOS 做测量开关是个完全错误的决定。功率 MOS 的特性决定了,在小电流场景下阻抗较大,导致测量值完全不可用。
墨水屏刷新模式优化
要理解刷新策略的控制,这里假设你已经知道了墨水屏的工作原理。简单来说,墨水屏通过电场控制微小胶囊内的黑白粒子移动,从而实现图像显示。刷新过程实际上是通过一系列电压脉冲来重新排列这些粒子。
刷新模式本质是控制墨水屏刷新过程中的电压脉冲序列和时序。这个序列往往在程序设计里称为 LUT. 这里介绍两种常见的刷新模式
- 全刷新: 追求质量。先通过连续、长时间的数次(往往 3-5 次)反转脉冲,彻底重置墨水屏上的所有粒子位置,消除残影。然后通过正向脉冲绘制内容,最后再次输出反相脉冲来消除任何 glow.
- 局部刷新: 追求速度。在局部刷新中,通常只对屏幕的部分区域进行更新。局部刷新往往只有一次到两次脉冲。
以上是 GxEPD2 实现的两种典型刷新波形。经过实际测量,全刷新模式的时间大约 3500ms, 而全窗口的快速刷新模式只需要 700ms. 如果能通过一定的策略控制残影,尽可能多的使用快速刷新模式,能显著缩短主控的活跃时间,达到低功耗的设计目标。
经过测试和调研,我设计的策略是这样的。
- 大部分时候,使用快速刷新。
- 每 6 分钟,在刷新前插入一次黑色帧的快速刷新,随后快速刷新内容。
- 每 30 分钟,进行一次全刷新,彻底清除残影。
实测下来,效果还不错。残影控制在可接受范围内,同时刷新时间大幅缩短。
这里顺便聊聊墨水屏上屏的链路。
支持快速刷新的主控,内部往往有两块显存区域: 一块是现在的内容,另一块是上次刷新的内容。快速刷新模式下,主控会把现在的内容和上次的内容做差分计算,只刷新有差异的像素点。当然,这部份显存管理是 MCU 侧的逻辑,通过 SPI 指令中的参数来控制写入哪一块显存。全刷新的时候,IC 一般期待两块显存内容一致,所以会把两块显存都写成一样的内容。关于这些逻辑,可以参考 GxEPD2 库的源码实现。
查阅数据手册,我用的 2.9 寸屏的主控是 SSD1680, 支持两种主要睡眠模式。核心区别在于是否保留显存内容。然而,两种模式的功耗差异仅 0.3 uA, 显存掉电只有在重绘间隔达到小时级别上才有合理的收益。微雪给的手册其实不是特别详细,要深入还得倒回去看 SSD1680 的原厂手册。
我一开始误以为睡眠模式会丢失缓存,实现了一套重建上次画面并回写控制器的逻辑,然而这是不必要的。GxEPD2 默认就是用的保留显存内容的睡眠模式。
功耗测量和验证
用可调电源串联万用表,再接到电池连接器,就可以测量整机功耗。
经过测量
- 刷新时的均值功耗约 30mA
- BLE 射频传输约 50mA
- 睡眠功耗约 500uA,异常偏高!
睡眠时的高功耗大概率是因为板子上有一些器件在漏电,比如 LDO 等。我的 EE 水平可能难以深入排查这个问题。当然,即使存在漏电,机器的理论续航也能达到 7 - 8d 的水平。功耗的大头依然是每分钟刷新。

最终产出和复盘
正确的决策
- 迁移到 ESP-IDF 构建系统。除了用新的 SDK,还拥有了更细粒度的固件控制,比如移除不必要的二进制,控制 CPU 主频等。设定主频到 80MHz 极大降低了功耗。
- 设计合理的刷新策略。通过混合使用快速刷新和全刷新,达到了低功耗和可接受的残影控制之间的平衡。
缺陷
- 错误使用功率 MOSFET 作为电池测量开关,导致测量值不准确。
- 板子存在休眠漏电问题,导致续航不及预期。
- 使用了 LDO 作为降压方案,效率不高。应该使用 DC-DC 降压模块。当然,这也会让电路 footprint 变大。
其他思考
装配结构上,我选择了板子中间留出约 30mm 的空间,用来放置锂电池。那么,如果严格控制元件高度,用三明治结构装配是否能放下更大的电池?
我在淘宝上搜了一下 4mm 厚度的电芯,其实变薄之后,大面积并没有带来容量的显著提升。板子中间留空的方案因为能装下更厚的电池,并不逊色。当然,这样的设计导致布局空间比较紧张,如果要做 DCDC 降压,面积可能会不够。
测量电池电压上,应该用模拟开关或者类似的方案,来实现小电流信号的通断控制。
总体来说,这个设计跑通了核心的功能,续航和功耗也基本达到预期。但是在外围功能和细节上还有很多可以改进的地方。如果有机会,我会考虑做一个 V2 版本,改进这些细节。