简介ESP32S3驱动CST328与ST7789的LVGL例程基于ESP-IDF官方框架面向嵌入式开发者与物联网爱好者用于在ESP32S3上快速实现彩色TFT显示与触摸交互。例程整合LVGL开源图形库覆盖硬件SPI初始化、显示控制器配置、界面控件加载的完整流程适合需要跳过底层适配、直接上手GUI开发的初中级开发者。资源包共1342个文件主要包含C语言源码与头文件辅以Python脚本、Markdown文档、演示图片及字体文件整体约26.45MB目录结构清晰便于按模块检索。已有1465人学习下载工程中的代码组织方式与实际运行效果对同类项目具有直接参考价值。通过该例程可掌握在ESP32S3上配置CST328触摸与ST7789显示的方法利用LVGL绘制按钮、滑块、图表等控件为智能仪表、交互终端等产品界面开发提供一套可复用的完整工程模板。1. ESP32-S3 驱动 CST328 和 ST7789例程是一张现成的 LVGL 接线图如果你在 ESP-IDF 环境下点亮过 ST7789 屏幕大概率遇到过这种情况屏亮了但触摸毫无反应或者触摸能读数坐标却像喝醉了一样乱跳。真正卡人的不是 LVGL 本身而是显示和触摸这两条链路怎么在工程里合流。这份例程的价值在于它把 CST328 电容触摸、ST7789 SPI 屏和 LVGL 图形栈完整接好烧进去就能看到 UI 在 240×320 的屏上跑起来。对新手来说它是一份可以直接抄的作业对熟手来说它省掉了翻芯片手册对时序的功夫拿来改引脚就能复用到自己的板子上。2. 例程的工程骨架与硬件接线先对照原理图再谈图形栈拿到这份例程第一件事不是急着编译而是先把两个东西看清楚目录结构、引脚定义。ESP-IDF 工程不像 Arduino 那样一个 ino 文件包打天下头文件、源文件、配置项散落在多个目录里找不到入口是很正常的。这份例程好在主逻辑集中在 main 目录下组件相关的代码压在 components 里属于一眼能看明白的布局。2.1 目录结构与各文件职责我拆这类例程的习惯是先从 main/CMakeLists.txt 入手看编译了哪些源文件再顺着文件名猜职责。这份例程的顶层结构大致是├── main/ │ ├── CMakeLists.txt │ ├── main.c │ ├── lvgl_demo.c │ └── lvgl_demo.h ├── components/ │ ├── cst328/ │ │ ├── cst328.c │ │ └── cst328.h │ ├── st7789/ │ │ ├── st7789.c │ │ └── st7789.h │ └── lvgl/ ├── sdkconfig.defaults └── partitions.csv// main/CMakeLists.txt 的关键片段 idf_component_register( SRCS main.c lvgl_demo.c INCLUDE_DIRS . )逻辑说明SRCS 列表里的 main.c 是程序入口lvgl_demo.c 负责 UI 初始化和页面搭建INCLUDE_DIRS 设置为当前目录保证头文件互相可见。参数说明components 下的 cst328 和 st7789 是独立的组件各自有源文件和头文件改动只影响对应屏幕或触摸不影响 UI 层。LVGL 组件通常是靠 IDF 的组件管理器拉取的本质是一份版本固定的源码副本。SDK 构建时会把 components 里的源文件一并编译进去。这个结构最实在的一点是如果你想换一块 1.8 寸的 ST7735 屏只需要改 st7789 组件内部的初始化序列和窗口设置函数main.c 和 lvgl_demo.c 可以原封不动。屏幕驱动和 UI 逻辑分得开是这类例程省时间的关键。2.2 引脚配置与初始化流程接下来是硬件映射。例程的引脚定义一般在 main.c 或头文件里像这样// 引脚映射ST7789 走 SPI2CST328 走 I2C0 #define PIN_LCD_SCLK 12 #define PIN_LCD_MOSI 11 #define PIN_LCD_CS 10 #define PIN_LCD_DC 9 #define PIN_LCD_RST 8 #define PIN_LCD_BL 7 #define PIN_TP_SDA 4 #define PIN_TP_SCL 5 #define PIN_TP_RST 3 #define PIN_TP_INT 2逻辑说明屏幕用了 SPI 的四线制——SCLK、MOSI、CS、DC外加 RST 和背光控制触摸用的是 I2CSDA 和 SCL 各一根线RST 是触摸芯片的复位脚INT 是触摸中断脚。参数说明SPI 时钟引脚选 12MOSI 选 11这是 ESP32-S3 上 SPI2 外设的常见映射。触摸的 SDA 和 SCL 挂在 I2C0 上地址取决于 CST328 的 ADDR 引脚电平常见默认地址是 0x5A。背光引脚 7 在例程里是普通的 GPIO 输出拉高点亮你也可以后续改成 LEDC 的 PWM 通道做亮度调节。初始化流程的顺序有讲究。例程里先初始化 SPI 总线、再挂载 ST7789 驱动、然后初始化 I2C 和 CST328最后才调 LVGL 的初始化函数。实际代码大概是spi_bus_config_t bus_cfg { .sclk_io_num PIN_LCD_SCLK, .mosi_io_num PIN_LCD_MOSI, .miso_io_num -1, // ST7789 是只写屏不接 MISO .quadwp_io_num -1, .quadhd_io_num -1, .max_transfer_sz 240 * 320 * 2 8, }; spi_bus_initialize(SPI2_HOST, bus_cfg, SPI_DMA_CH_AUTO); i2c_config_t i2c_cfg { .mode I2C_MODE_MASTER, .sda_io_num PIN_TP_SDA, .scl_io_num PIN_TP_SCL, .master.clk_speed 100000, // 触摸芯片时钟频率100kHz 起步 }; i2c_param_config(I2C_NUM_0, i2c_cfg); i2c_driver_install(I2C_NUM_0, I2C_MODE_MASTER, 0, 0, 0);逻辑说明SPI 的 max_transfer_sz 直接给到一整帧的字节数240×320 像素、RGB565 每像素 2 字节正好是一帧的容量这样用 DMA 刷全屏时不需要分成小块。触摸的 I2C 先按 100kHz 初始化跑通之后再考虑提速。参数说明SPI_DMA_CH_AUTO 是 ESP-IDF 5.x 推荐的写法让驱动自动分配 DMA 通道不用自己指定编号。I2C 的时钟频率参数 master.clk_speed 设成 100000 是为了稳定有些板子走线长、上拉电阻不合适400kHz 直接读不到寄存器降到 100kHz 就能正常工作。这个顺序如果反了后面的初始化会拿到无效的句柄。某些例程把 LVGL 的 lv_init 放在最前面其实问题不大但 ESP-IDF 场景下我习惯先让硬件就绪再初始化 LVGL这样如果 LVGL 配置里需要读屏幕分辨率可以直接取驱动结构体的值。3. 把触摸和显示接到 LVGL显示刷新回调与输入设备驱动硬件初始化完成接下来是核心问题LVGL 怎么知道屏幕长什么样、触摸点在哪儿。LVGL 本身不直接操作芯片它靠两套回调一套负责把显存里的像素刷到屏上另一套负责把触摸的坐标转成 LVGL 能理解的事件。例程里这两部分分别写在 st7789 组件和 cst328 组件里但注册动作发生在 main.c。3.1 显示驱动DMA 翻转全屏与 LVGL 刷新的关系LVGL 的显示驱动结构体核心是 flush_cb 回调。LVGL 每次渲染完一个区域就调用这个回调把数据交给屏幕驱动static void lcd_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { uint16_t x1 area-x1; uint16_t y1 area-y1; uint16_t x2 area-x2; uint16_t y2 area-y2; st7789_set_window(x1, y1, x2, y2); st7789_write_ram(color_p, (x2 - x1 1) * (y2 - y1 1) * 2); lv_disp_flush_ready(drv); }逻辑说明LVGL 会把需要重绘的区域通过 area 参数传进来坐标是绝对坐标。屏幕驱动要做两件事用 set_window 告诉屏幕从哪个矩形开始写然后用 write_ram 把像素数据整个发过去。最后调用 lv_disp_flush_ready 通知 LVGL 这一块刷完了可以继续下一块。参数说明最后一个参数 (x2 - x1 1) * (y2 - y1 1) * 2 是区域内的像素数乘以 RGB565 的 2 字节也就是这次 SIP 传输的总字节数。例程里 st7789_write_ram 内部用的是 esp_spi_device_polling_transmit 或者带 DMA 的传输接口区别在于大块数据是否经过 CPU 搬运。这里有一个很容易看漏的点LVGL 刷新缓冲区默认不是一整个屏幕而是只分配了若干行。LVGL 会把屏幕切分成多个横向条带逐条调用 flush_cb。所以 flush_cb 里设置窗口时不能写死坐标必须每次从 area 参数读取。如果图省事直接全屏刷性能反而会垮掉。3.2 触摸驱动CST328 的坐标上报与 LVGL 输入注册触摸部分看起来比显示简单但坑一点不少。CST328 的数据上报方式有几种轮询读取寄存器、依赖 INT 引脚中断提醒、或者两者结合。例程用的是轮询加中断标志读到的坐标结构大致是这样typedef struct { uint16_t x; uint16_t y; } tp_point_t; static void tp_read_cb(lv_indev_drv_t *drv, lv_indev_data_t *data) { tp_point_t point; bool touched cst328_read_point(point); if (touched) { >static lv_indev_drv_t indev_drv; lv_indev_drv_init(indev_drv); indev_drv.type LV_INDEV_TYPE_POINTER; indev_drv.read_cb tp_read_cb; lv_indev_drv_register(indev_drv);逻辑说明这一段是 LVGL 的标准输入设备注册流程。init 把结构体清零并填默认值type 指定为指针设备read_cb 指向上面实现的回调register 把设备挂到 LVGL 内部的事件循环里。顺带一提CST328 的 INT 引脚在例程里没有作为中断源使用而是作为状态检查的辅助信号。如果你的主板把 INT 接到了一个不支持的 GPIO可以直接把 INT 初始化代码注释掉改成纯轮询。代价是每次回调都要走一遍 I2C 读操作触摸的响应延迟会稍微高一点但对多数 UI 交互来说感受不到。4. 常见问题与避坑颜色格式、时序和坐标玄学这个例程虽然能直接跑起来但换成自己的板子时翻车点几乎都集中在颜色、时序、坐标这三块。以下是我在实际复用过程中踩过的坑按“现象 → 原因 → 解决”列出来。4.1 颜色整体错乱红色和蓝色互换现象UI 上的红色按钮变成蓝色蓝色变成红色绿色正常整体像调换了通道。原因ST7789 屏幕支持的 RGB 顺序有两种——RGB 和 BGR出厂配置可能不同。LVGL 内部默认按 RGB565 处理像素字节序是低字节在前如果屏幕固件期望的颜色通道顺序相反R 和 B 就会交换位置。解决优先在 ST7789 驱动初始化序列里改。例程的初始化命令表里有一个 36h 的寄存器用来设 RGB 顺序把参数从 0x00 改成 0x60 或者反之就能交换 R 和 B 通道。改完重新编译烧录颜色基本恢复。如果初始化命令里找不到这个控制项可以在 LVGL 的 lv_conf.h 里把 LV_COLOR_16_SWAP 打开同样能达到交换字节序的效果。注意两个方案只用一个别同时开否则颜色会再次错乱。4.2 触摸完全无响应但屏幕显示正常现象屏幕 UI 正常刷新LVGL 日志也没有报错但点任何控件都没反应。原因CST328 的复位时序没做对。触摸芯片在初始化时需要一个完整的复位脉冲——拉低、延时、拉高、再延时等内部固件启动完成后才能响应 I2C 命令。很多例程只把复位引脚设为输出高电平省略了脉冲过程芯片一直处在未就绪状态。解决在 cst328 初始化函数的最前面手动加复位时序标准做法是这样gpio_set_direction(PIN_TP_RST, GPIO_MODE_OUTPUT); gpio_set_level(PIN_TP_RST, 0); vTaskDelay(pdMS_TO_TICKS(100)); gpio_set_level(PIN_TP_RST, 1); vTaskDelay(pdMS_TO_TICKS(100));逻辑说明先把复位脚拉低 100ms让芯片进入复位状态再拉高并等待同样时长留足内部上电启动的时间。之后再做 I2C 探测一般是读芯片的 ID 寄存器能读出正确值才说明握手成功。参数说明延时长度 100ms 是保守值数据手册上要求的最小复位时间通常是 10ms 左右但 100ms 不会影响上电速度却能避开某些批次芯片上电慢的问题。如果改了还是没反应检查一下 SDA 和 SCL 上有没有接上拉电阻CST328 要求外部上拉悬空时 I2C 设备地址都探测不到。4.3 刷新画面闪烁或者撕裂现象拖动滑块或者刷新列表时屏幕能看到明显的水平条纹像是像素一行一行地被填充。原因LVGL 的缓冲区太小或者 DMA 没有真正启用。LVGL 默认可能只分配了两行像素的缓冲区刷新时每画两行就要等屏幕驱动把数据发完速度跟不上就会闪烁。另外如果 SPI 传输走了 CPU 轮询而不是 DMA大块像素数据会阻塞任务闪烁更明显。解决在 sdkconfig.defaults 里把 LVGL 缓冲大小加大同时确认 SPI 总线启用了 DMA。常用配置是给足一整个屏幕的 1/10 以上缓冲区比如 240×320 的屏幕分 10 行缓冲大约 240×10×2 4800 字节。如果内存允许直接分 40 行甚至 80 行刷屏体验会明显提升。确认 SPI 的 DMA 是否启用可以看 spi_bus_initialize 调用时有没有传入 DMA 参数传 auto 就是启用了。需要区分一个概念LVGL 的显示缓冲区不等于 SPU 的 DMA 缓冲区。LVGL 画完一块区域调用 flush_cb 把数据交出去如果 st7789_write_ram 内部用的是轮询 transmit那么 LVGL 要等整块数据发完才会继续画下一块。换成 DMA 传输的话flush_cb 可以立即返回LVGL 直接画下一块传输和渲染并行帧率差一个量级。4.4 触摸坐标方向不对上下或者左右镜像现象屏幕显示方向正确但手指往上滑UI 里的滑块却往下走或者点击左上角响应位置在右下角。原因触摸传感器的物理方向和屏幕的物理方向不一致。常见于屏和触摸贴合的模组触摸传感器的 X/Y 轴和屏幕的扫描方向可能相差 180 度。LVGL 的输入设备不会帮你做方向变换它拿到坐标直接用.解决在 tp_read_cb 里手动做坐标映射。假设屏幕是 240 宽 320 高传感器上报的原始坐标范围也是 240×320那么上下镜像的换算就是point.y 319 - point.y;左右镜像就是point.x 239 - point.x;如果既上下又左右两个都做。有时候坐标范围不是 240×320而是 10 位精度的 0 到 1023那就需要先按比例缩放到屏幕分辨率再做镜像变换。例程里没有做变换的原因是它的模组出厂方向一致你换屏之后必须检查这一项。4.5 编译报错指向 LV_TICK_CUSTOM 未定义现象idf.py build 阶段报错错误信息提到 LV_TICK_CUSTOM 或者 lv_tick_inc 相关函数未定义。原因LVGL 需要一个时基来驱动动画和长按检测。例程默认开启了 LV_TICK_CUSTOM用系统时钟源作为时基。但如果你在 lv_conf.h 里改动了配置或者把例程移植到另一个工程时覆盖了 lv_conf.h这个宏可能被关掉时基函数就没有被实现。解决检查 lv_conf.h 里的这几个宏#define LV_TICK_CUSTOM 1 #if LV_TICK_CUSTOM #define LV_TICK_CUSTOM_INCLUDE esp_timer.h #define LV_TICK_CUSTOM_SYS_TIME_EXPR (esp_timer_get_time() / 1000) #endif逻辑说明LV_TICK_CUSTOM 置 1 后LVGL 会用宏 LV_TICK_CUSTOM_SYS_TIME_EXPR 返回毫秒级时间戳。这里用 esp_timer_get_time() 获取微秒时间再除以 1000转成毫秒单位ESP32-S3 的 esp_timer 精度足够不需要额外开一个定时器任务。参数说明如果你不想用自定义时基在 main 函数里单独创建一个定时器调用 lv_tick_inc(1) 也能跑起来但要注意定时器优先级和周期抖动。esp_timer 方案的好处是零额外开销适合对功耗敏感的场景。5. LVGL 8.3 与 ESP-IDF 5.x 的匹配版本选型与避坑先交代一下版本。这份例程基于国内开发者常用的组合LVGL 8.3.x 搭配 ESP-IDF 5.1 或 5.2。这个组合的覆盖面广网上资料多出错时搜得到解决方案。LVGL 9.x 虽然已经发布但接口变化不小尤其是显示驱动结构体从 lv_disp_drv_t 改成了 lv_display_t输入设备驱动从 lv_indev_drv_t 改成了 lv_indev_t旧例程的注册代码几乎要重写。因此我这里围绕 LVGL 8.3 展开。LVGL 8.3 的显示驱动注册接口和 9.x 最大的区别在于缓冲区的组织方式。8.3 用 lv_disp_draw_buf_t 声明缓冲支持双缓冲模式9.x 把绘缓冲和显示驱动合并到 lv_display_t 里。例程里用的是 8.3 的标准写法static lv_disp_draw_buf_t draw_buf; static lv_color_t buf1[240 * 40]; // 40 行缓冲区 static lv_color_t buf2[240 * 40]; // 第二个缓冲区双缓冲 lv_disp_draw_buf_init(draw_buf, buf1, buf2, 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 lcd_flush_cb; disp_drv.draw_buf draw_buf; lv_disp_drv_register(disp_drv);逻辑说明buf1 和 buf2 是两个独立缓冲区lv_disp_draw_buf_init 把两个都交给 LVGL。LVGL 在画一帧时先画 buf1flus 回调把 buf1 数据送出去同时用 buf2 画下一块两块交替使用。这样 SPI 的 DMA 传输和 LVGL 的绘制并行缓冲区切换不阻塞渲染管线。缓冲区行数可以自己调240 * 40就是 40 行缓冲总共 240402 19200 字节两块合计 38400 字节。ESP32-S3 的 PSRAM 如果开启这个体量完全无压力。如果你需要释放内存可以只保留一个缓冲区占用减半但帧率会明显下降。关于刷新率例程里 SPI 时钟设置通常为 40MHz 到 80MHz。SPI 时钟 40MHz 时一帧 240×320×2 字节的数据量是 153600 字节理论传完要 30.7ms撑死了 30 帧左右。实际上 DMA 和数据线延迟会拉到 50ms 左右也就是 20 帧不到。想提帧率把 SPI 时钟提到 80MHz 立竿见影前提是排线质量能承受跳线太长会直接花屏。我一般会在新屏上先跑 LVGL 自带的 demo不急着上自己的 UI。这么做的好处是能快速判断屏和触摸是不是真的通了demo 页面里有滑块、动画、滚轮列表一滑就能看出坐标方向对不对。如果 demo 画面正常但自己的 UI 卡顿问题多半出在页面创建时机或回调函数里而不是底层驱动。最后留一个我自己的习惯任何基于这份例程的二次开发我都会在改完引脚后强制走一遍三件套——确认 SPI 时钟极性、确认 CST328 的 I2C 地址、确认颜色通道顺序再点编译。小屏彩屏的项目大差不差这三个点能挡掉九成问题。希望这篇拆解能帮你在 ESP32-S3 上更快地跑通 LVGL少走几趟弯路。本文还有配套的精品资源点击获取