1. 环境准备与项目搭建:从零开始的ESP32-CAM视频录制起点

想用ESP32-CAM这个小玩意儿录视频,听起来有点挑战,对吧?毕竟它内存不大,主频也不算高,还要同时处理摄像头数据、跑网页服务器、往SD卡里写文件。但实测下来,只要环境搭对了,代码结构理清了,实现一个基础版的视频录制功能是完全可行的。我自己在项目里折腾了好几次,从只能拍照片到终于能录上视频,中间踩了不少坑,也总结了一些让流程更顺滑的经验。

首先,你得有个ESP32-CAM开发板,就是带摄像头和SD卡槽的那个。然后,在电脑上把“战场”准备好。我强烈推荐使用 VScode 配合 esp-idf 插件来开发,这比纯命令行舒服太多了,代码提示、编译、烧录、监控日志都能在一个界面里搞定。如果你还没装,去VScode的扩展商店搜索“Espressif IDF”,安装那个官方插件就行。安装过程它会引导你下载esp-idf框架,选个最新的稳定版本,比如V5.x,一路下一步就好。这里有个小细节,安装路径最好全英文,别带空格和特殊字符,能避免很多玄学问题。

环境装好后,我们需要一个基础工程。最方便的就是直接用官方的 esp32-web-camera 例程。你可以在VScode里,通过命令面板(Ctrl+Shift+P)输入“ESP-IDF: Show Examples Projects”,然后在列表里找到这个例程,把它克隆到你的工作目录。这个例程已经帮我们实现了摄像头初始化、Wi-Fi连接、网页视频流服务器,这是我们功能的基石。接下来,我们要在这个“毛坯房”里,加装“视频录制”这个核心功能。

我们需要一个关键“零件”:一个能把一连串JPEG图片打包成AVI视频文件的库。网上有个开源库叫 jpeg2avi,作者“原野追逐”写得非常清晰。你不需要自己从头实现视频封装逻辑,直接把他的 Jpeg2AVI.c 和 list.c 这两个源文件下载下来,扔进我们工程的 main 文件夹里就行。然后,打开 main 目录下的 CMakeLists.txt 文件,找到设置源文件的那一行(通常是 set(COMPONENT_SRCS ...)),把这两个新文件的名字加进去。就像这样:

set(COMPONENT_SRCS
    "app_main.c"
    "app_wifi.c"
    "app_camera.c"
    "app_httpd.c"
    "app_mdns.c"
    "app_sd.c"
    "Jpeg2AVI.c"   # 新增
    "list.c"       # 新增
)

这一步相当于告诉编译系统:“嘿,编译的时候把这两个新家伙也一起带上。” 这样,我们需要的视频封装能力就集成到项目里了。硬件连接上,别忘了把SD卡模块正确接到ESP32-CAM的指定引脚上,并格式化成FAT32文件系统,不然程序可找不到它。

2. 核心代码实战:打通视频录制的任督二脉

环境搭好,库也引进了,接下来就是最关键的编码部分。怎么让摄像头的数据,一边在网页上直播,一边又能稳稳地存进SD卡变成视频文件?这需要我们巧妙地安排几个核心函数的调用时机,就像导演安排演员的出场顺序一样。

2.1 视频录制的启动:何时按下“录制键”

视频录制不是凭空开始的,它需要一个启动信号。在我们的工程里,这个信号最好和SD卡初始化成功绑定在一起。你想啊,卡都没准备好,录了往哪存?所以,我找到初始化SD卡的函数 SdCard_init,在它的最后,确认文件系统挂载成功、并且能创建文件之后,就是调用 jpeg2avi_start 的最佳时机。

这个函数需要你提供一个已经用“wb”(二进制写)模式打开的SD卡文件指针。我在这里做了点小改进:给生成的文件名加上实时时间戳,这样每次录制的视频都不会被覆盖,管理起来也方便。代码大概长这样:

void SdCard_init(void) {
    // ... 前面的SD卡初始化代码(挂载、探测等)
    sdmmc_card_print_info(stdout, card); // 打印卡信息,调试用

    // 生成带时间戳的文件名,例如 “20241028_143025.avi”
    char filename[64];
    struct timeval tv;
    gettimeofday(&tv, NULL);
    struct tm *timeinfo = localtime(&tv.tv_sec);
    strftime(filename, sizeof(filename), "/sdcard/%Y%m%d_%H%M%S.avi", timeinfo);

    fp_avi = fopen(filename, "wb");
    if(fp_avi == NULL) {
        ESP_LOGE(TAG, "Failed to open file for writing");
        return;
    }
    // 启动AVI文件写入,传入文件指针和视频参数(宽、高、帧率)
    jpeg2avi_start(fp_avi, 800, 600, 15);
    ESP_LOGI(TAG, "AVI recording started: %s", filename);
}

注意,jpeg2avi_start 需要知道视频的宽度、高度和期望的帧率。这些参数得和你摄像头初始化的分辨率对上,不然播放出来画面会是畸形的。我后面会详细说怎么设置和优化这些参数。

2.2 帧数据的捕获与写入:心脏跳动般的持续过程

启动录制只是拉开了序幕,真正的重头戏是持续不断地把每一帧图片数据塞进AVI文件里。在 esp32-web-camera 例程中,当你在网页上点击“Start Stream”按钮后,程序会启动一个任务,不断地从摄像头抓取JPEG格式的图片,并通过HTTP流推送到网页上显示。这个抓取每一帧图片的地方,就是我们要插入录制逻辑的“黄金位置”。

通常,这个函数叫 capture_and_stream 或者是在 stream_handler 相关的函数里。我们需要在这里,拿到刚刚压缩好的JPEG图片数据(一个缓冲区指针和它的长度),然后调用 jpeg2avi_add_frame 函数。这个过程必须高效,不能耽误给网页发送数据,否则网页视频流就会卡顿。代码片段示例如下:

// 这是一个简化示例,通常在摄像头捕获一帧后的回调或任务循环中
size_t jpeg_len = 0;
uint8_t *jpeg_buf = NULL;
if (esp_camera_fb_get() != NULL) { // 获取一帧摄像头数据
    jpeg_buf = fb->buf;  // JPEG数据缓冲区
    jpeg_len = fb->len;  // 数据长度

    // 1. 先发送给网页(原有功能)
    httpd_send_stream_data_to_client(...);

    // 2. 再写入AVI文件(新增功能)
    if (fp_avi != NULL && is_recording) { // is_recording是一个全局标志位
        jpeg2avi_add_frame(fp_avi, jpeg_buf, jpeg_len);
    }

    esp_camera_fb_return(fb); // 释放帧缓冲区
}

这里我引入了一个全局变量 is_recording,用来控制是否进行录制。这样我们就可以通过网页上的另一个按钮(比如“Record”),来独立控制录制的开始和停止,而不影响视频流的观看。这是比原始文章更灵活的一点。

2.3 视频录制的结束与收尾:完美封箱

所有拍摄结束后,我们不能简单粗暴地直接关闭SD卡文件。AVI文件格式有一个“文件头”和“索引块”,需要在文件末尾写入,告诉播放器这个视频的总帧数、时长等信息。这就是 jpeg2avi_end 函数的工作。它需要计算整个录制过程中的实际信息并写回文件头部,完成“封箱”。

那么,在哪里调用它呢?原始文章里巧妙地借用了调整LED亮度的命令来触发。这确实是个办法,但不够直观。我的做法是,在网页上创建一个“Stop Recording”按钮,点击后,后台首先将 is_recording 标志位设为 false,停止向AVI文件添加新帧,然后调用结束函数。确保所有帧数据都妥善处理完毕后,再安全地关闭 (fclose) 文件指针。核心结束逻辑如下:

void stop_recording_task(void *pvParameters) {
    if (fp_avi != NULL) {
        is_recording = false;
        vTaskDelay(100 / portTICK_PERIOD_MS); // 稍等片刻,确保最后一帧写完

        // 传入最终的视频参数(宽、高、总帧数/或帧率用于计算)
        jpeg2avi_end(fp_avi, 800, 600, total_frames_captured);
        fclose(fp_avi);
        fp_avi = NULL;
        ESP_LOGI(TAG, "AVI recording finished and file closed.");
    }
    vTaskDelete(NULL);
}

调用 jpeg2avi_end 后,一个完整的、可以被普通播放器(如VLC、Windows媒体播放器)打开的AVI视频文件就静静地躺在你的SD卡里了。你可以拔下卡,用电脑查看你的劳动成果。

3. 性能瓶颈分析与帧率优化实战

按照上面的步骤跑起来,你大概率会和我遇到一样的情况:录是能录了,但帧率惨不忍睹,大概也就13-15帧每秒(FPS),画面感觉一卡一卡的。日志里打印的耗时显示,处理一帧就要六七十毫秒。这对于一个要同时干三件事(抓图、传网页、写SD卡)的ESP32来说,确实压力山大。但这不意味着没有提升空间,我们可以从几个关键地方入手,做一轮“性能调优”。

3.1 分辨率与图像质量的权衡

这是提升帧率最直接、最有效的手段。摄像头输出的JPEG图片,分辨率越高、画质越好,单帧的数据量(jpeg_len)就越大。数据量大意味着两件事:一是从摄像头传感器读取到内存的时间变长,二是写入SD卡的时间暴增。SD卡的写入速度,尤其是小文件连续写入,在ESP32的SPI接口下是主要的瓶颈之一。

在 app_camera.c 的摄像头初始化配置里,有一个关键参数 framesize。原始文章提到了 FRAMESIZE_SVGA (800x600)。我们可以尝试把它调低,比如降到 FRAMESIZE_VGA (640x480) 甚至 FRAMESIZE_QVGA (320x240)。实测下来,从SVGA降到VGA,单帧数据量可能减少三分之一,帧率提升会非常明显。当然,画面会变模糊,这就需要根据你的项目需求来权衡了。如果你做的是门铃监控,QVGA可能也够用;如果是记录手工过程,可能就需要VGA。

除了分辨率,JPEG的压缩质量也能调。在 esp_camera 的配置结构体里,可以设置 jpeg_quality(通常是0-63,值越小质量越高压缩越小,但63质量最差)。适当降低质量(比如从10调到30),能在画质损失不大的情况下,显著减小每帧图片的大小,对提升帧率有帮助。你可以做个实验,固定分辨率,改变质量参数,记录帧率和文件大小,找一个你能接受的甜蜜点。

3.2 任务调度与缓冲区的艺术

ESP32是双核处理器,还有FreeRTOS实时操作系统。我们不能让所有活都在一个任务里干完,那样会阻塞。原始例程里,摄像头捕获、HTTP发送可能耦合得比较紧。我的优化思路是解耦和异步化。

1. 创建独立的录制任务: 不要直接在HTTP流发送的任务里同步调用 jpeg2avi_add_frame 和SD卡写入。因为SD卡写入速度慢且不稳定,会直接拖慢整个HTTP响应,导致网页视频流卡顿。我们可以创建一个单独的“录制任务”(Recording Task)。当摄像头捕获一帧后,迅速将JPEG数据的指针和长度通过一个队列(FreeRTOS Queue)发送给这个录制任务,然后立即返回去处理下一帧或发送网页数据。录制任务在后台从队列里取数据,再从容地写入SD卡。这样,视频流和视频录制就变成了两个并行的流水线,互不阻塞。

2. 采用乒乓缓冲区: 即使是异步,也要避免内存动态分配和拷贝的开销。可以预先分配两个或多个固定大小的JPEG缓冲区(乒乓缓冲区)。摄像头驱动填满一个缓冲区后,将其指针交给录制任务,自己立刻使用下一个空闲缓冲区。录制任务处理完一个缓冲区后,将其标记为空闲,交还给摄像头驱动。这种方式避免了频繁的 malloc/free 操作,在实时系统中非常高效。

3. 提高SD卡写入效率: 频繁调用 fwrite 写小数据块效率很低,因为涉及很多文件系统操作。我们可以缓冲多帧数据后再一次性写入。在录制任务内部,维护一个更大的内存缓冲区(比如20KB或更大),将多帧JPEG数据先拼接到这个缓冲区里,等攒到一定大小(或每隔一定时间),再用一次 fwrite 写入SD卡。这能大幅减少文件系统调用的次数,提升整体吞吐量。不过要注意,这增加了数据丢失的风险(断电会丢失缓冲区内未写入的数据),适合对实时性要求不极端高的场景。

3.3 编译配置与硬件微调

软件优化到一定程度后,可以看看编译环境和硬件设置。在 menuconfig 里(VScode中可通过“ESP-IDF: SDK Configuration editor”打开),有几处可以调整:

  • CPU频率: 确保ESP32的CPU频率设置在了最高档(比如240MHz)。有些开发板默认可能是160MHz。
  • 优化等级: 在“Compiler options”中,将优化等级设置为 -O2 或 -Os(尺寸优化),这能让编译器生成更高效的代码。
  • SPI速度: 在SD卡配置部分,检查SPI总线速度是否已设置为最高允许值(比如40MHz)。提高时钟速度能直接加快读写。
  • Wi-Fi与摄像头优先级: 如果视频流卡顿严重,可以尝试在 menuconfig 中适当提高摄像头中断或相关任务的优先级,确保图像捕获不被其他任务(如Wi-Fi TCP/IP栈)过分打断。

经过这一套“组合拳”优化,在我的测试中,将分辨率设为VGA(640x480),采用异步任务+双缓冲区,帧率可以从原来的14-15fps提升到20-22fps左右,视觉流畅度改善明显。虽然离30fps的流畅标准还有距离,但对于很多嵌入式监控、延时摄影场景来说,已经足够可用。

4. 进阶思路与项目拓展

把基础功能跑通并优化后,这个项目还能玩出很多花样,不再只是一个简单的技术Demo。这里分享几个我实践过或觉得有意思的拓展方向。

实现可控制的延时摄影: 这是原始文章作者最后提到的想法,我觉得非常实用。我们可以修改代码,不是每一帧都录,而是每隔固定的时间间隔(比如1秒、10秒)才捕获一帧并存入AVI文件。同时,在 jpeg2avi_start 函数中,将帧率参数设置为一个较高的值(如24或30)。这样,在播放时,这些长时间间隔拍摄的帧就会以正常速度快速播放,产生时光快速流逝的效果。这完全避开了实时帧率的瓶颈,ESP32处理起来绰绰有余。你可以在网页上增加设置间隔时间和拍摄时长的选项。

增加运动检测触发录制: 一直录制既耗电也浪费存储空间。可以加入简单的运动检测算法。一种轻量级的方法是,在内存中比较连续两帧图像的差异(可以先将图像缩放为极低分辨率如40x30,再计算像素差异总和)。当差异超过某个阈值时,才启动录制,并在运动停止后一段时间自动停止。这能让你的设备更像一个智能安防摄像头。

视频文件分段与循环录制: 为了避免SD卡被撑满,可以实现按时间(如每5分钟)或按文件大小(如每100MB)自动分段保存视频,并循环覆盖最旧的文件。这需要你管理好文件名的生成和旧文件的删除逻辑。同时,在网页界面上提供一个视频文件列表和下载功能,会大大提升项目的实用性。

探索更高效的编码格式: JPEG编码每一帧都是独立压缩的(M-JPEG),虽然简单,但压缩率不如H.264等帧间编码。不过,在ESP32上实时运行H.264编码器非常困难。一个折中的思路是,可以尝试将原始的YUV图像数据(摄像头传感器直接输出的格式)先缓存到SD卡,录制结束后,在电脑端再用工具软件转换成标准视频。这牺牲了实时可播放性,但能获得更高的录制帧率和更长的录制时间。

最后,调试这类项目,一定要善用ESP-IDF的日志系统 (ESP_LOGI, ESP_LOGD)。我在关键函数入口、缓冲区操作、SD卡写入前后都加了带时间戳的日志,通过分析不同步骤的耗时,才能精准定位到底是摄像头采集慢、JPEG压缩慢、还是SD卡写入慢。比如,我发现大部分时间其实花在了 fwrite 系统调用上,这直接促使我采用了缓冲写入的策略。调试的过程就是不断假设、验证、优化的过程,虽然有时候很折腾,但当看到优化后的帧率数字往上跳的那一刻,成就感是真的足。项目的完整代码,你可以基于官方例程和上面提到的思路去构建和调试,过程中遇到的具体问题,往往在社区的论坛或GitHub的Issues里能找到线索。

更多推荐