ESP32相机怎样识破照片保存的假成功

2026-10-184

按下快门,串口弹出一行 Saved,拔下 TF 卡却发现照片打不开。镜头、排线和 JPEG 编码都可能被怀疑,最该先问的其实是一个很小的问题——程序凭什么说保存成功?

open() 成功,只能说明拿到了文件句柄。write() 被调用,也不等于要求写入的字节已经全部接收。把这两件事直接翻译成“照片保存成功”,相当于看见快递员开了车门,就替收件人签收。

下面给 ESP32-S3 相机单独做一个存储最小工程。重点是把成功条件说清楚,再用故障注入检查这些条件是否真的成立。

相机实物参考

图1 对应硬件的原有成品照片,用于说明相机形态;它不是本文新程序的拍摄实测。

先把硬件和软件对上号

这次核对的是 EDA-Camera 的 TP4056方案_v1.2,原理图为 Schematic1_2,PCB 为 PCB1_2,图纸版本日期为 2025-07-21。主控采用带 8MB Flash、8MB PSRAM 的 ESP32-S3 开发板。

相机先输出 QVGA,也就是 320×240 的 JPEG;只用一个帧缓冲。完整工程使用 ESP-IDF,不启用 TFT 预览、Web 相册和滤镜,取帧、保存、回读都由同一个应用任务顺序处理。这样每一次错误都能定位到存储链路,而不会混进任务被强制删除、缓冲区被另一段程序归还等问题。

实际EDA导出的TF卡电路

图2 从对应版本 EDA 工程重新导出的 TF 接口局部。卡座的 DAT3/CS、CMD/MOSI、CLK、DAT0/MISO 分别走 SPI 的四根信号。

功能 ESP32-S3 GPIO
TF 卡 CS / MOSI / CLK / MISO 11 / 12 / 13 / 14
快门按键,低有效 10
相机 D0~D7 16 / 38 / 15 / 39 / 41 / 40 / 42 / 21
相机 SDA / SCL / PWDN 17 / 18 / 46
相机 PCLK / VSYNC / HREF 45 / 48 / 47

实际EDA导出的主控连接

图3 主控连接图。黑色数字是排针物理序号,板上绿色标记才是 GPIO;例如 TF_CS 位于物理第12脚,却连接 GPIO11。

这是很容易照着图接错的一步。代码里填 GPIO11,不能因为图纸旁边印着“12”就改成 GPIO12。

相机接口也有一个前提。这里采用自带时钟的模组,工程把 pin_xclk 和 pin_reset 设为 -1。这不等于裸 OV2640、OV5640 可以省掉输入时钟;只有接口线序、电压和时钟来源都一致的模组,才适用这组配置。不要把常见 ESP32-CAM 的引脚表套过来。

TF 卡通过 ESP-IDF 的 SDSPI 驱动挂载到 /sdcard,先从 4MHz 开始。这个频率是初始配置,不是实卡速度或兼容性测试结论。完整初始化和错误处理在附件中,下面摘出与图纸对应的配置。

sdmmc_host_t host = SDSPI_HOST_DEFAULT();
host.max_freq_khz = 4000;
spi_bus_config_t bus = {};
bus.mosi_io_num = SD_MOSI; bus.miso_io_num = SD_MISO;
bus.sclk_io_num = SD_CLK;
bus.quadwp_io_num = -1; bus.quadhd_io_num = -1;
bus.max_transfer_sz = 4096;

对应版本PCB的顶面和底面

图4 同一 PCB 版本的顶、底面重新导出。底面已水平翻转为从板底观察,因此尺寸标注会反向;曝光提亮未改变连线。母图分别为4095×3072像素。

从板上看,TF 卡接口与主控排针之间仍有一段板级走线。遇到挂载失败,先核对卡座方向、供电和焊接,再查 SPI 引脚、片选以及文件系统格式。反复升高 SPI 时钟,不能替代这些检查。

本文不修改充电电路,也没有验证电池并联、充电电流和温升。存储程序通过验证,不能顺带证明这些硬件指标合格。

写了多少,就只能前进多少

Arduino-ESP32 的 File::write() 返回实际接收的字节数。这个检查原则同样适用于 ESP-IDF 的文件接口;本文固件用 POSIX write() 封装存储适配器,负值或0均转为写失败,正数按实际数量推进。请求4096字节,返回512,下一次就应从第512字节继续。若不检查返回值,后面的 close() 和一条 Saved 不会把缺失的3584字节补回来。

下面是完整工程存储核心中的实际循环。s 是文件接口适配器,data 和 len 是当前 JPEG 帧;每次最多请求4096字节。

while (r.written < len) {
  const size_t remaining = len - r.written;
  const size_t wanted = remaining < 4096 ? remaining : 4096;
  const size_t n = s.write(data + r.written, wanted);
  if (n == 0 || n > wanted) {
    s.closeWrite();
    r.code = Code::WriteFailed;
    r.cleanupFailed = !s.removeFile(r.tempPath);
    return r;
  }
  r.written += n;
}

这里没有把所有短写都判死刑。返回正数,说明至少推进了一段,可以继续;返回0才终止,避免卡故障时无限重试。返回值大于请求长度同样属于接口异常,必须拒绝。

写入前还检查空指针、长度上下限,以及 JPEG 的 SOI、EOI 首尾标记。这只是帧边界检查,不能代替 JPEG 解码,更不能证明照片曝光正确。

给照片一个正式名字之前,再查两遍

直接写 photo_00001.jpg 有个麻烦——写到一半失败,目录里仍会出现一个像正式照片的文件。相册可能把它展示出来,使用者看到缩略图或文件名,也容易误以为拍摄已经完成。

这里先创建 photo_00001.part。选名时同时检查同编号的 JPG 和 PART,任意一个存在就跳过,编号范围为00001~99999。名字用完明确返回 NAMES_FULL,不覆盖旧文件,也不死循环。

照片写入提交的检查流程

图5 新程序实际执行的检查顺序。这里的“提交”是应用层文件命名流程,不代表 FAT 获得了断电事务能力。

写满后,先 fsync()、关闭,再重新打开 PART。同步或关闭失败也拒绝通过后面的校验。核对长度后,每次读取最多512字节,与仍然持有的 JPEG 帧逐字节比较。只有完全相同才改为 JPG。

为什么不用一个 CRC 就结束?在一帧仍可留在 PSRAM 的前提下,逐字节比较能直接回答“读回来的内容是否与这帧相同”,不需要再讨论摘要碰撞。代价是延长了帧缓冲占用,并多读一遍文件。

正式改名后还要再查一次。以下代码与附件一致。

s.closeWrite();
if (!s.equalFile(r.tempPath, data, len)) {
  r.code = Code::ReadbackFailed;
  r.cleanupFailed = !s.removeFile(r.tempPath);
  return r;
}
if (s.exists(r.finalPath) || !s.renameFile(r.tempPath, r.finalPath)) {
  r.code = Code::RenameFailed;
  r.cleanupFailed = !s.removeFile(r.tempPath);
  return r;
}
if (s.exists(r.tempPath) || !s.equalFile(r.finalPath, data, len)) {
  r.code = Code::CommitUncertain;
  return r;
}
r.code = Code::Ok;
return r;

第二次回读,是为了不把 rename() 返回成功直接当成终点。只有临时名消失、正式文件的长度与内容再次一致,才报告 OK。结果不明时返回 COMMIT_UNCERTAIN,保留现场,不能一边显示成功一边删掉可能完整的照片。

如果删除 PART 失败,结果会带上 cleanup_failed=1。下次拍摄跳过该编号;程序不自动清除历史 PART,人工处理前应先备份并判断来源。

这些规则依赖一个共同前提——当前挂载文件系统正常,只有一个所有者操作它。exists() 与打开文件之间并不是天然的跨任务原子操作。以后恢复 Web 相册,保存、枚举、下载和删除都必须走同一个所有者或一致的互斥机制,不能只给保存函数加一把锁。

还有一条边界更重要。回读可能经过文件系统和卡内缓存;fsync()、关闭和 FAT 改名都不能证明突然拔电后文件一定持久存在。本文的 OK 表示本次挂载下两次读回均与当前帧一致,不表示完成了掉电可靠性认证。

故障要主动制造,不能等坏卡来配合

正常保存一次,很难证明错误路径写对了。测试程序把真实 SD 接口换成内存文件接口,让完全相同的 photo_commit.cpp 接受可控故障。

数据夹具长度为10007字节,内容确定,只加了 JPEG 首尾标记;它不是一张可解码照片。测试先让正常路径通过,再限制每次写入只接收1、7、511、512、4095字节,检查短写之后的偏移是否连续。

接着,在0~10006的每个字节偏移注入写入中断,检查是否错误地产生了正式 JPG、是否留下打开的句柄、旧文件是否被改变。其他分支还包括回读数据被改、文件被截断、改名失败、改名返回成功却没执行、正式路径数据损坏,以及临时文件删除失败。

实际主机故障测试日志

图6 真实运行输出,共通过30056个断言。它是主机文件接口故障注入记录,不是相机串口、实卡写入或断电测试。

完整 ESP32-S3 固件也已在 ESP-IDF 5.4.3 下实际编译、链接并生成镜像,应用镜像为396176字节,能放入配置的1MB应用分区。相机组件固定为2.0.16,JPEG组件固定为1.3.1;这些构建结果没有包含实体烧录或拍照测试。

这些结果证明的是存储核心在所列接口行为下的处理结果,以及固定环境下的目标构建情况。实体相机取帧、不同 TF 卡、供电波动和突然断电仍需要分别做台架验证,不能从主机测试数量推导出来。

把最小工程跑起来

源码包包含 src/main.cpp、存储核心、主机故障测试、固定构建配置和实际验证日志。固件完全独立编写,不要求修改显示库内部配置。

环境固定为 ESP-IDF 5.4.3、官方 esp32-camera 2.0.16。目标为 ESP32-S3,配置8MB Flash、OPI PSRAM;依赖与构建配置随源码提供。

安装并激活 ESP-IDF 5.4.3 环境,解压后进入 code 目录,先编译,再把 COMx 换为实际串口烧录。首次构建会由官方组件管理器获取固定依赖。先备份卡中资料,并使用本构建能够挂载的 FAT 文件系统;不把 exFAT 当作自动兼容。

idf.py set-target esp32s3
idf.py build
idf.py -p COMx flash monitor

打开串口后复位,只有看到 READY press shutter 才按 GPIO10 对应的快门。按住至少30ms,再释放;每次按下触发一帧。程序不配置屏幕,状态由默认 UART0 控制台以115200波特率输出。若开发板只引出了原生USB,应另接UART0或明确调整控制台配置后重新编译。

输出 先检查什么
PSRAM_MISSING 实际开发板、PSRAM容量与OPI配置是否匹配
SD_MOUNT_FAILED 供电、卡座、四根SPI信号、卡格式
CAMERA_INIT_FAILED 模组时钟、接口方向、SCCB与数据引脚
CAPTURE_FAILED / NOT_JPEG 取帧结果及格式,未进入写卡
WRITE_FAILED 实际写入数,保留错误结果,不显示成功
READBACK_FAILED PART长度或内容与当前帧不一致
RENAME_FAILED 正式名字冲突或文件系统改名失败
COMMIT_UNCERTAIN 备份现场后检查两个路径,不立即重拍覆盖

帧缓冲从取到手一直保留到两次回读结束,随后才调用 esp_camera_fb_return()。这会让保存过程更慢,但此时最需要的是一个含义准确的 OK。等实体链路验证通过,再测耗时、调整分辨率和存储时钟,比先塞回一整套相册功能更容易定位问题。

完整源码与验证日志下载 ESP32相机存储最小工程1.1.0

资料与许可 硬件及实物照片采用 EDA-Camera 项目资料,署名 JasonYANG170、嘉立创EDA-莫工及EDA课程案例团队,实物拍摄致谢嘉立创EDA活动酱。项目和设计源文件 https://oshwhub.com/course-examples/camera ,项目页声明GPL3.0;图纸及照片的裁剪、缩放、底面翻转和曝光调整已在图注交代。程序接口对照乐鑫 ESP-IDF 5.4.3 与官方 esp32-camera 2.0.16,文中的 Arduino 写入返回值说明参照 Arduino-ESP32 2.0.17。本文独立代码与测试按MIT提供,不包含许可文本不完整的参考仓库源码。本文完成主机故障验证和目标构建核验,未做实体拍照、实卡兼容性和掉电测试。

技术交流,欢迎关注微信公众号:美男子玩编程。

创作许可协议

本项目采用 CC BY(署名) 进行许可。

评论(0)
- 没有更多了 -