一、项目背景

本项目使用 正点原子 ESP32-S3 开发板 驱动一块 2.15 英寸四色墨水屏,屏幕支持 黑、白、红、黄 四色显示,通信接口为 SPI。

项目目标是:

1. 使用 ESP-IDF 驱动墨水屏
2. 从 SD 卡读取已经转换好的 .epd 图片文件
3. 通过按键切换上一张 / 下一张图片
4. 理解墨水屏刷新机制、BUSY 引脚、SPI 驱动和 SD 卡之间的关系

由于官方示例主要提供了树莓派、Arduino、STM32、ESP32/ESP8266 等版本,并没有完全符合当前项目的 ESP32-S3 + ESP-IDF + SD 卡 + 按键切换 的工程,因此需要重新构思整个驱动移植和应用逻辑。


二、屏幕硬件参数

本次使用的墨水屏参数如下:

参数内容
尺寸2.15 英寸
驱动板尺寸65mm × 34mm
显示尺寸48.10mm × 26mm
裸屏外形尺寸60.8mm × 31.8mm × 1.00mm
工作电压3.3V / 5V
IO 电平需要和供电电压一致
通信接口SPI
点距0.1625mm × 0.1625mm
分辨率296 × 160
显示颜色黑、白、红、黄
灰度等级2
刷新时间20s
刷新功耗< 50mW typ.
休眠电流< 0.01uA
工作温度0 ~ 40 ℃
存储温度0 ~ 40 ℃

这里最关键的参数是:

刷新时间:20s
显示颜色:黑、白、红、黄
刷新方式:全刷

这意味着这块屏幕并不适合做动画、滚动菜单或快速翻页。它更适合显示静态内容,例如电子标签、卡牌、道具说明、奖励结果等。


三、墨水屏刷新速度为什么无法明显提升

在这个项目里,容易产生一个误解:
是不是 ESP32-S3 性能更强,就可以让屏幕刷新更快?

实际不是。

墨水屏的刷新速度主要受屏幕本身和屏幕控制器限制。MCU 只是把需要显示的图像数据通过 SPI 发送给墨水屏控制器,真正耗时的是屏幕控制器驱动物理墨水粒子移动的过程。

整体关系如下:

ESP32-S3
  ↓ SPI:发送命令和图像数据
墨水屏控制器 IC
  ↓ 输出刷新波形
墨水屏面板
  ↓ 物理粒子移动
最终显示图像

ESP32-S3 的任务是:

1. 初始化 GPIO
2. 初始化 SPI
3. 控制 DC / CS / RST / PWR
4. 发送屏幕命令
5. 发送图像 buffer
6. 等待 BUSY 引脚释放

墨水屏控制器的任务是:

1. 接收 MCU 发来的图像数据
2. 将图像数据写入控制器内部 RAM
3. 根据图像内容产生刷新波形
4. 驱动面板上的墨水粒子移动
5. 通过 BUSY 引脚告诉 MCU 当前是否忙碌

所以,MCU 可以很快把数据发给屏幕,但屏幕固定需要大约 20 秒完成物理刷新。这个时间不是简单通过提高 SPI 频率或提升 MCU 主频就能解决的。


四、全刷、局刷、快刷的区别

墨水屏常见刷新方式大致可以分成几类。

1. 全屏刷新

全刷会重新刷新整块屏幕。

特点:

1. 显示效果最稳定
2. 残影较少
3. 刷新时间较长
4. 多色墨水屏通常使用全刷

本项目使用的四色屏就是这种模式。每次显示新图时,整块屏都会重新刷新一次。


2. 局部刷新

局刷只刷新屏幕的一小块区域,例如电子价签上的价格、时钟上的数字。

特点:

1. 刷新速度快
2. 适合局部文字或数字变化
3. 容易产生残影
4. 不是所有墨水屏都支持

很多黑白墨水屏支持局刷,但四色墨水屏通常不支持或者不建议使用局刷。


3. 快速刷新

快刷是部分屏幕支持的一种快速刷新模式。

特点:

1. 比普通全刷快
2. 显示效果可能变差
3. 残影可能增加
4. 常见于部分黑白屏

本项目这块四色屏没有按快刷来设计,因此不适合做动画或快速切换界面。


五、为什么不能像 LCD 一样连续切换图片

LCD 和墨水屏的显示机制不同。

LCD 更像这样:

MCU / 显存持续提供图像
屏幕不断刷新
掉电后画面消失
刷新速度快

墨水屏更像这样:

MCU 只在换图时发送一次数据
屏幕控制器执行一次物理刷新
刷新完成后画面保持
掉电后画面仍可保持
刷新速度慢

因此,墨水屏不适合动画,也不适合像 LCD 那样频繁切图。

当屏幕正在刷新时,BUSY 引脚会进入忙状态。此时如果继续向屏幕发送下一张图片,通常不会得到正确效果,甚至可能导致显示异常。

正确逻辑应该是:

当前图片刷新中
  ↓
BUSY 表示屏幕忙
  ↓
MCU 等待 BUSY 释放
  ↓
刷新完成
  ↓
再发送下一张图片

六、官方驱动移植到 ESP-IDF 的思路

官方驱动通常不是直接为 ESP-IDF 工程准备的,里面可能使用 Arduino 风格接口,例如:

pinMode()
digitalWrite()
digitalRead()
delay()
SPI.begin()
SPI.transfer()

而 ESP-IDF 里应该使用:

gpio_config()
gpio_set_level()
gpio_get_level()
vTaskDelay()
spi_bus_initialize()
spi_bus_add_device()
spi_device_transmit()

因此,移植的核心不是重写整个屏幕协议,而是:

保留官方 EPD 屏幕命令流程,
重写底层 DEV_Config 硬件适配层,
将 Arduino 风格 GPIO / SPI / Delay 接口替换成 ESP-IDF 接口。

七、驱动分层关系

整个工程可以按三层理解。

main.cpp
  ↓
EPD_2in15g.cpp
  ↓
DEV_Config.cpp
  ↓
ESP-IDF GPIO / SPI / FreeRTOS

1. main.cpp:应用层

负责具体业务逻辑:

1. 挂载 SD 卡
2. 扫描 .epd 文件
3. 读取图片数据
4. 初始化按键
5. 根据按键切换上一张 / 下一张
6. 调用屏幕显示函数

例如:

EPD_2IN15G_Init();
EPD_2IN15G_Display(image);

2. EPD_2in15g.cpp:屏幕协议层

负责屏幕控制器命令流程:

1. 复位屏幕
2. 发送初始化命令
3. 设置 RAM 地址
4. 写入图像数据
5. 发送刷新命令
6. 等待 BUSY
7. 进入 Sleep

这一层一般保留官方代码,不大改。


3. DEV_Config.cpp:硬件适配层

这是移植最重要的一层。

它负责把官方驱动中的底层函数对接到 ESP-IDF:

DEV_Digital_Write()
DEV_Digital_Read()
DEV_SPI_WriteByte()
DEV_Delay_ms()
DEV_Module_Init()
DEV_Module_Exit()

屏幕协议层并不关心底层是 Arduino 还是 ESP-IDF,它只调用这些接口。

所以只要把 DEV_Config.cpp 写好,官方屏幕驱动就可以在 ESP-IDF 下运行。


八、Arduino API 和 ESP-IDF API 对照

移植时可以按下面关系替换:

Arduino 接口ESP-IDF 接口
pinMode()gpio_config()
digitalWrite()gpio_set_level()
digitalRead()gpio_get_level()
delay()vTaskDelay(pdMS_TO_TICKS(ms))
SPI.begin()spi_bus_initialize()
SPI.transfer()spi_device_transmit()

比如 Arduino 中的:

digitalWrite(EPD_CS_PIN, 0);
SPI.transfer(data);
digitalWrite(EPD_CS_PIN, 1);

在 ESP-IDF 中可以改成:

gpio_set_level((gpio_num_t)EPD_CS_PIN, 0);
DEV_SPI_WriteByte(data);
gpio_set_level((gpio_num_t)EPD_CS_PIN, 1);

九、硬件连接设计

本项目中,墨水屏使用一组独立 SPI 引脚:

墨水屏引脚ESP32-S3 GPIO
VCC3.3V
GNDGND
DIN / MOSIGPIO15
CLK / SCKGPIO16
CSGPIO10
DCGPIO7
RSTGPIO6
BUSYGPIO5
PWRGPIO14

SD 卡使用开发板上的 SPI 接口:

SD 卡信号ESP32-S3 GPIO
CSGPIO2
MOSIGPIO11
SCKGPIO12
MISOGPIO13

按键通过 XL9555 IO 扩展芯片读取:

信号GPIO / 扩展 IO
I2C SCLGPIO42
I2C SDAGPIO41
KEY0XL9555 IO1_7
KEY1XL9555 IO1_6
KEY2XL9555 IO1_5
KEY3XL9555 IO1_4

十、SPI_HOST 分配问题

ESP32-S3 内部有多个 SPI 外设。这里需要注意:
SD 卡和墨水屏不要抢同一个 SPI_HOST。

本项目中建议分配为:

SD 卡      -> SPI2_HOST
墨水屏     -> SPI3_HOST

如果两者都使用 SPI2_HOST,可能出现:

spi_bus_initialize: SPI bus already initialized
spi_master_deinit_driver: not all CSses freed

原因是 SD 卡已经初始化了 SPI2,总线引脚已经固定为 SD 卡使用的 GPIO。如果墨水屏再次初始化 SPI2,并试图使用另一组 GPIO,就会冲突。

正确做法是在墨水屏的 DEV_Config.cpp 里使用:

spi_bus_initialize(SPI3_HOST, &buscfg, SPI_DMA_CH_AUTO);
spi_bus_add_device(SPI3_HOST, &devcfg, &epaper_spi);

SD 卡保持使用:

SPI2_HOST

十一、ESP-IDF 版 DEV_Config 的设计

DEV_Config.h 中可以定义屏幕引脚:

#define EPD_PWR_PIN    14
#define EPD_BUSY_PIN   5
#define EPD_RST_PIN    6
#define EPD_DC_PIN     7
#define EPD_CS_PIN     10

#define EPD_MOSI_PIN   15
#define EPD_SCK_PIN    16

DEV_Module_Init() 负责初始化 GPIO 和 SPI。

需要配置的输出引脚有:

PWR
CS
DC
RST

BUSY 是输入引脚。

GPIO 初始化逻辑:

gpio_config_t out_conf = {};
out_conf.intr_type = GPIO_INTR_DISABLE;
out_conf.mode = GPIO_MODE_OUTPUT;
out_conf.pin_bit_mask =
    (1ULL << EPD_PWR_PIN) |
    (1ULL << EPD_CS_PIN) |
    (1ULL << EPD_DC_PIN) |
    (1ULL << EPD_RST_PIN);
out_conf.pull_down_en = GPIO_PULLDOWN_DISABLE;
out_conf.pull_up_en = GPIO_PULLUP_DISABLE;
gpio_config(&out_conf);

BUSY 输入配置:

gpio_config_t busy_conf = {};
busy_conf.intr_type = GPIO_INTR_DISABLE;
busy_conf.mode = GPIO_MODE_INPUT;
busy_conf.pin_bit_mask = (1ULL << EPD_BUSY_PIN);
busy_conf.pull_down_en = GPIO_PULLDOWN_DISABLE;
busy_conf.pull_up_en = GPIO_PULLUP_DISABLE;
gpio_config(&busy_conf);

PWR 上电:

gpio_set_level((gpio_num_t)EPD_PWR_PIN, 1);
DEV_Delay_ms(50);

SPI 初始化:

spi_bus_config_t buscfg = {};
buscfg.mosi_io_num = EPD_MOSI_PIN;
buscfg.miso_io_num = -1;
buscfg.sclk_io_num = EPD_SCK_PIN;
buscfg.quadwp_io_num = -1;
buscfg.quadhd_io_num = -1;
buscfg.max_transfer_sz = 4096;

spi_bus_initialize(SPI3_HOST, &buscfg, SPI_DMA_CH_AUTO);

添加 SPI 设备:

spi_device_interface_config_t devcfg = {};
devcfg.clock_speed_hz = 4 * 1000 * 1000;
devcfg.mode = 0;
devcfg.spics_io_num = -1;
devcfg.queue_size = 1;

spi_bus_add_device(SPI3_HOST, &devcfg, &epaper_spi);

这里 spics_io_num = -1,是因为屏幕驱动中手动控制 CS。

SPI 发送一个字节:

void DEV_SPI_WriteByte(UBYTE Value)
{
    spi_transaction_t trans = {};
    trans.length = 8;
    trans.tx_buffer = &Value;
    spi_device_transmit(epaper_spi, &trans);
}

GPIO 写:

void DEV_Digital_Write(UWORD Pin, UBYTE Value)
{
    gpio_set_level((gpio_num_t)Pin, Value);
}

GPIO 读:

UBYTE DEV_Digital_Read(UWORD Pin)
{
    return gpio_get_level((gpio_num_t)Pin);
}

延时:

void DEV_Delay_ms(UDOUBLE xms)
{
    vTaskDelay(pdMS_TO_TICKS(xms));
}

十二、BUSY 引脚和 Watchdog 问题

墨水屏刷新时会通过 BUSY 引脚告诉 MCU 当前是否忙碌。

官方驱动中可能会有类似代码:

while (DEV_Digital_Read(EPD_BUSY_PIN) == 1) {
}

这种写法在 Arduino 中可能可以运行,但在 ESP-IDF 中容易触发任务看门狗。因为它一直空转,占用 CPU,不给 FreeRTOS 调度机会。

应该改成:

while (DEV_Digital_Read(EPD_BUSY_PIN) == 1) {
    DEV_Delay_ms(100);
}

如果官方驱动原来判断是 == 0,就保持原来的判断方向,只是在循环里加入延时:

while (DEV_Digital_Read(EPD_BUSY_PIN) == 0) {
    DEV_Delay_ms(100);
}

原则是:

不要随便改变 BUSY 的高低电平判断方向,
只要在 while 等待中加入 vTaskDelay,让出 CPU。

同时,由于四色屏刷新一次可能接近 20 秒,建议在 menuconfig 中把 Task Watchdog timeout 调大,例如 60 秒或 120 秒。

路径:

idf.py menuconfig
→ Component config
→ ESP System Settings
→ Task watchdog timeout period

十三、图片数据格式和 .epd 文件

本项目没有直接在 ESP32-S3 上解析 PNG/JPG。
而是在电脑上先将图片转换成屏幕可以直接显示的 .epd raw 文件。

屏幕分辨率为:

160 × 296

四色屏每个像素需要 2 bit:

00 黑
01 白
10 黄
11 红

所以一张图片大小为:

160 × 296 × 2 bit / 8 = 11840 bytes

代码中定义:

#define EPD_IMAGE_SIZE 11840

完整流程是:

PNG / JPG 原图
  ↓ 电脑端 Python 工具
缩放 / 裁剪 / 四色量化
  ↓
2bpp 打包
  ↓
生成 .epd 文件
  ↓
放入 SD 卡
  ↓
ESP32-S3 读取 11840 bytes
  ↓
EPD_2IN15G_Display(image)

.epd 文件本质上不是普通图片格式,而是屏幕驱动可以直接使用的 raw buffer。


十四、SD 卡读取图片

SD 卡挂载成功后,程序扫描 SD 卡中的 .epd 文件。

可以使用:

#define EPD_IMAGE_DIR "/sdcard"

扫描目录:

scan_epd_files(EPD_IMAGE_DIR);

每找到一个 .epd 文件,就保存路径:

static char g_epd_files[MAX_EPD_FILES][MAX_PATH_LEN];
static int g_epd_file_count = 0;

读取文件时检查大小:

uint8_t *image = sd_card_load_file(path, EPD_IMAGE_SIZE, &read_size);

if (read_size != EPD_IMAGE_SIZE) {
    ESP_LOGE(TAG, "Bad image size");
    free(image);
    return NULL;
}

只有文件大小等于 11840 bytes,才说明它是适合当前屏幕的图片数据。


十五、FATFS 长文件名问题

调试中遇到过一个典型问题:
SD 卡中的文件名是:

image_2in15g.epd

但 ESP32-S3 读出来变成:

IMAGE_~2.EPD

这是 FAT 文件系统的 8.3 短文件名机制。

如果没有开启 FATFS 长文件名支持,长文件名可能会显示成短文件名别名。

解决方式有两种:

方式一:使用短文件名

例如:

001.EPD
002.EPD
003.EPD

这是最稳定的做法。

方式二:开启 Long filename support

进入:

idf.py menuconfig
→ Component config
→ FAT Filesystem support
→ Long filename support

选择:

Long filename buffer in heap

这样就可以正常识别较长文件名。


十六、文件排序问题

如果 SD 卡中文件名是:

1.epd
2.epd
3.epd
10.epd
11.epd

程序扫描出来的顺序可能是:

1.epd
10.epd
11.epd
2.epd
3.epd

这是因为字符串排序或 FAT 目录顺序导致的。

建议统一命名为:

001.EPD
002.EPD
003.EPD
...
010.EPD
011.EPD

这样扫描和排序都更稳定。


十七、按键切换逻辑

开发板按键通过 XL9555 IO 扩展芯片读取,I2C 引脚如下:

SCL -> GPIO42
SDA -> GPIO41
XL9555 地址 -> 0x20

按键连接:

KEY0 -> IO1_7
KEY1 -> IO1_6
KEY2 -> IO1_5
KEY3 -> IO1_4

定义按键:

#define KEY_NEXT_MASK  (1 << 7)   // KEY0:下一张
#define KEY_PREV_MASK  (1 << 6)   // KEY1:上一张

由于按键低有效,所以判断逻辑是:

if ((port & KEY_NEXT_MASK) == 0) {
    return KEY_EVENT_NEXT;
}

if ((port & KEY_PREV_MASK) == 0) {
    return KEY_EVENT_PREV;
}

按键扫描逻辑:

1. 读取 XL9555 输入寄存器
2. 判断是否有按键被按下
3. 延时 50ms 去抖
4. 再次读取确认
5. 等待按键释放
6. 返回按键事件

主程序中根据事件切换图片:

if (event == KEY_EVENT_NEXT) {
    g_current_index++;

    if (g_current_index >= g_epd_file_count) {
        g_current_index = 0;
    }

    display_epd_file(g_epd_files[g_current_index]);
}
else if (event == KEY_EVENT_PREV) {
    g_current_index--;

    if (g_current_index < 0) {
        g_current_index = g_epd_file_count - 1;
    }

    display_epd_file(g_epd_files[g_current_index]);
}

十八、为什么不要每次都 Init + Sleep

一开始可能会写成:

显示每一张图片:
EPD_2IN15G_Init()
EPD_2IN15G_Display()
EPD_2IN15G_Sleep()

但是连续切换图片时,这样不太合适。

因为 Sleep() 之后再马上 Init(),可能导致第二次初始化阶段等待 BUSY 时间过长,甚至触发 Watchdog。

更合理的逻辑是:

程序启动:
DEV_Module_Init()
EPD_2IN15G_Init()

每次切换图片:
EPD_2IN15G_Display(image)

不用屏幕时:
EPD_2IN15G_Sleep()
DEV_Module_Exit()

也就是说,初始化只做一次,切换图片时只调用显示函数。

显示函数可以写成:

static void display_epd_file(const char *path)
{
    uint8_t *image = load_epd_file(path);
    if (!image) {
        return;
    }

    EPD_2IN15G_Display(image);

    free(image);
}

十九、刷新期间能否响应按键

物理上,屏幕刷新期间不能立即切换下一张。

因为墨水屏正在执行完整的物理刷新波形,BUSY 忙的时候不能像 LCD 一样马上更新下一帧。

但是软件上可以优化成:

刷新期间允许用户按键
程序记录目标图片 index
当前刷新完成后
自动跳到最新目标图片

这需要把按键扫描放到单独的 FreeRTOS task 中。

基本思路:

key_task:
持续扫描按键,只更新目标 index

display_task:
发现目标 index 变化后,才执行 EPD_2IN15G_Display()

这样可以改善交互体验,但不能缩短屏幕本身的物理刷新时间。


二十、工程结构建议

最终工程可以整理成:

E-paper/
├── main/
│   ├── main.cpp
│   ├── sd_card.c
│   ├── sd_card.h
│   ├── key_manager.c
│   ├── key_manager.h
│   └── CMakeLists.txt
│
├── components/
│   └── epaper_2in15g/
│       ├── include/
│       │   ├── DEV_Config.h
│       │   ├── EPD_2in15g.h
│       │   ├── GUI_Paint.h
│       │   ├── ImageData.h
│       │   └── fonts.h
│       │
│       ├── src/
│       │   ├── DEV_Config.cpp
│       │   ├── EPD_2in15g.cpp
│       │   ├── GUI_Paint.cpp
│       │   ├── ImageData.cpp
│       │   └── fonts.cpp
│       │
│       └── CMakeLists.txt

这样分层比较清晰:

main/:
业务逻辑,包括 SD 卡、按键、文件扫描、图片切换

components/epaper_2in15g/:
墨水屏驱动组件

二十一、调试过程中遇到的问题总结

1. Arduino.h / Wire.h 找不到

原因:

官方代码是 Arduino 风格,不适合直接放进 ESP-IDF。

解决:

删除 Arduino 依赖,
使用 ESP-IDF 的 gpio、spi、FreeRTOS 接口重写 DEV_Config。

2. SD 卡和墨水屏 SPI 冲突

错误日志:

spi_bus_initialize: SPI bus already initialized
spi_master_deinit_driver: not all CSses freed

原因:

SD 卡和墨水屏同时使用 SPI2_HOST。

解决:

SD 卡使用 SPI2_HOST
墨水屏使用 SPI3_HOST

3. 文件找不到

错误日志:

Open file failed: /sdcard/image_2in15g.epd

原因可能是:

1. 文件路径写错
2. 文件没有放到对应目录
3. FATFS 长文件名没有开启

解决:

检查 SD 卡目录
使用短文件名
或者开启 Long filename support

4. 文件大小不对

原因:

.epd 文件不是当前屏幕对应格式。

正确大小:

11840 bytes

5. Watchdog 触发

错误日志:

task_wdt: Task watchdog got triggered

原因:

BUSY 等待时 while 空转太久。

解决:

while (DEV_Digital_Read(EPD_BUSY_PIN) == 1) {
    DEV_Delay_ms(100);
}

并适当增大 Task Watchdog timeout。


二十二、最终经验总结

本次移植的核心经验如下:

1. 官方墨水屏驱动不一定能直接用于 ESP-IDF,需要重写底层适配层。
2. EPD_2in15g.cpp 主要是屏幕协议层,尽量保留官方逻辑。
3. DEV_Config.cpp 是移植重点,需要替换 GPIO、SPI、Delay 接口。
4. SD 卡和墨水屏要分配到不同 SPI_HOST,避免总线冲突。
5. 四色墨水屏刷新慢是硬件特性,不能靠 MCU 明显加速。
6. 这块屏适合静态图,不适合动画或频繁切换。
7. .epd 文件是 raw buffer,不是普通图片。
8. FATFS 长文件名需要额外开启,否则会出现 IMAGE_~1.EPD。
9. BUSY 等待不能空转,要使用 vTaskDelay 让出 CPU。
10. 连续切换图片时,建议只初始化一次屏幕,不要每张图都 Init + Sleep。

二十三、结论

本项目的关键不是重新发明一个墨水屏驱动,而是理解官方驱动的分层结构:

屏幕协议层:负责发什么命令
硬件适配层:负责怎么发命令
应用层:负责什么时候显示什么内容

在 ESP-IDF 中移植官方墨水屏代码时,应重点改造 DEV_Config.cpp,将 Arduino 风格接口替换为 ESP-IDF 的 GPIO、SPI 和 FreeRTOS 接口。

对于四色墨水屏来说,刷新时间主要由屏幕物理特性决定。ESP32-S3 可以快速读取 SD 卡和发送图像数据,但屏幕最终显示仍然需要等待控制器完成全屏刷新。

因此,这类屏幕更适合:

电子标签
静态卡牌
TRPG 道具显示
低功耗展示
一次显示后长时间保持

而不适合:

动画
快速翻页
实时菜单
高频刷新界面

通过这次移植,可以比较完整地理解 ESP-IDF 下 GPIO、SPI、SD 卡、FATFS、I2C 按键和墨水屏 BUSY 刷新机制之间的配合关系。

更多推荐