ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

ESP32-S3驱动ST7789与XPT2046:LVGL移植实战与避坑指南

ESP32-S3驱动ST7789与XPT2046:LVGL移植实战与避坑指南

简介:面向嵌入式与物联网开发者的ESP32S3图形界面驱动示例,聚焦在IDF 5.2.2环境下用LVGL搭配ST7789显示驱动与xpt2046电阻触摸控制器,解决小尺寸LCD屏点亮、界面渲染与触摸坐标采集的入门与联调问题,适合已具备一定ESP-IDF基础、希望快速搭建带触摸GUI原型的工程师参考。资源包共10个文件,约36KB,以C源码与头文件承载显示与触摸初始化逻辑,辅以CMakeLists、sdkconfig及defaults等工程配置文件和组件依赖锁定文件,另有说明文本,目录结构贴近真实IDF工程,便于直接导入编译。已有761人学习下载。示例完整呈现LVGL移植、ST7789刷屏与xpt2046触摸读取的衔接方式,读者可据此验证显示效果与触摸响应,并作为后续物联网带屏产品的原型起点,节省环境搭建与驱动适配时间。

1. 一块 2.8 寸屏点亮之前,我踩过的那些坑

手里有块 ESP32-S3 开发板,配一块 2.8 寸 240×320 的 TFT 屏,屏上印着 ST7789 驱动芯片,背面还贴了片 XPT2046 触摸控制器。目标很朴素:让屏幕亮起来,刷个 LVGL 界面,手指点上去有反应。听起来是嵌入式入门标配,真动手才发现从引脚定义到 SPI 时序、从 LVGL 缓冲配置到触摸校准,每一步都能卡住半天。这个 Demo 就是把这套组合跑通的完整工程:ESP32-S3 做主控,ESP-IDF 5.2.2 做框架,LVGL 做 UI,ST7789 走 SPI 刷屏,XPT2046 走另一路 SPI 读触摸。适合刚拿到这套硬件、想少走弯路的开发者,也适合想把 LVGL 移植到自家板子上的熟手参考。

2. 技术栈与工程骨架:这套组合为什么这么搭

2.1 四层结构:硬件、驱动、GUI、应用各管一摊

这个 Demo 的代码组织不是把所有东西塞进一个 main.c,而是按职责分了四层。最底下是 ESP32-S3 的硬件抽象层,负责 SPI 总线、GPIO、定时器这些外设的初始化;往上是显示驱动层,ST7789 和 XPT2046 各自一个驱动文件,只关心怎么把像素写进显存、怎么把触摸坐标读出来;再往上是 LVGL 的移植层,把驱动层的刷屏函数和读触摸函数注册给 LVGL 的 display driver 和 indev driver;最顶上才是应用层,建几个控件、挂个事件回调。

这么分层的好处是换屏不用动 UI 代码。比如把 ST7789 换成 ILI9341,只要改驱动层和 LVGL 移植层的 flush_cb,应用层一行不用碰。我见过不少新手把 SPI 写屏的循环直接写在 LVGL 的 flush 回调里,结果想换分辨率时满工程找魔数,血泪经验就是一开始就把边界划清楚。

工程目录大致长这样:

esp32s3_lvgl_demo/ ├── CMakeLists.txt ├── sdkconfig.defaults ├── main/ │ ├── CMakeLists.txt │ ├── main.c # 应用入口,建 UI、跑 lv_timer_handler │ ├── lv_conf.h # LVGL 配置,颜色深度、缓冲、字体裁剪 │ ├── display/ │ │ ├── st7789.c # 屏驱动:初始化序列、刷屏、背光 │ │ └── st7789.h │ ├── touch/ │ │ ├── xpt2046.c # 触摸驱动:SPI 读 ADC、坐标换算 │ │ └── xpt2046.h │ └── port/ │ ├── lv_port_disp.c # LVGL 显示移植:flush_cb 注册 │ └── lv_port_indev.c # LVGL 输入移植:read_cb 注册 └── components/ └── lvgl/ # LVGL 源码作为组件放入

把 LVGL 放进 components 目录是 IDF 的常规做法,CMake 会自动把它当组件编译,不用手动改 include 路径。lv_conf.h 放在 main 下,靠LV_CONF_INCLUDE_SIMPLE宏让 LVGL 找到它。

2.2 为什么是 IDF 5.2.2 而不是 Arduino

选 ESP-IDF 而不是 Arduino 框架,核心原因是 LVGL 在 IDF 下的移植更干净。Arduino 那套TFT_eSPI虽然开箱即用,但它的配置全压在User_Setup.h里,改个引脚要翻一堆宏,而且和 LVGL 的缓冲对接是别人封装好的,出了问题不好查。IDF 下自己写 flush_cb,虽然多写几十行,但 SPI 事务怎么发、DMA 怎么用、双缓冲怎么切,全在掌控里。

IDF 5.2.2 这个版本对 ESP32-S3 的 SPI 驱动做了不少整理,spi_master的老接口还在但推荐用spi_bus加 device 的新写法。Demo 里用的是新接口,初始化时先spi_bus_initialize建总线,再spi_bus_add_device挂设备,每个设备有自己的时钟频率和 CS 引脚。屏和触摸挂同一条 SPI 总线但用不同 CS,这样省引脚,代价是刷屏和读触摸不能真正并行,得靠 LVGL 的任务调度错开。

2.3 引脚分配与总线规划

ESP32-S3 的 GPIO 矩阵很灵活,但 SPI 的默认 IO_MUX 引脚速度更快,能上到 80MHz。Demo 里屏的 SPI 时钟配到 40MHz,触摸配到 2MHz——XPT2046 的 SPI 时序比较慢,时钟拉高容易读出错数据,这是踩过的坑。引脚分配大致是:

信号屏 ST7789触摸 XPT2046
SCLKGPIO12GPIO12(共用)
MOSIGPIO11GPIO11(共用)
MISO不接GPIO13
CSGPIO10GPIO9
DCGPIO8无
RSTGPIO7无
中断无GPIO14

屏只写不读,所以 MISO 不接;触摸要读 ADC 值,必须接 MISO。两者共用 SCLK 和 MOSI,靠各自的 CS 区分。注意 XPT2046 的 CS 在空闲时要拉高,否则它会一直监听总线,干扰屏的刷屏数据。

3. 显示驱动拆解:ST7789 从初始化到刷屏

3.1 初始化序列:那些不能省的延时和命令

ST7789 上电后不会自己工作,得按数据手册发一串命令:软复位、退出睡眠、设颜色格式、设显示窗口、开显示。顺序错了屏就是白屏或花屏。Demo 里的初始化函数大致这样:

// st7789.c static void st7789_write_cmd(uint8_t cmd) { gpio_set_level(PIN_DC, 0); // DC 低表示命令 spi_transaction_t t = { .length = 8, .tx_buffer = &cmd, }; spi_device_polling_transmit(spi_dev, &t); } static void st7789_write_data(const uint8_t *data, size_t len) { gpio_set_level(PIN_DC, 1); // DC 高表示数据 spi_transaction_t t = { .length = len * 8, .tx_buffer = data, }; spi_device_polling_transmit(spi_dev, &t); } void st7789_init(void) { gpio_set_level(PIN_RST, 0); vTaskDelay(pdMS_TO_TICKS(20)); // 复位低电平至少 10ms gpio_set_level(PIN_RST, 1); vTaskDelay(pdMS_TO_TICKS(120)); // 复位后等 120ms 再发命令 st7789_write_cmd(0x01); // 软复位 vTaskDelay(pdMS_TO_TICKS(150)); st7789_write_cmd(0x11); // 退出睡眠 vTaskDelay(pdMS_TO_TICKS(120)); // 这个延时不能省 uint8_t colmod = 0x55; // 16bit/pixel st7789_write_cmd(0x3A); st7789_write_data(&colmod, 1); st7789_write_cmd(0x21); // 开反显,多数模块需要 st7789_write_cmd(0x29); // 开显示 }

0x11退出睡眠后的 120ms 延时是最容易翻车的地方。手册写的是 5ms,但实际模块的电源电路稳下来要更久,延时不够会出现上半屏正常、下半屏花的情况。我一般直接给 120ms,不差这点启动时间。0x21开反显也是看模块,有的屏不加这条颜色是反的,调试时先确认这条。

3.2 刷屏函数与 LVGL 的 flush_cb 对接

LVGL 不直接操作硬件,它把要刷新的区域画进缓冲区,然后调你注册的 flush_cb,由你把缓冲区数据推给屏。Demo 里 flush_cb 的核心是设窗口再写像素:

// lv_port_disp.c static void disp_flush(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 buf[4] = { x1 >> 8, x1 & 0xFF, x2 >> 8, x2 & 0xFF }; st7789_write_cmd(0x2A); // 列地址 st7789_write_data(buf, 4); buf[0] = y1 >> 8; buf[1] = y1 & 0xFF; buf[2] = y2 >> 8; buf[3] = y2 & 0xFF; st7789_write_cmd(0x2B); // 行地址 st7789_write_data(buf, 4); st7789_write_cmd(0x2C); // 写显存 size_t len = (x2 - x1 + 1) * (y2 - y1 + 1); st7789_write_data((uint8_t *)color_p, len * 2); lv_disp_flush_ready(drv); // 必须调,否则 LVGL 不再发下一帧 }

0x2A和0x2B设的是矩形窗口,0x2C之后连续写入的像素会按窗口自动换行。lv_disp_flush_ready这行是新手最容易漏的,漏了之后界面只刷第一帧就卡住,因为 LVGL 以为上一帧还没刷完。如果后面要上 DMA,得在 DMA 传输完成回调里再调这个函数,不能在发起传输后立刻调。

3.3 缓冲区大小怎么定:一行、半屏还是整屏

LVGL 的绘制缓冲区大小直接影响内存占用和刷新流畅度。Demo 里给了两种配置:小内存用一行缓冲(240 像素),流畅优先用半屏缓冲(240×160)。一行缓冲省 RAM,但每次 flush 只刷一行,SPI 事务次数多,界面滑动时能看出撕裂;半屏缓冲一次刷一半,配合 40MHz SPI,240×320 全屏刷新大概 30ms,滑动基本跟手。

// lv_port_disp.c 里的缓冲初始化 #define BUF_LINES 160 static lv_color_t buf1[240 * BUF_LINES]; static lv_disp_draw_buf_t draw_buf; lv_disp_draw_buf_init(&draw_buf, buf1, NULL, 240 * BUF_LINES);

第二个参数传 NULL 表示单缓冲。ESP32-S3 有 512KB SRAM,半屏缓冲占 240×160×2 = 76.8KB,放得下。如果开双缓冲,两个 76.8KB 就 150KB 了,再算上 LVGL 自身开销和 WiFi 栈(如果要用),就紧张了。我一般先单缓冲跑通,确认流畅度不够再考虑双缓冲加 DMA。

4. 触摸驱动与 LVGL 输入移植:XPT2046 怎么读才准

4.1 XPT2046 的 SPI 时序:慢一点反而快

XPT2046 是电阻屏控制器,内部一个 12 位 ADC,通过 SPI 读 X/Y 坐标和压力值。它的 SPI 模式是 CPOL=0、CPHA=0,时钟最高 2MHz 左右,拉高了读出来的值会跳。Demo 里单独给它挂了个低速 device:

// xpt2046.c static spi_device_handle_t touch_spi; void xpt2046_init(void) { spi_device_interface_config_t devcfg = { .clock_speed_hz = 2 * 1000 * 1000, // 2MHz,别贪快 .mode = 0, .spics_io_num = PIN_TOUCH_CS, .queue_size = 1, }; spi_bus_add_device(SPI2_HOST, &devcfg, &touch_spi); } uint16_t xpt2046_read_adc(uint8_t cmd) { uint8_t tx[3] = { cmd, 0, 0 }; uint8_t rx[3] = { 0 }; spi_transaction_t t = { .length = 24, .tx_buffer = tx, .rx_buffer = rx, }; spi_device_polling_transmit(touch_spi, &t); // 12 位数据在 rx[1] 的低 4 位和 rx[2] 的 8 位里 return ((rx[1] & 0x0F) << 8) | rx[2]; }

命令字节0xD0读 X、0x90读 Y,具体值看手册。返回的 12 位数据跨了两个字节,拼的时候注意移位。这里用 polling 传输而不是中断,因为触摸读取频率不高(LVGL 默认 30ms 读一次),polling 足够且代码简单。

4.2 坐标换算与校准:原始值到屏幕像素

XPT2046 读出来的是 0~4095 的 ADC 值,要映射到 240×320 的屏幕坐标,中间得做线性变换。电阻屏个体差异大,每块屏的映射系数都不一样,所以校准这步省不了。Demo 里用四个角点做校准,把原始值存进 NVS,下次启动直接读:

typedef struct { int16_t x_min, x_max; int16_t y_min, y_max; } touch_cal_t; static touch_cal_t cal = { 300, 3800, 300, 3800 }; // 默认值,校准后覆盖 void touch_map(int16_t raw_x, int16_t raw_y, int16_t *sx, int16_t *sy) { *sx = (raw_x - cal.x_min) * 240 / (cal.x_max - cal.x_min); *sy = (raw_y - cal.y_min) * 320 / (cal.y_max - cal.y_min); // 边界钳位,防止越界 if (*sx < 0) *sx = 0; if (*sx > 239) *sx = 239; if (*sy < 0) *sy = 0; if (*sy > 319) *sy = 319; }

默认值只是让屏先能用,实际校准要在屏幕上依次点四个角,记录原始 ADC 值,算出 min/max 存起来。注意 X 和 Y 的方向可能和屏幕相反,校准时如果发现左右颠倒,把映射公式里的减数对调就行。

4.3 注册给 LVGL:read_cb 的返回值语义

LVGL 的输入设备靠 read_cb 轮询,回调里要填坐标和按下状态,返回值表示有没有读到有效数据:

// lv_port_indev.c static void touch_read(lv_indev_drv_t *drv, lv_indev_data_t *data) { int16_t raw_x, raw_y; bool pressed = xpt2046_read(&raw_x, &raw_y); // 内部判断压力阈值 if (pressed) { int16_t sx, sy; touch_map(raw_x, raw_y, &sx, &sy); style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />

返回列表