ESP32-S3 + 2.8 吋 TFT LCD 觸控功能教學(LovyanGFX + XPT2046)
延續前兩篇 ESP32-S3 + TFT LCD 教學,本篇啟用 XPT2046 觸控功能。透過麵包板分接共用 SPI 腳位,搭配 LovyanGFX 內建觸控支援,實作畫板、按鈕、調色盤三個範例。
前兩篇教學我們成功在 ESP32-S3 上顯示繁體中文,也可以顯示JPG圖檔了,接下來這篇要來啟用螢幕上的觸控功能。這塊 2.8 吋 TFT 模組內建 XPT2046 電阻式觸控晶片,LovyanGFX 原生支援,不用額外裝函式庫。
如果你還沒看過第一篇的入門教學,建議可以看看,因為重複的部份,這個章節就不再說明囉!

至於材料也和前面兩篇一樣,主要是一塊ESP32-S3開發板,還有本篇的主角2.8吋ST7789 LCD。


觸控原理簡介
這塊模組使用的是電阻式觸控(Resistive Touch),跟手機的電容式觸控不同。電阻式需要用指尖或觸控筆稍微施力按壓才能感應,用指甲或筆尖效果最好。
觸控晶片 XPT2046 透過 SPI 跟 ESP32-S3 溝通,跟螢幕共用同一組 SPI 匯流排(SCK、MOSI、MISO),只是用不同的 CS 腳位來切換。這也是為什麼 LovyanGFX 設定裡要把 bus_shared 設為 true。
額外接線
在上一篇的基礎上,多接幾條線給觸控功能。
上一篇已經接好的(不動)
| LCD 模組排針 | ESP32-S3 GPIO |
|---|---|
| VCC | 3.3V |
| GND | GND |
| CS | GPIO 15 |
| RESET | GPIO 16 |
| DC | GPIO 17 |
| SDI (MOSI) | GPIO 18 |
| SCK | GPIO 8 |
| LED | 3.3V |
新增觸控接線
| LCD 模組排針 | ESP32-S3 GPIO | 說明 |
|---|---|---|
| SDO (MISO) | GPIO 13 | SPI 讀取(上一篇沒接,現在要接了) |
| T_CLK | GPIO 8 | 共用 SCK(已經接好) |
| T_DIN | GPIO 18 | 共用 MOSI(已經接好) |
| T_DO | GPIO 13 | 共用 MISO(同上) |
| T_CS | GPIO 21 | 觸控片選(獨立腳位) |
| T_IRQ | 不接 | 可不接,用輪詢方式讀取 |
重點: 觸控的 T_CLK、T_DIN、T_DO 跟螢幕的 SCK、MOSI、MISO 是共用的。實際上你需要多拉兩條線:SDO (MISO) → GPIO 13 和 T_CS → GPIO 21。但因為共用腳位的關係,SCK、MOSI、MISO 這三組訊號需要從一個 GPIO 同時接到螢幕端和觸控端,所以需要透過麵包板做分接。

共用 SPI 腳位的麵包板接法
螢幕和觸控共用 SCK、MOSI、MISO 三組 SPI 訊號。一個 GPIO 要同時接到兩個地方,利用麵包板同一行互通的特性來分接。
分接原理
麵包板同一直線的 5 個孔是互通的。把 ESP32 的 GPIO 接到同一行的上方孔,再從下方孔分別拉線到螢幕端和觸控端。


不經過麵包板的線(直接接)
| 連接 | 說明 |
|---|---|
| 3.3V → VCC | 供電 |
| 3.3V → LED | 背光 |
| GND → GND | 接地 |
| GPIO 15 → CS | 螢幕片選(獨立) |
| GPIO 16 → RESET | 重置 |
| GPIO 17 → DC | 資料/命令 |
| GPIO 21 → T_CS | 觸控片選(獨立) |
總線數
| 類型 | 數量 |
|---|---|
| 上一篇已有的線 | 8 條 |
| SCK 分接(多拉到 T_CLK) | 1 條 |
| MISO 新接(GPIO 13 → 麵包板 → SDO + T_DO) | 3 條 |
| MOSI 分接(多拉到 T_DIN) | 1 條 |
| T_CS → GPIO 21 | 1 條 |
| 新增合計 | 6 條 |
跟上一篇的程式碼差異
| 設定項目 | 上一篇 | 這一篇 |
|---|---|---|
cfg.pin_miso |
-1 |
13 |
cfg.readable |
false |
true |
cfg.bus_shared |
false |
true |
| 觸控設定 | 無 | 新增 Touch_XPT2046 |
觸控校正(Calibration)
電阻式觸控的原始讀值跟螢幕像素不一定精確對齊,校正可以提高準確度。強烈建議在正式使用觸控之前先跑一次校正,記下校正數據。
校正程式
#include <LovyanGFX.hpp>
class LGFX : public lgfx::LGFX_Device {
lgfx::Bus_SPI _bus_instance;
lgfx::Panel_ST7789 _panel_instance;
lgfx::Touch_XPT2046 _touch_instance;
public:
LGFX(void) {
{
auto cfg = _bus_instance.config();
cfg.spi_host = SPI2_HOST;
cfg.spi_mode = 0;
cfg.freq_write = 40000000;
cfg.freq_read = 16000000;
cfg.pin_sclk = 8;
cfg.pin_mosi = 18;
cfg.pin_miso = 13;
cfg.pin_dc = 17;
_bus_instance.config(cfg);
_panel_instance.setBus(&_bus_instance);
}
{
auto cfg = _panel_instance.config();
cfg.pin_cs = 15;
cfg.pin_rst = 16;
cfg.pin_busy = -1;
cfg.memory_width = 240;
cfg.memory_height = 320;
cfg.panel_width = 240;
cfg.panel_height = 320;
cfg.readable = true;
cfg.invert = false;
cfg.rgb_order = false;
cfg.dlen_16bit = false;
cfg.bus_shared = true;
_panel_instance.config(cfg);
}
{
auto cfg = _touch_instance.config();
cfg.x_min = 0;
cfg.x_max = 239;
cfg.y_min = 0;
cfg.y_max = 319;
cfg.pin_int = -1;
cfg.bus_shared = true;
cfg.offset_rotation = 0;
cfg.spi_host = SPI2_HOST;
cfg.freq = 2500000;
cfg.pin_sclk = 8;
cfg.pin_mosi = 18;
cfg.pin_miso = 13;
cfg.pin_cs = 21;
_touch_instance.config(cfg);
_panel_instance.setTouch(&_touch_instance);
}
setPanel(&_panel_instance);
}
};
LGFX display;
void setup() {
Serial.begin(115200);
display.init();
display.setRotation(1);
display.fillScreen(TFT_BLACK);
display.setFont(&fonts::efontTW_16);
display.setTextColor(TFT_WHITE);
display.setCursor(10, 10);
display.println("觸控校正程式");
display.setCursor(10, 40);
display.println("請依序點擊螢幕上的十字");
delay(2000);
// 執行觸控校正(螢幕會出現十字讓你點)
uint16_t calData[8];
display.calibrateTouch(calData, TFT_WHITE, TFT_BLACK, 20);
// 把校正數據印出來,記下這組數字
Serial.print("校正數據:{");
for (int i = 0; i < 8; i++) {
Serial.print(calData[i]);
if (i < 7) Serial.print(", ");
}
Serial.println("};");
display.fillScreen(TFT_BLACK);
display.setFont(&fonts::efontTW_16);
display.setTextColor(TFT_GREEN);
display.setCursor(10, 10);
display.println("校正完成!");
display.setCursor(10, 40);
display.println("請打開 Serial Monitor");
display.setCursor(10, 70);
display.println("記下校正數據");
display.setCursor(10, 110);
display.setTextColor(TFT_YELLOW);
display.println("現在可以試著觸碰螢幕");
display.println("看看座標是否正確");
}
void loop() {
lgfx::touch_point_t tp;
if (display.getTouch(&tp)) {
display.fillCircle(tp.x, tp.y, 3, TFT_CYAN);
Serial.printf("X:%d Y:%d\n", tp.x, tp.y);
}
}
程式執行後,會依序在螢幕的4個角出現箭號,請用觸控筆依指示小心點4個角落。

完成後就可以在畫面中隨意塗鴨了!如果發現定位不準,那就重跑一次程式,校正到準確為止。

使用校正數據
校正後 Serial Monitor 會印出一組數字,例如:

校正數據:{319, 3, 3800, 252, 3, 3780, 250, 3800};
把它記下來。之後的程式在 setup() 裡直接載入,就不用每次都重新校正:
uint16_t calData[8] = {319, 3, 3800, 252, 3, 3780, 250, 3800};
display.setTouchCalibrate(calData);
範例一:觸控畫板
最簡單的觸控測試,把剛才校正程式中的塗鴨程式獨立出來,做個陽春版的小畫家, 手指(請用指甲)碰哪裡就畫一個點,所以連續畫就是一條線囉!
這支程式的重點,在於你必須把校正程式中得到的校正數據先填入程式中,這樣這支新的程式就可以不經過校正,而得到精準的點位囉!
#include <LovyanGFX.hpp>
class LGFX : public lgfx::LGFX_Device {
lgfx::Bus_SPI _bus_instance;
lgfx::Panel_ST7789 _panel_instance;
lgfx::Touch_XPT2046 _touch_instance;
public:
LGFX(void) {
{
auto cfg = _bus_instance.config();
cfg.spi_host = SPI2_HOST;
cfg.spi_mode = 0;
cfg.freq_write = 40000000;
cfg.freq_read = 16000000;
cfg.pin_sclk = 8;
cfg.pin_mosi = 18;
cfg.pin_miso = 13;
cfg.pin_dc = 17;
_bus_instance.config(cfg);
_panel_instance.setBus(&_bus_instance);
}
{
auto cfg = _panel_instance.config();
cfg.pin_cs = 15;
cfg.pin_rst = 16;
cfg.pin_busy = -1;
cfg.memory_width = 240;
cfg.memory_height = 320;
cfg.panel_width = 240;
cfg.panel_height = 320;
cfg.readable = true;
cfg.invert = false;
cfg.rgb_order = false;
cfg.dlen_16bit = false;
cfg.bus_shared = true;
_panel_instance.config(cfg);
}
{
auto cfg = _touch_instance.config();
cfg.x_min = 0;
cfg.x_max = 239;
cfg.y_min = 0;
cfg.y_max = 319;
cfg.pin_int = -1;
cfg.bus_shared = true;
cfg.offset_rotation = 0;
cfg.spi_host = SPI2_HOST;
cfg.freq = 2500000;
cfg.pin_sclk = 8;
cfg.pin_mosi = 18;
cfg.pin_miso = 13;
cfg.pin_cs = 21;
_touch_instance.config(cfg);
_panel_instance.setTouch(&_touch_instance);
}
setPanel(&_panel_instance);
}
};
LGFX display;
void setup() {
Serial.begin(115200);
display.init();
display.setRotation(1);
display.fillScreen(TFT_BLACK);
// 把校正數據貼在這裡 重要!
uint16_t calData[8] = {你的校正數據};
display.setTouchCalibrate(calData);
display.setFont(&fonts::efontTW_16);
display.setTextColor(TFT_WHITE);
display.setCursor(10, 10);
display.println("觸控畫板 - 請用手指畫畫看");
display.drawRect(0, 0, 320, 240, TFT_DARKGREY);
}
void loop() {
lgfx::touch_point_t tp;
if (display.getTouch(&tp)) {
display.fillCircle(tp.x, tp.y, 3, TFT_YELLOW);
Serial.printf("X:%d Y:%d\n", tp.x, tp.y);
}
}
上傳後用手指或觸控筆在螢幕上滑動,應該會看到黃色的筆跡。

範例二:觸控按鈕
畫面上有三個按鈕,按下去會變色並顯示對應文字:
#include <LovyanGFX.hpp>
class LGFX : public lgfx::LGFX_Device {
lgfx::Bus_SPI _bus_instance;
lgfx::Panel_ST7789 _panel_instance;
lgfx::Touch_XPT2046 _touch_instance;
public:
LGFX(void) {
{
auto cfg = _bus_instance.config();
cfg.spi_host = SPI2_HOST;
cfg.spi_mode = 0;
cfg.freq_write = 40000000;
cfg.freq_read = 16000000;
cfg.pin_sclk = 8;
cfg.pin_mosi = 18;
cfg.pin_miso = 13;
cfg.pin_dc = 17;
_bus_instance.config(cfg);
_panel_instance.setBus(&_bus_instance);
}
{
auto cfg = _panel_instance.config();
cfg.pin_cs = 15;
cfg.pin_rst = 16;
cfg.pin_busy = -1;
cfg.memory_width = 240;
cfg.memory_height = 320;
cfg.panel_width = 240;
cfg.panel_height = 320;
cfg.readable = true;
cfg.invert = false;
cfg.rgb_order = false;
cfg.dlen_16bit = false;
cfg.bus_shared = true;
_panel_instance.config(cfg);
}
{
auto cfg = _touch_instance.config();
cfg.x_min = 0;
cfg.x_max = 239;
cfg.y_min = 0;
cfg.y_max = 319;
cfg.pin_int = -1;
cfg.bus_shared = true;
cfg.offset_rotation = 0;
cfg.spi_host = SPI2_HOST;
cfg.freq = 2500000;
cfg.pin_sclk = 8;
cfg.pin_mosi = 18;
cfg.pin_miso = 13;
cfg.pin_cs = 21;
_touch_instance.config(cfg);
_panel_instance.setTouch(&_touch_instance);
}
setPanel(&_panel_instance);
}
};
LGFX display;
// 按鈕結構
struct Button {
int x, y, w, h;
const char* label;
uint16_t color;
bool pressed;
};
Button buttons[3] = {
{10, 80, 90, 50, "紅燈", TFT_RED, false},
{115, 80, 90, 50, "綠燈", TFT_GREEN, false},
{220, 80, 90, 50, "藍燈", TFT_BLUE, false},
};
void drawButton(Button &btn) {
uint16_t color = btn.pressed ? TFT_WHITE : btn.color;
display.fillRoundRect(btn.x, btn.y, btn.w, btn.h, 6, color);
display.drawRoundRect(btn.x, btn.y, btn.w, btn.h, 6, TFT_WHITE);
display.setFont(&fonts::efontTW_16);
display.setTextColor(btn.pressed ? TFT_BLACK : TFT_WHITE);
display.setCursor(btn.x + 18, btn.y + 16);
display.println(btn.label);
}
void drawStatusBar(const char* msg, uint16_t color) {
display.fillRect(0, 160, 320, 40, TFT_BLACK);
display.setFont(&fonts::efontTW_24);
display.setTextColor(color);
display.setCursor(10, 166);
display.println(msg);
}
void setup() {
Serial.begin(115200);
display.init();
display.setRotation(1);
display.fillScreen(TFT_BLACK);
// 把校正數據貼在這裡
uint16_t calData[8] = {你的校正數據};
display.setTouchCalibrate(calData);
// 標題
display.fillRect(0, 0, 320, 40, display.color565(0, 80, 160));
display.setFont(&fonts::efontTW_24);
display.setTextColor(TFT_WHITE);
display.setCursor(10, 8);
display.println("觸控按鈕測試");
// 說明文字
display.setFont(&fonts::efontTW_14);
display.setTextColor(TFT_DARKGREY);
display.setCursor(10, 52);
display.println("請按下方按鈕:");
// 畫按鈕
for (int i = 0; i < 3; i++) {
drawButton(buttons[i]);
}
drawStatusBar("等待觸碰...", TFT_DARKGREY);
// 底部提示
display.setFont(&fonts::efontTW_12);
display.setTextColor(TFT_DARKGREY);
display.setCursor(10, 220);
display.println("傑森創工 ESP32-S3 觸控教學");
}
void loop() {
lgfx::touch_point_t tp;
if (display.getTouch(&tp)) {
// 檢查每個按鈕
for (int i = 0; i < 3; i++) {
Button &btn = buttons[i];
if (tp.x >= btn.x && tp.x <= btn.x + btn.w &&
tp.y >= btn.y && tp.y <= btn.y + btn.h) {
if (!btn.pressed) {
btn.pressed = true;
drawButton(btn);
drawStatusBar(btn.label, btn.color);
Serial.printf("按下:%s\n", btn.label);
}
}
}
delay(50); // 簡易防抖
} else {
// 放開手指,重置按鈕狀態
for (int i = 0; i < 3; i++) {
if (buttons[i].pressed) {
buttons[i].pressed = false;
drawButton(buttons[i]);
}
}
}
}

範例三:觸控調色盤
用觸控滑動來調整 RGB 顏色,即時預覽混色結果:
#include <LovyanGFX.hpp>
class LGFX : public lgfx::LGFX_Device {
lgfx::Bus_SPI _bus_instance;
lgfx::Panel_ST7789 _panel_instance;
lgfx::Touch_XPT2046 _touch_instance;
public:
LGFX(void) {
{
auto cfg = _bus_instance.config();
cfg.spi_host = SPI2_HOST;
cfg.spi_mode = 0;
cfg.freq_write = 40000000;
cfg.freq_read = 16000000;
cfg.pin_sclk = 8;
cfg.pin_mosi = 18;
cfg.pin_miso = 13;
cfg.pin_dc = 17;
_bus_instance.config(cfg);
_panel_instance.setBus(&_bus_instance);
}
{
auto cfg = _panel_instance.config();
cfg.pin_cs = 15;
cfg.pin_rst = 16;
cfg.pin_busy = -1;
cfg.memory_width = 240;
cfg.memory_height = 320;
cfg.panel_width = 240;
cfg.panel_height = 320;
cfg.readable = true;
cfg.invert = false;
cfg.rgb_order = false;
cfg.dlen_16bit = false;
cfg.bus_shared = true;
_panel_instance.config(cfg);
}
{
auto cfg = _touch_instance.config();
cfg.x_min = 0;
cfg.x_max = 239;
cfg.y_min = 0;
cfg.y_max = 319;
cfg.pin_int = -1;
cfg.bus_shared = true;
cfg.offset_rotation = 0;
cfg.spi_host = SPI2_HOST;
cfg.freq = 2500000;
cfg.pin_sclk = 8;
cfg.pin_mosi = 18;
cfg.pin_miso = 13;
cfg.pin_cs = 21;
_touch_instance.config(cfg);
_panel_instance.setTouch(&_touch_instance);
}
setPanel(&_panel_instance);
}
};
LGFX display;
int r_val = 128, g_val = 128, b_val = 128;
void drawSlider(int y, const char* label, int value, uint16_t color) {
// 標籤
display.setFont(&fonts::efontTW_14);
display.setTextColor(TFT_WHITE, TFT_BLACK);
display.setCursor(10, y + 2);
display.printf("%s: %3d", label, value);
// 滑桿背景
display.fillRect(100, y, 200, 20, TFT_DARKGREY);
// 滑桿填充
int barWidth = map(value, 0, 255, 0, 200);
display.fillRect(100, y, barWidth, 20, color);
// 滑桿邊框
display.drawRect(100, y, 200, 20, TFT_WHITE);
}
void drawColorPreview() {
uint16_t color = display.color565(r_val, g_val, b_val);
display.fillRoundRect(10, 150, 300, 60, 8, color);
display.drawRoundRect(10, 150, 300, 60, 8, TFT_WHITE);
// 顯示 hex 色碼
display.setFont(&fonts::efontTW_14);
// 自動選擇文字顏色(深色背景用白字,淺色用黑字)
int brightness = (r_val * 299 + g_val * 587 + b_val * 114) / 1000;
display.setTextColor(brightness > 128 ? TFT_BLACK : TFT_WHITE);
display.setCursor(110, 170);
display.printf("#%02X%02X%02X", r_val, g_val, b_val);
}
void setup() {
Serial.begin(115200);
display.init();
display.setRotation(1);
display.fillScreen(TFT_BLACK);
// 把校正數據貼在這裡
uint16_t calData[8] = {你的校正數據};
display.setTouchCalibrate(calData);
// 標題
display.fillRect(0, 0, 320, 36, display.color565(0, 80, 160));
display.setFont(&fonts::efontTW_24);
display.setTextColor(TFT_WHITE);
display.setCursor(10, 6);
display.println("觸控調色盤");
// 說明
display.setFont(&fonts::efontTW_12);
display.setTextColor(TFT_DARKGREY);
display.setCursor(10, 44);
display.println("滑動色條調整 RGB 值:");
drawSlider(65, "紅", r_val, TFT_RED);
drawSlider(95, "綠", g_val, TFT_GREEN);
drawSlider(125, "藍", b_val, TFT_BLUE);
drawColorPreview();
// 底部
display.setFont(&fonts::efontTW_12);
display.setTextColor(TFT_DARKGREY);
display.setCursor(10, 220);
display.println("傑森創工 ESP32-S3 觸控教學");
}
void loop() {
lgfx::touch_point_t tp;
if (display.getTouch(&tp)) {
// 判斷觸碰在哪個滑桿上
bool changed = false;
if (tp.x >= 100 && tp.x <= 300) {
int value = map(constrain(tp.x, 100, 300), 100, 300, 0, 255);
if (tp.y >= 55 && tp.y <= 90) {
r_val = value;
drawSlider(65, "紅", r_val, TFT_RED);
changed = true;
}
else if (tp.y >= 85 && tp.y <= 120) {
g_val = value;
drawSlider(95, "綠", g_val, TFT_GREEN);
changed = true;
}
else if (tp.y >= 115 && tp.y <= 150) {
b_val = value;
drawSlider(125, "藍", b_val, TFT_BLUE);
changed = true;
}
}
if (changed) {
drawColorPreview();
Serial.printf("R:%d G:%d B:%d\n", r_val, g_val, b_val);
}
delay(30);
}
}

常見問題排查
觸控完全沒反應
- 確認 MISO 有接 — 上一篇不需要 MISO,但觸控一定要接,因為 XPT2046 會回傳座標資料
- 確認 T_CS 有接 — T_CS 是觸控的片選,必須接到獨立的 GPIO
- 確認共用腳位有分接 — SCK、MOSI、MISO 要透過麵包板同時接到螢幕端和觸控端,缺一不可
- 確認
bus_shared = true— 螢幕和觸控共用 SPI,這個設定一定要開
觸控座標不準(碰左邊出現在右邊)
調整 cfg.offset_rotation,值 0~7 逐個試,找到跟你的 setRotation() 搭配正確的那一個。
觸控靈敏度太低
電阻式觸控需要一定的壓力才能感應。用指甲或觸控筆效果比指腹好。如果完全壓不出反應,可能是觸控面板本身有問題。
螢幕顯示會閃爍
開啟 bus_shared = true 後,每次讀取觸控都會短暫中斷螢幕的 SPI 通訊。如果你的 loop() 裡頻繁重繪整個畫面,就會看到閃爍。解法是只更新有變化的區域,不要整個 fillScreen 重畫。
下一步
到這裡,你已經能在 ESP32-S3 上實現螢幕顯示圖片 + 繁體中文 + 觸控互動了。後續可以進一步嘗試:
- SD 卡讀取自訂字型 — 用 OpenFontRender 載入思源黑體 TTF
- LVGL GUI 框架 — 專業級的嵌入式圖形介面
- 實用專案 — WiFi 氣象站、智慧家居控制面板、IoT 儀表板


