简介:面向嵌入式与物联网开发者的ESP32S3图形界面驱动示例,聚焦在IDF 5.2.2环境下将LVGL图形库与ST7789显示驱动、xpt2046电阻触摸控制器打通,解决小尺寸LCD屏点亮、界面渲染与触摸坐标采集的入门与联调问题,适合已具备一定ESP-IDF基础、希望快速搭建带触摸GUI原型的工程师参考。资源包共10个文件,约36KB,以C源码与头文件承载LVGL初始化、显示与触摸对接逻辑,辅以CMakeLists构建脚本、sdkconfig及defaults配置、idf_component.yml与依赖锁文件,便于直接导入工程复现。已有761人学习下载。示例完整呈现显示刷新与触摸输入的协同流程,读者可据此验证屏幕显示效果与触摸响应,理解组件依赖与工程配置组织方式,并以此为起点扩展控件与交互,节省从零适配驱动的时间。
1. 一块屏点不亮,多半不是屏的问题
上周帮人收拾一块 2.8 寸 240×320 的 SPI 屏,主控 ESP32S3,驱动 ST7789,触摸芯片 xpt2046,图形库用 LVGL,框架是 IDF 5.2.2。对方描述得很干脆:代码编译过了,烧进去白屏,串口没报错。这种“编译通过、运行白屏”的场景在 ESP32S3 LVGL ST7789 xpt2046 这套组合里出现频率极高,因为四层东西叠在一起——芯片引脚、SPI 总线、驱动初始化序列、LVGL 的显示缓冲,任何一层错位都表现为同一块白屏。装这套环境的人通常是做小尺寸 HMI 的开发者,想先把屏跑通再往上堆界面。这篇记录按我实际装机的顺序走:先把版本和引脚定死,再装 IDF、拉 LVGL、配 ST7789 与 xpt2046,最后给一套能自证的验证方法。装不好不会炸,但会让你在“是不是屏坏了”上浪费一整晚。
2. 环境与版本先定死:IDF 5.2.2 与 LVGL 的搭配取舍
2.1 为什么锁 IDF 5.2.2 而不是随手装最新
ESP32S3 的 SPI 主机驱动在 IDF 5.x 系列里改过接口,spi_master那套老 API 在 5.x 里被标记为兼容层,新代码走的是esp_lcd的 panel IO 接口。IDF 5.2.2 属于 5.2 分支的补丁版本,esp_lcd的esp_lcd_panel_io_spi_config_t结构体字段和 5.1 相比没有破坏性变动,社区里针对 ST7789 的现成初始化序列大多按这个版本写。装 5.3 或 5.4 也能跑,但esp_lcd内部对 SPI 传输队列的处理有调整,个别老例程会出现刷屏撕裂。我一般建议:新项目锁 5.2.2,已有工程别为了追新去动 IDF 版本,升级 IDF 带来的排查成本远高于它给的那点收益。
安装走官方安装器最省事,Linux 和 macOS 用下面这套:
# 下载 IDF 5.2.2 安装脚本并执行,-b 指定分支标签 mkdir -p ~/esp && cd ~/esp git clone -b v5.2.2 --recursive https://github.com/espressif/esp-idf.git cd esp-idf ./install.sh esp32s3 # 只装 esp32s3 的 toolchain,省磁盘 . ./export.sh # 每次开新终端都要 source 一次-b v5.2.2是关键,不加就拉到 master,版本号对不上后面全是玄学。install.sh esp32s3只装 S3 的工具链,比全量装少几个 G。export.sh必须用.或source执行,直接./export.sh起子 shell,环境变量出不来,idf.py会提示找不到。Windows 下用官方 IDF Installer 选 5.2.2,装完在开始菜单里开 “ESP-IDF 5.2.2 PowerShell”,别用普通 PowerShell,否则 PATH 里没有工具链。
装完先验版本,这一步别跳:
idf.py --version # 期望输出包含 v5.2.22.2 LVGL 版本与组件引入方式
LVGL 在 IDF 里有两种引入法:一是把 lvgl 源码整个拷进components/,二是用 IDF 组件管理器从注册表拉。前者可控,后者省事。我倾向后者,因为版本号写在idf_component.yml里,换机器复现时不会出现“我这能跑你那不能跑”。
在工程根目录建main/idf_component.yml:
dependencies: idf: ">=5.2" lvgl/lvgl: "~8.3.11" # 锁 8.3 小版本,9.x 的 API 变动大~8.3.11表示允许 8.3.x 的补丁升级,但不跨到 8.4 或 9.x。LVGL 9 把lv_disp_drv_t换成了lv_display_t,显示注册那套代码全得重写,网上大量 ST7789 例程还是 8.x 写法,混用必翻车。第一次idf.py build时组件管理器会自动下载 lvgl 到managed_components/,国内网络慢的话在menuconfig里把组件镜像源换成国内镜像,或者手动git clone到components/lvgl并删掉 yml 里那行依赖。
2.3 引脚与硬件参数对照表
接线错是白屏第一大来源,先把这张表填死再动代码。下面是我这块板子的实际接法,你的板子按原理图改,但 SPI 的 MOSI/SCLK 必须落在 S3 的 SPI2 或 SPI3 可用 IO 上:
| 信号 | ESP32S3 引脚 | 说明 |
|---|---|---|
| SCLK | GPIO12 | SPI 时钟,走 SPI2_HOST |
| MOSI | GPIO11 | 数据输出 |
| CS | GPIO10 | 屏片选,低有效 |
| DC | GPIO9 | 命令/数据切换 |
| RST | GPIO8 | 复位,可接 EN 但建议独立 IO |
| BL | GPIO7 | 背光,PWM 可调 |
| TOUCH_CS | GPIO6 | xpt2046 片选 |
| TOUCH_IRQ | GPIO5 | 触摸中断,可轮询不用 |
背光单独给 IO 是为了能调亮度,直接接 3.3V 也能亮,但夜里刺眼。xpt2046 和 ST7789 共用 SPI 总线、各自独立 CS,这是标准做法,别给触摸单独开一条 SPI,S3 的 SPI 外设数量够但没必要浪费。
3. 装 ST7789 驱动:esp_lcd 面板注册与初始化序列
3.1 用 esp_lcd 还是自己写 SPI 时序
IDF 5.2.2 里esp_lcd已经内置了 ST7789 的 panel 驱动,路径在components/esp_lcd/src/esp_lcd_panel_st7789.c,通过CONFIG_LCD_ENABLE_ST7789打开。自己写 SPI 时序也能跑,但要处理命令/数据切换的 DC 电平、复位时序、初始化命令表,工作量不小且容易在某个延时上翻车。常见做法是直接用esp_lcd_new_panel_st7789,把初始化命令表交给驱动,自己只管总线和 IO 配置。
menuconfig里确认一下:
idf.py menuconfig # Component config -> LCD drivers -> 勾选 ST7789不勾这个,链接时会报undefined reference to esp_lcd_new_panel_st7789,这是新手最常见的第一个坑。
3.2 SPI 总线与 panel IO 的配置代码
下面这段是总线初始化,放在app_main最前面:
#include "driver/spi_master.h" #include "esp_lcd_panel_io.h" #include "esp_lcd_panel_vendor.h" #include "esp_lcd_panel_ops.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 LCD_H_RES 240 #define LCD_V_RES 320 spi_bus_config_t bus_cfg = { .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 = LCD_H_RES * 80 * sizeof(uint16_t), // 单次最大传输 }; ESP_ERROR_CHECK(spi_bus_initialize(LCD_HOST, &bus_cfg, SPI_DMA_CH_AUTO));max_transfer_sz决定单次 DMA 能搬多少字节,设小了刷屏会分很多次传,帧率上不去;设大了占内存。按屏宽 × 80 行 × 2 字节算,240×80×2 = 38400 字节,够用且不浪费。SPI_DMA_CH_AUTO让驱动自己挑 DMA 通道,S3 上一般没问题。
接着配 panel IO:
esp_lcd_panel_io_handle_t io_handle = NULL; esp_lcd_panel_io_spi_config_t io_cfg = { .dc_gpio_num = PIN_DC, .cs_gpio_num = PIN_CS, .pclk_hz = 40 * 1000 * 1000, // 40MHz,ST7789 上限约 62.5MHz .lcd_cmd_bits = 8, .lcd_param_bits = 8, .spi_mode = 0, .trans_queue_depth = 10, }; ESP_ERROR_CHECK(esp_lcd_new_panel_io_spi((esp_lcd_spi_bus_handle_t)LCD_HOST, &io_cfg, &io_handle));pclk_hz是取舍点:40MHz 稳定,拉到 60MHz 部分模块会花屏,跟排线长度和屏的批次有关。trans_queue_depth是异步传输队列深度,配合 LVGL 的 flush 回调做非阻塞刷屏,10 是个保守值,内存紧张可以降到 4。spi_mode = 0对应 CPOL=0、CPHA=0,ST7789 基本都吃这个模式,接错模式表现为花屏或全黑。
3.3 面板初始化与方向、反色、镜像
esp_lcd_panel_handle_t panel_handle = NULL; esp_lcd_panel_dev_config_t panel_cfg = { .reset_gpio_num = PIN_RST, .rgb_ele_order = LCD_RGB_ELEMENT_ORDER_RGB, .bits_per_pixel = 16, }; ESP_ERROR_CHECK(esp_lcd_new_panel_st7789(io_handle, &panel_cfg, &panel_handle)); ESP_ERROR_CHECK(esp_lcd_panel_reset(panel_handle)); ESP_ERROR_CHECK(esp_lcd_panel_init(panel_handle)); ESP_ERROR_CHECK(esp_lcd_panel_invert_color(panel_handle, true)); // 多数 ST7789 需要反色 ESP_ERROR_CHECK(esp_lcd_panel_set_gap(panel_handle, 0, 0)); ESP_ERROR_CHECK(esp_lcd_panel_disp_on_off(panel_handle, true));invert_color(true)是血泪经验:ST7789 出厂默认颜色反转,不调这个,白底变黑底、红变青。不同批次模块可能相反,如果反色后颜色还是不对,把它改成false再试。set_gap用于 240×320 屏在 240×240 驱动上的偏移,纯 240×320 屏填 0,0。方向用esp_lcd_panel_swap_xy和esp_lcd_panel_mirror调,横屏就 swap 加 mirror 组合,别去改初始化命令表里的 MADCTL,驱动已经封装好了。
4. 接 xpt2046 触摸与 LVGL 显示注册
4.1 xpt2046 的 SPI 读时序与校准
xpt2046 是电阻屏控制器,走 SPI 读 12 位 ADC 值,命令字节里带通道选择。它和 ST7789 共用总线,但读操作要单独发命令,不能复用 panel IO 的写句柄。常见做法是给触摸单独加一个 SPI 设备:
spi_device_interface_config_t touch_cfg = { .clock_speed_hz = 2 * 1000 * 1000, // xpt2046 上限约 2MHz .mode = 0, .spics_io_num = PIN_TOUCH_CS, .queue_size = 3, }; spi_device_handle_t touch_spi; ESP_ERROR_CHECK(spi_bus_add_device(LCD_HOST, &touch_cfg, &touch_spi)); // 读 X 通道:0xD0,读 Y 通道:0x90,返回值高 12 位有效 uint16_t xpt2046_read(uint8_t cmd) { uint8_t tx[3] = {cmd, 0x00, 0x00}; uint8_t rx[3] = {0}; spi_transaction_t t = { .length = 24, .tx_buffer = tx, .rx_buffer = rx, }; spi_device_transmit(touch_spi, &t); return ((rx[1] << 8) | rx[2]) >> 3; // 取 12 位 }clock_speed_hz必须压到 2MHz 左右,xpt2046 的 SPI 时序比 ST7789 慢得多,用 40MHz 读出来全是 0 或 4095。>> 3是因为返回值是 12 位左对齐在 16 位里,不移位算出来的坐标会跳。原始 ADC 值范围大概 200~3900,需要做两点校准映射到屏幕坐标,校准系数存 NVS,别每次开机重校。
4.2 LVGL 显示缓冲与 flush 回调
LVGL 8.3 的显示注册分三步:建 buffer、填lv_disp_drv_t、注册 flush 回调。buffer 大小决定刷屏方式,全屏 buffer 占 240×320×2 = 150KB,S3 的 PSRAM 够但内部 RAM 紧张,我一般用 1/10 屏:
static lv_disp_draw_buf_t draw_buf; static lv_color_t buf1[LCD_H_RES * 40]; // 40 行缓冲 lv_disp_draw_buf_init(&draw_buf, buf1, NULL, LCD_H_RES * 40); static lv_disp_drv_t disp_drv; lv_disp_drv_init(&disp_drv); disp_drv.hor_res = LCD_H_RES; disp_drv.ver_res = LCD_V_RES; disp_drv.flush_cb = lcd_flush_cb; disp_drv.draw_buf = &draw_buf; lv_disp_drv_register(&disp_drv);flush 回调里把 LVGL 的像素区丢给esp_lcd_panel_draw_bitmap:
static void lcd_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_map) { esp_lcd_panel_draw_bitmap(panel_handle, area->x1, area->y1, area->x2 + 1, area->y2 + 1, color_map); lv_disp_flush_ready(drv); // 同步模式,传完立即通知 }lv_disp_flush_ready必须在传输完成后调用,同步模式下draw_bitmap返回即传完,直接调没问题;如果开了异步传输,要等on_color_trans_done回调里再调,否则 LVGL 会提前复用 buffer,画面撕裂。area->x2 + 1这个加一是 LVGL 坐标含端点、esp_lcd 坐标不含端点的差异,漏了会少刷一列像素,表现为右边一条竖线不更新。
4.3 触摸输入设备注册
触摸读到的坐标要注册成 LVGL 输入设备,read_cb里返回按下状态和坐标:
static void touch_read_cb(lv_indev_drv_t *drv, lv_indev_data_t *data) { uint16_t raw_x = xpt2046_read(0xD0); uint16_t raw_y = xpt2046_read(0x90); bool pressed = (raw_x > 200 && raw_y > 200); // 简单阈值判按下 if (pressed) { style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />