news 2026/11/4 8:15:35

ESP32S3+LVGL驱动ST7789与XPT2046:从零跑通SPI屏触摸

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ESP32S3+LVGL驱动ST7789与XPT2046:从零跑通SPI屏触摸

简介:面向嵌入式开发者与物联网图形界面初学者,这份资源提供 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 回调里读触摸。

我一般按下面这张表分配,具体引脚按自己板子改,但总线归属别乱:

信号归属说明
SCLKSPI2屏幕和触摸共用,频率取 40MHz 给屏、2MHz 给触摸
MOSISPI2共用
MISOSPI2只有触摸用,屏幕不需要
LCD_CSGPIO屏幕片选,低有效
LCD_DCGPIO命令/数据切换,ST7789 必需
LCD_RSTGPIO复位,可接 EN 也可独立
LCD_BLGPIO背光,PWM 调光
TP_CSGPIO触摸片选
TP_IRQGPIO触摸中断,可选轮询

关键点是 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 三层解耦,哪层出问题一眼能定位。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/4 8:14:54

基于LangGraph的自我路径规划与结果验证Agent实践

1. 为什么单步 Agent 一定会在真实任务里翻车我最早做 Agent 的时候也走过弯路&#xff1a;把用户的问题塞进一个足够长的 prompt&#xff0c;让模型自己"想一想再回答"&#xff0c;最多加几个工具调用&#xff0c;就觉得这已经是个智能体了。Demo 阶段确实好看&…

作者头像 李华
网站建设 2026/11/4 8:13:47

Python装库避坑指南:OpenCV、PyTorch、transformers环境配置全攻略

做Python开发这几年&#xff0c;我最大的感触是&#xff1a;写代码的时间经常没有装库的时间多。尤其是刚接触 OpenCV、PyTorch、transformers 这批库的朋友&#xff0c;几乎每天都要跟“ModuleNotFoundError”做斗争。今天想把这些年帮人“载入”各种Python库的经验梳理一遍&a…

作者头像 李华
网站建设 2026/11/4 8:12:47

Hermes Agent:安全检测器的法医级可观测性探针

1. 项目概述&#xff1a;这不是一个“调用API”的简单活儿&#xff0c;而是一次对检测逻辑的深度解剖“利用hermes agent 详细分析hunter检测器”——这个标题乍看像一句技术指令&#xff0c;但真正做过安全检测系统逆向或规则验证的人一眼就明白&#xff1a;它背后藏着三层硬需…

作者头像 李华
网站建设 2026/11/4 8:11:08

数字电源方案:从模拟环路到数字闭环的完整设计路径

简介&#xff1a;面向电源设计工程师、系统架构师及电子相关专业学习者&#xff0c;这份文档围绕数字电源控制器与芯片应用展开&#xff0c;重点解决从模拟电源向数字控制过渡时的概念理解与选型设计问题。内容从数字电源的基本特征与定义切入&#xff0c;梳理其灵活性、供电性…

作者头像 李华
网站建设 2026/11/4 8:11:01

LoRa技术在文物保护环境监测中的核心优势与应用

1. 项目背景与行业痛点 文物保护领域长期面临环境监测的严峻挑战。传统监测手段在古建筑、石窟寺、地下墓葬等特殊场景中往往束手无策——有线部署会破坏文物本体&#xff0c;常规无线方案又难以穿透厚重的墙体结构。某省级博物馆的监测数据显示&#xff0c;其地下文物库房的温…

作者头像 李华
网站建设 2026/11/4 8:10:17

命令行工具创作诗歌:技术人的浪漫实践

1. 当命令行遇上十四行诗&#xff1a;技术人的浪漫主义实践在终端窗口里写诗&#xff1f;这听起来像是文科生和工程师的一次跨界约会。作为一名在Linux系统里摸爬滚打十年的老运维&#xff0c;我从未想过有一天会把grep和押韵词典放在同一个脚本里。但正是这种看似荒谬的组合&a…

作者头像 李华