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×2=40960字节,也就是40KB。Uno那2KB连零头都不够。当然可以用局部刷新、不用帧缓冲的方式来驱动,但翻页动画需要频繁重绘,没有帧缓冲的话闪烁会非常严重。
ESP32就不一样了,ESP32-WROOM-32模组自带520KB SRAM,还有4MB的Flash,双核240MHz,跑TFT_eSPI库驱动ST7735绰绰有余。而且ESP32自带WiFi和蓝牙,后面想加网络对时、手机调参这些功能都很方便。价格上,一块ESP32开发板也就二十来块钱,比Uno贵不了多少,但性能完全不是一个量级。
1.2 硬件选型清单与成本核算
最终确定的硬件清单如下:
| 元件 | 型号/规格 | 数量 | 参考单价 |
|---|---|---|---|
| 主控板 | ESP32-WROOM-32开发板 | 1 | 22元 |
| 显示屏 | 1.8寸TFT ST7735 128x160 SPI | 1 | 15元 |
| 时钟模块 | DS3231高精度RTC模块 | 1 | 8元 |
| 温湿度传感器 | DHT22(AM2302) | 1 | 12元 |
| 外壳 | 3D打印件 | 1 | 约10元 |
| 杜邦线、排针等 | - | 若干 | 约5元 |
总计不到80元。DS3231这个模块值得单独说一下,它内置温度补偿晶振,精度可以做到±2ppm,也就是一年误差大概一分钟左右。相比DS1302那种几块钱的RTC,DS3231走时准得多,而且自带纽扣电池座,断电后时间不丢。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引脚 | 说明 |
|---|---|---|
| VCC | 3.3V | 供电 |
| GND | GND | 共地 |
| CS | GPIO5 | 片选 |
| RESET | GPIO4 | 复位 |
| DC/RS | GPIO2 | 数据/命令切换 |
| SDA/MOSI | GPIO23 | 数据线 |
| SCL/SCK | GPIO18 | 时钟线 |
| BLK/LED | 3.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引脚是GPIO21(SDA)和GPIO22(SCL)。接线很简单:
| DS3231引脚 | ESP32引脚 |
|---|---|
| VCC | 3.3V |
| GND | GND |
| SDA | GPIO21 |
| SCL | GPIO22 |
DHT22是单总线接口,接任意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实现流畅翻页动画
先创建一个全屏大小的Sprite:
TFT_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-120mA(WiFi关闭时),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配置一定要仔细核对,引脚、驱动型号、偏移量,任何一个不对都会导致显示异常。