
1. 为什么我最终选了ESP32而不是Arduino Uno来做这台翻页时钟1.1 从一块吃灰的1.8寸TFT屏说起手头攒了好几块1.8寸的TFT彩屏ST7735驱动128x160分辨率SPI接口之前做小项目剩下的。某天整理元件盒翻出来想着总不能一直让它吃灰就琢磨着做个桌面翻页时钟。翻页时钟这个品类其实已经烂大街了但市售成品要么是黑底白字的单色屏要么是那种机械翻页结构价格不便宜而且尺寸都偏大。我想要的是一个能放在显示器旁边、不占地方、能一眼看清时间、最好还能显示日期和温湿度的小东西。一开始想用Arduino Uno毕竟手头就有而且社区资源丰富。但仔细算了一下资源占用就发现问题了Uno的ATmega328P只有2KB SRAM而一块128x160的16位色TFT屏光是全屏帧缓冲就需要128×160×240960字节也就是40KB。Uno那2KB连零头都不够。当然可以用局部刷新、不用帧缓冲的方式来驱动但翻页动画需要频繁重绘没有帧缓冲的话闪烁会非常严重。ESP32就不一样了ESP32-WROOM-32模组自带520KB SRAM还有4MB的Flash双核240MHz跑TFT_eSPI库驱动ST7735绰绰有余。而且ESP32自带WiFi和蓝牙后面想加网络对时、手机调参这些功能都很方便。价格上一块ESP32开发板也就二十来块钱比Uno贵不了多少但性能完全不是一个量级。1.2 硬件选型清单与成本核算最终确定的硬件清单如下元件型号/规格数量参考单价主控板ESP32-WROOM-32开发板122元显示屏1.8寸TFT ST7735 128x160 SPI115元时钟模块DS3231高精度RTC模块18元温湿度传感器DHT22AM2302112元外壳3D打印件1约10元杜邦线、排针等-若干约5元总计不到80元。DS3231这个模块值得单独说一下它内置温度补偿晶振精度可以做到±2ppm也就是一年误差大概一分钟左右。相比DS1302那种几块钱的RTCDS3231走时准得多而且自带纽扣电池座断电后时间不丢。DHT22比DHT11贵一点但精度和量程都好不少温度±0.5°C湿度±2%对于桌面时钟来说完全够用。1.3 屏幕驱动的选择TFT_eSPI还是Adafruit_ST7735驱动ST7735的库主要有两个选择Adafruit_ST7735和TFT_eSPI。Adafruit的库胜在简单易用文档齐全但性能一般刷新率上不去。TFT_eSPI是专门为ESP32、ESP8266这些高性能MCU优化的库支持DMA传输、SPI频率可以拉到40MHz甚至更高刷新速度快很多。我最终选了TFT_eSPI原因很直接翻页动画对刷新率有要求。如果用Adafruit的库全屏刷新一次大概要100ms以上翻页动画会明显卡顿。TFT_eSPI在ESP32上SPI频率设到40MHz全屏刷新可以压到20ms以内动画流畅度完全不一样。TFT_eSPI的配置方式稍微有点特殊它不是通过代码配置引脚而是通过修改库文件夹里的User_Setup.h文件来配置。这一点后面会详细说也是很多人第一次用TFT_eSPI时容易踩坑的地方。2. 接线方案与TFT_eSPI配置里那些容易翻车的地方2.1 ESP32与ST7735的SPI接线ST7735是SPI接口的屏幕ESP32有两个硬件SPI接口VSPI和HSPI。我习惯用VSPI因为默认的TFT_eSPI配置就是基于VSPI的。接线如下ST7735引脚ESP32引脚说明VCC3.3V供电GNDGND共地CSGPIO5片选RESETGPIO4复位DC/RSGPIO2数据/命令切换SDA/MOSIGPIO23数据线SCL/SCKGPIO18时钟线BLK/LED3.3V背光可接PWM调光这里有个细节ST7735的背光引脚BLK可以直接接3.3V常亮也可以接一个支持PWM的GPIO来做亮度调节。我一开始接的是3.3V后来发现晚上太刺眼就改接到了GPIO21用ledcWrite()做PWM调光晚上自动降低亮度。另外ST7735模块上通常还有一个CS引脚和一个额外的DC引脚有些模块还带触摸功能比如ST7735S带触摸的版本如果不用触摸功能触摸相关的引脚悬空即可。2.2 TFT_eSPI的User_Setup.h配置详解TFT_eSPI库安装之后需要找到库文件夹下的User_Setup.h文件进行修改。在Arduino IDE中库文件夹通常在文档/Arduino/libraries/TFT_eSPI/下面。用文本编辑器打开User_Setup.h需要修改以下几个地方第一注释掉所有默认的驱动定义取消注释ST7735的驱动定义// #define ILI9341_DRIVER #define ST7735_DRIVER第二设置屏幕尺寸。ST7735有几种不同的分辨率变体常见的有128x160、128x128、160x128等。我用的这块是128x160#define TFT_WIDTH 128 #define TFT_HEIGHT 160第三配置引脚。找到引脚定义区域修改为实际接线#define TFT_CS 5 #define TFT_DC 2 #define TFT_RST 4 #define TFT_MOSI 23 #define TFT_SCLK 18 #define TFT_BL 21第四设置SPI频率。默认是27MHz可以提高到40MHz#define SPI_FREQUENCY 40000000这里有个坑不是所有ST7735模块都能稳定跑40MHz有些便宜模块的排线质量差40MHz下会出现花屏或者颜色错乱。如果遇到这种情况把频率降到27MHz或者20MHz试试。我手头这块模块40MHz没问题但另一块同型号的就不行最后降到27MHz才稳定。2.3 偏移量问题为什么屏幕边缘有黑边或者显示不全ST7735有一个很烦人的问题不同厂家生产的模块屏幕在驱动IC上的起始地址偏移量不一样。TFT_eSPI提供了偏移量设置#define TFT_OFFSET_X 0 #define TFT_OFFSET_Y 0但实际用的时候如果发现图像整体偏移了比如左边或上边有黑边或者右边或下边被截断就需要调整这两个值。常见的偏移量有0,0、0,24、24,0、2,1等。我手头这块模块需要设置成#define TFT_OFFSET_X 2 #define TFT_OFFSET_Y 1这个没有统一标准只能根据实际显示效果来试。建议在setup()里先填充全屏红色看看边缘是否对齐然后再微调偏移量。2.4 DS3231和DHT22的I2C接线DS3231是I2C接口ESP32的默认I2C引脚是GPIO21SDA和GPIO22SCL。接线很简单DS3231引脚ESP32引脚VCC3.3VGNDGNDSDAGPIO21SCLGPIO22DHT22是单总线接口接任意GPIO都可以我接的是GPIO15。需要注意的是DHT22的数据线需要接一个4.7kΩ到10kΩ的上拉电阻到3.3V。有些DHT22模块自带这个电阻如果是裸传感器就需要自己加。3. 翻页动画的数学原理与代码实现3.1 翻页效果的本质垂直缩放裁剪翻页时钟的动画效果本质上就是把数字的显示区域做一个垂直方向的缩放和裁剪。想象一张纸从中间对折上半部分向下翻下半部分不动。在屏幕上实现这个效果就是把数字分成上下两半上半部分做垂直缩放从正常高度缩到0下半部分保持不动同时新的数字上半部分从0开始展开。具体到代码层面TFT_eSPI提供了setAddrWindow()和pushImage()函数可以往指定区域推送图像数据。翻页动画的核心就是先把旧数字的上半部分逐行擦除或者用背景色覆盖然后把新数字的上半部分逐行绘制出来。每一帧改变擦除/绘制的行数就形成了动画效果。更高效的做法是用TFT_eSPI的Sprite功能。Sprite是一块内存中的画布可以先把数字渲染到Sprite上然后再把Sprite推送到屏幕。翻页动画时只需要改变Sprite的推送区域和缩放比例不需要反复重绘整个屏幕。3.2 用Sprite实现流畅翻页动画先创建一个全屏大小的SpriteTFT_eSPI tft TFT_eSPI(); TFT_eSprite spr TFT_eSprite(tft); void setup() { tft.init(); tft.setRotation(1); // 横屏显示 spr.createSprite(160, 128); // 注意旋转后的宽高 spr.setColorDepth(16); }然后定义一个绘制数字的函数把数字渲染到Sprite的指定位置void drawDigitToSprite(TFT_eSprite s, int digit, int x, int y, uint16_t color) { s.setTextColor(color); s.setTextSize(4); s.setCursor(x, y); s.print(digit); }翻页动画的实现思路是把数字区域分成上下两半上半部分高度为h下半部分高度也为h。动画过程中上半部分的高度从h逐渐变为0同时新数字的上半部分从0逐渐变为h。用循环实现void flipAnimation(int oldDigit, int newDigit, int x, int y, int w, int h) { int halfH h / 2; for (int i 0; i halfH; i 2) { // 擦除旧数字上半部分 spr.fillRect(x, y, w, halfH - i, BACKGROUND_COLOR); // 绘制新数字上半部分 spr.fillRect(x, y halfH - i, w, i, BACKGROUND_COLOR); drawDigitToSprite(spr, newDigit, x, y, FOREGROUND_COLOR); // 只推送上半部分区域 spr.pushSprite(0, 0); delay(10); } }这个实现比较粗糙实际效果会有闪烁。更好的做法是只推送变化区域而不是全屏推送。TFT_eSPI的pushSprite(x, y)可以指定推送位置但推送区域大小是固定的。如果要只推送局部区域可以用pushImage()配合setAddrWindow()。我实际用的方案是把每个数字单独做成一个Sprite翻页时只操作这个数字的Sprite然后只推送这个数字所在的区域。这样效率高很多动画也更流畅。3.3 帧率与动画时长的平衡翻页动画的帧率和时长需要平衡。帧率太高比如每帧delay 1ms动画太快看不清翻页效果帧率太低比如每帧delay 50ms动画卡顿。我试下来每帧delay 8-12ms比较合适整个翻页动画持续200-300ms既有翻页的视觉效果又不会让人觉得慢。另外动画的缓动函数也很重要。线性动画看起来比较生硬用ease-in-out缓动会自然很多。简单的实现方式是用正弦函数float t (float)i / halfH; float eased (1 - cos(t * PI)) / 2; int currentH halfH * (1 - eased);这样动画开始和结束时会慢一些中间快一些看起来更自然。4. 时间同步与传感器数据融合的实操细节4.1 DS3231的初始化和时间设置DS3231用RTClib库驱动初始化很简单#include RTClib.h RTC_DS3231 rtc; void setup() { Wire.begin(21, 22); if (!rtc.begin()) { Serial.println(RTC初始化失败); } if (rtc.lostPower()) { Serial.println(RTC掉电需要重新设置时间); rtc.adjust(DateTime(F(__DATE__), F(__TIME__))); } }rtc.lostPower()用来检测RTC是否掉过电。如果纽扣电池没电或者第一次使用这个函数会返回true此时需要用编译时间__DATE__和__TIME__来初始化RTC。编译时间是电脑编译代码时的时间精度到秒对于桌面时钟来说够用了。读取时间DateTime now rtc.now(); int hour now.hour(); int minute now.minute(); int second now.second();4.2 DHT22的读取与数据滤波DHT22用DHT库读取#include DHT.h DHT dht(15, DHT22); void setup() { dht.begin(); } void loop() { float temp dht.readTemperature(); float humi dht.readHumidity(); if (isnan(temp) || isnan(humi)) { Serial.println(DHT22读取失败); return; } }DHT22的读取间隔不能太短官方建议至少2秒一次。如果读取太频繁传感器会返回NaN。我实际用的时候是每5秒读一次读到的数据做滑动平均滤波避免数值跳动太厉害。滑动平均滤波的实现const int FILTER_SIZE 5; float tempBuffer[FILTER_SIZE]; int bufferIndex 0; float filterTemp(float newTemp) { tempBuffer[bufferIndex] newTemp; bufferIndex (bufferIndex 1) % FILTER_SIZE; float sum 0; for (int i 0; i FILTER_SIZE; i) { sum tempBuffer[i]; } return sum / FILTER_SIZE; }4.3 屏幕布局设计与字体选择128x160的屏幕横屏之后是160x128空间比较紧张。我的布局是这样的上半部分显示时间大号字体HH:MM格式中间一个冒号闪烁下半部分左边显示日期MM/DD右边显示温湿度。字体方面TFT_eSPI自带了几种字体但大号数字字体只有FreeFonts系列。我用的FreeSansBold24pt7b数字高度大概30像素左右在160宽度的屏幕上显示四个数字加一个冒号刚好合适。如果要更精细的控制可以用自定义字体。TFT_eSPI支持从TTF字体转换成的位图字体数组网上有工具可以把TTF转成C数组。不过对于这个项目来说自带字体够用了。4.4 冒号闪烁与整点报时冒号闪烁很简单每秒切换一次显示状态if (now.second() % 2 0) { spr.setTextColor(FOREGROUND_COLOR); } else { spr.setTextColor(BACKGROUND_COLOR); } spr.setCursor(colonX, colonY); spr.print(:);整点报时我加了一个小功能整点时屏幕边框闪烁三下同时如果接了蜂鸣器可以响一声。这个功能用简单的状态机实现就行不需要额外的库。5. 外壳设计与装配中的那些坑5.1 3D打印外壳的设计要点外壳用Fusion 360设计主要考虑几个点屏幕开窗尺寸要精确ESP32和DS3231的USB接口和电池仓要留出来整体厚度要控制好。屏幕开窗我一开始留了1mm余量结果装上去发现缝隙太大后来改成0.3mm余量才合适。外壳分前后两部分用卡扣连接。前壳开屏幕窗和DHT22的通风孔后壳开USB口和散热孔。DHT22的通风孔很重要如果传感器被完全封在壳里测出来的温度会偏高因为ESP32和屏幕本身会发热。5.2 屏幕固定与排线处理ST7735模块通常是用双面胶或者热熔胶固定在面板上的。我用的是3M双面胶先在屏幕背面贴一层然后对准开窗位置粘上去。注意排线要朝下或者朝侧面不要折成90度否则容易断。排线如果太长可以折叠后用胶带固定但折叠半径不能太小否则内部铜箔会断裂。我一般折叠半径至少5mm。5.3 供电方案USB供电还是锂电池USB供电最简单插上就能用。但如果想做成便携的可以用18650锂电池加TP4056充电模块。ESP32的工作电流大概在80-120mAWiFi关闭时18650电池2000mAh的话可以撑十几个小时。不过屏幕背光是大头背光全亮时电流能到100mA以上所以如果要用电池供电背光亮度要调低。我最终用的是USB供电因为放在桌面上旁边就有USB口没必要折腾电池。而且USB供电可以一直开着不用担心充电问题。6. 调试过程中遇到的几个典型问题与解决思路6.1 屏幕花屏与SPI频率的关系前面提到过SPI频率太高会导致花屏。我遇到的具体现象是屏幕大部分区域显示正常但偶尔会出现几条彩色条纹或者某个数字显示成乱码。一开始以为是代码问题查了半天才发现是SPI频率的问题。把SPI_FREQUENCY从40MHz降到27MHz之后花屏现象消失。这个问题的根本原因是SPI信号完整性。杜邦线太长、接触不良、或者模块本身的排线质量差都会导致高频下信号失真。如果降到27MHz还有问题可以试试20MHz或者16MHz。另外缩短杜邦线长度、用排线代替杜邦线也能改善信号质量。6.2 DHT22读取失败的几种情况DHT22读取失败返回NaN常见原因有几个一是读取间隔太短DHT22需要至少2秒的转换时间二是上拉电阻没接或者阻值不对DHT22的数据线需要4.7kΩ到10kΩ的上拉三是电源电压不稳DHT22对电源比较敏感如果ESP32的3.3V输出纹波太大也会导致读取失败。我遇到的是第二种情况模块自带的10kΩ上拉电阻虚焊了重新焊了一下就好了。如果用的是裸传感器记得一定要加上拉电阻。6.3 时间走时不准的排查DS3231走时不准一般不会是RTC本身的问题而是初始化或者读取的问题。常见原因一是rtc.adjust()只在掉电后调用如果每次上电都调用时间会被重置成编译时间二是I2C通信不稳定导致读取的时间数据错误三是纽扣电池没电RTC掉电后时间丢失。我遇到过一次走时偏快的问题查下来是I2C上拉电阻的问题。ESP32的I2C引脚内部有上拉但阻值比较大大概几十kΩ如果DS3231模块上没有外部上拉电阻I2C通信在高速下会不稳定。后来在SDA和SCL上各加了一个4.7kΩ的上拉电阻问题解决。6.4 翻页动画卡顿的优化翻页动画卡顿主要原因有两个一是SPI刷新速度不够二是动画逻辑本身效率低。优化方向提高SPI频率在稳定的前提下、减少每帧推送的数据量只推送变化区域、用DMA传输TFT_eSPI支持DMA但需要配置。我实际优化下来把全屏推送改成局部推送之后帧率从大概15fps提升到了30fps以上动画明显流畅了。局部推送的关键是计算好变化区域的坐标和大小只推送这个区域。7. 后续可以扩展的几个方向这台翻页时钟目前只做了基础功能显示时间、日期、温湿度。后面还可以加不少东西。比如加一个光敏电阻根据环境光自动调节屏幕亮度加一个蜂鸣器做整点报时加一个按钮切换显示模式比如显示秒表、倒计时、或者自定义文字。网络功能方面ESP32自带WiFi可以加NTP对时这样就不需要手动设置时间了。还可以加一个简单的Web服务器用手机浏览器就能调整时间、亮度、显示模式这些参数。甚至可以用MQTT接入智能家居系统把温湿度数据推送到Home Assistant。如果嫌128x160的屏幕太小可以换成2.4寸或2.8寸的ILI9341屏幕分辨率240x320显示内容更丰富。不过屏幕大了之后外壳也要重新设计整体尺寸会大一圈。代码方面目前是单文件结构所有逻辑都在一个.ino文件里。如果功能继续增加建议拆分成多个模块显示模块、RTC模块、传感器模块、网络模块每个模块一个.h和.cpp文件主文件只负责调度。这样代码可维护性会好很多。我在实际做这个项目的过程中最大的体会是硬件项目里软件问题往往比硬件问题更难查。屏幕花屏、传感器读数异常、时间走时不准这些现象背后可能是接线问题、电源问题、信号完整性问题也可能是代码逻辑问题。排查的时候要有耐心一步一步缩小范围先确认硬件没问题再查软件。另外TFT_eSPI的User_Setup.h配置一定要仔细核对引脚、驱动型号、偏移量任何一个不对都会导致显示异常。