简介:本资源是一套专为ESP32-S3平台适配LVGL v9图形库的SPI LCD驱动代码集合,面向嵌入式开发工程师、物联网项目开发者及LVGL初学者,解决多型号屏幕在ESP-IDF环境下快速移植与稳定显示的核心痛点。压缩包共13个文件(29KB),涵盖核心驱动源码(lcd_lvgl.c)、硬件抽象层头文件(config.h)、构建配置(CMakeLists.txt、sdkconfig、idf_component.yml)、开发环境支持(.vscode配置)、项目说明(README.md)及分区表(partitions.csv)等关键组件,结构清晰、开箱即用。已有950人学习下载,代码已实测兼容ST7789、ST7735、ST7796、ILI9341和ILI9488五类主流SPI屏,覆盖135×240至480×320等多种分辨率,显著降低LVGL在ESP32-S3上的显示适配门槛,助力用户聚焦UI设计与业务逻辑开发。 ESP32S3 配 LVGL9,再把 SPI 接口的 ST7789、ST7735、ST7796、ILI9341、ILI9488 这些常见屏全跑起来,这事我前后折腾了挺长时间,踩过的坑最后都变成了经验。如果你正在做小尺寸 HMI、桌面摆件、温湿度计、甚至开源手表之类的东西,这篇文章基本能把你从“不知道从哪下手”带到“一次点亮并流畅跑动画”的阶段。
先说结论:ESP32S3 的 SPIn 外设足够强,LVGL9 的接口比 LVGL8 清晰不少,真正会卡人的地方反而不是 LVGL 本身,而是屏幕 IC 的初始化序列、偏移量设置、以及 DMA 传输的同步方式。这篇文章不会只贴一份 ST7789 的代码就完事,我会把常见屏的差异、LVGL9 的 display 驱动结构、SPI 参数选择、以及花屏和偏移的排查方法全部拆开讲。
1. 整体设计思路:为什么这个组合值得写
1.1 ESP32S3 在 GUI 项目里的定位
ESP32S3 跟早期的 ESP32 比,最大的变化是增加了向量指令和 SIMD 支持,虽然主频还是 240MHz,但在跑 LVGL 这种 2D 图形库时,软件渲染的功耗和流畅度都会好一些。关键是这颗芯片的 SPIn 控制器支持 DMA,最大传输数据量可以做到按描述符走完一整块显存数据,不需要 CPU 一点点往外挤。这个是决定你能不能跑“高分屏动画”的核心。
很多人会问,那为什么不用 ESP32-S3 直接接 RGB 并口屏?因为 RGB 屏需要的引脚太多,而且需要 PSRAM 配合帧缓冲,成本也比 SPI 屏高一截。SPI 屏虽然带宽有限,但胜在引脚少(6 根线搞定)、驱动 IC 成熟、资料多,而且 LVGL9 本身对低带宽的优化做得不错,缓存策略合理的话,日常刷新 320x240 的屏没有任何压力。
1.2 为什么选择 LVGL9 而不是继续用 LVGL8
LVGL9 改了不少底层接口,最大的变化是把显示驱动从lv_disp_drv_t重构为lv_display_t,注册方式、buffer 处理、flush 回调的触发逻辑都有调整。网上大量教程还停留在 LVGL8 甚至 LVGL7,直接抄代码在 LVGL9 下面没法编译。这篇文章所有的代码都基于 LVGL9.x,你拿最新的 release 过来可以直接用。
还有一点就是 LVGL9 对 partial refresh(局部刷新)的处理更聪明了,它不再要求每次全屏 flush,而是会计算 dirty area,配合 ESP32S3 的 DMA,实际跑起来帧率比 LVGL8 有明显提升。所以如果你是新项目,别犹豫,直接用 LVGL9。
2. LVGL9 的显示驱动接口变化
2.1 LVGL8 到 LVGL9 哪些旧代码不能抄
LVGL8 时代典型的初始化代码长这样:
static lv_disp_draw_buf_t draw_buf; static lv_color_t buf[240 * 10]; lv_disp_draw_buf_init(&draw_buf, buf, NULL, 240 * 10); static lv_disp_drv_t disp_drv; lv_disp_drv_init(&disp_drv); disp_drv.flush_cb = my_flush_cb; disp_drv.draw_buf = &draw_buf; lv_disp_drv_register(&disp_drv);到了 LVGL9,这段代码里的lv_disp_drv_t已经被拆掉,统一走lv_display_t:
static lv_display_t *disp; disp = lv_display_create(240, 320); lv_display_set_flush_cb(disp, my_flush_cb); lv_display_set_buffers(disp, buf1, buf2, sizeof(buf1), LV_DISPLAY_RENDER_MODE_PARTIAL);注意 LVGL9 里 buffer 是用字节数传入的,不是用像素数。而且有两个 buffer 的话就是双缓冲,LVGL9 会在后台渲染一个 buffer 的同时,把另一个 buffer 通过 flush_cb 送出去。这个机制是流畅度的关键。
2.2 flush_cb 到底该怎么写
LVGL9 的 flush_cb 签名是:
void my_flush_cb(lv_display_t *disp, const lv_area_t *area, uint8_t *px_map);它比 LVGL8 更直接,第三个参数直接给你渲染好的像素数据指针,px_map就是你要通过 SPI 发给屏幕的内容。你需要做的是:
- 把
px_map里的数据切分成 16 位的 RGB565 数据,通过 SPI 发出去; - 发送完成后调用
lv_display_flush_ready(disp)告诉 LVGL 可以继续渲染下一帧。
这里最容易犯错的是:LVGL9 默认的像素格式是 RGB565,但很多屏幕(尤其是国产屏)内部实际是按 BGR 方式排列的,你需要额外配置LV_COLOR_16_SWAP或者自己转换字节顺序,否则颜色会明显偏色。
px_map是像素数据,不是完整的“要发送的 frame buffer”,它只是 dirty area 的那部分。你拿到area结构体后,要自己计算出显存地址偏移,然后把 area 的坐标换算成屏幕的起始 column 和 row,通过 SPI 写命令设置 window,再发送像素数据。这一步是整个驱动里最容易写错的地方。
3. 常见 LCD 驱动 IC 适配记录
3.1 ST7789:现在用得最多的“街屏”
ST7789 是当前 1.3 寸到 2.0 寸小屏里最常见的驱动 IC,分辨率全覆盖:240x240、240x320、320x480 甚至部分圆屏。它支持 RGB565 和 RGB666 两种格式,通常我们走 RGB565。
ST7789 的一个特点是它没有单独的VCOM引脚,初始化序列较短,而且很多模组已经把 madctl(内存访问控制)的翻转电路做在 PCB 上了,所以你拿到屏之后,可能需要在代码里根据实际显示方向调整MADCTL寄存器的值。
初始化序列里有一个坑:ST7789 在 240x320 和 240x240 屏上的 row/column 偏移不一样。大部分 240x240 的模组其实都是 240x320 的玻璃,只是为了做方形显示而已,驱动 IC 内部仍然有 320 行的寻址空间,所以你需要把 row offset 设置成 80。也就是说,当你要刷新第 0 行的时候,实际上要写命令到 row 80 开始的位置。这个偏移量不确定时,可以查模组资料,或者通过反复改 x/y offset 看屏幕显示情况来确定。
3.2 ST7735:小尺寸花屏重灾区
ST7735 常见于 1.8 寸 128x160 和 1.44 寸 128x128,是 ST7789 的“老前辈”。ST7735 的初始化序列更麻烦,因为它有多个版本(A/B/C),不同版本的 gamma 曲线和偏压配置不完全一样。如果初始化不对,最常见的现象是白屏或者显示重影。
ST7735 的另一个坑是它的 MADCTL 默认扫描方向和很多 128x160 模组的实际接线不匹配,你经常需要在代码里反转 RGB 顺序(MADCTL中的 BGR 位)才能得到正确的颜色,如果颜色反了,把MADCTL的 BGR 位从 0 改成 1 或者反过来即可。
ST7735 的偏移量:128x160 屏通常没有偏移问题,但 128x128 方屏需要把 row offset 设成 2,也就是数据区域从第 2 行开始。如果没有设置这个偏移,图形会整体向下偏两行,像一条细线出现在顶部。
3.3 ST7796 和 ILI9341:大屏兼容性
ST7796 和 ILI9341 都常见于 2.4 寸和 2.8 寸的 320x240 屏,这两颗 IC 的初始化序列有很多相似之处,网上也经常有人用同一套初始化序列去兼容两种情况,但严格来说还是不一样。
ILI9341 是最经典的 SPI LCD 驱动 IC,几乎所有单片机教程都会提到它。它的初始化序列很长,但兼容性很好,如果你不确定自己手里的屏是哪颗 IC,先用 ILI9341 的序列试着点亮,成功的概率很高。
ST7796 跟 ILI9341 默认的 gamma 不同,ST7796 的色彩更鲜艳一些,但如果你把 ILI9341 的 gamma 设置到了 ST7796 上,屏幕可能会发白或者发灰。我实测过,如果你的屏是 ST7796 而用了 ILI9341 的初始化序列,部分屏能显示,但对比度明显差一截,建议还是找对应 IC 的初始化序列来跑。
3.4 ILI9488:RGB666 的坑
ILI9488 常见于 3.5 寸 480x320 屏,有一个大坑:它默认是 RGB666 格式,不是 RGB565。如果你直接把 LVGL 的 RGB565 buffer 发给它,显示颜色会明显不对,几乎是扭曲的。
解决办法有两种:
- 初始化序列里切换到 RGB565 模式(如果 IC 支持),具体操作是在
COLMOD(3Ah 命令)里设置 16 位色深。 - 如果你的屏模块不支持 RGB565 模式,就得在 flush_cb 里把 RGB565 转换成 RGB666。但这样不仅费 CPU,还会降低帧率。所以我一般不建议在 ESP32S3 上用 ILI9488,除非你确实需要大屏且没有替代方案。
下面是这几颗 IC 的直观对比表,方便你快速定位自己手上的屏。
| IC 型号 | 常见分辨率 | 色深支持 | 常见偏移问题 | 初始化复杂度 | 实测推荐度 |
|---|---|---|---|---|---|
| ST7789 | 240x240 / 240x320 | RGB565 / RGB666 | 240x240 需 row offset 80 | 低 | 高 |
| ST7735 | 128x160 / 128x128 | RGB565 | 128x128 需 row offset 2 | 中 | 中 |
| ST7796 | 320x240 | RGB565 | 基本无 | 低 | 高 |
| ILI9341 | 320x240 | RGB565 | 基本无 | 中 | 高 |
| ILI9488 | 480x320 | RGB666 默认 | 色深转换麻烦 | 高 | 低 |
4. 实操:完整驱动配置与初始化
4.1 引脚分配与接线
ESP32S3 的 SPIn 外设很灵活,GPIO 矩阵允许你几乎把 SPI 信号映射到任意引脚。但为了 DMA 性能和信号稳定,我个人建议把 SCLK、MOSI 固定到一组相邻引脚上,MISO 不用接(LCD 输出我们一般不读)。
我常用的引脚分配是这样:
| 信号 | GPIO | 说明 |
|---|---|---|
| SCLK | GPIO12 | SPI 时钟 |
| MOSI | GPIO11 | SPI 数据输出 |
| CS | GPIO10 | 片选 |
| DC | GPIO9 | 数据/命令选择 |
| RST | GPIO8 | 复位 |
| BL | GPIO45 | 背光控制 |
每个人手里的板子不一样,引脚完全可以根据自己的布局换。注意 DC 引脚很关键,LCD 通过 DC 来区分命令和数据,如果你的 DC 接错,屏幕能亮但显示内容会是一堆乱码。RST 可以用 GPIO 也可以用 ESP32S3 的上电自动复位电路,但手动控制更可靠。
SPI LCD 常用 7 根线(包含 GND 和 VCC),有些模组是 6 针,省掉了 RST 或 BL,这就意味着 RST 要靠寄存器软复位、BL 要一直拉高,在代码里要注意别去操作不存在的引脚。
4.2 SPI 初始化配置
使用 ESP-IDF 的spi_bus_initialize来初始化 SPI 主机,ESP32S3 有 SPI2 和 SPI3 两个可用主机,LCD 接到 SPI2 比较常见:
spi_bus_config_t bus_cfg = { .sclk_io_num = 12, .mosi_io_num = 11, .miso_io_num = -1, .quadwp_io_num = -1, .quadhd_io_num = -1, .max_transfer_sz = 240 * 320 * 2 + 8, }; spi_bus_initialize(SPI2_HOST, &bus_cfg, SPI_DMA_CH_AUTO);重点在max_transfer_sz,这个值决定了 DMA 能一次传输的最大字节数。如果你要直接传输整个 320x240 的 RGB565 frame(153600 字节),这里必须留够空间。我一般会设成屏幕宽 * 屏幕高 * 2 + 8,留一点余量。
SPI_DMA_CH_AUTO让驱动自动分配 DMA 通道,ESP32S3 上可用 DMA 通道比老 ESP32 多,这个建议别手动指定,让系统自动管理。
4.3 LCD 初始化序列与偏移量设置
先把 GPIO 和 SPI 总线准备好,然后对 LCD 进行复位和初始化。下面是一份标准 ST7789 初始化序列的核心部分,适用于 240x240 或 240x320 屏:
// 复位 gpio_set_level(RST_PIN, 0); vTaskDelay(pdMS_TO_TICKS(20)); gpio_set_level(RST_PIN, 1); vTaskDelay(pdMS_TO_TICKS(120)); // 初始化命令 lcd_cmd(0x01); // SWRESET vTaskDelay(pdMS_TO_TICKS(20)); lcd_cmd(0x11); // SLPOUT vTaskDelay(pdMS_TO_TICKS(120)); lcd_cmd(0x3A); // COLMOD lcd_data(0x05); // 16-bit color // 设置偏移 uint16_t x_offset = 0; uint16_t y_offset = 80; // 240x240 屏常见偏移 lcd_cmd(0x2A); // CASET lcd_data(x_offset >> 8); lcd_data(x_offset & 0xFF); lcd_data((x_offset + width - 1) >> 8); lcd_data((x_offset + width - 1) & 0xFF); lcd_cmd(0x2B); // RASET lcd_data(y_offset >> 8); lcd_data(y_offset & 0xFF); lcd_data((y_offset + height - 1) >> 8); lcd_data((y_offset + height - 1) & 0xFF); lcd_cmd(0x21); // INVON lcd_cmd(0x11); vTaskDelay(pdMS_TO_TICKS(20)); lcd_cmd(0x29); // DISPON这里最重要的就是CASET和RASET,也就是 column address set 和 row address set。LVGL 告诉你需要刷新窗口的左上角和右下角坐标,你要把这两个坐标加上偏移量后换算成屏幕内部显存地址。如果偏移量设置有误,显示的内容会整体偏移,轻则边缘出现黑边,重则内容错位或被截断。
4.4 LVGL9 display 注册代码
初始化完 LCD 之后,把 display 注册到 LVGL9 里:
static lv_color_t buf1[240 * 10]; static lv_color_t buf2[240 * 10]; lv_display_t *disp = lv_display_create(240, 320); lv_display_set_flush_cb(disp, lcd_flush_cb); lv_display_set_buffers(disp, buf1, buf2, sizeof(buf1), LV_DISPLAY_RENDER_MODE_PARTIAL);注意两个 buffer 各是240*10像素,换算成 RGB565 就是 4800 字节。这个 buffer 大小是我实测下来性能和内存占用的平衡点,太小会导致 LVGL 频繁刷新,太大又浪费内存。如果你开了 PSRAM 且不在意内存,可以把 buffer 扩大到一行像素的 2~4 倍,帧率会更好。
flush 回调里,需要根据 LVGL 传入的 area 来控制 LCD 的 window。假设我们的屏是 240x320,LVGL 的坐标原点在左上角,物理屏的坐标原点也在左上角:
void lcd_flush_cb(lv_display_t *disp, const lv_area_t *area, uint8_t *px_map) { uint16_t x1 = area->x1; uint16_t x2 = area->x2; uint16_t y1 = area->y1; uint16_t y2 = area->y2; uint16_t w = x2 - x1 + 1; uint16_t h = y2 - y1 + 1; // 设置 window lcd_cmd(0x2A); // CASET lcd_data((x1 + x_offset) >> 8); lcd_data((x1 + x_offset) & 0xFF); lcd_data((x2 + x_offset) >> 8); lcd_data((x2 + x_offset) & 0xFF); lcd_cmd(0x2B); // RASET lcd_data((y1 + y_offset) >> 8); lcd_data((y1 + y_offset) & 0xFF); lcd_data((y2 + y_offset) >> 8); lcd_data((y2 + y_offset) & 0xFF); lcd_cmd(0x2C); // RAMWR // 发送像素数据 spi_transaction_t t = { .length = w * h * 2 * 8, .tx_buffer = px_map, }; spi_device_polling_transmit(spi_dev, &t); lv_display_flush_ready(disp); }spi_device_polling_transmit是阻塞式发送,简单但会浪费 CPU。更好的方式是使用spi_device_queue_trans配合传输完成中断,在中断回调里调用lv_display_flush_ready。不过这套异步逻辑对初学者不太友好,我建议先把阻塞式跑通,再改成 DMA + 中断。
5. 性能优化:DMA、双缓冲、帧率调整
5.1 SPI DMA 为什么是刚需
如果不使用 DMA,每次发送像素数据都要 CPU 把数据搬到 SPI 外设的 FIFO,320x240 一帧 153600 字节,按每字节 8 个 SPI 时钟算,纯 SPI 传输就需要约 240 万次外设写入操作。即使 SPI 跑到 80MHz,CPU 也会被 SPI 中断占满,LVGL 的渲染时间被严重挤压。
ESP32S3 的 SPI 主机支持 DMA 方式发送,你需要用spi_device_queue_trans接口,或者在初始化 SPI 设备时把spi_device_interface_config_t里的.flags设置上SPI_DEVICE_HALFDUPLEX和SPI_DEVICE_NO_DUMMY。发送大块 buffer 时,驱动会自动拆分成 DMA 描述符,传输过程中 CPU 可以继续做下一帧渲染。
我用 80MHz SPI 时钟 + DMA 的方式跑 240x320 的 ST7789,LVGL9 的动画帧率能到 50fps 左右(简单场景),日常 UI 操作也不卡。如果 SPI 时钟降为 40MHz,帧率会掉到一半,感官上就明显“肉”了。
5.2 双缓冲与 LVGL9 的渲染模式
LVGL9 的双缓冲机制比 LVGL8 要顺畅,原因在于它的渲染任务和 flush 任务可以并行。你在lv_display_set_buffers里传入两个 buffer 后,LVGL 会在 buffer 1 上渲染,渲染完成触发 flush_cb,然后立刻开始在 buffer 2 上渲染下一帧,DMA 传输 buffer 1 的同时,CPU 在渲染 buffer 2,等于把传输时间和渲染时间重叠起来了。
如果你的内存紧张,可以退化成单缓冲,但代价是每次渲染完必须等 SPI 发送完,GPU 和 IO 完全串行,流畅度会明显下降。所以我建议哪怕是牺牲一点 buffer 大小,也要保留双缓冲。
5.3 SPI 时钟频率与传输优化
ESP32S3 的 SPI 时钟在驱动 LCD 时通常可以跑到 60~80MHz,但不是所有屏都能稳定跑 80MHz。如果你的屏模组走线很长,或者使用了杜邦线连接,跑 80MHz 大概率会出现随机花屏或条纹。这时候不要怀疑代码,先把 SPI 时钟降到 40MHz 试试,90% 的“花屏”都能好。
另外,SPI 设备初始化时有一个clock_source参数。ESP32S3 上可以选择SPI_CLK_SRC_DEFAULT或SPI_CLK_SRC_XTAL。我实测发现用外部晶振作为 SPI 时钟源在高温环境下更稳定。不过这个差异很小,一般不需要专门改。
6. 常见问题与排查技巧实录
6.1 花屏 / 显示错位
花屏是一大类问题,我把它拆成几个具体现象:
| 现象 | 大概率原因 | 排查方法 |
|---|---|---|
| 全屏噪点 / 雪花 | SPI 时序不稳或频率太高 | 降低 SPI 时钟到 20MHz 测试 |
| 显示内容偏移 80 行 | ST7789 240x240 屏 offset 未设置 | 加 row offset 80 |
| 局部区域内容错位 | window 设置不正确 | 检查 CASET/RASET 是否使用了正确的 x_offset / y_offset |
| 顶部有一条彩色线 | 偏移量少了 1~2 像素 | 调整 row/column offset 的细微值 |
| 刷新时撕裂 | 单缓冲 + 高速刷新 | 改用双缓冲 |
花屏排查有一个通用技巧:先用纯色填充命令来测试 LCD 本身是否正常。如果你调用lcd_cmd(0x2C)后直接发送 2 字节的红色像素数据,屏幕能正常显示红色,说明 LCD 和 SPI 底层没问题,问题出在 LVGL 的坐标或 buffer 管理上。
6.2 颜色不对
颜色不对的原因主要有两个:
RGB 顺序不对:如果你的红色显示成蓝色、绿色显示成紫色,多半是 RGB 与 BGR 顺序反了。在初始化序列里尝试把 MADCTL(36h)中的 BGR 位翻转,或者设置
LV_COLOR_16_SWAP 1,取决于你的屏和 ESP32S3 的字节序。RGB565 与 RGB666 混用:ST7789 默认 RGB565 没问题,但 ILI9488 初始状态往往是 RGB666,如果不显式切换,颜色会整体发灰,需要手动设置 COLMOD 寄存器。
另外,ESP32S3 是小端 CPU,当你把 RGB565 颜色存入内存时,字节序是反的。LVGL9 默认会处理这个,但如果你直接输出了裸 buffer,可能就要手动 swap 一下高低字节。这个看具体编译选项,不需要死记,先跑起来再根据现象调。
6.3 亮度、闪烁
背光控制建议用 PWM 引脚,不要直接 GPIO 拉高拉低。一级线性调光时,ESP32S3 的 LEDC 外设就能胜任。接线时把 BL 接到 LEDC 输出引脚即可。
闪烁问题则大概率不是背光的问题,而是 LVGL 的刷新频率过低或 buffer 太小。比如 240x320 的屏只给了 240x4 的 buffer,LVGL 就得把屏幕切成几十个局部区域刷新,人眼看起来就像一直在闪。这时候加大 buffer、关闭 LVGL 的LV_DISPLAY_RENDER_MODE_DRAW_UNIT或者调整刷新周期,都会有效果。
6.4 初始化不成功,白屏
白屏通常说明 LCD 已经上电、背光也亮了,但没有收到有效的初始化序列或命令参数不对。先检查 RST 和 DC 引脚是否连接正确,然后用逻辑分析仪抓 SPI 信号,确认 MOSI 上有没有数据、SCLK 有没有时钟。
如果手头没有逻辑分析仪,就写一个最简单的循环:反复发送0x2C命令和全红像素数据。如果屏幕有反应但颜色不对,说明 SPI 通信正常,只是初始化序列或者 RGB 配置有问题。这个循环排查法能快速把问题从“通信”和“时序配置”里分离出来。
7. 我的实际配置参考与最后提醒
最后给一套我用着比较顺手的模板配置,你可以直接照着抄一部分。屏幕是 1.86 寸 240x320 ST7789,SPI 时钟 80MHz,LVGL9.1,ESP-IDF 5.2:
static spi_device_interface_config_t dev_cfg = { .clock_speed_hz = SPI_MAIN_FREQ, // 80MHz .mode = 0, .spics_io_num = PIN_CS, .queue_size = 7, .flags = SPI_DEVICE_HALFDUPLEX, .pre_cb = NULL, };static lv_display_t *disp = lv_display_create(240, 320); lv_display_set_flush_cb(disp, lcd_flush_cb); lv_display_set_buffers(disp, buf1, buf2, sizeof(buf1), LV_DISPLAY_RENDER_MODE_PARTIAL); lv_display_set_rotation(disp, LV_DISPLAY_ROTATION_0);其他屏的适配其实就是替换初始化序列和调整 x_offset / y_offset,flush_cb 里的逻辑完全通用。你只需要知道手里屏的 IC 型号和玻璃分辨率,就能把整套驱动迁移过去。
我个人在实际操作中的体会是:SPI LCD 驱动的难点从来不在 LVGL 本身,而在于你对“屏幕内部显存寻址”的理解。只要把 CASET / RASET 偏移量、RGB 顺序、以及 SPI 传输的同步方式弄明白,SPI LCD 就是一个数据搬运工的角色。说白了你是在用一根 SPI 线往屏幕显存里不断的写,LVGL9 负责告诉你“该往哪里写、写什么颜色”,你负责用最省 CPU 的方式把数据送出去。剩下的就是调参和磨代码的过程了。
本文还有配套的精品资源,点击获取