☰
ESP32-S3 + ST7789 + LVGL 驱动与触摸调试实战指南
2026/11/4 20:40:06 网站建设 项目流程

简介:面向嵌入式与物联网开发者的ESP32S3图形界面驱动示例,聚焦在IDF 5.2.2框架下将LVGL图形库与ST7789显示驱动、xpt2046电阻触摸控制器打通这一常见难点,适合已具备一定ESP-IDF基础、希望快速验证屏幕显示与触摸交互的中级开发者参考。资源包共10个文件,约36KB,以C源码与头文件承载LVGL初始化及显示触摸对接逻辑,辅以CMakeLists构建脚本、sdkconfig配置项与idf_component.yml依赖清单,便于直接导入工程编译。已有761人学习下载。Demo完整呈现了从SPI驱动ST7789刷屏、xpt2046坐标采集到LVGL控件渲染的链路,读者可据此测试显示效果与触摸响应,评估ESP32S3在图形界面项目中的适用性,并作为产品原型与功能验证的起点,省去从零搭建驱动框架的时间。

1. 一块 2.8 寸屏点亮之前,先想清楚这套组合拳怎么打

手上有一块 ST7789 驱动的 SPI 屏,带 xpt2046 电阻触摸,主控是 ESP32-S3,开发环境锁在 IDF 5.2.2,UI 想用 LVGL。这四个东西单拎出来都不难,凑一起就是另一回事:SPI 总线要分时复用给屏幕和触摸,LVGL 的刷新缓冲要跟 DMA 传输对齐,触摸读数还得做滤波和坐标映射。我见过太多人卡在“屏幕白屏但串口有输出”或者“触摸能读到值但点哪都不准”这种玄学问题上。这份 Demo 就是把这些坑一次性趟平的参考工程,适合刚拿到 ESP32-S3 开发板、想快速把 LVGL 跑起来的嵌入式开发者,也适合已经能点屏但触摸调不准、想找一份干净基线代码的人。

2. 工程结构与技术选型:为什么是 IDF 5.2.2 加 LVGL 这套组合

2.1 目录骨架与组件划分

拿到工程先别急着编译,把目录翻一遍比看任何文档都快。这套 Demo 的目录结构遵循 IDF 的标准组件模型,核心思路是把“驱动层”和“UI 层”彻底分开,后面换屏、换触摸芯片时只动驱动层,LVGL 那边一行不改。

esp32s3_lvgl_st7789/ ├── CMakeLists.txt # 顶层工程定义,声明 EXTRA_COMPONENT_DIRS ├── sdkconfig.defaults # 关键配置固化,避免每次 menuconfig 手点 ├── main/ │ ├── CMakeLists.txt │ ├── main.c # 入口:初始化 NVS、LVGL、驱动、创建 UI │ └── idf_component.yml # 声明 lvgl 组件依赖及版本 └── components/ ├── bsp_display/ │ ├── include/bsp_display.h │ └── bsp_display.c # ST7789 初始化 + LVGL flush 回调 ├── bsp_touch/ │ ├── include/bsp_touch.h │ └── bsp_touch.c # xpt2046 SPI 读取 + LVGL indev 注册 └── bsp_lvgl_port/ ├── include/bsp_lvgl_port.h └── bsp_lvgl_port.c # LVGL 任务、tick、互斥锁封装

这个划分不是摆样子。bsp_display只关心“怎么把一块像素缓冲区刷到屏上”,bsp_touch只关心“怎么把一次触摸读成坐标”,bsp_lvgl_port负责把两者接进 LVGL 的心跳。我第一次写这类工程时把所有东西塞进 main.c,结果换一块 240x320 的屏改了三天,血泪经验就是分层要趁早。

2.2 技术栈版本与依赖关系

IDF 5.2.2 这个版本不是随便选的。5.x 系列把esp_lcd组件正式稳定下来,ST7789 这类 SPI 屏可以直接用esp_lcd_new_panel_st7789而不用自己拼初始化序列;同时 5.2 对 PSRAM 和 DMA 的描述符管理做了优化,跑 LVGL 这种吃内存的框架时不容易在heap_caps_malloc上翻车。LVGL 侧建议用 8.3 或 9.x,Demo 里通过idf_component.yml拉取,版本锁死避免组件市场更新后编译不过。

组件版本/来源作用
ESP-IDF5.2.2底层驱动、FreeRTOS、SPI 主机
LVGL8.3.x(组件管理器)UI 框架、控件、渲染
esp_lcdIDF 内置SPI 面板抽象、ST7789 驱动
xpt2046自写组件电阻触摸 ADC 读取

依赖关系上有个容易忽略的点:esp_lcd的 ST7789 驱动内部已经处理了 SPI 事务的队列化,你不需要再手动spi_device_acquire_bus,但触摸和屏幕共用一条 SPI 总线时,必须保证两者不在同一时刻抢总线。Demo 的做法是给触摸单独挂一个 SPI 设备句柄,靠 IDF 的 SPI 主机锁自动串行化,这一点在后面的避坑章节会展开。

2.3 引脚分配与硬件假设

工程默认按一块常见的 ESP32-S3 开发板接线,屏幕和触摸共用 SPI2 主机,片选分开。改硬件时只需要动bsp_display.h和bsp_touch.h里的宏定义,不用翻 .c 文件。

// components/bsp_display/include/bsp_display.h #define BSP_LCD_HOST SPI2_HOST #define BSP_LCD_PIN_SCLK 12 #define BSP_LCD_PIN_MOSI 11 #define BSP_LCD_PIN_CS 10 #define BSP_LCD_PIN_DC 9 #define BSP_LCD_PIN_RST 8 #define BSP_LCD_PIN_BL 7 // 背光,PWM 可调 #define BSP_LCD_H_RES 240 #define BSP_LCD_V_RES 320

参数说明:BSP_LCD_HOST选 SPI2 是因为 SPI0/1 被 Flash 和 PSRAM 占用,动不得;H_RES/V_RES要和实际屏一致,写错会出现画面偏移或只显示一角。背光脚单独留出来,方便后面做亮度调节,Demo 里先用 GPIO 拉高,改成 LEDC PWM 也就几行的事。

3. 驱动层拆解:ST7789 刷屏与 xpt2046 读触摸的完整链路

3.1 ST7789 初始化与 LVGL flush 回调

屏幕驱动的核心就两件事:把面板初始化起来,然后告诉 LVGL“我有一块缓冲区,你画完叫我,我负责刷出去”。esp_lcd把第一件事封装得很薄,第二件事需要自己写回调。

// components/bsp_display/bsp_display.c(节选) static bool lcd_flush_ready(esp_lcd_panel_io_handle_t io, esp_lcd_panel_io_event_data_t *edata, void *user_ctx) { // DMA 传输完成中断里调用,通知 LVGL 这块 buffer 可以复用了 lv_disp_flush_ready((lv_disp_drv_t *)user_ctx); return false; } void bsp_display_init(void) { spi_bus_config_t bus_cfg = { .sclk_io_num = BSP_LCD_PIN_SCLK, .mosi_io_num = BSP_LCD_PIN_MOSI, .miso_io_num = -1, // 屏幕只写,不接 MISO .max_transfer_sz = BSP_LCD_H_RES * 80 * sizeof(uint16_t), }; ESP_ERROR_CHECK(spi_bus_initialize(BSP_LCD_HOST, &bus_cfg, SPI_DMA_CH_AUTO)); esp_lcd_panel_io_spi_config_t io_cfg = { .dc_gpio_num = BSP_LCD_PIN_DC, .cs_gpio_num = BSP_LCD_PIN_CS, .pclk_hz = 40 * 1000 * 1000, // 40MHz,屏能扛就往上加 .lcd_cmd_bits = 8, .lcd_param_bits = 8, .spi_mode = 0, .trans_queue_depth = 10, .on_color_trans_done = lcd_flush_ready, .user_ctx = &disp_drv, }; esp_lcd_new_panel_io_spi((esp_lcd_spi_bus_handle_t)BSP_LCD_HOST, &io_cfg, &io_handle); esp_lcd_panel_dev_config_t panel_cfg = { .reset_gpio_num = BSP_LCD_PIN_RST, .rgb_ele_order = LCD_RGB_ELEMENT_ORDER_RGB, .bits_per_pixel = 16, }; esp_lcd_new_panel_st7789(io_handle, &panel_cfg, &panel_handle); esp_lcd_panel_reset(panel_handle); esp_lcd_panel_init(panel_handle); esp_lcd_panel_invert_color(panel_handle, true); // 多数 ST7789 模块需要反色 esp_lcd_panel_disp_on_off(panel_handle, true); }

逻辑说明:on_color_trans_done这个回调是整个刷屏链路的关键,它在 DMA 传输完成时触发,里面调lv_disp_flush_ready告诉 LVGL 缓冲区空了。如果忘了写这个回调,LVGL 会一直等,表现就是画面卡在第一帧不动。pclk_hz设 40MHz 是保守值,杜邦线接的模块超过 40MHz 容易花屏,PCB 走线好的可以试 60MHz。invert_color这个参数因模块而异,如果上电后颜色像底片,把它反过来就行。

LVGL 侧的 flush 回调负责把渲染好的区域推给面板:

static void disp_flush(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_map) { esp_lcd_panel_draw_bitmap(panel_handle, area->x1, area->y1, area->x2 + 1, area->y2 + 1, color_map); // 注意:这里不调 lv_disp_flush_ready,交给 DMA 完成回调 }

这里有个反直觉的点:disp_flush里不要调lv_disp_flush_ready,否则 LVGL 会在 DMA 还没传完时就覆写缓冲区,画面出现撕裂。正确做法就是上面那样,把 ready 通知放到on_color_trans_done里,让硬件中断来驱动 LVGL 的节奏。

3.2 xpt2046 触摸读取与坐标映射

xpt2046 是电阻触摸,本质是个 SPI 接口的 ADC,读出来的是原始 ADC 值,需要做两件事:滤波和映射。滤波防抖动,映射把 ADC 值换算成屏幕像素坐标。

// components/bsp_touch/bsp_touch.c(节选) #define XPT2046_CMD_X 0xD0 #define XPT2046_CMD_Y 0x90 static uint16_t xpt2046_read(uint8_t cmd) { uint8_t tx[3] = {cmd, 0x00, 0x00}; uint8_t rx[3] = {0}; spi_transaction_t t = { .length = 24, .tx_buffer = tx, .rx_buffer = rx, }; spi_device_transmit(touch_spi, &t); // 12 位 ADC 值在 rx[1] 低 4 位 + rx[2] 高 8 位 return ((rx[1] & 0x0F) << 8) | rx[2]; } static void touch_read_cb(lv_indev_drv_t *drv, lv_indev_data_t *data) { uint16_t raw_x = xpt2046_read(XPT2046_CMD_X); uint16_t raw_y = xpt2046_read(XPT2046_CMD_Y); if (raw_x < 200 || raw_y < 200) { // 未按下时 ADC 值很低 >static void lvgl_task(void *arg) { while (1) { bsp_lvgl_lock(); uint32_t delay = lv_timer_handler(); bsp_lvgl_unlock(); vTaskDelay(pdMS_TO_TICKS(delay > 10 ? 10 : delay)); } } void bsp_lvgl_port_init(void) { lv_init(); const esp_timer_create_args_t tick_args = { .callback = &lv_tick_inc_cb, // 内部调 lv_tick_inc(1) .name = "lv_tick" }; esp_timer_handle_t tick_timer; esp_timer_create(&tick_args, &tick_timer); esp_timer_start_periodic(tick_timer, 1000); // 1ms xTaskCreate(lvgl_task, "lvgl", 4096, NULL, 5, NULL); }

lv_timer_handler返回的是“距离下次该处理还有多少毫秒”,直接拿来做vTaskDelay比固定延时省 CPU。互斥锁bsp_lvgl_lock用递归互斥量实现,这样 UI 任务里嵌套调用也不会死锁。任务栈给 4096 字节是底线,控件多了要往上加,栈溢出在 ESP32 上表现为莫名其妙的重启,排查起来很费劲。

4. 从零跑起来:编译、烧录与首屏验证

4.1 环境准备与编译命令

IDF 5.2.2 装好后,进工程目录先设目标芯片,再编译。这一步顺序不能反,先idf.py set-target会清掉 build 目录,如果之前已经编译过别的芯片,配置会全部重置。

# 激活 IDF 环境(路径按实际安装位置改) . $HOME/esp/esp-idf/export.sh cd esp32s3_lvgl_st7789 idf.py set-target esp32s3 idf.py build

编译过程中组件管理器会自动拉 LVGL,第一次会慢一点。如果卡在Solving dependencies,多半是网络问题,可以配个本地镜像或者提前把 lvgl 组件放到components/下。编译通过后烧录:

idf.py -p /dev/ttyUSB0 flash monitor

monitor会直接打开串口,看到LVGL init done和display ready之类的日志就说明初始化走通了。退出 monitor 用Ctrl+]。

4.2 首屏验证与常见现象对照

烧录完第一件事不是看 UI 好不好看,是确认三件事:背光亮不亮、画面有没有花、触摸有没有反应。这三件事对应三个独立的排查方向,别混在一起查。

现象大概率原因先查什么
全黑无背光背光脚没拉高 / 屏没供电万用表量 BL 脚电平
白屏有背光初始化序列没跑 / RST 时序不对串口日志有没有 panel init 成功
花屏条纹SPI 时钟太高 / 接线太长把 pclk_hz 降到 20MHz 试
颜色反了invert_color 参数错改esp_lcd_panel_invert_color
触摸无反应片选或中断脚配错读原始 ADC 值看有没有变化

首屏建议先跑一个纯色填充加一个lv_label,别一上来就加载复杂 UI。纯色能验证刷屏链路,label 能验证字体和渲染,两步都过了再往上叠控件。

4.3 用 menuconfig 固化关键配置

sdkconfig.defaults里已经写好了几个必改项,但如果你手动menuconfig改过,记得把改动同步回 defaults,否则下次set-target又回到默认值。关键几项是:CONFIG_SPIRAM=y(如果板子带 PSRAM,LVGL 缓冲可以放外部)、CONFIG_FREERTOS_HZ=1000(tick 精度)、CONFIG_LV_COLOR_DEPTH_16=y(和屏的 16 位色对齐)。

idf.py menuconfig # Component config -> LVGL -> Color depth 选 16 # Component config -> ESP PSRAM -> 使能(按板子实际情况)

改完idf.py build重新编译,配置会写进sdkconfig,但sdkconfig.defaults不会自动更新,需要手动把关键行抄过去。这个习惯能省掉很多“换台机器就编译不过”的麻烦。

5. 避坑与排查:那些让我熬夜的五个问题

5.1 屏幕和触摸抢 SPI 总线导致触摸偶发失灵

现象:UI 正常显示,但触摸时灵时不灵,快速滑动时尤其明显。原因:屏幕 DMA 传输和触摸读取共用 SPI2,触摸读的时候如果屏幕正在刷,事务会排队甚至超时。解决:给触摸设备单独设一个spi_device_handle_t,并在spi_device_interface_config_t里把queue_size设成 1,让 IDF 的 SPI 主机锁自动串行化;更稳的做法是触摸读取放在 LVGL 的 indev 回调里,天然和刷屏错开。我一般还会把触摸的 SPI 时钟降到 1MHz,电阻触摸对速度不敏感,降频换稳定性很划算。

5.2 LVGL 缓冲区大小选错导致内存溢出或撕裂

现象:画面局部撕裂,或者运行一段时间后heap_caps_malloc返回 NULL。原因:缓冲区按整屏算(2403202 = 150KB)在内部 RAM 里放不下,或者只给了一个小 buffer 但开了双缓冲。解决:单缓冲用 1/10 屏大小(240*32),双缓冲各 1/10,放 PSRAM 的话可以放大到 1/4 屏。配置时注意lv_disp_draw_buf_init的第三个参数是 buffer 大小(像素数),不是字节数,这里写错是经典翻车点。

5.3 触摸坐标偏移,点哪都不准

现象:触摸有反应,但点击位置和实际控件对不上,越往边缘偏得越多。原因:校准参数用的是别的屏的值,或者 X/Y 轴接反了。解决:写个临时校准程序,在屏幕四角画标记,读原始 ADC 值,记下四个角的 min/max,填进TOUCH_X_MIN/MAX。如果发现 X 轴读数随 Y 方向变化,说明 X/Y 接反了,在映射时交换即可。电阻屏的校准值会随温度轻微漂移,工业场景要定期重标。

5.4 编译报 lv_conf.h 找不到

现象:fatal error: lv_conf.h: No such file or directory。原因:LVGL 组件默认不启用lv_conf.h,需要在 menuconfig 里打开,或者手动把模板拷到工程里。解决:idf.py menuconfig进Component config -> LVGL -> Enable lv_conf.h,或者从组件目录把lv_conf_template.h拷到main/下改名lv_conf.h,再把第一行的#if 0改成#if 1。后者更灵活,改配置不用反复进 menuconfig。

5.5 烧录后反复重启,日志停在某一行

现象:串口日志刷到一半就重启,循环往复。原因:多半是栈溢出或看门狗超时。LVGL 任务栈给太小、或者lv_timer_handler里某个回调执行太久触发任务看门狗。解决:先把 LVGL 任务栈加到 8192 试,如果还重启,在lv_timer_handler前后打时间戳,看哪个回调耗时超过 100ms。另外确认CONFIG_ESP_TASK_WDT_TIMEOUT_S没被改得太小,默认 5 秒一般够用。

6. 二次开发:换屏、加控件与性能调优的实操路径

拿到这份 Demo 之后,最常见的改造方向有三个:换一块不同分辨率的屏、加自己的 UI 控件、把刷新率提上去。这三个方向的改动点其实很集中,摸清了就不用每次从头翻代码。

换屏的改动面最小。以换成 240x240 的 ST7789 方屏为例,只需要改bsp_display.h里的BSP_LCD_H_RES/V_RES,然后检查max_transfer_sz是否还够(按新分辨率重算),最后重新校准触摸。如果换的是不同驱动芯片(比如 ILI9341),把esp_lcd_new_panel_st7789换成对应的esp_lcd_new_panel_ili9341,初始化序列 IDF 都封装好了,其余代码不动。这里有个技巧:把面板类型也做成宏,在bsp_display.c里用#if分支,一份工程能同时支持几种屏,打样阶段特别省事。

加控件的关键是别在中断或驱动回调里直接调lv_*。所有 UI 操作走bsp_lvgl_lock/unlock包起来,或者用lv_async_call投递到 LVGL 任务里执行。我一般会在bsp_lvgl_port里再封一层ui_post(fn, arg),底层就是lv_async_call,业务代码只管调,不用关心线程问题。

性能调优先看两个数:lv_timer_handler的单次耗时和 SPI 的实际刷屏帧率。前者超过 30ms 说明有控件布局太复杂,用 LVGL 自带的lv_profiler或者简单打点定位;后者上不去先查pclk_hz和缓冲区大小,双缓冲加 DMA 是标配。如果 PSRAM 带宽成为瓶颈(缓冲放外部时常见),把缓冲区改回内部 RAM 的 1/10 屏大小,帧率反而更稳。下面这个配置是我在 240x320 屏上实测比较均衡的一组:

// 双缓冲,各 1/10 屏,放内部 RAM #define BUF_LINES 32 static lv_color_t buf1[BSP_LCD_H_RES * BUF_LINES]; static lv_color_t buf2[BSP_LCD_H_RES * BUF_LINES]; lv_disp_draw_buf_init(&draw_buf, buf1, buf2, BSP_LCD_H_RES * BUF_LINES);

最后说个验证方法:改完任何驱动层代码,先跑一个“红绿蓝白黑”五色轮播的测试函数,确认刷屏链路没问题,再叠 UI。这个习惯是我早期一次改 SPI 时钟后画面偶发花屏、查了两天才发现是 DMA 描述符没对齐之后养成的,现在每次动驱动层都强制走一遍五色测试。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询