ESP32-S3 移植四色墨水屏驱动到 ESP-IDF:从官方示例到 SD 卡图片切换
一、项目背景
本项目使用 正点原子 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 |
|---|---|
| VCC | 3.3V |
| GND | GND |
| DIN / MOSI | GPIO15 |
| CLK / SCK | GPIO16 |
| CS | GPIO10 |
| DC | GPIO7 |
| RST | GPIO6 |
| BUSY | GPIO5 |
| PWR | GPIO14 |
SD 卡使用开发板上的 SPI 接口:
| SD 卡信号 | ESP32-S3 GPIO |
|---|---|
| CS | GPIO2 |
| MOSI | GPIO11 |
| SCK | GPIO12 |
| MISO | GPIO13 |
按键通过 XL9555 IO 扩展芯片读取:
| 信号 | GPIO / 扩展 IO |
|---|---|
| I2C SCL | GPIO42 |
| I2C SDA | GPIO41 |
| KEY0 | XL9555 IO1_7 |
| KEY1 | XL9555 IO1_6 |
| KEY2 | XL9555 IO1_5 |
| KEY3 | XL9555 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 刷新机制之间的配合关系。
更多推荐
所有评论(0)