前几天朋友发来一段视频,说是自己用 ESP32-S3 点亮了一块 1.86 寸 SPI 屏幕,正在刷色块和文字,让我看看效果怎么样。视频里颜色过渡顺畅,文字滚动也看不出明显卡顿,看起来确实不错。但我知道,这种“看看刷屏效果”的事情,真正有意思的不是那段画面,而是画面背后那几条决定流畅度的链路:SPI 总线是怎么把像素送进屏幕的,像素数据放在哪里,又是谁在调度每一次刷新。
ESP32-S3 在刷屏这件事上确实有优势,双核 LX7、可扩展 PSRAM、支持高速 SPI,还有不少开发板直接预留了屏幕接口。但如果你把同款屏幕接到两块不同的 ESP32-S3 开发板上,跑同一个 Demo,效果可能完全不同。原因多半不是芯片坏了,而是刷屏配置没有对齐。换句话说,ESP32-S3 的刷屏效果不是“能不能点亮”的问题,而是“总线带宽、内存策略和渲染框架怎么匹配”的问题。
1. 先别急着看效果,想清楚 ESP32-S3 刷屏到底考什么
1.1 为什么很多人点亮屏幕之后,效果差距很大
我见过不少新手拿到 ESP32-S3 和一块 ST7789 驱动的屏幕,第一件事就是找一个例程烧进去。运气好的话,屏幕亮了,颜色也对;运气不好的话,白屏、黑屏、花屏、偏色、闪烁,各种问题都会冒出来。然后就开始怀疑屏幕是坏的,或者开发板有问题。实际上,大多数问题都出在配置上。
先说一个常见事实:STM32、ESP32、Arduino 上驱动小尺寸屏幕,很多都走 SPI 接口,屏幕控制器则是 ST7789、ST7735、ILI9341 这类型号。ESP32-S3 本身没有“屏幕驱动芯片”,它只是通过 SPI 外设把像素数据写进屏幕控制器的显存,由控制器负责最终显示。所以刷屏效果直接取决于三件事:SPI 传输是否稳定、像素数据格式是否正确、刷新策略是否合理。
举个例子,同样的 ST7789 屏幕,有的屏幕模块默认使用 4 线 SPI(CS、DC、SCLK、MOSI),有的把 DC 引脚固定,只留 3 线;有的需要外部复位,有的模块把复位和背光都拉好了。如果你套用一个用户配置里没有对应引脚定义的例程,就会出现“屏幕没反应”或者“第一次能亮,重启后不亮”的现象。这不是 ESP32-S3 的问题,而是刷屏流程里最容易被忽略的硬件适配问题。
1.2 一个核心判断:刷屏效果是总线、内存和渲染框架三者博弈
我给朋友回了一句话:视频里看到的“流畅”,并不代表这个方案已经最优。刷屏效果本质上是一笔关于总线带宽、内存占用和渲染框架的账。
先算一个最简单的账。假设屏幕分辨率是 240x280,使用 RGB565 颜色格式,每个像素占 2 字节。那么整屏一帧的像素数据大约是 240 x 280 x 2 = 134,400 字节,约 128 KB。SPI 在 40MHz 时钟下,理论上每秒最多传 5 MB(这里按 8 位传输估算,实际还要看 SPI 模式和开销)。把一帧 128 KB 数据灌进屏幕控制器,理想传输时间大约是 26 毫秒,换算下来理论帧率约 37 fps。如果 SPI 频率降到 10MHz,理论帧率可能只有 9 fps 左右,肉眼自然能看出卡顿。
但实际帧率还取决于画一帧需要多少计算量。哪怕你用的是 ESP32-S3 这样的双核芯片,如果每帧都用 drawLine 去绘制几百个矩形,CPU 的时间也会被大量消耗,真正留给 SPI 传输的时间反而变少。这时候改用整块内存里的像素数据 pushImage,一次搬运一个区域,速度就会明显改善。所以“刷屏效果”的好坏,不是芯片主频一个指标能代表的。
更关键的是内存策略。ESP32-S3 搭配 PSRAM 后,可以很轻松地放下一整块帧缓冲,甚至双缓冲。但 PSRAM 的访问延迟比内部 SRAM 高,并不是“有 PSRAM 就一定更快”。很多 UI 框架选择小块缓冲加局部刷新,反而能在小尺寸屏幕上达到更好的触摸和动画响应。这些取舍,就是刷屏效果真正的分水岭。
提醒一下:先别急着把 SPI 频率和缓冲区拉到最大。刷屏优化的前提是先跑通一条最小链路,再逐步加码。
2. 从硬件准备到接线:看起来简单,实际决定稳定性的几个细节
2.1 屏幕型号和驱动选型:ST7789 只是一个起点
“看看我的 ESP32-S3 刷屏效果”这种项目,最常见的屏幕是 1.86 寸、1.69 寸这一类小尺寸屏,驱动 IC 通常是 ST7789 或它的变体。比如热词里提到的 ST7789P3,就是 ST7789 的一个具体版本。很多库在配置时只需要选择“ST7789”,但实际使用中,不同厂家的模块在初始化序列、像素偏移、镜像方向、是否内置 DC 引脚上有细微差异。
所以我的建议是:拿到屏幕之后,先确认三件事。
第一,屏幕接口是 4-SPI 还是 3-SPI。4-SPI 有 DC 引脚,用来区分命令和数据;3-SPI 没有 DC,通常用 9 位数据包来区分。驱动库的配置方式完全不同。第二,屏幕默认的 RGB 顺序是什么,颜色格式是 RGB565 还是 BGR565。如果顺序反了,刷色块时会看到红色和蓝色互换。第三,屏幕的宽度和高度实际是多少。很多 1.86 寸屏虽然是 240x280 分辨率,但有些库默认初始化后可能是 240x320,导致显示区域有偏移。
这些信息一般会在屏幕模块的商品页、数据手册或库的驱动文件里标注清楚。如果找不到,就先用一个纯红、纯绿、纯蓝的测试页,观察颜色和边界是否正常。这会比反复看视频里的刷屏效果更有效。
2.2 引脚分配和 SPI 外设选择
ESP32-S3 的 SPI 外设可以映射到很多 GPIO 上,但具体哪个引脚能用、哪个引脚不能,还要看开发板的设计。比如某块 ESP32-S3 开发板把一部分 GPIO 用作了 PSRAM、Flash、USB 或 LED,你再用这些引脚接屏幕,就可能冲突。
常见的 ST7789 SPI 屏接线是:SCLK、MOSI、CS、DC、RST、BLK。有些模块还有 MISO,但 ST7789 通常只写不读,MISO 不一定需要接。下面是一个典型的 TFT_eSPI 用户配置示例,具体引脚编号要以你的开发板丝印为准:
#define TFT_CS 10 #define TFT_DC 11 #define TFT_RST 12 #define TFT_MOSI 13 #define TFT_SCLK 14 #define TFT_BL 15在 TFT_eSPI 库中,你需要修改User_Setup.h,把屏幕驱动设为 ST7789,并填上这些引脚。如果开发板把背光引脚和屏幕电源接在一起,也可以不用 TFT_BL,而直接通过 GPIO 控制背光。重点是不要和系统常用的启动引脚、Flash 引脚冲突。
选择 SPI 外设时,TFT_eSPI 会自动使用默认的 SPI 总线,但 Arduino 环境下有时会用到SPI.begin()初始化。ESP-IDF 环境下则更明确,可以手动配置spi_bus_config_t和spi_device_interface_config_t。对小尺寸单块屏幕来说,使用默认外设通常够用。如果后续还要挂 SD 卡、触摸屏、传感器,就要考虑多个 SPI 设备共用总线的问题,注意每个设备的 CS 引脚要独立。
2.3 背光、复位和电压匹配的坑
刷屏效果不好,除了像素数据问题,还有一个很隐蔽的地方:背光。很多人遇到黑屏,第一反应是屏幕没初始化,但把背光引脚接上后发现其实屏幕已经正常显示,只是背光没亮。反过来,如果背光引脚悬空且模块内部没有上拉,也可能出现屏幕亮一下又灭的情况。所以调试的第一步,是把背光、复位、电源和地线全部接好,再谈刷屏。
复位信号同样关键。部分屏幕模块的复位引脚可以通过 RC 电路自动复位,但如果你使用独立 GPIO 控制复位,最好在初始化序列里先拉低再拉高,给屏幕控制器一个干净的启动时序。否则可能第一次上电初始化失败,刷新时出现花屏。
电压匹配是另一个容易被忽略的问题。ESP32-S3 的逻辑电平是 3.3V,屏幕模块通常也支持 3.3V,但不能因为方便就把 5V 接进 GPIO。很多模块板载了电平转换和稳压,可以直接用 5V 供电,但信号线仍然要 3.3V。另外,杜邦线过长或者接触不良,会让 SPI 时钟和数据的边沿变形,直接导致刷新闪烁或花屏。建议调试时尽量缩短排线距离,并使用共地连接。
3. 跑一个最小刷屏 Demo:把像素画到屏幕上
3.1 开发环境选择:Arduino 和 ESP-IDF 怎么选
如果你是第一次玩 ESP32-S3 刷屏,我建议先用 Arduino 环境加 TFT_eSPI 库。原因很简单:库已经帮你处理了大部分屏幕初始化细节,你只需要填引脚、选驱动,然后调用fillScreen、drawPixel、pushImage这些接口就能看到效果。这对建立“刷屏链路”的直觉非常有帮助。
但如果你是想做产品原型,或者需要精确控制刷新时序、内存占用和电源,ESP-IDF 会更合适。ESP-IDF 里可以用官方 SPI 驱动,也可以集成 LVGL 组件,配置更复杂,但调试手段也更多。比如在menuconfig里调整 SPI 频率、任务栈大小、日志等级,这些是在 Arduino 里不容易直观看到的东西。
判断标准可以很简单:只是看看刷屏效果,Arduino 最快;要面对完整 UI、OTA、开机自检、低功耗这些需求,就要往 ESP-IDF 迁移。两条路线不冲突,很多人都是从 Arduino 验证完后,再用 ESP-IDF 重写工程。
3.2 最小示例代码:用 TFT_eSPI 刷色块
下面这段代码是最小的刷屏 Demo,代码结构基于 TFT_eSPI 常见用法。它不会直接复现你项目里的最终效果,但可以验证屏幕初始化和基本像素写入是否正常。
#include <TFT_eSPI.h> TFT_eSPI tft = TFT_eSPI(); void setup() { tft.init(); tft.setRotation(1); // 根据实际情况调整方向 tft.fillScreen(TFT_BLACK); } void loop() { tft.fillScreen(TFT_RED); delay(500); tft.fillScreen(TFT_GREEN); delay(500); tft.fillScreen(TFT_BLUE); delay(500); }如果你的接线和User_Setup.h配置正确,这段程序会让屏幕按顺序刷过红、绿、蓝三种纯色。这个测试的核心目的是确认三个基本配置:SPI 通信正常、颜色格式正确、初始化时序成功。
这里有一个很关键的经验:不要一开始就跑去跑复杂的动画或者 LVGL,那样出了问题很难判断是哪一层的问题。先用纯色刷屏,再用简单的几何图形,最后再上复杂 UI。每一步都确认没问题,再进入下一步。
3.3 改引脚后如何验证是“真刷”还是“假刷”
有时候你会看到颜色能刷,但屏幕边缘出现一条竖线,或者显示区域偏移。这很可能是屏幕驱动的偏移参数没配好,不一定是刷新速度的问题。还有的时候,你会看到刷红色时屏幕偏橙,刷绿色时偏暗,这可能是 RGB 顺序和颜色格式不匹配。
我建议做一个“刷屏测试页”,不要只刷纯色。测试页里包含以下几种内容:
- 纯红、纯绿、纯蓝三色块,用来验证颜色通道;
- 黑白相间的棋盘格,用来验证像素边界和对比度;
- 横线和竖线,用来验证坐标是否偏移;
- 从黑到白的灰度渐变,用来验证颜色深度是否正常;
- 屏幕四个角各画一个 1 像素点,用来判断显示区域是否被裁剪。
把这几个元素画到屏幕上后,你就能很快判断出问题是出在 SPI 传输、像素格式,还是屏幕初始化参数。比如四个角只有三个能看到,说明显示区域偏移了;横线和竖线出现锯齿或断裂,说明时钟线上有干扰或 SPI 速率过高。否则,你只是看到“颜色在动”,很难判断到底刷得对不对。
4. 刷屏速度的瓶颈:SPI 速率、DMA 和帧缓冲
4.1 为什么 40MHz 和 80MHz 的体感差异不明显
很多人调高 SPI 频率后,发现动画好像快了一点,但没有想象中翻倍。这是因为刷屏一帧的耗时不仅仅是 SPI 传输时间,还包括 CPU 准备像素的时间、调用绘制函数的时间、屏幕控制器内部更新的时间。
比如你用fillScreen(TFT_RED)刷整屏,TFT_eSPI 会循环填充像素,这个循环本身也会消耗 CPU 时间。如果 SPI 频率低了,CPU 大部分时间在等待 SPI 发送完成;如果 SPI 频率高了,CPU 在生成像素数据的部分可能变成新瓶颈,整体提升就被削弱了。
另外,杜邦线和模块布线也会限制最高可用频率。ESP32-S3 的 SPI 外设理论上支持 80MHz 甚至更高,但实际跑在 80MHz 时,如果屏幕的数据线过长或者接触不良,就可能出现花屏。比较稳妥的做法是从 26.67MHz 或 40MHz 开始,确认显示稳定后,再一步步往上加,每次加完都要跑完整的动画和花屏检测。
4.2 缓冲区策略:整屏缓冲、行缓冲和部分刷新
屏幕刷新的方式会直接影响流畅度和内存占用。常见的有三种。
第一种是无缓冲,一个像素一个像素地往 SPI 写。这种方式最简单,但效率很低,不太适合动画。第二种是行缓冲,一次准备好一行的像素,然后通过pushPixels或pushColor发出去。TFT_eSPI 的许多绘制函数就是按行处理的,内存开销小,适合小屏幕。第三种是整屏缓冲,先在 RAM 或 PSRAM 里把整帧图像绘制好,再一次性搬给屏幕控制器。这种方式可以让复杂画面的合成时间缩短,因为 CPU 不必在发送每一行时临时计算,但代价是内存变大了。
对 ESP32-S3 来说,如果开发板带 PSRAM,你可以放一整块 128 KB 的帧缓冲。但这里要注意:PSRAM 的读写速度通常比内部 SRAM 慢,如果频繁在 PSRAM 里绘制和读取整帧,并不一定比“行缓冲 + 直接发送”更快。它真正的好处是让 UI 框架更容易管理复杂页面,而不是单纯提升帧率。
所以建议是:先根据你的屏幕尺寸和 RAM 余量选择一个缓冲策略。小尺寸屏幕、纯色或简单动画,用行缓冲就够了;复杂 UI、滑动列表、图片切换,可以考虑部分双缓冲;只有当你明确需要全屏图像合成时,再去用 PSRAM 整屏缓冲。
4.3 撕裂避免:画面“割裂”不是屏幕坏了
动态画面刷新时,屏幕控制器会不断从自己的显存里读取数据并显示。如果你在一个不合适的时间点往显存里写入新的一帧,就可能出现“上半屏是新的,下半屏还是旧的”的撕裂效果。很多人遇到这种情况会以为是屏幕坏了,或者刷新太快,其实这是很常见的刷新同步问题。
ST7789 这类控制器一般会提供 TE(Tearing Effect)信号引脚,用来告诉主控“屏幕当前处于安全的刷新区域”。启用 TE 同步后,主控会在合适的时间窗口开始写入数据,从而避免撕裂。很多屏幕模块没有把 TE 引脚引出来,或者没有在库中启用,这时可以通过双缓冲来减少撕裂。
双缓冲的思路是:先在后台缓冲里把下一帧完整画好,然后在一个尽量短的时间内切换显示缓冲区或复制到屏幕。这样做的好处是绘制和显示分离,动画中间状态不会出现在屏幕上。坏处是内存占用增加,小尺寸屏幕里一块缓冲约 128 KB,双缓冲约 256 KB。对于没有 PSRAM 的 ESP32-S3 开发板,这可能比较紧张;有 PSRAM 时,才能比较轻松地做双缓冲。
5. 从刷屏 Demo 到 UI 流畅度:LVGL 和帧率认知
5.1 LVGL 怎么接入 ESP32-S3
如果你的最终目标是做一个“看起来像产品”的界面,比如时钟、菜单、仪表盘,直接用原始 API 逐项绘制会非常累。这时候通常会在 ESP32-S3 上集成 LVGL。LVGL 是一个开源的图形库,负责管理控件、布局、事件和动画,你只需要提供一个“把像素刷到屏幕”的回调函数。
在 ESP-IDF 中,LVGL 可以通过组件管理器引入,然后在代码里初始化显示驱动。常见的结构是:
static lv_disp_draw_buf_t draw_buf; static lv_color_t buf[240 * 10]; void my_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { // 把 color_p 指向的区域写入屏幕控制器 // 调用 SPI 发送接口 lv_disp_flush_ready(drv); } void lvgl_init(void) { lv_init(); lv_disp_draw_buf_init(&draw_buf, buf, NULL, 240 * 10); lv_disp_drv_init(&disp_drv); disp_drv.flush_cb = my_flush_cb; disp_drv.buffer = &draw_buf; disp_drv.hor_res = 240; disp_drv.ver_res = 280; lv_disp_drv_register(&disp_drv); }上面是示例结构,不是可以直接照抄的成品。LVGL 版本不同,API 会有差异,比如lv_disp_drv_t和lv_disp_draw_buf_t在新版本里可能命名有所调整。落地之前,一定要去看你所用版本的官方文档和移植示例。
关键点是:LVGL 并不关心你把像素写到哪块屏幕上,它只负责生成一帧画面,然后调用你的 flush 回调。所以刷屏速度的下限由 SPI 和屏幕控制器决定,上限由 LVGL 的绘制策略和你的缓存区大小决定。
5.2 帧率多少才叫流畅?先设一个合理预期
很多人在“看看刷屏效果”时,会拿手机 60fps 乃至 120fps 的标准来要求一块 SPI 屏幕。这个预期本身就不合理。一块 240x280 的 ST7789 屏,通过 SPI 传输,40MHz 下的理论帧率只有 30 多帧,再算上绘制开销,能稳定跑到 25fps 已经很不错。
对于普通 UI,比如时钟、菜单、弹窗,15fps 到 25fps 通常是可以接受的。因为人眼对界面切换的流畅感,更大程度取决于“交互后有没有及时反馈”,而不是数字上的帧率高低。如果一个按钮按下后 100ms 内画面有变化,感觉就不会太差。
对于动画,比如页面切换、进度条滚动、图片轮播,30fps 和 20fps 的体感差别比较明显。如果真的很在意动画流畅度,可以从降低动画持续时间、减少透明层混合、限制刷新区域这几个方向下手。一个看起来顺滑的动画,不一定是帧率高,也可能是刷新区域小、刷新均匀。
5.3 动画卡顿的排查顺序
LVGL 动画卡顿时,不要急着把 SPI 频率拉到最高。我建议按顺序排查。
先看是不是绘制开销太大。比如你在每一帧都更新整个屏幕的背景,或者使用了很多圆角、阴影、半透明效果,这些都会消耗 CPU。可以试着把背景改为纯色,取消阴影,看帧率是否提升。如果提升明显,说明瓶颈在绘制,而不在 SPI。
再看缓冲区大小。LVGL 的 draw buffer 越大,每次能准备的像素区域就越多,刷屏次数越少。但缓冲区过大也会增加内存占用,尤其在无 PSRAM 的开发板上。常用做法是缓冲区设置为屏幕行数的 1/10 到 1/5,比如 240x10、240x20。如果缓冲区太小,LVGL 需要频繁调用刷屏回调,效率就低。
再看任务调度。ESP32-S3 是双核,但 Arduino 环境默认不一定把 LVGL 任务和刷新任务分开。在 ESP-IDF 里,可以把 LVGL timer 和 SPI 传输放在同一个 task,把其他逻辑放到另一个 task,并适当调整任务优先级。注意不要和 Wi-Fi 协议栈抢核心。
最后再回到 SPI 频率和电源。如果前面都排除了,再尝试把 SPI 频率从 40MHz 提到 60MHz 或 80MHz,同时观察花屏。另外,如果屏幕供电不稳,高频率刷新时也会出现闪屏。用短而粗的电源线,或者额外加一颗电容,有时能解决奇怪的花屏问题。
6. 学会沉淀一套可复用的刷屏调优流程
6.1 从点亮到产品级的七个步骤
刷屏这件事,看起来是给开发板插一块屏幕,但要做到“稳定又流畅”,我建议每次都用同一套流程,而不是每次遇到问题时临时猜。这里是我自己习惯用的七个步骤,可以当作参考:
- 确认屏幕型号、驱动 IC、接口类型和分辨率;
- 按照数据手册或库的示例配置引脚,先接最小必要线路;
- 跑一个纯色刷屏测试,验证初始化、SPI 和颜色格式;
- 跑一个几何图形测试页,验证坐标、旋转和显示边界;
- 测量单帧刷新耗时,记录不同 SPI 频率下的表现;
- 接入 UI 框架后,再以 flush 回调耗时和动画帧率为指标优化;
- 做长期运行测试,观察是否会出现偶然花屏、漂移或低功耗唤醒异常。
这套流程的价值在于:每一步都有明确的验证目标,不会把问题留到最后一起爆发。尤其是前两步,看起来简单,却决定了后面所有调试是否顺利。
6.2 不同场景下的配置建议
不同项目的刷屏目标不一样,配置也应该不一样。下面是一个经验性的对照,具体参数还要结合你的屏幕和开发板调整。
| 使用场景 | 常见分辨率 | SPI 频率 | 缓冲策略 | UI 框架 | 关注重点 |
|---|---|---|---|---|---|
| 学习入门 | 240x240 / 240x280 | 26.67MHz 或 40MHz | 行缓冲 | 无 | 接线和初始化稳定 |
| 静态界面 | 240x320 | 40MHz 到 60MHz | 局部帧缓冲 | LVGL | 显示效果和功耗 |
| 简单动画 | 240x280 | 80MHz(稳定前提下) | 部分双缓冲 | LVGL | 帧率和刷新延迟 |
| 图片轮播 | 320x480 或更高 | 80MHz 及以上 | 整屏缓冲(配合 PSRAM) | 自研或 LVGL | 内存带宽和图片解码 |
这个表格不是“标准答案”。如果你的屏幕本身不支持高 SPI 频率,或者开发板布线不太好,那 80MHz 反而会引入花屏。调优的目标是在稳定和流畅之间找到你自己项目的平衡点。
6.3 长期使用要补的工程化能力
如果只是拍一段“刷屏效果”视频上传,做到上面的程度已经足够。但如果是做一个真实产品,比如智能家居面板、桌面小摆件、工业仪表,还需要补几件事。
第一,日志和错误提示。屏幕初始化失败时,不能只表现为黑屏。最好在串口输出初始化状态,并配合 LED 或蜂鸣器提示。否则设备到了用户手里,坏了都不知道是哪一环。第二,低功耗策略。很多屏幕在不刷新时也要维持显存内容,背光却可以关闭。在电池供电场景下,周期性刷新和背光控制会直接影响续航。第三,固件升级。产品发布后,UI 布局和屏幕驱动可能要调整,远程升级是常见需求。第四,硬件测试。不要只在一块屏幕和一块开发板上验证,换 3 到 5 块同型号屏幕跑老化测试,才能发现焊接、排线、初始化的个体差异。
回到最初那个问题。朋友问“看看我的 ESP32-S3 刷屏效果”,我给他的建议是:先别只盯着颜色是否好看,去量一下每次刷屏的耗时,再用一个复杂一点的页面测测是否卡顿,最后把日志打印出来看稳定不稳定。真正的刷屏效果,不是一段几十秒的视频能证明的,而是在连续运行几个小时甚至几天后,屏幕依然不花、不闪、不卡。这才是 ESP32-S3 刷屏真正值得琢磨的地方。