ESP32-S3 录制摄像头视频并保存到 TF 卡教程

AI助手已提取文章相关产品:

用 ESP32-S3 实现摄像头视频录制到 TF 卡:从零搭建嵌入式视觉系统

你有没有试过在野外部署一个监控设备,只为了拍一段动物出没的视频?或者想做个能自动记录车间状态的小盒子,又不想依赖网络和云服务?这类需求其实很常见—— 我们想要的是一个“会自己记事”的小眼睛 ,它不说话、不联网,但关键时刻能拿出证据。

今天,我们就来手把手打造这样一个“沉默的记录者”:使用 ESP32-S3 + OV2640 摄像头 + TF 卡 ,实现完全离线的本地视频录制。整个过程不需要电脑参与,录完直接拔卡就能在 Windows 上播放 .avi 文件,就像老式 DV 摄像机一样可靠。

听起来是不是有点复古?可正是这种“笨办法”,在资源受限、环境恶劣、供电不稳的场景下,反而最皮实好用 😎。


为什么选 ESP32-S3 来干这活儿?

别看 ESP32 系列常被当作 Wi-Fi 小玩具, ESP32-S3 可是个狠角色 。它是乐鑫专门为 AIoT 视觉应用优化过的型号,双核 Xtensa LX7 处理器跑起来有模有样,主频最高 240MHz,还带 DSP 指令和 AI 加速支持。

更重要的是:

  • ✅ 支持外部 PSRAM(最大 16MB),这是处理图像数据的命脉;
  • ✅ 内置 LCD 接口,可以复用为 DVP 并行总线,完美对接 OV2640 这类摄像头;
  • ✅ 提供标准 SPI 和 SDMMC 接口,轻松驱动 TF 卡;
  • ✅ ESP-IDF 官方支持 esp-camera 组件,OV2640 驱动开箱即用;

换句话说, 这套组合是目前低成本嵌入式视觉中最成熟、文档最全、社区最活跃的一条技术路线 。哪怕你是第一次碰摄像头,也能在几天内跑通全流程。


先搞明白:我们到底在录什么?

很多人以为“视频”就是 MP4 或 H.264,但在 ESP32 这种没有硬件编码器的芯片上硬搞 H.264,基本等于自找麻烦。那怎么办?

答案是: 换思路 —— 我们不“编码”视频,而是“打包”图片流

这就是所谓的 Motion JPEG(M-JPEG) :把一连串 JPEG 图片按时间顺序塞进一个容器文件里,比如 .avi 。每帧都是独立压缩的 JPEG,播放器打开时就像快速翻相册一样,形成连续画面。

虽然 M-JPEG 文件体积比 H.264 大 3~5 倍(没办法,牺牲空间换简单性),但它有几个致命优点:

  • 📦 封装简单:只需要写个 AVI 头 + 不断追加 JPEG 数据;
  • 🔧 解码容易:几乎所有操作系统都原生支持;
  • 💥 抗损性强:哪怕中间某帧坏了,前后还能正常播放;
  • ⚙️ CPU 负载低:摄像头已经输出 JPEG,MCU 几乎不用干活;

所以,在资源紧张的嵌入式系统中, M-JPEG 是最现实的选择 ,尤其适合 QVGA(320×240)或 VGA(640×480)分辨率下的 10~15 FPS 录制。


摄像头怎么接?OV2640 的那些坑你知道吗?

市面上能跟 ESP32 配合的摄像头不少,但真正稳定可用的其实就两款: OV2640 和 OV3660 。今天我们聚焦更常见的 OV2640 —— 成本低、资料多、兼容性好。

它长什么样?怎么连?

OV2640 是 OmniVision 出的一款 CMOS 图像传感器,通过 8位 DVP 并行接口 + I²C 控制总线 与主控通信。

典型引脚连接如下:

ESP32-S3 OV2640
GPIO1~8 D0~D7(数据线)
GPIO9 PCLK(像素时钟)
GPIO10 VSYNC(垂直同步)
GPIO11 HREF / HSYNC(水平参考)
GPIO12 XCLK(输入时钟源)
GPIO18 SDA(I²C)
GPIO19 SCL(I²C)
3.3V VDD/VDDA
GND GND

⚠️ 注意事项:
- 所有信号线尽量等长走线,否则高速传输时会出错;
- XCLK 由 ESP32 输出,通常设为 20MHz;
- 必须外接 3.3V 稳压电源,电压波动会导致初始化失败;
- I²C 地址一般是 0x30 0x60 (读写不同),可以用 i2c_scan 工具先确认是否在线;

如果你用的是 AI-Thinker 的 ESP32-CAM 模块,这些都已经焊好了,省心很多 👍。


初始化不是“一键启动”,顺序很重要!

OV2640 虽然便宜,但它脾气有点怪。 初始化失败十次里九次是因为顺序不对或参数没配好

它的内部结构其实挺复杂:感光阵列 → ISP(图像信号处理器)→ JPEG 编码器。我们需要通过 I²C 写一堆寄存器来告诉它:“我要多少分辨率?”、“要输出 JPEG 还是 RGB?”、“亮度对比度怎么调?”……

幸运的是,ESP-IDF 的 esp-camera 库已经封装了大部分配置表(叫 ov2640_settings.h ),我们只需要调用标准 API 即可:

#include "esp_camera.h"

// 摄像头配置结构体
camera_config_t config = {
    .pin_pwdn = -1,        // 没有使能脚就填 -1
    .pin_reset = -1,
    .pin_xclk = 12,
    .pin_sscb_sda = 18,
    .pin_sscb_scl = 19,

    .pin_d7 = 1,
    .pin_d6 = 2,
    .pin_d5 = 3,
    .pin_d4 = 4,
    .pin_d3 = 5,
    .pin_d2 = 6,
    .pin_d1 = 7,
    .pin_d0 = 8,
    .pin_vsync = 9,
    .pin_href = 10,
    .pin_pclk = 11,

    .xclk_freq_hz = 20000000,
    .ledc_timer = LEDC_TIMER_0,
    .ledc_channel = LEDC_CHANNEL_0,

    .pixel_format = PIXFORMAT_JPEG,  // 关键!必须设为 JPEG
    .frame_size = FRAMESIZE_VGA,     // 分辨率:VGA=640x480
    .jpeg_quality = 12,              // 质量 0~63,越小越大
    .fb_count = 2,                   // 帧缓冲数量(PSRAM 中分配)
    .grab_mode = CAMERA_GRAB_WHEN_EMPTY,
};

// 初始化摄像头
esp_err_t err = esp_camera_init(&config);
if (err != ESP_OK) {
    printf("Camera init failed: %s\n", esp_err_to_name(err));
    return;
}

📌 关键点提醒:
- PIXFORMAT_JPEG 是灵魂!如果设成 YUV 或 RGB,后续内存压力暴增;
- jpeg_quality=12 是个不错的平衡点,QVGA 下单帧约 8~15KB;
- fb_count=2 表示启用双缓冲机制,避免采集和读取冲突;
- 必须确保已启用 PSRAM,否则 fb_count > 1 会失败;

一旦初始化成功,就可以开始拿帧了:

camera_fb_t *fb = esp_camera_fb_get();
if (!fb) {
    printf("Frame buffer not available\n");
    return;
}

printf("Got frame: %dx%d, size: %d bytes\n", 
       fb->width, fb->height, fb->len);

// 使用完记得释放
esp_camera_fb_return(fb);

看到这段日志输出,你就离成功不远了 ✅


TF 卡怎么接?SPI 模式才是王道

现在有了图像帧,下一步就是存下来。TF 卡(microSD)几乎是唯一靠谱的大容量存储方案。

ESP32-S3 支持两种模式接入 TF 卡:
- SDMMC 模式 :速度快(可达 40MHz),但要求严格对齐的 PCB 走线;
- SPI 模式 :速度慢一点(建议 ≤20MHz),但接线灵活、抗干扰强;

对于我们这种 DIY 项目, 强烈推荐 SPI 模式 ,毕竟谁也不想因为一根线太长导致反复掉卡 😅。

接线很简单

ESP32-S3 TF 卡模块
GPIO2 MISO
GPIO15 MOSI
GPIO14 SCK
GPIO13 CS(片选)
3.3V VCC
GND GND

注意:有些劣质 TF 卡模块自带升压电路,反而不稳定,建议选无源模块。


初始化代码来了!

ESP-IDF 提供了基于 FatFs 的完整文件系统封装,我们只需几行代码就能挂载:

#include "driver/spi_common.h"
#include "driver/spi_bus.h"
#include "sdmmc_cmd.h"
#include "esp_vfs_fat.h"

#define PIN_NUM_MISO 2
#define PIN_NUM_MOSI 15
#define PIN_NUM_CLK  14
#define PIN_NUM_CS   13

void init_sd_card(void) {
    esp_err_t ret;

    // 1. 初始化 SPI 总线
    spi_bus_config_t bus_cfg = {
        .mosi_io_num = PIN_NUM_MOSI,
        .miso_io_num = PIN_NUM_MISO,
        .sclk_io_num = PIN_NUM_CLK,
        .quadwp_io_num = -1,
        .quadhd_io_num = -1,
        .max_transfer_sz = 4096
    };

    ret = spi_bus_initialize(SPI2_HOST, &bus_cfg, SPI_DMA_CH_AUTO);
    assert(ret == ESP_OK);

    // 2. 配置 SD 卡设备
    sdspi_device_config_t slot_cfg = SDSPI_DEVICE_CONFIG_DEFAULT();
    slot_cfg.gpio_cs = PIN_NUM_CS;
    slot_cfg.host_id = SPI2_HOST;

    // 3. 挂载 FAT 文件系统
    esp_vfs_fat_sdspi_mount_config_t mount_cfg = {
        .format_if_mount_failed = false,  // 别轻易格式化!
        .max_files = 5,
        .allocation_unit_size = 16 * 1024
    };

    sdmmc_card_t* card;
    ret = esp_vfs_fat_sdspi_mount("/sdcard", &slot_cfg, &mount_cfg, &card);

    if (ret != ESP_OK) {
        if (ret == ESP_FAIL) {
            printf("Failed to mount filesystem.\n");
        } else {
            printf("Failed to initialize the card (%s).\n", esp_err_to_name(ret));
        }
        return;
    }

    printf("SD card mounted successfully\n");

    // 打印卡信息(可选)
    printf("Name: %s\n", card->cid.name);
    printf("Type: %s\n", (card->type == SDMMC_TYPE_SD ? "SD" : "MMC"));
    printf("Capacity: %llu MB\n", card->csd.capacity / 2048);
}

跑通这段代码后,你就可以像操作普通文件系统一样读写 /sdcard 目录了:

FILE *f = fopen("/sdcard/hello.txt", "w");
fprintf(f, "Hello from ESP32-S3!\n");
fclose(f);

🎉 成功写入!这意味着你的“硬盘”已经准备好了。


存储环节的关键陷阱

别高兴太早,TF 卡看似简单,实际坑不少:

❌ 使用劣质卡 = 自杀式操作

一定要买 Class 10 或 UHS-I 标准的正品卡。我曾经用一张杂牌卡测试,每写 3 秒就卡住一次,最后发现是写入性能不足导致 DMA 超时。

❌ 不调用 fflush() = 掉电即丢数据

FatFs 有缓存机制,默认不会立刻写入物理扇区。如果你突然断电,最近几秒的数据可能根本没落盘。

解决方法:定期刷新缓存。

fwrite(data, 1, len, fp);
fflush(fp);  // 强制刷入

当然,频繁刷会降低寿命和帧率,折中做法是每 10 帧刷一次,或者用定时器控制。

❌ 单文件超过 2GB = FAT32 炸裂

FAT32 最大支持 4GB 文件,但某些播放器只认 2GB 以内。如果你打算长时间录制,得做分段处理:

if (file_size > 1.8 * 1024 * 1024 * 1024) {  // 1.8GB
    close_current_file();
    open_new_file();  // video_part2.avi
}

视频文件怎么封装?AVI 头部详解

现在摄像头能出图,TF 卡也能写了,最后一步就是把它们组装成一个“真正的视频文件”。

我们选择 .avi 格式,因为它结构清晰、兼容性好,而且属于 RIFF 容器家族,解析起来不难。

AVI 文件结构简析

一个最小可用的 AVI 文件包含三部分:

  1. RIFF Header :声明这是一个 AVI 文件;
  2. LIST hdrl :存放元数据(宽度、高度、帧率等);
  3. LIST movi :存放实际帧数据(每个 chunk 是一个 JPEG);
  4. (可选)idx1 索引表:记录每帧偏移量(但我们先忽略它)

虽然完整的 AVI 封装很复杂,但我们只需要写出一个“播放器能认出来”的简化版即可。


动手写 AVI 头

下面这个函数可以生成一个适用于 M-JPEG 的 AVI 头:

typedef struct {
    uint32_t chunk_id;   // 'RIFF'
    uint32_t chunk_size;
    uint32_t format;     // 'AVI '
} __attribute__((packed)) riff_header_t;

typedef struct {
    uint32_t list_id;    // 'LIST'
    uint32_t list_size;
    uint32_t type;       // 'hdrl'
} __attribute__((packed)) list_header_t;

void write_avi_header(FILE *fp, int width, int height, int fps) {
    riff_header_t rh = { .chunk_id = 0x46464952, .format = 0x20495641 };
    list_header_t lh = { .list_id = 0x5453494c, .type = 0x6c726468 }; // LIST hdrl

    fwrite(&rh, 1, sizeof(rh), fp);
    uint32_t hdrl_start = ftell(fp);
    fwrite(&lh, 1, sizeof(lh), fp);

    // 写入 'avih' 主头部
    uint32_t tag = 0x68697661; // 'avih'
    uint32_t size = 56;
    fwrite(&tag, 1, 4, fp);
    fwrite(&size, 1, 4, fp);

    uint32_t unused[2] = {0};
    uint32_t total_frames = 0;
    uint32_t streams = 1;
    uint32_t suggested_buffer = 0x10000;
    uint32_t width_le = width;
    uint32_t height_le = height;
    uint32_t scale = 1;
    uint32_t rate = fps;
    uint32_t start = 0;
    uint32_t length = 0;

    fwrite(&unused, 1, 8, fp);
    fwrite(&total_frames, 1, 4, fp);
    fwrite(&streams, 1, 4, fp);
    fwrite(&suggested_buffer, 1, 4, fp);
    fwrite(&width_le, 1, 4, fp);
    fwrite(&height_le, 1, 4, fp);
    fwrite(unused, 1, 16, fp);
    fwrite(&scale, 1, 4, fp);
    fwrite(&rate, 1, 4, fp);
    fwrite(&start, 1, 4, fp);
    fwrite(&length, 1, 4, fp);

    // 结束 hdrl 列表
    long pos = ftell(fp);
    fseek(fp, hdrl_start + 4, SEEK_SET);
    uint32_t hdrl_size = pos - hdrl_start - 8;
    fwrite(&hdrl_size, 1, 4, fp);

    // 开始 movi 列表
    uint32_t movi_list = 0x5453494c; // 'LIST'
    uint32_t movi_size_placeholder = 0; // 后面再填
    uint32_t movi_type = 0x69766f6d;   // 'movi'

    fwrite(&movi_list, 1, 4, fp);
    fwrite(&movi_size_placeholder, 1, 4, fp);
    fwrite(&movi_type, 1, 4, fp);

    // 记录 movi 起始位置,方便后面更新大小
    long movi_pos = ftell(fp);
    fseek(fp, 0, SEEK_END); // 回到末尾继续写
}

这个头文件大概占 200 多字节,写入后就可以不断往里面追加 JPEG 帧了。


怎么命名每一帧?

在 AVI 中,每个 chunk 都有一个 ID,视频流通常是 '00db' (data block)。所以我们每次写入都要加上这个标记:

uint32_t chunk_id = 0x64623030; // '00db'
uint32_t chunk_size = fb->len;

fwrite(&chunk_id, 1, 4, fp);
fwrite(&chunk_size, 1, 4, fp);
fwrite(fb->buf, 1, fb->len, fp);

// 如果长度是奇数,补一个字节对齐
if (chunk_size % 2) {
    uint8_t pad = 0;
    fwrite(&pad, 1, 1, fp);
}

这样播放器就知道:“哦,这是一帧画面”。


录完了要不要修尾巴?

理想情况下,我们应该在录制结束时回填两个字段:
1. RIFF 总长度;
2. movi 列表长度;

但问题来了: ESP32 在录制过程中可能突然断电或重启,根本来不及“优雅收尾”

所以更实用的做法是: 放弃修复头部,靠播放器容错能力撑着

好消息是,VLC、Windows Media Player、甚至手机相册都能播放“未完成”的 AVI 文件,只要前面结构正确就行。你可以理解为:“它知道你在录,就算中途拔卡也尽力播”。

当然,严谨项目还是建议加入 CRC 校验、日志标记、断点续录等功能。


主循环怎么写?稳定性和帧率如何保障?

万事俱备,现在进入核心录制逻辑。

这里有个关键矛盾: 摄像头产帧的速度 ≠ 写卡的速度 。如果某一帧写得太久,下一帧就会积压,最终导致丢帧甚至死锁。

怎么办?两个策略:

✅ 策略一:固定帧率 + 延时控制

假设目标是 15 FPS,那每帧间隔就是 1000ms / 15 ≈ 66.7ms 。我们可以用 vTaskDelay() 来卡节奏:

while (recording) {
    int64_t t_start = esp_timer_get_time();

    camera_fb_t *fb = esp_camera_fb_get();
    if (!fb) {
        printf("No frame captured\n");
        vTaskDelay(pdMS_TO_TICKS(10));
        continue;
    }

    // 写入帧数据
    write_jpeg_frame_to_file(fp, fb);
    esp_camera_fb_return(fb);

    int64_t t_used = esp_timer_get_time() - t_start;
    int64_t t_sleep = 66700 - t_used;  // us

    if (t_sleep > 0) {
        vTaskDelay(pdMS_TO_TICKS(t_sleep / 1000));
    }
}

优点:帧率稳定;
缺点:若某帧处理超时,会压缩延时时间,可能导致累积抖动。


✅ 策略二:双任务解耦(推荐)

更健壮的方式是把“采集”和“写入”拆成两个任务,中间用队列传递帧指针:

QueueHandle_t frame_queue;

// 采集任务
void capture_task(void *pvParam) {
    while (1) {
        camera_fb_t *fb = esp_camera_fb_get();
        if (fb && xQueueSend(frame_queue, &fb, 10) != pdTRUE) {
            esp_camera_fb_return(fb); // 队列满则丢弃
        }
    }
}

// 写入任务
void writer_task(void *pvParam) {
    FILE *fp = fopen("/sdcard/video.avi", "wb");
    write_avi_header(fp, 640, 480, 15);

    while (recording) {
        camera_fb_t *fb;
        if (xQueueReceive(frame_queue, &fb, 1000)) {
            write_jpeg_frame_to_file(fp, fb);
            esp_camera_fb_return(fb);
        }
    }

    fclose(fp);
}

这样即使写卡卡顿,也不会阻塞摄像头继续采图,整体鲁棒性大幅提升。


实战常见问题及解决方案

别以为代码跑通就万事大吉,真实世界总是充满惊喜 😅

🛠 问题 1:摄像头打不开,报 CAMERA_NOT_FOUND

排查步骤:
- 用 I²C 扫描工具检查地址是否存在;
- 测量 XCLK 是否有 20MHz 输出;
- 查看供电是否稳定(万用表测 OV2640 的 VDD 引脚);
- 尝试降低分辨率(如从 VGA 改为 QVGA);

有时候只是排线松了,重新插一下就好了……


🛠 问题 2:TF 卡识别不了,提示 SDMMC_CMD_TIMEOUT

常见原因:
- 接线太长或接触不良;
- 卡本身损坏或格式不兼容;
- SPI 速率太高(>20MHz)导致通信失败;

解决办法:
- 换张 FAT32 格式的 ≤32GB 卡;
- 把 max_freq_khz 设为 10000 (10MHz)试试;
- 加上 10kΩ 上拉电阻到 MISO/MOSI/SCK;


🛠 问题 3:录像一会儿就卡住,CPU 占满

多半是内存崩了!

OV2640 输出的 JPEG 帧需要放在 PSRAM 中。如果没有开启 PSRAM,或者堆空间不足,会导致 malloc 失败,进而引发无限重试或看门狗复位。

检查项:
- menuconfig 中是否启用 PSRAM;
- fb_count 是否设为 1(默认放内部 RAM);
- 是否有其他大内存占用模块(如 LVGL);

建议在录制前打印内存状态:

heap_caps_print_heap_info(MALLOC_CAP_SPIRAM);

看到 “SPIRAM free” 至少有几 MB 才算安全。


🛠 问题 4:文件能生成,但打不开

这种情况八成是 AVI 头写错了。

建议做法:
- 先用 Python 写一个最小 AVI 文件,确认播放器能打开;
- 对比 hex dump,找出差异;
- 使用现成库如 simple-avi-writer 移植版本;

一个小技巧:用 VLC 播放时开启日志,能看到具体的解析错误。


如何让它更聪明?未来的升级方向

你现在拥有的是一个“傻瓜相机”级别的系统。但它潜力远不止于此。

🔍 加入 PIR 人体感应,实现运动触发录制

接一个 HC-SR501 或 APDS-9960,平时休眠,有人经过才启动摄像头,极大延长电池寿命。

if (gpio_get_level(PIR_PIN) == 1) {
    start_recording();
    last_motion_time = millis();
} else if (millis() - last_motion_time > 30e3) {
    stop_recording();
}

📡 录完自动上传到服务器或 NAS

通过 Wi-Fi 把视频传走,然后清空 TF 卡,实现“无限循环录制”。

协议可选:
- FTP:直接扔到树莓派;
- HTTP POST:发给 Node.js 服务;
- Samba/CIFS:挂载网络硬盘(需额外组件);


🤖 加 AI 检测,只录“有意义”的画面

利用 ESP32-S3 的 AI 加速能力,跑轻量级模型检测是否出现人、猫、狗等目标,只有感兴趣的对象出现才开始录。

相关框架:
- ESP-DL(乐鑫官方深度学习库);
- TensorFlow Lite Micro(需裁剪模型);

想象一下:你家猫咪跳上餐桌才会触发录制,其他时间安静待机 —— 这才是智能监控该有的样子🐱。


🔄 构建完整的边缘视觉节点

最终形态可能是这样的:

[OV2640] → [ESP32-S3] → [AI推理] → [决策]
                     ↘           ↘
                    [TF卡]     [Wi-Fi → 云端]

白天本地存原始视频,晚上定时上传摘要片段;
平时低帧率运行,异常事件提升质量;
支持 OTA 更新算法,永远不过时。


写在最后:嵌入式开发的魅力在哪里?

做这个项目的过程中,你会遇到各种意想不到的问题:某个引脚接触不良、某张卡莫名其妙罢工、某个帧头少写了两个字节导致全盘皆输……

但当你终于看到那个 .avi 文件在电脑上顺利播放,画面里是你亲手搭建的小设备正在“看着这个世界”——那种成就感,无可替代。

这不仅仅是在“存视频”,而是在教会一块硅片如何去观察、记忆和表达。

而 ESP32-S3 + OV2640 + TF 卡这套组合,正是通往这个世界的最低门槛门票。

所以,别再犹豫了。去买块板子,插张卡,点亮你的第一个视觉节点吧 🎥✨

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

您可能感兴趣的与本文相关内容

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值