上周在帮朋友做一块基于 ESP32 的小玩意,主屏是一块 240x320 的 ST7789,副屏是一块 320x480 的 ILI9488,两块屏都要跑 LVGL,主屏还得循环播放 GIF。第一版代码写完后,GIF 确实“能播”了,画面动了、动画也转了,可这个状态只维持了大概三分钟。紧接着就是画面撕裂、整机卡死、反复重启,抓日志的时候我一度怀疑自己是不是拿错了芯片。
这个项目最折磨人的地方,不是把 GIF 跑起来,而是让它在双屏 + LVGL 的体系里连续跑几个小时不崩。这也是我想写这篇文章的原因:GIF 能播放和系统稳定运行之间,隔着总线仲裁、内存规划、任务调度和一堆看似不起眼的缓冲区细节,任何一个环节没想清楚,表面上是“偶尔死机”,本质上都是必然崩溃。如果你也在用 ESP32 做双屏显示,或者正准备把 GIF、开机动画这类动态内容塞进 LVGL 项目,这篇文章应该能帮你绕开我走过的弯路。
1. 硬件底子和第一版方案:双屏不是“再挂一块屏”这么简单
1.1 双屏方案选型时容易忽略的硬件约束
先说硬件。主屏选 ST7789,副屏选 ILI9488,这两块屏都是 SPI 接口的 TFT LCD,LVGL 驱动层可以直接复用。之所以这么选,是因为这两块屏在 ESP32 生态下太成熟了,网上案例一抓一大把,Pin 定义、背光控制、LED 极性这些都有现成资料。
但双屏和单屏有个本质区别:两块屏各自的 SCLK、SDI、DC、RST、BL 脚位可以独立接,但也可以共用,而 CS 脚位必须分开或者通过硬件片选器扩展。我第一版选择让两块屏“各走各的”——主屏挂在 SPI2 主机上,副屏挂在 SPI3 主机上,然后每个主机单独启用 DMA。这种做法的好处是两条总线可以同时传输,坏处是 ESP32 的 SPI2/SPI3 外设资源一共就那么多,DMA 描述符数量、中断优先级、内存访问冲突都会在运行时冒出来。
如果你问“共用一条 SPI 总线行不行”,答案是行,但刷新率会打折。两条总线并行传输的时候,理论上每块屏都能跑在 40MHz 的 SPI 时钟下;共用一条总线则意味着每一帧数据都得排队发送,两个屏的刷新率加起来才是单条总线的吞吐上限。我的实测结论是:240x320 和 320x480 这种分辨率组合,共用总线做动态 GIF 会明显掉帧,所以最终坚持了双 SPI 的接线方式。
1.2 LVGL 初始化参数:缓冲区的第一个坑
LVGL 的初始化很多人直接照抄 Demo,但双屏项目里的缓冲区配置比单屏敏感得多。第一版里我用了常见的lv_disp_draw_buf_init设置一个 40 行高的缓冲,颜色深度LV_COLOR_DEPTH设为 16,主屏分辨率 240x320,副屏 320x480。单看这些参数,似乎没有任何问题。
真正的问题是:两块屏都需要自己的lv_disp_drv_t和lv_disp_draw_buf_t,但很多示例代码默认只有一个显示器实例。如果你把同一个 draw_buf 传给两个显示设备,LVGL 的刷新任务在切换屏幕时会互相踩内存。我第一版没犯这个错,却犯了一个更隐蔽的错——两个 draw_buf 在同一个 LVGL 刷新线程里轮流使用,当主屏 GIC 动画刷新到一半时,副屏如果同时触发 flush,两个 buffer 的读写顺序就会混乱。
1.3 第一版的核心代码轮廓
那时候的核心代码如下:
static lv_disp_draw_buf_t draw_buf_main; static lv_disp_draw_buf_t draw_buf_sub; static lv_color_t buf_main_1[240 * 40]; static lv_color_t buf_sub_1[320 * 40]; lv_disp_draw_buf_init(&draw_buf_main, buf_main_1, NULL, 240 * 40); lv_disp_draw_buf_init(&draw_buf_sub, buf_sub_1, NULL, 320 * 40); lv_disp_drv_init(&disp_drv_main); disp_drv_main.hor_res = 240; disp_drv_main.ver_res = 320; disp_drv_main.flush_cb = main_flush_cb; disp_drv_main.draw_buf = &draw_buf_main; lv_disp_drv_register(&disp_drv_main); lv_disp_drv_init(&disp_drv_sub); disp_drv_sub.hor_res = 320; disp_drv_sub.ver_res = 480; disp_drv_sub.flush_cb = sub_flush_cb; disp_drv_sub.draw_buf = &draw_buf_sub; lv_disp_drv_register(&disp_drv_sub);这段代码跑起来,静态界面没问题,GIF 一上就出事。我当时以为是 LVGL 版本 Bug,后来才发现问题出在更底层的地方。
2. 现象复现与第一次定位:死机日志不会说谎
2.1 崩溃现象:卡死、重启、还有“画面撕裂”
把 GIF 播放代码加进去之后,系统行为分三个阶段。前十几秒完全正常,动画流畅,副屏的静态仪表盘也稳定显示;大约一分钟后开始出现轻微撕裂,GIF 画面里偶尔出现半帧错位;再往后,主屏直接卡死,副屏保持最后一帧,然后系统在几秒内看门狗超时重启。
如果只是重启,问题还算好定位,但撕裂这个现象非常扰人。它通常表示数据传输到了屏幕中间突然被截断,或者 DMA 还没传完一整帧,屏幕就被下一个 flush 操作重新拉低了 CS。
用esp_task_wdt抓到的日志里,除了Task watchdog got triggered之外,还有一段让我印象很深的异常栈:
abort() was called at PC 0x400d2a1c on core 0 Backtrace: 0x4008b44c:0x3ffb0ab0 0x4008b2d5:0x3ffb0ad0 0x400d2a1c:0x3ffb0af0 0x400f2a91:0x3ffb0b10 0x400f1405:0x3ffb0b30 0x40139d3f:0x3ffb0b50这个 Backtrace 指向了lv_refr_master或者memcpy相关函数,说明 LVGL 刷新线程在拷贝像素数据时访问了非法地址。非法地址的来源通常是 draw_buf 被别的任务释放或复用了,但我第一版代码里两个 draw_buf 都是静态数组,不存在释放问题。那就只剩一个可能:DMA 还在读这块 buffer 的时候,LVGL 已经开始往同一块 buffer 写下一帧数据了。
2.2 为什么单屏没这个问题,双屏就出现了
单屏项目里,LVGL 的 flush_cb 通常会等 DMA 传输完成再返回,或者用lv_disp_flush_is_last判断最后一行再同步。当只有一个显示设备时,LVGL 的刷新循环天然地和这一个设备绑定,DMA 完成中断来了,才执行下一次 flush,数据竞争的概率被大大降低。
双屏项目就不一样了。两块屏幕分别注册了两个显示驱动,LVGL 内部会对两个显示器分别产生刷新任务。如果两个任务的优先级相同,或者它们共享同一个lv_timer_handler周期,那么一个显示设备的 DMA 还没有完成,另一个设备的刷新请求就可能先插进来,导致 CPU 在等待总线时切换上下文,而第一个设备的 buffer 此时还处于“发送中”的状态。
所以第一步修复动作不是加互斥锁,而是让 flush_cb 足够“诚实”:DMA 没有完全发送完,就坚决不把lv_disp_flush_ready()作为最后一步调用。我在主屏和副屏的 flush_cb 里都加了 DMA 完成回调,只有收到DMA_EVENT_TRANS_DONE事件后才标记 flush ready。这一步做完,撕裂问题消失了大半,但死机还在,说明还有别的问题。
3. 双屏驱动调度:DMA、互斥锁和 LVGL 刷新时序的三角关系
3.1 两条 SPI 总线之间的隐性竞争
ESP32 有多个 SPI 外设,SPI2 和 SPI3 是独立的硬件外设,但这并不意味着它们在系统层面完全隔离。DMA 传输需要占用系统总线带宽,当 SPI2 的 DMA 和 SPI3 的 DMA 同时访问 PSRAM 或内部 SRAM 时,内存控制器会成为瓶颈。如果内存带宽不足,传输速度就会变慢,进而影响到 LVGL 的刷新节奏。
这种隐形竞争在代码层面非常难查,因为它不产生错误日志,只会表现为“明明 SPI 时钟设置的 40MHz,实际帧率却只有预期的一半”。我用esp_timer测量两次lv_disp_flush_ready()之间的时间间隔后,发现主屏刷新一帧 240x320 的画面偶尔会耗时 60ms 左右,比理论值整整翻了一倍。
解决思路是给两个 SPI 总线分配不同的 DMA 通道优先级,同时把副屏的刷新频率降下来。副屏是静态仪表盘,根本不需要全速刷新,所以我用lv_disp_drv_t的full_refresh = 1,并配合lv_timer把副屏的刷新节流到 30ms 一次,这样两条总线就不会每次都同时抢内存带宽。
3.2 正确加锁的姿势:锁的不是像素数据,而是 SPI 总线
我最初以为加锁很简单,在main_flush_cb和sub_flush_cb里各加一个互斥锁就完了。结果加完之后,系统直接死锁,两个任务都在等待对方释放锁。
正确做法是把锁放在 SPI 传输函数里,确保同一条总线上不会有两个线程同时发起传输。双 SPI 总线虽然各自独立,但 ESP32 的 SPI driver 在创建spi_device_interface_config_t时,如果.flags设置了SPI_DEVICE_NO_DUMMY或者SPI_DEVICE_HALFDUPLEX,驱动内部可能会使用共享的中断处理资源。某些 ESP32 芯片版本上,SPI2 和 SPI3 的中断号可能映射到同一个 CPU 核,如果两个中断处理函数同时执行,也可能引发竞争。
最终我在驱动回调里加了portMUX_TYPE锁,只在spi_device_polling_transmit或者spi_device_queue_trans的外层加锁,生产下发的数据不能加锁。改完之后用xTaskGetTickCount统计两个显示屏刷新周期,主屏稳定在 33ms 一帧,副屏稳定在 30ms 一帧,没有再出现互相阻塞的现象。
3.3 flush_cb 的完整回调逻辑:一个可以直接用的模板
现在我的 flush_cb 差不多长这样,拿过来可以直接套:
static void main_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { esp_err_t ret; spi_transaction_t trans = {0}; trans.type = SPI_TRANS_USE_SEND_ONLY; trans.length = (area->x2 - area->x1 + 1) * (area->y2 - area->y1 + 1) * 2 * 8; trans.tx_buffer = color_p; trans.user = drv; lv_disp_flush_ready(drv); // 先告诉 LVGL buffer 可以继续画,但实际 DMA 还没发完 // 真正发送数据 ret = spi_device_queue_trans(spi_main, &trans, portMAX_DELAY); if (ret != ESP_OK) { lv_disp_flush_ready(drv); } }注意这个模板里有一个看起来矛盾的顺序:先调用了lv_disp_flush_ready,再调spi_device_queue_trans。原因在于spi_device_queue_trans是异步的,它会立即返回,数据真正通过 DMA 在后台发送。如果你等 DMA 完成再调lv_disp_flush_ready,那 LVGL 的绘制线程就会一直被阻塞,GIF 帧率会掉到惨不忍睹的程度。先标记 ready 再异步发送,是双屏项目里比较常见的折中方案,当然前提是你得保证color_p这块 buffer 在 DMA 完成前不会被 LVGL 复用。所以这里最好用双缓冲,比如buf_main_1和buf_main_2交替使用。
4. GIF 解码链路:每一跳都在消耗 RAM 和 CPU
4.1 解码器选型:SPIFFS 文件流 + 轻量 GIF 解码器
GIF 文件在 ESP32 上通常存放在 SPIFFS 或 LittleFS 分区里,读取的时候通过FILE*或者LVGL 的文件系统接口流式读取。如果你用的是 LVGL v8,可以注册lv_fs_drv_t把 LittleFS 挂到 LVGL 的文件系统上,然后直接用lv_fs_fopen打开 GIF 文件,这样解码器就不关心文件来自哪里。
解码器方面,网上开源项目里常见的轻量级 GIF 解码器是gifdec(一个单文件库),它基于gif.h的思路,支持 LZW 解压、全局/局部色表和 GIF 89a 扩展块。它对 ESP32 友好的原因是它的内存占用可以控制,而且它输出的是 24 位 RGB,到了 LVGL 里头再转成 RGB565。
但在传输给 LVGL 之前,我强烈建议先把解码结果转成 RGB565,再送给 LVGL 的 buffer。因为 LVGL 的颜色格式是 RGB565,如果你直接把 RGB888 数据塞进去,LVGL 会自己转换一遍,CPU 占用和内存搬运都会翻倍。
4.2 帧缓冲区的真实内存账本
这里算一笔账:一块 240x320 的 RGB565 帧缓冲,大小是240*320*2 = 153600 字节,约 150KB。ESP32 常见的 SRAM 在 320KB 到 520KB 之间,听起来似乎够用,但你还有 LVGL 的 draw_buf 至少240*40*2 = 19200 字节,文件系统缓冲,Wi-Fi 协议栈缓冲,FreeRTOS 任务栈。如果全部放在内部 SRAM,内存会非常紧张。
解决方法是把它放到 PSRAM,也就是用heap_caps_malloc(size, MALLOC_CAP_SPIRAM)来分配 GIF 帧缓冲。但这里有个坑:PSRAM 的访问速度比内部 SRAM 慢不少,如果 GIF 解码器输出 RGB888 到 PSRAM,再用 LVGL 从 PSRAM 做转换,CPU 等待内存的时间会变得非常明显。实测下来,放 PSRAM 的编码转换耗时比放内部 SRAM 高了约 30%。所以我的选择是:帧缓冲放 PSRAM,临时调色板数组和 LZW 解压双缓冲放内部 SRAM。
内存分配策略大致是:
gif_frame_buf = heap_caps_malloc(240 * 320 * 2, MALLOC_CAP_SPIRAM); gif_palette_tmp = heap_caps_malloc(256 * 3, MALLOC_CAP_8BIT); // 内部 SRAM gif_lzw_prefix = heap_caps_malloc(4096 * 2, MALLOC_CAP_8BIT); // 内部 SRAM4.3 透明色和 disposal method:GIF 循环播放时的隐藏杀手
很多人在解码 GIF 时只关注 LZW 解压,忽略了帧的透明色处理和disposal method。GIF 每一帧都可以定义透明色索引,上一帧与下一帧之间的合成方式由Graphic Control Extension里的Disposal Method字段决定。
比较阴险的 G M 场景是:有些 GIF 第一帧不透明,第二帧只画局部变化区域,其余部分透明。如果你解码器不做合成,直接把第二帧整块渲染上去,背景就会变成黑块或残影。我处理的方式是在解码完每一帧之后,遍历每个像素,把透明索引替换成上一帧的像素值,然后再送入 LVGL。这个遍历在 240x320 画面上大约是 76800 次像素判断,耗时约几毫秒,完全可以接受。
4.4 帧率控制:GIF 自带的 delay 根本不能照抄
GIF 文件头里每一帧都有自己的 delay time,单位是 10ms。但很多网上下载的 GIF,delay 写的是 0 或者 3,如果按这个速度播放,ESP32 根本解不过来,画面会越来越卡,而且 LVGL 刷新任务会被 GIF 解码任务长时间霸占,最终触发看门狗。
我把每一帧的 delay time 统一做了下限钳制:如果原文件 delay 小于 5(即 50ms),就强制改成 5;如果大于 5 则保留原始值。这个改动看起来是“降低 GIF 流畅度”,实际上对观感影响很小,却很有效地稳定了帧节奏。GIF 解码任务和 LVGL 刷新任务之间用一个简单的信号量作为同步,解码完一帧就发送信号量,LVGL 任务收到信号量后再执行 flush。这样 LVGL 永远拿到的是一帧完整数据,不会出现“解了一半就拿来画”的错位画面。
5. 长时间稳定运行的两个扒皮级问题:内存碎片和任务优先级
5.1 第一波 OOM 排查:堆信息让我崩溃
连续运行时间长了之后,死机现象来得毫无规律,有时是 10 分钟,有时是 1 小时。打开 ESP32 的 heap 监控,发现空闲内存像心电图一样不断上下跳动,但总体趋势是往下走的。最终在一个低谷点,malloc返回 NULL,系统抛了 Out of Memory 的断言。
排查过程让我意识到一个容易忽略的事情:LVGL 控件对象本身也会动态分配内存。每执行一次lv_img_set_src或者lv_label_set_text,LVGL 内部都会为新的字符串或图像描述符分配内存;旧的内存会在合适的时机释放。如果 GIF 播放的逻辑里每帧都调用一次lv_img_set_src,就会产生大量的内存分配和释放,导致堆碎片化越来越严重。最终即使有足够的总内存,也无法找到连续大块内存来满足分配。
解决办法是复用同一个lv_img控件,并且只更新它的数据指针,而不是每次都重建 image 节点。GIF 解码出来的帧先写进固定的帧缓冲,然后调用lv_img_set_src把图像源指到这个固定缓冲上,不再为每一帧创建一个新的 image descriptor。
5.2 FreeRTOS 任务优先级:不要让解码任务饿死刷新任务
另一个隐蔽问题是任务优先级。ESP32 双核虽然可以并行,但 LVGL 的lv_timer_handler通常跑在一个核心上,GIF 解码任务如果跑在同一个核心且优先级更高,会导致 LVGL 刷新被抢占,界面看起来就是卡顿、闪烁。
我这里踩过的坑是:把 GIF 解码任务优先级设成 10,LVGL 任务优先级设成 5,结果 GIF 一直霸占 CPU,LVGL 刷新任务几乎得不到执行。后来把解码任务优先级降到 3,并在解码循环里每解完一行就主动vTaskDelay(1)一次,让低优先级的刷新任务有机会插进来。这个改动之后,两个显示器的画面都平滑了不少。
如果你用 LVGL 自带的操作系统接口,可以在lv_conf.h里打开:
#define LV_USE_OS 1 #define LV_OS_CUSTOM 0这样 LVGL 会在 FreeRTOS 上创建自己的线程,但线程优先级也得按实际场景调整。我的最终配置是:LVGL 线程优先级 5、GIF 解码任务优先级 3、主循环任务优先级 1、SPI DMA 完成回调通过队列通知解码任务,避免在中断里做任何耗时操作。
5.3 看门狗超时的根因:卡死在一种很难注意到的等待
看门狗超时往往不是 CPU 死循环,而是某个任务长时间得不到运行,或者某个互斥锁被长时间占用。我遇到过一种特殊情况:副屏的 SPI 总线上挂着一个触摸控制器(GT911),触摸控制器在连续读取时,如果 SPI 时钟配置不当,会引发总线长时间 busy,导致 LVGL flush 任务卡在spi_device_polling_transmit上。
这个问题的排查过程很典型。先用idf.py monitor打开日志,发现异常前有一条I (xxxx) gpio: GPIO[2]| InputEn: 0| OutputEn: 1的日志,说明某个 GPIO 被反复触发中断。查原理图才发现触摸屏的中断脚和副屏的 DC 脚共用了同一个 GPIO。这种硬件设计问题在软件层面只能通过屏蔽中断、或者调整 SPI 模式来规避。我把触摸读取频率从 10ms 降到 50ms,并给触摸读取出增加了超时判断,看门狗才安静下来。
6. 最终稳定运行的配置清单和实测数据
6.1 优化前后对比
我把整个优化过程分成三个阶段:初始版本(能播但会崩)、中间版本(撕裂减少但内存有泄漏)、最终版本(稳定运行数小时)。对应数据如下:
| 项目 | 初始版本 | 中间版本 | 最终版本 |
|---|---|---|---|
| 主屏 GIF 最大帧率 | 约 15fps | 约 22fps | 25fps 稳定 |
| 副屏静态界面刷新 | 随缘抖动 | 正常但偶发卡顿 | 30ms 周期稳定 |
| 连续运行时间 | 3 分钟以内崩溃 | 约半小时崩溃 | 8 小时以上稳定 |
| 空闲堆内存波动 | 持续下降 | 上下波动但平均下降 | 稳定在 ±10KB 内 |
| CPU 负载峰值 | 无法测量 | 约 80% | 约 60% |
6.2 直接抄作业的配置清单
很多项目其实不需要原创代码,把关键配置搞清楚就能跑。下面是我最后保留下来的核心配置,适用于 ESP32 系列带 PSRAM 的型号:
CONFIG_SPIRAM_MODE_QUAD=y,使用四线 SPI PSRAMCONFIG_SPIRAM_SPEED_80M=yCONFIG_LVGL_COLOR_DEPTH_16=yCONFIG_LVGL_DISP_DEF_REFR_PERIOD=30,即 33ms 刷新一次LV_MEM_SIZE设置为(64U * 1024U),并启用LV_MEM_CUSTOM=1,走heap_caps_malloc- GIF 帧缓冲用
MALLOC_CAP_SPIRAM申请,大小固定为240*320*2 - 主屏 SPI 时钟
36MHz,副屏26MHz,降速不仅没有明显观感损失,还大幅降低总线错误概率 - 两个显示屏的 CS 引脚不要接在同一个 RTC 域,避免电源波动互相干扰
最后一条是最反直觉的:我把副屏 CS 从 GPIO 14 换到 GPIO 19 后,黑屏概率几乎降到了零。原因可能是 GPIO 14 和某个 RTC 功能共用引脚,上电瞬间电平不稳定,导致屏幕初始化偶尔失败。这个经验不一定适合所有人的板子,但排查双屏问题时,值得列入怀疑清单。
6.3 一点运行中的经验补充
连续跑了几个小时后,我特意观察过 ESP32 的晶振温度和 SPI 信号波形,没有发现明显的信号抖动,说明硬件本身没有瓶颈,问题几乎全部集中在软件调度和内存策略上。如果你也在做类似项目,我建议一上来就为双屏项目预留足够的内存余量,宁可使用较大的 PSRAM 帧缓冲,也不要试图省出来跑一些花哨的动态控件。
另外,LVGL 的动态主题、阴影效果、字体抗锯齿都会消耗额外的 CPU 和内存。在主屏跑 GIF 的同时,尽量少用这些效果。我把副屏的圆角和阴影都改成了纯色块,界面美观差距不大,但 CPU 负载下降了大概 12%,这 12% 换来了长跑时的容错空间,非常值得。
7. 最后再分享一个排查技巧
如果看完上面的内容你还是觉得无从下手,我建议按这个顺序排查:先确认双屏驱动层没有数据和总线竞争,再确认 GIF 帧缓冲没有碎片化泄漏,最后才去调任务优先级。很多人在第一步就翻了车,却去怀疑 LVGL 的调度器有问题。
我自己的体会是,ESP32 双屏 + GIF 播放本身并不复杂,复杂的是每个环节都要求你“认真对待内存和时序”。以前我用单片机点个屏,只要像素输出正确就觉得完事了;现在做这种连续跑几小时的显示项目,我连一个 GPIO 的电平时序都不敢忽略。这大概就是工程复盘最大的价值——表面上是修 Bug,实际上是重新理解这套系统的每一处关键决策。