简介:这是一份基于ESP-IDF框架、面向ESP32S3微控制器的LVGL图形界面例程,用于驱动CST328触摸芯片与ST7789 TFT显示屏,适合希望快速构建彩色显示屏交互界面的嵌入式开发者与物联网应用工程师。资源包内共1342个文件,包括497个C源文件、256个头文件、150个Python脚本、139个Markdown文档,以及字体、图片、构建配置等辅助文件,压缩后整体为26.45MB,目录结构清晰。已有1448人学习/浏览,例程完整覆盖了从SPI接口配置、显示屏初始化到加载按钮、滑块、图表等LVGL控件的全过程。资源中附带的音乐播放器、控件演示等UI示例及配套图片、字体素材,可帮助开发者理解LVGL的移植和界面设计思路。开发者可直接参考或移植到实际项目,大幅降低彩色触摸界面开发门槛,缩短产品原型的验证周期。 最近在折腾一个基于ESP32-S3的物联网项目,功能比较复杂,需要一块带触摸的显示屏来做人机交互。屏幕选了ST7789驱动的TFT液晶,触摸部分用的是CST328电容触摸芯片,整体图形界面用LVGL来做。这里把从硬件接线、ESP-IDF环境搭建,到ST7789和CST328底层驱动,再到LVGL最终跑起来的完整过程分享出来。这不是一个简单的“抄代码”教程,而是把我实际遇到的那些坑和排查思路都写出来,希望对正在这个组合上花时间的开发者有点帮助。
1. 为什么选这个硬件组合:屏幕、触摸、芯片的定位
1.1 几个关键部件的分工
ESP32-S3是一颗带Wi-Fi和BLE的MCU,主频最高能跑到240MHz,内存和Flash配置也够用,最关键的是它内置了硬件SPI和I2C外设,驱动LCD和触摸屏不需要软件模拟时序,这对LVGL这类需要高帧率渲染的场景很重要。ST7789是一个很常见的TFT显示屏驱动IC,最大分辨率通常是240x320像素,支持SPI接口,初始化寄存器表完整,兼容性很好,市面上能找到大量现成的屏模组。CST328则是电容式触摸控制芯片,通讯方式是I2C,可以上报最多两点触摸坐标,用在3.5寸左右的屏幕上刚刚好。LVGL则是一个开源嵌入式图形库,自带控件、动画和对象管理,像是做界面用的“积木盒”。
这套组合的核心优势在于:ESP32-S3的性能足够带动LVGL的渲染,ST7789的SPI接口速度不低,触摸部分用I2C读取也不占用太多资源,三者配合可以做到页面切换流畅、触控跟手。
1.2 适合用来做什么场景
如果你需要在设备上展示数据、设置参数,或者做成一个小型人机交互面板,这套组合是非常合适的。比如我之前在做的智能家居中控,需要显示传感器数值、开关状态,还要能触摸切换页面。用LVGL做界面可比自己在屏幕上画裸像素省太多事了。另一个典型场景就是开发板的学习套件,因为整个链路从显示到触摸到UI都很完整,能学到不少东西。
2. 动手前的硬件接线和引脚分配
2.1 ST7789屏幕的SPI接线
ST7789屏幕模组通常引出了SCLK(时钟)、MOSI(主出从入)、CS(片选)、DC(数据/命令选择)、RST(复位)、BLK(背光)这几个引脚。有的模组把MISO也引出来了,但在只写不读的纯显示场景下可以不用接。
我实际用的接线方式如下(ESP32-S3引脚基于开发板丝印,你完全可以按需调整):
- SCLK -> GPIO12
- MOSI -> GPIO11
- CS -> GPIO10
- DC -> GPIO9
- RST -> GPIO8
- BLK -> GPIO7
这里有个细节要注意:ST7789的SPI最高时钟频率可以到几十MHz,但刚开始调试时建议先降到10~20MHz,等初始化正常后再把频率提上去,不然很容易出现“花屏但主控没报错”的情况。
2.2 CST328触摸的I2C接线
CST328是通过I2C通信的,通常引出SDA、SCL、INT(中断)、RST(复位)四个引脚。INT引脚在检测到触摸时会拉低,主控可以通过读取这个引脚来减少轮询次数。
接线参考:
- SDA -> GPIO4
- SCL -> GPIO5
- INT -> GPIO6
- RST -> GPIO14(通常可以随便接一个GPIO,或者干脆接主控的复位)
值得注意的是,CST328在模组上一般已经内置了上拉电阻,外接I2C总线上就不需要额外再并联上拉了,反而可能导致电平匹配问题。
3. ESP-IDF开发环境准备:版本选择和工程初始化
3.1 版本选择和建议
ESP-IDF现在迭代很快,热词里有人提到“C:/esp_544”这类路径,说明v5.4.4这个版本在开发者中很常用。我用的是v5.4.4,整体稳定,对ESP32-S3的支持很完善。
如果你是完全从零开始,推荐按官方文档装好飞书文档里说的依赖软件包,Windows用户可以用Espressif的离线安装器,Linux用户则需要手动克隆仓库并安装工具链。装好之后一定要记得执行install.sh和export.sh(Windows是install.bat和export.bat)来设置环境变量。
3.2 创建第一个工程并设置目标芯片
初始化一个空工程很简单,用idf.py create-project就可以。接下来最重要的一步就是设置目标芯片:
idf.py set-target esp32s3这个命令会重写项目里的sdkconfig文件,同时会把工具链切换到ESP32-S3对应的版本。
很多人在这一步会踩到“failed to set target esp32s3: non zero exit code 2”这个错误。根据我的经验,这类报错常见的原因有三个:
- 环境变量没有正确加载,导致
idf.py找不到对应的工具。 - 当前用户对项目目录没有写权限。
- Python包冲突,特别是系统里同时装了多套Python时。
排查方式很简单,先重新打开终端,确保ESP-IDF的环境变量已经生效,然后试试idf.py --version能不能正常输出。如果不行,再检查IDF_PATH是否指向了正确的ESP-IDF目录,最后才考虑Python环境问题。
4. 点亮屏幕:ST7789底层驱动的完整实现
4.1 初始化SPI接口
ST7789走的是SPI总线,在ESP-IDF里配置起来非常直接。下面是初始化SPI主机接口的代码示例:
#include "driver/spi_master.h" #define LCD_SCLK_PIN 12 #define LCD_MOSI_PIN 11 #define LCD_CS_PIN 10 #define LCD_DC_PIN 9 #define LCD_RST_PIN 8 #define LCD_BL_PIN 7 spi_device_handle_t lcd_spi; void lcd_spi_init(void) { spi_bus_config_t buscfg = { .sclk_io_num = LCD_SCLK_PIN, .mosi_io_num = LCD_MOSI_PIN, .miso_io_num = -1, .quadwp_io_num = -1, .quadhd_io_num = -1, .max_transfer_sz = 320 * 240 * 2 }; spi_device_interface_config_t devcfg = { .clock_speed_hz = 20 * 1000 * 1000, // 先跑20MHz .mode = 0, // ST7789一般为SPI Mode 0 .spics_io_num = LCD_CS_PIN, .queue_size = 7, .flags = SPI_DEVICE_HALFDUPLEX }; spi_bus_initialize(SPI2_HOST, &buscfg, SPI_DMA_CH_AUTO); spi_bus_add_device(SPI2_HOST, &devcfg, &lcd_spi); }要注意的是SPI_DEVICE_HALFDUPLEX这个标志,ST7789这类屏幕只需要主控向屏幕写数据,不需要读回,半双工模式能有效降低总线负载。
4.2 ST7789初始化命令序列
ST7789的初始化要严格按照数据手册里面的命令时序来写,核心是关闭睡眠、设置像素格式、设置分辨率、打开显示这几个步骤。下面是我整理的最简初始化序列:
void lcd_cmd(uint8_t cmd, const uint8_t *data, size_t len) { // 先拉低DC脚表示写命令 // 然后发送cmd本身 // 如果len>0,再拉高DC脚表示写数据 } void lcd_init(void) { // 硬件复位 gpio_set_level(LCD_RST_PIN, 0); vTaskDelay(pdMS_TO_TICKS(10)); gpio_set_level(LCD_RST_PIN, 1); vTaskDelay(pdMS_TO_TICKS(120)); lcd_cmd(0x11, NULL, 0); // 退出睡眠模式 vTaskDelay(pdMS_TO_TICKS(20)); lcd_cmd(0x3A, (uint8_t[]){0x55}, 1); // 16位色深 RGB565 lcd_cmd(0x36, (uint8_t[]){0x00}, 1); // 扫描方向,根据你的模组调整 lcd_cmd(0x2A, (uint8_t[]){0x00, 0x00, 0x01, 0x3F}, 4); // 列地址0~319 lcd_cmd(0x2B, (uint8_t[]){0x00, 0x00, 0x00, 0xEF}, 4); // 行地址0~239 lcd_cmd(0x29, NULL, 0); // 打开显示 lcd_cmd(0x2C, NULL, 0); // 开始写显存 }这里面的0x36寄存器里存着扫描方向,屏幕模组的FPC排线不同,这个值需要按实际显示的方向调整。如果发现显示内容是旋转或者镜像的,改这个值就可以了。
4.3 测试显示:填充整屏颜色
初始化跑通之后,写一个简单的填色函数来验证屏幕是否工作正常:
void lcd_fill_color(uint16_t color) { uint8_t *buf = malloc(320 * 240 * 2); memset(buf, (color >> 8) & 0xFF, 320 * 240); for (size_t i = 0; i < 320 * 240; i++) { buf[i * 2] = (color >> 8) & 0xFF; buf[i * 2 + 1] = color & 0xFF; } spi_device_transmit(lcd_spi, &t); free(buf); }如果屏幕全屏显示成同一颜色,说明初始化序列和SPI通信都正常了。如果出现横条纹或颜色不对,多半是RGB565字节顺序问题,也就是0x36寄存器里的BGR位没设置对。
5. 触摸输入:CST328驱动与坐标处理
5.1 I2C初始化
CST328的I2C地址一般是0x15(7bit) 或者0x2A(8bit),不同模组可能不同,最好看下屏幕模组背后的丝印或者厂家提供的规格书。
ESP-IDF代码里初始化I2C主机接口:
#include "driver/i2c.h" #define I2C_PORT I2C_NUM_0 #define TOUCH_SDA 4 #define TOUCH_SCL 5 void touch_i2c_init(void) { i2c_config_t conf = { .mode = I2C_MODE_MASTER, .sda_io_num = TOUCH_SDA, .scl_io_num = TOUCH_SCL, .sda_pullup_en = GPIO_PULLUP_ENABLE, .scl_pullup_en = GPIO_PULLUP_ENABLE, .master.clk_speed = 400 * 1000 }; i2c_param_config(I2C_PORT, &conf); i2c_driver_install(I2C_PORT, I2C_MODE_MASTER, 0, 0, 0); }关于触摸芯片是否需要软复位,我实际测下来,如果拉到外部复位引脚,每次上电后等50毫秒再走I2C读取是最稳定的,不然偶尔会出现首次读取失败的情况。
5.2 读取触摸坐标
CST328内部维护一个触摸数据缓冲区,主控读取该区域就能拿到坐标信息。以单点触摸为例,核心思路是:先读状态寄存器判断是否有手指按下,然后读X和Y坐标寄存器。
典型寄存器布局如下(不同版本可能有差异,需要参考数据手册):
- 寄存器0x00:有效触摸点数
- 寄存器0x02~0x05:第一个触摸点的X/Y坐标(高低字节)
- 寄存器0x06~0x09:第二个触摸点的X/Y坐标(如果有多点)
读取流程可以用i2c_master_write_read_device一次性把整块数据读出来,然后解析。我封装成这样一个函数:
typedef struct { uint16_t x; uint16_t y; bool touched; } touch_point_t; touch_point_t touch_read(void) { uint8_t buf[8]; i2c_master_write_read_device(I2C_PORT, CST328_ADDR, (uint8_t[]){0x00}, 1, buf, 8, 100 / portTICK_PERIOD_MS); touch_point_t pt = {0}; if (buf[0] & 0x0F) { pt.touched = true; pt.x = (buf[2] & 0x0F) << 8 | buf[3]; pt.y = (buf[4] & 0x0F) << 8 | buf[5]; } return pt; }注意坐标值不是直接用,很多屏模组的触摸原点和屏幕原点不在一个角,需要做一次坐标映射。
5.3 触摸坐标到屏幕坐标的映射
CST328输出的原始坐标范围通常是0~4095(12位精度)或者0~2047(11位),但我们的屏幕分辨率只有320x240,所以必须做缩放和方向校正。
比如我的模组触摸坐标X方向跟屏幕X方向相反,Y方向一致,那么映射公式是:
uint16_t screen_x = 320 * pt.x / 4096; uint16_t screen_y = 240 * pt.y / 4096;如果要镜像,就改为screen_x = 320 - 320 * pt.x / 4096;。这一步不做对的话,LVGL界面点按钮会“点不准”,明明点的是左上角,实际触发的是右上角。
6. LVGL接入:让显示和触摸变成一套完整UI
6.1 在ESP-IDF中引入LVGL组件
LVGL在ESP-IDF里有两种集成方式:一种是使用ESP-IDF的组件管理器拉取,一种是把LVGL的源码直接放到components目录下。我推荐用组件管理器,直接在项目根目录执行:
idf.py add-component lvgl它会自动下载并配置好LVGL,还能识别已安装的其他组件。如果是手动拷贝源码,记得在CMakeLists.txt里面把LV_TICK_CUSTOM配置为1,让LVGL自己用系统时钟做心跳。
6.2 注册显示驱动到LVGL
LVGL要求提供一个“把某个区域的像素写到屏幕显存”的回调函数,我们需要把ST7789的写显存逻辑填进去。核心代码如下:
static void lvgl_lcd_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { uint32_t w = area->x2 - area->x1 + 1; uint32_t h = area->y2 - area->y1 + 1; lcd_set_window(area->x1, area->y1, area->x2, area->y2); // 把color_p中的数据通过SPI直接写入显存 lcd_write_data((uint8_t *)color_p, w * h * sizeof(lv_color_t)); lv_disp_flush_ready(drv); }这里有个关键点:lv_color_t默认在宏LV_COLOR_DEPTH为16时是RGB565格式,刚好可以跟ST7789的RGB565直接对应,不需要做像素格式转换。
6.3 注册触摸设备到LVGL
LVGL的输入设备回调需要返回当前触摸坐标和按压状态,接上我们之前的touch_read函数:
static void lvgl_touch_cb(lv_indev_drv_t *drv, lv_indev_data_t *data) { touch_point_t pt = touch_read(); >void app_main(void) { lcd_spi_init(); lcd_init(); touch_i2c_init(); lv_init(); lv_disp_drv_t disp_drv; lv_disp_drv_init(&disp_drv); disp_drv.flush_cb = lvgl_lcd_flush_cb; disp_drv.hor_res = 320; disp_drv.ver_res = 240; lv_disp_drv_register(&disp_drv); lv_indev_drv_t indev_drv; lv_indev_drv_init(&indev_drv); indev_drv.type = LV_INDEV_TYPE_POINTER; indev_drv.read_cb = lvgl_touch_cb; lv_indev_drv_register(&indev_drv); lv_obj_t *label = lv_label_create(lv_scr_act()); lv_label_set_text(label, "Hello ESP32-S3 + LVGL"); lv_obj_center(label); while (1) { lv_timer_handler(); vTaskDelay(pdMS_TO_TICKS(5)); } }如果屏幕能显示“Hello ESP32-S3 + LVGL”,并且触摸屏幕时能感知到按压事件,那硬件链路和软件链路就都通了,后面就可以开始堆界面了。
7. 常见错误与调试心得分享
7.1 “failed to set target esp32s3”到底怎么排查
这个报错在热词列表里反复出现,说明踩的人不在少数。我是这样定位的:先用idf.py --version确认工具链环境正常,再用echo $IDF_PATH确认环境变量指向正确。如果这两步都通过,问题往往是Python虚拟环境或者build目录里的缓存冲突。
有一个很“土”但有效的办法:把整个工程目录里的build文件夹删掉,然后重新set-target。删了之后再执行:
idf.py fullclean idf.py set-target esp32s3基本能解决一大半环境问题。除此之外,Windows用户用PowerShell执行export.ps1之前,如果没开执行策略,也会导致环境变量没生效,这种情况下可以先看脚本输出有没有报错。
7.2 显示花屏或颜色不对的处理
ST7789花屏的原因不外乎四个:SPI时钟频率太高、RGB565字节序没对齐、扫描方向错误、初始化序列漏步骤。
我调试时习惯用20MHz时钟,如果花屏就往下调到10MHz。颜色不对优先看0x36寄存器里的BGR位;显示内容整体“镜像”也是这个寄存器里MSB位决定的。花屏还有一种被忽略的情况是背光的PWM频率太低,屏幕看起来在闪烁,检查BLK引脚的GPIO输出模式是不是设置成PWM通道了。
7.3 触摸不灵敏或漂移的调整
触摸不灵敏大多不是芯片问题,而是I2C频率过高。如果你把master.clk_speed设置成1MHz,CST328可能来不及应答。稳妥做法是先跑400kHz,确认功能正常后再优化速度。
漂移问题则几乎都是坐标映射没做对。很多屏模组的触摸原点在左上角,但坐标方向可能跟屏幕扫描方向不同,中间还会隔着一层玻璃厚度产生偏移。建议屏模组驱动起来后用LVGL做一个“触摸测试”界面,把读取到的原始X、Y值直接打在屏幕上,再跟手指实际位置对比,这样大约几分钟就能摸清映射规律。
7.4 性能优化的一点经验
LVGL在ESP32-S3上跑,最大的瓶颈往往在SPI总线传输上。建议打开ESP-IDF的SPI DMA功能,也就是在初始化spi_bus_initialize时传入SPI_DMA_CH_AUTO,这样传输大块数据时不用占用CPU时间。另外,把ST7789的SPI时钟稳定提升到40MHz,屏幕刷新率会明显改善。LVGL侧的LV_MEM_SIZE也建议调大到32KB以上,否则复杂的页面布局可能会出现莫名其妙的卡死。
8. 一个完整可用的工程结构参考
最后晒一下我的实际工程目录结构,方便你对着搭建:
my_lvgl_demo/ ├── CMakeLists.txt ├── main/ │ ├── CMakeLists.txt │ ├── main.c // 入口,驱动和LVGL初始化 │ ├── lcd.h // ST7789相关 │ ├── lcd.c │ ├── touch.h // CST328相关 │ └── touch.c ├── components/ │ └── lvgl/ // 组件管理器拉取或手动放置 └── sdkconfig主工程的CMakeLists.txt只需要保留最基础的cmake_minimum_required和include即可,组件依赖交给idf.py add-component去管理,这样最省心。
另外,如果你和我一样习惯在VSCode里开发,装好Espressif的插件后可以直接在IDE里点按钮烧录和监视串口,针对idf.py命令的各种回调错误也能在终端面板里看到更清晰的日志。第一次烧录前建议用idf.py -p COMx flash monitor分开执行,这样能避免组合命令在出现异常时互相干扰,日志也更容易定位。
这个项目的完整链路其实不复杂,但每一段都可能出现独立的问题。只要先把屏幕点亮、再把触摸读通、最后接上LVGL三个步骤分开做,任何一个环节出问题都能快速定位。我个人最深的体会就是调试触摸坐标映射时别着急,多点几次,把打印出来的坐标跟自己手指的位置对应起来,一次就能搞定。接下来你可以试着加两个按钮、做个页面切换,体会一下LVGL的控件布局,慢慢就会越做越顺手。
本文还有配套的精品资源,点击获取