简介:面向嵌入式与物联网开发者的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-IDF | 5.2.2 | 底层驱动、FreeRTOS、SPI 主机 |
| LVGL | 8.3.x(组件管理器) | UI 框架、控件、渲染 |
| esp_lcd | IDF 内置 | 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 monitormonitor会直接打开串口,看到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 描述符没对齐之后养成的,现在每次动驱动层都强制走一遍五色测试。希望帮到你。
本文还有配套的精品资源,点击获取