Màn hình TFT SPI ILI9341 với ESP32: nối dây, lệnh khởi động, vẽ chữ và đồ thị không cần thư viện
Nối màn hình TFT ILI9341 2.4–2.8 inch với ESP32, các lệnh khởi động và vẽ vùng, màu RGB565, bộ chữ 5x7, chương trình vẽ số lớn và đồ thị điện áp không nhấp nháy, bảng sửa lỗi.
Màn hình TFT màu SPI 2.4 hoặc 2.8 inch (chip điều khiển ILI9341, 240 × 320 điểm ảnh) cho ESP32 hiện được số lớn, nhiều màu, đồ thị, rõ hơn hẳn màn hình OLED nhỏ. Thường người ta dùng thư viện có sẵn, nhưng hiểu màn hình nhận lệnh gì giúp tự sửa được khi gặp màn hình trắng, màu bị đảo hay hình chạy chậm. Bài này giải thích cách nối dây, các lệnh khởi động, màu RGB565, cách vẽ chữ và đồ thị, rồi viết một chương trình vẽ đồ thị điện áp chỉ dùng thư viện SPI có sẵn trong gói esp32.
1. Nối màn hình TFT ILI9341 với ESP32

| Chân TFT | ESP32 DevKit V1 | Vai trò |
|---|---|---|
| VCC | 3V3 | Nguồn (module có ổn áp thì cấp 5 V vào VIN cũng được) |
| GND | GND | |
| CS | GPIO5 | Chọn màn hình: 0 là đang nói chuyện với màn hình |
| RESET | GPIO17 (TX2) | Đặt lại phần cứng lúc khởi động |
| DC | GPIO16 (RX2) | 0: byte gửi là mã lệnh; 1: byte gửi là dữ liệu |
| MOSI (SDI) | GPIO23 | Dữ liệu từ ESP32 sang màn hình |
| SCK | GPIO18 | Xung nhịp SPI |
| LED | 3V3 | Đèn nền (sáng hết cỡ); muốn chỉnh độ sáng thì nối qua transistor và PWM |
| MISO (SDO) | để trống | Chỉ cần khi đọc dữ liệu từ màn hình |
Chân tín hiệu của màn hình chạy ở 3,3 V, hợp với ESP32. Phần khe thẻ nhớ (các chân SD_…) trên cùng module là một thiết bị SPI khác, không dùng trong bài. Nếu chưa quen SPI, đọc trước bài SPI là gì: MOSI, MISO, SCK, CS.
2. Màn hình nhận lệnh gì

ILI9341 có bộ nhớ khung hình riêng, 240 × 320 ô, mỗi ô 16 bit. ESP32 chỉ cần ghi vào bộ nhớ đó; chip tự quét ra tấm nền liên tục. Mỗi lần nói chuyện gồm một byte lệnh (DC = 0) và vài byte dữ liệu (DC = 1).
Khởi động chỉ cần 5 lệnh: đặt lại (0x01), thoát ngủ (0x11), chọn 16 bit mỗi điểm (0x3A với dữ liệu 0x55), chọn hướng quét và thứ tự màu (0x36 với 0x48: màn hình dọc, tấm nền xếp màu theo thứ tự lam – lục – đỏ), bật hiển thị (0x29). Các thư viện thường gửi thêm lệnh chỉnh điện áp, gamma; giá trị mặc định sau khi đặt lại đủ dùng cho phần lớn module.
Vẽ luôn theo ba bước: 0x2A chọn cột đầu và cuối, 0x2B chọn hàng đầu và cuối, 0x2C rồi gửi liền một loạt điểm ảnh, màn hình tự điền từ trái sang phải, từ trên xuống dưới trong vùng đã chọn. Tô một hình chữ nhật, vẽ một chữ hay một cột đồ thị đều quy về ba bước này.
Màu dùng dạng RGB565: 5 bit đỏ, 6 bit lục, 5 bit lam ghép thành 16 bit. Mắt người nhạy với màu lục nhất nên lục được nhiều bit hơn. Hàm mau(r, g, b) trong chương trình đổi màu 8 bit mỗi kênh sang dạng này.
3. Vẽ chữ bằng bộ chữ 5 × 7

Màn hình không có sẵn chữ; chữ cũng chỉ là các điểm ảnh. Chương trình có một bộ chữ 5 × 7 tự vẽ gồm 43 ký tự (dấu cách, số, chữ in hoa và vài dấu). Mỗi ký tự lưu thành 5 byte, mỗi byte là một cột, bit 0 là hàng trên cùng. Để vẽ cỡ co, mỗi điểm được vẽ thành ô vuông co × co.
Hàm veChu() dựng cả ô 6 × 8 (thêm một cột, một hàng làm khoảng cách) của mỗi ký tự trong bộ nhớ, gồm cả màu nền, rồi gửi một lần. Nhờ có màu nền, chữ mới đè thẳng lên chữ cũ mà không cần xóa trước, nên số đo thay đổi liên tục không bị nhấp nháy. Bộ chữ không có tiếng Việt có dấu; muốn có dấu cần bộ chữ lớn hơn, khi đó nên dùng thư viện.
4. Linh kiện cần chuẩn bị
- ESP32 DevKit V1 30 chân (hoặc loại 38 chân) và dây USB nạp code.
- Màn hình TFT SPI 2.4 hoặc 2.8 inch, chip ILI9341 (thường ghi trên tem hoặc trang bán).
- Biến trở 10 kΩ để tạo điện áp thay đổi (xem bài biến trở, quang trở và NTC với ESP32).
- Breadboard và dây cắm ngắn.
5. Chương trình vẽ đồ thị điện áp
Biến trở nối hai đầu vào 3V3 và GND, chân giữa vào GPIO34. Mỗi 100 ms, chương trình đọc điện áp, vẽ số lớn, giá trị nhỏ nhất, lớn nhất và đồ thị 200 điểm gần nhất (20 giây).
// PYWORLD.VN - Màn hình TFT ILI9341 (240x320, SPI) với ESP32 không cần thư viện: khởi động, tô màu, vẽ chữ 5x7 và đồ thị điện áp
// Nối: SCK GPIO18, MOSI GPIO23, CS GPIO5, DC GPIO16, RESET GPIO17, VCC và LED vào 3V3, GND chung. Biến trở 10 kΩ: hai đầu 3V3, GND, chân giữa GPIO34
// Màn hình hiện điện áp ở GPIO34, nhỏ nhất, lớn nhất và đồ thị 20 giây gần nhất (mỗi 100 ms một điểm)
#include <SPI.h>
const int CHAN_SCK = 18, CHAN_MOSI = 23, CHAN_CS = 5, CHAN_DC = 16, CHAN_RST = 17;
const int CHAN_DO = 34;
const uint32_t TOC_DO_SPI = 40000000; // 40 MHz; dây dài hoặc hình lỗi thì hạ xuống 20000000
constexpr uint16_t mau(uint8_t r, uint8_t g, uint8_t b) { return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3); }
const uint16_t NEN = mau(8, 12, 24), TRANG = mau(255, 255, 255), XAM = mau(150, 160, 175), XANH_DAM = mau(20, 60, 140);
const uint16_t LUOI = mau(40, 50, 70), DUONG = mau(80, 220, 120), VANG = mau(255, 200, 40);
// Bộ chữ 5x7 tự vẽ của bài: mỗi ký tự 5 byte theo cột, bit 0 là hàng trên cùng
const char KY_TU[] = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ.:-%/=";
const uint8_t PHONG[][5] = {
{0x00, 0x00, 0x00, 0x00, 0x00}, // ' '
{0x3E, 0x51, 0x49, 0x45, 0x3E}, // '0'
{0x00, 0x42, 0x7F, 0x40, 0x00}, // '1'
{0x42, 0x61, 0x51, 0x49, 0x46}, // '2'
{0x21, 0x41, 0x45, 0x4B, 0x31}, // '3'
{0x18, 0x14, 0x12, 0x7F, 0x10}, // '4'
{0x27, 0x45, 0x45, 0x45, 0x39}, // '5'
{0x3C, 0x4A, 0x49, 0x49, 0x30}, // '6'
{0x01, 0x71, 0x09, 0x05, 0x03}, // '7'
{0x36, 0x49, 0x49, 0x49, 0x36}, // '8'
{0x06, 0x49, 0x49, 0x29, 0x1E}, // '9'
{0x7E, 0x09, 0x09, 0x09, 0x7E}, // 'A'
{0x7F, 0x49, 0x49, 0x49, 0x36}, // 'B'
{0x3E, 0x41, 0x41, 0x41, 0x22}, // 'C'
{0x7F, 0x41, 0x41, 0x22, 0x1C}, // 'D'
{0x7F, 0x49, 0x49, 0x49, 0x41}, // 'E'
{0x7F, 0x09, 0x09, 0x09, 0x01}, // 'F'
{0x3E, 0x41, 0x49, 0x49, 0x7A}, // 'G'
{0x7F, 0x08, 0x08, 0x08, 0x7F}, // 'H'
{0x00, 0x41, 0x7F, 0x41, 0x00}, // 'I'
{0x20, 0x40, 0x41, 0x3F, 0x01}, // 'J'
{0x7F, 0x08, 0x14, 0x22, 0x41}, // 'K'
{0x7F, 0x40, 0x40, 0x40, 0x40}, // 'L'
{0x7F, 0x02, 0x0C, 0x02, 0x7F}, // 'M'
{0x7F, 0x04, 0x08, 0x10, 0x7F}, // 'N'
{0x3E, 0x41, 0x41, 0x41, 0x3E}, // 'O'
{0x7F, 0x09, 0x09, 0x09, 0x06}, // 'P'
{0x3E, 0x41, 0x51, 0x21, 0x5E}, // 'Q'
{0x7F, 0x09, 0x19, 0x29, 0x46}, // 'R'
{0x46, 0x49, 0x49, 0x49, 0x31}, // 'S'
{0x01, 0x01, 0x7F, 0x01, 0x01}, // 'T'
{0x3F, 0x40, 0x40, 0x40, 0x3F}, // 'U'
{0x1F, 0x20, 0x40, 0x20, 0x1F}, // 'V'
{0x3F, 0x40, 0x38, 0x40, 0x3F}, // 'W'
{0x63, 0x14, 0x08, 0x14, 0x63}, // 'X'
{0x03, 0x04, 0x78, 0x04, 0x03}, // 'Y'
{0x61, 0x51, 0x49, 0x45, 0x43}, // 'Z'
{0x00, 0x60, 0x60, 0x00, 0x00}, // '.'
{0x00, 0x36, 0x36, 0x00, 0x00}, // ':'
{0x08, 0x08, 0x08, 0x08, 0x08}, // '-'
{0x23, 0x13, 0x08, 0x64, 0x62}, // '%'
{0x20, 0x10, 0x08, 0x04, 0x02}, // '/'
{0x14, 0x14, 0x14, 0x14, 0x14}, // '='
};
// ---- Gửi lệnh và dữ liệu qua SPI ----
void batDau() {
SPI.beginTransaction(SPISettings(TOC_DO_SPI, MSBFIRST, SPI_MODE0));
digitalWrite(CHAN_CS, LOW);
}
void ketThuc() {
digitalWrite(CHAN_CS, HIGH);
SPI.endTransaction();
}
void guiLenh(uint8_t lenh) { // DC = 0: byte này là mã lệnh
digitalWrite(CHAN_DC, LOW);
SPI.write(lenh);
digitalWrite(CHAN_DC, HIGH); // các byte sau (nếu có) là dữ liệu của lệnh
}
void lenhDon(uint8_t lenh, const uint8_t *duLieu = nullptr, int n = 0) {
batDau();
guiLenh(lenh);
for (int i = 0; i < n; i++) SPI.write(duLieu[i]);
ketThuc();
}
// Chọn vùng hình chữ nhật; các điểm ảnh gửi sau lệnh 0x2C sẽ lấp đầy vùng theo từng hàng.
void datVung(int x0, int y0, int x1, int y1) {
guiLenh(0x2A);
SPI.write16(x0);
SPI.write16(x1);
guiLenh(0x2B);
SPI.write16(y0);
SPI.write16(y1);
guiLenh(0x2C);
}
void khoiDongTft() {
digitalWrite(CHAN_RST, LOW); // đặt lại bằng chân RESET
delay(20);
digitalWrite(CHAN_RST, HIGH);
delay(150);
lenhDon(0x01); // đặt lại phần mềm
delay(150);
lenhDon(0x11); // thoát chế độ ngủ
delay(120);
const uint8_t dinhDang = 0x55, huong = 0x48;
lenhDon(0x3A, &dinhDang, 1); // 16 bit mỗi điểm ảnh (RGB565)
lenhDon(0x36, &huong, 1); // màn hình dọc 240 x 320, thứ tự màu BGR của tấm nền
lenhDon(0x29); // bật hiển thị
}
// ---- Vẽ ----
void toMau(int x, int y, int w, int h, uint16_t c) {
static uint16_t dong[240];
for (int i = 0; i < w; i++) dong[i] = c;
batDau();
datVung(x, y, x + w - 1, y + h - 1);
for (int j = 0; j < h; j++) SPI.writePixels(dong, w * 2); // writePixels tự gửi byte cao trước
ketThuc();
}
// Dựng cả ô 6 x 8 của mỗi ký tự (phóng to `co` lần) trong bộ nhớ rồi gửi một lần: chữ mới đè chữ cũ, không nhấp nháy.
void veChu(int x, int y, const char *chuoi, int co, uint16_t mauChu, uint16_t mauNen) {
static uint16_t o[6 * 5 * 8 * 5]; // đủ cho cỡ 5
for (; *chuoi; chuoi++) {
const char *p = strchr(KY_TU, *chuoi);
const uint8_t *b = PHONG[p ? p - KY_TU : 0];
int w = 6 * co, h = 8 * co;
for (int py = 0; py < h; py++) {
for (int px = 0; px < w; px++) {
int cot = px / co, hang = py / co;
bool bat = cot < 5 && hang < 7 && (b[cot] >> hang & 1);
o[py * w + px] = bat ? mauChu : mauNen;
}
}
batDau();
datVung(x, y, x + w - 1, y + h - 1);
SPI.writePixels(o, w * h * 2);
ketThuc();
x += w;
}
}
// ---- Đồ thị ----
const int X0 = 30, Y0 = 136, W = 200, H = 170;
int duLieu[W]; // mV, cũ bên trái, mới bên phải
int soMau = 0;
int yCua(int mv) { return Y0 + H - 1 - min(H - 1, mv * (H - 1) / 3300); }
void veKhung() {
toMau(X0 - 1, Y0 - 1, W + 2, 1, XAM);
toMau(X0 - 1, Y0 + H, W + 2, 1, XAM);
toMau(X0 - 1, Y0, 1, H, XAM);
toMau(X0 + W, Y0, 1, H, XAM);
veChu(6, yCua(1000) - 3, "1V", 1, XAM, NEN);
veChu(6, yCua(2000) - 3, "2V", 1, XAM, NEN);
veChu(6, yCua(3000) - 3, "3V", 1, XAM, NEN);
}
// Mỗi cột dựng trong bộ nhớ (nền, lưới, đoạn nối hai điểm liền nhau) rồi gửi một lần.
void veDoThi() {
static uint16_t cot[H];
for (int i = 0; i < W; i++) {
for (int y = 0; y < H; y++) cot[y] = NEN;
for (int v = 1000; v <= 3000; v += 1000) cot[yCua(v) - Y0] = LUOI;
int j = i - (W - soMau);
if (j >= 1) {
int a = yCua(duLieu[j - 1]) - Y0, b = yCua(duLieu[j]) - Y0;
for (int y = min(a, b); y <= max(a, b) + 1 && y < H; y++) cot[y] = DUONG; // nét dày 2 điểm
}
batDau();
datVung(X0 + i, Y0, X0 + i, Y0 + H - 1);
SPI.writePixels(cot, H * 2);
ketThuc();
}
}
void setup() {
Serial.begin(115200);
pinMode(CHAN_CS, OUTPUT);
pinMode(CHAN_DC, OUTPUT);
pinMode(CHAN_RST, OUTPUT);
digitalWrite(CHAN_CS, HIGH);
SPI.begin(CHAN_SCK, -1, CHAN_MOSI, -1);
khoiDongTft();
toMau(0, 0, 240, 320, NEN);
toMau(0, 0, 240, 30, XANH_DAM);
veChu(8, 8, "DIEN AP GPIO34", 2, TRANG, XANH_DAM);
veKhung();
Serial.println("TFT san sang");
}
void loop() {
static uint32_t lanDo = 0;
if (millis() - lanDo < 100) return;
lanDo = millis();
uint32_t tong = 0;
for (int i = 0; i < 16; i++) tong += analogReadMilliVolts(CHAN_DO);
int mv = tong / 16;
if (soMau < W) {
duLieu[soMau++] = mv;
} else {
memmove(duLieu, duLieu + 1, (W - 1) * sizeof(int));
duLieu[W - 1] = mv;
}
int nho = duLieu[0], lon = duLieu[0];
for (int i = 1; i < soMau; i++) {
nho = min(nho, duLieu[i]);
lon = max(lon, duLieu[i]);
}
char chuoi[32];
snprintf(chuoi, sizeof(chuoi), "%.2f V", mv / 1000.0f);
veChu(10, 48, chuoi, 5, VANG, NEN);
snprintf(chuoi, sizeof(chuoi), "MIN %.2f MAX %.2f", nho / 1000.0f, lon / 1000.0f);
veChu(10, 104, chuoi, 2, XAM, NEN);
veDoThi();
}
batDau(),ketThuc()mở và đóng một giao dịch SPI (40 MHz, chế độ 0) và kéo chân CS;guiLenh()đặt DC = 0 cho byte lệnh rồi trả DC về 1 cho dữ liệu.SPI.writePixels()gửi một mảng điểm ảnh 16 bit, byte cao trước, đúng thứ tự ILI9341 cần.toMau()chọn vùng rồi gửi cùng một màu theo từng hàng;veDoThi()dựng từng cột 170 điểm (nền, đường lưới, đoạn nối hai điểm liền nhau) rồi gửi một lần, không xóa cả vùng nên không nhấp nháy.- Dữ liệu đồ thị dồn về bên phải: điểm mới nhất luôn ở cột cuối, đồ thị như đang trôi sang trái.
6. Kết quả mong đợi

Xoay biến trở, số lớn đổi ngay và đồ thị vẽ lại đường đi của điện áp. Mỗi lần cập nhật gửi khoảng 45 000 điểm ảnh, tức 90 000 byte (số lớn, dòng nhỏ nhất – lớn nhất, 200 cột đồ thị); ở 40 MHz mất khoảng 20 ms, nên cập nhật 10 lần mỗi giây vẫn còn thời gian cho việc khác. ADC của ESP32 đo tới khoảng 3,1 V, nên đầu biến trở sát 3V3 sẽ dừng ở gần 3,1 V.
Khi cần vẽ nhiều kiểu chữ, hình ảnh, nút bấm cảm ứng, dùng thư viện như TFT_eSPI hoặc Adafruit_ILI9341: chúng làm đúng những việc ở trên, kèm nhiều hàm có sẵn. Hiểu các lệnh 0x2A, 0x2B, 0x2C giúp chọn đúng cấu hình cho thư viện và đoán được lỗi khi màn hình không lên.
7. Xử lý lỗi thường gặp
| Hiện tượng | Nguyên nhân thường gặp | Cách xử lý |
|---|---|---|
| Màn hình trắng, chỉ có đèn nền | Sai chân CS, DC, RESET; nhầm MOSI với MISO | Đối chiếu bảng nối dây; MOSI còn có tên SDI |
| Hình có sọc, điểm sai rải rác | Dây dài, tốc độ SPI quá cao | Dây dưới 15 cm; hạ TOC_DO_SPI xuống 20 MHz |
| Màu đỏ thành xanh dương | Tấm nền xếp màu khác | Đổi 0x48 thành 0x40 ở lệnh 0x36 |
| Hình bị lật ngang hoặc chữ ngược | Hướng quét khác | Thử 0x88, 0x28, 0xE8 cho lệnh 0x36 |
| Màu đảo (đen thành trắng), hình lệch | Màn hình dùng chip ST7789 hay ST7735, không phải ILI9341 | Đọc lại tên chip; ST7789 thường cần thêm lệnh 0x21 và có kích thước khác |
| Số đo nhảy liên tục vài chục mV | Nhiễu ADC, dây biến trở dài | Tăng số lần lấy trung bình; tụ 100 nF từ GPIO34 xuống GND |
8. Xem thêm
- OLED 0.96 hay 1.3 inch, SSD1306 hay SH1106: khi chỉ cần màn hình nhỏ, ít dây.
- Màn hình LCD 2004 I2C với ESP32: hiện chữ đơn giản, dễ đọc dưới nắng.
- Đọc ADC trên ESP32: đo điện áp thật thay cho biến trở, ví dụ điện áp pin.
Xem thêm