简介:这套例程演示了ESP32-S3通过ESP-IDF框架驱动CST328电容触摸和ST7789 TFT LCD,并集成LVGL图形库,适合需要快速搭建嵌入式人机界面的中高级开发者。资源共1342个文件、压缩后约26.45MB,以C源码与H头文件为主体,另含Python脚本、Markdown文档与PNG图片,以及TTF字体、Kconfig、CMake构建配置和LVGL官方demo的位图素材,工程结构清晰,便于按模块移植。已有1465人学习下载。例程覆盖SPI接口初始化、触摸控制器读取、显示驱动配置和LVGL渲染流程,并预置音乐播放器、控件展示等演示界面,可直接在原有demo上替换或扩展,用于开发智能仪表板、智能手表或交互式控制面板等实际项目。开发者对照源码即可梳理从初始化到事件回调的完整移植路径。
1. 驱动CST328与ST7789:为什么例程比你想的更省事
一块丝印上同时出现CST328和ST7789的2.4寸屏,往往是采购盯上参数表直接焊上来的。ST7789的初始化序列并不稀奇,麻烦的从来是那颗CST328电容触摸控制器:I2C地址、坐标原点、触摸状态寄存器,每一项不亲自试一次就容易翻车。把这两颗芯片和LVGL、ESP32-S3组合成一套例程,价值就在于不用再盲调底层,直接能在LVGL的输入设备接口上收到干净的坐标事件。这套例程适合两类人:刚拿屏想三天内跑到LVGL的嵌入式界面开发者,以及要把触摸和显示驱动移植到其他MCU平台的人。至于它到底能不能直接用,往下看完引脚映射和避坑清单,心里就有数了。
2. 把CST328和ST7789接进ESP-IDF:按组件拆工程,目标起屏出字
2.1 例程为什么按组件拆:board、总线、LVGL各管一摊
ESP32-S3跑LVGL驱动ST7789加CST328,最合适的工程结构不是把所有代码塞进main.c,而是按ESP-IDF的组件(component)方式拆开。原因很直接:这三样东西的职责完全不同。ST7789是SPI接口的TFT驱动,背后是像素时钟和刷屏命令;CST328是I2C接口的触摸控制器,背后是寄存器读取和坐标换算;LVGL是渲染框架,只关心“屏幕多宽多高”“触摸点在哪个坐标”,不关心底层是用SPI还是并口。硬把它们写在一起,换一颗触摸IC或者换一块同尺寸异型号屏幕,都要动整段代码。
例程里常见的拆分是:main目录放应用初始化,components目录下面分别放display驱动、touchpad驱动、LVGL组件包。display驱动只做ST7789的上电时序、初始化命令序列、画点画块的回调函数;touchpad驱动只做CST328的I2C读写和坐标预处理;LVGL组件则原样包进来,通过几个宏配置和两个注册函数绑定上下两端。这样做的另一个好处是编译粒度清晰,哪一个环节出问题,编译报错会直接指到对应组件。
我拿到这种例程时,第一个动作永远是打开目录结构看它有没有把驱动分成独立组件。如果看到的是一个大main.c里既有I2C又有SPI又有LVGL回调,我会先花时间拆开再往下走。这不是洁癖,是后面排错时能不能快速定位到问题所在的工程基础。
2.2 引脚映射表:先核对一遍接线再谈驱动
在敲下idf.py build之前,先把引脚映射核对完。ESP32-S3的外设引脚是可任意映射的,但例程里SPI、I2C、背光、复位、TE这些信号默认分配在哪些GPIO,每个板子都不一样。直接照抄例程的GPIO定义去接一个引脚顺序不同的板子,起屏概率为零。
下面这张表是这套例程比较常见的默认分配,做的是240x320分辨率的屏。拿到手第一件事,拿起万用表对着原理图逐脚打一遍通断,确认例程默认值和实际接线一致。
| 信号 | 例程默认引脚示例 | 方向 | 说明 |
|---|---|---|---|
| ST7789 CS | GPIO_NUM_10 | 输出 | SPI片选,拉低选中屏幕 |
| ST7789 SCLK | GPIO_NUM_12 | 输出 | SPI时钟,建议先接20MHz以下 |
| ST7789 MOSI | GPIO_NUM_11 | 输出 | SPI数据线,四线模式没有MISO |
| ST7789 DC | GPIO_NUM_4 | 输出 | 数据/命令选择,关键信号 |
| ST7789 RST | GPIO_NUM_5 | 输出 | 复位脚,低电平有效 |
| ST7789 BL | GPIO_NUM_3 | 输出 | 背光控制,可用LEDC PWM |
| ST7789 TE | GPIO_NUM_6 | 输入 | 撕裂同步信号,可选但推荐接 |
| CST328 SDA | GPIO_NUM_8 | 双向 | I2C数据线,需上拉 |
| CST328 SCL | GPIO_NUM_9 | 输出 | I2C时钟,需上拉 |
| CST328 RST | GPIO_NUM_7 | 输出 | 触摸IC复位 |
| CST328 INT | GPIO_NUM_15 | 输入 | 触摸中断,可配置触发方式 |
这里特别提醒一点:CST328的I2C地址取决于芯片的地址引脚电平,不少模块上SDA和SCL都接了4.7k上拉,但I2C地址冲突的情况仍然存在。例程默认用的是7位地址0x15,换算成8位写地址是0x2A。如果你那块屏模组厂家改过地址引脚,读回来的寄存器全是0xFF。这个坑在后面第5章会展开讲。
2.3 构建流程:从idf.py到串口日志,确认例程已经亮起来
引脚核对完毕,接下来是标准的ESP-IDF构建流程。这套例程不依赖复杂的厂商IDE,命令行直接能做。下面这套命令是完整可执行的操作路径:
# 先选择目标芯片,这一步不对后面全是白费 idf.py set-target esp32s3 # 打开LVGL、SPI、I2C相关配置项 # 重点看Component config -> LVGL configuration和触摸驱动菜单 idf.py menuconfig # 编译,首次拉取组件依赖会比较慢 idf.py build # 烧录并监视串口,日志会打印LVGL版本和触摸IC的ID idf.py -p /dev/ttyUSB0 flash monitorset-target必须放在最前面,因为ESP-IDF的sdkconfig会按目标芯片生成,先编译默认的其他芯片再切过来,经常出现缓存冲突导致的外设配置残留。menuconfig这一步值得花时间逐项看一遍,例程的LVGL组件装了之后,默认配置不一定是为240x320分辨率准备的,颜色深度、缓冲区大小、字体开关都在这里。build出现警告可以不管,但有error就要看是哪个组件报的。
flash monitor起来之后,正常现象是先打印ESP-ROM启动日志,接着是应用程序里自己写的初始化日志。如果例程留了打印,应当能看到ST7789的屏幕ID或者CST328的I2C设备扫描结果。看不到这两个信息,不用急着加载LVGL界面,先回去查接线,这是整套系统最底层的探针。
3. 移植LVGL:把屏幕刷新和CST328坐标接到lv_drv接口上的关键配置
3.1 lv_conf.h与lv_drv_conf.h:打开哪些开关才不掉帧
LVGL移植到ESP-IDF,核心是配好lv_conf.h。这个头文件是LVGL的行为开关,分辨率、颜色深度、内存池大小、是否启用自定义tick,全在这里。例程通常带一份默认配置,但默认配置往往不是为这块屏优化的。我处理这套例程时,会先盯住下面几个宏:
/* 颜色深度:ST7789是16位真彩,用RGB565 */ #define LV_COLOR_DEPTH 16 /* LVGL内部堆大小:ESP32-S3外接PSRAM时可给到96KB以上 */ #define LV_MEM_SIZE (96 * 1024) /* 使能自定义tick,避免和FreeRTOS tick打架 */ #define LV_TICK_CUSTOM 1 #if LV_TICK_CUSTOM #define LV_TICK_CUSTOM_INCLUDE "esp_attr.h" #define LV_TICK_CUSTOM_SYS_TIME_EXPR (esp_timer_get_time() / 1000) #endifLV_COLOR_DEPTH设为16意味着LVGL内部所有颜色都按RGB565打包,正好和ST7789的像素格式对齐,刷屏时不需要做颜色转换。如果设成32,刷屏回调里就要做一次像素格式转换,240x320全屏刷新会白白多算几十毫秒。LV_MEM_SIZE是LVGL自己管理的内存堆大小,界面里的对象、样式、动画都从这个堆里分配。ESP32-S3如果没有外接PSRAM,96KB放在普通SRAM里略显紧张,保守可以降到64KB,但要确保同时显示的控件数量小于这个值。
LV_TICK_CUSTOM是很多初移植的人容易漏掉的地方。LVGL需要一个毫秒级tick来驱动动画和触摸扫描,默认会占用一个硬件定时器。在ESP-IDF环境里,直接用esp_timer_get_time()的返回值最省事,不需要额外申请定时器外设。这个宏一开,LVGL的动画、滚动、长按检测全部有节奏了。
3.2 让LVGL知道屏长什么样:显示驱动注册与flush回调
配完宏之后,真正把屏幕接进LVGL的是显示驱动注册。LVGL通过lv_disp_drv_t结构体描述一块屏幕,驱动注册之后,LVGL会按需调用flush回调,把一帧画面里需要更新的矩形区域送给ST7789。例程里这段代码是整套驱动链路的中心:
static lv_color_t buf1[240 * 10]; static lv_color_t buf2[240 * 10]; static lv_disp_draw_buf_t draw_buf; static lv_disp_drv_t disp_drv; static void st7789_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { /* 把LVGL给出的矩形区域直接发给ST7789 */ st7789_draw_bitmap(area->x1, area->y1, area->x2, area->y2, (uint8_t *)color_p); /* 关键:告诉LVGL这块buffer已经画完,可以接着用 */ lv_disp_flush_ready(drv); } void lvgl_display_init(void) { lv_disp_draw_buf_init(&draw_buf, buf1, buf2, 240 * 10); lv_disp_drv_init(&disp_drv); disp_drv.hor_res = 240; disp_drv.ver_res = 320; disp_drv.flush_cb = st7789_flush_cb; disp_drv.draw_buf = &draw_buf; lv_disp_drv_register(&disp_drv); }这里有几个参数值得停下来看。240*10表示每个buffer能装10行像素,两个buffer共20行,换算成字节是240 * 10 * 2 = 4800字节一个buffer。这个尺寸是刻意选的:它比全屏buffer(9600字节)小得多,又足够大,能让LVGL在一次刷新周期内处理10行像素,减少flush次数。如果内存有余,把10改成20,刷新帧率会再往上走一点。flush_cb里的st7789_draw_bitmap是ST7789驱动组件提供的函数,它负责设置窗口地址、发送像素数据。lv_disp_flush_ready是LVGL的握手信号,漏掉这个调用,LVGL会认为buffer还在被占用,界面直接卡死,这是新手移植时最常见的死机原因。
3.3 触摸坐标上报:CST328原始寄存器值怎么转成LVGL坐标
显示链路打通之后,触摸就是第二个关键点。CST328通过I2C上报触摸数据,数据从寄存器0x02开始,每帧5个字节:第一个字节的最高位表示是否有触摸,接下来两个字节是X坐标的高低位,再接下来两个字节是Y坐标的高低位。LVGL侧需要一个输入设备回调,持续向LVGL报告“当前有没有手指按下,按在哪儿”:
#define CST328_I2C_ADDR 0x15 static void cst328_indev_read(lv_indev_drv_t *drv, lv_indev_data_t *data) { uint8_t reg = 0x02; uint8_t buf[5] = {0}; int ret = i2c_master_write_read_device( I2C_NUM_0, CST328_I2C_ADDR, ®, 1, buf, 5, pdMS_TO_TICKS(10)); if (ret != ESP_OK || !(buf[0] & 0x80)) { >static lv_indev_drv_t indev_drv; lv_indev_drv_init(&indev_drv); indev_drv.type = LV_INDEV_TYPE_POINTER; indev_drv.read_cb = cst328_indev_read; lv_indev_drv_register(&indev_drv);注册完成之后,LVGL会周期性调用read_cb。需要注意的是,read_cb里不能做阻塞时间太长的事情。I2C读5个字节在10ms超时内能完成,但如果I2C总线上同时挂了其他传感器,每次读取超时都会拖慢LVGL的输入轮询周期。例程里如果对触摸响应手感有要求,可以把I2C时钟频率从100kHz提到400kHz,这个参数在I2C初始化里改。
4. 参数与性能调优:背光、帧率、触摸灵敏度一次调到位
4.1 SPI时钟不是越高越好:慢速初始化,快速刷新
ST7789的数据手册上写的SPI最高时钟频率并不低,但实际刷屏效果和接线质量强相关。用杜邦线连接时,SCLK超过60MHz就会出现花屏和边缘锯齿;用排线或者PCB走线则能稳定跑到80MHz。这套例程默认做法是初始化阶段用低速时钟,进LVGL刷新阶段再切高速时钟,我给驱动组件补齐了这一套参数后发现,起屏成功率明显提升。
/* 初始化阶段:20MHz,兼容长走线 */ #define ST7789_SPI_CLOCK_INIT (20 * 1000 * 1000) /* 刷屏阶段:60MHz,需要信号质量过关 */ #define ST7789_SPI_CLOCK_FLUSH (60 * 1000 * 1000)SPI驱动初始化时用CLOCK_INIT,等到LVGL的flush回调首次被调用时再调用spi_bus_set_frequency切到CLOCK_FLUSH。这样做的好处是上电时序不受高频干扰,初始化命令能稳稳写进ST7789;进入正常显示后,高频模式又能保证刷新率。如果发现切换到高频后花屏,优先检查MOSI线上有没有串阻、SCLK线有没有和电源线走得太近,而不是急着降频。
4.2 背光PWM、长按阈值、触摸灵敏度参数表
背光和触摸灵敏度是影响使用手感的两个因素。背光用ESP32-S3的LEDC外设做PWM,频率选1kHz以上,避免人眼可见的闪烁。触摸灵敏度由CST328的中断触发模式和上报阈值决定,例程里如果在初始化时配置了触摸IC的寄存器,下面这组参数可以作为起点:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 背光PWM频率 | 1kHz ~ 5kHz | 低于500Hz会看到屏幕闪烁 |
| 背光PWM占空比 | 0 ~ 4095 | 线性调光,部分屏低占空比偏色 |
| CST328中断触发 | 下降沿 | 配合INT引脚,节省轮询开销 |
| 触摸上报阈值 | 默认即可 | 灵敏度过高会误触,过低会断触 |
| LVGL长按触发时间 | 400ms | 在lv_conf.h中配置 |
4.3 双缓冲与LV_MEM_SIZE:内存和流畅度的一笔账
LVGL的显示缓冲策略直接决定画面流畅度。单缓冲时,LVGL画完一帧区域后要等flush完成才能继续画下一块,刷新过程中画面会看到从上往下逐行扫描的痕迹。双缓冲则可以让LVGL在flush当前buffer的同时,往另一个buffer里画下一块内容,撕裂和扫描感会明显减轻。代价是内存翻倍。以240x320屏幕为例,10行像素的双缓冲方案内存开销约19KB,对ESP32-S3来说完全可以接受。
/* lv_conf.h 内存相关 */ #define LV_HOR_RES_MAX 240 #define LV_VER_RES_MAX 320 #define LV_MEM_SIZE (96 * 1024) /* 显示缓冲区:两个240x20行像素的buffer */ static lv_color_t buf1[240 * 20]; static lv_color_t buf2[240 * 20];240*20行双缓冲换算下来每个buffer是240 * 20 * 2 = 9600字节,两个加起来不到19KB。相比直接用全屏buffer的150KB,节省出的内存可以留给LVGL对象和动画使用。如果ESP32-S3没有外接PSRAM,LV_MEM_SIZE建议控制在64KB以内,超过之后FreeRTOS的堆会报警。有PSRAM的话,LV_MEM_SIZE可以放到128KB,界面复杂程度可以明显提升。
5. 避坑排查:CST328和ST7789例程里最容易翻车的5个位置
5.1 白屏与花屏:先查复位时序再怀疑驱动
现象:背光亮,屏幕全白,或者出现无规律的雪花点。解决完复位时序后,画面直接进入正轨。我遇到过不止一次例程代码看起来没问题、屏幕就是不亮的情况。
原因:ST7789的复位信号没做到位。上电后需要拉低RESET至少10us,然后拉高并等待120ms左右让内部电路稳定。很多简化例程把复位和背光初始化写在一起,或者根本没处理RESET引脚,导致芯片进入异常状态。
解决:在ST7789初始化第一行之前,把RESET拉低10ms,再拉高延时120ms,然后才开始发初始化命令。这段延时用的是vTaskDelay而不是忙等,避免阻塞其他任务。改完这个,绝大多数白屏问题直接消失。
5.2 触摸坐标镜像:CST328的X/Y翻转和原点设置
现象:点击屏幕左上角,LVGL显示的光标或者触摸对象跑到了右下角;或者水平方向正常、垂直方向是反的。
原因:CST328作为触摸IC,默认坐标原点和屏幕显示原点不一定一致。屏幕的物理安装方向、CST328在FPC上的贴装方向都会影响坐标。例程默认的坐标映射不一定适配你手上这块屏的模组方向。
解决:先在cst328_indev_read回调里做坐标映射预处理,而不是去改LVGL层。通过交换x和y、翻转坐标,把触摸坐标对齐到屏幕坐标。我一般会做一个四向映射测试:点左上、右上、左下、右下四个角,记录触摸读到的值,就能确定该用哪种翻转组合。
5.3 刷屏撕裂:TE帧同步引脚不是摆设
现象:页面快速滚动时,屏幕中间出现一条横向错位的亮带,像是画面被切开后又错位拼回去。
原因:LVGL往SPI发送像素数据的时间点,和ST7789内部正在刷新屏幕的行位置不同步。当写入窗口跨越正在刷新的行时,就会出现撕裂。
解决:把屏的TE引脚接到ESP32-S3的一个GPIO,在flush回调里等待TE信号有效再开始发送数据。上面第3章代码里的st7789_flush_cb内部加一个等待TE电平的循环,就能把写入动作匹配到屏幕的消隐区间。撕裂消除后帧率会略有下降,但观感是流畅的。
5.4 I2C读触摸寄存器全0xFF:除了地址还要查上拉和电平
现象:触摸完全无响应,I2C读出来的5个字节全是0xFF;或者读地址时返回NACK错误。
原因:三种可能最常见:第一,I2C地址不匹配,7位地址不是0x15;第二,SDA/SCL总线上没有上拉电阻,开漏输出拉不低;第三,屏模组是1.8V的电平,直接接3.3V的ESP32-S3引脚,总线被钳位或驱动能力不足。
解决:先跑一个I2C地址扫描,依次对0x08到0x77发送探测帧,看实际ACK的是哪个地址。确认地址正确后,用示波器看SDA和SCL的波形,低电平必须低于0.3V以下才正常。电平不匹配时加电平转换,不要用直接分压这种不可靠的做法。
5.5 LVGL进Hard Fault:内存对齐和buffer分配背锅
现象:界面加载到一半,串口打印Guru Meditation Error,指向LoadProhibited。
原因:LVGL的draw buffer要求地址对齐。直接在普通全局数组上分配,在某些编译器配置下可能会得到未对齐的地址;另一个常见原因是LV_MEM_SIZE设置过大,超出ESP32-S3实际可用内存,导致LVGL内部malloc失败。
解决:用heap_caps_malloc分配draw buffer,指定MALLOC_CAP_DMA或者MALLOC_CAP_8BIT。LV_MEM_SIZE改成实际可用内存的一半以下。例程如果默认值偏大,先砍一半再跑。这样处理后,Hard Fault基本不会再出现。
6. 进阶验证:用LVGL自带demo压测触摸与刷屏,把玄学变成数据
验证这套例程是否真正可用,不应该只靠肉眼看到界面亮起来。LVGL自带一组demo,里面有benchmark压测程序,可以量化刷新性能。启用方式是在lv_conf.h里打开LV_USE_DEMO_BENCHMARK,然后在主循环里调用一次:
#include "demos/lv_demos.h" void app_main(void) { /* 初始化外设和LVGL后 */ lv_demo_benchmark(); while (1) { lv_timer_handler(); vTaskDelay(pdMS_TO_TICKS(5)); } }benchmark会自动跑一组场景,切换过程中会统计每个场景的帧率和耗时。我通常关注两个指标:过场动画的帧率是否大于30fps,以及空闲时的CPU占用。低于30fps,先检查是不是SPI时钟没切到高频模式;空余CPU占用过高,再看是不是LV_TICK_CUSTOM没开导致LVGL反复轮询。
触摸侧验证用lv_demo_widgets配合一个跟随手指移动的小方块。把触摸回调里的坐标通过串口打印出来,再让屏幕上一个小方块实时跟随,能直观看到坐标是否漂移。我习惯用慢速画圈的方式滑动,观察方块轨迹是否贴合手指路径。偏差超过两个像素,优先查坐标翻转和I2C读取时序,而不是怀疑触摸IC本身。
这套验证流程走完,例程的性能边界基本就摸清了:多快的SPI时钟会花屏、多大的LV_MEM_SIZE会死机、触摸在哪个区域容易漂移。从那以后,我每次拿到新的屏驱动例程,都会强制跑一遍benchmark并把基线帧率记录到工程README里,后续调优都有据可依。这让我少踩了很多“明明代码没改,却突然变卡”的坑。希望帮到你。
本文还有配套的精品资源,点击获取