為 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 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 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 顯示器會不斷循環顯示三種純色畫面,每一秒切換一次:

  1. 紅色畫面 (0xF800):RGB565 格式中的紅色,顯示 1 秒
  2. 綠色畫面 (0x07E0):顯示 1 秒
  3. 藍色畫面 (0x001F):顯示 1 秒
  4. 然後從頭開始重複

這代表整個螢幕每秒都被完整刷新一次,並且由 DMA 直接把 frame buffer 的內容傳送給 LCD,大幅減少 CPU 干預和卡頓的可能。

結論

在 LVGL Render 嵌入式系統中,CPU 是珍貴資源。傳統 SPI 傳輸方式不僅耗時,還會造成系統反應遲鈍。而 DMA 的引入,讓你能以極低 CPU 負載完成全螢幕資料輸出

雖然這一篇還未實際接上 LVGL Render,但這套底層架構,就是日後與 LVGL 整合時真正讓畫面跑得流暢的核心關鍵