ESP32-P4 MJPEG视频播放器开发实战:从摄像头到SD卡的完整解决方案

290 阅读13分钟

ESP32-P4 MJPEG视频播放器开发实战:从摄像头到SD卡的完整解决方案

项目背景

本文记录了在ESP32-P4开发板(配ST7703 LCD屏幕)上,将摄像头视频采集改为SD卡MJPEG视频播放的完整开发过程。整个过程历经多次技术选型和问题排查,最终实现了稳定的24fps多视频轮播系统。

开发环境:

  • 芯片:ESP32-P4
  • 屏幕:ST7703 MIPI-DSI (720x720)
  • ESP-IDF:v5.5.1
  • 视频格式:MJPEG (480x480 @ 24fps)

第一阶段:技术选型与初步实现

1.1 文件格式选择

初始方案:AVI容器 + MJPEG编码

最初选择了AVI容器格式,理由如下:

  • 成熟的格式,有现成的解析库
  • 包含完整的元数据(分辨率、帧率等)
  • 可以直接从已有AVI文件读取

遇到的第一个问题:AVI文件解析

实现了基于内存搜索的AVI解析器:

// 搜索"movi"标识定位数据区
uint32_t movi_offset = search_fourcc(header_buf, read_size, "movi");

// 逐帧读取00dc chunk
while (fread(chunk_header, 18, fp) == 8) {
    if (chunk_id == 0x63643030) {  // "00dc"
        // 读取JPEG帧数据
        fread(jpeg_data, 1, chunk_size, fp);
    }
}

这部分基本顺利,能正确提取JPEG帧数据。

1.2 JPEG硬件解码器集成

ESP32-P4内置硬件JPEG解码器,理论性能很高。按照官方文档配置:

// 创建解码器引擎
jpeg_decode_engine_cfg_t decode_eng_cfg = {
    .intr_priority = 0,
    .timeout_ms = 40,
};
ESP_ERROR_CHECK(jpeg_new_decoder_engine(&decode_eng_cfg, &decoder_handle));

// 分配输入/输出缓冲区
jpeg_decode_memory_alloc_cfg_t rx_mem_cfg = {
    .buffer_direction = JPEG_DEC_ALLOC_OUTPUT_BUFFER,
};
output_buf = jpeg_alloc_decoder_mem(width * height * 3, &rx_mem_cfg, &size);

第二阶段:问题爆发 - 解码失败与色块

2.1 现象描述

运行后出现以下问题:

  1. 每帧都超时ESP_ERR_TIMEOUT
  2. 输出数据全0:即使out_size正确,但buffer内容是全0
  3. 屏幕显示规则色块/网格:绿色、紫色、粉色相间的马赛克

关键日志:

E (6392jpeg.decoderjpeg_decoder_process timeout
I (6392video_playerDecoded frame #1 output data:
I (6392video_player:   00 00 00 00 00 00 00 00 00 00 00 00 ...
W (6392video_playerJPEG decode timeout but data complete (out:691200 bytes)

2.2 问题排查过程

猜测1:输入JPEG数据有问题?

验证JPEG数据完整性:

// 检查JPEG头尾标记
if (jpeg_data[0] == 0xFF && jpeg_data[1] == 0xD8 &&
    jpeg_data[size-2] == 0xFF && jpeg_data[size-1] == 0xD9) {
    ESP_LOGI(TAG, "✓ JPEG frame is complete");
}

结果:✅ JPEG数据完整正确

猜测2:RGB字节序不对?

尝试切换 JPEG_DEC_RGB_ELEMENT_ORDER_BGRRGB。 结果:❌ 无效,仍然是色块

猜测3:YUV色彩空间转换问题?

添加YUV到RGB转换配置:

.conv_std = JPEG_YUV_RGB_CONV_STD_BT601,

结果:❌ 无效

猜测4:Cache一致性问题?

这是问题的核心!尝试了多种Cache同步方案:

// 输入:CPU写入后,刷新到内存
esp_cache_msync(input_buf, size, ESP_CACHE_MSYNC_FLAG_DIR_C2M);

// 输出:DMA写入后,失效CPU cache
esp_cache_msync(output_buf, size, ESP_CACHE_MSYNC_FLAG_DIR_M2C);

结果:各种对齐错误,数据仍然全0

2.3 对比测试:单张照片 vs 视频

关键发现

  • ✅ 单张JPEG照片能正常解码显示
  • ❌ AVI视频每帧都失败

对比代码发现:

  • 照片测试:不调用任何Cache同步,却能正常工作
  • 视频播放:添加了各种Cache同步,反而失败

结论:问题不在Cache同步本身,而在AVI容器格式的连续解码上。


第三阶段:转折点 - 切换到纯MJPEG格式

3.1 发现参考代码

找到乐鑫官方的MJPEG播放示例,使用的是纯MJPEG格式(不是AVI容器):

纯MJPEG格式:

[FF D8 ... FF D9][FF D8 ... FF D9][FF D8 ... FF D9]...
   JPEG帧1         JPEG帧2         JPEG帧3

AVI容器格式:

[AVI Header][LIST movi]
  [00dc][size][JPEG数据]
  [00dc][size][JPEG数据]

3.2 视频格式转换

使用FFmpeg转换:

# 错误的方式(强制YUV422p)
ffmpeg -i input.avi -pix_fmt yuvj422p -f mjpeg output.mjpeg  # ❌

# 正确的方式(让FFmpeg自动选择)
ffmpeg -i input.mp4 -q:v 3 -f mjpeg output.mjpeg  # ✅

关键差异

  • yuvj422p:某些YUV变体,ESP32-P4可能不完全兼容
  • 自动选择:通常是yuv420p,标准格式,完全兼容

3.3 集成参考代码

复制官方的esp_mjpeg_decode组件:

typedef struct {
    FILE *input;
    uint8_t *mjpeg_buf;
    uint8_t *output_buf;
    jpeg_decoder_handle_t decoder_engine;
    int16_t w, h;
    // ...esp_mjpeg_decode_t;

// 读取一帧
esp_mjpeg_decode_read_mjpeg_buf(&mjpeg);

// 解码
esp_mjpeg_decode_jpg(&mjpeg);

// 显示
esp_lcd_panel_draw_bitmap(..., esp_mjpeg_decode_get_out_buf(&mjpeg));

结果:✅ 立即成功!视频正常播放,无超时,无色块!


第四阶段:性能优化

4.1 初始性能

使用纯MJPEG格式后:

  • 帧率:16-18 FPS

  • 瓶颈分析:

    • JPEG解码:~40ms
    • SD卡读取:~2ms
    • LCD刷新:~18ms
    • 总计:~60ms = 16.7 FPS

4.2 关键优化:启用DMA2D

发现参考代码的LCD配置有一个关键参数:

esp_lcd_dpi_panel_config_t dpi_config = {
    // ...
    .flags.use_dma2d = true,  // ★ 关键!
};

效果:帧率从 16fps 飙升到 70-82 FPS

原理

  • 不启用DMA2D:CPU逐字节复制像素数据到LCD
  • 启用DMA2D:硬件DMA直接传输,CPU只需触发

4.3 Cache配置优化

对比参考代码的sdkconfig,发现关键差异:

# 你的配置(失败时)
CONFIG_CACHE_L2_CACHE_128KB=y
CONFIG_CACHE_L2_CACHE_LINE_64B=y

# 参考代码(成功)
CONFIG_CACHE_L2_CACHE_256KB=y
CONFIG_CACHE_L2_CACHE_LINE_128B=y

更大的Cache和Cache Line能提升DMA传输的稳定性。

4.4 SD卡速度优化

发现:不同SD卡速度差异巨大!

  • 旧卡(SDSC):40 MHz → 16-18 fps
  • 新卡(SDHC):52 MHz → 70-82 fps

教训:硬件性能对整体体验影响巨大,不要忽视SD卡的选择。


第五阶段:帧率精确控制

5.1 问题

全速播放是70-82 FPS,但源视频是24 FPS。如何精确控制到24fps?

失败的尝试1:固定延迟

vTaskDelay(pdMS_TO_TICKS(41));  // 固定延迟41ms
// 结果:18-19 FPS(太慢)
// 原因:FreeRTOS tick粒度问题,延迟不精确

失败的尝试2:动态延迟

elapsed_time = 实际处理时间;
delay = target_time - elapsed_time;
vTaskDelay(pdMS_TO_TICKS(delay));
// 结果:仍然18-19 FPS
// 原因:累积误差,每帧处理时间不同

5.2 成功的方案:固定时间间隔法

核心思想:基于绝对时间而非相对延迟

int64_t next_frame_time_us = esp_timer_get_time();  // 初始时间
int64_t frame_interval_us = 1000000 / 24;  // 41667微秒

while (read_frame()) {
    // 等待到预定时间
    int64_t now = esp_timer_get_time();
    int64_t wait_us = next_frame_time_us - now;
    if (wait_us > 1000) {
        vTaskDelay(pdMS_TO_TICKS(wait_us / 1000));
    }
    
    // 解码并显示
    decode_and_display();
    
    // 更新下一帧时间(累加,不是重新计算)
    next_frame_time_us += frame_interval_us;
}

效果:帧率精确控制在 23.9-24.1 FPS,误差 < 0.5%

优点

  1. 消除累积误差
  2. 自动补偿慢帧
  3. 基于高精度定时器(微秒级)

核心技术要点总结

1. 文件格式选择

格式优点缺点推荐度
AVI容器包含元数据解析复杂,Cache问题⭐⭐
纯MJPEG简单高效无元数据⭐⭐⭐⭐⭐

转换命令:

ffmpeg -i video.mp4 -vf "scale=480:480" -r 24 -q:v 3 -f mjpeg video.mjpeg

注意

  • ✅ 使用 -f mjpeg 输出纯MJPEG
  • ✅ 让FFmpeg自动选择色彩空间(通常是yuv420p)
  • ❌ 不要强制 -pix_fmt yuvj422p(可能不兼容)

2. 内存分配

正确方式:

// 输入和输出都使用 jpeg_alloc_decoder_mem
jpeg_decode_memory_alloc_cfg_t tx_mem_cfg = {
    .buffer_direction = JPEG_DEC_ALLOC_INPUT_BUFFER,
};
input_buf = jpeg_alloc_decoder_mem(jpeg_size, &tx_mem_cfg, &alloc_size);

jpeg_decode_memory_alloc_cfg_t rx_mem_cfg = {
    .buffer_direction = JPEG_DEC_ALLOC_OUTPUT_BUFFER,
};
output_buf = jpeg_alloc_decoder_mem(w * h * bpp, &rx_mem_cfg, &alloc_size);

错误方式:

// ❌ 使用普通 heap_caps_malloc
input_buf = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_DMA);
// 可能导致DMA访问问题

3. Cache同步

关键结论jpeg_alloc_decoder_mem 返回的内存是DMA-coherent的,不需要手动Cache同步!

如果你添加了 esp_cache_msync,反而可能导致问题:

  • C2M(Cache to Memory):会覆盖DMA写入的数据
  • M2C(Memory to Cache):可能有对齐错误

正确做法:什么都不做,让库自动处理。

4. LCD加速

必须启用DMA2D

esp_lcd_dpi_panel_config_t dpi_config = {
    // ...
    .flags.use_dma2d = true,  // ★ 关键配置
};

效果:帧率从16fps → 70+fps

5. 帧率控制

固定时间间隔法

next_frame_time += frame_interval;  // 基于绝对时间
wait_until(next_frame_time);        // 等待到这个时间点
decode_and_display();               // 然后立即处理

优于动态延迟法(delay = target - elapsed)。


常见问题与解决方案

Q1: JPEG解码器每帧都超时,输出全0

可能原因

  1. 文件格式问题(AVI容器有兼容性问题)
  2. Cache一致性问题
  3. 内存分配不正确

解决方案

  1. ✅ 改用纯MJPEG格式
  2. ✅ 使用 jpeg_alloc_decoder_mem 分配内存
  3. ✅ 不要手动Cache同步

Q2: 单张照片能解码,视频不行

原因:单次解码和连续解码的差异。

解决方案

  • 使用参考代码的 esp_mjpeg_decode 组件
  • 确保视频格式是标准MJPEG(不是AVI)

Q3: 屏幕显示规则色块/网格

原因

  1. 解码失败但返回了错误的成功状态
  2. 显示了未初始化的内存
  3. LCD DMA2D未启用

解决方案

  1. 解决解码问题(参考Q1)
  2. 启用DMA2D

Q4: 帧率无法精确控制

原因:FreeRTOS tick粒度(1ms)+ 动态延迟算法

解决方案

  • 使用固定时间间隔法
  • 基于 esp_timer_get_time()(微秒级)

最终实现效果

性能指标

  • JPEG解码能力:70-82 FPS(硬件极限)
  • 实际播放帧率:24.00-24.06 FPS(精确控制,误差<0.3%)
  • 视频切换:7个视频自动轮播,无缝切换
  • 稳定性:长时间运行85000+帧无崩溃

系统架构

SD卡(SDMMC) → MJPEG文件读取 → JPEG硬件解码器 
    ↓                               ↓
  40MHz              →        DMA输出缓冲区
                                    ↓
                           LCD(DMA2D加速) → 屏幕显示

资源使用

  • RAM:约20KB(栈+全局变量,使用堆分配避免栈溢出)
  • PSRAM:约2MB(JPEG缓冲区)
  • CPU占用:单核,约30%(大部分时间在等待DMA)

开发建议与最佳实践

1. 文件格式

推荐:纯MJPEG格式

  • 简单、高效、兼容性好
  • 使用FFmpeg转换,质量参数 -q:v 3(平衡质量和大小)

不推荐:AVI容器(除非必须使用元数据)

2. 开发流程

  1. 先测试单张JPEG解码:验证基本功能
  2. 再测试纯MJPEG播放:验证连续解码
  3. 最后优化性能和帧率:DMA2D、帧率控制

3. 调试技巧

关键诊断点

// 1. 验证JPEG数据完整性
ESP_LOGI(TAG, "JPEG header: %02x %02x", data[0], data[1]);  // 应该是 FF D8

// 2. 验证解码输出
ESP_LOGI(TAG, "Decoded output: %02x %02x %02x ...", 
         output[0], output[1], output[2]);  // 不应该全是00

// 3. 测量实际处理时间
int64_t start = esp_timer_get_time();
decode();
int64_t elapsed = (esp_timer_get_time() - start) / 1000;
ESP_LOGI(TAG, "Decode took %lld ms", elapsed);

4. 性能优化清单

  • ✅ 使用纯MJPEG格式(避免容器解析开销)
  • ✅ 启用LCD DMA2D加速
  • ✅ 使用高速SD卡(Class 10或以上)
  • ✅ 适当调整L2 Cache大小(建议256KB)
  • ✅ 使用堆内存分配大对象(避免栈溢出)

完整代码示例

SD卡初始化

esp_err_t init_sd_card(void) {
    // LDO电源配置
    esp_ldo_channel_config_t ldo_config = {
        .chan_id = 4,
        .voltage_mv = 3300,
    };
    ESP_ERROR_CHECK(esp_ldo_acquire_channel(&ldo_config, &ldo_handle));
    
    // SDMMC主机配置
    sdmmc_host_t host = SDMMC_HOST_DEFAULT();
    host.slot = SDMMC_HOST_SLOT_1;
    host.max_freq_khz = SDMMC_FREQ_HIGHSPEED;
    
    // 挂载
    const esp_vfs_fat_sdmmc_mount_config_t mount_config = {
        .format_if_mount_failed = false,
        .max_files = 10,
        .allocation_unit_size = 64 * 1024
    };
    
    ESP_ERROR_CHECK(esp_vfs_fat_sdmmc_mount("/sdcard", &host, 
                    &slot_config, &mount_config, &card));
    return ESP_OK;
}

MJPEG播放主循环

void play_mjpeg(const char *filename) {
    // 初始化解码器
    esp_mjpeg_decode_t mjpeg = {
        .mjpeg_buffer_size = 480 * 480,
        .output_buffer_size = 480 * 480 * 3,
        .decode_cfg = {
            .output_format = JPEG_DECODE_OUT_FORMAT_RGB888,
            .rgb_order = JPEG_DEC_RGB_ELEMENT_ORDER_BGR,
        }
    };
    esp_mjpeg_decode_setup(&mjpeg, filename);
    
    // 帧率控制
    int64_t next_frame_time = esp_timer_get_time();
    int64_t frame_interval = 1000000 / 24;  // 24 fps
    
    // 播放循环
    while (esp_mjpeg_decode_read_mjpeg_buf(&mjpeg)) {
        // 等待到预定时间
        int64_t wait_us = next_frame_time - esp_timer_get_time();
        if (wait_us > 1000) {
            vTaskDelay(pdMS_TO_TICKS(wait_us / 1000));
        }
        
        // 解码
        esp_mjpeg_decode_jpg(&mjpeg);
        
        // 显示
        esp_lcd_panel_draw_bitmap(panel, x, y, x+w, y+h, 
                                 esp_mjpeg_decode_get_out_buf(&mjpeg));
        
        // 更新下一帧时间
        next_frame_time += frame_interval;
    }
    
    esp_mjpeg_decode_close(&mjpeg);
}

经验教训

技术层面

  1. 不要过度优化:参考代码不做Cache同步也能工作,说明库已经处理好了
  2. 格式很重要:纯MJPEG比AVI容器简单可靠得多
  3. 硬件加速必须启用:DMA2D能带来4-5倍性能提升
  4. 精确延迟需要高精度定时器:FreeRTOS tick不够,要用 esp_timer

调试层面

  1. 对比测试法:单张照片 vs 视频,快速定位问题域
  2. 参考代码是金矿:官方示例代码已经踩过坑,直接使用最可靠
  3. 打印诊断信息:关键数据点(JPEG头、输出前16字节、地址)帮助快速定位
  4. 硬件也是变量:不要忽视SD卡等外设的影响

附录:完整配置清单

sdkconfig 关键配置

# PSRAM
CONFIG_SPIRAM=y
CONFIG_SPIRAM_SPEED_200M=y

# Cache (重要!)
CONFIG_CACHE_L2_CACHE_256KB=y
CONFIG_CACHE_L2_CACHE_LINE_128B=y

# FAT长文件名
CONFIG_FATFS_LFN_HEAP=y
CONFIG_FATFS_MAX_LFN=255

# JPEG解码器
CONFIG_SOC_JPEG_DECODE_SUPPORTED=y

CMakeLists.txt

idf_component_register(SRCS "main.c" "app_lcd.c" "app_sdcard.c"
                       REQUIRES 
                           esp_mjpeg_decode
                           esp_driver_sdmmc
                           esp_lcd
                           esp_lcd_st7703
                           esp_timer
                           fatfs
                           driver)

组件结构

components/
├── esp_mjpeg_decode/          # MJPEG解码组件
│   ├── esp_mjpeg_decode.c
│   ├── include/
│   │   └── esp_mjpeg_decode.h
│   └── CMakeLists.txt
main/
├── main.c                     # 主程序(视频轮播)
├── app_lcd.c/h               # LCD初始化
├── app_sdcard.c/h            # SD卡管理
└── CMakeLists.txt

项目成果


参考资料

  1. ESP-IDF JPEG编解码器文档
  2. SDMMC主机驱动文档
  3. ESP32-P4官方MJPEG示例代码
  4. FFmpeg官方文档

致谢

感谢乐鑫官方技术支持和开源社区的帮助。本项目的成功很大程度上得益于参考了官方示例代码和社区经验。


作者:拆技 日期:2025年11月25日
联系方式78680321@qq.com


关键词:ESP32-P4, MJPEG, 视频播放, JPEG硬件解码, DMA2D, SD卡, Cache一致性, 帧率控制