ESP32-S3驱动1.69寸圆角屏:LVGL移植与触摸避坑指南 简介这份资源面向使用ESP32-S3开发嵌入式图形界面的工程师与爱好者聚焦于用LVGL图形库驱动1.69寸电容触摸屏这一典型场景解决显示控制器ST7789与触摸芯片CST816在ESP32-S3上的适配与联调问题。压缩包共971个文件约17.39MB以C源码与头文件为主体辅以Python脚本、Markdown说明、PNG/GIF图片素材及字体文件涵盖LVGL示例、驱动适配与配置参考。资源中提供了ST7789经SPI刷屏、CST816经I2C上报触点、以及将触摸数据接入LVGL输入设备的完整思路并包含音乐播放器、控件演示等界面素材便于直接移植到自己的物联网项目中验证显示与触控效果。目前已有6177人学习下载适合希望快速跑通LVGL触摸交互、减少底层驱动调试成本的开发者参考。1. 一块 1.69 寸圆角屏为什么值得用 ESP32-S3 单独跑一遍 LVGL手里攒了几块 1.69 寸电容触摸屏分辨率 240×280驱动芯片常见是 ST7789V2 配 CST816 触摸圆角造型放桌面小设备上很讨喜。但真把它接到 ESP32-S3 上跑起来你会发现事情没那么简单SPI 刷屏要占引脚触摸走 I2C 又要另一组引脚LVGL 还要吃 RAM 和 Flash稍不注意就是花屏、触摸漂移、刷新卡顿三连。这套方案要解决的就是把「小屏 电容触摸 图形界面」这条链路在 ESP32-S3 上跑通适合做智能家居面板、便携仪表、桌面天气钟这类带交互的小设备。下面按我实际拆过的顺序从硬件接线一路讲到 LVGL 配置和踩坑。2. 硬件接线与驱动选型SPI 刷屏、I2C 读触摸怎么分工2.1 先确认屏幕的驱动芯片和引脚定义1.69 寸这个尺寸市面上流通的模组显示部分绝大多数是 ST7789V2少数是 ST7789P3两者初始化序列有细微差别买回来第一件事是看模组背面丝印或问卖家要规格书。触摸部分常见 CST816S、CST816T也有 FT6236 的变种。ESP32-S3 的 GPIO 矩阵很灵活但 SPI 和 I2C 的默认引脚最好别乱改否则 IDF 的驱动初始化会多绕一圈。我一般这样分配功能ESP32-S3 引脚说明SPI SCLKGPIO 12显示时钟SPI MOSIGPIO 11显示数据LCD CSGPIO 10片选LCD DCGPIO 9数据/命令切换LCD RSTGPIO 8复位LCD BLKGPIO 7背光可 PWM 调光I2C SDAGPIO 5触摸数据I2C SCLGPIO 6触摸时钟TP RSTGPIO 4触摸复位TP INTGPIO 3触摸中断注意ESP32-S3 的 GPIO 3 在某些模组上默认是 strapping 引脚上电瞬间电平会影响启动模式如果触摸 INT 接在这里导致无法下载换到 GPIO 13 更稳妥。2.2 为什么显示走 SPI 而不是 RGBESP32-S3 支持 RGB 接口直驱屏幕但 1.69 寸这种小屏基本只引出 SPI而且 SPI 占用引脚少、布线简单。240×280 分辨率下SPI 时钟跑到 40MHz 就能有不错的刷新率LVGL 的局部刷新配合 DMA 传输日常界面完全够用。RGB 接口虽然带宽高但需要连续帧缓冲对 RAM 压力大小屏没必要。2.3 触摸用轮询还是中断CST816 支持中断输出触摸发生时 INT 拉低。用中断方式能省 CPU但 LVGL 的输入设备读取本身是周期性的所以实际项目里我更多用轮询在 LVGL 的lv_timer_handler之前读一次触摸坐标简单可靠。中断方式适合低功耗场景需要配合esp_sleep唤醒复杂度高一些。3. 从 IDF 工程到 LVGL 移植SPI 初始化、触摸读取、显示注册3.1 建立 ESP-IDF 工程并加入 LVGL 组件用idf.py create-project建工程然后通过组件管理器把 LVGL 拉进来。LVGL 官方提供了lvgl/lvgl组件在idf_component.yml里声明即可dependencies: idf: 5.0 lvgl/lvgl: ^8.3.0执行idf.py add-dependency lvgl/lvgl^8.3.0会自动下载。LVGL 8 和 9 的 API 差异较大下面以 8.3 为例因为 8.x 在 ESP32 上的移植资料最全坑也最少。3.2 初始化 SPI 总线与 ST7789#include driver/spi_master.h #include driver/gpio.h #define LCD_HOST SPI2_HOST #define PIN_SCLK 12 #define PIN_MOSI 11 #define PIN_CS 10 #define PIN_DC 9 #define PIN_RST 8 #define PIN_BLK 7 spi_device_handle_t spi; void lcd_spi_init(void) { spi_bus_config_t buscfg { .sclk_io_num PIN_SCLK, .mosi_io_num PIN_MOSI, .miso_io_num -1, // 只写不读MISO 不接 .quadwp_io_num -1, .quadhd_io_num -1, .max_transfer_sz 240 * 280 * 2 8, // 足够一帧 }; spi_device_interface_config_t devcfg { .clock_speed_hz 40 * 1000 * 1000, // 40MHz .mode 0, // ST7789 用模式 0 .spics_io_num PIN_CS, .queue_size 7, }; ESP_ERROR_CHECK(spi_bus_initialize(LCD_HOST, buscfg, SPI_DMA_CH_AUTO)); ESP_ERROR_CHECK(spi_bus_add_device(LCD_HOST, devcfg, spi)); }max_transfer_sz要大于一帧的字节数否则 DMA 传输会被截断表现为屏幕下半部分花屏。clock_speed_hz从 40MHz 起步如果排线较长或屏幕质量一般降到 20MHz 更稳。mode选 0 是因为 ST7789 在 SPI 模式下采样沿固定选错会全屏噪点。3.3 封装 ST7789 的写命令和写数据void lcd_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, t); } void lcd_data(const uint8_t *data, size_t len) { if (len 0) return; gpio_set_level(PIN_DC, 1); // DC 高 数据 spi_transaction_t t { .length len * 8, .tx_buffer data }; spi_device_polling_transmit(spi, t); }DC 引脚在命令和数据之间切换这是 ST7789 的基本时序。spi_device_polling_transmit是阻塞式发送简单但会占 CPU刷大块数据时换成spi_device_queue_trans加回调能释放 CPU但代码复杂度上升。小屏项目里阻塞发送足够。3.4 初始化 ST7789 并点亮背光void lcd_init(void) { gpio_set_direction(PIN_DC, GPIO_MODE_OUTPUT); gpio_set_direction(PIN_RST, GPIO_MODE_OUTPUT); gpio_set_direction(PIN_BLK, GPIO_MODE_OUTPUT); gpio_set_level(PIN_RST, 0); vTaskDelay(pdMS_TO_TICKS(20)); gpio_set_level(PIN_RST, 1); vTaskDelay(pdMS_TO_TICKS(120)); lcd_cmd(0x01); // 软复位 vTaskDelay(pdMS_TO_TICKS(120)); lcd_cmd(0x11); // 退出睡眠 vTaskDelay(pdMS_TO_TICKS(120)); lcd_cmd(0x3A); // 像素格式 uint8_t fmt 0x55; // RGB565 lcd_data(fmt, 1); lcd_cmd(0x36); // 内存访问控制 uint8_t madctl 0x00; // 根据屏幕方向调整 lcd_data(madctl, 1); lcd_cmd(0x21); // 反显部分模组需要 lcd_cmd(0x29); // 开显示 gpio_set_level(PIN_BLK, 1); // 背光常亮 }0x36寄存器的值决定屏幕旋转方向0x00 是竖屏正向0xC0 旋转 180 度0x60 和 0xA0 是横屏。如果显示内容上下颠倒或镜像改这个值比改坐标换算省事。0x21反显不是所有模组都要不加可能颜色发白加了可能颜色发暗实测决定。3.5 移植 LVGL 的显示缓冲和刷新回调LVGL 需要一个缓冲区来绘制然后通过flush_cb把脏区域推到屏幕。ESP32-S3 有 512KB 内部 SRAM可以开一个 240×40 行的缓冲约 19KB双缓冲就是 38KB压力不大。static lv_disp_draw_buf_t draw_buf; static lv_color_t buf1[240 * 40]; static lv_color_t buf2[240 * 40]; void lvgl_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { int x1 area-x1, x2 area-x2; int y1 area-y1, y2 area-y2; int w x2 - x1 1; int h y2 - y1 1; lcd_cmd(0x2A); // 列地址 uint8_t col[4] {x1 8, x1 0xFF, x2 8, x2 0xFF}; lcd_data(col, 4); lcd_cmd(0x2B); // 行地址 uint8_t row[4] {y1 8, y1 0xFF, y2 8, y2 0xFF}; lcd_data(row, 4); lcd_cmd(0x2C); // 写显存 lcd_data((uint8_t *)color_p, w * h * 2); lv_disp_flush_ready(drv); // 必须调用否则 LVGL 不再刷新 } void lvgl_disp_init(void) { 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 280; disp_drv.flush_cb lvgl_flush_cb; disp_drv.draw_buf draw_buf; lv_disp_drv_register(disp_drv); }lv_disp_flush_ready是新手最容易漏的一行不调用的话 LVGL 会一直等界面卡死。缓冲区大小 240×40 是权衡太小刷新次数多太大会挤占其他任务的 RAM。双缓冲能让 LVGL 在刷屏的同时准备下一帧但 SPI 是阻塞发送双缓冲优势有限单缓冲也能跑。3.6 接入 CST816 触摸并注册输入设备#include driver/i2c.h #define I2C_PORT I2C_NUM_0 #define PIN_SDA 5 #define PIN_SCL 6 #define CST816_ADDR 0x15 void touch_i2c_init(void) { i2c_config_t conf { .mode I2C_MODE_MASTER, .sda_io_num PIN_SDA, .scl_io_num PIN_SCL, .sda_pullup_en GPIO_PULLUP_ENABLE, .scl_pullup_en GPIO_PULLUP_ENABLE, .master.clk_speed 400000, }; i2c_param_config(I2C_PORT, conf); i2c_driver_install(I2C_PORT, I2C_MODE_MASTER, 0, 0, 0); } bool touch_read(int16_t *x, int16_t *y) { uint8_t data[6]; i2c_cmd_handle_t cmd i2c_cmd_link_create(); i2c_master_start(cmd); i2c_master_write_byte(cmd, (CST816_ADDR 1) | I2C_MASTER_WRITE, true); i2c_master_write_byte(cmd, 0x02, true); // 坐标寄存器起始 i2c_master_start(cmd); i2c_master_write_byte(cmd, (CST816_ADDR 1) | I2C_MASTER_READ, true); i2c_master_read(cmd, data, 6, I2C_MASTER_LAST_NACK); i2c_master_stop(cmd); esp_err_t ret i2c_master_cmd_begin(I2C_PORT, cmd, pdMS_TO_TICKS(50)); i2c_cmd_link_delete(cmd); if (ret ! ESP_OK) return false; uint8_t touches data[1] 0x0F; if (touches 0) return false; *x ((data[2] 0x0F) 8) | data[3]; *y ((data[4] 0x0F) 8) | data[5]; return true; } void lvgl_indev_read(lv_indev_drv_t *drv, lv_indev_data_t *data) { int16_t x, y; if (touch_read(x, y)) { >static void lv_tick_task(void *arg) { lv_tick_inc(1); // 每 1ms 调用一次 } void lvgl_tick_init(void) { const esp_timer_create_args_t tick_args { .callback lv_tick_task, .name lv_tick }; esp_timer_handle_t tick_timer; esp_timer_create(tick_args, tick_timer); esp_timer_start_periodic(tick_timer, 1000); // 1000us 1ms }esp_timer的精度是微秒级比 FreeRTOS 的 tick 钩子稳。注意lv_tick_inc要在中断或高优先级任务里调用别放在低优先级任务里被其他任务阻塞。验证刷新率可以用 LVGL 自带的性能监控在lv_conf.h里打开LV_USE_PERF_MONITOR屏幕右上角会显示 FPS 和 CPU 占用。240×280 的屏SPI 40MHz 下刷简单界面能到 30FPS 以上复杂界面掉到 15FPS 左右这时候就要考虑减少透明度和阴影效果。还有一点是内存。ESP32-S3 的 PSRAM 如果开了可以把 LVGL 的缓冲区放到 PSRAM 里但 PSRAM 的访问速度比内部 SRAM 慢flush 回调里读 PSRAM 再走 SPI 会有额外延迟。我的习惯是缓冲区放内部 SRAM图片资源放 PSRAM这样刷新快、大图也不占内部 RAM。从那以后我每次接新屏幕都强制先跑一遍lv_demo_widgets看 FPS 和触摸响应再开始写业务界面。这个习惯帮我省了很多返工。希望帮到你。本文还有配套的精品资源点击获取