写这篇的时候,我刚好收到板厂寄回来的第三版样板。前几篇聊了需求定义、整体方案和PCB设计,这篇是系列第四篇,主题很纯粹:硬件终于到手了。这篇不会讲太多虚的东西,就是从拆快递开始,到把屏幕点亮,再跑到第一个能看的Demo为止,完整记录中间踩过的坑、调过的参数、改过的代码。如果你也在自造类似的桌面状态屏、信息仪表盘或者各种带屏的嵌入式小玩具,这篇内容应该对你有直接的参考价值。
1. 全局设计回顾与选型逻辑
1.1 我在做的 Status Deck 到底是个什么东西
简单来说,它就是一个放在桌上的小屏,全天候显示那些“你不想掏手机看”的信息:时间日期、天气、环境温湿度、待办事项、日历、服务器状态、GitHub Actions结果等等。它不取代手机,也不取代电脑,它只是在你工作的时候安静地待在余光范围内,把重要信息一口气摆出来。
为什么叫 Status Deck,而不是叫“智能闹钟”或者“桌面小屏”——因为 Deck 带有仪表盘、控制台的含义,强调多信息分区同时展示。硬件上我选择的是:一块彩色TFT主屏负责信息主区,一块小OLED做状态副屏,再加温湿度传感器、旋转编码器和USB-C供电。这个组合在硬件成本上大概控制在几十块钱级别,比随便一个成品桌面摆件都要便宜,关键是你能完全掌控它的行为。
这已经是系列的第四篇了。前几篇分别交代了需求拆解、主控选型和电路设计,这次总算聊到硬件实体部分,我尽量把每一处板子上的细节都讲到位。
1.2 为什么还是选了 ESP32 做主控
很多人问过,一个“状态屏”而已,为什么不用 STM32、不用树莓派 Zero、甚至不用 Arduino Uno——答案是这一切取决于你想要多“全栈”。
先说要联网。Status Deck 的一个核心使用场景就是轮询天气、日历或服务器接口,因此 WiFi 几乎是刚需。树莓派 Zero 能联网也能跑 Linux,但在我看来太重了,一个需要几十秒开机引导的 Linux 系统用来显示温湿度,属于杀鸡用牛刀,而且功耗和体积都大。STM32 确实功耗低、控制能力好,但要让它联网就得外挂 WiFi 模块,又凭空多了一组天线和协议栈要处理。
ESP32 的优势在它天生就是为了物联网应用准备的:双核、WiFi + 蓝牙、足够多的 GPIO、3路 UART、硬件 SPI 和 I2C,跑 Arduino 或 ESP-IDF 都行。最实在的是它的开发调试链路极短——USB 接到电脑就能直接烧录,串口监视器随时输出日志,SWD 调试器都不用接。这种“开箱就能跑,改完就烧录”的体验,对于需要不断迭代界面和逻辑的项目状态屏来说,太宝贵了。
1.3 屏幕选型:为什么是 TFT 配 OLED 的双屏组合
这个项目最早我只准备用一块 1.8 寸 TFT,后来发现单一屏幕很难兼顾“大信息量”和“常亮低功耗”:主屏一直亮着,OLED 那点微弱亮度也不够看;主屏熄灭时,如果连最基本的时间都不显示,体验上又很割裂。
所以最终方案是双屏:一块 1.9 寸 IPS TFT 作为主屏,驱动芯片是 ST7789,分辨率 320x170 左右,日常显示天气、日历和时间主区;另一块 0.96 寸 OLED,驱动芯片是 SSD1306,分辨率 128x64,负责在底部单独显示 CPU 负载、GitHub Actions 状态这类带图标的轻量内容。OLED 的好处是每个像素自发光,黑色不发光,所以常亮显示一小块图标时功耗反而低,而且对比度极其锐利。TFT 的优势则是色彩丰富、尺寸更大,适合做信息主区。
两块屏的接线也完全错开:TFT 走硬件 SPI,OLED 走 I2C。这样不仅可以用两套并行逻辑驱动,还不会在排针上打架。后面我会给每个屏的接线表。
1.4 物料清单与整体接线规划
这块工程板的物料其实没有多少,列一个清单:
- ESP32-WROOM-32 模组,4MB Flash,经典款,带板载天线。
- 1.9 寸 ST7789 TFT,SPI 接口,带焊接排母。
- 0.96 寸 SSD1306 OLED,I2C 接口,带四针排针。
- SHT30 温湿度传感器模块,I2C 接口。
- 旋转编码器 EC11,带按键功能,用于菜单切换。
- AMS1117-3.3 LDO,把 USB 的 5V 降到 3.3V。
- 若干 10kΩ 电阻、100nF 去耦电容和 100uF 电解电容。
- 一个 USB-C 母座,用于供电和烧录调试。
接线规划上,TFT 的 SCK、MOSI 走 ESP32 的 VSPI 默认引脚,DC 和 CS、RST 分别占用三个 GPIO;OLED 的 SDA、SCL 占用默认 I2C 引脚;SHT30 和 OLED 共用 I2C 总线,地址不同所以不冲突;编码器 A、B 相位接两个带中断的 GPIO,公共端接 3.3V 或 GND(取决于编码器模块设计)。
2. 焊接到点亮:从最小系统到屏幕亮起
2.1 到货检查与通电前的小习惯
板子到手,我的习惯是先拍一张 PCB 照片,然后拿万用表挨个测量电源域的对地阻值。这个习惯能救很多人一命:如果 3.3V 和 GND 之间阻值接近短路,通电必然会烧 USB 口或者稳压器。
量下来我的这块板子 3.3V 对 GND 阻值有 400Ω 左右,这个数值在合理范围内(有传感器和屏幕负载的前提下,几百欧到几千欧都正常)。然后再看丝印,确认 TFT 的接插件方向没有歪,OLED 的四根针顺序没有弄反。很多所谓“硬件翻车”,其实是在接插件方向这种低级细节上翻的。我还在每个排针旁边贴了一点点标签胶带,方便后面接线不迷路。
接着把 ESP32 模组焊上去。这里有个实操建议:如果用的是模组贴片版本,焊接温度别超过 350°C,热风枪风速调小一点,对着接地焊盘预热之后再吹主芯片。我用的是手工烙铁方案,因为模组的邮票孔引脚间距还算友好,拖焊之后拿放大镜检查有没有连锡。检查方法很简单——拿万用表二极管档,测相邻引脚是否短路,正常情况应该是开路滴一声不响。
上电之前,我还会先不插 TFT 和 OLED,只让主控板先通电。这是最重要的一个原则:永远先验证最小系统,再一级一级往上挂外设。如果一上来就全部插满,出了问题你根本不知道是屏幕的问题还是主控的问题,还是电源的问题。
2.2 最小系统验证:先跑一个串口 Hello
这时候拔掉所有外设,只留下 ESP32 和 USB-C 转串口电路。插上 USB 线,Mac 上ls /dev/tty.wchusbserial*能看到新设备,Windows 上设备管理器会出现一个 COM 口。如果没出现,先查驱动、查线材(很多 USB 线是纯充电线,没有数据线芯),再查板子的 EN 复位引脚是否被拉低。
烧录之前,按住 BOOT 键,同时短按一下 EN 键,松开 EN 再松开 BOOT,就能让 ESP32 进入下载模式。Arduino IDE 里选择 ESP32 Dev Module,端口选对,写一段最简单的循环打印:
void setup() { Serial.begin(115200); delay(100); Serial.println("[STATUS DECK] boot ok"); } void loop() { Serial.printf("heap free: %d bytes\n", ESP.getFreeHeap()); delay(1000); }第一次编译烧录成功后,串口监视器会每隔一秒打一行heap free: xxx bytes。看到这行输出,就知道主控的工作链路是通的,可以开始碰硬件外设了。
这里有一个很多人容易忽略的点:刚焊完的板子,第一次烧录就失败的概率并不低。常见原因是芯片没有进入下载模式或 USB 转串口芯片供电不足。我的方法是把 USB 口换到电脑机箱后置 USB 口,或者换一根粗线径的数据线,排除供电不足的问题。
2.3 TFT 点亮:ST7789 驱动的初始化过程
硬件链路验证完之后,我把主屏 TFT 接上去。这块屏是 SPI 接口,接线表如下:
| TFT 引脚 | ESP32 GPIO |
|---|---|
| VCC | 3.3V |
| GND | GND |
| SCK | GPIO18 |
| MOSI | GPIO23 |
| DC | GPIO2 |
| CS | GPIO5 |
| RST | GPIO4 |
| BLK | GPIO15 |
如果屏幕模块是带背光控制脚的,BLK 最好接到 GPIO 而不是直接接 3.3V,这样以后可以实现息屏省电。SPI 的 SCK 和 MOSI 使用了 ESP32 的 VSPI 默认引脚,代码里可以直接用SPI.begin()初始化,但 DC、CS、RST、BLK 这几个控制脚必须自己在初始化前先指定。
软件库我选的是 TFT_eSPI,因为它的渲染速度快、内置了丰富的图形接口,而且对 ST7789、ILI9341 这些常见驱动支持得比较好。关键配置是在User_Setup.h里把引脚映射和屏幕尺寸写对。我这边核心配置如下:
#define ST7789_DRIVER #define TFT_WIDTH 320 #define TFT_HEIGHT 170 #define TFT_SCLK 18 #define TFT_MOSI 23 #define TFT_DC 2 #define TFT_CS 5 #define TFT_RST 4 #define TFT_BL 15初始化代码可以简化成下面这样:
#include <TFT_eSPI.h> TFT_eSPI tft = TFT_eSPI(); void setup() { pinMode(15, OUTPUT); digitalWrite(15, HIGH); // 打开背光 tft.init(); tft.setRotation(1); tft.fillScreen(TFT_BLACK); tft.setTextColor(TFT_GREEN, TFT_BLACK); tft.setTextSize(2); tft.drawString("STATUS DECK v0.1", 20, 60); }这段代码跑起来之后,屏幕应该出现黑底绿字的启动界面。如果你第一次板子点亮后屏幕一直白屏,大概率是 RST 引脚悬空导致复位序列没跑对,或者 CS 没有拉低,SPI 主机根本没法选中屏幕。这些问题后面我会在排查表里统一讲。
成功点亮 TFT 的那一瞬间,是我每次做硬件最高光的时刻。那不会是一瞬间的狂喜,而是一种“之前画的原理图、布的线、选的焊料都没白费”的踏实感。但别高兴太早,这只是开始,接着还要把传感器接上。
2.4 OLED 副屏和 SHT30 传感器接入
OLED 和 SHT30 都走 I2C,占用同一组引脚:SDA 用 GPIO21,SCL 用 GPIO22。我在 PCB 上把它们挂到同一条总线上,SHT30 的 I2C 地址是 0x44,SSD1306 的地址是 0x3C,默认不冲突。
接线顺序:先把 OLED 接上,跑一遍Adafruit_SSD1306库自带的示例代码,确认能显示字符。再把 SHT30 接上,用Adafruit_SHT31库读一次温湿度。两个都单独验证通过后,就可以集成到一起。
SHT30 的读取逻辑很简单:
#include <Wire.h> #include <Adafruit_SHT31.h> Adafruit_SHT31 sht31 = Adafruit_SHT31(); void setup() { Serial.begin(115200); Wire.begin(21, 22); if (!sht31.begin(0x44)) { Serial.println("SHT31 not found"); } } void loop() { float temp = sht31.readTemperature(); float hum = sht31.readHumidity(); if (!isnan(temp) && !isnan(hum)) { Serial.printf("temp=%.2f hum=%.2f\n", temp, hum); } delay(2000); }有个很隐蔽的坑:sht31.begin()这个库函数内部会发送一个软复位命令和加热命令的去抖延时,如果你在begin()之前调用Wire.begin()但没有等待足够时间,偶尔会读到全零数据。我的解决办法是在begin()之后加一个delay(10)再开始读,实测稳定很多。
集成后的画面大致会是:TFT 上画了四个区块——左上角大字号显示时间,右侧显示天气图标区域,左下角显示室内温度和湿度条,底部显示日期;OLED 则在右下角轮播显示 GitHub Actions 的工作流状态图标和 CPU 负载百分比。这离成品还隔着十万八千里,但第一个 Demo 的核心功能已经“通”了。
3. 实操中被按在地上摩擦的细节
3.1 引脚冲突与为什么我放弃了“默认引脚”
ESP32 的默认 SPI 引脚默认是 VSPI(SCK=18、MOSI=23、MISO=19、SS=5),表面上跟 TFT 接法完全一致,但如果你直接把屏幕挂在默认引脚上跑tft.init(),有很大概率会花屏或者根本不亮。
原因在于:官方库的默认引脚配置很多时候没有意识到这些引脚同时被 UART、JTAG 或者板载 Flash 占用了。尤其在我这块板子上,GPIO23 和 GPIO18 附近刚好有Flash和USB转串口的走线,信号完整性受到影响。我最后没有用“默认引脚”那套,而是在User_Setup.h里硬编码映射到 GPIO18/23 以外的引脚吗?
不对,我最终其实保留 18 和 23 作为 SCK 和 MOSI,因为我硬件上已经把线画死了。真正的教训是:做 PCB 布局时,SPI 走线要尽量远离晶振和 USB-D+/D- 差分对,而且要短。如果是飞线(面包板)连接,就无所谓,直接把代码里的引脚映射改到空闲 GPIO 就很稳。我的建议是,硬件设计阶段就提前把 TFT 的 SPI 引脚避开 USB 串口和 ADC 输入,能躲开一堆后来的时序问题。
3.2 ST7789 偏色与花屏的真正原因
关于 ST7789,这里头有个公认可怕的坑:颜色编码顺序。很多国产 1.9 寸屏模组默认是 RGB 颜色顺序(色彩数据按 R、G、B 排列),但 TFT_eSPI 在设置里有个宏叫TFT_RGB_ORDER,默认可能被定成 BGR,你要是跟屏幕的实际顺序反了,画出来的颜色就会诡异——红色会显示成蓝色,绿色会显示成紫色,让人以为硬件坏了。
解决方式是在User_Setup.h中取消注释#define TFT_RGB_ORDER TFT_BGR或TFT_RGB,跟屏库的规格书对一下再编译烧录。还有一个常见问题是花屏:屏幕显示一堆随机的彩色噪点,完全看不出内容。这种情况下 90% 是 SPI 速率太高或者总线长度太长。ST7789 驱动 IC 能跑到 40MHz 以上,但你的飞线和杜邦线的寄生电容可能会在 20MHz 就导致数据错乱。把tft.init()之后加一行:
tft.setSPISpeed(20000000); // 20MHz,降低速率换取稳定实测很多“花屏”在降到 20MHz 以后直接就好了。
对了,还有一个偏移量的问题。320x170 的屏幕,你的驱动 IC 内部其实有一个更大的显存地址空间,坐标偏移不对会导致内容显示在屏幕偏左、偏右或者出现整体的错位偏移。TFT_eSPI 里可以通过tft.setSwapBytes(true)和调整初始化后的偏移来解决,但最简单的做法是在User_Setup.h里把屏幕分辨率改成#define TFT_WIDTH 320和#define TFT_HEIGHT 170,并设置#define ST7789_OFFSET_X 0和#define ST7789_OFFSET_Y 0。不同厂家的屏偏移量不一样,通常屏幕数据手册里会写,自己调节时可以用一块纯色背景填充整个屏幕来判断。
我调这个偏移量大概花掉四十分钟,期间屏幕时而左边缺一块,时而上方多一条,最后发现是某块国产屏模组的 Offset 从不是 0。这块坑让我记住了:买屏前先确定驱动 IC 的 datasheet 里是怎么定义 Display Resolution 和 Offset 的,别等点亮了再猜。
3.3 供电与背光电流:别让小屏拖垮整个系统
TFT 背光是个电流大户。普通 1.9 寸 TFT 在最大亮度时,背光电流能到 20-40mA,加上主控和其他外设,整板电流在 Wi-Fi 开启峰值时能达到 250mA 甚至更高。如果用的是电脑 USB 口供电,多数 USB 口能提供 500mA,所以不至于直接崩,但如果是充电宝或者带过流保护的 HUB,就可能出现“插上去能亮,一开 WiFi 就掉线重启”的怪象。
我在这块板子上特意留了一个 AMS1117-3.3,输入是 USB 5V,最大输出 1A,理论上足够。但 AMS1117 这种 LDO 的压差比较大,记得输入输出端都要加电容,否则负载一变就会纹波超标,SPI 时序也跟着出错。实测下来,我在 3.3V 输出端放了一颗 100uF 电解电容,效果立竿见影,之前偶发的屏幕闪一下的情况直接没了。
还有个小技巧:如果你要拿电池供电,比如一颗 18650 或者锂聚合物电池,那更要把屏幕背光降到 60% 以下,并且在软件里做一个亮屏/熄屏策略——没有任何交互 30 秒之后把背光调到最低,这样整板功耗可以从 150mA 降到 60mA 左右。电池供电的话,LDO 就不合适了,得换 DCDC 降压芯片,比如 TPS563200 这类同步降压,效率能到 90% 以上。
3.4 旋转编码器的消抖
旋转编码器 EC11 是很多状态屏项目里用来切换菜单的交互件,但它有个麻烦:机械抖动。如果直接把 A、B 相的 GPIO 接在主控上,不做任何滤波,旋转的时候读数会跳来跳去,明明向左转一下,计数却加了 3 次。我的做法是硬件上在 A、B 相各接一个 10nF 电容对地,做 RC 低通滤波;软件上用ESP32的外部中断或定时器轮询。
最稳妥的软件处理是“状态机消抖”:记录上一时刻 A、B 的电平组合,每次变化时按相位表查旋转方向,而不是简单数电平变化次数。代码类似:
// 旋转方向表:编码器两相变化的编码序列 const int8_t enc_states[] = {0, -1, 1, 0, 1, 0, 0, -1, -1, 0, 0, 1, 0, 1, -1, 0}; int8_t old_AB = 0; int8_t enc_pos = 0; void readEncoder() { int8_t AB = (digitalRead(ENC_A) << 1) | digitalRead(ENC_B); enc_pos += enc_states[(old_AB << 2) | AB]; old_AB = AB; }这段代码的核心思想是:只有合法的 AB 变化序列才会计数,单边信号的抖动会被当成非法状态直接忽略。实测下来,即使不加硬件电容,软件消抖也能把误触机率降低到可以接受的范围,但我依然建议硬件软件两头都做,因为状态屏是要一直常亮的。
4. 常见问题与排查技巧实录
这块儿我直接把过程中踩过的问题整理成速查表,基本都是我自己实测遇到过或者看别人项目里高频出现的问题,你照着一个个排查会快很多。
| 现象 | 可能原因 | 排查与解决方法 |
|---|---|---|
| 屏幕完全不亮(黑屏) | 背光未打开;或 RST 被拉低;或 SPI 没有初始化 | 用万用表量 BLK 引脚是否有 3.3V;确认 RST 引脚在上电后拉高;烧录前确认 SPI 引脚映射对 |
| 屏幕白屏,有背光但无内容 | CS 未拉低;DC 接错;SPI 时钟没起来 | 检查 CS、DC 接线;逐步降低 SPI 速率;用逻辑分析仪看 SCK 和 MOSI 有没有信号输出 |
| 显示颜色错乱,红变蓝 | RGB 颜色顺序配置反了 | 修改User_Setup.h的TFT_RGB_ORDER,RGB 改 BGR 或反过来 |
| 显示区域偏移,内容只显示一部分 | ST7789 的 Offset 参数不对 | 调整ST7789_OFFSET_X/ST7789_OFFSET_Y,用纯色背景测试边界 |
| 屏幕闪烁或随机花屏 | 电源纹波过大;SPI 速率过高;接线太长 | 3.3V 输出端加 100uF 电容;将 SPI 速率降到 20MHz;缩短杜邦线长度 |
| 传感器读数为 0 / NaN | I2C 地址冲突;上电初始化不够等待;线序接反 | 用i2cscanner扫描总线地址;begin()后加delay(10);检查 SDA/SCL 方向 |
| 烧录时报“Connecting... 超时” | 芯片没进入下载模式;USB 线只是充电线 | 按住 BOOT,短按 EN,再松开 BOOT;换数据线;检查 USB CDC 驱动 |
| OLED 出现残影、显示不全 | I2C 总线被其他设备拉低;亮屏时间过长产生烧屏 | 探测 I2C 总线电压是否正常;开启 OLED 的电荷泵配置;定期整体反色刷新或者降低静态亮度 |
除了这个速查表,我额外提三个“翻车现场”细节。
第一个是背光引脚悬空。有块测试板我忘记把 BLK 接到 GPIO,而是接到了 3.3V 上。结果屏幕确实能亮,但后续想做自动息屏时发现软件完全控制不了背光,只能重新飞线。这件事告诉我,设计阶段就要给所有“看起来可以直接接电源”的引脚多留一个GPIO控制口,后面做功耗策略才不会被硬件限制死。
第二个是 I2C 总线的“假死”。因为 OLED 和 SHT30 共用一根 I2C 总线,OLED 初始化时如果失败跑飞了,会把 SDA 线卡在低电平,导致后面 SHT30 也读不出来。我排查了半天最后抓逻辑分析仪,才发现是 OLED 的 Adafruit 库和 SHT31 库初始化顺序问题。解决办法是让 OLED 的display.begin()放在 SHT30 的begin()之前,并且每次读取 SHT30 之前检查Wire.endTransmission()的返回值,非 0 就重启 I2C。
第三个是编码器计数方向反了。EC11 编码器拧顺时针,计数却是负的。这个不用改代码,直接把 A、B 两根线对调就行,但如果你把线已经焊死在板子上就惨了,只能改软件把方向取反。这次经历让我学会了画 PCB 前就要把编码器 A、B 的相位查清楚,不要等焊完再赌。
5. 后续要做的和我的实操体会
第一版 Demo 跑通之后,我接下来的改动方向很明确。一是要接上真正的网络数据:天气用免费的公开接口,日历用标准协议拉取,GitHub Actions 状态用它的接口轮询;二是把屏幕刷新的架构从“每 2 秒全屏重画”改成“局部脏矩形刷新”,减少重绘,降低闪烁感;三是给整机加一个 3D 打印的外壳,把 USB-C 口、编码器和两块屏固定好,不然桌面上就是一块裸板加一堆线,看着很乱。
如果你也想做类似的 Status Deck,我的体会分两个层面。硬件层面,别追求一次画完终极板,先飞线或者面包板跑通 Demo,再回头画 PCB,这样可以避免很多因为引脚冲突或者供电问题导致的无意义返工。实在要用杜邦线连接的话,线材越短越好,信号速率能降则降,电源电容能多放就多放。软件层面,先把显示库的配置项吃透,尤其是驱动型号、分辨率、颜色顺序、偏移量这几个参数,它们决定了你的屏幕到底是“能亮”还是“显示正确”。你宁可花一晚上把显示参数调到完美,也不要带着花屏启动后续的一切功能,因为后面所有界面的调试都会建立在这层基础之上。
最后分享一个小习惯:每次上电之前,先量电压、再量对地阻值、最后才插 USB,整套流程大概多花十秒钟,但能省掉无数个怀疑人生的深夜。然后,大胆把屏幕点亮吧。