大飞的技术博客

·5 分钟·

datasheet 说 152×152,实测 200×200:点亮 UC8253 墨水屏的三个坑

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」路径)每次要写两个平面:

步骤命令作用
10x10 (DTM1)旧帧——当前玻璃上的内容
20x13 (DTM2)新图像
30x04 (PON)打开电源,等 BUSY 空闲
40x12 (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 没有捷径,但每一次踩坑都值得记下来——下一次 换屏,大概率还会用上。