ESP8266 12F 电动车仪表盘开发教程(320x240 ST7789 版)
避坑指南(血泪经验,强烈建议阅读)
在正式开始写代码之前,请务必了解以下三个关键点,它们能帮你省去 90% 的莫名其妙的报错:
1. 为什么屏幕的 CS 必须接 GND?
现象:接在 D8 上,每次上传代码都报错
Timed out waiting for packet header(连接超时),拔掉屏幕线就能传。原因:ESP8266 的D8 (GPIO15)是极其重要的“启动模式选择引脚”。芯片上电的瞬间,它必须是低电平。如果屏幕的 CS 接在 D8 上,屏幕内部电路可能会意外把它拉高,导致 ESP8266 直接拒绝进入下载模式。
解法:把屏幕的 CS 引脚直接接到 GND。因为只有一块屏幕挂在 SPI 总线上,它默认就会被一直选中,完全不影响通信,还彻底解决了上传冲突。
2. 上传速度(Upload Speed)过快导致失败
现象:点击上传后,控制台一直显示
Connecting........_____最后报错。解法:在 Arduino IDE 的“工具”菜单里,把
Upload Speed从默认的921600降到115200。慢一点,但是极其稳定,一传一个准。
3. 黑屏没反应?检查背光(BLK)
现象:程序上传成功了,但屏幕一点光都没有(纯黑)。
排查:代码里如果没有
digitalWrite(D1, HIGH),屏幕的 BLK 引脚得不到高电平就不会亮。如果你不确定是代码问题还是硬件问题,可以暂时把 BLK 拔下来,直接插到开发板的 3.3V 上。如果此时屏幕亮了(哪怕只是白屏),说明背光没问题,问题出在你的控制代码或引脚接触上
一、 硬件准备与接线
1. 硬件清单
主控:ESP8266 12F (NodeMCU / Wemos D1 mini)
屏幕:2.4寸 TFT 彩屏 (ST7789 驱动,分辨率 240x320,SPI接口)
连接线:杜邦线若干
2. 核心接线表(⚠️ 务必注意 CS 引脚)
| TFT 屏幕 | ESP8266 引脚 | 备注 |
|---|---|---|
| VCC | 3.3V | 绝对不能接 5V,会烧屏 |
| GND | GND | 共地 |
| SDA / MOSI | D7 (GPIO13) | 硬件 SPI |
| SCL / SCK | D5 (GPIO14) | 硬件 SPI |
| DC | D2 (GPIO4) | 数据/命令 |
| RST | D4 (GPIO2) | 复位 |
| BLK | D1 (GPIO5) | 背光控制(代码中拉高点亮) |
| CS | GND | 核心避坑点!接 D8 会导致无法烧录代码,直接接 GND 即可 |
二、 软件环境配置
1. 安装核心库
在 Arduino IDE 中,点击项目 -> 加载库 -> 管理库,搜索并安装:
TFT_eSPI(作者: Bodmer)
2. 修改库配置文件User_Setup.h
找到 Arduino 库文件夹中的TFT_eSPI/User_Setup.h(路径通常在文档/Arduino/libraries/TFT_eSPI/)。
用记事本打开,清空原内容,粘贴以下配置,然后保存:
cpp
复制
下载
#define ST7789_DRIVER #define TFT_WIDTH 240 #define TFT_HEIGHT 320 // 接线引脚配置 #define TFT_MOSI 13 // D7 #define TFT_SCLK 14 // D5 #define TFT_DC 4 // D2 #define TFT_RST 2 // D4 #define TFT_CS -1 // 不使用 CS // 开启高级位图字体与平滑字体引擎 #define LOAD_GLCD #define LOAD_FONT2 #define LOAD_FONT4 #define LOAD_FONT7 #define LOAD_FONT8 #define SMOOTH_FONT // 【关键】开启抗锯齿平滑字体支持 #define SPI_FREQUENCY 27000000
注意:修改保存后,必须彻底关闭 Arduino IDE 并重新打开,配置才会生效。
3. 引入平滑字体文件
从TFT_eSPI/examples/Smooth Fonts/文件夹中,找到NotoSansBold36.h和NotoSansBold15.h这两个文件,复制到你当前代码所在的文件夹(与.ino文件同级)。
三、 核心技术原理解析(本项目三大亮点)
在运行代码前,了解这几个技术点,能帮你在未来开发长条屏或其他 UI 时举一反三:
消除锯齿:传统的
Font 4放大后边缘像马赛克。本项目引入了SMOOTH_FONT和NotoSansBold36.h,利用8-bit 灰度抗锯齿技术,字体的边缘会根据背景色进行半透明混合,实现像手机屏幕一样丝滑的视觉体验。无闪烁刷新:调用
tft.setTextColor(前景色, 背景色, true)时,第三个参数true开启背景覆盖。画新数字的同时自动擦除旧数字,彻底告别fillScreen造成的全屏闪烁。动态防残影(
eraseDelta):因为右侧数据是右对齐的,当数据从10.5缩短为9.5时,左边会残留一个旧数字。代码中通过对比新旧字符串的宽度,动态计算出多余区域,用背景色精准覆盖,做到天衣无缝。
四、 最终完整代码
/* ===================================================================== 硬件平台:ESP8266-12F(ESP-12F 模块) Arduino IDE 板型选择:"NodeMCU 1.0 (ESP-12E Module)" (必须选带 D1~D8 引脚宏的板型,否则下面代码里的 D1/D2 无法识别; 若选 "Generic ESP8266 Module",请把 D1/D2 等换成对应 GPIO 编号) 屏幕:ST7789 240x320(SPI 接口,无触摸) 注意:屏幕引脚定义写在库文件里,不在本文件: C:\Users\Administrator\Documents\Arduino\libraries\TFT_eSPI\User_Setup.h 改接线时两个文件要一起改,否则屏不亮或不显示。 ── 接线表(ESP-12F 的 NodeMCU 引脚名 ↔ GPIO 编号)────────────────── 屏幕引脚 接到 ESP8266-12F 代码里的宏 说明 ------------ -------------------- -------------- ------------------ VCC 3V3 - 严禁接 5V,会烧屏 GND GND - 必须共地 SCL / SCK D5 = GPIO14 TFT_SCLK 硬件 SPI 时钟 SDA / MOSI D7 = GPIO13 TFT_MOSI 硬件 SPI 数据 CS GND(直接接地) TFT_CS = -1 不用片选,省一根线 DC / RS D2 = GPIO4 TFT_DC 数据/命令选择 RES / RST D4 = GPIO2 TFT_RST 复位,低电平有效 BLK / BL(背光) D1 = GPIO5 BL_PIN 用 PWM 调亮度 SPI 频率 27MHz(在 User_Setup.h 里改 SPI_FREQUENCY,花屏就降到 20MHz) ── 烧录用(ESP-12F 没有 USB,需 USB-TTL 模块)────────────────────── TX -> RX RX -> TX GND -> GND 下载模式:GPIO0(D3) 拉低 → 复位 → 松开 GPIO0,再点上传; 若上传失败,把 GPIO15(D8) 也接地,并确认 3.3V 电源够电流。 ===================================================================== */ #include <TFT_eSPI.h> #include <SPI.h> // ===== 时间源开关 ===== // 0 = 模拟时间(当前测试显示用:从 MOCK_START_* 起每秒递增) // 1 = WiFi + NTP 真实时间(填好 WIFI_SSID / WIFI_PWD 后改成 1) #define USE_NTP_TIME 0 #if USE_NTP_TIME #include <ESP8266WiFi.h> #include <time.h> #endif // 平滑抗锯齿字体 (存放在 Flash 中,无需 SPIFFS) #include "NotoSansBold36.h" #include "NotoSansBold15.h" TFT_eSPI tft = TFT_eSPI(); // ================== 颜色定义 ================== #define COLOR_BG 0x0000 // 纯黑背景 #define COLOR_SPEED 0x07E0 // 速度亮绿 #define COLOR_WHITE 0xFFFF // 纯白 #define COLOR_BLACK 0x0000 // 纯黑 #define COLOR_GREY 0x8410 // 灰色 #define COLOR_BORDER 0x4208 // 深灰色线框 (用于底部和边框) #define COLOR_CARD_V 0x033F // 纯蓝 (右侧卡片) #define COLOR_CARD_P 0xFC00 // 纯橙 (右侧卡片) #define COLOR_CARD_CAP 0xF81F // 紫红 (右侧卡片) // ================== 底部区域布局 (屏幕 320x240) ================== // 底部大框: y 171 ~ 235,上半是系统时间,下半是一行三项数据 #define BOX_X 5 #define BOX_Y 171 #define BOX_W 310 #define BOX_H 64 #define LINE_Y 204 // 上半行与下半行之间的分隔线 #define CLOCK_Y 188 // 上半行:系统时间的中线 #define ROW_Y 220 // 下半行:三列数据的中线 #define COL_GAP 22 // 三组数据之间的间隔(数值+单位为一组) // ================== 时间源配置 ================== // 模拟日期与时刻(USE_NTP_TIME = 0 时生效),开机从这里开始每秒走,跨天自动进位 #define MOCK_START_Y 2026 #define MOCK_START_MO 10 #define MOCK_START_D 26 #define MOCK_START_H 12 #define MOCK_START_M 30 #define MOCK_START_S 0 // ================== 背光 ================== // D1(GPIO5) 接屏幕 BLK,固定最高亮度(如需调光,把它改成 PWM 输出即可) #define BL_PIN D1 // ================== 日期时间结构 ================== // 注意:必须定义在第一个函数之前,否则 Arduino 自动生成的函数原型会找不到该类型 struct DateTime { int year, month, day, hour, minute, second; }; const char* WIFI_SSID = ""; // 例: "00" const char* WIFI_PWD = ""; // 例: "mangmang" bool timeCfgDone = false; // NTP 是否已配置 float speedVal = 46.5; int socVal = 86; // 记录上一次显示的数字字符串,用于清除残影 char prevV[8] = ""; char prevC[8] = ""; char prevP[8] = ""; void setup() { Serial.begin(115200); // 开启背光 (D1 引脚),固定最高亮度 pinMode(BL_PIN, OUTPUT); digitalWrite(BL_PIN, HIGH); delay(100); tft.init(); tft.setRotation(1); tft.invertDisplay(false); // 如果屏幕反色,请改成 true setupWifi(); drawUI(); } void loop() { // 500ms 刷新数据 (每秒 2 次) static unsigned long lastUpdate = 0; if (millis() - lastUpdate >= 500) { lastUpdate = millis(); // 模拟数据 (后续替换为蓝牙真实数据) speedVal = 45.0 + (random(0, 50) / 10.0); socVal = 85 + random(0, 3); float voltage = 67.0 + (random(0, 10) / 10.0); float current = 5.0 + (random(0, 260) / 10.0); float power = 0.30 + (random(0, 100) / 100.0); float temp = 32.0 + (random(0, 10) / 10.0); float diff_v = 0.010 + (random(0, 5) / 1000.0); float cons = 2.00 + (random(0, 50) / 100.0); updateUI(voltage, power, current, temp, diff_v, cons); } // 1 秒刷新时钟 static unsigned long lastClock = 0; if (millis() - lastClock >= 1000) { lastClock = millis(); syncWifiTime(); drawClock(); } } // ================== 绘制静态 UI ================== void drawUI() { tft.fillScreen(COLOR_BG); // === 左侧线框 === // 速度框 (亮绿色) tft.drawRoundRect(5, 5, 170, 76, 6, COLOR_SPEED); // SOC 框 (纯白色) tft.drawRoundRect(5, 87, 170, 76, 6, COLOR_WHITE); // === 右侧实色卡片 === tft.fillRoundRect(181, 5, 134, 48, 6, COLOR_CARD_V); tft.fillRoundRect(181, 59, 134, 48, 6, COLOR_CARD_P); tft.fillRoundRect(181, 113, 134, 48, 6, COLOR_CARD_CAP); // === 底部线框 (白色,无竖线分隔) === tft.drawRoundRect(BOX_X, BOX_Y, BOX_W, BOX_H, 6, COLOR_WHITE); // 上半行(时间)与下半行(数据)之间的横向分隔线 tft.drawFastHLine(BOX_X + 3, LINE_Y, BOX_W - 6, COLOR_WHITE); } // ================== 底部三列:数值+单位成对绘制,三组整体左右居中 ================== // 数值白色、单位灰色,同一个 Font4,x 为整组左边界 void drawPair(const char* val, const char* unit, int x, int cy) { tft.setTextFont(4); int wVal = tft.textWidth(val); tft.setTextColor(COLOR_WHITE, COLOR_BG); tft.drawString(val, x, cy); tft.setTextColor(COLOR_GREY, COLOR_BG); tft.drawString(unit, x + wVal + 3, cy); } // 一组 "数值 + 单位" 的像素宽度 int pairWidth(const char* val, const char* unit) { tft.setTextFont(4); return tft.textWidth(val) + 3 + tft.textWidth(unit); } // ================== SOC 竖直进度条 (带方框,从底部往上填充) ================== void drawSocBar(int percent, int x, int y, int w, int h) { // 外方框 tft.drawRect(x, y, w, h, COLOR_WHITE); // 先把内部擦成背景色,避免电量下降时残留上一帧 tft.fillRect(x + 1, y + 1, w - 2, h - 2, COLOR_BG); int innerH = h - 4; int fillH = innerH * percent / 100; if (fillH > 0) { uint16_t c = (percent <= 20) ? TFT_RED : ((percent <= 50) ? TFT_YELLOW : COLOR_SPEED); tft.fillRect(x + 2, y + h - 3 - fillH, w - 4, fillH, c); } } // ================== 左侧:速度 (7 段大字体上下居中 + 小号单位) ================== // 速度框 y 5~81,中心 43,Font7 高 48 -> 顶部 19 void drawSpeed(float speed) { char buf[16]; sprintf(buf, "%.1f", speed); tft.setTextSize(1); // 量宽度:数字用 Font7,单位用 Font2 tft.setTextFont(7); int wNum = tft.textWidth(buf); if (wNum <= 0 || wNum > 160) wNum = 113; // 兜底,防止字体度量异常时布局跑飞 tft.setTextFont(2); int wUnit = tft.textWidth("km/h"); const int GAP_U = 4; // 数字与单位间距 int x0 = 90 - (wNum + GAP_U + wUnit) / 2; // 整组在速度框内 (中心 x=90) 水平居中 // 数字 tft.setTextFont(7); tft.setTextColor(COLOR_SPEED, COLOR_BG); tft.setCursor(x0, 19); tft.print(buf); // 单位:紧跟数字右侧,小一号字体,与数字底部对齐 tft.setTextFont(2); tft.setTextDatum(ML_DATUM); tft.setTextColor(COLOR_GREY, COLOR_BG); tft.drawString("km/h", x0 + wNum + GAP_U, 59); } // ================== 左侧:SOC (数字+%+两空格+进度条 作为整体居中) ================== // SOC 框 y 87~163,中心 125,Font7 高 48 -> 顶部 101 void drawSoc(int soc) { char buf[8]; sprintf(buf, "%d", soc); tft.setTextSize(1); // 先量各处宽度 tft.setTextFont(7); int wNum = tft.textWidth(buf); if (wNum <= 0 || wNum > 140) wNum = 68; // 兜底 tft.setTextFont(4); int wPct = tft.textWidth("%"); int wSp = tft.textWidth(" "); const int BAR_W = 20; // 竖直进度条宽度 const int GAP = 3; // 数字与 % 的间距 int total = wNum + GAP + wPct + 2 * wSp + BAR_W; int x0 = 90 - total / 2; // 整组在框内 (中心 x=90) 水平居中 // 数字 tft.setTextFont(7); tft.setTextColor(COLOR_WHITE, COLOR_BG); tft.setCursor(x0, 101); tft.print(buf); // 百分号 int xPct = x0 + wNum + GAP; tft.setTextFont(4); tft.setTextColor(COLOR_GREY, COLOR_BG); tft.setCursor(xPct, 121); tft.print("%"); // % 后空 2 个字符宽度,再画竖直进度条(数值没变就不重绘,避免闪烁) static int prevSoc = -1; if (soc != prevSoc) { prevSoc = soc; drawSocBar(soc, xPct + wPct + 2 * wSp, 95, BAR_W, 60); } } // ================== 刷新动态数据 ================== void updateUI(float v, float p, float c, float t, float diff_v, float cons) { char buf[16]; // ================= 左侧:速度 / SOC ================= drawSpeed(speedVal); drawSoc(socVal); // ================= 右侧卡片 (平滑抗锯齿字体,数值+单位整组右对齐) ================= tft.setTextDatum(MR_DATUM); // 右对齐 const int CARD_RIGHT = 308; // 单位右边界 const int UNIT_GAP = 3; // 数值与单位之间的间距 // 单位宽度用 15 号字体量,数值用 36 号字体画 tft.loadFont(NotoSansBold15); int wUnitV = tft.textWidth("V"); int wUnitA = tft.textWidth("A"); int wUnitKW = tft.textWidth("kW"); tft.loadFont(NotoSansBold36); // 1. 电压 sprintf(buf, "%.1f", v); int xV = CARD_RIGHT - wUnitV - UNIT_GAP; eraseDelta(buf, prevV, xV, 12, 38, COLOR_CARD_V); tft.setTextColor(COLOR_WHITE, COLOR_CARD_V, true); tft.drawString(buf, xV, 29); strcpy(prevV, buf); // 2. 电流 sprintf(buf, "%.1f", c); int xC = CARD_RIGHT - wUnitA - UNIT_GAP; eraseDelta(buf, prevC, xC, 66, 38, COLOR_CARD_P); tft.setTextColor(COLOR_BLACK, COLOR_CARD_P, true); tft.drawString(buf, xC, 83); strcpy(prevC, buf); // 3. 功率 sprintf(buf, "%.2f", p); int xP = CARD_RIGHT - wUnitKW - UNIT_GAP; eraseDelta(buf, prevP, xP, 120, 38, COLOR_CARD_CAP); tft.setTextColor(COLOR_WHITE, COLOR_CARD_CAP, true); tft.drawString(buf, xP, 137); strcpy(prevP, buf); // ---- 单位 ---- tft.loadFont(NotoSansBold15); tft.setTextColor(COLOR_WHITE, COLOR_CARD_V, true); tft.drawString("V", CARD_RIGHT, 29); tft.setTextColor(COLOR_BLACK, COLOR_CARD_P, true); tft.drawString("A", CARD_RIGHT, 83); tft.setTextColor(COLOR_WHITE, COLOR_CARD_CAP, true); tft.drawString("kW", CARD_RIGHT, 137); tft.unloadFont(); // ================= 底部:三项数据整体左右居中 ================= char v1[16], v2[16], v3[16]; sprintf(v1, "%.2f", cons); // 能耗 sprintf(v2, "%.1f", t); // 温度 sprintf(v3, "%d", (int)(diff_v * 1000)); // 压差 // 先清空下半行内容区 (不动白色边框和分隔线) tft.fillRect(BOX_X + 2, 207, BOX_W - 4, 27, COLOR_BG); tft.setTextSize(1); tft.setTextDatum(ML_DATUM); // 按实测宽度排布:三组 + 两组间隔 作为整体居中于框中心 (x=160) int w1 = pairWidth(v1, "kWh"); int w2 = pairWidth(v2, "°C"); int w3 = pairWidth(v3, "mV"); int x = 160 - (w1 + w2 + w3 + 2 * COL_GAP) / 2; drawPair(v1, "kWh", x, ROW_Y); x += w1 + COL_GAP; drawPair(v2, "°C", x, ROW_Y); x += w2 + COL_GAP; drawPair(v3, "mV", x, ROW_Y); } // ================== 系统时间 (到秒) ================== void setupWifi() { #if USE_NTP_TIME if (strlen(WIFI_SSID) == 0) return; // 没填 WiFi 就不联网 WiFi.mode(WIFI_STA); WiFi.setAutoReconnect(true); WiFi.begin(WIFI_SSID, WIFI_PWD); #endif } // 连上 WiFi 后配置一次 NTP(北京时间 UTC+8) void syncWifiTime() { #if USE_NTP_TIME if (timeCfgDone || strlen(WIFI_SSID) == 0) return; if (WiFi.status() == WL_CONNECTED) { configTime(8 * 3600, 0, "ntp.aliyun.com", "cn.pool.ntp.org", "ntp.tencent.com"); timeCfgDone = true; } #endif } // ================== 日期时间 ================== int daysInMonth(int y, int m) { static const uint8_t dim[12] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; if (m == 2 && ((y % 4 == 0 && y % 100 != 0) || (y % 400 == 0))) return 29; // 闰年 return dim[m - 1]; } // 日期向后推 n 天(跨月/跨年自动进位) void addDays(DateTime* dt, long n) { if (n <= 0) return; dt->day += (int)n; while (dt->day > daysInMonth(dt->year, dt->month)) { dt->day -= daysInMonth(dt->year, dt->month); if (++dt->month > 12) { dt->month = 1; dt->year++; } } } // 当前日期时间:测试阶段用模拟时间;以后接 GPS 时只改这个函数即可 DateTime getNow() { DateTime dt; #if USE_NTP_TIME time_t now = time(nullptr); if (now > 1700000000UL) { // 2023 之后说明 NTP 已同步 struct tm lt; localtime_r(&now, <); dt.year = lt.tm_year + 1900; dt.month = lt.tm_mon + 1; dt.day = lt.tm_mday; dt.hour = lt.tm_hour; dt.minute = lt.tm_min; dt.second = lt.tm_sec; return dt; } #endif // 模拟时间 = 模拟起始时刻 + 开机后的秒数 long total = MOCK_START_H * 3600L + MOCK_START_M * 60L + MOCK_START_S + (long)(millis() / 1000UL); long secs = total % 86400L; dt.year = MOCK_START_Y; dt.month = MOCK_START_MO; dt.day = MOCK_START_D; addDays(&dt, total / 86400L); // 满一天日期进位 dt.hour = secs / 3600; dt.minute = (secs % 3600) / 60; dt.second = secs % 60; return dt; } // 时间栏:年月日 + 时分秒(宽度固定,直接带背景重绘不会残影) void drawClock() { static char prev[24] = ""; char buf[24]; DateTime dt = getNow(); sprintf(buf, "%04d-%02d-%02d %02d:%02d:%02d", dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second); if (strcmp(buf, prev) == 0) return; // 秒没变就不重绘 tft.setTextSize(1); tft.setTextFont(4); tft.setTextDatum(MC_DATUM); tft.setTextColor(COLOR_WHITE, COLOR_BG); tft.drawString(buf, 160, CLOCK_Y); strcpy(prev, buf); } // 高位数字位宽变短时(如 31.0 -> 5.0),右对齐会留下旧笔画,这里把多出来的部分擦掉 void eraseDelta(const char* now, const char* prev, int xRight, int yTop, int h, uint16_t color) { if (prev[0] == '\0') return; int wNew = tft.textWidth(now); int wOld = tft.textWidth(prev); if (wNew < wOld) { tft.fillRect(xRight - wOld - 2, yTop, wOld - wNew + 2, h, color); } }