前阵子收拾书架的时候,看着那些积灰的纸质书,突然想做一个真正属于自己的电子阅读器。要求不高:屏幕要护眼、能装几本常看的小说、最重要的是从电脑或手机传书要方便。市面上主流电纸书当然能买,但自己用便宜物料焊一台,那种“什么都能控制”的感觉完全不一样。所以就有了这个项目:用ESP32做主控,配一块2.13寸墨水屏,做一个能通过WiFi自动接收书籍文件的小型阅读器。整个过程不算复杂,核心就三件事——驱动墨水屏显示文字、处理文本分页、让ESP32开一个网页上传服务。最终的效果是:手机连上阅读器开的热点,浏览器里选一个txt文件传上去,屏幕上立刻出现书页,还能按键翻页、记住阅读进度。这篇文章就把我整个踩坑和实现过程写出来,从引脚接线到代码逻辑都给你捋清楚,适合手里有ESP32开发板和墨水屏模块、想自己动手折腾的玩家。
1. 项目整体设计与思路拆解
1.1 核心需求:一台能“传书”的阅读器
动手之前我先反复确认了需求:我并不是要做一个能媲美商用电纸书的设备,而是做一个“电子书工具”:每天导入几章公众号文章、小说TXT、甚至我整理的读书笔记,在墨水屏上看,不伤眼睛,也不容易被手机消息干扰。所以项目的核心功能只有三点:第一,屏幕能稳定显示中文小说内容;第二,电脑或手机能通过WiFi把文件传进来;第三,翻页流畅、关机能保存进度,打开还能继续看。
这个定位决定了整个技术选型。商用设备动不动几千块,是因为它要解决排版、格式、字体渲染、超大存储等一系列问题。而我的需求很克制,用一个带WiFi的MCU加一块低功耗屏就能覆盖。我不需要背光,不需要触控,也不需要高性能处理器。把需求收敛得足够小,实现难度就直线下降,这才是DIY项目能做成的关键。
1.2 为什么选ESP32和2.13寸墨水屏
选ESP32几乎是必然的。这个芯片带双核240MHz处理器、520KB SRAM,还内置WiFi和蓝牙双模,价格却只要十几块钱,开发板更是白菜价。它的Flash有4MB甚至16MB版本,这对本地存储小体积TXT文本绰绰有余。市面上的Arduino、树莓派Pico等等级单片机也能做显示驱动,但要做“WiFi传书”就麻烦得多,要么需要外挂WiFi模块,要么网络性能不稳定。ESP32天生就是干这个的,一张开发板全搞定。
墨水屏选2.13寸,分辨率250x122,驱动IC大多是SSD1680。我之所以选这个尺寸,第一是它功耗极低,只在刷新瞬间耗几毫安电流,静态显示完全不耗电,非常适合阅读这种长时间显示静态画面的场景;第二是它们的模组很普及,淘宝几十块就能买到带转接板的模块,SPI接口也容易接线;第三,2.13寸刚好能显示20x10个12像素汉字,一屏能装两百字左右,读三五行翻一页,节奏正好。
1.3 方案选型与取舍对比
先列一下我到最终版本之间做过的几个关键选型:
| 对比项 | 选项A | 选项B | 我的选择 | 理由 |
|---|---|---|---|---|
| 墨水屏驱动库 | Adafruit_GFX | GxEPD2 | GxEPD2 | 对多款SSD1680模组支持完善,自带全刷/局刷接口 |
| 中文字体方案 | 字模数组 | U8g2字库 | U8g2 | 内置文泉驿12像素汉字字库,不需要自己取模 |
| 书籍存储 | SD卡模块 | LittleFS | LittleFS | ESP32 Flash足够存几十本TXT,省掉一张TF卡和接线 |
| Web服务库 | ESP8266WebServer | 内置WebServer | 内置WebServer | Arduino专用,稳定不依赖第三方异步库 |
| 翻页刷新 | 局部刷新 | 全屏刷新 | 全屏刷新 | 局刷会有残影,阅读场景翻页频次低,全刷更干净 |
这些选择不是拍脑袋,每一项都对应着特定的工程问题。比如存储,SD卡方案虽然容量大,但会让接线多一倍的引脚,而且SD卡模块供电不稳会造成读卡失败,排查起来非常麻烦。在只读TXT文本的情况下,ESP32自带的LittleFS分区足够放几十万字的内容,所以直接砍掉SD卡,项目可靠性大幅提升。选择U8g2字库也是,如果自己取模一个12x12的汉字库,几千个汉字就要大几十KB空间,还得写脚本转换,而U8g2里已经集成了文泉驿点阵字库,直接调用即可,编译后Flash占用多一点,但开发效率高得多。
2. 硬件准备与接线详解
2.1 元器件清单与购买建议
这个项目的物料极其克制,总共就这么几样:
- ESP32开发板一块(DevKit V1,带38引脚排针,ESP32-WROOM-32模组)
- 2.13寸墨水屏模块(SSD1680驱动,带FPC转接板,引脚接口是SPI)
- 三个轻触按键,用来做翻页、菜单、返回功能
- 一个10uF和一个100uF的电解电容,给3.3V供电做滤波
- 若干杜邦线和面包板,或者直接洞洞板焊接
- 可选:一个锂电池和3.3V升压板,做成便携版
买墨水屏的时候有个坑:市面上2.13寸墨水屏有两代产品,分辨率虽然一样,但驱动IC不同,老款是SSD1675,新款是SSD1680,两者的初始化时序不完全一样。购买时尽量选“SSD1680”驱动的,因为GxEPD2库里对这个型号支持最成熟。如果买到的是SSD1675,在写代码时把GxEPD2的类改成对应的型号也能驱动,但别搞混,否则屏幕刷新会异常。
2.2 引脚定义与完整接线表
ESP32 DevKit V1有38个引脚,实际这次项目用到的不到10个。墨水屏走SPI,我用的是VSPI接口:MOSI、SCK、CS、DC、RST、BUSY这6根信号线。按键则各自接一个GPIO并开启内部上拉,按下时读到低电平。
| 功能 | 墨水屏引脚 | ESP32 GPIO | 备注 |
|---|---|---|---|
| 电源 | VCC | 3.3V | 注意不要接5V,会烧屏幕 |
| 地 | GND | GND | 保证共地 |
| 数据 | DIN(MOSI) | GPIO23 | 串行数据输入 |
| 时钟 | CLK(SCK) | GPIO18 | SPI时钟 |
| 片选 | CS | GPIO5 | 低电平有效 |
| 数据/命令 | DC | GPIO17 | 高电平为数据,低电平为命令 |
| 复位 | RST | GPIO16 | 低电平复位 |
| 忙检测 | BUSY | GPIO4 | 高电平表示屏幕忙 |
按键部分,我用了GPIO12、GPIO14和GPIO27。GPIO12在ESP32上是特殊引脚,不能接上拉电阻,因为如果上拉到高电平会导致Flash电压异常,使芯片无法启动。所以如果你也把按键接在GPIO12上,要记得开启内部下拉电阻模式等待高电平触发,而不是用常见的“按下接GND”方式。这个坑让我折腾了两个小时,后面会在问题排查里详细说。
2.3 供电设计要点:避开复位电流的坑
很多人做ESP32项目时忽略了供电,结果屏幕刷新瞬间单片机就重启,或者WiFi连上后运行一会儿就死循环。ESP32的WiFi工作瞬时电流能达到240mA甚至更高,加上墨水屏驱动电路,峰值电流可能超过300mA。如果用一个普通AMS1117-3.3V从5V供电,虽然芯片标称最大输出1A,但实际在小压差场景下性能一般,很容易跌落到3.3V以下,触发芯片欠压复位。
我的做法是:外接5V电源先经过一个二极管防反接,再进入AMS1117-3.3V,在3.3V输出端并联一个100uF钽电容和一个10uF瓷片电容。这个组合能有效吸收瞬时电流尖峰。实际测试时,用USB供电后接入WiFi热点,电压跌落控制在0.1V以内。另外,千万不要尝试用3.3V直接驱动墨水屏背面的升压电路,屏幕的VCC引脚应该接3.3V,而不是接5V,虽然大部分模块做了电平转换,但万一买到简化版模块,接错可能直接烧坏屏幕。
3. 开发环境搭建与基础验证
3.1 Arduino IDE安装ESP32核心
代码开发我选择Arduino IDE,原因特别简单:GxEPD2和U8g2这两个关键库的示例和文档都以Arduino为主,PlatformIO虽然工程管理更好,但在这种小项目里没有明显优势,反而会增加配置负担。我在Arduino IDE的“开发板管理器”地址中添加乐鑫官方JSON源,然后打开开发板管理器搜索“esp32”并安装。如果你下拉列表里看不到esp32 by Espressif,多半是网络连接问题,可以检查“文件—首选项—附加开发板管理器网址”是否填对了。
安装成功后,在“工具—开发板”里选择“ESP32 Dev Module”,在Flash Size里选4MB,分区方案选择“Huge APP (3MB No OTA)”或者“Default 4MB”。对于我这个项目,我建议选择“Huge APP (3MB No OTA)”,因为U8g2字库编译后的固件体积会超过1MB,默认分区里App区只有1.2MB,编译可能直接报错提示固件太大。
3.2 墨水屏驱动库安装与选型
在Arduino IDE的库管理器里搜索并安装两个库:GxEPD2和U8g2_for_Adafruit_GFX。前者负责墨水屏的底层驱动,比如全刷、局刷、休眠;后者负责在高分辨率屏上绘制中文文字,它就是我在前面选型时提到的U8g2字库整合方案。注意,U8g2_for_Adafruit_GFX是U8g2库的附加包装,所以同时也要安装U8g2本体。
选GxEPD2库时要看清它内部对不同型号屏幕的类名定义。我的屏幕是SSD1680驱动,在GxEPD2里面对应的类是GxEPD2_213_B74,构造时传入CS、DC、RST、BUSY四个引脚即可。如果你的屏幕是SSD1675驱动,则需要换一个类。最稳妥的确认方式是看GxEPD2源码里的GxEPD2_display_selection.h文件,里面把所有支持的屏幕排列了出来,找到自己型号对应的那一行。
3.3 烧录前必须检查的三个设置
第一个是开发板型号,选择错了会烧录失败或者串口时序错乱;工具菜单里如果同时插入了USB转TTL等其他设备,要确认串口选的是ESP32的COM号。第二个是Flash大小和分区,我之前把Flash选成16MB但实际板子只有4MB,烧录时写入成功但上电后疯狂崩溃,排查了很久才发现是Flash size不匹配。第三个是波特率,下载速度115200通常稳,如果遇到“A fatal error occurred: Timeout waiting for packet header”的报错,按住开发板上的BOOT键不放,再点击下载,看到“Connecting…”字样时松开BOOT键,就能进入下载模式。
这里额外说一句,很多新手把USB线插上后什么都不设置就直接烧录,大概率会失败。ESP32在烧录前需要进入下载模式,不同开发板的操作略有差异,但DevKit V1一般只需要按住BOOT再点烧录,出现进度条后松开即可。如果你连“Connecting”都看不到,检查驱动装没装,Windows下需要CH340或CP2102驱动,Mac和Linux一般免驱。
4. 核心功能代码实现
4.1 墨水屏驱动与文字显示
我采用面向对象思路,把显示相关操作封装成一个DisplayManager类。初始化时先调用display.init(),然后设置屏幕方向,最后调用U8g2字体库的begin(display)把两者绑定。
#include <GxEPD2_BW.h> #include <U8g2_for_Adafruit_GFX.h> #define EPD_CS 5 #define EPD_DC 17 #define EPD_RST 16 #define EPD_BUSY 4 GxEPD2_BW<GxEPD2_213_B74, GxEPD2_213_B74::HEIGHT> display( GxEPD2_213_B74(EPD_CS, EPD_DC, EPD_RST, EPD_BUSY)); U8G2_FOR_ADAFRUIT_GFX u8g2Fonts; void initDisplay() { display.init(115200, true, 2, false); display.setRotation(0); u8g2Fonts.begin(display); u8g2Fonts.setFont(u8g2_font_wqy12_t_gb2312); u8g2Fonts.setFontMode(1); // 透明背景 u8g2Fonts.setForegroundColor(GxEPD_BLACK); u8g2Fonts.setBackgroundColor(GxEPD_WHITE); }这里有两个容易踩的坑。第一个是display.init()的第三个参数reset_duration,不同模组参数不一致,如果发现初始化后屏幕一直处于忙状态,把它从2调成3或4试试。第二个是setRotation(0)的方向,如果显示文字是横躺的,改成display.setRotation(1)就会旋转90度。我个人习惯横向显示,因为2.13寸的宽度是250像素,横放能在一行放20个汉字,阅读宽度比较舒服。
4.2 文本分页算法实现
墨水屏不像手机可以上下滑动,它更适合“翻页”:一屏显示固定内容,翻页后整屏替换。整个项目最核心的算法就是分页。我用一个全局的int textPos变量保存当前读到文本的字节偏移量,然后每一帧从textPos开始,按“像素宽度”一个一个读取UTF-8字符,直到填满当前页。
const uint16_t SCREEN_W = 250; const uint16_t SCREEN_H = 122; const uint16_t LINE_H = 14; // 12像素字高,加两像素行距 String bookText; // 整个书的文本内容 int textPos = 0; void renderPage() { display.clearBuffer(); u8g2Fonts.setFont(u8g2_font_wqy12_t_gb2312); int pos = textPos; int y = 2; while (pos < bookText.length() && y + LINE_H <= SCREEN_H) { uint16_t lineWidth = 0; String line = ""; while (pos < bookText.length()) { uint8_t c = bookText[pos]; // 根据UTF-8首字节判断字符占用长度 int charLen = 1; if (c >= 0xE0) charLen = 3; else if (c >= 0xC0) charLen = 2; if (charLen == 1 && c == '\n') { pos++; break; } if (pos + charLen > bookText.length()) break; String ch = bookText.substring(pos, pos + charLen); uint16_t w = u8g2Fonts.getUTF8Width(ch.c_str()); if (lineWidth + w > SCREEN_W) break; line += ch; lineWidth += w; pos += charLen; } u8g2Fonts.setCursor(2, y); u8g2Fonts.print(line); y += LINE_H; } display.display(); }这个算法看似简单,却是最容易出bug的地方。第一,它必须“按字符宽度”累加,而不能简单截取固定字节数,因为英文字母在u8g2内置字体中只有6像素宽,中文是12像素宽,如果按字节截会把一行塞爆。第二,它要处理字符截半的问题,比如刚好遇到一个汉字被切成两半,那半个字节会变成乱码,所以用charLen判断字节数后整体取出。第三,遇到换行符时要把当前位置跳过换行符本身,但英文换行和中文换行逻辑都要兼容。
4.3 WiFi传书服务实现
传书是本项目的灵魂功能。我的实现方式是:ESP32启动后先开启一个SoftAP热点,名字叫eReader,密码为12345678,然后在内网开启一个HTTP服务器,监听80端口。手机上连接这个热点后,浏览器访问192.168.4.1,就能看到一个简单的上传页面。
#include <WiFi.h> #include <LittleFS.h> #include <WebServer.h> WebServer server(80); File uploadFile; void handleRoot() { String html = F("<!DOCTYPE html><html><head><meta charset='utf-8'>" "<title>传书</title></head><body>" "<h3>上传TXT文件到阅读器</h3>" "<form method='POST' action='/upload' enctype='multipart/form-data'>" "<input type='file' name='file'><br><br>" "<input type='submit' value='上传'></form></body></html>"); server.send(200, "text/html", html); } void handleUpload() { HTTPUpload& upload = server.upload(); if (upload.status == UPLOAD_FILE_START) { uploadFile = LittleFS.open("/book.txt", "w"); } else if (upload.status == UPLOAD_FILE_WRITE) { if (uploadFile) uploadFile.write(upload.buf, upload.currentSize); } else if (upload.status == UPLOAD_FILE_END) { if (uploadFile) { uploadFile.close(); } server.send(200, "text/plain", "Upload OK"); } } void setup() { LittleFS.begin(); WiFi.softAP("eReader", "12345678"); server.on("/", handleRoot); server.on("/upload", HTTP_POST, []() { // 上传完成后的回调 }, handleUpload); server.begin(); }实际测试时,网页上传一个2MB的TXT文件到LittleFS大约需要10秒,速度取决于热点信号和孩子列入网络的稳定程度。上传完成后,代码会把文件内容整体读入RAM,再调用renderPage()显示第一页。要注意的是,这里为了简化示例直接用了固定的/book.txt作为存储文件名,也就是说每次上传都会覆盖之前的书。如果需要管理多本书,可以在文件名后面加时间戳或者序号,构建一个简单的书库列表。
4.4 翻页交互与阅读进度记忆
翻页按键需要做消抖,否则按一次可能跳两页。我采用状态变量加时间戳的方式,在循环中轮询检测,只有按键状态从高电平变为低电平,并且距离上次触发超过80毫秒,才处理一次翻页。
#define BTN_NEXT 14 #define BTN_PREV 27 int lastButtonState = HIGH; unsigned long lastDebounceTime = 0; void loop() { int reading = digitalRead(BTN_NEXT); if (reading != lastButtonState) { lastDebounceTime = millis(); } if ((millis() - lastDebounceTime) > 80 && reading == LOW && lastButtonState == HIGH) { nextPage(); } lastButtonState = reading; server.handleClient(); }阅读进度的记忆我用的是ESP32的Preferences库,它内部基于NVS参数存储,掉电不丢失。翻页时把textPos写入NVS,开机时读取,如果读取到有效值就直接跳到上次的位置。这里有一个细节:如果每次都调用prefs.begin()和prefs.end(),会频繁写入Flash,减少Flash寿命。所以我只在切换页面或者关机时才写一次,阅读过程中所有进度都保存在内存里。
#include <Preferences.h> Preferences prefs; void saveProgress() { prefs.begin("reader", false); prefs.putInt("pos", textPos); prefs.end(); } void loadProgress() { prefs.begin("reader", false); textPos = prefs.getInt("pos", 0); prefs.end(); }5. 常见问题与排查技巧实录
5.1 花屏和残影问题排查
墨水屏最常见的问题就是花屏。如果上电后屏幕出现随机噪点或者刷新一半停住,首先检查SPI接线:CS、DC、RESET、BUSY这几根信号线如果接触不良,偶尔能显示偶尔花屏。我建议在接线后用万用表量一遍连通性,不要只用肉眼确认杜邦线插紧了。CS和DC线如果接反了,GxEPD2库会初始化失败,屏幕直接白屏或黑屏。
残影问题是墨水屏固有特性,局部刷新时残影尤其明显。我的方案是每次翻页都做全屏刷新,虽然刷新时间要1.5秒左右,但阅读场景下这个延迟完全可以接受。如果你想要更流畅的体验,也可以实现“局刷翻页+每十页全刷一次”的策略,但局刷一多,残影会积累成阴影,看着非常难受。
5.2 WiFi连接不稳定与上传超时
ESP32开热点后,手机偶尔会提示“没有网络”,这是正常现象,因为热点本身不提供网络出口。你只需要在浏览器地址栏输入192.168.4.1,不必依赖系统的“已连接WiFi”提示。
上传大文件超时是另一个常见问题。WebServer库的客户端处理一次请求要占用一个连接,如果上传过程中有数据包丢失,重传可能导致内存溢出或超时。我的经验是,把上传文件限制在10MB以内,同时在上传前用LittleFS.remove("/book.txt")删掉旧文件,确保有足够的空间。另外,尽量避免在WiFi信号差的情况下传书,ESP32的SoftAP本身信号强度一般,距离超过三米就可能出现速度变慢。
5.3 反复重启问题:供电与复位电流
如果发现在一刷新屏幕或者WiFi连上的瞬间ESP32重启,十有八九是供电问题。这类问题的典型现象是串口监视器打印一行“Brownout detector was triggered”之后重启。这个英文提示的意思是芯片检测到供电电压跌落,触发了欠压保护。解决办法就是把100uF电容尽可能靠近开发板的3.3V引脚,铜线太长会产生寄生电感,影响瞬时供流能力。
还有一种情况,就是ESP32开发板上的USB转串口芯片和主控共用5V电源,当主控瞬时电流过大时,USB口电压被拉低,电脑端可能弹出“USB设备无法识别”。这时应改用独立5V电源给开发板供电,不要依赖电脑USB口的电流稳定度。
5.4 中文乱码与编码陷阱
上传的TXT文件如果是从Windows记事本保存的,大概率是GBK编码,而ESP32侧使用UTF-8处理文本,直接读出来就是乱码。这正是很多初学者问“为什么中文显示全是问号”的原因。
最简单的解决办法是在传书之前,把TXT文件另存为UTF-8编码,记事本保存时在编码下拉框选择“UTF-8”。如果你想在代码里做一个自动转换,也可以引入GBKtoUTF8这类转换库,但会让代码量增加不少,而且转换表会占用额外Flash空间。我的建议是:与其在做转换上费功夫,不如做一次“文件格式检查”——在上传完成后读取文件开头几个字节,如果发现非UTF-8编码,就提示用户改成UTF-8再传。还有一个小坑:UTF-8 BOM(文件开头三个字节EF BB BF)会导致第一页显示一个隐藏字符,读取文件时记得把BOM跳过去。
6. 后续扩展与我的实操心得
6.1 可以做哪些“接地气”的扩展
这个项目的基础版已经能完整实现“传书—阅读—翻页—记忆进度”的闭环,但我继续做了几个小扩展,都效果不错。
第一个是加蓝牙传书:很多人说ESP32蓝牙和WiFi不能同时用,其实它们可以共存,只是在时间片切换上会有相互影响。我在项目里增加了BLE服务的支持,手机用BLE调试助手发送TXT文件的数据块,代码接收到后写入LittleFS,这样就可以在没有电脑的情况下通过手机蓝牙传书。实际测试时,BLE传输速度大约每秒1KB,传一本几百KB的小说要几分钟,不如WiFi快,但胜在连接简单。
第二个是加多本书管理:在LittleFS里建立一个目录,每次上传时用时间戳命名文件名,然后在墨水屏上做一个极简的“书库列表”界面,用按键上下选择,按确认键打开。这也就是说,阅读器要为每本书维护一个独立的进度记录,我选用文本偏移量存在NVS里的一个JSON字符串中,格式大概是{"book1":123,"book2":456}。
第三个是加自动关机:如果五分钟没有按键操作,调用esp_deep_sleep()进入深度睡眠,唤醒方式可以用一个GPIO连接到按键。深度睡眠时整机电流降到几十微安,一个2000mAh的锂电池能撑几周。
6.2 复盘时最想提醒新手的三件事
第一件是“先驱动屏幕再写逻辑”,不要一上来就把整个项目拆成一堆模块同时写。墨水屏的驱动库示例代码能正常显示“Hello World”之后,再开始写分页和WiFi,这样出问题时排查范围会小很多。我一开始想一步到位,结果最后花了很长时间定位是屏幕驱动还是分页逻辑出了问题。
第二件是“谨慎处理GPIO12”,ESP32的GPIO12是MTDI引脚,不能外部上拉,涉及这一引脚的按键方案最好换成其他引脚或改用下拉触发。我在接线时没有注意这个问题,导致程序反复重启,后来查资料才知道是GPIO12的坑。
第三件是“墨水屏不是LCD”,它不能高频刷新,也不应该作为实时显示设备。做这个项目,我最大的感悟是先想清楚它能做什么再动手,而不是为了炫技把所有传感器都堆上去。墨水屏和ESP32的组合天然适合做低功耗、静态阅读场景的设备,如果想做动画或者看视频,请换回TFT屏幕,别又绕回去了。
这套代码我从零写到最后跑通,大概用了两个周末的时间。如果只是跟着抄代码,应该两三个小时就能让书页显示出来。真正花时间的,是在分页算法、中文编码、供电稳定性这些细节上做调整。如果你是第一次碰ESP32和墨水屏,从拷贝代码开始,再逐段理解逻辑,是最快上手的方式。后续我还会在这台阅读器上继续做屏幕方向自动切换和目录跳转功能,到时候再写一篇分享。