ESP32 双屏 GIF 与 LVGL 稳定性:从“能播放”到连续运行数小时的工程复盘
在 ESP32-S3 上驱动两块 240×240 圆形屏幕播放 GIF,看起来只是把同一张动画画两遍。真正落到产品中,它却同时涉及 GIF 帧语义、LVGL 对象生命周期、SPI DMA、PSRAM、SD 卡、任务并发和故障恢复。
本文来自一个真实 AI 玩偶项目。文中的帧率、内存和运行时长取自开发过程中的设备日志,用于解释工程问题,不是实验室基准测试。
1. 项目背景:两块眼睛屏不只是两个显示器
我们的设备基于 Waveshare ESP32-S3-DualEye-Touch-LCD-1.28:
- ESP32-S3;
- 两块 240×240 GC9A01 圆形 LCD;
- 两块屏幕共享 SPI 总线,各自拥有 CS、RESET 和背光控制;
- GIF 表情包括
start、neutral、listen、happy、angry、surprise、confused和mute; - 默认素材编译进固件,自定义素材从云端下载并缓存到 SD 卡;
- 语音链路同时运行麦克风采集、WebSocket、MQTT、流式 PCM 播放和云端 AEC。
用户看到的只是“一双会动的眼睛”,设备内部却有多条路径可能要求切换表情:
开机流程 ───────────────→ start → neutral
VAD / ASR ──────────────→ listen
LLM 情绪 ───────────────→ happy / angry / ...
BOOT 按钮 ──────────────→ mute / neutral
App / MQTT ─────────────→ 切换整套眼睛素材
OTA / 配网 ─────────────→ 进度界面
如果这些调用直接进入 LVGL,即使每一处代码单独看都正确,组合起来也可能造成对象重复销毁、素材指针失效、锁超时,最终停在某一帧。
2. 我们遇到的故障,比“GIF 播放慢”复杂得多
显示链路的演进大致经历了五类问题。
2.1 方向和颜色错误
最初的 GIF 在电脑上是左右转动,设备上却变成上下转动;两块屏幕方向相反,蓝色虹膜还会变成白色或出现蓝黑色块。
这里混合了三类问题:
- GC9A01 的扫描方向和面板安装方向不同;
- RGB/BGR 顺序及 RGB565 字节序不一致;
- 左右屏幕不能简单套用相同的
swap_xy和mirror参数。
最终配置为:
left: swap_xy=true, mirror_x=false, mirror_y=false
right: swap_xy=true, mirror_x=true, mirror_y=true
pixel: BGR, 16 bit, panel invert enabled
刷新前只做一次 RGB565 字节交换,方向差异交给两个面板各自的硬件映射处理,而不是在 GIF 数据上做二次旋转。
2.2 动画能播放,但两眼不同步或负担过重
最直接的实现是创建两个 LVGL display、两个 GIF 对象和两个解码器。它的问题也很直接:
- 同一帧被解码两次;
- 需要两套解码状态和帧缓冲;
- 两个 GIF timer 可能产生细微相位差;
- 一次表情切换要同时管理两套对象生命周期。
我们随后尝试“左眼解码,右眼借用左眼当前 draw buffer”。它减少了第二个解码器,却引入了更隐蔽的所有权问题:右眼 image 的 source 并不是独立 image descriptor,而是 GIF 对象拥有的 lv_draw_buf_t。如果按普通图片调用 image cache 清理,就可能破坏 LVGL 的缓存结构。多次切换后,表现不是立即崩溃,而是某个 timer 或解码路径永久卡住。
最终方案不是“共享两个对象”,而是只建立一个逻辑 LVGL display:
一个 GIF 解码器
↓
一个 LVGL 逻辑画布
↓
dual_panel_flush()
├─→ 左屏 SPI DMA
└─→ 右屏 SPI DMA
两块物理屏幕显示的是同一个逻辑帧。刷新回调依次提交左右面板 DMA,并等待各自的完成信号,最后只调用一次 lv_display_flush_ready()。
这一改变同时解决了三个问题:
- 两眼天然使用同一帧,不再发生相位漂移;
- GIF 只解码一次;
- LVGL 只管理一套 display、screen 和动画对象。
3. 灰色方块不是“ESP32 解码能力不足”,而是 GIF 帧语义不兼容
自定义素材上线后,我们遇到过一个典型现象:眼睛主体正常,但眨眼或移动区域会出现大片灰色矩形。
这类问题很容易被误判为:
- COS 文件损坏;
- ESP32 下载不完整;
- LVGL GIF 解码器性能不足;
- RGB565 转换错误。
实际根因在上传端。
Sharp/libvips 在缩放动画 GIF 时,会倾向于输出优化后的 delta frame:每一帧只保存相对上一帧变化的矩形区域,并使用特定 disposal 策略。浏览器能够正确合成这些帧,但嵌入式 GIF 解码路径对 disposal 和局部帧的组合更敏感。局部矩形如果没有按预期恢复背景,就会把矩形边界直接留在眼白上。
因此我们没有继续加重固件端,而是在服务端统一素材:
用户上传 GIF
↓
Sharp 生成 240×240 / 160×160 / 128×128
↓
gifsicle --unoptimize --disposal=background
↓
完整画布帧、明确背景恢复语义
↓
上传 COS
核心不是某一个转换工具,而是建立“设备素材契约”:
- 尺寸必须与设备声明的屏幕分辨率一致;
- 每帧应能在统一逻辑画布上独立、确定地还原;
- disposal 策略必须是固件解码器验证过的形式;
- 颜色和透明背景规则必须固定;
- 转换发生在算力充足的服务端,而不是 ESP32。
这是一个很重要的产品化经验:设备支持 GIF,不等于设备应该支持互联网上任意编码方式的 GIF。
4. 卡死不等于内存不足:日志改变了我们的判断
早期设备卡死时,最直觉的怀疑是堆内存耗尽。但多轮日志给出了不同结论。
一组高频复现中,设备在启动约 36~41 秒后卡死:
LVGL heartbeat delayed 31650 ms
internal_free=67611
largest=36864
LVGL heartbeat delayed 36650 ms
internal_free=67607
largest=36864
其他复现的内部空闲堆约为 63~69 KB,最大连续块约为 36~38 KB。也就是说,LVGL 已经停止推进,但内存仍足以完成很多普通分配。这说明至少这批故障不是简单 OOM,更像是对象生命周期、缓存状态或异步刷新状态被破坏。
另一组运行约 15 分钟后卡死的日志则是:
pattern=default emotion=listen
frame_idle=34960 ms
internal_free=27215
largest=7680
此时内存碎片显然已经更严重。它可能放大问题,却仍不能解释前一组“有 60 多 KB 空闲仍然卡死”的情况。
因此我们的诊断指标不再只有 free heap,而是同时记录:
- LVGL heartbeat 距离上次推进的时间;
- GIF 距离上次换帧的时间;
- 当前素材包和情绪;
- 是否正在显示进度界面;
- internal heap 总空闲;
- internal heap 最大连续块。
这让“画面停住”从一个模糊现象变成可以区分的故障:
heartbeat 继续、frame_idle 增长 → GIF 解码/帧推进问题
heartbeat 和 frame 都停止 → LVGL task 或刷新路径卡死
largest block 快速下降 → 内存碎片或 DMA 临时分配风险
progress=1 → 下载/OTA 界面切换路径需要重点检查
5. 真正稳定的关键:只有 LVGL 线程修改 LVGL 对象
MQTT 回调、按钮任务、启动 timer 和下载任务都可能要求切换表情。早期实现让调用方先获取 LVGL lock,再同步删除旧对象、加载新 GIF。这带来两个问题:
- 调用方可能在持锁期间做文件读取或内存分配;
- 多个来源同时切换时,素材指针和对象状态容易交叉。
最终我们把显示请求变成一个长度为 1 的覆盖队列:
typedef struct {
char emotion[16];
char pattern[40];
} display_command_t;
xQueueOverwrite(display_queue, &command);
长度为 1 是刻意设计的。表情不是财务流水,不需要逐条执行历史命令。假设 100 ms 内依次收到:
listen → neutral → happy
用户真正需要看到的是 happy,而不是设备花时间补播已经过期的中间状态。
专用 loader task 负责:
- 从固件资源或 SD 卡预加载 GIF;
- 大文件读取和重试发生在 LVGL lock 之外;
- 资源准备完成后再获取 LVGL lock;
- 在同一临界区内核对素材包、删除旧对象、替换 descriptor、创建新 GIF;
- lock 内不做网络请求,也不做整文件读取。
这样,表情切换从“任何线程都能操作 UI”变成“任何线程只能提交意图,显示任务串行提交状态”。
6. PSRAM 能放 GIF,但 DMA 不能随便用 PSRAM
自定义 GIF 大小通常是数百 KB。我们的日志中出现过:
| 表情示例 | GIF 大小 |
|---|---|
listen(一版默认素材) |
480,086 B |
neutral(一版默认素材) |
464,454 B |
happy(一版默认素材) |
390,134 B |
neutral(优化后的自定义素材) |
273,019 B |
listen(优化后的自定义素材) |
142,685 B |
这些文件适合放在 PSRAM,但 SPI/SDMMC DMA 使用的缓冲区需要 DMA-capable internal memory。
我们曾看到:
sdmmc_cmd: allocate_dma_buf: not enough mem
diskio_sdmmc: sdmmc_read_blocks failed
cannot preload emotion 'happy'
问题不是 SD 卡容量不足,而是驱动收到 PSRAM 目标地址后,需要临时申请一块内部 DMA bounce buffer。TLS、WebSocket、音频和 LVGL 同时运行时,这类“按 GIF 大小临时申请”的行为很不稳定。
最终读取路径改为固定的 2 KB DMA staging buffer:
SD 卡 → 2 KB internal DMA buffer → memcpy → PSRAM GIF buffer
下载 COS 文件到 SD 卡也使用同样大小的固定 DMA buffer。代价是多几次复制,但换来了可预测的内部堆占用。
LVGL draw buffer 则保留在 internal DMA memory:
240 px × 40 lines × 2 bytes × 双缓冲 = 38,400 bytes
这里的原则非常清晰:
- 大而长期存在的压缩素材放 PSRAM;
- 小而确定的 DMA buffer 放 internal memory;
- 不让驱动在高负载时临时猜测和申请大块 bounce buffer。
7. 云端素材切换不能与实时语音链路硬抢资源
一次完整素材切换需要下载 8 个 GIF、写入 SD 卡、验证、提交目录、切换显示并持久化选择。如果它和 WebSocket 音频、麦克风上传、PCM 播放同时进行,用户看到的可能不是“下载慢一点”,而是:
- 音频播放欠载;
- TLS 分配失败;
- SDMMC DMA 分配失败;
- LVGL 切换超时;
- WebSocket 因长时间得不到调度而断开。
因此素材切换被定义为明确的 maintenance transaction:
停止 MP3/PCM 播放
暂停麦克风采集
让云端 WebSocket 进入维护模式
显示下载进度
检查本地缓存
下载到 <pattern>.new
写 manifest
原子替换正式目录
加载 neutral 验证
持久化 pattern ID
恢复 WebSocket 和采集
7.1 缓存不是优化项,而是稳定性设计
每套素材的 manifest 保存 8 个 URL 去掉查询参数后的身份哈希。再次选择同一素材时,如果文件和 manifest 都匹配,直接命中 SD 卡缓存,不再重新下载。
缓存带来的不只是速度提升:
- 减少 TLS 建连;
- 减少 SD 写入;
- 减少内部堆高峰;
- 降低显示与网络任务相互影响的机会。
7.2 staging 目录避免半套素材生效
下载过程中不能直接覆盖当前素材。任何一张图失败,都可能让设备只剩 7 个有效表情。
所以新资源先进入 .new 目录,全部成功并写完 manifest 后,再通过目录 rename 提交;失败则删除 staging,继续使用旧素材。这和服务端发布、OTA 的事务思想是相同的。
8. 开机动画需要“事件完成 + 超时兜底”
需求是开机播放一次 start.gif,完成后切换到 neutral.gif。
单纯按固定时间延迟并不可靠,因为不同素材的帧数和每帧 delay 不同;单纯依赖 GIF ready 事件也不可靠,因为异常素材可能永远不触发完成事件。
最终使用双保险:
start.gif loop_count = 1
├─ LV_EVENT_READY → 异步切 neutral
└─ 5 秒 fallback timer → 强制切 neutral
这里还修复了一个恢复自定义素材后的竞争:初始化阶段只恢复 pattern ID,不立即排队 neutral;随后由统一启动流程提交 start。否则 neutral 和 start 两个命令竞争,可能导致开机永远停在 start。
9. 看门狗的目标不是“重启一切”,而是保住可恢复性
完全避免所有第三方解码器、SPI 驱动和异常素材故障并不现实。因此最终版本加入显示专用 watchdog:
- LVGL timer 每 20 ms 更新 heartbeat;
- watchdog 每 5 秒检查一次;
- heartbeat 超过 30 秒未更新,记录一次异常;
- 连续两次确认仍未恢复,持久化
LVGL_HANG并重启; - RTC no-init 区保存 display recovery marker;
- 下次启动发现 marker 时,暂时绕过自定义素材并回退默认眼睛;
- 启动后通过设备诊断链路上报上一次崩溃原因。
为什么不是 2 秒或 5 秒就重启?因为素材加载、Flash/SD 操作和系统高负载可能造成短暂延迟。过于激进的 watchdog 会把偶发慢帧升级成重启风暴。30 秒加二次确认牺牲了一点恢复速度,却显著降低误杀。
更重要的是,watchdog 日志携带足够上下文。它不是一句“LVGL timeout”,而是:
pattern=<当前素材>
emotion=<当前情绪>
frame_idle=<多久没换帧>
progress=<是否正在升级界面>
internal_free=<内部空闲堆>
largest=<最大连续块>
这使线上设备重启后仍能解释“为什么重启”。
10. 数据对比:我们如何判断改造是否真的有效
10.1 帧率稳定性
一段连续采样日志每 5 秒统计一次实际换帧数:
16.6, 16.8, 16.7, 16.3, 16.4, 16.8, 16.7 FPS
结果为:
- 平均:16.61 FPS;
- 最低:16.3 FPS;
- 最高:16.8 FPS;
- 波动范围:0.5 FPS。
对这一组原始 GIF 而言,重点不是盲目追求 30 FPS,而是按照 GIF 自带帧延迟稳定推进。日志说明当时系统能够连续解码和刷新,且实际帧率没有随网络、音频任务出现大幅漂移。
10.2 卡死版本与稳定版本
| 指标 | 早期高频卡死版本 | 后续稳定测试日志 |
|---|---|---|
| 常见卡死时间 | 启动后约 36~41 秒 | 日志总运行时间超过 12,070 秒 |
| LVGL heartbeat 超时 | 连续出现 31~39 秒延迟 | 0 次 |
| 最大连续内部堆 | 卡死时仍有 36~38 KB 的样本 | 长时间运行未触发显示恢复 |
| 表情切换验证 | 很少切换也可能卡死 | 一个 95.6 分钟窗口内完成 123 次切换 |
| 故障后行为 | 屏幕停帧,最终整机重启 | watchdog 可诊断,异常启动可回退默认素材 |
“超过 3 小时没有卡死”不代表已经证明永不出错,但它至少跨过了早期 40 秒左右高频复现区间约两个数量级,也覆盖了大量真实语音对话和表情切换。
11. 哪些优化最有价值
回看整个过程,真正决定稳定性的不是某一个更大的栈或更高的任务优先级,而是边界设计。
11.1 一个逻辑 display,而不是两套解码状态
双屏展示同一动画时,最可靠的同步方式是在 flush 层复制同一帧,而不是在对象层努力同步两个 GIF。
11.2 素材在服务端标准化
把 resize、完整帧展开、disposal 统一和格式检查放到上传链路,固件只支持一个明确子集。产品系统越开放,这个契约越重要。
11.3 UI 状态只能有一个提交者
MQTT、按钮和业务状态机都可以发命令,但只有 loader/LVGL 线程真正修改对象。长度为 1 的覆盖队列还能自动丢弃过期表情。
11.4 区分 PSRAM、internal heap 和 DMA memory
“还有几 MB PSRAM”不能证明 SDMMC 或 SPI DMA 一定能申请成功。需要关注最大连续 internal block,并用固定 DMA staging buffer 消除运行时大块申请。
11.5 故障恢复必须保留上下文
重启不是修复,但在不可恢复状态下,带原因的受控重启比永久停帧更符合产品体验。RTC marker、默认素材回退和云端诊断让重启成为闭环的一部分。
12. 一份可复用的检查清单
如果你也在 ESP32 + LVGL 上做双屏动画,可以按下面的顺序排查。
显示与颜色
- 每块面板分别确认
swap_xy、mirror_x、mirror_y; - 确认 RGB/BGR 和 RGB565 字节序;
- 不要同时在素材、LVGL 和 panel 三层重复旋转。
GIF 资源
- 检查帧尺寸是否一致;
- 检查 disposal method;
- 对局部 delta frame 做完整画布展开;
- 保留原始每帧 delay,不要统一强制帧率;
- 在服务端生成设备支持的固定尺寸变体。
LVGL 生命周期
- 所有对象创建、删除和 source 替换都在 LVGL 上下文或锁内完成;
- 不要释放仍被 decoder/image 引用的内存;
- 不要对借用的 draw buffer 调用普通 image cache 清理;
- 切换时先删除依赖对象,再删除拥有 buffer 的 GIF 对象。
并发
- 外部任务只投递显示命令;
- 文件读取和网络下载不占用 LVGL lock;
- 用覆盖队列合并过期表情;
- 素材包 ID 和 emotion 在应用前再次核对。
内存与 DMA
- 压缩素材放 PSRAM;
- draw buffer 和 staging buffer 使用 DMA-capable internal memory;
- 同时记录 free heap 和 largest free block;
- 避免让驱动临时为 PSRAM 地址申请大块 bounce buffer。
可观测性
- 定期统计实际帧率;
- 区分 LVGL heartbeat 与 GIF frame progress;
- 日志记录当前素材、情绪、内存和进度状态;
- 异常原因跨重启保存并在下次联网后上报。
结语
双屏 GIF 最初看起来是整个 AI 玩偶里最简单的模块:素材已经准备好,调用 LVGL 播放即可。实际工程中,它却是实时音频、网络、存储和 UI 并发交汇的地方。
这次稳定性改造最后得到的经验,与 GIF 本身相比更像一套嵌入式系统方法论:
- 把不确定的外部资源变成明确的设备契约;
- 把共享状态收敛到单一提交者;
- 把大容量内存和 DMA 内存按能力而不是按“剩余多少”区分;
- 用真实帧率、heartbeat、frame idle 和堆连续块替代主观判断;
- 为无法完全避免的故障设计可解释、可回退的恢复路径。
做到这些以后,两块眼睛屏才不再是“偶尔能动的 Demo”,而成为可以与语音、云端素材、OTA 和长时间在线共同运行的产品组件。

浙公网安备 33010602011771号