為 LVGL Render 打好地基 | ESP32 使用 SPI DMA 加速 TFT 畫面刷新
LVGL Render 如果你曾在 ESP32 上開發過 UI,使用 LVGL(Light and Versatile Graphics Library)來打造圖形介面,那你很可能遇過一個令人沮喪的情況:畫面更新速度太慢,導致操作延遲和畫面閃爍。
這不是 LVGL 的錯,而是底層 顯示驅動的資料傳輸方式影響了效能。這篇文章的目標,就是帶你理解如何透過 SPI DMA(Direct Memory Access) 的方式,在 ESP32 上提升 TFT LCD 顯示效率,為你後續實作 LVGL 打下穩定、高效的基礎。

內容
什麼是 LVGL Render?
LVGL 的核心任務是將畫面轉換成像素數據(frame buffer),然後透過一個「flush 函數」把這些像素送到螢幕上。這個過程稱為 render。
在 ESP32 上,這個 flush 的資料傳遞通常是透過 SPI 介面將 LVGL 計算後的像素資料寫入 TFT LCD 的 RAM 中。
LVGL 並不管你用哪種方式送資料——你可以一筆一筆寫,也可以用 DMA 方式。但 DMA 可以一次送完一整塊畫面,效能差距極大。
ESP32 的 SPI DMA 原理簡介
DMA(Direct Memory Access)是一種讓硬體直接從記憶體搬資料到周邊裝置(如 SPI),不經由 CPU 的機制。對 SPI 來說:
- 沒用 DMA:CPU 需要一筆一筆送資料到 SPI 寄存器;
- 用了 DMA:CPU 只要設定一次,DMA 控制器會自行將整個畫面資料搬到 SPI。
ESP32 支援 DMA,搭配 spi_device_queue_trans() 等 API,可非同步提交整筆畫面資料。這對於畫面大小為 240×320 的 TFT 而言,是必須優化的部分。
沒有 DMA vs 有 DMA 的 LVGL Render 差異
項目 | 無 DMA | 有 DMA |
---|---|---|
資料傳送方式 | CPU 逐筆傳送 | DMA 自動搬運 |
CPU 使用率 | 高 | 低 |
畫面刷新效率 | 慢(有明顯卡頓) | 快(接近硬體極限) |
LVGL 整體體驗 | lag、掉幀 | 絲滑流暢 |
程式碼複雜度 | 低 | 稍高(需設置 DMA) |
開發環境
在開始編程之前,請確保已完成以下準備工作:
- 安裝 ESP-IDF 開發環境 (至少版本 v5.x 或更高)。
- ESP32 開發板。
- 一個 SPI 接口的 TFT LCD(如 ST7789)。
專案結構
建立一個乾淨的 ESP-IDF LVGL Render 專案如下:
tft_dma_lvgl_demo/
├── CMakeLists.txt
├── sdkconfig
└── main/
├── main.c # Main program
└── tft_driver.c # TFT driver (including DMA implementation)
本文將所有邏輯集中在 main.c
做 SPI DMA 示範 ,日後你可將 tft_fill_color()
抽成獨立 driver 模組。
二種方案程式碼比較
傳統版本(無 DMA):
void tft_fill_color(uint16_t color) {
uint8_t color_data[2] = {color >> 8, color & 0xFF}; // Convert 16-bit color to two 8-bit bytes
for (int i = 0; i < TFT_WIDTH * TFT_HEIGHT; i++) {
tft_send_data(color_data, 2); // Send 2 bytes per pixel
}
}
DMA 加速版本:
void tft_fill_color_dma(uint16_t color) {
for (int i = 0; i < TFT_WIDTH * TFT_HEIGHT; i++) {
frame_buffer[i * 2] = color >> 8; // High byte of color
frame_buffer[i * 2 + 1] = color & 0xFF; // Low byte of color
}
spi_transaction_t t = {
.length = TFT_WIDTH * TFT_HEIGHT * 16, // Total bits (16 bits per pixel)
.tx_buffer = frame_buffer,
.user = (void*)1
};
spi_device_queue_trans(spi, &t, portMAX_DELAY); // Queue the DMA transfer
spi_transaction_t *ret;
spi_device_get_trans_result(spi, &ret, portMAX_DELAY); // Wait for transfer to complete
}
實測結果(相同畫面刷新):
測試項目 | 傳統方式 | DMA 方式 |
---|---|---|
全螢幕填色時間 | 約 1 s | 小小於 1 s |
CPU 空閒時間 | 幾乎為 0 | 可做其他事 |
是否流暢 | 非常不流暢 | 流暢 |
完整 Code
#include "driver/spi_master.h"
#include "driver/gpio.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_heap_caps.h"
#define TAG "TFT_DMA"
// Hardware pin definitions
#define PIN_NUM_MISO -1
#define PIN_NUM_MOSI 13
#define PIN_NUM_CLK 14
#define PIN_NUM_CS 4
#define PIN_NUM_DC 15
#define PIN_NUM_RST 2
#define PIN_NUM_LED 27
// Screen resolution
#define TFT_WIDTH 240
#define TFT_HEIGHT 320
// ST7789 command definitions
#define ST7789_SWRESET 0x01
#define ST7789_SLPOUT 0x11
#define ST7789_DISPON 0x29
#define ST7789_CASET 0x2A
#define ST7789_RASET 0x2B
#define ST7789_RAMWR 0x2C
spi_device_handle_t spi;
uint8_t *frame_buffer = NULL;
/* SPI pre-transfer callback: sets DC pin */
static void IRAM_ATTR spi_pre_transfer_callback(spi_transaction_t *t) {
gpio_set_level(PIN_NUM_DC, (int)t->user);
}
/* Send command to TFT */
void tft_send_cmd(uint8_t cmd) {
spi_transaction_t t = {
.length = 8,
.tx_buffer = &cmd,
.user = (void*)0,
};
spi_device_polling_transmit(spi, &t);
}
/* Send data to TFT */
void tft_send_data(uint8_t *data, uint16_t len) {
spi_transaction_t t = {
.length = len * 8,
.tx_buffer = data,
.user = (void*)1,
};
spi_device_polling_transmit(spi, &t);
}
/* Set display window area */
void tft_set_window(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2) {
uint8_t buf[4];
buf[0] = x1 >> 8; buf[1] = x1 & 0xFF;
buf[2] = x2 >> 8; buf[3] = x2 & 0xFF;
tft_send_cmd(ST7789_CASET);
tft_send_data(buf, 4);
buf[0] = y1 >> 8; buf[1] = y1 & 0xFF;
buf[2] = y2 >> 8; buf[3] = y2 & 0xFF;
tft_send_cmd(ST7789_RASET);
tft_send_data(buf, 4);
}
/* Fill the entire screen with a color using DMA */
void tft_fill_color_dma(uint16_t color) {
if (!frame_buffer) {
frame_buffer = (uint8_t *)heap_caps_malloc(TFT_WIDTH * TFT_HEIGHT * 2, MALLOC_CAP_DMA);
if (!frame_buffer) {
ESP_LOGE(TAG, "Frame buffer allocation failed");
return;
}
}
// Fill DMA buffer with color (RGB565 format)
for (int i = 0; i < TFT_WIDTH * TFT_HEIGHT; i++) {
frame_buffer[i * 2] = color >> 8;
frame_buffer[i * 2 + 1] = color & 0xFF;
}
tft_set_window(0, 0, TFT_WIDTH - 1, TFT_HEIGHT - 1);
tft_send_cmd(ST7789_RAMWR);
// DMA SPI transfer for full frame
spi_transaction_t t = {
.length = TFT_WIDTH * TFT_HEIGHT * 16,
.tx_buffer = frame_buffer,
.user = (void*)1
};
ESP_ERROR_CHECK(spi_device_queue_trans(spi, &t, portMAX_DELAY));
spi_transaction_t *ret;
ESP_ERROR_CHECK(spi_device_get_trans_result(spi, &ret, portMAX_DELAY));
}
/* Initialize TFT display */
void tft_init() {
// Configure GPIO
gpio_config_t io_conf = {
.pin_bit_mask = (1ULL << PIN_NUM_LED) | (1ULL << PIN_NUM_DC) | (1ULL << PIN_NUM_RST),
.mode = GPIO_MODE_OUTPUT,
};
gpio_config(&io_conf);
gpio_set_level(PIN_NUM_RST, 0);
vTaskDelay(pdMS_TO_TICKS(100));
gpio_set_level(PIN_NUM_RST, 1);
vTaskDelay(pdMS_TO_TICKS(120));
// Initialize SPI bus
spi_bus_config_t buscfg = {
.mosi_io_num = PIN_NUM_MOSI,
.miso_io_num = PIN_NUM_MISO,
.sclk_io_num = PIN_NUM_CLK,
.quadwp_io_num = -1,
.quadhd_io_num = -1,
.max_transfer_sz = TFT_WIDTH * TFT_HEIGHT * 2,
};
ESP_ERROR_CHECK(spi_bus_initialize(SPI2_HOST, &buscfg, SPI_DMA_CH_AUTO));
spi_device_interface_config_t devcfg = {
.clock_speed_hz = 40 * 1000 * 1000,
.mode = 0,
.spics_io_num = PIN_NUM_CS,
.queue_size = 7,
.pre_cb = spi_pre_transfer_callback,
};
ESP_ERROR_CHECK(spi_bus_add_device(SPI2_HOST, &devcfg, &spi));
// Initialize TFT controller
tft_send_cmd(ST7789_SWRESET);
vTaskDelay(pdMS_TO_TICKS(150));
tft_send_cmd(ST7789_SLPOUT);
vTaskDelay(pdMS_TO_TICKS(120));
uint8_t colmod_cmd[] = {0x3A, 0x55}; // Set color mode to RGB565
tft_send_cmd(colmod_cmd[0]);
tft_send_data(&colmod_cmd[1], 1);
tft_send_cmd(ST7789_DISPON);
vTaskDelay(pdMS_TO_TICKS(120));
gpio_set_level(PIN_NUM_LED, 1);
}
/* Main function */
void app_main(void) {
tft_init();
ESP_LOGI(TAG, "TFT initialized with DMA");
while (1) {
tft_fill_color_dma(0xF800); // Red
vTaskDelay(pdMS_TO_TICKS(1000));
tft_fill_color_dma(0x07E0); // Green
vTaskDelay(pdMS_TO_TICKS(1000));
tft_fill_color_dma(0x001F); // Blue
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
這段程式碼展示了如何在 ESP32 上使用 SPI DMA 傳輸來提升 TFT LCD(使用 ST7789 控制器)顯示效率,並搭配 LVGL 的 Render 概念奠定底層高速刷新能力
編譯和燒錄
完成程式碼後,您可以使用 ESP-IDF 提供的命令進行編譯、燒錄和監控。
在 VS Code 的左下角 ESP-IDF 工具列:
- 點選 Build project
- 點選 Flash device
- 點選 Monitor device
當程式啟動後,TFT 顯示器會不斷循環顯示三種純色畫面,每一秒切換一次:
- 紅色畫面 (
0xF800
):RGB565 格式中的紅色,顯示 1 秒 - 綠色畫面 (
0x07E0
):顯示 1 秒 - 藍色畫面 (
0x001F
):顯示 1 秒 - 然後從頭開始重複
這代表整個螢幕每秒都被完整刷新一次,並且由 DMA 直接把 frame buffer 的內容傳送給 LCD,大幅減少 CPU 干預和卡頓的可能。
結論
在 LVGL Render 嵌入式系統中,CPU 是珍貴資源。傳統 SPI 傳輸方式不僅耗時,還會造成系統反應遲鈍。而 DMA 的引入,讓你能以極低 CPU 負載完成全螢幕資料輸出。
雖然這一篇還未實際接上 LVGL Render,但這套底層架構,就是日後與 LVGL 整合時真正讓畫面跑得流暢的核心關鍵。