简介:视频录制在嵌入式开发中常被视为复杂任务,但借助ESP32-CAM与MJPEG2SD项目,可以将问题简化。OV2640传感器自带JPEG编码,无需软件压缩,只需将帧数据按AVI容器格式写入SD卡。本文从AVI容器原理、SD卡存储机制、帧缓冲管理出发,讲解如何配置帧率、分辨率与jpeg_quality,并通过定期flush与索引回填降低掉电损坏风险。适合监控留档、车载记录等场景,帮你从跑通到用稳。
1. ESP32-CAM 录视频不靠编码,靠封装:MJPEG2SD 的思路拆解
很多人第一次接触 ESP32-CAM 时,会默认在这块板子上做录像要先解决视频编码。实际恰好反——OV2640 传感器在内部就完成了 JPEG 编码,MJPEG2SD 这个方向要做的只是把每帧 JPEG 原样取出来,按 AVI 容器规则写进 SD 卡。所谓的录像,在这里被简化成了「取帧 + 写卡」两件事。
这类方案能解决什么?把浏览器里的实时预览变成可以回放、可以拷贝、断电后仍在的本地文件。它不依赖路由器、不依赖上位机,也不需要音频链路,适合监控留档、车载记录、环境监测这类把「能不能稳定落盘」放在第一位的场景。
适合看这篇的人:手里有 ESP32-CAM,试过示例工程,但没把录制功能真正跑通;或者已经能录像,但总遇到文件播不了、录一会儿就重启的问题。下面从容器原理、编译烧录、参数调优讲到掉电文件损坏的修复思路,把 MJPEG2SD 从跑通到用稳的路径完整过一遍。
2. MJPEG2SD 的底层逻辑:JPEG 帧、SD 卡与 AVI 容器的组合
2.1 OV2640:JPEG 编码交给传感器,主机只取现成帧
OV2640 是一颗自带完整 ISP 和 JPEG 编码器的传感器,支持 RGB、YUV、JPEG 三种输出。配置成 JPEG 模式后,DMA 送到内存里的每一帧就是一份带 SOI(0xFFD8)和 EOI(0xFFD9)的标准 JPEG 文件。ESP32 不需要做帧间预测、量化、霍夫曼编码,它只是把摄像头已经做完的图片收进内存。
所有工作都围绕esp_camera驱动展开。初始化配置里pixel_format设成PIXFORMAT_JPEG,fb_count设成 2 或更多,之后每次esp_camera_fb_get()拿到一帧指针,用完必须esp_camera_fb_return()。这个返回动作是 MJPEG2SD 最容易翻车的地方:如果只盯着写文件,很容易忘记归还。
#include "esp_camera.h" static void show_one_frame(void) { camera_fb_t *fb = esp_camera_fb_get(); // 取回当前就绪帧,可能等待曝光完成 if (!fb) { ESP_LOGE("demo", "capture failed"); return; } // JPEG 模式下 fb->buf 就是完整的 JPEG 字节流,len 是该帧字节数 ESP_LOGI("demo", "jpeg %u bytes, %ux%u", fb->len, fb->width, fb->height); esp_camera_fb_return(fb); // 用完后必须归还,否则帧缓冲耗尽 }为什么必须归还:camera 驱动的帧缓冲是预分配池,不归还等于每次取帧从池子里钓走一个缓冲。MJPEG2SD 录制循环每秒十几帧,漏还 5 秒就能把池子占空,之后esp_camera_fb_get()一直失败,表现就是录到一半突然没有画面。
2.2 SD 存储:本地落盘为什么比网络推流更适合 MJPEG2SD
网络推流需要上位机持续在线,上位机一断,数据就断档。MJPEG2SD 的定位是独立录像,数据要落在摄像头自己身上,SD 卡就是最直接的块设备。带宽也能算清楚:VGA 640x480、jpeg_quality=10时每帧 JPEG 大约 15~35KB,按 15fps 算写入量约 300~500KB/s。SDMMC 1-bit 模式在 20MHz 时钟下实测大约 1.5~2.5MB/s,完全够用。
为什么不直接用 4-bit 模式?ESP32-CAM 的 SD 卡槽 4-bit 模式要占用 GPIO2、4、12、13,其中 GPIO12 是 strap 引脚,上电瞬间电平会影响启动模式;GPIO4 又被板载 LED 占用。1-bit 模式只走 GPIO2、14、15 三条线,把冲突降到最低。这个取舍在源码里通常体现为slot_config.width = 1。
文件系统选 FAT32 而不是 LittleFS 的理由很简单:录完要把卡拔下来插电脑,FAT 是播放器和操作系统默认认识的格式。代价是掉电可能丢目录项,这个坑在第 4 章专门处理。
2.3 AVI 容器:MJPEG 兼容性的全部秘密在头结构和索引
MJPEG 本质是一串连续 JPEG 的定格动画,帧与帧之间没有运动补偿,所以每一帧损坏只影响自身,不会像 H.264 那样发生错误传播。但播放器不会凭空认识裸的 JPEG 流,必须套容器。AVI 是 MJPEG 最常用也最宽松的容器。
| 段 | 四字符码 | 作用 |
|---|---|---|
| RIFF 头 | RIFF / AVI | 告诉播放器这是 AVI 文件,并记录总大小 |
| 主头 | hdrl / avih | 帧率、帧数、宽高等全局信息 |
| 流定义 | strl / strh + strf | 说明视频流格式,编解码器标识为 MJPG |
| 帧数据 | movi / 00dc | 每帧 JPEG 原样存放,00dc表示视频帧 chunk |
| 索引 | idx1 | 每帧偏移与大小,播放器靠它做拖动定位 |
三个容易被忽略的规则。一是每个 chunk 的字节数必须偶数对齐,奇数长度末尾补一个 0x00;二是idx1里记录的 offset 一般从第一个00dc的数据区起点算,而不是从文件头算;三是strh的dwRate/dwScale要填实际帧率,写死 30 而实际只有 12fps,视频会整体快放。这些规则直接决定文件在播放器里能不能打开、能不能拖进度条,后面写代码时会反复遇到。
3. 建工程与烧录:先把 MJPEG2SD 固件编译到板子上
3.1 准备 IDF 环境与四个关键配置项
环境用 Linux,装好乐鑫官方 ESP-IDF。源码里没注明版本时,我一般选 4.4 LTS,稳定且示例多。注意 ESP32-CAM 主控是 ESP32 芯片,idf.py set-target只能选esp32,不要选成 esp32s2 或 esp32s3,否则编译链接阶段会报芯片不匹配。
cd ~/esp tar zxf MJPEG2SD-master.tar.gz cd MJPEG2SD-master idf.py set-target esp32 idf.py menuconfig菜单配置里检查四项。第一,Component config → ESP32-specific → Support for external, SPI-connected RAM必须打开,否则 4MB PSRAM 不参与分配,高分辨率下内存直接不够。第二,SPI RAM 类型选自动识别或 Quad,ESP32-CAM 出厂一般是 4MB PSRAM。第三,摄像头引脚配置:多数 MJPEG2SD 代码里写死了 ESP32-CAM 默认引脚,你只要确认板子对应AI-Thinker那套 pin map,找不到这个选项就说明代码里已经写死,不用管。第四,SD 卡模式选 1-bit,减少 GPIO12 strap 引脚干扰。
配置完直接编译。失败时先看错误,是缺组件还是工具链版本;常见的是没有先source $IDF_PATH/export.sh,导致idf.py不存在。编译通过后,会生成build/esp32-cam.bin之类的固件文件,下一步是烧录。
3.2 烧录接线:为什么 IO0 接地还要共地
ESP32-CAM 板子上没有 USB 转串口芯片,必须外接 USB-TTL 下载器。接线如下,注意是交叉接:
| ESP32-CAM 引脚 | USB-TTL 下载器 |
|---|---|
| 3V3 | 3.3V(只取逻辑电平,不用于供电) |
| GND | GND |
| U0TXD | RX |
| U0RXD | TX |
| IO0 | GND(下载模式时短接) |
下载流程是:保持 IO0 接地 → 给板上电 → 开始烧录 → 烧完松开 IO0 → 复位。很多人卡在Connecting...,八成是 IO0 没拉低,或者 USB-TTL 和板子没有共地。
idf.py -p /dev/ttyUSB0 flash monitor上电瞬间摄像头和 SD 卡同时供电,峰值电流可能到 300~400mA,USB-TTL 板直供电往往不够,表现为 SD 初始化失败或录像中途重启。正确做法是用独立 5V 电源给板子供电,再和 USB-TTL 共地,TTL 板只负责串口信号。
提示:ESP32-CAM 板载稳压器支持 5V 输入,标称 Vin 接 5V 供电比 3.3V 直供稳得多,这是 SD 卡频繁掉卡的第一避坑手段。
4. 调稳录制:分辨率、帧率、画质与掉电保护
4.1 参数取舍表与推荐初始化配置
MJPEG2SD 的录制质量由三个变量决定:分辨率、帧率、JPEG 压缩质量。它们互相挤占 SD 写入带宽,不能同时拉满。
| 分辨率 | frame_size 枚举 | 单帧 JPEG 典型大小 | 建议帧率 | 适合场景 |
|---|---|---|---|---|
| 320x240 | FRAMESIZE_QVGA | 8~20 KB | 20~30 fps | 长时间记录,内容优先 |
| 640x480 | FRAMESIZE_VGA | 15~35 KB | 12~18 fps | 通用录像,画质和速度均衡 |
| 1024x768 | FRAMESIZE_XGA | 40~90 KB | 4~8 fps | 静态场景,细节优先 |
我一般建议先定分辨率,再调帧率,最后才动jpeg_quality。反过来容易自我怀疑:画质调低后发现帧率还是上不去,其实瓶颈在 SD 卡或功耗,不在编码参数。
camera_config_t config = { .pin_pwdn = 32, .pin_reset = -1, .pin_xclk = 0, .pin_sccb_sda = 26, .pin_sccb_scl = 27, .pin_d7 = 35, .pin_d6 = 34, .pin_d5 = 39, .pin_d4 = 36, .pin_d3 = 21, .pin_d2 = 19, .pin_d1 = 18, .pin_d0 = 5, .pin_vsync = 25, .pin_href = 23, .pin_pclk = 22, .xclk_freq_hz = 20000000, // 时钟 20MHz,不要超过 24MHz .ledc_timer = LEDC_TIMER_0, .ledc_channel = LEDC_CHANNEL_0, .pixel_format = PIXFORMAT_JPEG, // 关键:直接取 JPEG .frame_size = FRAMESIZE_VGA, // 从 VGA 起步调,跑顺再提分辨率 .jpeg_quality = 10, // 数值越小画质越高,文件越大 .fb_count = 2, // 双缓冲,写 SD 时不丢帧 .grab_mode = CAMERA_GRAB_LATEST, // 只取最新帧,旧帧直接丢弃 };xclk_freq_hz太高会让传感器发热和时序不稳,20MHz 是社区默认值,不要看到别人用 24MHz 就照抄。grab_mode选CAMERA_GRAB_LATEST对录像更合理:写 SD 慢时丢掉旧帧,保证拿到的画面尽可能接近当前时刻;CAMERA_GRAB_WHEN_EMPTY反而会在队列满的时候反复拿旧帧,造成「录像时间看起来在走,画面其实卡住」。
4.2 掉电与索引:AVI 文件这一刻还完好的唯一办法
AVI 的索引块默认在文件末尾,正常停止录制时才写。掉电意味着索引没写进去,RIFF 头的大小字段也没回填,播放器自然拒收。MJPEG2SD 这类项目的工程做法不是祈祷不掉电,而是把损失窗口缩小到可接受。
第一招是定期把文件缓存刷进 SD 卡,fflush(fp)之后再加一层fsync(fileno(fp)),我一般每 10 秒刷一次。这样掉电时最多丢最后几秒的帧,而不是整个文件的目录项都消失。第二招是有一个能被快速调用的finalize()函数,收到停止信号或按键触发时立刻补写索引并关闭文件。第三招是长时间录制时自动分段,每 5~10 分钟一个新文件,把一个损坏文件的影响限制在它自己身上。
typedef struct { char ckid[4]; // '0','0','d','c' uint32_t flags; // 0x10 表示关键帧,MJPEG 每帧都是关键帧 uint32_t offset; // 相对第一个 00dc 数据起点 uint32_t size; // 该帧 JPEG 字节数 } avi_index_entry_t; static avi_index_entry_t idx[4096]; static uint32_t idx_count = 0; static uint32_t movi_data_begin = 0; void avi_write_frame(FILE *fp, camera_fb_t *fb) { uint32_t data = ftell(fp); // 当前文件偏移 idx[idx_count].offset = data - movi_data_begin; idx[idx_count].size = fb->len; idx_count++; char ckid[4] = {'0','0','d','c'}; fwrite(ckid, 1, 4, fp); fwrite(&fb->len, 1, 4, fp); // 该 chunk 的大小 fwrite(fb->buf, 1, fb->len, fp); // 整帧 JPEG 原样落盘 if (fb->len & 1) fputc(0, fp); // AVI chunk 必须偶数对齐 } void avi_finalize(FILE *fp) { uint32_t idx_bytes = idx_count * sizeof(avi_index_entry_t); fseek(fp, 0, SEEK_END); fwrite("idx1", 1, 4, fp); fwrite(&idx_bytes, 1, 4, fp); fwrite(idx, 1, idx_bytes, fp); // 尾部补索引 uint32_t riff_size = ftell(fp) - 8; // RIFF 大小 = 文件总长 - 8 fseek(fp, 4, SEEK_SET); fwrite(&riff_size, 1, 4, fp); fclose(fp); }写第一帧之前要记录movi_data_begin = ftell(fp),就是第一个00dc的数据区起始位置,索引里的 offset 都以它为基准。头部avih帧数字段和moviLIST 大小字段,正常项目会在文件头留占位变量,finalize时一并回填;上面代码省略了这些回填赋值,是为了把索引和 RIFF 大小这两个核心思想讲清楚。
注意:播放器不会帮你修复无索引 AVI。VLC 对缺失索引的文件能靠扫描
movi猜着放,但拖动进度条必卡,Windows 自带的播放器更是直接不认。所以录完不要立刻拔卡,先确认程序正常走了finalize。
4.3 串口日志:每条关键日志对应什么状态
固件启动后,串口输出大致是这个节奏:
I (1092) mjpeg2sd: opening sdcard... I (1103) sdmmc: SD card detected, speed 20.0MHz/1-bit I (1110) mjpeg2sd: mounted /sdcard I (1120) mjpeg2sd: REC_000.AVI created I (1130) mjpeg2sd: frame=1 size=21432 I (1190) mjpeg2sd: frame=2 size=20897 fps=16判断是否健康看三点。第一,fps数值是否稳定,如果从 16 掉到 6 再回 15,说明写卡有间歇性阻塞或卡速不够。第二,size是否在同一量级,JPEG 大小随场景亮度波动是正常的,但如果连续几帧掉到几 KB,多半是摄像头进光异常或传感器挂了。第三,有没有SD write retry之类的警告,频繁出现就该换卡或降帧率。
想保持帧率可见,可以每 50 帧打印一次平均值:
static uint32_t frames = 0; static uint32_t last_ms = esp_timer_get_time() / 1000; frames++; if (frames % 50 == 0) { uint32_t now_ms = esp_timer_get_time() / 1000; ESP_LOGI("mjpeg2sd", "fps=%.1f", 50.0 * 1000.0 / (now_ms - last_ms)); last_ms = now_ms; }esp_timer_get_time()返回微秒,除以 1000 转毫秒。50 帧取一次平均值,比逐帧打印稳定,也避免日志刷屏拖慢录制循环。
5. 避坑:MJPEG2SD 最容易翻车的 6 个现实问题
编译通过只是第一步。下面这些现象我基本都遇到过,每条按「现象 → 原因 → 解决」排列,前四条是录像相关,后两条是系统稳定相关。
5.1 SD 卡初始化失败,卡在 sdmmc 检测
现象:sdmmc_card_init返回类似0x103的超时错误,或者卡能检测到但挂载失败。换一张卡结果又不一样,玄学味道很重。
原因:最常见是供电不足,卡在初始化瞬间电流抽不上去;其次是 GPIO12 strap 引脚电平异常;再就是卡槽接触不良,尤其是拔插过多次的板子。
解决:先检查供电,Vin 接 5V 并加一个 470uF 电解电容,TTL 板供电基本都会翻车。然后把 SD 模式改成 1-bit,避开 D2/D3 引脚。最后换一张正品 Class 10 卡,先用 FAT32 格式化、簇大小 32KB。如果还失败,检查卡是否插到底,ESP32-CAM 的卡槽比较紧,半插状态下表面看着到位,实际触点悬空。
5.2 录几十秒后申请不到帧缓冲
现象:启动正常,录 20~50 秒后日志开始出现capture failed,随后esp_camera_fb_get一直返回 NULL,录出来的文件只有开头十几秒。
原因:帧缓冲池被占满。绝大多数情况是某个分支只get没return,比如写卡失败时直接continue,把回收帧的代码跳过去了。少数情况是fb_count设太大,或者 WiFi 同时开启抢占了 PSRAM。
解决:审查录制循环里每一个continue和return,确保所有路径都执行esp_camera_fb_return(fb)。把fb_count调到 2 就够,不要再大。录制期间不要初始化 WiFi 或蓝牙,这个项目用不到无线。
5.3 文件在播放器里打不开或时长不对
现象:录完的文件在电脑上双击没反应,或者能播放但明显快进,10 秒的视频 4 秒放完。
原因:索引偏移基准错误,或者strh里的帧率参数写死成 30。索引问题常见于把ftell(fp)的总偏移直接当成索引 offset,忘了减去movi_data_begin。时长问题则是dwRate/dwScale与实际帧率不一致。
解决:索引 offset 统一用「当前文件偏移 - movi 数据区起点」。帧率不要写死,录制结束时用实际帧计数除以耗时回填。验证时不要用播放器猜测,先用ffprobe看r_frame_rate和duration,都符合预期再谈播放器兼容。
5.4 断电后文件 0 字节或只有开头一段
现象:充着电录像时直接拔电源,重启后 SD 卡里文件还在,但大小为 0,或者只有前几秒钟能播。
原因:FAT 文件系统的目录项和文件数据不会实时同步到卡上。程序写了几百 KB,但目录项还没更新,断电后系统只知道有这个文件,不知道它多大。索引和 RIFF 大小也没回填,播放器只能读到一个残缺壳。
解决:录制循环里每 10 秒做一次fflush(fp)+fsync(fileno(fp)),把缓存强制落盘。再配合自动分段,每 5 分钟一个文件,掉电最多毁掉当前这一段。按键正常停止时必须走avi_finalize,不要直接断电。
5.5 写卡时任务看门狗反复触发,系统无限重启
现象:日志出现Task watchdog got triggered,复位前往往停在一行fwrite相关调用上,系统进入无限重启循环。
原因:录制主循环里连续写卡,低优先级系统任务长时间拿不到 CPU。SD 写在低速卡上可能阻塞几百毫秒,超过看门狗阈值。
解决:把写卡逻辑从摄像头采集拆出去,用 FreeRTOS 队列把帧指针交给独立任务,两个任务分优先级。简单做法是在每帧写卡前加vTaskDelay(pdMS_TO_TICKS(1)),主动让出 CPU;再不行调大任务看门狗超时时间。直接关看门狗是下策,不要把这个隐患压住就完事。
5.6 摄像头复位后初始化失败或画面花屏
现象:第一次上电能出图,按复位按钮后camera init failed,或者画面全紫、全绿;放一会儿又自己恢复。
原因:OV2640 在时钟和电源刚建立时还没稳定,I2C 初始化时机太早;PWDN 引脚悬空时电平不确定,也会导致传感器处于掉电模式。
解决:初始化摄像头之前先vTaskDelay(pdMS_TO_TICKS(200)),等电源稳定。把 PWDN 引脚显式拉低:
gpio_pad_select_gpio(32); gpio_set_direction(32, GPIO_MODE_OUTPUT); gpio_set_level(32, 0); // PWDN 低电平 = 正常工作如果热复位仍然失败,把xclk_freq_hz降到 10MHz 试试。这个现象在环境温度偏高时更容易出现,和传感器个体差异有关,不是代码写错。
6. 再进一步:按键分段录制与文件可读性验证
6.1 一个 GPIO 实现分段录制
录像录到一半想切开文件,最常见需求。用一个按键接 GPIO33,另一端接 GND,内部上拉。按下时当前文件立即finalize,再创建一个新文件继续录。
#include "driver/gpio.h" #define BTN_PIN GPIO_NUM_33 void btn_init(void) { gpio_config_t cfg = { .pin_bit_mask = (1ULL << BTN_PIN), .mode = GPIO_MODE_INPUT, .pull_up_en = GPIO_PULLUP_ENABLE, .pull_down_en = GPIO_PULLDOWN_DISABLE, }; gpio_config(&cfg); } // 放在录像主循环里轮询 if (gpio_get_level(BTN_PIN) == 0) { // 按下为低电平 avi_finalize(fp); // 关闭当前文件并补写索引 snprintf(fname, sizeof(fname), "/sdcard/REC_%04d.AVI", seq++); fp = avi_create(fname); // 新建分段文件 vTaskDelay(pdMS_TO_TICKS(500)); // 简单防抖 }轮询方式在写帧循环里就能用,缺点是卡写慢时按键会迟钝。想更及时,就把按键检测放进独立任务,检测到按下后向录制任务发一个事件。分段文件用自增编号,比用时间戳可靠,因为 ESP32 的 RTC 在上电后默认不准,除非接入了网络校时。
6.2 用 ffprobe 验证每个文件真实格式
录完之后不要急着拔卡,用 FFmpeg 工具链里的ffprobe确认文件结构完整:
ffprobe -v error -show_entries stream=codec_name,codec_tag_string,width,height,r_frame_rate:format=duration,size \ -of default=noprint_wrappers=1 /path/to/REC_000.AVI期望输出里codec_name=mjpeg,codec_tag_string=MJPG,r_frame_rate接近你配置的帧率,duration和实际录制时长一致。如果duration异常偏大或偏小,优先怀疑strh帧率字段没有按实际帧率回填;如果直接报错读不出流,检查idx1是否写入、RIFF 大小是否回填。每个分段文件都过一遍这条命令,再插回板子继续录。
我第一次做这类项目时,为了画质把jpeg_quality调到了 3,结果帧率掉了一半,折腾半天才发现瓶颈根本不是编码,是 SD 卡写入速度跟不上。从那以后我调参顺序固定为:先定分辨率,再定帧率,最后才动画质。MJPEG2SD 这套方案就是这样一个诚实的系统——它不负责压缩,只负责稳定搬运 JPEG 帧,你要做的也是先把搬运路径理顺,再谈画质。希望帮到你。
本文还有配套的精品资源,点击获取