ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ESP32-S3+LVGL驱动1.69寸电容触摸屏:初始化、DMA与坐标映射全解

ESP32-S3+LVGL驱动1.69寸电容触摸屏:初始化、DMA与坐标映射全解

简介:面向嵌入式开发者的ESP32-S3图形界面驱动资源包,聚焦LVGL在1.69寸电容触摸屏上的落地:屏显部分采用ST7789显示控制器,触摸部分由CST816芯片实现,适合希望快速搭建物联网UI、学习LVGL移植或做屏幕驱动二次开发的工程师。资源共971个文件,压缩后17.39MB,主体为361个C源文件和178个头文件(显示/触摸驱动、LVGL配置与移植层),另有128个Python脚本、96个Markdown文档、fnt字体与PNG图片,以及kconfig/yml/mk等构建与配置辅助文件,目录结构接近完整ESP-IDF工程,便于对照源码理解。已有6160人学习下载。压缩包内除了ST7789/CST816的初始化与触摸事件处理示例,还提供LVGL官方控件和音乐播放器界面资源,方便对照液晶屏接线、SPI/I2C参数和中断服务逻辑进行二次开发。按步骤将驱动注册进LVGL后,即可实现按钮、滑块等图形交互,为物联网项目增添直观操作界面;对初次接触ESP32-S3与LVGL的读者,这份源码集合能大幅缩短从硬件配置到界面显示的时间。

1. 一块1.69寸电容触摸屏,为什么让ESP32-S3和LVGL一起翻车

一块 1.69 寸、240×280 的电容触摸屏,拿在手里比拇指大不了多少。把它接到 ESP32-S3 上再挂上 LVGL,本意是想让 UI 又小又流畅,结果新手最常见的体验是三种:刷新肉眼可见地慢,点击「确定」却点到「取消」,屏幕一旋转触摸坐标就完全对不上。这个事的难点从来不在 LVGL 的 API,而在于显示驱动 IC、触摸 IC、SPI DMA 和坐标映射这四样东西要同时配合。本文按我实际做过的方案来写,从接线开始,到 LVGL 刷屏、触摸接入、参数设置和踩坑,给你一条每一步都能自己验证的路径。适合刚拿到 ESP32-S3 开发板和 1.69 寸模组、准备用 LVGL 做小屏交互的开发者。

2. 先认识屏和触摸IC:1.69寸的常见组合与接线

2.1 常见组合是显示 ST7789V + 触摸 CST816 系列,先确认你手上是哪一套

1.69 寸 240×280 的电容触摸模组,市面上最常见的搭配是显示端用 ST7789V、触摸端用 CST816S 或 CST816T,触摸走 I2C,显示走 SPI。但同一尺寸也有用其他驱动的模组,丝印经常被遮住,最快的确认办法是把模组反面的丝印拍清楚,找卖家要一份规格书,或者直接看模块标注的型号。省略这一步,后面所有初始化代码都靠猜——你搬来的例程可能根本不是同一颗 IC,表现就是点不亮或者颜色全乱。

确认 IC 之后再查两个关键信息:显示 IC 的初始化命令序列,以及触摸 IC 的 I2C 寄存器布局。有些模组会把触摸 IC 和显示接到同一组 SPI 接口上,但 1.69 寸这类小尺寸模组几乎都是触摸独立走 I2C,接线方式更统一。别急着写代码,先把这两份资料放在手边,后面每改一个寄存器都对照着看,能省掉大量「改来改去不知道哪里错」的时间。

2.2 最小接线表:SPI 四线 + 触摸 I2C,8 根线别让 GPIO 打架

1.69 寸电容触摸屏最常见的物理接口是「SPI 四线 + 触摸 I2C」。显示部分至少 5 根线加背光,触摸部分 2 根线加中断,实际接起来最少 8 根。我用 ESP32-S3 时最常用的是 SPI2_HOST,也就是默认的 FSPI/VSPI 这一组,方便跟其他外设隔离。

功能屏端丝印接 ESP32-S3说明
SPI 时钟SCL / SCK任意 GPIO建议时钟 20~40MHz 起步
SPI 数据SDA / MOSI任意 GPIO四线 SPI 只需要一根 MOSI
片选CS / SS任意 GPIO不要复用其他 SPI 从设备
数据/命令选择DC / D/C任意 GPIO写命令拉低,写数据拉高
复位RST / RESET任意 GPIO常见与触摸 RST 分开,也有的共用一个脚
背光BLK / BL / LEDA任意 GPIO 或直连 3.3V调试期先直连 3.3V,减少变量
触摸数据SDA(触摸)任意 GPIO建议与显示 MOSI 分开走
触摸时钟SCL(触摸)任意 GPIO注意是否要上拉

提示:模块默认接法里背光可能已经由板载三极管控制,此时 BLK 直接接 3.3V 就能亮。如果点不亮,先量一下 BLK 引脚电平,不要急着换 GPIO。

GPIO 选择有一个原则:避开芯片的下载/启动引脚和 PSRAM 相关引脚,尤其是 GPIO35、GPIO36、GPIO37 这类纯输入脚,别拿去驱动屏幕。我一般会把显示相关引脚集中放到 GPIO7~12,触摸放到 GPIO3、GPIO4 附近,这样后面布局、排线都好处理。

2.3 上电先点亮背光和屏幕:初始化序列的最小写法

屏能点亮之前不要碰 LVGL。先用一段裸驱动把屏初始化到「显示纯色」的状态,确认 SPI 时序和命令序列没错,再往上挂 LVGL。这一步值得单独花 20 分钟,因为 LVGL 会把所有初始化问题都混在一起,最后你根本分不清是屏的问题还是库的问题。

常见写法是复位脚拉低再拉高,等待内部上电,然后发初始化命令:

#include "driver/spi_master.h" #include "driver/gpio.h" #define PIN_SCLK GPIO_NUM_12 #define PIN_MOSI GPIO_NUM_11 #define PIN_CS GPIO_NUM_10 #define PIN_DC GPIO_NUM_9 #define PIN_RST GPIO_NUM_8 #define PIN_BLK GPIO_NUM_7 void lcd_pin_init(void) { gpio_set_direction(PIN_RST, GPIO_MODE_OUTPUT); gpio_set_direction(PIN_DC, GPIO_MODE_OUTPUT); gpio_set_direction(PIN_BLK, GPIO_MODE_OUTPUT); gpio_set_level(PIN_BLK, 1); // 复位:拉低至少 20ms,再释放等 120ms gpio_set_level(PIN_RST, 0); vTaskDelay(pdMS_TO_TICKS(20)); gpio_set_level(PIN_RST, 1); vTaskDelay(pdMS_TO_TICKS(120)); } void st7789v_write_cmd(uint8_t cmd) { gpio_set_level(PIN_DC, 0); // DC 低电平表示写命令 spi_transaction_t t = { .length = 8, .tx_buffer = &cmd, }; spi_device_transmit(spi_dev, &t); } void st7789v_write_data(uint8_t data) { gpio_set_level(PIN_DC, 1); // DC 高电平表示写数据 spi_transaction_t t = { .length = 8, .tx_buffer = &data, }; spi_device_transmit(spi_dev, &t); }

这里的spi_dev是通过spi_bus_add_device得到的设备句柄,初始化时有一个参数很关键:max_transfer_sz决定了 DMA 一次最多搬运多少字节,后面 LVGL 刷屏也要用到它,所以提前设成一个合理的行缓冲值,比如240 * 2 * 20,表示一次最多传 20 行的 RGB565 数据。

初始化序列只保留几个必须命令就够让屏亮起来了:

static void st7789v_init(void) { // MADCTL:控制扫描方向与 RGB/BGR,先填 0 拿到默认方向 st7789v_write_cmd(0x36); st7789v_write_data(0x00); // 像素格式:0x55 表示 RGB565,16bit/像素 st7789v_write_cmd(0x3A); st7789v_write_data(0x55); // 反色开关:有的模组不反色会显示成底片,花屏时先关掉它 st7789v_write_cmd(0x21); // 退出睡眠命令,之后必须等 120ms st7789v_write_cmd(0x11); vTaskDelay(pdMS_TO_TICKS(120)); // 开显示 st7789v_write_cmd(0x29); }

写完这些命令后,如果屏幕能显示点亮后的底色,说明 SPI 时序、DC 电平和命令序列都正常。注意有些模组的 0x21 反色位是反的,体现在屏幕上就是颜色像照片底片,这时候把 0x21 换成 0x20 再试。

3. 移植LVGL到ESP32-S3:刷屏回调、Tick和DMA

3.1 LVGL 8 还是 9:选型直接决定你抄作业的难度

LVGL 8 系列和 9 系列在 API 上有明显差别:8 系列用lv_disp_drv_t、lv_indev_drv_t这套回调结构,网上绝大多数 ESP32 例程、教程和回答都是这个写法;9 系列改成了lv_display、lv_indev对象式 API,改版力度不小。

我一般建议:第一次接触这个组合的人直接用 LVGL 8 系列,原因就一条——你能搜到的可用例程几乎都是它,照着改最不容易卡死。9 系列适合已经有完整工程、想追新特性的朋友。下面所有代码都以 8 系列的 API 为例,逻辑在 9 上大同小异,只是注册方式不同。

3.2 lv_conf.h 里必须先改的4个配置

LVGL 的配置集中在lv_conf.h,项目模板里通常给的是lv_conf_template.h,要复制一份改名才能生效。有几个配置直接决定这块屏能不能正常驱动:

// 颜色深度必须和屏的像素格式一致:屏设了 RGB565,这里就是 16 #define LV_COLOR_DEPTH 16 // 内存分配器:用标准 malloc/free 还是 LVGL 自带的内存池 #define LV_MEM_CUSTOM 1 // LVGL 内部任务刷新周期,单位 ms #define LV_DISP_DEF_REFR_PERIOD 30 // 打开右上角的 FPS/CPU 监控,调试期强烈建议开 #define LV_USE_PERF_MONITOR 1

LV_MEM_CUSTOM置 1 时,LVGL 直接用malloc/free;置 0 时用内置分配器,LV_MEM_SIZE决定一个静态数组大小,会占掉一大块.bss,ESP32-S3 内存紧张时容易复位。我用下来的经验是自定义成标准库分配器,省心且内存碎片相对可控。

LV_DISP_DEF_REFR_PERIOD是 LVGL 的刷屏节奏,默认 30ms 意味着理论最高约 33 帧。具体数值还会被 flush 回调的实际传输耗时压住,后面会细说。

3.3 flush 回调:DMA 和 max_transfer_sz 是卡顿根源

LVGL 画完一帧后,调用你注册的flush_cb,把一帧的局部区域交给屏幕驱动。这个回调的写法直接决定刷新率和会不会花屏。

static lv_disp_draw_buf_t draw_buf; // 双缓冲:LVGL 在准备下一帧时,上一帧还在 SPI 传输 static lv_color_t buf1[240 * 20]; static lv_color_t buf2[240 * 20]; static void lvgl_disp_init(void) { lv_init(); lv_disp_draw_buf_init(&draw_buf, buf1, buf2, 240 * 20); static lv_disp_drv_t disp_drv; lv_disp_drv_init(&disp_drv); disp_drv.hor_res = 240; disp_drv.ver_res = 280; disp_drv.flush_cb = disp_flush_cb; disp_drv.draw_buf = &draw_buf; lv_disp_drv_register(&disp_drv); } static void disp_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { int w = area->x2 - area->x1 + 1; int h = area->y2 - area->y1 + 1; // 按行设置窗口并发送,单次传输就是一行像素 for (int line = 0; line < h; line++) { st7789v_set_window(area->x1, area->y1 + line, area->x2, area->y1 + line); st7789v_write_pixels( (uint8_t *)color_p + line * w * 2, w * 2); } // 全部发送完成后必须调用,LVGL 才会释放这块 buffer lv_disp_flush_ready(drv); }

st7789v_set_window要写命令 0x2A(列地址)和 0x2B(行地址),窗口就是「当前这一行」;写像素用 0x2C。按行发送比整块发送更安全:.max_transfer_sz如果小于整块区域,SPI 驱动会被迫内部拆分,拆分逻辑在某些版本里会有字节对齐问题,导致花屏。一行 480 字节(240×2)基本不会撞上任何拆分边界,代价只是命令开销多了点,实际影响可以忽略。

注意:lv_disp_flush_ready(drv)一定等所有像素发完再调用。提前调用之后,LVGL 可能立刻往这块缓冲区里画新内容,而 SPI DMA 还在读旧数据,结果就是典型的水波纹和撕裂。

st7789v_write_pixels内部直接用spi_device_transmit发送一整行,配合初始化 SPI 时指定的 DMA 通道,CPU 在 SPI 传输期间可以去做 UI 计算。不要用轮询 GPIO 逐字节发数据,那个方案只能跑出每秒几帧的效果。

3.4 Tick 别用 vTaskDelay:独立任务喂 LVGL 才是正解

LVGL 内部所有动画、输入扫描都依赖 tick。很多例程会在loop里写lv_tick_inc(10),这在主循环里会造成两种后果:一是一次延时卡住,动画整体抖动;二是 tick 和 UI 刷新互相抢占时序。

正确姿势是独立任务或定时器,每 1ms 喂一次:

static void lv_tick_task(void *arg) { while (1) { lv_tick_inc(1); vTaskDelay(pdMS_TO_TICKS(1)); } } // 在初始化时创建这个任务 xTaskCreate(lv_tick_task, "lv_tick", 2048, NULL, 5, NULL);

lv_tick_inc的参数就是自上次调用以来经过的毫秒数。不要图省事每 10ms 调一次lv_tick_inc(10),时间久了误差会累积,LVGL 的动画时间判断会出现「看起来卡一下又跳一下」的奇怪现象。另一种常见方案是用esp_timer_create建一个 1ms 回调,效果相同,选哪种都行,逻辑一致。重点是 tick 来源必须独立且稳定,不能和刷屏、触摸读取挤在同一个循环里互相拖后腿。

4. 电容触摸接入LVGL:坐标读取、旋转映射与indev注册

4.1 从 I2C 寄存器里把坐标和手指数读出来

1.69 寸模组上的电容触摸 IC 绝大多数走 I2C,从机地址常见是两个:0x15 和 0x2C。第一次接线别猜地址,写一个 I2C 扫描函数,把 0x08 到 0x77 全部扫一遍,能 ACK 的地址会列出来。

寄存器布局每个 IC 有差异,不能无脑照搬。以最常见的「从 0x01 开始连读 6 字节」为例,读到的手势、坐标高 4 位、坐标低 8 位都在这个包里:

uint8_t reg = 0x01; uint8_t buf[6] = {0}; esp_err_t ret = i2c_master_write_read_device( I2C_NUM_0, touch_addr, &reg, 1, buf, 6, pdMS_TO_TICKS(20)); if (ret != ESP_OK) { // I2C 读失败,视为无触摸,保持上一次坐标 return; } int touch_points = buf[0] & 0x0F; // 低 4 位是触摸点数 int x = ((buf[2] & 0x0F) << 8) | buf[3]; // 高字节在 buf[2] 的低 4 位 int y = ((buf[4] & 0x0F) << 8) | buf[5];

这里的偏移是「以我的模组为例」,不同批次不一样。拿到 IC 数据手册后,重点核对三个字节:触摸点数位、X 高字节位置、Y 高字节位置。有的版本把坐标高 4 位放在开头,有的放在末尾,读错的表现就是坐标只在 0~15 之间跳。

读出来的坐标通常是 12bit 精度,有效区只占一部分,实际映射到 240×280 时还要做线性缩放:

int lv_x = (int64_t)x * 240 / 4096; int lv_y = (int64_t)y * 280 / 4096;

先别急着加旋转和镜像,第一步用默认方向把触摸点亮,确认坐标方向和屏幕一致,再去做方向处理。这个顺序反了,你会同时面对「坐标反了」和「坐标偏了」两个问题,完全没法定位。

4.2 注册 indev:read_cb 里点状态要同步更新

LVGL 通过lv_indev_drv_t读取触摸。回调里要同时更新坐标和按下状态:

static void touch_read_cb(lv_indev_drv_t *drv, lv_indev_data_t *data) { int x = 0, y = 0, touched = 0; touch_read_from_i2c(&x, &y, &touched); if (touched) { style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进