屏幕不识字,它只认电压——把"字"变成"帧",是艺术与工程的合奏。
核心方法论:艺术与工程
「别人画画用笔刷,我们画画用『电压、帧、查找表』。电子墨水把白纸和墨揉进同一个像素——你写下的每个字,都要由屏驱用几十帧的序列『一笔一笔』描上去。别嫌那些寄存器琐碎:波形表是画家的调色盘,时序是落笔的节奏,VCOM 是画面的底色。懂它们,你才真正『会画』。」
电子墨水屏和主控之间靠什么"说话"?本项目给出了两种答案,对应第 3 章里的两套板级配置。第一种叫 DBI(Display Bus Interface,显示总线接口):一种把数据通过多根并行信号线同时送出的接口;这里用的是 8-bit DBI,一次并行送出 8 位数据,速度快、吞吐高,适合 1032×758、1448×1072 这种大分辨率墨水屏。第二种叫 SPI(Serial Peripheral Interface,串行外设接口):一种用一根数据线按位逐位传的通用串行接口,引脚少(SDA / SCK / DC / CS / RES / BUSY),但速度低,适合 240×416 这种小屏。
这两种接口在代码里用一"行"字就能区分——LCDC_InitTypeDef 里的 .lcd_itf 字段。8-bit 并行屏写 LCDC_INTF_DBI_8BIT_B(B 代表 8 位总线),SPI 屏写 LCDC_INTF_SPI_DCX_1DATA(DCX 表示数据/命令由 DC 引脚区分、1 根数据线)。剩下的差距全在驱动里:并行屏要把像素数据打包成"每帧的 8-bit 波形查找表"喂给 EPIC 图像加速引擎;SPI 屏则把数据通过寄存器写进屏幕自带的波形引擎。先记下这个总纲,后面两节分别展开。
| 对比项 | DBI(8-bit 并行) | SPI(串行) |
|---|---|---|
| 数据线 | 8 根(D0-D7) | 1 根(SDA) |
| 其它信号 | CLK / LE / OE / SPH / STV / CPV / GMODE | SCK / DC / CS / RES / BUSY |
| 接口宏 | LCDC_INTF_DBI_8BIT_B | LCDC_INTF_SPI_DCX_1DATA |
| 典型时钟 | sclk_freq=24MHz(并行一次 8bit) | freq=5000000(5MHz 串行) |
| 驱动目录 | src/boards/display_dbi/ | src/boards/display_spi/ |
| 适用屏 | YZC085 / R7D005 | OPM037A3 240×416 |
// src/boards/display_dbi/epd_display.c —— 8-bit 并行 DBI 接口
static LCDC_InitTypeDef lcdc_int_cfg =
{
.lcd_itf = LCDC_INTF_DBI_8BIT_B,
.freq = 0, /* 由 epd_get_timing_config 返回的 sclk_freq 覆盖 */
.color_mode = LCDC_PIXEL_FORMAT_RGB332,
};
// src/boards/display_spi/epd_display.c —— SPI 串行接口
static LCDC_InitTypeDef lcdc_int_cfg = {
.lcd_itf = LCDC_INTF_SPI_DCX_1DATA,
.freq = 5000000,
.color_mode = LCDC_PIXEL_FORMAT_RGB332,
};
判断"这块屏走哪种接口",最快的方法是看板级配置的引脚:出现 EPD_D0-D7、EPD_LE/OE/SPH 是 8-bit 并行;出现 EPD_SDA/SCK/DC/CS/RES/BUSY 是 SPI。接口决定了数据怎么送、时钟多快,也决定了后面波形引擎谁来干活——一个靠外部 EPIC 加速引擎,一个靠屏幕内置寄存器。
先把 波形(waveform)说人话:它是电子墨水"翻面"的完整剧本——屏幕上每一个点,从"旧颜色"变到"新颜色",需要按一定顺序施加一串电压,这串"每帧给哪个点加什么电压"的序列就叫波形。墨水屏不是像 LCD 那样一亮即显,而是靠电压把黑、白墨粒推来推去,一个画面要分很多次(帧)刷完才稳定。刷整个屏幕叫全刷(full refresh),只刷局部区域叫局刷(partial refresh)。
波形由屏幕厂商提供,做成查表数据:本项目在 epd_configs_opm060d.c 里按"温区 × 刷新模式"组织了两张表——yzc085_wave_full_0_100(0~100℃ 温区、全刷,32 帧)和 yzc085_wave_partial_0_100(0~100℃ 温区、局刷,12 帧)。每张表是 [帧数][256] 的二维数组:256 对应 16×16 种"旧像素×新像素"组合(旧色占高 4 位、新色占低 4 位),每一帧为每种组合查出一个 0~3 的灰阶值。用一张 WaveTableEntry 把"温区范围 + 帧数 + 表地址"打包成一条记录:全刷 32 帧、局刷 12 帧,越刷越多越清晰,代价是时间。
| 波形表 | 温区 | 帧数 | 用途 |
|---|---|---|---|
yzc085_wave_full_0_100 | 0~100℃ | 32 | 全刷:整屏推倒重画,残影少、清晰 |
yzc085_wave_partial_0_100 | 0~100℃ | 12 | 局刷:只动变化区域,快但有残影 |
二进制波形 epd_waveform.bin | 多温区 | 由库查表 | 厂商成品波形(38216 字节,见 4.3) |
// src/boards/display_dbi/epd_configs_opm060d.c —— 波形表条目结构
typedef struct {
int min_temp;
int max_temp;
uint32_t frame_count;
const uint8_t (*wave_table)[256];
} WaveTableEntry;
static const WaveTableEntry full_wave_table =
{0, 100, 32, &yzc085_wave_full_0_100[0]};
static const WaveTableEntry partial_wave_table =
{0, 100, 12, &yzc085_wave_partial_0_100[0]};
// 按"温度 + 刷新模式"挑选波形表,并返回本次刷新的帧数
uint32_t epd_wave_table_get_frames(int temperature, EpdDrawMode mode)
{
const WaveTableEntry *wave_table = NULL;
if (EPD_DRAW_MODE_FULL == mode) {
wave_table = &full_wave_table;
} else {
wave_table = &partial_wave_table;
}
if (temperature < wave_table->min_temp || temperature >= wave_table->max_temp) {
wave_table = &full_wave_table; // 温区不匹配就退回全刷,稳妥
}
p_current_wave_from = (const uint8_t *)&wave_table->wave_table[0][0];
return wave_table->frame_count;
}
温度为什么重要?墨粒子的"脾气"随温度变:低温墨粒懒、要更多帧才推得动,高温墨粒活、帧多了反而过冲。所以波形表按温区分档,epd_wave_table_get_frames 里温度超了范围就保险地退回全刷。这也是"调试屏发虚先查温度"的由来——把 temperature 传成恒定的 26℃ 固然能用,但真实量产要考虑冬夏温差。
全刷 vs 局刷不是"好看"与"难看"的单选题,而是"清晰"与"速度"的取舍:局刷只更新变化的像素行,快很多,但新墨粒没被彻底推到位,会留下淡淡的上一页影子(残影)。所以驱动用"刷 N 次局刷、然后强制一次全刷"的策略来清残影——DBI 驱动里 reflesh_times % g_part_disp_times == 0 就切全刷(默认每 10 次),SPI 驱动里 epd_get_refresh_mode() 也是同一套逻辑(默认 PART_DISP_TIMES 为 10)。
有了波形表,还要把它"翻译"成屏能用的样子。这里的 LUT(Lookup Table,查找表)就是一张查表:给定"旧色×新色",查出这一帧该输出的灰阶。代码注释说得很直白——"8bit lookup table for the current frame (high 4 bits: old data, low 4 bits: new data). The output is 2-bit data":输入是 8 位(旧 4 位 + 新 4 位),输出是 2 位灰阶。8-bit 并行屏走的是 EPIC(电子纸图像加速引擎,一个专门做墨水屏像素变换的硬件加速器),驱动把每一帧的 256 项 LUT 填进 EPIC 的 LUT 寄存器,EPIC 再逐行把混合灰度帧缓冲转成屏上波形。
epd_wave_table_fill_lut 干的就是"逐帧填 LUT"这件小事:先取出当前帧的 256 字节波形(p_current_wave_from + frame_num * 256),再把每个字节左移 3 位塞进 32 位 LUT(p_epic_lut[i] = p_frame_wave[i] << 3)。为什么是左移 3 位?因为 EPIC 的 LUT 里每个输入组合要占一个 32 位字的低 3 位通道,8 位输入正好是 256 个 32 位字。与此同时,CopyToMixedGrayBuffer 在 PSRAM 上维护一块"混合灰度帧缓冲"(每字节高 4 位是旧像素、低 4 位是新像素),把新旧两层合起来供 LUT 查询——这就在第 3 章那个 psram1 分区上。它里面有一段真实的未实现代码,正适合当教学点:当图层格式是 LCDC_PIXEL_FORMAT_MONO 时,直接 RT_ASSERT(0)//Not implemented yet。
第 3 章提过 Kconfig.proj 里有个 EPD_WAVEFORM_USE_BIN 开关:不开时,波形是上面这种编译进固件的 C 数组(我们刚看的 opm060d.c 前半段);开了时,波形从 waveform/ 目录的 epd_waveform.bin(38216 字节)读取——这是一份厂商给的二进制波形文件,通过 waveform/ 下的 epd_waveform_bin_reader.h 接口(配好的 libepd_waveform_bin_reader_gcc.a / .rvds.lib 库)按温区、模式查帧。它烧在 ptab.json 的 wave_table 分区(CUSTOM_EPD_WAVE_TABLE),由 SConstruct 用 AddCustomImg 打包进固件。两种波形来源各有利弊,对比如下。
| 对比项 | C 数组波形(默认) | 二进制波形(EPD_WAVEFORM_USE_BIN) |
|---|---|---|
| 波形数据 | 编译进固件的 yzc085_wave_full/partial_0_100 | 独立文件 epd_waveform.bin(38216 字节) |
| 存放位置 | 固件代码段(只读数据) | 烧录到 wave_table 分区(CUSTOM_EPD_WAVE_TABLE) |
| 读取方式 | 直接引用数组指针 | waveform_bin_reader_* 库 + rt_flash_read / rt_nand_read |
| 切换开关 | 不定义 EPD_WAVEFORM_USE_BIN | EPD_WAVEFORM_USE_BIN=y |
| 适用场景 | 波形随代码一起发布、要改表需重编译 | 厂商单独交付波形、想按 Flash 分区热替换 |
// src/boards/display_dbi/epd_configs_opm060d.c —— 逐帧填 EPIC 的 32 位 LUT
void epd_wave_table_fill_lut(uint32_t *p_epic_lut, uint32_t frame_num)
{
const uint8_t *p_frame_wave = p_current_wave_from + (frame_num * 256);
// Convert the 8-bit waveforms to 32-bit epic LUT values
for (uint16_t i = 0; i < 256; i++)
p_epic_lut[i] = p_frame_wave[i] << 3;
}
// PSRAM 上的混合灰度帧缓冲 + 一个真实的"未实现"占位
L2_NON_RET_BSS_SECT(frambuf, ALIGN(4) static uint8_t
mixed_framebuffer[LCD_HOR_RES_MAX * LCD_VER_RES_MAX]);
if (hlcdc->Layer[HAL_LCDC_LAYER_DEFAULT].data_format == LCDC_PIXEL_FORMAT_MONO)
{
RT_ASSERT(0);//Not implemented yet
}
# 使用二进制波形的分支(epd_configs_opm060d.c · EPD_WAVEFORM_USE_BIN)
waveform_bin_reader_init(CUSTOM_EPD_WAVE_TABLE_SIZE);
frames = waveform_bin_reader_get_frames(temperature, WAVE_MODE_FULL);
waveform_bin_reader_fill_lut(p_epic_lut, frame_num);
# waveform/ 目录(配好的库,SConscript 按需选用)
epd_waveform.bin # 38216 字节二进制波形
epd_waveform_bin_reader.h
libepd_waveform_bin_reader_gcc.a # GCC 工具链静态库
epd_waveform_bin_reader_rvds.lib # RVDS 工具链静态库
左移 3 位这个细节别忽略——它是"查表翻译"的落点:波形文件里每个点存的是 0~3 的灰阶,EPIC 的 LUT 把灰阶放进 32 位字的低 3 位。看懂这行 << 3,你就明白"波形表是数据、LUT 是通道、EPIC 是执行者"三层关系,后面移植新屏时也只需照抄这套翻译。
波形表定好了"每帧画什么",接下来要定"每帧怎么扫过去"——这就是时序。驱动用 EPD_TimingConfig 一次性描述整屏扫描节奏:sclk_freq(像素时钟,单位 MHz)、fclk_freq(行/帧时钟,单位 KHz)、以及一堆 L/F 开头的前后沿长度。最值得记的是两个"按分辨率算"的字段:LDL = LCD_HOR_RES_MAX/4(每行有效数据时钟数,注释说得很清楚——"2bit+8数据线,需除以4")和 FDL = LCD_VER_RES_MAX(每帧有效行数)。这印证了第 3 章"分辨率宏驱动一切":改分辨率,时序自动跟着变,驱动主体不用动。
再说 VCOM(common voltage,公共电极电压):墨水屏所有像素共享的一个参考电压,像素电极相对它产生的压差决定墨粒往哪边翻。它必须和屏幕面板匹配,调高了残影重、调低了对比度差。本项目里 epd_get_vcom_voltage() 是个很典型的"按屏编译"写法:编译目标是 R7D005 时返回 2100(mV),否则统一 1050。8-bit 并行屏的 VCOM 由电源管理芯片 TPS65185 通过 I2C 设置(oedtps_init(epd_get_vcom_voltage())、oedtps_vcom_enable()),而 SPI 小屏没有独立电源芯片,VCOM 相关参数直接写在寄存器配置里。
| 字段 | 含义 | 本项目的值 |
|---|---|---|
sclk_freq | 像素时钟(MHz) | 24 |
LDL | 每行有效数据时钟数 | LCD_HOR_RES_MAX/4 |
fclk_freq | 行/帧时钟(KHz) | 83 |
FDL | 每帧有效行数 | LCD_VER_RES_MAX |
FSL/FBL/FEL | 帧同步/起始/结束长度 | 1 / 3 / 5 |
SDMODE | 源驱动模式(R7D005=1) | 0(其余屏) |
// src/boards/display_dbi/epd_configs_opm060d.c —— 整屏时序
const EPD_TimingConfig *epd_get_timing_config(void)
{
static const EPD_TimingConfig timing_config = {
.sclk_freq = 24,
#ifdef LCD_USING_EPD_R7D005
.SDMODE = 1,
#else
.SDMODE = 0,
#endif
.LSL = 0, .LBL = 0,
.LDL = LCD_HOR_RES_MAX/4,
.LEL = 1,
.fclk_freq = 83,
.FSL = 1, .FBL = 3,
.FDL = LCD_VER_RES_MAX,
.FEL = 5,
};
return &timing_config;
}
// VCOM 电压:按屏编译的经典写法
uint16_t epd_get_vcom_voltage(void)
{
#if defined(LCD_USING_EPD_R7D005)
return 2100;
#else
return 1050;
#endif
}
// epd_display.c · LCD_Init:把时钟和 VCOM 接进初始化流程
lcdc_int_cfg.freq = epd_get_timing_config()->sclk_freq * 1000*1000;
oedtps_init(epd_get_vcom_voltage());
改时序的排查思路:屏"花"了先看 LDL/FDL 对不对(行/帧数据量);整屏"慢"了再看 fclk_freq;对比度差、残影浓先动 VCOM。这三个旋钮是画电子水墨画最常用的调色板,其它字段(LSL/LBL/LEL/FSL/FBL/FEL)一般保持厂商默认即可。
读 README 的「3.4 设置VCOM电压」时会看到,它举的例子是 R7D005 返回 2100、其它 1050——和代码里 epd_get_vcom_voltage() 完全一致。但也要留个心眼:README 里「3.3 调整时序与频率参数」给的参数表与 epd_get_timing_config() 字段一一对应,可一旦换成新屏,必须以屏幕数据手册为准——VCOM 是面板级的,抄错了同一块屏的电压是会物理上"压"坏的。
没有 EPIC 和 TPS 电源芯片的 SPI 小屏,把"波形"这件事全部收进屏幕自己的寄存器里。它内置了 5 组波形 LUT 寄存器:REG_LUT_VCOM(0x20)、REG_LUT_W2W(0x21)、REG_LUT_K2W(0x22)、REG_LUT_W2K(0x23)、REG_LUT_K2K(0x24)——分别描述"公共极"和"白→白 / 黑→白 / 白→黑 / 黑→黑"的过渡波形。驱动里的 EPD_LoadLUT 按当前刷新模式从 waveinit.h 的三套常量数组里挑一组写进寄存器:0=5S(清屏)、1=GC(全刷无残影)、2=DU(局刷有残影)。
这套"写寄存器"的流程还有个标志位细节:LUT_Flag 在 K2W 与 W2K 两组寄存器之间轮流切换(R22/R23 对调),用来抵消某些面板上反复刷带来的极性偏置。而"屏幕忙不忙"靠 EPD_BUSY 引脚的中断信号——驱动把 EPD_BUSY 挂成高电平中断,忙碌结束触发回调释放信号量 epd_busy_sem,写命令前 EPD_ReadBusy() 等信号量(2 秒超时兜底)。这与 8-bit 并行屏"等 LCDC 的 STATUS 寄存器"是两种不同的同步哲学:一个是硬件中断驱动的信号量,一个是轮询状态位。
| LUT 模式 | 含义 | 说明 |
|---|---|---|
0 | 5S | 清屏:全屏刷成黑或白 |
1 | GC | 全刷无残影:清晰但慢 |
2 | DU | 局刷有残影:快,翻页首选 |
| 寄存器 | 0x20~0x24 | VCOM / W2W / K2W / W2K / K2K |
// src/boards/display_spi/epd_display.c —— 按模式加载 LUT(节选)
static void EPD_LoadLUT(LCDC_HandleTypeDef *hlcdc, uint8_t lut_mode)
{
switch (lut_mode)
{
case 0: // 5S模式(清屏)
LCD_WriteReg(hlcdc, REG_LUT_VCOM, lut_R20_5S, 56);
LCD_WriteReg(hlcdc, REG_LUT_W2W, lut_R21_5S, 42);
LCD_WriteReg(hlcdc, REG_LUT_K2K, lut_R24_5S, 42);
if (LUT_Flag == 0) {
LCD_WriteReg(hlcdc, REG_LUT_K2W, lut_R22_5S, 56);
LCD_WriteReg(hlcdc, REG_LUT_W2K, lut_R23_5S, 42);
LUT_Flag = 1;
} else {
// R22/R23 对调,抵消极性偏置
LCD_WriteReg(hlcdc, REG_LUT_K2W, lut_R23_5S, 56);
LCD_WriteReg(hlcdc, REG_LUT_W2K, lut_R22_5S, 42);
LUT_Flag = 0;
}
break;
case 1: // GC模式(全刷无残影)
case 2: // DU模式(局刷有残影)
// ...同一套结构,换成 lut_Rxx_GC / lut_Rxx_DU
}
}
// 忙信号:EPD_BUSY 中断 → 信号量,写寄存器前等待
static void epd_busy_callback(void *args)
{
rt_sem_release(epd_busy_sem);
rt_pin_irq_enable(EPD_BUSY, PIN_IRQ_DISABLE);
}
static void EPD_ReadBusy(void)
{
rt_pin_irq_enable(EPD_BUSY, PIN_IRQ_ENABLE);
rt_err_t result = rt_sem_take(epd_busy_sem, RT_TICK_PER_SECOND * 2);
if (result != RT_EOK) {
rt_kprintf("EPD busy wait timeout! (may cause display error)\n");
}
rt_sem_control(epd_busy_sem, RT_IPC_CMD_RESET, RT_NULL);
}
// 送一帧:写新数据寄存器 + 触发自动刷新
uint8_t lut_mode = epd_get_refresh_mode(); // 1=全刷 2=局刷
EPD_LoadLUT(hlcdc, lut_mode);
HAL_LCDC_LayerSetData(hlcdc, HAL_LCDC_LAYER_DEFAULT, (uint8_t *)RGBCode,
0, 0, LCD_HOR_RES_MAX_8 - 1, LCD_VER_RES_MAX - 1);
if (HAL_LCDC_SendLayerData2Reg_IT(hlcdc, REG_WRITE_NEW_DATA, 1) != HAL_OK) return;
parameter[0] = 0xA5;
LCD_WriteReg(hlcdc, REG_AUTO_REFRESH, parameter, 1); // 触发屏幕自刷
reflesh_times++;
两套驱动放一起看,会发现同一个"全刷/局刷"节奏被实现了两遍:8-bit 并行屏在软件侧数帧数(reflesh_times % g_part_disp_times),SPI 屏在硬件侧分模式(5S/GC/DU)。这就是移植的妙处——上层应用只需要调用标准的 LCD_WriteMultiplePixels,底下选并行还是串行、用 EPIC 还是用寄存器,对应用完全透明。看驱动,先看它实现的是哪套 LCD_DrvOpsDef 回调,再看它各自调了哪些"屏的私货"。
给下列"线索"匹配接口:LCDC_INTF_DBI_8BIT_B、LCDC_INTF_SPI_DCX_1DATA、引脚 EPD_D0-D7、引脚 EPD_SCK/EPD_DC、freq=5000000、分辨率 240×416。分别对应 DBI 还是 SPI?
看 4.1 的对比表:数据线根数、接口宏、时钟、适用屏。
LCDC_INTF_DBI_8BIT_B、EPD_D0-D7、高分辨率都是 DBI(8-bit 并行)的特征;LCDC_INTF_SPI_DCX_1DATA、EPD_SCK/EPD_DC、freq=5000000、240×416 是 SPI(串行)的特征。一句话记:并行走 8 根线、线多块快;串行走 1 根线、线少省引脚。
结合 4.2 和 4.5,解释为什么翻书页通常用局刷,但每翻若干页就要强制一次全刷;代码里这个"若干"是由哪个变量/宏控制的?
找 g_part_disp_times 和 PART_DISP_TIMES,以及 reflesh_times % g_part_disp_times == 0 这句判断。
局刷只更新变化的像素,速度快,适合翻页这种高频小变化;但局刷帧数少、墨粒没被彻底推到位,连续多次会积累残影。所以每刷满 g_part_disp_times 次局刷,下一次就切全刷把整屏推倒重画一遍(两套驱动默认都是 10 次,宏 PART_DISP_TIMES 也是 10)。
<< 3 进阶对照 4.3,说明 epd_wave_table_fill_lut 里 p_epic_lut[i] = p_frame_wave[i] << 3 的输入、输出各是什么;为什么说它是一张 LUT 的"填充",而不是直接发像素?
输入是 8 位(旧 4 位+新 4 位),输出是 2 位灰阶,放进 32 位字的低 3 位;回忆"高 4 位旧、低 4 位新"。
每帧取 256 字节波形(p_current_wave_from + frame_num*256),字节下标就是"旧×新"组合,值是灰阶。左移 3 位把灰阶放进 32 位 EPIC LUT 字的低 3 位——所以它是在"按组合建表",供 EPIC 在扫描每一行时用它把混合灰度帧转成屏上电压波形,属于波形翻译阶段,不是最终的像素发送。这也解释了为什么帧缓冲是"旧+新"两层:没有旧像素,就查不了"怎么从旧变到新"。
假设厂商给你一块新的 8-bit 并行墨水屏(1032×758)和它的波形文件。参考 4.2-4.4 与 README「3.x 添加新的屏幕驱动」,列出要新建/修改的文件,并指出哪几个数值必须来自数据手册而不是照抄旧屏。
Kconfig.proj 加屏宏 + 分辨率/DPI;新建 epd_configs_你的屏.c 放波形表、时序、VCOM;波形可用二进制文件走 EPD_WAVEFORM_USE_BIN。
(1) project/Kconfig.proj:新增 LCD_USING_EPD_你的屏 选项并 select BSP_LCDC_USING_EPD_8BIT,给 LCD_HOR_RES_MAX/LCD_VER_RES_MAX/LCD_DPI 加 default;(2) 新建 src/boards/display_dbi/epd_configs_你的屏.c:把厂商波形转成 [帧数][256] 表(或复制 epd_waveform.bin 走 EPD_WAVEFORM_USE_BIN 路径),实现 epd_wave_table_get_frames / epd_wave_table_fill_lut / epd_get_timing_config / epd_get_vcom_voltage;(3) 若复用 epd_display.c,它已按 LCD_HOR_RES_MAX 等宏自适应。必须来自数据手册的数值:sclk_freq/fclk_freq、LDL/FDL、VCOM 电压、全刷/局刷帧数——这些是面板物理参数,照抄 R7D005 的 2100mV 到别的屏上是有风险的。