ESP32-S3 上用 MicroPython 播视频:RV565 双核流水线稳跑 24 FPS,瓶颈原来不在解码
原文信息:本文译自 arXiv 论文《RV565: Cross-Layer Co-Design for Frame-Independent Video Playback on MicroPython-Class ESP32-S3 Systems》,作者 Ayushman Bhattacharya(Pollinations AI)、Anwesha Chakraborty(JIS University 计算机科学与工程系),2026 年 10 月 22 日提交(arXiv:2610.35935)。原文链接:https://arxiv.org/abs/2610.35935 。原文采用 CC BY 4.0 许可。本文为翻译(节译):摘要、引言、设计方法、实验结果与结论按原文翻译,背景综述与部分统计方法细节从略;原文配图、电路原理图与参考文献请见原文链接。文末附译者注。
译者导语:在 ESP32 这类单片机上接一块 SPI 小屏播视频,很多人第一反应是“解码太慢”。这篇论文用一块 ESP32-S3 开发板和一份逐帧计时数据说明:真正决定能不能跑满帧率的,是每个字节放在哪块内存里、哪些步骤在串行等待。作者把解码挪到第二个核心、与屏幕 DMA 传输重叠,最终在 40 MHz SPI 上稳定 24 FPS、零掉帧,屏幕传输跑到了总线理论上限的 98.1%。做嵌入式显示、智能硬件和 MicroPython 应用的开发者,都能从中拿到可直接复用的思路。
摘要
在 SPI 接口的单片机屏幕上播放视频,瓶颈与其说是解码,不如说是“每个字节存在哪里”以及“哪些阶段在串行执行”。我们提出 RV565:一个面向帧间独立视频的解码框架,解码结果直接就是屏幕原生的 RGB565 格式。我们在 ESP32-S3 上用一部开放许可电影中的两段各 10 秒片段进行评估。
串行路径(先解压,再缩放写入内部内存条带,同时与 SPI 直接内存访问(DMA)传输重叠)在较轻的片段上能满足 41.7 ms 的帧期限,但在较重的片段上 7,200 帧全部超时。RV565 的双核流水线在传输期间由第二个核心解压下一帧,在两段片段上都稳定保持 24 FPS、零超时,并留有 10.3 ms 余量;此时帧时间等于屏幕传输时间,而传输速度达到 40 MHz 线速理论上限的 98.1%。
由于帧与帧相互独立,解压时间与容器中记录的压缩长度呈线性关系(合并拟合 r² = 0.9987):在一个片段上拟合的模型对另一片段的预测误差不超过 0.04 ms,仅凭元数据就能判断串行路径无法按时调度哪一段。我们还说明了为什么 Deflate 无法利用大于 128×128 像素的 RGB565 帧中“同位置”的时间冗余,并量化了代价:共享一个 Deflate 流,体积变化最多 1.1%;而显式的 XOR 帧差可以节省 46%–80%。
在 MicroPython 应用操作系统 OreoOS 中,仅把解压移入固件可以达到 12.2 FPS;分阶段计时显示,80.4 ms 的帧时间来自串行化的文件系统读取、外部 RAM 中的内存布局和阻塞式显示输出,解释器开销只有 0.8 ms。固件、测量数据和分析脚本均已公开。
1 引言
在单片机上播放视频,不只是解码问题。一帧完整的画面必须在显示期限之前完成读取、解码、转换为屏幕像素布局并传输出去;与此同时,交互设备还得持续轮询按键、处理操作系统任务。在 SPI 屏幕上,这些约束格外明显——传输时间在每个帧周期中都占据一大块,而且相当固定。
通用视频格式追求压缩率、互操作性和画质,往往带来容器解析、色彩空间转换、可变的解码器状态和帧间依赖。这些特性在大系统上很有价值,但在单片机上会让延迟和内存占用难以给出上界。原始 RGB565 则恰好相反:屏幕传输简单且确定,但存储和输入带宽随像素数线性增长。
现有方案可以按三个维度归类:
- 解码复杂度放在哪里:屏幕原生的原始帧播放把所有工作交给主机,代价是存储;JPEG 等变换编码则把反变换和颜色转换放在设备端。
- 帧间是否依赖:H.264/AVC 这类运动补偿编码依靠参考帧获得高压缩率;MJPEG 这类纯帧内流则每帧独立解码。
- 解码格式与屏幕格式的距离:输出 RGB888 或 YUV 的解码器还需要一遍转换才能驱动 RGB565 屏幕;QOI、LZ4 这类字节级无损格式以压缩率换解码速度,但不绑定显示格式。
在单片机上,每个维度都对应一种硬件资源:设备端解码消耗帧周期内的 CPU,帧间状态消耗稀缺的内部 SRAM,格式转换则让整帧多走一遍存储层次。
RV565 刻意选择了一个很窄的位置:在主机端完成重采样和颜色量化,只用帧内(intra-only)帧,载荷采用无损 Deflate,且解码后的字节就是屏幕的 RGB565 格式。框架包括:容器格式及其校验约定;把解压、缩放和 DMA 传输分配到不同执行资源上的双核流水线;基于绝对期限的调度器;以及一个根据容器元数据预测可调度性的代价模型。熵解码直接使用标准的 tinfl 解压器,我们并不提出新的熵编码器。本文要回答的是:在 ESP32-S3 上,无论是裸固件还是在 MicroPython 应用操作系统 OreoOS 中,这样一路视频流的帧预算究竟花在了哪里。
本文的贡献:
- 定义了一种帧间独立、屏幕原生的容器格式,并给出主机端与设备端的校验约定;
- 设计了双核解码流水线,并在两种负载上与串行路径做受控消融实验:它把解压移出关键路径,在串行路径无法调度的内容上保持 24 FPS 零超时,帧时间降到屏幕传输时间,与线速上限相差不到 1.9%;
- 证明帧内 Deflate 让解码代价成为容器中已存储的载荷长度的线性函数,因此过去实时解码器必须估计的复杂度,现在播放前就能从元数据得到;
- 推导了 Deflate 匹配窗口无法利用同位置时间冗余的条件,并量化帧独立的真实代价;
- 对解释器托管下的播放做了分阶段剖析,表明裸固件与应用操作系统之间的差距来自串行化和内存布局,而不是解释器开销。
2 以 RGB565 为设计起点
RGB565 用 16 位表示一个像素:红 5 位、绿 6 位、蓝 5 位。绿色多一位,是因为人眼对这段光谱的亮度细节更敏感。像素值可写为:
p = (r5 << 11) | (g6 << 5) | b5
RV565 按屏幕控制器在串行接口上期望的大端字节序存储这个 16 位值,因此解码后的帧可以直接送往屏幕,不需要经过 RGB888 或 YUV 中间转换。
这样做简化了设备端,但也暴露了系统约束:一帧 W×H 像素压缩前占 2WH 字节。帧率把这个值放大成持续的存储和内存带宽需求,而最终 320×240 的画面还必须通过 SPI 总线。因此 RV565 压缩的是“存储和读取的数据”,而不是屏幕最终接收的数据。
RGB565 也划定了画质边界:Deflate 对编码字节是无损的,但从原始色彩空间转换和空间缩放并非无损。RV565 的所有损失都来自重采样和 RGB565 量化,从不来自压缩。
3 从相册功能到系统问题
项目起点很朴素:相册应用(Gallery)已经能显示静态图片,视频看起来不过是“一串图片加一个时钟”。第一版播放器就照字面实现:读一帧,在 MicroPython 里解码,用 Python 循环缩放像素,拷进帧缓冲,再交给普通显示驱动输出。技术上它是个视频播放器,但没法交互——一帧就把解释器占用太久,画面一跳一跳地前进,按键事件排在媒体任务后面等待。
这次失败改变了问题本身:问题不再是“怎么解析视频文件”,而是“怎么让一整帧在下一个期限之前走完存储、内存、转换和显示总线,同时还能回到操作系统主循环”。之后的每个版本都去掉了这条路径上的一个瓶颈:
| 阶段 | 做法 | 暴露的问题 |
|---|---|---|
| 初版 | Python 解码与像素缩放 | 解释器忙于计算,按键无响应 |
| 原生缩放 | 原生代码直接缩放进帧缓冲 | 缩放变快,但读 Flash 仍然昂贵 |
| RV565 v4 | 带调色板的索引帧预载到 PSRAM | 时序改善,但调色板降低画质 |
| RV565 v6 串行 DMA | 独立压缩的全彩帧 | 只有解压能塞进余量时才有 24 FPS |
| RV565 v6 + OreoOS | 固件内解压 | 12.2 FPS,各阶段仍是串行 |
| RV565 v6 双核重叠 | 第二核解压下一帧 | 两段片段都 24 FPS,瓶颈变成总线 |
(前三个阶段没有埋点计时,只是定性回顾;后三个 v6 路径在第 6 节定量评估。把双核路径接入 OreoOS 还是后续工作。)
把像素搬出 Python。 第一处修正是写一个原生缩放器,直接把 RGB565 像素写进屏幕帧缓冲。这去掉了嵌套的 Python 循环,找回了大部分帧预算,但也暴露出下一个代价:反复从文件系统读取整帧大小的载荷。优化了计算,只是把存储路径的问题露了出来。
RV565 v4 把昂贵的颜色分析挪到主机端:每帧带一个自适应 RGB565 调色板和一个 8 位索引平面,相册把这些独立的帧块增量加载到 PSRAM,再由原生模块直接展开到帧缓冲。之所以分块而不是一次大分配,是因为长时间运行的界面可能 PSRAM 总量充足,却找不到一整块足够大的连续区域。这个设计去掉了每帧的文件系统读取,但逐帧调色板给画质设了一个本可避免的天花板。
把硬件极限和操作系统开销分开。 RV565 v6 回到全彩 RGB565 帧,并对每帧单独压缩。为了弄清硬件本身能否撑住目标帧率,作者暂时拿掉操作系统,用 ESP-IDF 写了一个独立实验:先解压每帧,再经由两个内部内存条带、通过 SPI2 GDMA 输出缩放后的画面。这条串行路径只有在解压能塞进屏幕传输剩下的余量时才能按时完成,而这取决于画面内容。
重新引入操作系统后,第二道边界出现了:Deflate 解码已经移到 Python 之下、成为固件常驻模块,但相册仍沿用原来的文件系统读取、帧缓冲缩放和显示驱动,这条集成路径只有 12.170 FPS。而对串行路径的测量显示,每次传输期间 CPU 大部分时间都闲着——于是有了双核流水线。这段历程本身就是方法:架构是一层一层测量并搬走关键路径得来的。
5 RV565 的方法
方法沿着一帧从离线准备到点亮 LCD 的路径展开。核心原则是:把昂贵、不受约束的工作移到主机端,设备端每个阶段都要有上界、可观测,并尽可能不在关键路径上。
5.1 设计目标
四个目标:每帧工作量可预测、屏幕原生颜色、设备内存有上界、按键响应及时。设备端只做解析、解压、最近邻缩放和屏幕传输,缩放和格式决策全部放在主机端。
5.2 为什么要帧独立
RV565 v6 中每个压缩载荷都是一帧完整画面,而不是相对前一帧的差分。因此解码器只需全局头和该帧 4 字节的长度前缀,就能从任意索引帧开始。好处不止于跳转:循环播放不需要解码历史;错过期限时直接推进帧号即可;某个载荷损坏也不会污染后续帧的字典。
在 Deflate 内部,帧独立的代价比直觉要小。把连续帧拼成一个流时,前一帧同位置的像素恰好在当前位置之前 2WH 字节处。而 Deflate 的回溯引用最远只有 32,768 字节,所以只要
2WH > 32,768 ⇔ WH > 16,384
即帧大于 128×128 像素,共享流就够不着那个像素。此时共享流仍能复用统计信息和前一帧末尾附近的空间位移内容,但无法利用静态的同位置内容——而这正是视频时间冗余的主要来源。要利用它,就得像帧间编码那样做显式预测,代价是参考帧状态和跳转后的追赶解码。本文评估的帧为 180×135,即每帧 48,600 字节。
5.3 端到端架构
在独立固件中,视频容器直接链接进应用镜像,通过内存映射的 Flash 缓存读取,所以没有单独的文件系统读取。解码后的帧(每帧 48,600 字节)和两个 24 行的输出条带(每个 15,360 字节)都放在内部 SRAM,独立固件不启用外部 RAM 分配器。GDMA 传输条带 k 的同时,CPU 把条带 k+1 缩放进另一块缓冲。串行配置中,单个核心先解压一帧再传输;重叠配置中,第二个核心在传输期间把下一帧解压到第二块缓冲。
主机端流程:源视频 → 裁剪、缩放、帧率转换 → RGB888 转 RGB565(大端)→ 每帧独立 zlib level 1 压缩 → RV565 v6 容器。设备端流程:内存映射 Flash → 头校验与帧索引 → tinfl 解压(重叠配置在核心 1 上)→ SRAM 中的解码帧 → 核心 0 最近邻条带缩放 → SRAM 条带 A/B 交替 → SPI2 GDMA 排队传输 → 320×240 RGB565 LCD;调度由绝对期限循环控制:预取下一帧,迟到则跳帧。
测试平台使用 40 MHz、只写的 ST7789 屏幕接口(原文给出了 KiCad 绘制的板级原理图,见原文链接)。
5.4 容器格式
RV565 v6 文件以 12 字节的头开始,其后每帧是“32 位小端压缩长度 + 一个 zlib 封装的 Deflate 载荷”。解压成功必须恰好得到 2WH 字节的 RGB565 大端数据。
| 偏移 | 大小 | 字段 |
|---|---|---|
| 0 | 3 | 魔数 RV5 |
| 3 | 1 | 版本号 6 |
| 4 | 2 | 源宽度 |
| 6 | 2 | 源高度 |
| 8 | 1 | 帧率 |
| 9 | 1 | 标志位(目前为 0) |
| 10 | 2 | 帧数 |
| 12 | 4 | 第 0 帧压缩长度 |
| 16 | 可变 | 第 0 帧 zlib/Deflate 载荷 |
| … | … | 重复:长度 + 载荷 |
长度前缀让一次扫描就能建立帧偏移表,之后跳转只需改变压缩数据的读取位置,无需解码前面的帧。相册在第一次跳转时才懒加载偏移表;独立固件在打开片段时就建立指针表和长度表(240 帧共 1,920 字节)。由于解码前就知道每帧长度,它们还能用来预测解码代价(见 6.4 节)。
5.5 校验与故障恢复
媒体在两道关口接受检查。传输前,主机端导入工具会解码每个压缩帧,核对魔数、版本、尺寸、帧率、帧数、RGB565 输出长度是否精确、载荷是否完整覆盖、末尾有无多余数据,并把编码后的产物(而非源文件)同时与媒体配额和设备报告的剩余空间比较。中断或损坏的转换因此在变成相册输入之前就会被拒绝。
设备端会再校验一遍,因为存储中的媒体不能默认可信:尺寸必须在 320×240 屏幕之内,帧率限制在 1–30 FPS,帧数和压缩长度必须非零且不超过文件大小与已分配的输入缓冲。只有恰好解压出 2WH 字节才算成功,不完整的解码结果永远不会当作新帧显示。
5.6 主机端编码
编码器用 FFmpeg 定位到所需片段的起点,重采样到目标帧率,用 Lanczos 滤波器保持宽高比缩放,裁剪到目标尺寸,转换为 RGB565BE;然后每帧单独压缩为 level 1(最快档)的 zlib 流。这样编码器复杂度与设备时序无关,选 level 1 是有意选择“压得快”而非“压得最小”。
5.7 串行解码与条带流水线
独立解码器复用所有大块内存,并用 ESP-IDF 的按能力分配器把缓冲限定在可 DMA 的内部内存。串行配置下,它把一个独立载荷从内存映射 Flash 直接解压到解码帧缓冲,再把输出行缩放进两个 24 行条带之一(每帧 10 个条带)。片选在屏幕 RAMWR 命令和所有条带期间保持有效:第一个条带缩放后作为 SPI 事务交给 GDMA,传输期间 CPU 填充另一个条带,缓冲复用前等待上一个事务完成,然后两个缓冲交换角色。串行帧工作量为:
T_work(串行) = T_inflate + T_lcd, T_lcd ≈ T_wire + T_scale(首条带) + ε
其中只有第一个条带的缩放不能被 DMA 掩盖。对于 40 MHz 单线 SPI 上的 320×240 RGB565 帧:
T_wire = 320 × 240 × 16 bit / 40 MHz = 30.72 ms
也就是说,无论解码多快,全屏输出的上限都是 32.55 FPS。而且只有 T_work 的高分位尾部(不只是平均值)低于帧周期,播放才算达标,所以评估报告了分位数和观测最大值。
5.8 双核重叠解码
串行路径在每次传输的大部分时间里都让 CPU 等 DMA。重叠配置利用这段时间,把解压移到第二个核心:一个绑定在核心 1 的工作任务负责解压,通过两个单元素队列与核心 0 上的播放循环通信。传输第 k 帧之前,核心 0 请求把第 k+1 帧解压到另一块解码缓冲;然后在核心 1 解压的同时缩放并传输第 k 帧;最后等待工作任务的完成消息,再交换缓冲。帧工作量变为:
T_work(重叠) = max(T_lcd, T_inflate(k+1)) + ε_sync
只要解压比传输短,它就离开了关键路径。预取是推测性的:如果调度器跳了帧,预取的帧就作废,需要在关键路径上重新解压所需的帧,然后才能恢复播放。这一配置的代价是多一块 48,600 字节的解码缓冲和 24 KiB 的工作任务栈(tinfl 把解压状态放在栈上)。两种配置来自同一份源码、编译时选择,固件每次运行都会报告当前配置,所以两者的对比是严格的受控消融。
5.9 OreoOS 集成
在本文测量的版本中,相册负责播放策略,固件只负责解压。每一帧,相册从文件系统读取一个带长度前缀的载荷到复用的压缩缓冲,再把这块缓冲和一块复用的 RGB565 帧缓冲以 memoryview 形式传给固件常驻模块 _oreo_rv565(一个 MicroPython 用户 C 模块)。该模块调用 tinfl,校验解码长度是否精确,返回时不创建临时 Python bytes 对象,也不创建 Python 管理的 Deflate 流。随后一个独立的原生相册例程把帧缩放进 OreoOS 153,600 字节的帧缓冲,Python 绘制播放控件浮层,显示驱动用阻塞式 SPI 写分四块(每块 38,400 字节,间隔 200 μs,用来平滑电源电流)输出这一帧。由于这块板子上 MicroPython 堆位于 PSRAM,压缩缓冲、解码帧和帧缓冲都在外部 RAM 中,各阶段严格串行。
5.10 调度与交互语义
对于帧率为 f 的片段,帧周期 P = 1/f,第 i 帧的名义期限为 d_i = t_0 + iP。独立播放器每次传输后推进绝对期限,并等待周期的剩余时间;如果时钟落后超过一个周期,就同时推进期限和帧号,直到它们重新指向未来的显示时刻。被跳过的帧计为丢帧,绝不会为了追赶而解码。
相册在操作系统边界采用“有界工作量”规则:每个更新周期最多解码一帧,超过一个周期的累计延迟直接丢弃,而不是变成一阵突发工作。因此相册落后时不跳帧,而是放慢播放。这样能限定到下一次按键轮询的时间——播放慢一点,总好过界面卡死。
6 评估
| 参数 | 取值 |
|---|---|
| 开发板 | ESP32-S3-DevKitC-1 N16R8 |
| 处理器 | 双核 Xtensa LX7,240 MHz(配置值) |
| 存储 | 16 MiB Flash,8 MiB Octal PSRAM |
| 屏幕 | ST7789,320×240,RGB565,四线 SPI;背光 60% PWM |
| SPI 时钟 | 40 MHz(驱动报告值) |
| 独立固件 | ESP-IDF v5.5.1,关闭 PSRAM 分配器 |
| 集成环境 | MicroPython v1.28.0 + 固件常驻解压器 + 相册应用 |
| 视频源 | Big Buck Bunny,480p H.264,CC BY 3.0 |
| 负载 | W90:第 90–100 秒;W360:第 360–370 秒;各 240 帧,180×135,24 FPS |
6.1 负载与方法
两段片段在测量前就选定,压缩后的大小差异明显:W90 压缩后 6,421,314 字节(单帧载荷 24,341–33,261 字节,平均 26,751),W360 为 8,959,471 字节(36,958–37,816 字节,平均 37,327),而 240 帧原始数据为 11,664,012 字节。
计时使用 ESP32-S3 的单调微秒计时器。独立固件中,帧工作量从播放循环开始一帧算起,到该帧最后一个 LCD 事务完成为止(重叠配置还要等到下一帧的解压结果被取回);解压、累计条带缩放、累计 DMA 等待和整个 LCD 阶段分别计时。相册中,帧工作量从解码该帧的更新周期开始,到屏幕刷新结束为止,文件系统读取、解压、缩放、绘制和输出分别计时,剩余部分记为操作系统循环和解释器开销。帧工作量超过帧周期即为一次超时。
每种独立配置先预热 2 秒(48 帧),再计时 30 秒(720 帧);每种配置独立复位运行 10 次,共 7,200 个帧样本,每个源帧覆盖 30 次。逐帧记录先保存在 RAM 中,计时结束后才打印,避免串口输出影响延迟。每个结果都绑定到应用镜像、媒体、构建配置和相关源文件的 SHA-256 摘要,以及对应的原始串口日志和逐帧 CSV;采集器拒绝帧序号不完整的数据,汇总器拒绝合并摘要、配置或元数据不一致的运行。
6.2 结果
| 负载 | 路径 | 帧数 | FPS | 丢帧 | 超时 | 均值 (ms) | p50 | p95 | p99 | 最大值 |
|---|---|---|---|---|---|---|---|---|---|---|
| W90 | 独立,串行 | 7,200 | 24.002 | 0 | 0 | 40.224 | 40.205 | 40.741 | 41.348 | 41.353 |
| W90 | 独立,双核重叠 | 7,200 | 24.009 | 0 | 0 | 31.324 | 31.322 | 31.331 | 31.333 | 31.334 |
| W360 | 独立,串行 | 7,200 | 23.905 | 20 | 7,200 | 41.830 | 41.828 | 41.869 | 41.881 | 41.901 |
| W360 | 独立,双核重叠 | 7,200 | 24.009 | 0 | 0 | 31.326 | 31.324 | 31.334 | 31.335 | 31.336 |
| W90 | OreoOS 相册,固件解压 | 720 | 12.170 | 不适用 | 720 | 80.400 | 80.174 | 86.067 | 87.117 | 92.102 |
串行路径在 W90 上满足了每一个 41.667 ms 的期限,最差帧仍有 0.314 ms 余量;但在 W360 上全部超时:平均帧工作量比周期多 0.163 ms,延迟不断累积,直到期限循环大约每 256 帧跳过一个源帧,最终 20 次丢帧、23.905 FPS。双核路径在两段片段上都满足所有期限,最差帧仍有 10.331 ms 余量。各次运行之间的差异可以忽略,例如 W90 重叠配置平均帧工作量的 95% 置信区间为 31.32397–31.32401 ms。
集成路径 720 个期限全部超时,显示帧率 12.170 FPS;由于相册放慢而不跳帧,W90 实际以 0.507 倍速播放。
在 W90 上,各路径的平均关键路径构成如下:
- 独立串行:解压 8.9 ms + LCD 31.3 ms(线速 30.72 ms,缩放在 DMA 期间完成)= 40.2 ms;
- 独立双核:核心 0 上 LCD 31.3 ms,核心 1 同时解压下一帧 8.9 ms,帧时间 = 31.3 ms;
- OreoOS 相册:文件系统读取 27.6 ms + 解压 9.5 ms + 缩放与浮层 9.1 ms + 阻塞式 SPI 输出 33.3 ms + 系统与解释器 0.83 ms = 80.4 ms。
6.3 重叠把解码移出关键路径
在串行路径中,前述公式对两段片段的实测平均值误差都在 2 μs 以内(W90:8.910 + 31.312 对 40.224 ms;W360:10.516 + 31.312 对 41.830 ms)。T_lcd 均值 31.312 ms,对比 30.72 ms 的线速时间,屏幕传输跑到了总线上限的 98.1%。在这段时间里,核心 0 花 2.598 ms 缩放、28.470 ms 等待 DMA,传输期间 90.9% 的时间处于空闲;而传输之前那段随内容变化的解压,决定了这一帧会不会迟到。
双核路径把这段空闲变成了解码能力。核心 1 在 W90 上解压耗时 8.902 ms,在 W360 上 10.503 ms,与串行路径的 8.910 和 10.516 ms 相差不到 13 μs,说明两个核心并发运行对解压没有可测的争用代价。帧工作量降到 31.324 和 31.326 ms,只比传输多出 8 和 9 μs 的同步开销,降幅分别为 22.1% 和 25.1%。此时调度只受总线限制:解压只占被它“藏住”的那段传输时间的 28%–34%,每帧余量 10.3 ms。按传输时间推算,全屏帧的上限约为 31.9 FPS(作者未实测)。
6.4 从容器就能预测解码代价
解压时间与存储的载荷长度 L 呈线性关系。在 W90 上,串行运行拟合得到:
T_inflate ≈ 4.950 ms + 0.1480 μs/字节 × L
用偶数帧拟合、奇数帧测试,平均绝对误差 0.023 ms。W360 的载荷长度只跨 2.3%,单独拟合条件很差。更有说服力的是跨片段迁移:W90 模型预测 W360 每一帧的平均误差为 0.040 ms(最大 0.097 ms),而只用 W360 窄范围拟合的模型预测 W90 平均偏差 0.86 ms。合并两段片段得到:
T_inflate ≈ 4.858 ms + 0.1515 μs/字节 × L (r² = 0.9987,平均绝对误差 0.022 ms)
截距对应产出 48,600 字节输出的固定开销,斜率对应符号解码。
因为 L 已经存储在容器里,可调度性在导入时就能判定。把上式和实测 T_lcd 代入串行路径公式,串行路径只对满足以下条件的帧才能按时完成:
L ≤ L* = (P − T_lcd − a) / b ≈ 36,273 字节
W90 最大载荷 33,261 字节,W360 最小载荷 36,958 字节,所以阈值预测 W90 没有一帧迟到、W360 每一帧都迟到——实测在全部 480 个源帧上与预测一致。而在双核路径下,只要 a + bL ≤ T_lcd,解压就被完全隐藏,也就是载荷最大可到约 175 KB,远大于 48,600 字节的原始帧;在测量范围内,双核路径的可调度性已与内容无关。这一性质来自独立、单一编码的帧,对解码代价依赖参考帧状态的格式并不成立。
6.5 帧独立的代价
为了衡量帧独立放弃了什么,作者在主机上无损解码两个容器,再用不同组织方式重新压缩完全相同的 RGB565 帧(逐帧 level 1 重压缩与发布的 480 个载荷逐字节一致,验证了实验设置)。
| 组织方式 | W90 字节数 | 相对值 | W360 字节数 | 相对值 |
|---|---|---|---|---|
| 逐帧独立,level 1(实际采用) | 6,420,342 | 1.000 | 8,958,499 | 1.000 |
| 单一共享流,level 1 | 6,488,284 | 1.011 | 8,947,582 | 0.999 |
| 逐帧独立,level 6 | 6,027,250 | 0.939 | 8,881,173 | 0.991 |
| 逐帧独立,level 9 | 6,024,303 | 0.938 | 8,881,230 | 0.991 |
| 单一共享流,level 9 | 6,076,028 | 0.946 | 8,864,882 | 0.990 |
| 与前一帧 XOR 后 level 1 | 1,254,138 | 0.195 | 4,840,026 | 0.540 |
在 Deflate 框架内,帧独立是免费的:单一共享流在 W90 上反而大 1.1%,在 W360 上只小 0.1%,level 9 下差异也不超过 0.9%——前一帧同位置的像素根本够不着。压缩级别对 W90 影响更大(level 1 到 6 缩小 6.1%),对 W360 只有 0.9%;它对设备端解压时间的影响未测,所以评估仍用 level 1。
但相对于显式时间预测,帧独立并不免费。与前一帧 XOR 后再用同样的 level 1 压缩,W90 载荷缩小 80.5%,W360 缩小 46.0%:这部动画有大片静止区域,冗余恰好位于 2WH 字节之外、Deflate 够不到的地方。因此在这类内容上,帧独立的真实代价是数据流大 1.9–5.1 倍,换来的是随机跳转、局部错误恢复,以及 6.4 节那种可由长度预测的解码代价。采用有界帧组的“关键帧 + 差分”变体可以收回一部分代价;双核路径的第二块解码缓冲本身就能充当参考帧,但那样解码代价就会依赖参考链。
6.6 集成差距的构成
| 阶段 | 均值 (ms) | p99 (ms) | 最大值 (ms) |
|---|---|---|---|
| 文件系统读取 | 27.631 | 33.546 | 34.383 |
| 解压 | 9.525 | 10.574 | 10.850 |
| 缩放(原生) | 7.641 | 8.194 | 8.743 |
| 浮层绘制 | 1.459 | 2.074 | 7.010 |
| 屏幕输出 | 33.317 | 33.918 | 34.344 |
| 系统与解释器剩余 | 0.827 | 1.315 | 5.204 |
| 帧工作量 | 80.400 | 87.117 | 92.102 |
解释器和操作系统循环平均只占 0.83 ms(p99 1.32 ms)。与独立路径的差距来自三处结构性差异:
文件系统读取。 读一个平均 26,751 字节的载荷要 27.631 ms,有效吞吐 0.97 MB/s。按比例换算到未压缩的 48,600 字节帧,光读取就要约 50.2 ms,超过 41.7 ms 的周期。所以在这条路径上,Deflate 不只是为了省存储——没有它,输入根本进不了帧周期。独立路径读内存映射 Flash,完全避开了这一开销。
内存布局。 集成路径解压 9.525 ms,独立路径同样的帧只要 8.910 ms。两者每字节开销相近(0.1416 对 0.1480 μs/字节),但固定开销高 0.79 ms(5.738 对 4.950 ms),与“把 48,600 字节输出写到 PSRAM 而不是内部 SRAM”相符。缩放 7.641 ms 对 2.598 ms:前者要写满 PSRAM 中 153,600 字节的整个帧缓冲,而不是 SRAM 条带;不过两者的缩放内核也不同,所以这 2.9 倍差距不能全部归因于内存布局。
串行化的屏幕输出。 屏幕输出耗时 33.317 ms,比线速时间多 2.60 ms,其中 0.6 ms 是有意加入的分块间隔。与独立路径不同,输出期间没有任何其他阶段在运行。
这些结果指明了下一步:只去掉文件系统读取,仍剩约 52.8 ms 的串行工作,依然超过周期。要在 OreoOS 内达到 24 FPS,必须把独立路径的整套结构——内存驻留的输入、SRAM 条带、与 DMA 重叠的缩放、第二核上的解压——做成一个固件操作,由相册每帧调用一次。
6.7 内存
| 配置 | SRAM | PSRAM | 漂移 | 最小剩余 |
|---|---|---|---|---|
| 串行 | 79.9 KiB | 0 KiB | 1.4 KiB | 240.4 KiB |
| 双核重叠 | 152.4 KiB | 0 KiB | 1.4 KiB | 167.9 KiB |
串行路径在片段表之外分配了 81,816 字节内部 SRAM:48,600 字节解码帧、两个 15,360 字节条带和分配器开销。双核路径分配了 156,096 字节,多出的 74,280 字节是第二块解码帧、24 KiB 工作任务栈以及队列和任务控制块。两个数字都不含 23,040 字节的逐帧测量数组。1.4 KiB 的漂移来自 SPI 和背光驱动初始化,每次运行都相同,播放过程中堆不会增长。
7 局限与效度威胁
评估只用了一部动画片的两段 10 秒片段、一块开发板、一种分辨率和帧率。解压模型的系数和帧独立的测量结果都只是这类内容和配置的属性;摄像机噪点等统计特性不同的素材可能改变它们,不过“WH > 16,384 时共享流够不着同位置像素”的条件与内容无关。没有报告画质指标,也没有与 MJPEG 或乐鑫(Espressif)播放器做编码基线对比——那需要固定解码尺寸、帧率、片段范围和画质,并考虑完整的资源占用,而不只是体积或 FPS。集成路径尚未使用双核流水线,输入延迟也没有测量。
RV565 目前没有声音、只支持 RGB565、面向小屏幕。后续工作包括:为 OreoOS 实现融合的双核固件路径、解码代价有界的“关键帧 + 差分”变体、在导入工具中基于 L* 阈值做可调度性检查、SD 卡流式读取、音画同步、画质对齐的编码基线、用逻辑分析仪测量输入延迟,以及支持其他处理器和总线宽度。
8 结论
RV565 把单片机视频当作一个整体的“存储、内存、解码、调度和显示”时序问题来处理。在 ESP32-S3 上,它的双核流水线把解压藏在屏幕传输背后,在串行路径无法调度的内容上稳定 24 FPS、零超时、余量 10.3 ms,帧时间只受 SPI 传输限制,而传输跑到了线速上限的 98.1%。独立 Deflate 帧让解码代价成为容器中已有长度的线性函数,因此播放前就能判定可调度性;超过 128×128 像素后,它相对共享 Deflate 流没有任何代价,但放弃了显式时间预测能节省的 46%–80%。在 MicroPython 操作系统中,只把解压移入固件是不够的:分阶段测量把剩余开销定位到串行化的文件系统读取、外部内存布局和阻塞式屏幕输出。
作者公开了固件、测量数据和分析脚本(Zenodo,DOI:10.5281/zenodo.23543367)。
译者注
- 最值得带走的结论:先算总线账,再谈解码。 320×240、RGB565、40 MHz 单线 SPI 的一帧线速时间是 30.72 ms,全屏帧率天花板约 32.55 FPS,与解码器快慢无关。做 SPI 屏项目时,先用“像素数 × 16 bit ÷ SPI 时钟”算出这个上限,再决定分辨率、帧率和是否局部刷新,能少走很多弯路。想突破这个上限,只能换 8080 并口、QSPI 屏或 ESP32-S3 的 RGB 接口屏。
- 可直接复用的三个工程技巧。 一是 DMA 双缓冲条带:小块缓冲放在可 DMA 的内部 SRAM(ESP-IDF 中用 heap_caps_malloc 加 MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL),一边传输一边填下一块;二是把解压等纯计算任务用 xTaskCreatePinnedToCore 绑定到另一个核心,用队列同步;三是热数据尽量别放 PSRAM——论文实测把输出写到 PSRAM,解压固定开销就多了约 0.8 ms,缩放更是慢了近 3 倍。在 MicroPython 项目里,把热点路径写成用户 C 模块、用 memoryview 传缓冲避免拷贝,比优化 Python 代码有效得多。
- 与物联网设备上云的关系。 文中“主机端预处理 + 导入前严格校验 + 设备端再校验”的思路,同样适用于需要远程下发媒体或固件的设备:在云端完成转码和校验,设备只做有界的简单工作。在华为云上,设备可以通过设备接入 IoTDA 上报播放帧率、超时次数等运行指标,并利用其 OTA 升级能力下发新的媒体包或固件;论文提出的“按载荷长度预判能否按时解码”,也可以放到云端的打包流程里,在下发前就过滤掉设备播不动的内容。
- 点赞
- 收藏
- 关注作者
评论(0)