用 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 文件包含三部分:
- RIFF Header :声明这是一个 AVI 文件;
- LIST hdrl :存放元数据(宽度、高度、帧率等);
- LIST movi :存放实际帧数据(每个 chunk 是一个 JPEG);
- (可选)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),仅供参考
944

被折叠的 条评论
为什么被折叠?



