ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

ESP32-P4 MJPEG播放优化:从AVI到DMA2D的30fps实战

2026/10/6 10:54:44 拓冰建站 浏览量
ESP32-P4 MJPEG播放优化:从AVI到DMA2D的30fps实战 1. 为什么要在 ESP32-P4 上折腾 MJPEG 播放第一次拿到 ESP32-P4 的开发板时我脑子里想的其实不是点灯也不是跑什么跑分测试而是想看看这块带 JPEG 硬件编解码器的芯片到底能不能流畅播一段视频。手头正好有一批用手机拍的 MJPEG 素材想着直接拿来用结果从 AVI 容器封装到纯 MJPEG 裸流再到 DMA2D 加速显示前前后后折腾了差不多两周踩的坑比我想象的多得多。这篇文章就是把这整个过程完整地摊开来讲。核心关键词是ESP32-P4、MJPEG、AVI、FFmpeg、DMA2D我会从视频源的处理开始讲到怎么用 FFmpeg 把 AVI 转成纯 MJPEG 序列再讲到 ESP32-P4 端怎么解码、怎么用 DMA2D 把帧率拉上去。适合谁看如果你手里有 ESP32-P4 或者类似带 JPEG 硬解的单片机想做一个本地视频播放器、UI 动效背景、或者产品演示屏那这篇内容基本可以直接抄作业。如果你只是好奇 MJPEG 这种古老的格式为什么在嵌入式领域还这么能打也能从里面找到答案。先说结论ESP32-P4 播 MJPEG 这件事瓶颈从来不在 JPEG 解码本身硬件解码一帧 800x480 的 JPEG 大概只要几毫秒真正吃掉帧率的是数据搬运和显示刷新。我最初用 AVI 容器直接读帧率只有 12fps 左右改成纯 MJPEG 序列 DMA2D 之后同样的素材跑到了 30fps 以上翻了一倍还多。这个过程中 FFmpeg 的转码参数、文件系统的读取方式、DMA2D 的配置每一个环节都有讲究。2. 视频源处理从 AVI 到纯 MJPEG 的完整转换链路2.1 为什么不能直接用 AVI 播放很多人第一反应是AVI 不就是容器吗里面装的就是 MJPEG 帧直接读不就行了理论上没错但实际做起来问题一大堆。AVI 是 RIFF 格式的一种它的结构是分块的文件头、流头、索引块、数据块。你要播放一帧得先解析索引找到对应帧在文件里的偏移再跳过去读。这个过程在 PC 上无所谓但在 ESP32-P4 上每一次文件 seek 都是一次 flash 或 SD 卡的随机读取延迟很高。我实测过用 AVI 索引方式读一帧光定位就要 2 到 3 毫秒加上读取数据的时间一帧还没解码就已经花掉 5 毫秒以上。更麻烦的是 AVI 的索引块本身可能很大。一个 30 秒的 800x480 MJPEG 视频索引块能到几十 KB全部加载到内存里对 ESP32-P4 来说虽然不算致命但也没必要。而且 AVI 里还可能混有音频流解析的时候要跳过逻辑复杂容易出 bug。所以我的选择很明确把 AVI 拆掉只留纯 MJPEG 帧序列。每一帧就是一个独立的 JPEG 文件按序号命名播放的时候顺序读就行。这样文件系统层面可以用最简单的顺序读甚至可以用内存映射的方式省掉 seek 的开销。2.2 用 FFmpeg 提取 MJPEG 帧的实操命令FFmpeg 是这件事的核心工具不管你用什么系统先把 FFmpeg 装好。Windows 上直接去官网下那个ffmpeg-master-latest-win64-gpl.zip解压后把 bin 目录加到 PATH 里就行。Linux 上apt install ffmpeg或者自己编译都可以如果要做 NVIDIA 硬件加速转码那就得装带 nvenc 的版本不过我们这里只是提取帧用不上硬件加速普通版本足够。提取 MJPEG 帧的命令其实很简单ffmpeg -i input.avi -c:v copy -f image2 frame_%04d.jpg这里-c:v copy是关键它表示视频流直接复制不做重新编码。因为 AVI 里的 MJPEG 本来就是 JPEG 帧复制出来就是原汁原味的 JPEG没有任何画质损失速度也快。-f image2指定输出格式为图片序列frame_%04d.jpg是命名模板生成的就是 frame_0001.jpg、frame_0002.jpg 这样。但这里有个坑不是所有 AVI 里的 MJPEG 都是标准 JPEG。有些编码器会在 JPEG 数据前后加一些私有头或者用非标准的 Huffman 表。直接 copy 出来的文件用图片查看器能打开但 ESP32-P4 的硬件解码器可能不认。我遇到过一批素材copy 出来的 JPEG 在 PC 上正常到了板子上解码就报错。解决办法是重新编码一遍强制走标准 JPEG 编码器ffmpeg -i input.avi -c:v mjpeg -q:v 3 -f image2 frame_%04d.jpg-q:v 3是质量参数范围 2 到 31数字越小质量越高。3 已经是很高的质量了文件大小也还能接受。如果你对画质要求没那么高可以用 5 或者 7文件能小不少解码压力也更低。2.3 帧率、分辨率与文件大小的权衡在嵌入式设备上做视频播放永远是在画质、帧率、存储空间之间做取舍。ESP32-P4 的 JPEG 解码器支持到 1080p但实际能跑多快取决于你的分辨率和内存带宽。我做过一组测试用同一段 10 秒的素材分别转成不同分辨率看文件大小和解码帧率分辨率质量参数单帧大小总大小(10s30fps)实测解码帧率1280x7203约 85KB25.5MB18fps800x4803约 42KB12.6MB32fps640x4805约 28KB8.4MB45fps480x3205约 16KB4.8MB60fps可以看到分辨率降下来之后帧率提升非常明显。800x480 是一个比较甜点的分辨率大部分 4.3 寸到 5 寸的屏都是这个分辨率32fps 已经足够流畅了。如果你用的是 7 寸 1024x600 的屏那可能得降到 640x480 才能跑满 30fps。还有一个技巧用 FFmpeg 的 scale 滤镜在转码时直接缩放不要先转再缩那样多一次编码损失ffmpeg -i input.avi -vf scale800:480 -c:v mjpeg -q:v 3 -f image2 frame_%04d.jpg2.4 打包成适合嵌入式读取的格式生成一堆 JPEG 文件之后直接放到 SD 卡里也能用但文件系统对大量小文件的读取效率并不高。每个文件都要走一遍 open、read、close开销不小。我的做法是把所有 JPEG 帧打包成一个二进制文件前面加一个简单的索引表。索引表的格式很简单先是一个 4 字节的帧数然后每帧一个 4 字节的偏移量和 4 字节的长度。播放的时候先把索引读进来然后直接按偏移量读数据一次顺序读就能拿到一整帧省掉了文件系统的开销。打包脚本用 Python 写就行import os import struct frames sorted([f for f in os.listdir(.) if f.endswith(.jpg)]) with open(video.mjpeg, wb) as out: out.write(struct.pack(I, len(frames))) offset 4 len(frames) * 8 index [] for f in frames: data open(f, rb).read() index.append((offset, len(data))) offset len(data) for off, length in index: out.write(struct.pack(II, off, length)) for f in frames: out.write(open(f, rb).read())这样生成一个video.mjpeg文件播放的时候直接读索引然后按偏移读帧效率比读一堆小文件高得多。3. ESP32-P4 端的解码与显示架构设计3.1 整体数据流设计ESP32-P4 播放 MJPEG 的数据流我最终定下来的架构是这样的存储Flash/SD卡→ 读取缓冲区PSRAM→ JPEG 硬件解码器 → 解码输出缓冲区PSRAM→ DMA2D → 显示屏这里面每一个环节都有优化空间。最开始我是解码完直接往屏幕上写用 CPU 搬运帧率上不去。后来改成 DMA2D 搬运CPU 占用一下子降下来了帧率也上去了。关键点是双缓冲。JPEG 解码器解一帧需要时间DMA2D 搬运一帧也需要时间如果串行做总时间就是两者之和。用双缓冲之后解码器解下一帧的同时DMA2D 在搬上一帧两者并行总时间取决于较慢的那个。3.2 JPEG 硬件解码器的配置要点ESP32-P4 的 JPEG 解码器是通过esp_jpeg组件或者直接调jpeg_decoder驱动来用的。配置的时候有几个参数要注意输出格式支持 RGB565、RGB888、YUV422 等。如果你的屏幕是 RGB565 接口那就直接输出 RGB565省掉一次格式转换。我一开始输出 RGB888然后软件转 RGB565白白浪费了 CPU 时间。解码缓冲区对齐JPEG 解码器的输出缓冲区需要按 16 字节对齐否则 DMA2D 搬运的时候会出问题。这个在分配内存的时候用heap_caps_aligned_alloc指定对齐就行。超时设置解码一帧的时间跟分辨率有关800x480 大概 5 到 8 毫秒。超时设太短会误报设太长出问题了又卡住。我一般设 100 毫秒足够宽松。解码的调用流程大概是jpeg_decode_memory(frame_data, frame_len, output_buf, out_len, cfg);这个函数是阻塞的解完才返回。如果你要做流水线可以放到单独的任务里解完通过队列通知显示任务。3.3 DMA2D 加速显示的原理与配置DMA2D 是 ESP32-P4 里专门用来做 2D 图形搬运和外设刷新的硬件模块。它可以在不占用 CPU 的情况下把一块内存的数据搬到另一块内存或者直接搬到显示屏的接口 FIFO 里。用 DMA2D 刷屏的关键配置源地址解码输出缓冲区的地址必须是 PSRAM 里 DMA 可访问的区域。目标地址显示屏的 framebuffer 或者直接是 LCD 接口的数据寄存器。搬运宽度和高度跟你的分辨率一致。像素格式RGB565 的话就是 16 位。配置好之后调一次dma2d_start_transfer就启动了搬运完成会触发中断。你在中断里切换缓冲区然后启动下一帧的搬运。这里有个坑DMA2D 的源地址必须 4 字节对齐而且搬运的行长度如果是奇数像素可能会有对齐问题。我建议分辨率都用偶数比如 800x480、640x480避免麻烦。3.4 双缓冲与流水线调度的实现双缓冲的实现逻辑是这样的准备两个解码输出缓冲区 A 和 B。解码任务解一帧到 A解完后把 A 标记为待显示。显示任务发现 A 待显示启动 DMA2D 从 A 搬运到屏幕。在 DMA2D 搬运 A 的同时解码任务继续解下一帧到 B。DMA2D 搬完 A触发中断显示任务把 B 标记为待显示启动搬运 B。如此循环。这样解码和显示完全并行帧率取决于两者中较慢的那个。实测下来800x480 的 MJPEG解码一帧约 6 毫秒DMA2D 搬运一帧约 4 毫秒并行之后每帧约 6 到 7 毫秒算下来能到 140fps 以上。当然实际受限于读取速度和屏幕刷新率最终稳定在 30 到 40fps。4. 实操过程中的性能优化与踩坑记录4.1 从 12fps 到 30fps 的关键改动我最初版本的帧率只有 12fps问题出在三个地方第一用 AVI 索引方式读取每次 seek 都有延迟。改成纯 MJPEG 打包文件之后顺序读读取时间从每帧 3 毫秒降到 0.5 毫秒。第二解码输出 RGB888 然后软件转 RGB565这个转换每帧要花 4 到 5 毫秒。改成解码器直接输出 RGB565 之后这部分时间直接省掉了。第三用 CPU 搬运到屏幕每帧搬运 800x480x2 字节 768KBCPU 搬运要 8 到 10 毫秒。改成 DMA2D 之后CPU 完全不参与时间降到 4 毫秒左右而且不占用 CPU。这三项改完帧率从 12fps 直接跳到 32fps。4.2 内存分配与 PSRAM 使用的注意事项ESP32-P4 有内部 SRAM 和外部 PSRAM。JPEG 解码的输出缓冲区比较大800x480x2 768KB必须放在 PSRAM 里。但 PSRAM 的访问速度比内部 SRAM 慢DMA2D 从 PSRAM 读数据也会慢一些。我的做法是解码输出缓冲区放 PSRAM但 DMA2D 的配置里开启缓存和预取。ESP32-P4 的 DMA2D 支持从 PSRAM 读取时使用 cache能明显提升搬运速度。具体是在配置结构体里设置use_cache true。另外PSRAM 的分配要用heap_caps_malloc指定MALLOC_CAP_SPIRAM不要用普通的 malloc否则可能分配到内部 SRAM 里大块内存分配会失败。4.3 文件读取速度的优化技巧从 Flash 或 SD 卡读数据速度是另一个瓶颈。我试过几种方式标准 fopen/fread每帧读取约 1.5 毫秒不算慢但也不快。内存映射mmap如果视频文件放在 Flash 的只读分区里可以用 mmap 直接映射读取就是内存访问几乎零开销。但 Flash 容量有限大视频放不下。预读取用一个后台任务提前把后续几帧读到内存缓冲区里播放任务直接从缓冲区取。这个方式最灵活SD 卡和 Flash 都能用。我最终用的是预读取方案开一个 4 帧的环形缓冲区后台任务持续填充播放任务消费。这样读取的延迟被完全隐藏了。4.4 常见问题速查表问题现象可能原因解决方法解码报错返回错误码JPEG 数据非标准用 FFmpeg 重新编码加-c:v mjpeg屏幕花屏颜色不对像素格式不匹配检查解码输出格式和屏幕接口是否一致帧率低CPU 占用高用 CPU 搬运改用 DMA2DDMA2D 搬运卡住地址未对齐确保源地址 4 字节对齐分辨率用偶数播放几帧后卡死缓冲区溢出检查双缓冲切换逻辑加信号量保护内存分配失败分配到内部 SRAM用heap_caps_malloc指定 PSRAM4.5 几个容易被忽略的细节JPEG 解码器的时钟频率ESP32-P4 的 JPEG 解码器时钟可以配置默认频率下解码 800x480 约 8 毫秒提高时钟后能到 5 毫秒左右。但提高时钟会增加功耗如果对功耗敏感可以保持默认。DMA2D 的中断优先级如果系统里还有其他中断DMA2D 的中断优先级要设高一点否则搬运完成中断被延迟会影响下一帧的启动时间。屏幕的刷新率有些屏幕支持 60Hz 刷新但实际写入速度跟不上会出现撕裂。可以在 DMA2D 搬运完成后等一个垂直同步信号再切换缓冲区避免撕裂。5. 进阶玩法与扩展思路5.1 用 FFmpeg 做批量转码的脚本化如果你有很多视频要转一个个敲命令太慢。我写了一个 shell 脚本自动遍历目录下所有 AVI 文件转成 MJPEG 序列再打包#!/bin/bash for f in *.avi; do name${f%.avi} mkdir -p $name ffmpeg -i $f -vf scale800:480 -c:v mjpeg -q:v 3 -f image2 $name/frame_%04d.jpg python3 pack.py $name $name.mjpeg rm -rf $name done这样一条命令就能处理整个目录省事很多。5.2 在 ESP32-P4 上做 UI 动效背景MJPEG 播放不只是播视频还可以用来做 UI 的动态背景。比如做一个半透明的 MJPEG 层上面叠加 UI 控件。ESP32-P4 的 DMA2D 支持 alpha 混合可以把 MJPEG 帧和 UI 图层混合输出。具体做法是解码输出到缓冲区 AUI 渲染到缓冲区 B然后用 DMA2D 的 blend 模式把 A 和 B 混合到屏幕。这样就能实现视频背景加 UI 的效果帧率也能保持在 30fps 左右。5.3 结合 LVGL 做视频播放器界面如果你用 LVGL 做 UI可以把 MJPEG 播放封装成一个 LVGL 的 image 对象每解码一帧就更新 image 的源数据。LVGL 的刷新机制会自动处理重绘。不过要注意 LVGL 的刷新和 DMA2D 的搬运要协调好避免同时操作同一块缓冲区。我一般的做法是MJPEG 解码任务解完一帧后通过lv_img_set_src更新 imageLVGL 在下一个刷新周期会调用 DMA2D 把 image 搬到屏幕。这样 LVGL 的 UI 和视频播放就能共存了。5.4 性能还能再压榨吗目前 800x480 跑到 32fps我觉得还有提升空间。几个方向用 JPEG 解码器的缩放输出ESP32-P4 的 JPEG 解码器支持输出缩放比如解码 800x480 但输出 400x240数据量减半DMA2D 搬运时间也减半。如果屏幕分辨率不高这个很划算。用 DMA2D 的块搬运模式DMA2D 支持按块搬运可以只更新屏幕变化的部分而不是全屏刷新。对于 UI 场景这个能省不少带宽。多核并行ESP32-P4 是双核的可以把解码和显示放到不同核心上减少相互干扰。这些我还在试等有稳定结果了再分享。最后说一个我踩过的坑不要用 LabVIEW 生成的 YUV 格式 AVI 直接转 MJPEG。LabVIEW 生成的 AVI 里YUV 数据的排列方式跟标准不一样FFmpeg 读出来颜色是错的。如果非要用得先用 FFmpeg 做一次色彩空间转换加-pix_fmt yuvj420p强制指定像素格式否则转出来的 JPEG 颜色会偏。这个坑我调了整整一个下午才找到原因。