简介:面向嵌入式开发者与物联网图形界面初学者,这份资源提供 ESP32S3 平台下基于 IDF 5.2.2 与 LVGL 的 LCD 屏幕驱动示例,重点解决 ST7789 显示驱动与 xpt2046 电阻触摸接入的完整工程搭建问题,可作为带屏物联网设备原型开发的参考起点。压缩包共 10 个文件,约 36KB,以 c 源文件与 h 头文件承载主程序与驱动逻辑,配合 sdkconfig、sdkconfig.defaults 等配置项及 yml、lock 依赖清单,另有 txt 说明与 CMakeLists 构建脚本,目录结构清晰,便于按模块阅读与移植。已有 761 人学习下载,说明该组合方案在社区中具备一定参考价值。读者可从中获得 LVGL 与 ST7789、xpt2046 协同工作的工程组织方式,理解显示初始化、触摸坐标读取与界面刷新的衔接思路,并借助现成配置快速验证屏幕显示与触摸响应效果,减少从零搭建环境的试错成本。
1. 一块 2.8 寸屏点亮之前:这套 ESP32S3 + LVGL 组合到底值不值得拆
手上有一块 2.8 寸 SPI 屏,驱动是 ST7789,触摸是 XPT2046,主控换成 ESP32S3,框架用 IDF 5.2.2,UI 层上 LVGL——这套组合在物联网面板、小型 HMI、桌面信息屏里出现频率极高,但真正从零跑通的人不多。原因不在单个模块难,而在四层东西叠在一起:SPI 总线要同时喂屏幕和触摸、LVGL 的显示缓冲要跟 DMA 对齐、IDF 5.2.2 的驱动接口跟老版本有出入、触摸校准数据还得自己存。这份 Demo 的价值就在于把这四层一次性串起来,给出一份能编译、能显示、能触摸的基线工程。适合已经会用 ESP-IDF 建工程、但卡在 LVGL 移植或 SPI 屏花屏阶段的从业者,也适合想快速验证硬件是否正常的硬件同学。下面按「先立原理、再动手、最后排坑」的顺序拆,参数和代码都能直接抄。
2. 硬件连线与 SPI 总线分配:ST7789 和 XPT2046 怎么共用一组引脚
2.1 先定引脚,再谈驱动
ESP32S3 的 SPI 外设分 SPI1(主机)和 SPI2/SPI3,LVGL 刷屏走 SPI2 的 DMA 通道最稳,因为屏幕刷新是持续大块传输,触摸读取是低频小包,两者放同一条总线会互相打断。常见做法是屏幕和触摸共用 SCK/MOSI/MISO,但各自独立 CS,靠 CS 切换设备。这样省引脚,代价是触摸读取期间屏幕不能刷,所以触摸采样频率要压到 30Hz 以内,别在 LVGL 的 flush 回调里读触摸。
我一般按下面这张表分配,具体引脚按自己板子改,但总线归属别乱:
| 信号 | 归属 | 说明 |
|---|---|---|
| SCLK | SPI2 | 屏幕和触摸共用,频率取 40MHz 给屏、2MHz 给触摸 |
| MOSI | SPI2 | 共用 |
| MISO | SPI2 | 只有触摸用,屏幕不需要 |
| LCD_CS | GPIO | 屏幕片选,低有效 |
| LCD_DC | GPIO | 命令/数据切换,ST7789 必需 |
| LCD_RST | GPIO | 复位,可接 EN 也可独立 |
| LCD_BL | GPIO | 背光,PWM 调光 |
| TP_CS | GPIO | 触摸片选 |
| TP_IRQ | GPIO | 触摸中断,可选轮询 |
关键点是 SCLK 频率不能一刀切。ST7789 在 40MHz 下多数模块能跑,但排线长、杜邦线接的板子容易花屏,降到 26MHz 就稳;XPT2046 的 SPI 时钟上限一般 2MHz 左右,超了读出来的坐标会跳。所以工程里要建两个spi_device_handle_t,挂在同一个spi_bus_handle_t上,各自配clock_speed_hz。
// 总线初始化:只初始化一次,屏幕和触摸共用 spi_bus_config_t bus_cfg = { .mosi_io_num = PIN_MOSI, .miso_io_num = PIN_MISO, .sclk_io_num = PIN_SCLK, .quadwp_io_num = -1, .quadhd_io_num = -1, .max_transfer_sz = 320 * 240 * 2, // 单次最大传输,按屏分辨率给 }; ESP_ERROR_CHECK(spi_bus_initialize(SPI2_HOST, &bus_cfg, SPI_DMA_CH_AUTO)); // 屏幕设备:40MHz,8 位命令 spi_device_interface_config_t lcd_cfg = { .clock_speed_hz = 40 * 1000 * 1000, .mode = 0, .spics_io_num = PIN_LCD_CS, .queue_size = 7, }; spi_bus_add_device(SPI2_HOST, &lcd_cfg, &lcd_spi); // 触摸设备:2MHz,模式 0 spi_device_interface_config_t tp_cfg = { .clock_speed_hz = 2 * 1000 * 1000, .mode = 0, .spics_io_num = PIN_TP_CS, .queue_size = 3, }; spi_bus_add_device(SPI2_HOST, &tp_cfg, &tp_spi);max_transfer_sz必须大于等于一帧的字节数,否则 DMA 传输会被截断,表现是屏幕下半部分不刷新或者整屏错位。queue_size给 7 是给 LVGL 的 flush 留余量,太小会在高频刷新时阻塞。两个设备共用总线时,spi_device_transmit内部会做总线仲裁,不用自己加锁,但别在中断里做长传输。
2.2 ST7789 初始化序列不能照抄
ST7789 的初始化命令序列网上版本很多,差异主要在MADCTL(0x36)和COLMOD(0x3A)。MADCTL 决定扫描方向和 RGB 顺序,写错就是颜色反了或者画面镜像;COLMOD 设成 0x55 表示 16 位色,跟 LVGL 的LV_COLOR_DEPTH 16对齐。我一般把初始化序列写成数组,逐条发,发完再发0x11退出睡眠、延时 120ms、发0x29开显示,这个延时不能省,省了就是白屏。
typedef struct { uint8_t cmd; uint8_t data[4]; uint8_t len; uint16_t delay; } lcd_init_cmd_t; static const lcd_init_cmd_t st7789_init[] = { {0x36, {0x00}, 1, 0}, // MADCTL:按实际方向调 0x00/0x60/0xC0 {0x3A, {0x55}, 1, 0}, // 16 位色 {0xB2, {0x0C,0x0C,0x00,0x33,0x33}, 5, 0}, {0x11, {0}, 0, 120}, // 退出睡眠,必须延时 {0x29, {0}, 0, 0}, // 开显示 };发命令时 DC 拉低发 cmd,拉高发 data,这是 ST7789 的硬性时序,DC 接错就是全屏噪点。初始化完先填一个纯色,确认硬件通路没问题,再上 LVGL,不然花屏时分不清是驱动还是 UI 层的问题。
3. LVGL 移植到 IDF 5.2.2:显示缓冲、tick 和 flush 回调三件事
3.1 缓冲区别抠门,双缓冲加 DMA 才不撕裂
LVGL 的显示驱动核心就一个flush_cb,LVGL 把渲染好的区域交给你,你负责搬到屏幕上。缓冲区大小直接决定刷新体验:单缓冲小 buffer 会频繁局部刷新,肉眼可见撕裂;双缓冲各给屏幕 1/10 大小,配合 DMA,能明显顺滑。ESP32S3 有 512KB SRAM,拿两块 240×40×2 字节的缓冲(约 38KB)完全够。
#define BUF_LINES 40 static lv_disp_draw_buf_t draw_buf; static lv_color_t buf1[240 * BUF_LINES]; static lv_color_t buf2[240 * BUF_LINES]; void lvgl_port_init(void) { lv_init(); lv_disp_draw_buf_init(&draw_buf, buf1, buf2, 240 * BUF_LINES); static lv_disp_drv_t disp_drv; lv_disp_drv_init(&disp_drv); disp_drv.hor_res = 240; disp_drv.ver_res = 320; disp_drv.flush_cb = lcd_flush_cb; disp_drv.draw_buf = &draw_buf; lv_disp_drv_register(&disp_drv); }lv_disp_draw_buf_init的第三个参数是单块缓冲的像素数,不是总字节数,这里容易填错导致越界。hor_res/ver_res要跟 ST7789 实际分辨率一致,竖屏 240×320 就按这个填,横屏靠 MADCTL 转,别在 LVGL 里改分辨率。
3.2 flush 回调里只做搬运,别做逻辑
flush_cb被 LVGL 在渲染线程里调用,里面只该做「设窗口 + 发像素 + 通知 LVGL 完成」。设窗口就是发0x2A(列地址)和0x2B(行地址),然后0x2C写显存。像素数据用spi_device_transmit发,发完调lv_disp_flush_ready。
static void lcd_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { uint16_t x1 = area->x1, y1 = area->y1, x2 = area->x2, y2 = area->y2; lcd_set_window(x1, y1, x2, y2); // 发 0x2A/0x2B/0x2C size_t len = (x2 - x1 + 1) * (y2 - y1 + 1) * 2; lcd_send_data((uint8_t *)color_p, len); // DMA 传输 lv_disp_flush_ready(drv); // 必须调,否则 LVGL 卡死 }lv_disp_flush_ready漏调是新手最常见的翻车点,现象是界面只刷新第一帧然后卡住不动。另外color_p在双缓冲下会被 LVGL 复用,传输必须同步完成再返回,用 DMA 的话要等spi_device_transmit返回,别用spi_device_queue_trans异步发完就 ready,那样会花屏。
3.3 tick 用 esp_timer,别用 FreeRTOS 延时
LVGL 需要 1ms 的 tick 来驱动动画和超时。IDF 里最稳的是esp_timer周期回调里调lv_tick_inc(1),然后在主任务里lv_timer_handler()循环。别用vTaskDelay去凑 tick,任务调度抖动会让动画一顿一顿。
static void tick_cb(void *arg) { lv_tick_inc(1); } void lvgl_tick_start(void) { const esp_timer_create_args_t args = { .callback = tick_cb, .name = "lv_tick" }; esp_timer_handle_t t; esp_timer_create(&args, &t); esp_timer_start_periodic(t, 1000); // 1ms } // 主循环任务 void ui_task(void *arg) { while (1) { lv_timer_handler(); vTaskDelay(pdMS_TO_TICKS(5)); } }lv_timer_handler的调用间隔建议 5ms 左右,太密浪费 CPU,太疏动画掉帧。esp_timer回调运行在中断上下文,里面只能调lv_tick_inc这种极轻量的函数,别塞业务逻辑。
4. XPT2046 触摸接入:校准、滤波和 LVGL 输入设备注册
4.1 读原始值只是第一步,校准才是关键
XPT2046 输出的是 12 位 ADC 原始值,范围大致 0~4095,跟屏幕像素不是线性对应,必须做两点校准。常见做法是开机时在屏幕四角画十字,让用户依次点,记录原始坐标,解出ax+b的系数。Demo 里如果省了校准,触摸位置会整体偏移,越往边角越离谱。
// 读一次 X/Y 原始值(简化版,实际要发控制字) uint16_t tp_read_raw(uint8_t cmd) { uint8_t tx[3] = {cmd, 0, 0}, rx[3]; spi_transaction_t t = { .length = 24, .tx_buffer = tx, .rx_buffer = rx }; spi_device_transmit(tp_spi, &t); return ((rx[1] << 8) | rx[2]) >> 3; // 12 位有效 }控制字里0x90读 Y、0xD0读 X,具体位定义看芯片手册。读之前要拉低 TP_CS,读完拉高,中间别插别的 SPI 传输。原始值抖动大,我一般连续读 5 次取中位数,再去掉最大最小各一个求平均,比单次读稳得多。
4.2 注册成 LVGL 输入设备
校准系数算出来后,把原始值映射成屏幕坐标,再通过lv_indev_drv_t注册进去。LVGL 的输入设备回调返回false表示没按下,true表示按下并带坐标。
static bool tp_read_cb(lv_indev_drv_t *drv, lv_indev_data_t *data) { int16_t x, y; bool pressed = tp_get_coord(&x, &y); // 内部做校准映射 >typedef struct { float ax, bx, ay, by; } tp_cal_t; void tp_cal_save(const tp_cal_t *cal) { nvs_handle_t h; nvs_open("tp_cal", NVS_READWRITE, &h); nvs_set_blob(h, "coef", cal, sizeof(*cal)); nvs_commit(h); nvs_close(h); }nvs_set_blob的 size 要跟结构体严格一致,改结构体字段后旧数据会读失败,得升版本或换键名。校准流程建议做成一个独立页面,点完四点自动算系数并保存,别让用户手动输数字。
5. 避坑排查:花屏、触摸跳点、编译报错各踩一遍
5.1 现象:屏幕花屏、颜色错乱、下半屏不刷新
原因通常是三个:SPI 时钟过高导致时序余量不足;max_transfer_sz小于单帧字节数导致 DMA 截断;MADCTL 的 RGB 顺序跟屏体不一致。解决顺序是先降到 26MHz 看是否恢复,恢复就是时钟问题;再把max_transfer_sz调到320*240*2;最后改 MADCTL 的 0x08 位翻转 RGB/BGR。我遇到过排线用杜邦线接的板子,40MHz 必花,焊上排线座就稳,这种硬件问题只能靠降频绕。
5.2 现象:触摸坐标整体偏移,边角点不中
原因是没做校准或校准系数用了别的屏的。XPT2046 的原始值范围跟触摸面板的电阻层直接相关,换屏必须重校。解决是强制走一遍四点校准并存 NVS,同时在tp_get_coord里加 clamp。还有一种隐蔽情况:读触摸时 SPI 模式设成了 mode 3,XPT2046 要 mode 0,模式错了读出来的值看着像坐标其实是噪声。
5.3 现象:编译报lv_disp_drv_t未定义或 flush 回调签名不匹配
IDF 5.2.2 配 LVGL 8.x 和 9.x 的 API 差异很大。8.x 用lv_disp_drv_t,9.x 改成lv_display_t,flush 回调参数也变了。报这个错先看lvgl.h里的LVGL_VERSION_MAJOR,8 和 9 的移植代码不能混用。解决是锁定一个版本,idf_component.yml里写死lvgl/lvgl: "~8.3.0",别用latest,否则今天能编明天就挂。
5.4 现象:界面卡在第一帧,触摸也没反应
原因是lv_disp_flush_ready没调,或者lv_timer_handler没在主循环里跑。前者让 LVGL 以为上一帧还没画完,后者让 LVGL 的定时器永远不触发。排查时在 flush 回调和主循环各打一条日志,看哪个没输出。还有一种情况是ui_task的栈给小了,lv_timer_handler内部递归深,栈溢出直接重启,日志里能看到***ERROR*** A stack overflow。
5.5 现象:背光一亮就重启,或屏幕闪一下黑
原因是背光 GPIO 和某个启动 strapping 引脚冲突,或者背光 PWM 占空比跳变太猛导致电源跌落。解决是换一个非 strapping 的 GPIO 做背光,PWM 从 0 缓慢升到目标值,加 100ms 渐变。用 USB 供电的板子尤其明显,屏背光瞬间电流能到 100mA 以上,电源余量不够就复位。
6. 进阶:把刷新率和触摸响应压到可用的边界
跑通之后真正影响体验的是两个数:帧率和触摸延迟。LVGL 默认配置偏保守,LV_DISP_DEF_REFR_PERIOD是 30ms,意味着理论最高 33fps,实际受 SPI 带宽限制。240×320×2 字节一帧约 150KB,40MHz SPI 理论 5MB/s,算下来一帧 30ms 左右,所以全屏刷新很难超过 30fps,这是物理上限,别指望调参数突破。能优化的是局部刷新:LVGL 只重绘脏区域,把disp_drv.full_refresh保持 0,让脏矩形机制生效,小控件更新时只传几百字节,体感就顺了。
触摸延迟主要来自采样周期。轮询方式下read_cb被 LVGL 按刷新周期调用,30ms 一次,点按响应会有明显滞后。改成 IRQ 触发、在中断里置标志、read_cb里读标志并立即读坐标,能把延迟压到 10ms 以内。但中断里不能做 SPI 传输,只能置标志,传输放回任务上下文。
static volatile bool tp_irq_flag = false; static void IRAM_ATTR tp_isr(void *arg) { tp_irq_flag = true; } // 初始化时挂中断 gpio_config_t io = { .pin_bit_mask = 1ULL << PIN_TP_IRQ, .mode = GPIO_MODE_INPUT, .intr_type = GPIO_INTR_NEGEDGE }; gpio_config(&io); gpio_install_isr_service(0); gpio_isr_handler_add(PIN_TP_IRQ, tp_isr, NULL);IRAM_ATTR不能省,ISR 放 flash 里在 cache miss 时会崩。gpio_install_isr_service全局只能调一次,多个中断源共用。改完之后用逻辑分析仪量 TP_IRQ 下降沿到 SPI 读完成的时间,正常在 2ms 内,超过 5ms 说明任务优先级被别的任务压住了,把 UI 任务优先级提到 5 以上。
还有一个容易被忽略的点:LVGL 的lv_conf.h里LV_MEM_SIZE默认 48KB,控件一多就不够,表现是创建对象返回 NULL 或者界面缺块。ESP32S3 内存宽裕,直接给到 128KB,用LV_MEM_CUSTOM 0让 LVGL 自己管。改完记得idf.py fullclean再编,lv_conf.h的改动有时候不触发重编,玄学问题多半出在这。
从那以后我每次移植 LVGL,都强制先只跑一个纯色填充加一个按钮,确认 flush 和触摸两条链路各自独立通了,再往上堆界面。这套 Demo 的基线工程就是按这个顺序搭的,屏幕、触摸、UI 三层解耦,哪层出问题一眼能定位。希望帮到你。
本文还有配套的精品资源,点击获取