资讯动态

ESP8266 12F 电动车仪表盘开发教程(320x240 ST7789 版)

发布时间:2026/10/27 10:31:14 来源:尧图企业网站定制
避坑指南血泪经验强烈建议阅读在正式开始写代码之前请务必了解以下三个关键点它们能帮你省去 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 驱动分辨率 240x320SPI接口)连接线杜邦线若干2. 核心接线表⚠️ 务必注意 CS 引脚TFT 屏幕ESP8266 引脚备注VCC3.3V绝对不能接 5V会烧屏GNDGND共地SDA / MOSID7 (GPIO13)硬件 SPISCL / SCKD5 (GPIO14)硬件 SPIDCD2 (GPIO4)数据/命令RSTD4 (GPIO2)复位BLKD1 (GPIO5)背光控制代码中拉高点亮CSGND核心避坑点接 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-12FESP-12F 模块 Arduino IDE 板型选择NodeMCU 1.0 (ESP-12E Module) 必须选带 D1~D8 引脚宏的板型否则下面代码里的 D1/D2 无法识别 若选 Generic ESP8266 Module请把 D1/D2 等换成对应 GPIO 编号 屏幕ST7789 240x320SPI 接口无触摸 注意屏幕引脚定义写在库文件里不在本文件 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); } // 底部三列数值单位成对绘制三组整体左右居中 // 数值白色、单位灰色同一个 Font4x 为整组左边界 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中心 43Font7 高 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; // 整组在速度框内 (中心 x90) 水平居中 // 数字 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中心 125Font7 高 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; // 整组在框内 (中心 x90) 水平居中 // 数字 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); // 按实测宽度排布三组 两组间隔 作为整体居中于框中心 (x160) 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北京时间 UTC8 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, lt); 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); } }

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑