简介面向嵌入式与物联网开发者的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 做 UIST7789 走 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触摸 XPT2046SCLKGPIO12GPIO12共用MOSIGPIO11GPIO11共用MISO不接GPIO13CSGPIO10GPIO9DCGPIO8无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 SPI240×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 模式是 CPOL0、CPHA0时钟最高 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 注册给 LVGLread_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;" />
阅读完成 · 觉得有帮助?