LVGL v8.3.11 Porting Guide
Source:
main/APP/lvgl_demo.c/lvgl_demo.h
1. Configuration Overview
| Item | Value |
|---|---|
| LVGL version | v8.3.11 |
| Color format | RGB565 (16-bit) |
| Buffer strategy | Full-screen double buffer (full_refresh=1) |
| Buffer location | PSRAM (MALLOC_CAP_SPIRAM) |
| Single buffer size | 800×480×2 = 768,000 bytes |
| Two buffers total | ~1.46 MB |
| Tick source | esp_timer hardware timer (1ms) |
2. Data Flow
Two buffers (buf1, buf2) give LVGL and DMA one buf for LVGL to draw, and the other buf for DMA to send to the screen synchronously.
2.1 Double Buffer Working Principle
Continuous loop ──────────────────────────────────────────────→
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐
│ buf1 ← LVGL draw │ │ buf1 → DMA send │ │ buf1 ← LVGL draw │
│ buf2 → DMA send │ │ buf2 ← LVGL draw │ │ buf2 → DMA send │
└────────┬──────────┘ └────────┬──────────┘ └────────┬──────────┘
│ Swap on draw done │ Swap on draw done │ Swap on draw done
▼ ▼ ▼
flush_cb(): "LVGL, this buf is drawn. Take the other buf and keep drawing." ← Only does this one thingWhy we need double buffering:
Single buffer: ████ Draw ████ Wait DMA send ████ Draw ████ Wait DMA send ████ ...(serial, must wait after drawing)
Double buffer: ████ Draw ████ Draw ████ Draw ████ Draw ████ Draw ████ ...(parallel, no waiting)
████ Send ████ Send ████ Send ████ Send ████ Send ████2.2 Frame Lifecycle
┌─────────────── Frame lifecycle (one-shot, ~30 ms/frame) ───────────────────┐
│ │
│ lv_demo_task (Core 1, priority 1) │
│ │ │
│ ├─① lv_timer_handler() ← Called every 10ms │
│ │ │ UI logic (events/animations/styles) │
│ │ │ Render → write to buf1 or buf2 (LVGL internal allocation) │
│ │ └─② flush_cb(drv, area, color_map) │
│ │ │ Receive frame data pointer from LVGL │
│ │ ├─ lock xMutex → save g_area / g_color │
│ │ ├─ lv_disp_flush_ready() ★ Tell LVGL: buffer has been freed │
│ │ ├─ unlock xMutex │
│ │ └─③ xSemaphoreGive() ─────────────────────────────┐ │
│ │ │ │
│ │ ④ (After release LVGL can immediately render next frame to other buf) │
│ │ │ │
│ lv_flush_task (Core 1, priority 2) │ │
│ │ │ │
│ │ ← Block on xSemaphoreTake() ←─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ │
│ ├─⑤ lock xMutex → read g_area / g_color │
│ ├─⑥ lcd_lv_cb(x1,y1,x2,y2, color_map, sizeof(lv_color_t)) │
│ │ │ Set window + QSPI DMA transfer → LCD GRAM │
│ │ │ Time = old_time → current_time (for fps statistics) │
│ │ └─ vTaskDelay((MIN_PERIOD - dt)/1000) if needed for rate throttle │
│ ├─⑦ unlock xMutex │
│ └─⑧ Loop back to ⑤ (wait for next frame semaphore) │
│ │
└────────────────────────────────────────────────────────────────────────────┘3. Thread and Synchronization Configuration
c
/* ── Thread configuration ─────────────────────────────── */
#define LV_DEMO_TASK_PRIO 1
#define LV_DEMO_STK_SIZE (5 * 1024)
#define LV_FLUSH_TASK_PRIO 2
#define LV_FLUSH_STK_SIZE (5 * 1024)
/* ── Thread handles ─────────────────────────────────── */
TaskHandle_t LV_DEMOTask_Handler;
TaskHandle_t LV_FLUSHTask_Handler;
/* ── Synchronization and shared data ─────────────────── */
SemaphoreHandle_t xSemaphore = NULL; // flush_cb → flush task notification
SemaphoreHandle_t xMutex = NULL; // protect g_area / g_color
lv_area_t g_area; // current refresh area
lv_color_t *g_color = NULL; // current pixel data pointer4. Entry lvgl_demo() — Six-step Initialization
c
void lvgl_demo(void)
{
lv_init(); // ① LVGL core
lv_port_disp_init(); // ② Display device (LCD + double buffer)
lv_port_indev_init(); // ③ Touch input
if (xSemaphore == NULL) xSemaphore = xSemaphoreCreateBinary();
if (xMutex == NULL) xMutex = xSemaphoreCreateMutex();
const esp_timer_create_args_t tick_args = { // ④ 1ms Tick
.callback = &increase_lvgl_tick,
.name = "lvgl_tick"
};
esp_timer_handle_t tick_timer = NULL;
ESP_ERROR_CHECK(esp_timer_create(&tick_args, &tick_timer));
ESP_ERROR_CHECK(esp_timer_start_periodic(tick_timer, 1000));
xTaskCreatePinnedToCore(lv_demo_task, // ⑤ UI thread
"lv_demo_task", LV_DEMO_STK_SIZE, NULL,
LV_DEMO_TASK_PRIO, &LV_DEMOTask_Handler, 1);
xTaskCreatePinnedToCore(lv_flush_task, // ⑥ Flush thread
"lv_flush_task", LV_FLUSH_STK_SIZE, NULL,
LV_FLUSH_TASK_PRIO, &LV_FLUSHTask_Handler, 1);
}5. Display Port lv_port_disp_init()
c
void lv_port_disp_init(void)
{
lcd_init(); // Hardware init (see lcd.md for details)
void *buf1 = heap_caps_malloc(
qspilcd_dev.width * qspilcd_dev.height * sizeof(lv_color_t),
MALLOC_CAP_SPIRAM);
if (buf1 == NULL) printf("lvgl req buffer fail\n");
void *buf2 = heap_caps_malloc(
qspilcd_dev.width * qspilcd_dev.height * sizeof(lv_color_t),
MALLOC_CAP_SPIRAM);
if (buf2 == NULL) printf("lvgl req buffer fail\n");
static lv_disp_draw_buf_t disp_buf;
lv_disp_draw_buf_init(&disp_buf, buf1, buf2,
qspilcd_dev.width * qspilcd_dev.height);
static lv_disp_drv_t disp_drv;
lv_disp_drv_init(&disp_drv);
disp_drv.hor_res = qspilcd_dev.width;
disp_drv.ver_res = qspilcd_dev.height;
disp_drv.flush_cb = lvgl_disp_flush_cb;
disp_drv.draw_buf = &disp_buf;
disp_drv.user_data = qspilcd_handle;
disp_drv.full_refresh = 1; // ★ Full-screen refresh
lv_disp_drv_register(&disp_drv);
}6. Flush Callback flush_cb
c
static void lvgl_disp_flush_cb(lv_disp_drv_t *drv,
const lv_area_t *area,
lv_color_t *color_map)
{
static int64_t last_time = 0; // Frame interval stats
int64_t current_time = esp_timer_get_time();
if (xMutex) xSemaphoreTake(xMutex, portMAX_DELAY);
g_area = *area;
g_color = color_map;
lv_disp_flush_ready(drv); // ★ Tell LVGL immediately: buffer has been freed
if (xMutex) xSemaphoreGive(xMutex);
if (xSemaphore) xSemaphoreGive(xSemaphore); // ★ Wake up flush task
last_time = esp_timer_get_time();
}Core strategy: Call
lv_disp_flush_ready()immediately without waiting for DMA to complete, so LVGL can render the next frame to the other buffer right away, while the flush task finishes the DMA transfer asynchronously in the background.
7. Flush Task
c
#define MIN_PERIOD 0 // Minimum frame interval (μs), 0 = no rate limit
void lv_flush_task(void *pvParameters)
{
static uint32_t fps_cnt = 0;
int64_t current_time, old_time;
while (1) {
if (xSemaphore) xSemaphoreTake(xSemaphore, portMAX_DELAY); // Block waiting for new frame
if (xMutex) xSemaphoreTake(xMutex, portMAX_DELAY);
old_time = esp_timer_get_time();
lcd_lv_cb(g_area.x1, g_area.y1,
g_area.x2, g_area.y2,
(void*)g_color, sizeof(lv_color_t)); // ★ DMA transfer
fps_cnt++;
current_time = esp_timer_get_time();
if ((current_time - old_time) < MIN_PERIOD)
vTaskDelay((MIN_PERIOD - (current_time - old_time)) / 1000);
if (xMutex) xSemaphoreGive(xMutex);
}
}8. LVGL Main Task
c
void lv_demo_task(void *pvParameters)
{
lv_demo_widgets(); // ★ Current demo, switchable
while (1) {
lv_timer_handler(); // UI logic + render
vTaskDelay(pdMS_TO_TICKS(10)); // ~100Hz
}
}Optional Demos
| Function | Description |
|---|---|
lv_demo_widgets() | Widget demo (default) |
lv_demo_music() | Music player UI |
lv_demo_benchmark() | Performance benchmark |
lv_demo_stress() | Stress test |
lv_demo_keypad_encoder() | Encoder input test |
lv_example_win_1() | Window widget example |
demo1_init() | NXP GUI Guider dashboard |
lv_mainstart() | SD card file browser |
create_buttons_demo() | Button demo |
tdo_lv_display_init() | TDO custom UI |
9. Tick Time Base
c
static void increase_lvgl_tick(void *arg)
{
lv_tick_inc(1); // +1 every 1ms, drives lv_timer
}10. Touch Input Registration
c
void lv_port_indev_init(void)
{
esp32_ctp_init(); // Init touch hardware (see ctp.md for details)
static lv_indev_drv_t indev_drv;
lv_indev_drv_init(&indev_drv);
indev_drv.type = LV_INDEV_TYPE_POINTER;
indev_drv.read_cb = touchpad_read;
lv_indev_drv_register(&indev_drv);
}
void touchpad_read(lv_indev_drv_t *drv, lv_indev_data_t *data)
{
static int64_t last_x = 0, last_y = 0;
static int64_t last_time = 0; // FPS stats baseline time
static uint32_t frame_count = 0;
int64_t current_time = esp_timer_get_time();
if ((current_time - last_time) >= 1000000) {
last_time = current_time; // Reset every second
frame_count = 0;
}
if (touchpad_is_pressed()) {
touchpad_get_xy(&last_x, &last_y);
data->state = LV_INDEV_STATE_PR;
frame_count++;
} else {
data->state = LV_INDEV_STATE_REL;
}
data->point.x = (lv_coord_t)last_x;
data->point.y = (lv_coord_t)last_y;
}11. Thread Configuration
| Thread | Priority | Stack | Core | Trigger |
|---|---|---|---|---|
lv_demo_task | 1 (low) | 5 KB | Core 1 | 10ms periodic polling |
lv_flush_task | 2 (high) | 5 KB | Core 1 | Semaphore event-driven |
The flush task has higher priority to ensure it immediately preempts the UI task right after xSemaphoreGive to start DMA transfer. Both are bound to Core 1; Core 0 is left for Wi-Fi/BLE.
12. Porting Checklist
- [ ] Implement
lv_port_disp_init()→ registerflush_cb+ double buffer +full_refresh=1 - [ ] Implement
lv_port_indev_init()→ registerread_cb - [ ] Implement
flush_cb→ copy coordinates then immediately calllv_disp_flush_ready()(do not wait for DMA) - [ ] Implement
lv_flush_task→ block on semaphore →lcd_lv_cb()→ release mutex → loop - [ ] Implement
lv_demo_task→lv_demo_xxx()+while(1) { lv_timer_handler(); vTaskDelay(10); } - [ ] Create 1ms Tick timer → call
lv_tick_inc(1)in callback - [ ] Initialize semaphore/mutex →
xSemaphoreCreateBinary/Mutex()
13. Troubleshooting
| Symptom | Check |
|---|---|
| No display | PSRAM enabled? RST timing? SPI waveform? Did buffer malloc succeed? |
| Garbled screen | Color format is RGB565? Window coordinate range correct? |
| Stuck | Stack high water mark uxTaskGetStackHighWaterMark()? Remaining PSRAM? |
| Low FPS | Is MIN_PERIOD limited? Is vTaskDelay throttle correct? |
| Touch unresponsive | I2C address? Pull-up resistors? TDO_CTP_ONCE matches? Was esp32_ctp_init() called? |