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 表情包括 startneutrallistenhappyangrysurpriseconfusedmute
  • 默认素材编译进固件,自定义素材从云端下载并缓存到 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 在电脑上是左右转动,设备上却变成上下转动;两块屏幕方向相反,蓝色虹膜还会变成白色或出现蓝黑色块。

这里混合了三类问题:

  1. GC9A01 的扫描方向和面板安装方向不同;
  2. RGB/BGR 顺序及 RGB565 字节序不一致;
  3. 左右屏幕不能简单套用相同的 swap_xymirror 参数。

最终配置为:

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. 调用方可能在持锁期间做文件读取或内存分配;
  2. 多个来源同时切换时,素材指针和对象状态容易交叉。

最终我们把显示请求变成一个长度为 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 负责:

  1. 从固件资源或 SD 卡预加载 GIF;
  2. 大文件读取和重试发生在 LVGL lock 之外;
  3. 资源准备完成后再获取 LVGL lock;
  4. 在同一临界区内核对素材包、删除旧对象、替换 descriptor、创建新 GIF;
  5. 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。否则 neutralstart 两个命令竞争,可能导致开机永远停在 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_xymirror_xmirror_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 本身相比更像一套嵌入式系统方法论:

  1. 把不确定的外部资源变成明确的设备契约;
  2. 把共享状态收敛到单一提交者;
  3. 把大容量内存和 DMA 内存按能力而不是按“剩余多少”区分;
  4. 用真实帧率、heartbeat、frame idle 和堆连续块替代主观判断;
  5. 为无法完全避免的故障设计可解释、可回退的恢复路径。

做到这些以后,两块眼睛屏才不再是“偶尔能动的 Demo”,而成为可以与语音、云端素材、OTA 和长时间在线共同运行的产品组件。

posted @ 2026-09-03 10:52  YakumoChen  阅读(4)  评论(0)    收藏  举报