1. 为什么选GxEPD2库来驱动墨水屏
1.1 墨水屏和普通屏的驱动逻辑完全不一样
刚开始从LCD转到墨水屏的时候,我第一反应是:"这不就是个不怎么刷新的屏吗,用以前那套SPI/FSMC驱动方式还不是照样跑?"结果翻了车。墨水屏的工作原理决定了它不能用逐行扫描的方式持续刷新,而是要通过驱动芯片给每一个像素施加特定的电压波形,让黑白粒子发生翻转,而且翻转完成后不需要持续供电也能保持画面。这意味着控制逻辑里必须有"刷新时序"、"波形查找表"、"BUSY忙状态等待"这些环节。
普通屏幕你往显存里写像素就完事了,哪怕写慢点,只要不断刷新屏幕就正常显示。墨水屏不行,你得先告诉驱动IC:我现在要开始刷新了,IC会走一整段波形(有的长达两秒),中间你要是又发命令打断它,画面直接花掉甚至永久性残影。用ESP32和Arduino环境开发时,如果自己从寄存器层面去写这些流程,光适配一款屏幕的驱动IC就要折腾好几天。
1.2 GxEPD2库到底解决了什么问题
GxEPD2库是Jean-Marc Zingg一直在维护的开源库,它把所有主流墨水屏驱动IC的初始化序列、刷新波形、命令设置全部封装好了。你不需要去翻SSD1680的寄存器手册,也不需要自己去算LUT(查找表),只需要在代码里指定屏幕型号,然后调用几个简单的API,就能完成初始化、清屏、显示文字、显示图片。
更重要的是,GxEPD2不是只支持某一片特定型号,它把当前市面上常见的黑白、三色、四灰度墨水屏几乎都收进来了。你换屏幕的时候,往往只需要改一个驱动类宏定义,代码结构完全不用动。我后来从2.9寸黑白屏换到4.2寸三色屏,程序主体部分就改了十几行,这比我预想的要省事太多了。
1.3 对比U8g2和Adafruit_EPD,GxEPD2的优势在哪
国内很多Arduino玩家习惯用U8g2,因为它在OLED这类屏上确实好用。但U8g2对墨水屏的支持比较有限,它走的是像素点阵渲染的思路,对墨水屏的"部分刷新"和"全屏刷新"模式区分不够细,尤其是三色屏和四灰度屏,U8g2基本无能为力。Adafruit_EPD库也不错,但它更多是围绕Adafruit自家模块设计的,换成淘宝上几十块钱的通用墨水屏模组,引脚定义和驱动芯片对不上就得自己改库,挺遭罪的。
GxEPD2走的是"一个框架,按屏幕选驱动类"的路子。你买到的屏幕模块只要驱动芯片在支持列表里,就能用同一套初始化流程。遇到不支持的型号,也可以继承现有类或者新增一个驱动类,社区里已经有不少人这么干过。
1.4 支持范围与项目适配参考
我目前用过的GxEPD2支持屏幕包括常见的1.54寸、2.13寸、2.9寸、4.2寸、7.5寸,驱动芯片涵盖IL3829、SSD1680、SSD1675、UC8151等。颜色分类上有纯黑白、黑白红三色、黑白黄三色、四灰阶,刷新方式上有全刷和局刷。做桌面温湿度计、日程提醒牌、电子标签、离屏时钟都比较合适。下面要讲的这个项目,就是拿ESP32开发板配2.9寸黑白墨水屏,完整走一遍GxEPD2的驱动流程。
2. 硬件准备与开发环境搭建
2.1 硬件清单与选型建议
- ESP32开发板:我用的是一块ESP32 DevKitC(带CH340串口芯片的那种,Arduino IDE识别为ESP32 Dev Module)
- 2.9寸黑白墨水屏模块:驱动IC是SSD1680,常见于微雪、合宙等模块
- 杜邦线若干:最好选母对母,方便把屏幕模块直接插到ESP32开发板上
- 面包板和3.3V稳压模块:如果手头屏幕模块是5V逻辑供电版本,建议先看规格书
这里有个提醒:很多墨水屏模块自带电平转换电路,可以直接用3.3V供电和逻辑,但有些便宜的模块是裸屏转接板,逻辑电平可能是5V。ESP32的GPIO最高输出3.3V,如果要驱动5V逻辑的模块,建议加一块电平转换板,否则可能出现画面乱码或者DC/CS引脚拉不低的情况。
2.2 Arduino IDE里配置ESP32核心
我用的是Arduino IDE 2.x,开发板管理器里加ESP32支持。打开"文件"->"首选项"->"附加开发板管理器网址",填入官方那个JSON地址,然后在"开发板管理器"里搜索ESP32,安装最新版。这个流程很多教程都有,但我想重点说一句:务必装稳定版或者较新的release版本,实测有些老版本会导致GxEPD2在编译时报一些莫名其妙的SPI头文件错误。装好之后,在"开发板"菜单里选ESP32 Dev Module,Flash Size选4MB,其余默认就行。
2.3 安装GxEPD2库
打开"库管理器",搜索GxEPD2,认准作者Jean-Marc Zingg,点击安装。同时建议顺手装一下Adafruit GFX库,虽然GxEPD2不强制依赖它,但显示文字时要用的字体文件就是从Adafruit GFX里带的,装好能省一堆编译报错。GxEPD2库安装后会自带大量示例,其中有一个文件夹叫examples/GxEPD2_Example,里面用宏定义的方式列出了几十种屏幕型号,新手可以直接在头文件里把对应的宏打开。
2.4 引脚规划原则:别让墨水屏占了串口和下载引脚
ESP32的引脚很多,但并不是每个都能随便接。第一个要注意的坑是GPIO34到GPIO39只能做输入,不能做输出,所以CS、DC、RST、BUSY绝对不能接到这几个脚上。第二个坑是GPIO0、GPIO2、GPIO15,这些影响启动模式的引脚,虽然能作为普通输出用,但接线稍长就可能影响下载和启动。为了避免麻烦,我一般把墨水屏的四个控制脚放到GPIO4、GPIO5、GPIO16、GPIO17这些"干净"的引脚上。SPI的MOSI和SCK则用ESP32默认的VSPI引脚,也就是GPIO23和GPIO18。MISO一般用不到,墨水屏是单工通信,但有些模块有BUSY反馈,已经用独立GPIO处理了。
3. 引脚配置详解与接线实战
3.1 墨水屏模块引脚定义
市面上常见的2.9寸墨水屏模块,接口引脚基本是这几个:
| 引脚名 | 作用 |
|---|---|
| VCC | 电源正极,一般接3.3V |
| GND | 电源地 |
| DIN / MOSI | SPI数据输入 |
| CLK / SCK | SPI时钟 |
| CS | 片选信号,低电平有效 |
| DC | 数据/命令切换 |
| RST | 复位信号,低电平有效 |
| BUSY | 忙状态输出,高电平表示忙 |
有些模块还会多一个PWR_CTRL或排针带NC,不用管。屏幕模块上如果丝印写的是DIN、CLK,那就对应ESP32的MOSI和SCK,别傻乎乎找MISO。
3.2 推荐接线表(ESP32 DevKitC)
我的接线方案如下:
| 墨水屏引脚 | ESP32引脚 |
|---|---|
| VCC | 3V3 |
| GND | GND |
| DIN | 23 (MOSI) |
| CLK | 18 (SCK) |
| CS | 5 |
| DC | 17 |
| RST | 16 |
| BUSY | 4 |
这个组合的好处是避开了下载时可能被占用的串口引脚和启动配置引脚,同时和Arduino-ESP32的默认SPI定义完全一致,代码里不用额外设置SPI引脚。如果你的开发板引脚丝印不方便,可以把CS/DC/RST/BUSY换到其他输出引脚,但代码里的构造函数参数必须同步换。
3.3 代码中初始化GxEPD2对象
打开Arduino IDE,新建一个工程,先把这段代码贴进去:
#include <GxEPD2_BW.h> #include <Fonts/FreeMonoBold9pt7b.h> #include <Fonts/FreeSans9pt7b.h> #define EPD_CS 5 #define EPD_DC 17 #define EPD_RST 16 #define EPD_BUSY 4 GxEPD2_BW<GxEPD2_290_T94_V2, GxEPD2_290_T94_V2::HEIGHT> display( GxEPD2_290_T94_V2(/*CS=*/ EPD_CS, /*DC=*/ EPD_DC, /*RST=*/ EPD_RST, /*BUSY=*/ EPD_BUSY) ); void setup() { Serial.begin(115200); display.init(115200); } void loop() { }注意GxEPD2_BW的模板参数:第一个是屏幕驱动类,第二个是屏幕的HEIGHT,这个值在对应的驱动类里已经定义好了,直接写GxEPD2_290_T94_V2::HEIGHT就行。对象构造时把四个控制引脚按顺序填进去,接线不对或者对象参数写错,后续的显示肯定出问题。
如果手头屏幕和我不一样,比如是1.54寸的,就去GxEPD2库自带的GxEPD2_Example里把你那块屏幕对应的宏定义复制过来,把对象声明替换掉。不同屏幕的构造参数个数是一样的,只是驱动类名不同。
3.4 供电和电平注意事项
墨水屏刷新瞬间的电流比待机时要高不少,有些模块峰值能到20-30mA,虽然ESP32开发板的3.3V稳压器一般能顶住,但如果同时接了其他外设,比如温湿度传感器、OLED、蜂鸣器,建议用外部3.3V稳压模块统一供电,否则可能出现在屏幕刷新瞬间ESP32自动重启的情况。另外,千万不要把5V接进VCC想当然"供电更稳定",很多墨水屏模块的VCC走的是逻辑电平转换芯片,接了5V会把后级芯片烧掉。先看模块丝印,如果写了3.3V-5V兼容,再考虑5V。
4. 基础显示:文本与图形
4.1 初始化流程里的几个关键细节
display.init()不仅是初始化驱动IC,它还会调用reset复位屏幕,所以执行init之前要确保CS/DC/RST/BUSY已经接好。init之后我习惯立刻调用display.setRotation(1),把屏幕旋转到横屏状态,不然默认竖屏在2.9寸屏上看起来特别窄,后面排版麻烦。
display.firstPage()和display.nextPage()是GxEPD2特有的分页刷新机制,看起来有点像状态机。每次都把要显示的东西全部画在缓冲区里,然后nextPage()会真正把缓冲区推给屏幕驱动。以2.9寸为例,屏幕分辨率是128x296,全屏刷新如果不开分页,ESP32的P SRAM可能不够用。这个分页机制对新手来说很抽象,只要记得一个套路:
display.firstPage(); do { display.fillScreen(GxEPD_WHITE); display.setCursor(...); display.print(...); // 画其他东西 } while (display.nextPage());do-while会一直分页刷新直到整屏画完,千万不要在中间加delay或者长时间阻塞操作。
4.2 显示文本:字体、坐标与对齐
GxEPD2通过Adafruit GFX的字体来绘制文字。默认不带任何字体时,display.print()只会画一个字符占位框,必须手动设置字体。比如:
display.setFont(&FreeMonoBold9pt7b); display.setTextColor(GxEPD_BLACK); display.setCursor(10, 30); display.print("Hello ESP32");这里的坐标是字符左上角的基础线位置,跟普通LCD的写法一样。但墨水屏不像LCD那样有个普遍用的图形库,GxEPD2的文字没有自动居中函数,实际排版时得自己计算。比如要在宽度为128像素的屏幕上居中绘制"TEMP: 25C",先算一下字符像素宽度,2.9寸的9pt字体差不多一个字符6像素,自己手写一个居中算法最可靠。
字体文件在<Fonts/目录下,常用的有FreeMonoBold9pt7b、FreeSans9pt7b、FreeSansBold12pt7b。文件名称里的数字是pt字号,7b表示7位编码。如果显示中文,自带的字体不支持,需要用u8g2_font工具转中文字库或者自己用图片方式显示中文,这一点后面会提到。我一般项目里的中文标题都是用图片处理。
4.3 绘制图形:矩形、圆、线
墨水屏最实用的图形API就是fillScreen、drawRect、fillRect、drawCircle、drawLine。做一个简单的信息卡片时,可以这样画边框:
display.drawRect(5, 5, display.width() - 10, display.height() - 10, GxEPD_BLACK); display.fillRect(20, 40, 50, 30, GxEPD_BLACK);这里有个细节:display.width()和display.height()在setRotation之后会变化,比如横屏后width是296,height是128,所以动态获取尺寸比写死数字更稳妥。画完图形后,如果你不想整块区域全刷成黑色,又想做一个反色效果,可以手动对指定区域做像素遍历异或,但GxEPD2_BW类本身没有提供xOr操作,简单项目用填充矩形就够了。
4.4 一个小例子:做一个横向时间卡片
我把这些基础API组合起来,做了一个开机显示时间日期的小卡片,完整代码如下:
void displayDateTime(const char* timeText, const char* dateText) { display.setRotation(1); display.firstPage(); do { display.fillScreen(GxEPD_WHITE); display.setFont(&FreeSansBold12pt7b); display.setTextColor(GxEPD_BLACK); display.setCursor(20, 35); display.print(timeText); display.setFont(&FreeMonoBold9pt7b); display.setCursor(20, 60); display.print(dateText); display.drawRect(10, 10, display.width() - 20, display.height() - 20, GxEPD_BLACK); } while (display.nextPage()); }实际测试下来,从调用到显示完大概要2到3秒,屏幕会先闪一下,然后慢慢出图。这是正常的全屏刷新过程,GxEPD2默认用慢速刷新,能有效减少残影。如果你想加快速度,GxEPD2里有些屏幕驱动类支持display.epd2.setPaged之类的参数调整,但提高刷新速度会增加残影风险,后面会细说。
5. 图像显示:从数组到本地图片
5.1 图像显示前必须搞清楚的三个约束
墨水屏显示图片不像手机屏幕那么自由,首先分辨率要匹配,2.9寸屏是128x296,你拿一张1920x1080的图直接塞进去肯定不行,必须压缩到目标分辨率。其次是颜色深度,黑白墨水屏只能显示两种颜色,彩图需要经过二值化处理。最后是存储格式,GxEPD2的drawBitmap接口接收的是const unsigned char*数组,每个bit对应一个像素,要么把图片转成这种数组直接编译进固件,要么从SD卡读取BMP文件在运行时解析。
5.2 用取模工具生成C数组
我最常用的方式是电脑端先用Python的Pillow库处理图片,再输出C数组。也可以用Image2LCD这类工具,但实测Image2LCD默认输出格式需要手工调整,还是Python可控。例程代码:
from PIL import Image img = Image.open("input.png").convert("1").resize((128, 296)) pixels = img.load() out = [] for y in range(0, 296, 8): for x in range(128): byte = 0 for bit in range(8): if y + bit < 296 and pixels[x, y + bit] > 127: byte |= (1 << bit) out.append(byte) with open("image.h", "w") as f: f.write("const unsigned char logo[] = {\n") for i in range(0, len(out), 12): f.write(" " + ", ".join(f"0x{val:02X}" for val in out[i:i+12]) + ",\n") f.write("};\n")生成的头文件里是一个unsigned char数组,在Arduino工程里#include "image.h",然后用这几行显示:
extern const unsigned char logo[]; display.firstPage(); do { display.fillScreen(GxEPD_WHITE); display.drawBitmap(20, 10, logo, 128, 296, GxEPD_BLACK); } while (display.nextPage());注意drawBitmap的宽度和高度参数必须和数组里存的分辨率保持一致,而且这里的GxEPD_BLACK指的是前景色,如果数组第1位表示黑色,那传GxEPD_BLACK就是正常显示。这个方向搞反了,图片会变成负片效果。
5.3 从SD卡读取BMP显示的方案
如果你希望机器跑起来后随时换图,不想每次重新编译烧录,可以把BMP文件放在SD卡里。SD卡和墨水屏同时挂在SPI总线上,需要给SD卡单独一个CS引脚,比如GPIO21。代码里要用到SD_MMC或SD库。这个方案的好处是图片文件可以直接用USB拷到SD卡里,内容更新非常方便。
具体流程是:初始化SPI,把SD库和GxEPD2都指向同一个SPI对象。然后读取BMP文件头部,确认文件确实是24位或32位BMP格式,再逐像素解析成单bit缓存。GxEPD2库中也提供drawBitmap参考代码,在examples/GxEPD2_Example里有一个从SD卡读取BMP的函数,但完整度不够,需要自己补全解析函数。这里贴一个精简版的读取24位BMP并显示的核心概念:
void drawBMPFromSD(const char* filename) { File bmpFile = SD.open(filename); if (!bmpFile) return; uint8_t header[54]; bmpFile.read(header, 54); int width = header[18] | (header[19] << 8) | (header[20] << 16) | (header[21] << 24); int height = header[22] | (header[23] << 8) | (header[24] << 16) | (header[25] << 24); int bitCount = header[28] | (header[29] << 8); // 这里省略BMP行对齐和像素读取细节 // 最终把每个像素二值化后写入GxEPD2的缓冲 }BMP的行数据是倒序存储的,而且每行要按4字节对齐,新手很容易在这两个地方踩坑。如果不是必须用SD卡,建议先用C数组方式,等基础显示跑通了再升级到SD卡。
5.4 灰度与抖动处理
2.9寸SSD1680这种屏本身只支持1bit黑白,但有些4灰阶的墨水屏比如UC8151,GxEPD2对应的是GxEPD2_4G类,可以显示4种灰度。如果只有黑白屏又想显示灰度照片,可以用Floyd-Steinberg抖动算法把灰度图转成黑白点阵图,视觉上会有类似报纸照片的网点效果。Python里Pillow库自带抖动转换:
img = Image.open("gray.jpg").convert("L").resize((128, 296)) img = img.convert("1", dither=Image.FLOYDSTEINBERG)然后按照前面pickle方式转成数组。抖动算法会让画面边缘更自然,不会出现大面积色块,但屏幕刷新时间会明显增加。实测一张经过抖动的图片全刷时间能从2秒涨到3秒多。
6. 实测踩坑与问题排查
6.1 白屏:先别怀疑库,查供电和时序
白屏是墨水屏驱动最典型的故障,我遇到三次白屏,两次是供电问题,一次是复位时序问题。第一次屏幕模块的VCC接了ESP32的5V,模块上的稳压芯片当场发热但没烧,画面始终全白。换回3.3V后正常。第二次是杜邦线CS引脚接触不良,屏幕初始化白屏,后来把CS线换成焊接线解决。第三次更隐蔽:BUSY引脚一直悬空没有接到ESP32,GxEPD2在初始化时等不到ICU空闲标志,直接超时返回,屏幕当然白屏。
如果你确认接线没问题,可以在display.init()前面手动加一个复位时序:
pinMode(EPD_RST, OUTPUT); digitalWrite(EPD_RST, LOW); delay(20); digitalWrite(EPD_RST, HIGH); delay(200);有些屏幕的复位电路比较敏感,GxEPD2内部虽然会发复位命令,但自己手动拉低再拉高能让驱动IC状态更可预测。还有一点,BUSY引脚必须接,并且代码里构造GxEPD2对象时传入的BUSY引脚要跟实际一致,GxEPD2在大部分刷新场景下会等待BUSY变低,接错或者悬空就是白屏或刷新卡死。
6.2 花屏和残影:刷新模式与温度的影响
花屏常见于在屏幕刷新过程中又调用了display.hibernate(),或者在上一次刷新还没结束时再次调用display.nextPage()。墨水屏刷新需要完整走完波形,中断刷新会产生乱七八糟的灰阶残留。GxEPD2里对刷新保护还算严格,正常情况下不会并行刷新,但如果你在loop里频繁调用刷新函数,要自己加一个布尔标志位避免冲突。
残影是墨水屏的天性,全屏刷新时GxEPD2会执行一整轮"先清全黑再清全白再出图"的流程,残影会轻很多。如果你用局部刷新模式,比如只改一个数字,那残影就挺明显。低温环境下残影会更重,温度低于10度时最好先做一次全屏预热刷新,否则画面会灰蒙蒙一片。
6.3 GxEPD2爷爷级问题:驱动类选错与内存溢出
评论区经常会看到有人说"我的屏幕也是2.9寸,为什么代码编译不过",大概率是驱动类选错了。同样是2.9寸墨水屏,微雪的V1版本和V2版本驱动芯片不一样,可能是SSD1680和SSD1681,或者旧版是IL3829,选错驱动类不是编译报错,就是初始化乱码。建议看一眼屏幕背面芯片丝印,然后去GxEPD2的README或者GxEPD2_display_selection.h里找对应型号。
ESP32虽然有240KB左右的RAM,但GxEPD2在分页模式下也需要预留大概8KB到32KB的缓冲区。Arduino IDE的默认堆设置一般够用,但如果你在代码里用了大量String拼接或者开过大的全局数组,可能刷屏时触发重启。我习惯用ESP.getFreeHeap()打印一下空闲堆内存,至少保留30KB以上再刷新。
6.4 常见问题速查表
| 现象 | 可能原因 | 处理方案 |
|---|---|---|
| 全白屏 | VCC供电错误、CS虚接、BUSY悬空 | 检查供电电压,重新插拔/焊接CS,BUSY接GPIO4 |
| 初始化卡死 | RST时序不对、驱动类选错 | 手动复位,确认屏幕芯片型号 |
| 画面花屏 | 刷新中途被中断、时钟频率过高 | 降低SPI速度到4MHz,刷新期间禁止其他中断 |
| 残影严重 | 没走全屏刷新流程、低温 | 强制全屏清黑再全清白,预热后再出图 |
| 编译报错 | 模板参数不对、缺少Adafruit GFX | 检查GxEPD2_BW模板参数,安装Adafruit GFX |
| 显示负片效果 | 前景色设置反了 | 检查drawBitmap最后一个参数 |
| 屏幕闪烁但无内容 | DC引脚接错或驱动类不匹配 | 确认DC引脚,换成对应驱动类 |
7. 进阶思路与低功耗优化
7.1 用Deep Sleep把待机电流降到微安级
墨水屏最大的价值就是静态显示不耗电,所以低功耗是这个领域绕不开的话题。很多ESP32开发板自带的USB转串口芯片在睡眠时也会耗电几毫安,所以我后来做低功耗项目时直接把板子上的稳压器和串口芯片跳线断开,只用电池供ESP32芯片。代码侧使用esp_deep_sleep_start(),定时唤醒后刷新一次数据,然后继续睡。GxEPD2的display.hibernate()进入休眠时,屏幕功耗几乎为零,但注意hibernate之后不能直接重新显示,需要先调用display.init()重新初始化,或者直接调用display.wakeup()。
我的低温湿度计大概每30分钟唤醒一次,读取传感器、刷新屏幕、再睡,4节18650电池并联可以跑大半年。如果你也想做类似项目,唤醒后建议先读RTC内存判断这次唤醒是不是无效唤醒,避免因为GPIO touch wake触发导致频繁刷新。
7.2 局部刷新和功耗的平衡
墨水屏局部刷新可以显著降低刷新功耗,因为不需要把整个屏幕的粒子都翻转一遍。GxEPD2的display.writeImage()接口可以指定区域,或者是使用display.firstPage()配合display.setPartialWindow()。但局部刷新的残影问题比全刷严重,尤其是大块黑色区域更新时。我自己的策略是:重要信息比如日期、标题采用全刷,数字变化部分采用局刷,并且每天固定一个时间全刷一次清残影。这样既省电,又不会出现越用越糊的情况。
7.3 扩展:多块屏幕、传感器和远程更新
如果你以后想玩更复杂的,可以用ESP32的一路SPI接多块墨水屏,每一块用独立CS片选。刷新的时候先选中对应屏幕,再调用GxEPD2对象的显示函数。GxEPD2也支持建立多个display实例,但要注意同一时刻只能有一个实例在总线上工作。配合传感器,比如DHT11或BME280,把温湿度、空气质量画成卡片,就是完整的桌面信息屏。再往后可以加WiFi拉取天气API,或者用WebServer做一个小后台,上传图片到SD卡更新屏幕,这个项目就能演变成一个自用的电子公告牌了。
最后分享一个我自己的小习惯:每次给墨水屏写布局前,先在纸上把坐标算一遍,尤其注意setRotation之后宽高是否变化。我因为在横屏和竖屏之间反复切换,经常出现字和边框错位,后来干脆强制把屏幕固定为一个方向,所有尺寸取display.width()和display.height()动态计算。这样哪怕换屏,代码也不用改坐标。做墨水屏项目最大的乐趣就在排错和打磨布局的过程,希望这篇梳理能让你少走点弯路。