简介:面向嵌入式与物联网开发者的ESP32S3图形界面驱动示例,聚焦在IDF 5.2.2框架下将LVGL图形库与ST7789显示驱动、xpt2046电阻触摸控制器打通这一常见难点,适合已具备一定ESP-IDF基础、希望快速验证屏幕显示与触摸交互的中级开发者参考。资源包共10个文件,约36KB,以C源码与头文件承载LVGL初始化及显示触摸对接逻辑,辅以CMakeLists构建脚本、sdkconfig及defaults配置文件和组件依赖锁定文件,工程结构完整,可直接作为项目模板导入。已有761人学习下载,说明该组合方案在实际开发中具有较高参考价值。通过这份Demo,读者能获得一套可运行的显示与触摸驱动对接思路,理解SPI时序配置、LVGL移植层适配及触摸坐标读取的关键环节,并据此评估ESP32S3在图形界面项目中的适用性,节省从零搭建环境的调试时间。
1. 一块 2.8 寸屏点亮之前:ESP32S3 跑 LVGL 到底卡在哪
手里有块 ST7789 驱动的 2.8 寸 SPI 屏,背面还焊着 xpt2046 触摸芯片,主控换成 ESP32S3,想直接上 LVGL 做个带触摸的界面——这套组合在创客圈和课程设计里出现频率极高,但真正从零跑通的人不多。卡点从来不是“LVGL 会不会用”,而是三件事叠在一起:ESP-IDF 5.2.2 的 SPI 主机驱动接口跟老版本有出入,ST7789 的初始化序列对时序和偏移量敏感,xpt2046 又挂在同一组 SPI 上抢总线。这份 Demo 的价值就在于把这三块拼图按 IDF 5.2.2 的写法固定下来,给出一套能编译、能显示、能触摸的最小闭环。适合已经装好 IDF 环境、想跳过底层调试直接看界面效果的人,也适合想搞清楚 LVGL 移植层到底要写哪几个函数的从业者。
2. 硬件连线与 IDF 工程骨架:先把总线分清楚
2.1 ST7789 与 xpt2046 的引脚分配逻辑
SPI 屏和 SPI 触摸共用一组总线是常见做法,但前提是两者片选(CS)独立、时钟极性一致。ST7789 通常只支持 SPI 模式 0 或 3,xpt2046 对模式不挑,所以整条总线按 ST7789 的要求配成模式 0 最稳。下面这套引脚是我在 ESP32S3 上反复验证过的分配,避开了启动时会被拉高的 strapping 引脚:
| 信号 | ESP32S3 引脚 | 说明 |
|---|---|---|
| SCLK | GPIO12 | SPI2 时钟 |
| MOSI | GPIO11 | SPI2 数据输出 |
| MISO | GPIO13 | 仅触摸读数据用 |
| LCD_CS | GPIO10 | 屏片选 |
| LCD_DC | GPIO9 | 数据/命令选择 |
| LCD_RST | GPIO14 | 硬复位 |
| LCD_BL | GPIO21 | 背光,PWM 可调 |
| TP_CS | GPIO15 | 触摸片选 |
| TP_IRQ | GPIO16 | 触摸中断 |
关键点在于 MISO 必须接——很多人做纯显示 Demo 时省掉这根线,等到加触摸才发现 xpt2046 读不到坐标。另外 LCD_DC 和 LCD_CS 不要选 GPIO0、GPIO3、GPIO45、GPIO46 这类启动敏感脚,否则上电瞬间屏幕会闪一下乱码,血泪经验。
2.2 用 idf.py 建工程并挂载 LVGL 组件
IDF 5.2.2 的组件管理器已经比较成熟,LVGL 直接通过idf_component.yml拉取,不用手动拷源码。在工程根目录建main/idf_component.yml:
dependencies: idf: ">=5.2" lvgl/lvgl: "^8.3.11"然后idf.py set-target esp32s3再idf.py build,组件管理器会自动把 LVGL 8.3.x 下载到managed_components目录。这里锁 8.3 而不是 9.x,是因为 9.x 的 API 变动较大,网上大部分 ST7789 移植代码还是按 8.x 写的,混用会编译报错。工程目录结构大致如下:
project/ ├── CMakeLists.txt ├── sdkconfig.defaults └── main/ ├── CMakeLists.txt ├── idf_component.yml ├── main.c ├── lcd_st7789.c ├── lcd_st7789.h ├── touch_xpt2046.c └── touch_xpt2046.hsdkconfig.defaults里至少设三项:CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192(LVGL 任务吃栈)、CONFIG_FREERTOS_HZ=1000(LVGL 心跳按毫秒算)、CONFIG_LV_COLOR_DEPTH_16(ST7789 是 RGB565)。这三项不改,后面大概率遇到栈溢出重启或者颜色发绿。
2.3 SPI 总线初始化:IDF 5.2.2 的写法差异
IDF 5.x 把spi_bus_config_t里的flags和intr_flags合并简化了,老教程里那些SPICOMMON_BUSFLAG_MASTER的写法还能用,但max_transfer_sz必须显式给够,否则刷整屏时 DMA 会截断。初始化代码:
#include "driver/spi_master.h" #define LCD_HOST SPI2_HOST #define PIN_SCLK 12 #define PIN_MOSI 11 #define PIN_MISO 13 void spi_bus_init(void) { spi_bus_config_t buscfg = { .sclk_io_num = PIN_SCLK, .mosi_io_num = PIN_MOSI, .miso_io_num = PIN_MISO, .quadwp_io_num = -1, .quadhd_io_num = -1, // 一屏 240x320x2 字节约 150KB,这里给 16KB 分块刷 .max_transfer_sz = 16 * 1024, }; ESP_ERROR_CHECK(spi_bus_initialize(LCD_HOST, &buscfg, SPI_DMA_CH_AUTO)); }max_transfer_sz设成 16KB 是个折中:太小则每刷一块都要重新配 DMA 描述符,帧率上不去;太大则占用内部 DMA 缓冲,和 WiFi 抢内存。实际刷屏时按 16KB 分块调用spi_device_transmit,配合 LVGL 的flush_cb就能跑出流畅的滑动效果。总线初始化只做一次,屏和触摸各自spi_bus_add_device挂上去,两个 device 的clock_speed_hz可以不同——屏给 40MHz,触摸给 2MHz,xpt2046 跑快了读出来全是 0。
3. ST7789 驱动层:初始化序列与 LVGL flush 回调
3.1 初始化序列里不能省的几条命令
ST7789 的 datasheet 初始化序列很长,但真正影响点亮的就那么几条。下面这段是 240x320 竖屏、RGB565、无偏移的配置,直接抄:
static void st7789_send_cmd(uint8_t cmd) { gpio_set_level(LCD_DC, 0); spi_transaction_t t = { .length = 8, .tx_buffer = &cmd }; spi_device_polling_transmit(lcd_spi, &t); } static void st7789_send_data(const uint8_t *data, size_t len) { if (len == 0) return; gpio_set_level(LCD_DC, 1); spi_transaction_t t = { .length = len * 8, .tx_buffer = data }; spi_device_polling_transmit(lcd_spi, &t); } void st7789_init(void) { // 硬复位 gpio_set_level(LCD_RST, 0); vTaskDelay(pdMS_TO_TICKS(20)); gpio_set_level(LCD_RST, 1); vTaskDelay(pdMS_TO_TICKS(120)); st7789_send_cmd(0x01); vTaskDelay(pdMS_TO_TICKS(120)); // SWRESET st7789_send_cmd(0x11); vTaskDelay(pdMS_TO_TICKS(120)); // SLPOUT st7789_send_cmd(0x3A); uint8_t colmod = 0x55; st7789_send_data(&colmod, 1); // RGB565 st7789_send_cmd(0x36); uint8_t madctl = 0x00; st7789_send_data(&madctl, 1); // 竖屏方向 st7789_send_cmd(0x21); // 反显,多数模块需要 st7789_send_cmd(0x13); // 普通显示模式 st7789_send_cmd(0x29); vTaskDelay(pdMS_TO_TICKS(20)); // DISPON }0x21反显命令是翻车重灾区:有的模块出厂就带反显,不加这条颜色是负片;有的模块不加反而正常。判断方法很简单——刷纯红,屏幕显示青色就说明反显状态反了,把0x21换成0x20即可。0x36的 madctl 值决定扫描方向,竖屏 0x00、横屏 0x60,改完记得同步改 LVGL 的hor_res和ver_res,否则画面会错位。
3.2 LVGL flush_cb 的实现与分块刷屏
LVGL 8.x 的显示驱动核心就一个flush_cb,它把 LVGL 渲染好的像素区推给屏。ST7789 需要先设窗口再写显存,代码:
static void lvgl_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { uint16_t x1 = area->x1, y1 = area->y1; uint16_t x2 = area->x2, y2 = area->y2; // 设置显示窗口 uint8_t caset[4] = { x1 >> 8, x1 & 0xFF, x2 >> 8, x2 & 0xFF }; uint8_t raset[4] = { y1 >> 8, y1 & 0xFF, y2 >> 8, y2 & 0xFF }; st7789_send_cmd(0x2A); st7789_send_data(caset, 4); st7789_send_cmd(0x2B); st7789_send_data(raset, 4); st7789_send_cmd(0x2C); // 分块写像素,避免单次传输超 max_transfer_sz size_t total = (x2 - x1 + 1) * (y2 - y1 + 1); uint8_t *buf = (uint8_t *)color_p; size_t chunk = 16 * 1024; while (total > 0) { size_t n = total > chunk ? chunk : total; st7789_send_data(buf, n); buf += n; total -= n; } lv_disp_flush_ready(drv); }lv_disp_flush_ready必须在数据真正发完后调用,否则 LVGL 会提前复用缓冲区导致画面撕裂。这里用的是阻塞式polling_transmit,简单可靠;如果追求帧率可以换spi_device_queue_trans加回调,但要注意 LVGL 的缓冲区在传输期间不能被改写,得配双缓冲。lv_color_t在 16 位色深下就是 2 字节,和 ST7789 的 RGB565 字节序一致,不需要额外转换——如果发现颜色红蓝颠倒,那是 madctl 的 RGB/BGR 位没设对,改 0x36 参数而不是改像素。
3.3 注册显示驱动与 LVGL 心跳
把 flush_cb 挂到lv_disp_drv_t上,再给 LVGL 一个毫秒级心跳:
void lvgl_disp_init(void) { static lv_disp_draw_buf_t draw_buf; static lv_color_t buf[240 * 40]; // 40 行缓冲,约 19KB lv_disp_draw_buf_init(&draw_buf, buf, NULL, 240 * 40); static lv_disp_drv_t disp_drv; lv_disp_drv_init(&disp_drv); disp_drv.hor_res = 240; disp_drv.ver_res = 320; disp_drv.flush_cb = lvgl_flush_cb; disp_drv.draw_buf = &draw_buf; lv_disp_drv_register(&disp_drv); } // 在 tick 任务里每 5ms 调一次 void lv_tick_task(void *arg) { while (1) { lv_tick_inc(5); vTaskDelay(pdMS_TO_TICKS(5)); } }缓冲区给 40 行而不是整屏,是为了省内部 RAM——整屏 240x320x2 要 150KB,ESP32S3 虽然有 512KB SRAM,但 WiFi 协议栈和 LVGL 自身还要占,留点余量更稳。40 行缓冲在滑动列表时会有轻微分块感,但日常界面够用。心跳任务优先级设低一点(比如 2),别和刷屏任务抢 CPU。
4. xpt2046 触摸接入:同总线共存与坐标校准
4.1 触摸读坐标的时序与滤波
xpt2046 是 12 位 SAR ADC,通过 SPI 发控制字节选通道,再读回 16 位数据(高 12 位有效)。它和 ST7789 共用 MOSI/MISO/SCLK,靠各自的 CS 区分。读 X 坐标的代码:
static uint16_t xpt2046_read(uint8_t cmd) { uint8_t tx[3] = { cmd, 0x00, 0x00 }; uint8_t rx[3] = { 0 }; gpio_set_level(TP_CS, 0); spi_transaction_t t = { .length = 24, .tx_buffer = tx, .rx_buffer = rx }; spi_device_polling_transmit(tp_spi, &t); gpio_set_level(TP_CS, 1); // 12 位结果在 rx[1] 低 4 位 + rx[2] 高 8 位 return ((rx[1] & 0x0F) << 8) | rx[2]; } // 读 X 用 0xD0,读 Y 用 0x90 uint16_t x_raw = xpt2046_read(0xD0); uint16_t y_raw = xpt2046_read(0x90);单次读噪声不小,常见做法是连读 5 次去掉最大最小再取平均,或者用中值滤波。触摸任务里加个简单的滑动平均就能明显改善抖动。另外 xpt2046 的 IRQ 脚在触摸时拉低,可以配成下降沿中断唤醒读取任务,比轮询省 CPU——但中断里别直接读 SPI,置个标志让任务去读。
4.2 原始值到屏幕坐标的映射
xpt2046 读出来是 0~4095 的原始值,和屏幕 240x320 不是线性对应,得做两点校准。最省事的办法是取屏幕四角触摸的原始值,算线性映射:
// 假设校准得到:左上(200,240) 右下(3900,3800) #define RAW_X_MIN 200 #define RAW_X_MAX 3900 #define RAW_Y_MIN 240 #define RAW_Y_MAX 3800 void touch_get_point(uint16_t *sx, uint16_t *sy) { uint16_t rx = xpt2046_read(0xD0); uint16_t ry = xpt2046_read(0x90); // 映射并限幅 int32_t x = (int32_t)(rx - RAW_X_MIN) * 240 / (RAW_X_MAX - RAW_X_MIN); int32_t y = (int32_t)(ry - RAW_Y_MIN) * 320 / (RAW_Y_MAX - RAW_Y_MIN); *sx = x < 0 ? 0 : (x > 239 ? 239 : x); *sy = y < 0 ? 0 : (y > 319 ? 319 : y); }如果 X 方向反了(触摸左边光标跑右边),把映射式改成240 - x;Y 同理。校准值因屏而异,换一块屏就得重测,别指望一套参数通吃。LVGL 侧注册lv_indev_drv_t,read_cb里调上面的函数,返回false表示无触摸。
4.3 触摸与显示共用 SPI 的互斥处理
两个 device 挂同一总线,SPI 驱动本身有总线锁,但 LVGL 刷屏是长传输,触摸读取如果插在中间会被阻塞几十毫秒,表现为触摸迟滞。解决办法是给触摸读取加个短超时重试,或者干脆把触摸任务优先级设得比刷屏任务高、读之前先spi_device_acquire_bus。我一般会在触摸 read_cb 里做一次“读不到就返回 false”,让 LVGL 下一帧再试,避免卡死整个 GUI 任务。实测这样处理,240x320 屏滑动时触摸响应延迟在 30ms 以内,肉眼基本无感。
5. 避坑排查:五个让 Demo 跑不起来的典型问题
5.1 白屏或花屏
现象:上电后背光亮的,但屏幕全白或雪花。原因多半是初始化序列没发完就刷数据,或者 SPI 时钟太高。解决:先把时钟降到 10MHz 验证,确认能显示再逐步提到 40MHz;检查0x11之后是否等了 120ms,SLPOUT 没等够就发 DISPON 会白屏。
5.2 颜色整体偏色或负片
现象:红色显示成青色,或者整体发暗。原因:0x21反显命令与模块不匹配,或0x3A的 colmod 不是 0x55。解决:刷纯色测试,红变青就去掉/加上0x21;发暗则检查背光 PWM 占空比是否给满。
5.3 触摸坐标乱跳或恒为 0
现象:读出来全是 0 或 4095。原因:MISO 没接、触摸 CS 没拉低、SPI 模式不对。解决:用逻辑分析仪看 TP_CS 拉低后 MISO 有没有数据;xpt2046 要求模式 0,和屏一致;确认spi_bus_add_device时触摸的mode字段是 0。
5.4 编译报 LVGL 符号未定义
现象:lv_disp_drv_t找不到。原因:idf_component.yml拉的 LVGL 版本和代码不匹配,或者 main 的 CMakeLists 没REQUIRES lvgl。解决:确认managed_components里是 8.3.x,main/CMakeLists.txt加idf_component_register(... REQUIRES lvgl driver esp_timer)。
5.5 运行一段时间重启
现象:刷复杂界面时看门狗复位或栈溢出。原因:LVGL 任务栈给小了,或 flush 里阻塞太久触发任务看门狗。解决:GUI 任务栈给 8KB 以上,flush 分块传输,必要时在长传输循环里喂狗。
6. 把 Demo 改成能用的界面:双缓冲与局部刷新调优
Demo 跑通只是起点,真要做产品级界面,帧率和内存得再抠一抠。我一般会先把单缓冲换成双缓冲:lv_disp_draw_buf_init(&draw_buf, buf1, buf2, size),两个缓冲区各 40 行,LVGL 在一块渲染时另一块可以 DMA 发送,滑动列表的卡顿感会明显下降。代价是多占 19KB RAM,ESP32S3 扛得住。
再进一步是开局部刷新。LVGL 默认只重绘脏区域,但前提是disp_drv.full_refresh = 0(默认就是 0)。真正影响性能的是flush_cb里每次重设窗口的开销——如果脏区域很小(比如只刷新一个按钮),设窗口的命令占比就高了。可以在 flush_cb 里判断:如果本次区域和上次连续,跳过 CASET/RASET 直接写数据。这个优化在仪表盘类界面(数字频繁局部更新)上能提 20% 左右帧率。
| 优化项 | 改动点 | 收益 | 代价 |
|---|---|---|---|
| 双缓冲 | draw_buf 传两个指针 | 滑动更顺 | +19KB RAM |
| 提高 SPI 时钟 | 40MHz→60MHz | 刷屏快 30% | 部分模块不稳 |
| 跳过重复设窗 | flush_cb 记录上次区域 | 局部刷新快 | 逻辑稍复杂 |
| 触摸中断替代轮询 | TP_IRQ 配下降沿 | 省 CPU | 需处理消抖 |
验证方法很直接:在 flush_cb 里翻转一个 GPIO,用示波器看刷屏频率;或者跑 LVGL 自带的lv_demo_benchmark,看平均帧率和最大帧耗时。我习惯每次改完驱动都跑一遍 benchmark 留个基线,后面加界面时帧率掉了能立刻定位是驱动退化了还是界面太重。从那以后我每次移植新屏,都强制先跑纯色测试、再跑 benchmark、最后接触摸,三步缺一步后面都得返工。希望这套流程帮到你,少走点白屏和坐标乱跳的弯路。
本文还有配套的精品资源,点击获取