PocketOracle(电子抽签器)的第一版板子用 SSD1680 控制器的 1.54 寸墨水屏,驱动照 datasheet 写得很顺。换到新玻璃 E0154A04-BF-S(UltraChip UC8253 家族控制器)之后, 同一套思路全部失效——不是代码写错了,是 datasheet 在骗人。这篇文章记录三个坑和 最终跑通的驱动流程,给同样在 UC8253 上挣扎的人省点时间。
先交代硬件:
- 屏幕:E0154A04-BF-S,1.54 寸黑白,UC8253 家族控制器;
- 接线:SPI 四线(SCK/MOSI/CS,没有 MISO),外加 DC(命令/数据选择)、RST、BUSY 三个控制脚;
- 主控:ESP32-C3,硬件 SPI,10 MHz。
坑一:datasheet 的分辨率是错的
A01 版 datasheet 写的是 152×152、每字节 8 像素。按这个参数画图,右半边花屏, 条纹间距也对不上。
验证方法很土:画一张「标尺图」——每隔固定数量的数据字节画一道刻度竖线,烧进屏幕 看刻度往哪儿漂。实测刻度每 8 行数据向右漂 16 像素,反推控制器显存的行距是 50 字节/行(显存是 2 bit/像素),50 × 8 ÷ 2 = 200,这才是真实的宽度。
datasheet A01:152×152,8 px/byte ← 别信
实测 A04 玻璃:200×200 ← 显存行距 50 B/row
于是驱动里的定义是:
#define EPD_WIDTH 200
#define EPD_HEIGHT 200
#define EPD_FB_SIZE (EPD_WIDTH / 8 * EPD_HEIGHT) // 5000 字节/平面教训:拿到陌生玻璃,先烧标尺图量分辨率,再写渲染代码。同一块屏不同批次 (A01 → A04),关键参数都可能变。
坑二:BUSY 低电平才是忙
UC8253 的 BUSY 语义(datasheet Note 5-4,实测一致):LOW = busy,HIGH = idle。 和很多「BUSY 拉高才算忙」的习惯相反,轮询等的是高电平:
#define EPD_BUSY_IDLE_LEVEL 1
static esp_err_t wait_busy(int timeout_ms)
{
for (int waited = 0; waited < timeout_ms; waited += 10) {
if (gpio_get_level(PIN_EPD_BUSY) == EPD_BUSY_IDLE_LEVEL) return ESP_OK;
vTaskDelay(pdMS_TO_TICKS(10));
}
ESP_LOGW(TAG, "BUSY stuck at %d after %d ms",
gpio_get_level(PIN_EPD_BUSY), timeout_ms);
return ESP_ERR_TIMEOUT;
}顺带一个 FreeRTOS 细节:轮询间隔必须不小于一个 tick。CONFIG_FREERTOS_HZ=100
时 tick 恰好 10 ms,vTaskDelay(pdMS_TO_TICKS(10)) 是一次真正的让出;间隔再短
就截断成 vTaskDelay(0),等于不让出 CPU,整个超时预算在几毫秒内烧完,BUSY 还没
等到就被误报超时。
坑三:分块 SPI 传输会把命令流切碎
第一版驱动把 5000 字节的显存拆成 64 字节一段分批发(以为和别家 SPI 屏一样需要 分块),结果图案永远缺一块。原因是 ESP32 的硬件 CS 在每个事务之间自动拉高,而 UC8253 的 CS 上升沿会复位控制器内部的写入状态机——控制器以为你要开始发新命令了, 于是大块显存写一半就被「截胡」。
解法反而更简单:整个 payload 一次事务发完,让 DMA 去搬运:
static esp_err_t spi_tx(const uint8_t *data, int len)
{
spi_transaction_t t = {
.length = 8 * (size_t)len,
.tx_buffer = data,
};
return spi_device_transmit(s_spi, &t); // 一次事务,DMA 链式搬完
}SPI 总线初始化时把 max_transfer_sz 设成 5000(一整个平面),DMA 通道用
SPI_DMA_CH_AUTO 自动分配。C3 的 DMA 一次链式搬运 5 KB 毫无压力。
刷新:DTM1/DTM2 双平面
UC8253 的全刷流程(datasheet §11.1 的「LUT from OTP」路径)每次要写两个平面:
| 步骤 | 命令 | 作用 |
|---|---|---|
| 1 | 0x10 (DTM1) | 旧帧——当前玻璃上的内容 |
| 2 | 0x13 (DTM2) | 新图像 |
| 3 | 0x04 (PON) | 打开电源,等 BUSY 空闲 |
| 4 | 0x12 (DRF) | 启动刷新,等 BUSY 空闲(实测最长 15 s) |
控制器对比两个平面,为每个像素选择对应的驱动波形(白变黑、黑变白、保持不变各有 自己的脉冲序列),这就是墨水屏刷新时「闪一下」的来源。数据格式 1bpp,1=白、0=黑, MSB 在前。
驱动里用一个静态数组跟踪「玻璃上现在是什么」:
static uint8_t s_old_plane[EPD_FB_SIZE]; // 专供 DTM1
esp_err_t epd_full_refresh(const uint8_t *framebuffer)
{
esp_err_t r = epd_cmd(0x10, s_old_plane, EPD_FB_SIZE); // DTM1: 旧帧
if (r == ESP_OK) r = epd_cmd(0x13, framebuffer, EPD_FB_SIZE); // DTM2: 新图
if (r != ESP_OK) return r;
memcpy(s_old_plane, framebuffer, EPD_FB_SIZE); // 记录玻璃状态
r = epd_cmd(0x04, NULL, 0); // PON
if (r == ESP_OK) r = wait_busy(3000);
if (r != ESP_OK) return r;
r = epd_cmd(0x12, NULL, 0); // DRF
if (r == ESP_OK) r = wait_busy(15000);
return r;
}上电后玻璃是全白的,所以 epd_init 成功后把 s_old_plane 填满 0xFF(1=白),
保证第一次刷新时 DTM1 和玻璃的真实状态一致。
别覆盖 OTP 里的出厂配置
UC8253 出厂把 LUT(驱动波形)、PWR(电源时序)、CDI(驱动强度)、PLL 都烧进了 OTP, 上电自动加载。除非你比屏幕厂更懂这块玻璃,否则别发命令去覆盖它们——覆盖错了轻则 灰底、重则花屏。我们的刷新流程完全依赖 OTP 默认值,实测干净利落,一毫秒的调参 都没花。
睡觉:POF + DSLP
显示结束后两步断电,控制器功耗归零:
void epd_sleep(void)
{
epd_cmd(0x02, NULL, 0); // POF: power off
vTaskDelay(pdMS_TO_TICKS(20));
static const uint8_t DSLP[] = {0xA5}; // 0xA5 是 datasheet 规定的魔数
epd_cmd(0x07, DSLP, 1); // DSLP: deep sleep
}墨水屏的价值在这一步兑现:断电后画面保持,屏幕和主控一起睡,下次唤醒只管画新帧。 对一个 99% 时间在睡觉的电池设备来说,「不刷新就不耗电」是选墨水屏而不是 OLED 的 根本理由。
小结
三个坑的共性是「datasheet/直觉和实物不符」:分辨率要量出来,BUSY 极性要实测确认, CS 行为要读芯片手册的注释。驱动最终不到 160 行,却是在标尺图、逻辑分析和一次一次 烧录里磨出来的。嵌入式外设 bring-up 没有捷径,但每一次踩坑都值得记下来——下一次 换屏,大概率还会用上。