【以太网摄像头采集存储终端】SD 抓拍存储以及 USB 模拟 U 盘导出文件
1. 引言
前面的文章已经完成了 OV5640 图像采集、LCD 显示、JPEG 硬件编解码、HTTP MJPEG 推流以及 OTA 升级。这一篇给相机补上"落地"的一步:按一下按键把画面拍进 SD 卡,再用 USB 线插到电脑上,把整张卡当成 U 盘读写。
本篇涉及的三个外设——SDMMC2、FatFs、USB OTG——在本系列中还没有出现过,因此与前面的文章不同,这一篇会把 CubeMX 的配置和生成代码的接入完整写出来,没有碰过这些外设的读者也可以照着做。
当前实现包含以下功能:
- 短按抓拍:松开按键保存一张 800×480 的 24 位 BMP,文件名从
CAM0001.BMP自动递增,不覆盖旧照片。 - 长按切换:按住两秒,在本地 FatFs 与 USB 导出之间切换 SD 卡的所有权。
- 读写导出:USB 模式下电脑识别出 U 盘,可读也可写;电脑写回的文件在切回本地后照常存在。
- 业务互斥:OTA 期间禁止抓拍与切换;USB 模式下直接拒绝 OTA。
- 故障防护:挂载失败不自动格式化;文件关闭失败锁存故障等待复位;SD 读写故障复位控制器后重建。
2. 从按键到 U 盘的整体链路
2.1 两条操作路径
先看整篇文章的主线,一句话概括:短按把一帧图像变成卡上的 BMP 文件;长按把同一张卡的扇区访问权交给 USB 主机。
板子上只有一个按键 KEY0(PA8),它承担两种操作,触发后走完全不同的路径:
短按路径自上而下:图像从显示帧复制出来,转成 BMP 落到 SD 卡。长按路径则不动图像,只改变"谁能访问卡":本地模式把卡交给 FatFs,USB 模式把卡交给 USB 主机。
两条路径共享一条铁律:一张 SD 卡同一时刻只有一个主人。FatFs 在写文件时 USB 不能碰扇区;电脑挂着 U 盘时 FatFs 必须已卸载。这条铁律由 2.2 的所有权状态机保证,也是本文最重要的设计。
2.2 存储所有权的四个模式
存储管理把卡的状态分成四个模式:
| 模式 | 含义 | 谁在访问卡 |
|---|---|---|
STORAGE_LOCAL |
本地模式 | FatFs(抓拍保存) |
STORAGE_SWITCHING |
切换中 | 交接流程本身 |
STORAGE_USB |
USB 导出模式 | USB 主机(读写扇区) |
STORAGE_FAULT |
故障 | 无人访问,等待复位 |
切换不是瞬时完成的:卸载文件系统、停止 USB 设备、重新挂载都要等真实硬件操作结束,所以中间经过 SWITCHING 状态;任何一步失败就进入 FAULT,宁可不用也不能让两个主人同时摸卡。
2.3 任务分工与文件职责
这条链路涉及的 FreeRTOS 任务如下,抓拍任务的优先级刻意压低,保住预览和推流的实时性:
| 任务 | 优先级 | 职责 |
|---|---|---|
Camera_Thread |
5 | OV5640 持续采集与显示 |
| MJPEG 编码/发送任务 | 4 | 推流编码与两个观看连接 |
usb_msc |
AboveNormal | USB 设栈处理与扇区读写 |
snapshot |
3 | 抓拍保存、存储初始化与模式切换 |
key_mode |
Normal | 按键轮询、消抖与长短按识别 |
主要代码集中在以下文件:
| 文件 | 职责 |
|---|---|
CM7/APP/CAMERA/app_snapshot.c |
按键任务、抓拍任务与 BMP 保存 |
CM7/APP/CAMERA/app_storage.c |
存储所有权状态机与切换流程 |
CM7/APP/CAMERA/app_sd.c |
SDMMC2 同步轮询读写适配层 |
CM7/APP/CAMERA/app_usb_export.c |
USB 任务、启停握手与扇区接口 |
CM7/USB_DEVICE/App/usbd_storage_if.c |
MSC 存储回调,转发到 app_usb_export_* |
CM7/FATFS/Target/user_diskio.c |
FatFs 磁盘接口,转发到 app_sd_* |
CM7/Core/Src/stm32h7xx_it.c |
USB 中断入口,只做唤醒 |
2.4 阅读线索
阅读后文时可以围绕三个问题展开:
- 一帧图像怎么安全地变成卡上的 BMP 文件?
- 一张卡怎么在 FatFs 和 USB 主机两个主人之间交接?
- USB 模式下,板子在做什么、不能做什么?
3. SD 卡与 FatFs:抓拍的存储地基
这一章从零开始配置 SD 卡。先在 CubeMX 里把 SDMMC2 和 FatFs 配好,再写一层薄薄的适配代码,让 FatFs 能读写卡。
3.1 SDMMC2 外设配置
引脚
H757 的 SDMMC 外设有两个实例,这里用 SDMMC2,占用六个引脚:
| 引脚 | 功能 | 复用 |
|---|---|---|
| PA0 | SDMMC2_CMD | AF9 |
| PD6 | SDMMC2_CK | AF11 |
| PB14 | SDMMC2_D0 | AF9 |
| PB15 | SDMMC2_D1 | AF9 |
| PB3 | SDMMC2_D2 | AF9 |
| PB4 | SDMMC2_D3 | AF9 |
在 CubeMX 的引脚视图里点开 SDMMC2,勾选 SD 4 bit Wide bus 即可自动分配。引脚速度保持 VERY_HIGH,由生成代码统一配置。
时钟
SDMMC 的内核时钟不走 SYSCLK,需要单独选择。工程把 SDMMC 时钟源设为 PLL2R,PLL2 参数如下(可在 Clock Configuration 页配置,或在 SDMMC2 的 MspInit 里看到生成结果):

PLL2 输入 = HSE 25 MHz
M = 5 → 分频后 5 MHz(落在输入范围 4~8 MHz)
N = 80 → VCO 输出 400 MHz
R = 4 → PLL2R = 100 MHz(SDMMC 内核时钟)
SDMMC 实际输出给卡的时钟还要经过 ClockDiv 分频:
卡时钟 = 100 MHz / (ClockDiv + 2) = 100 / (2 + 2) = 25 MHz
ClockDiv 取 2 而不是 0,是给 25 MHz 留了余量:4 bit 总线下 25 MHz 已有 12.5 MB/s 的理论上限,对每秒一张的抓拍绰绰有余,时钟越低信号越稳。
参数配置
CubeMX 中 SDMMC2 的 Parameter Settings 只改了两项,其余默认:

| 参数 | 取值 | 说明 |
|---|---|---|
| ClockDiv | 2 | 卡时钟 25 MHz |
| HardwareFlowControl | Enable | 硬件流控,防止主机侧响应慢导致 CMD 线冲突 |
| BusWide | 4B(默认) | 初始化仍从 1 bit 开始,HAL 在卡就绪后切 4 bit |
生成的初始化代码在 Core/Src/sdmmc.c。工程对它做了一处改造:初始化失败不进 Error_Handler() 死循环,而是把错误留给存储任务处理——SD 卡是可插拔介质,没插卡不该拖死整个应用:
void MX_SDMMC2_SD_Init(void)
{
/* USER CODE BEGIN SDMMC2_Init 1 */
/* 配置由 CubeMX 提供;错误由存储任务处理,避免介质故障终止整个应用。 */
hsd2.Instance = SDMMC2;
hsd2.Init.ClockEdge = SDMMC_CLOCK_EDGE_RISING;
hsd2.Init.ClockPowerSave = SDMMC_CLOCK_POWER_SAVE_DISABLE;
hsd2.Init.BusWide = SDMMC_BUS_WIDE_4B;
hsd2.Init.HardwareFlowControl = SDMMC_HARDWARE_FLOW_CONTROL_ENABLE;
hsd2.Init.ClockDiv = 2;
(void)HAL_SD_Init(&hsd2);
return; /* 提前返回,跳过生成代码里的 Error_Handler 分支 */
/* USER CODE END SDMMC2_Init 1 */
/* ……此处省略未执行的 CubeMX 原始初始化代码…… */
}
另外工程在 CM7 的 Makefile 里加了编译宏 -DSDMMC_SWDATATIMEOUT=1000U,把 HAL 等待卡数据阶段完成的超时从默认值压到 1 秒。官方 SDMMC 头文件对这个宏用了 #ifndef 保护,所以不需要改动 HAL 源码。
3.2 FATFS 中间件配置
CubeMX 配置
Middleware 里勾选 FATFS,接口类型选 User-defined。这样 CubeMX 不会把 FatFs 绑定到 SDMMC,而是生成一套通用的驱动链接框架(ff_gen_drv),由用户填五个磁盘接口函数。


关键配置项:
| 配置项 | 取值 | 说明 |
|---|---|---|
| 勾选 User-defined | 是 | 不与特定外设绑定 |
| 自动初始化 | 关闭 | MX_FATFS_Init() 不在 main() 里调用,由存储任务调用 |
CubeMX 的 FATFS 配置页上实际设置的选项如下,每一项都值得知道为什么:
| 配置项 | 取值 | 影响 |
|---|---|---|
_CODE_PAGE |
437 | OEM 代码页,影响文件名字符集 |
_USE_LFN |
0 | 不启用长文件名,只支持 8.3 短名——CAM0001.BMP 恰好是 8.3 格式,不受影响 |
_USE_MKFS |
0 | 不编入 f_mkfs,与 3.4 节"不格式化"的策略一致 |
_FS_NORTC |
1 | 板上无 RTC,文件时间戳用固定日期,照片的修改时间不代表拍摄时间 |
_FS_LOCK |
2 | 打开文件期间锁定对象,并检查重复打开等非法操作 |
_FS_REENTRANT |
0 | FatFs 不可重入——这是第 5 章"所有 FatFs 操作只发生在抓拍任务"的配置依据 |
其余保持默认:_FS_READONLY = 0(允许写)、_VOLUMES = 1(一个卷)、_MIN_SS = _MAX_SS = 512(固定 512 字节扇区)。
生成代码的结构
CubeMX 生成两个文件,构成"FatFs → 通用驱动层 → 用户实现"三层:

f_open / f_write / f_close(FatFs 核心,Middlewares 里的 ff.c)
│ 通过卷号找到注册的驱动
▼
ff_gen_drv.c:disk_initialize / disk_read / disk_write ...
│ 转发到注册进来的 USER_Driver
▼
user_diskio.c:USER_initialize / USER_read / USER_write ...
│ 每个函数一行,直接转发
▼
app_sd.c:真正的 SDMMC2 读写实现
user_diskio.c 里把生成的骨架函数全部改成转发,例如读扇区只剩一行:
DRESULT USER_read(BYTE pdrv, BYTE *buff, DWORD sector, UINT count)
{
/* USER CODE BEGIN READ */
return app_sd_read(pdrv, buff, sector, count);
/* USER CODE END READ */
}
fatfs.c 里的 MX_FATFS_Init() 负责把驱动注册进 ff_gen_drv,得到逻辑卷路径 "0:":
uint8_t retUSER; /* FATFS_LinkDriver 返回值 */
char USERPath[4]; /* 生成的卷路径 "0:" */
FATFS USERFatFS; /* 文件系统对象 */
void MX_FATFS_Init(void)
{
retUSER = FATFS_LinkDriver(&USER_Driver, USERPath);
}
由任务决定何时初始化
注意一个容易踩的坑:CubeMX 默认会在 main() 里自动调用 MX_SDMMC2_SD_Init() 和 MX_FATFS_Init(),本工程把这两个自动调用都关掉了,改由抓拍任务在 app_storage_start() 里按需执行:
void app_storage_start(void)
{
MX_FATFS_Init();
FRESULT result = retUSER == 0U ? f_mount(&USERFatFS, USERPath, 1U) : FR_INVALID_DRIVE;
s_mode = result == FR_OK ? STORAGE_LOCAL : STORAGE_FAULT;
LOG_INFO("STORAGE", "STORAGE_INIT FR=%d mode=%d (no format)", result, s_mode);
app_usb_export_start_task();
}
f_mount 第三个参数传 1 表示立即挂载(会触发 disk_initialize,真正去初始化卡)。挂载结果决定初始模式:成功进本地模式,失败进故障模式。
3.3 同步轮询适配层
app_sd.c 是 FatFs 与 SDMMC2 之间的适配层。它有三个刻意的设计。
第一,只用同步轮询,不用中断和 DMA。 抓拍写卡的节奏是"一行一行写",数据量小、实时性要求低,同步轮询的实现最简单,也最容易推理:函数返回时数据已经落卡。
第二,单扇区字对齐中转。 SDMMC 的数据寄存器访问要求字对齐,而 FatFs 传来的缓冲区地址不保证对齐,所以用一个静态中转区倒一手:
static uint32_t s_sector[512U / 4U]; // 字对齐的单扇区中转区,调用方须串行访问
读写都以单个扇区为单位循环:
while (count-- != 0U)
{
HAL_StatusTypeDef status;
if (writing)
{
memcpy(s_sector, buffer, 512U);
status = HAL_SD_WriteBlocks(&hsd2, (uint8_t *)s_sector, sector, 1U, SD_TIMEOUT_MS);
}
else
status = HAL_SD_ReadBlocks(&hsd2, (uint8_t *)s_sector, sector, 1U, SD_TIMEOUT_MS);
/* HAL 数据传输结束后仍需等待卡内部操作完成,才能开始下一扇区。 */
if (status != HAL_OK || wait_ready() != RES_OK)
{
LOG_ERROR("STORAGE", "SD_IO_FAIL write=%d sector=%lu HAL=0x%lX",
writing, (unsigned long)sector, (unsigned long)hsd2.ErrorCode);
stop_transfer();
return RES_ERROR;
}
if (!writing) memcpy(buffer, s_sector, 512U);
buffer += 512U;
sector++;
osThreadYield(); // 扇区之间向同优先级就绪任务让出执行机会
}
这里有个容易忽略的细节:HAL_SD_WriteBlocks() 返回 HAL_OK 只表示数据交给了卡,卡内部的编程(把数据写进 Flash)还要一段时间。wait_ready() 轮询卡状态直到回到 HAL_SD_CARD_TRANSFER,带着 1 秒超时:
static DRESULT wait_ready(void)
{
uint32_t start = HAL_GetTick();
while (HAL_SD_GetCardState(&hsd2) != HAL_SD_CARD_TRANSFER)
{
if (HAL_GetTick() - start >= SD_TIMEOUT_MS) return RES_ERROR;
osDelay(1U);
}
return RES_OK;
}
不等卡空闲就发下一条命令,是 SD 写入丢数据的常见原因。
第三,故障就复位控制器。 任何读写失败都执行 stop_transfer():强制复位 SDMMC2 并把状态标记为未初始化,阻止后续调用继续使用状态不明的控制器:
static void stop_transfer(void)
{
s_status = STA_NOINIT;
__HAL_RCC_SDMMC2_FORCE_RESET();
__DSB();
__HAL_RCC_SDMMC2_RELEASE_RESET();
}
复位之后下一次 app_sd_initialize() 会重新走 HAL_SD_DeInit → MX_SDMMC2_SD_Init → 等卡就绪的完整初始化,这就是第 5 章切换回本地时必须重链驱动的原因之一。
app_sd_ioctl() 处理 FatFs 的杂项查询:CTRL_SYNC(确认卡空闲)、GET_SECTOR_COUNT(返回 LogBlockNbr)、GET_SECTOR_SIZE(固定 512)、GET_BLOCK_SIZE(返回 1,表示不擦除块信息)。
3.4 挂载失败不格式化
app_storage_start() 的日志末尾带着 (no format)。挂载失败时工程不会调用 f_mkfs(),而是进入故障模式,用串口日志把问题暴露出来。
这是一条取向明确的设计:SD 卡里可能有用户数据,"自动格式化"对存储设备来说等于自动销毁证据。格式化应该由人插拔卡后在电脑上做,板子只负责报告"我挂不上"。
4. 按键与抓拍:从显示帧到 BMP 文件
4.1 按键消抖与长短按
KEY0 接 PA8,输入下拉,按下为高电平。按键任务每 10 ms 轮询一次,电平持续稳定 30 ms 才接受边沿:
if (sample != stable && now - changed >= 30U) // 电平持续稳定 30 ms 才接受边沿
{
stable = sample;
if (stable)
pressed = now; // 按下沿:记录起点
/* else:松开沿的短按处理见下文 */
}
一个按键要区分"短按松开"和"长按两秒",还需要两个辅助标志:
| 标志 | 作用 |
|---|---|
armed |
上电时按键已按住的话,先要求松开一次,之后每次松开沿都会重新武装 |
held |
长按已触发;抑制随后松开沿的抓拍动作,直到下一次按下沿重置 |
短按的处理在松开沿:
if (armed && !held)
{
taskENTER_CRITICAL();
uint8_t accepted = !s_busy && app_storage_mode() == STORAGE_LOCAL;
if (accepted) s_requested = 1U;
taskEXIT_CRITICAL();
if (!accepted) LOG_INFO("SNAPSHOT", "SNAPSHOT_BUSY mode=%d", app_storage_mode());
}
armed = 1U;
按键任务只提交请求(s_requested),不直接做任何文件操作;抓拍请求是否被接受,取决于抓拍任务是否空闲、是否在本地模式。
长按在按住满 2 秒的瞬间触发,并且先把可能已挂起的抓拍请求清掉,再提交切换:
if (stable && armed && !held && now - pressed >= 2000U)
{
held = 1U; // 本次长按只处理一次,并抑制松开后的短按动作
taskENTER_CRITICAL(); s_requested = 0U; taskEXIT_CRITICAL();
LOG_INFO("STORAGE", "BUTTON_LONG accepted=%d", app_storage_toggle());
}
还有一个容易忽略的分支:held 在每次按下沿被重新赋值为"当前是否处于切换中"。也就是说切换过程中按下的这一次,松开后不会触发抓拍——切换中的按键只用来排队下一次切换,不会被误读成拍照。
4.2 安全复制显示帧
抓拍要的是"某一时刻的完整图像",但显示帧缓冲随时可能被 DMA 改写。直接把显示帧地址拿去转 BMP,读到的一半是新帧一半是旧帧。
MJPEG 推流解决同样问题的办法是持有帧(ov5640_dcmi_acquire_frame),抓拍则用另一个接口复制帧:
copied = ov5640_dcmi_copy_frame(SNAPSHOT_FRAME);
复制与持有的区别在于:持有期间 LCD 只能显示别的帧,编码器独占这块内存;复制则把帧内容搬走,尽快还回显示帧。抓拍是低优先级任务,复制期间还主动让出 CPU,每 4 ms 一次:
for (uint32_t offset = 0U; offset < CAMERA_FRAME_BYTES; offset += 800U * 16U * 2U)
{
g_stream.copy_offset = offset;
SCB_InvalidateDCache_by_Addr((uint32_t *)(address + offset), 800U * 16U * 2U);
memcpy((uint8_t *)destination + offset, (const void *)(address + offset), 800U * 16U * 2U);
if (offset + 800U * 16U * 2U < CAMERA_FRAME_BYTES && HAL_GetTick() - yield_tick >= 4U)
{
osDelay(1U);
yield_tick = HAL_GetTick();
}
}
三个细节值得注意:
- 分块大小 800×16×2 字节:不是一次整帧复制,避免长时间连续占用 SDRAM 总线,挤占 DCMI、DMA2D 和 LTDC 的访问。
- 每块先失效 D-Cache 再读:显示帧由 DMA 写入,CPU 的 D-Cache 里可能是旧数据,必须按块失效。
s_copying标志在让出 CPU 期间保持置位:复制一半去睡觉了,显示帧也不能被回收成采集缓冲,否则下半段复制的就是正在被覆盖的数据。复制完成后才清零。
复制目的地是专用快照缓冲 0xD0900000,一块 800×480×2 = 768000 字节的 SDRAM 区域,避开 LCD、相机三帧、JPEG 工作区和 MJPEG 缓冲。这样抓拍帧与推流互不干扰。
抓拍任务调用复制时还要与 MJPEG 编码任务竞争:如果编码任务正持有显示帧,复制会返回失败,抓拍以 5 ms 间隔重试,1 秒内拿不到就放弃本次抓拍(SNAPSHOT_FAIL stage=camera busy_or_timeout);OTA 进行中也直接放弃。
4.3 BMP 文件格式
BMP 是最容易手写的图像格式:一个 54 字节的头,加上按规则排列的像素。头部的填充代码只有十几行:
uint8_t header[54] = {'B', 'M'};
/* 省略文件对象、路径等局部变量声明 */
put_u32(header + 2, 54U + sizeof(s_row) * SNAPSHOT_HEIGHT); // 文件总大小
put_u32(header + 10, 54U); // 像素数据偏移
put_u32(header + 14, 40U); // 信息头大小
put_u32(header + 18, SNAPSHOT_WIDTH); // 宽 800
put_u32(header + 22, SNAPSHOT_HEIGHT); // 高 480
header[26] = 1U; // 颜色平面数,固定 1
header[28] = 24U; // 每像素 24 位
put_u32(header + 34, sizeof(s_row) * SNAPSHOT_HEIGHT); // 像素数据大小
逐字段对应 BMP 规范:
| 偏移 | 字段 | 本工程的值 | 说明 |
|---|---|---|---|
| 0 | bfType | BM |
文件标识 |
| 2 | bfSize | 1152054 | 54 + 800×480×3 |
| 10 | bfOffBits | 54 | 像素数据从第 54 字节开始 |
| 14 | biSize | 40 | BITMAPINFOHEADER 长度 |
| 18 | biWidth | 800 | 图像宽度 |
| 22 | biHeight | 480 | 正值表示从底行开始存储 |
| 26 | biPlanes | 1 | 固定 1 |
| 28 | biBitCount | 24 | 每像素 3 字节 |
| 34 | biSizeImage | 1152000 | 像素数据字节数 |
两个排列规则必须遵守:
- 行序倒放:biHeight 为正时,文件里第一行像素是图像的最后一行,所以写入循环从
y = 480往y = 1走。 - 行四字节对齐:BMP 要求每行字节数补齐到 4 的倍数。本工程 800×3 = 2400 字节,恰好整除,天然满足;若宽度是别的值就要在行尾补零。
选 24 位而不是 16 位:BMP 也支持 RGB565 的 16 位格式,但需要填掩码字段且兼容性差。24 位 BGR888 任何看图软件都能直接打开。
4.4 为什么存 BMP 而不用硬件 JPEG
项目里明明有硬件 JPEG 编码器(MJPEG 推流一直在用),抓拍为什么不用它、反而用 CPU 转格式存一张 1.1 MB 的 BMP?这是设计权衡,不是疏忽。
用硬件 JPEG 的代价是两条业务耦合到同一台编码器上:抓拍要走推流的老路——取帧持有、RGB565→YCbCr420 颜色转换、JPEG+MDMA 压缩——每一步都要和 MJPEG 编码任务抢外设。抓拍任务优先级更低,推流忙时抓拍只能等;更麻烦的是错误处理,编码器状态被两个业务共享,一处异常两处遭殃。
存 BMP 则完全解耦:抓拍只需要读显示帧、做 5/6 位到 8 位的位扩展、调 FatFs 写文件,全程不碰 JPEG 外设。代价是文件大(1152054 字节)和写卡时间长(约 3 秒,见 9.2),但对"按一下存一张"的交互来说,3 秒完全可以接受。
一句话概括:推流要的是持续的高吞吐,抓拍要的是偶发的简单可靠,两者的需求不同,选了不同的工具。
4.5 逐行转换与写入
像素转换逐行进行,每行先把 800 个 RGB565 像素扩展成 BGR888 存进行缓冲,再写文件:
static uint8_t s_row[SNAPSHOT_WIDTH * 3U]; // 单行 BGR888 数据,800 像素恰好满足 BMP 四字节对齐
const uint16_t *source = SNAPSHOT_FRAME + (y - 1U) * SNAPSHOT_WIDTH;
for (uint32_t x = 0U; x < SNAPSHOT_WIDTH; x++)
{
uint16_t pixel = source[x];
uint8_t b = pixel & 31U, g = (pixel >> 5) & 63U, r = pixel >> 11;
s_row[x * 3U] = (b << 3) | (b >> 2); // 5 位 → 8 位
s_row[x * 3U + 1U] = (g << 2) | (g >> 4); // 6 位 → 8 位
s_row[x * 3U + 2U] = (r << 3) | (r >> 2); // 5 位 → 8 位
}
result = write_exact(&file, s_row, sizeof(s_row));
osDelay(1U); // 每行写完后阻塞让出 CPU,保持预览任务可运行
位扩展 (b << 3) | (b >> 2) 把 5 位最大值 31 映射到 255,比单纯左移三位的 248 更接近满量程。注意 BMP 的字节序是 B、G、R,与 RGB565 的位段顺序相反。
写入用 write_exact() 包了一层:FatFs 的 f_write 可能返回成功但写入字节数不足,这种情况按磁盘错误处理:
static FRESULT write_exact(FIL *file, const void *buffer, UINT size)
{
UINT written;
FRESULT result = f_write(file, buffer, size, &written);
return result == FR_OK && written != size ? FR_DISK_ERR : result;
}
文件写完的收尾顺序是 f_sync → f_close,然后看结果决定是否清理半成品:
- 成功:打日志
SNAPSHOT_PASS path=0:/CAM0007.BMP size=1152054 800x480 ms=3319。 - 失败:只有在文件已干净关闭时才
f_unlink删掉不完整的照片;关闭本身失败的文件不删,进入锁存故障(见 5.4)。
每一行之间 osDelay(1U) 让出 CPU,写卡的几秒里 LCD 预览和网页推流不受影响——这是抓拍任务"低优先级、多让出"策略的最后一环。
4.6 文件命名与防覆盖
文件名从 CAM0001.BMP 到 CAM9999.BMP 递增,用两道保险防止覆盖旧照片:
for (uint32_t index = 1U; index <= 9999U; index++)
{
snprintf(path, sizeof(path), "0:/CAM%04lu.BMP", (unsigned long)index);
result = f_stat(path, &info);
if (result == FR_NO_FILE) break; // 找到第一个不存在的编号
if (result != FR_OK) goto failed; // 真正的文件系统错误
}
if (result != FR_NO_FILE) { result = FR_EXIST; goto failed; } // 编号全部用尽
result = f_open(&file, path, FA_WRITE | FA_CREATE_NEW); // 第二道保险:已存在则失败
第一道保险是 f_stat 找空闲编号;第二道是 FA_CREATE_NEW——即使两道检查之间有别的设备改了卡(比如刚从 USB 模式切回来、电脑写过卡),打开已存在的文件仍会失败而不是截断它。9999 个编号用尽时以 FR_EXIST 失败报告,不折返覆盖。
5. 存储所有权:一张卡不能同时有两个主人
5.1 准入与互斥
app_storage.c 用几个标志位实现所有权:s_saving(保存中)、s_toggle(切换请求)、s_ota(OTA 占用)加上模式本身。四个接口的准入条件如下:
| 接口 | 接受条件 | 谁调用 |
|---|---|---|
app_storage_snapshot_begin() |
本地模式,且无保存、无切换请求、无 OTA | 抓拍任务 |
app_storage_toggle() |
无 OTA、无未处理切换、不已在切换中 | 按键任务 |
app_storage_ota_lock() |
本地模式,且无保存、无切换请求、无 OTA | OTA 服务 |
app_storage_service() |
有切换请求,且无保存、无 OTA | 抓拍任务 |
所有标志的读取和置位都在临界区内完成,例如抓拍准入:
int app_storage_snapshot_begin(void)
{
int accepted;
taskENTER_CRITICAL();
accepted = s_mode == STORAGE_LOCAL && !s_saving && !s_toggle && !s_ota;
if (accepted) s_saving = 1U;
taskEXIT_CRITICAL();
return accepted;
}
注意 app_storage_toggle() 只提交请求,真正的切换动作发生在抓拍任务的 app_storage_service() 里。这样设计的原因:切换要卸载和重新挂载文件系统,这些操作必须与文件操作串行,而抓拍任务正是唯一碰 FatFs 的任务。
5.2 本地 → USB:先卸载再开放
进入 USB 模式的路径:
if (previous == STORAGE_LOCAL)
{
/* 先卸载本地文件系统,再允许 USB 主机访问原始扇区。 */
FRESULT result = f_mount(NULL, USERPath, 0U);
if (result == FR_OK && app_usb_export_set(1) == 0)
{
s_mode = STORAGE_USB;
LOG_INFO("STORAGE", "USB_EXPORT_READY readonly=0");
return 1;
}
LOG_ERROR("STORAGE", "USB_EXPORT_START_FAIL unmount=%d", result);
}
顺序是关键:先 f_mount(NULL) 卸载 FatFs(此时起任何文件操作都会被拒绝),确认成功后才启动 USB 设备。如果 USB 启动失败,流程落到下面的"返回本地"分支,尝试把 FatFs 挂回来——失败不会让卡卡在"两个系统都没挂"以外的中间态,只会进故障模式或恢复本地。
5.3 USB → 本地:先确认停再重链
返回本地的路径要求更苛刻:
/* 返回本地模式前必须确认 USB 已停止访问,失败时不重新挂载 FatFs。 */
if (app_usb_export_set(0) != 0)
{
s_mode = STORAGE_FAULT;
LOG_ERROR("STORAGE", "USB_EXPORT_STOP_FAIL ownership=usb");
return 1;
}
if (!s_clean)
{
s_mode = STORAGE_FAULT;
LOG_ERROR("STORAGE", "STORAGE_RECOVERY_FAIL unclosed_file reset_required");
return 1;
}
/* Cube 的 disk_initialize 缓存初始化标记;重新链接后才能从 I/O 故障恢复。 */
FRESULT result = f_mount(NULL, USERPath, 0U);
if (result == FR_OK)
{
result = FATFS_UnLinkDriver(USERPath) == 0U ? FR_OK : FR_INVALID_DRIVE;
if (result == FR_OK)
{
MX_FATFS_Init();
result = retUSER == 0U ? f_mount(&USERFatFS, USERPath, 1U) : FR_INVALID_DRIVE;
}
}
s_mode = result == FR_OK ? STORAGE_LOCAL : STORAGE_FAULT;
为什么返回本地不是简单 f_mount 一次了事?因为 USB 主机用卡期间什么都可能发生:卡可能被主机重新格式化过,也可能在第 3.3 节的 I/O 故障后处于复位状态。Cube 的 ff_gen_drv 会缓存"驱动已初始化"的标记,单纯重新 f_mount 不一定触发 disk_initialize 重新走一遍卡识别。所以完整流程是:先卸载,再 FATFS_UnLinkDriver 注销驱动,再 MX_FATFS_Init 重新链接,最后 f_mount(1) 强制立即挂载——每一步都推倒重来。
app_usb_export_set(0) 返回失败时所有权保留在 USB、直接进故障模式,绝不带着"USB 可能还在写"的疑问去挂 FatFs——这正是第 8 章故障表里 USB_EXPORT_STOP_FAIL 一行的语义。
5.4 关闭失败为什么锁存故障
抓拍保存结束时,f_close 的返回值决定一个全局标志:
FRESULT closed = f_close(&file);
if (closed != FR_OK)
{
s_file_clean = 0U;
LOG_ERROR("SNAPSHOT", "SNAPSHOT_CLOSE_FAIL FR=%d", closed);
if (result == FR_OK) { stage = "close"; result = closed; }
}
s_file_clean 一旦清零就不再恢复,app_storage_snapshot_end(0) 随即把模式切到 STORAGE_FAULT。为什么这么绝情?
f_close 失败意味着文件系统内部状态已经不可信:目录项可能没更新,FAT 表可能写了一半,FatFs 对这张卡的所有后续操作都建立在损坏的元数据上。此时如果继续切换到 USB 让电脑挂载,电脑的文件系统驱动会基于同样损坏的结构工作,可能进一步放大损坏。锁存故障、要求复位,是把决定权交还给人:检查串口日志,必要时用电脑修复卡。
锁存原则:文件状态不明时,宁可整个存储功能停摆,也不带着疑问交接所有权。
6. USB MSC 导出:把 SD 卡变成 U 盘
6.1 USB OTG_FS 外设配置
引脚与时钟
USB 全速设备只需要两个信号引脚:
| 引脚 | 功能 |
|---|---|
| PA11 | USB_OTG_FS_DM |
| PA12 | USB_OTG_FS_DP |
48 MHz 时钟不占 PLL:H757 内置 48 MHz 的 HSI48 振荡器,配合 CRS(时钟恢复系统)用 USB 总线上的 SOF(Start Of Frame)信号自动校准精度。CubeMX 里把 USB 时钟源选 HSI48、打开 CRS 即可,生成的配置在 main.c:


HSI48 是 RC 振荡器,精度不满足 USB ±0.25% 的要求,必须靠 CRS 拿主机 SOF 做基准动态微调——这是"USB 能枚举但传输不稳定"这类问题的常见根源,配置时不要漏。
参数配置
CubeMX 中 USB_OTG_FS 的配置:

| 配置项 | 取值 |
|---|---|
| 模式 | Device_Only |
| VBUS sensing | 关闭 |
| SOF | 关闭 |
| 低功耗 / LPM / 电池充电 | 关闭 |
Middleware 勾选 USB_DEVICE,Class 选 Mass Storage Class (MSC)。注意 USB_DEVICE 中间件本身没有更多参数可配——CubeMX 里它的全部工作就是选定 Class(对应 ioc 里的 CLASS_NAME_FS=MSC),MSC 类没有额外的配置页;端点 FIFO 划分等细节由 usbd_conf.c 的生成代码固定(USBD_LL_Init 里 RxFIFO 0x80、EP0 Tx 0x40、EP1 Tx 0x80,单位为字)。与 SD/FatFs 一样,USB 的自动初始化也关闭:MX_USB_Device_Init() 不在 main() 里调用,USB 设备由 USB 任务按需启动(见 6.3)。


OTG_FS 中断在 NVIC 里使能,抢占优先级 5(与 FreeRTOS 管理的中断兼容),并且 MspInit 末尾默认先关掉它:
HAL_NVIC_SetPriority(OTG_FS_IRQn, 5, 0);
HAL_NVIC_EnableIRQ(OTG_FS_IRQn);
/* USER CODE BEGIN USB_OTG_FS_MspInit 1 */
/* 由 USB 任务在 PCD/BOT 一轮处理完毕后打开 IRQ。 */
HAL_NVIC_DisableIRQ(OTG_FS_IRQn);
/* USER CODE END USB_OTG_FS_MspInit 1 */
中断的默认状态是"关",由 USB 任务在合适的时机开关——这正是下一节的主题。
6.2 MSC、BOT 与 SCSI:U 盘的协议栈
电脑插上 U 盘后发生的事,可以分四层理解。
枚举层。USB 设备插入后主机先读描述符:设备描述符(VID/PID,本板是 ST 官方默认的 0x0483/0x572A)、配置描述符、接口描述符。MSC 的接口描述符里写死三个类代码:类 0x08(Mass Storage)、子类 0x06(SCSI Transparent 命令集)、协议 0x50(BOT)。接口还声明两个 BULK 端点,一进一出。
传输层(BOT)。Bulk-Only Transport 规定主机与设备的每次交互都是三段式:
主机 → 设备:CBW(31 字节,含一条 SCSI 命令和传输长度)
设备 → 主机:Data(读盘是设备发,写盘是主机发)
设备 → 主机:CSW(13 字节,报告成功或失败)
命令层(SCSI)。CBW 里装的是 SCSI 命令。U 盘场景用到的命令不多,ST 的 MSC 中间件处理了这些:
| 命令 | 作用 |
|---|---|
INQUIRY |
报告设备身份(厂商、产品名,就来自 usbd_storage_if.c 里的 STORAGE_Inquirydata_FS) |
TEST_UNIT_READY |
介质是否就绪 |
REQUEST_SENSE |
查询上一条命令的失败原因 |
READ_CAPACITY10/16 |
报告容量(多少扇区、每扇区多少字节) |
READ10 / READ12 |
读若干扇区 |
WRITE10 / WRITE12 |
写若干扇区 |
MODE_SENSE6/10 |
介质参数(含写保护状态) |
数据层。READ10/WRITE10 的载荷就是 512 字节的原始扇区。
把这四层叠起来,板端在 USB 模式下的角色一句话就能说清:它是一块只会按扇区读写的"硬盘",文件系统(FAT 表、目录项)完全由电脑解析。板子不知道电脑在卡上建了什么文件,也不需要知道——它只对 READ10(扇区号) 回数据、对 WRITE10(扇区号) 落数据。反过来,抓拍模式下是板端的 FatFs 在解析文件系统。同一张卡,两套文件系统解析者,这正是需要所有权交接的原因。
6.3 一个任务串行做所有事
USB 栈的常规用法是:中断里调 HAL_PCD_IRQHandler(),它层层回调到 MSC 类,MSC 再调存储回调读写介质。这套流程默认介质访问很快,而且不在中断里做重活。
SD 卡不满足这个默认前提:一次同步读扇区要毫秒级,写扇区更慢。如果在 USB 中断里直接读卡,中断会被阻塞几毫秒,所有更低优先级中断全部遭殃。
本工程的做法是把 USB 的所有工作搬进一个任务,中断只做"记录 + 唤醒":
void app_usb_export_irq(void)
{
HAL_NVIC_DisableIRQ(OTG_FS_IRQn); // 先把中断关掉
if (s_thread != NULL) (void)osThreadFlagsSet(s_thread, 1U); // 唤醒任务
__DSB(); __ISB();
}
中断入口 OTG_FS_IRQHandler 调完它就直接返回,不去碰 HAL_PCD_IRQHandler:
void OTG_FS_IRQHandler(void)
{
/* USER CODE BEGIN OTG_FS_IRQn 0 */
app_usb_export_irq();
return; /* 不再执行默认的 HAL_PCD_IRQHandler */
/* USER CODE END OTG_FS_IRQn 0 */
HAL_PCD_IRQHandler(&hpcd_USB_OTG_FS);
}
USB 任务被唤醒后,一轮做三件事:
for (;;)
{
(void)osThreadFlagsWait(1U, osFlagsWaitAny, 10U); // 等 IRQ 唤醒,最多等 10 ms
HAL_NVIC_DisableIRQ(OTG_FS_IRQn); // 本轮独占,防止新中断插入
if (s_done != s_request) { /* 启停请求的处理见 6.5 */ }
if (s_active && s_wanted)
{
HAL_PCD_IRQHandler(&hpcd_USB_OTG_FS); // 在任务里处理 USB 事件
HAL_NVIC_EnableIRQ(OTG_FS_IRQn); // 处理完一轮,重新放开中断
}
}
HAL_PCD_IRQHandler() 会一路走到 MSC 的 BOT 状态机,最终调用存储回调(6.4)同步读写 SD 卡——这些原本"该在中断里"的事,现在全部发生在任务上下文:可以阻塞等卡、可以 osDelay、栈也宽裕(1536 字)。中断被屏蔽期间 USB 事件积压在硬件里,任务处理完这一轮再开中断,来一轮处理一轮,天然串行。
串行原则:PCD/BOT 处理、启停设备、扇区读写、断开清理,全部在同一个任务里按顺序执行,不存在两个上下文同时碰 USB 栈或 SD 卡的可能。
6.4 存储接口挂接
CubeMX 生成的 usbd_storage_if.c 是 MSC 与介质之间的桥。工程把它的回调全部转发到 app_usb_export_*,以读为例:
int8_t STORAGE_Read_FS(uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len)
{
return lun == 0U && app_usb_export_read(buf, blk_addr, blk_len) == 0 ? USBD_OK : USBD_FAIL;
}
app_usb_export_read/write 不是简单转发,而是带着一套防御检查:
int app_usb_export_read(uint8_t *buffer, uint32_t sector, uint16_t count)
{
uint32_t capacity = hsd2.SdCard.LogBlockNbr;
if (__get_IPSR() != 0U || !app_usb_export_ready() || count == 0U ||
count > MSC_MEDIA_PACKET / 512U || sector >= capacity || count > capacity - sector)
return -1;
if (app_sd_read(0U, buffer, sector, count) != RES_OK) return -1;
if (!s_wanted || (USB_OTG_FS->GINTSTS & USB_OTG_GINTSTS_USBRST) != 0U) return -1;
return 0;
}
五道检查的含义:
| 检查 | 防的是什么 |
|---|---|
__get_IPSR() != 0 |
不允许在中断上下文调用——这是"任务串行"设计的兜底断言 |
app_usb_export_ready() |
USB 未激活或 SD 未就绪时不碰卡 |
count 与扇区边界 |
越界读写直接拒绝 |
事后 s_wanted 检查 |
读写期间用户已请求停止,按失败处理 |
USBRST 检查 |
主机正在复位总线(可能正在重新枚举),当前结果不可信 |
写路径与读对称,同样走这五道检查后调 app_sd_write。因为写回是真实落卡的:电脑往 U 盘拖文件,最终就是 WRITE10 → app_sd_write → HAL_SD_WriteBlocks 逐扇区写入。
6.5 启停握手与切换时序
USB 设备的启动和停止由 app_usb_export_set() 触发,用"请求序号 + 完成序号"握手:
int app_usb_export_set(int enabled)
{
if (s_thread == NULL || s_request != s_done) return -1;
s_wanted = enabled;
__DMB();
uint32_t request = ++s_request;
(void)osThreadFlagsSet(s_thread, 1U);
uint32_t start = HAL_GetTick();
while (s_done != request)
{
if (HAL_GetTick() - start >= 3000U) // 最多等 3 秒
{
s_wanted = 0;
LOG_ERROR("USB", "USB_TRANSITION_TIMEOUT ownership_retained");
return -1;
}
osDelay(1U);
}
return s_result;
}
调用方(抓拍任务)提交请求后阻塞等待 s_done 追上 s_request,USB 任务完成启停动作后更新 s_done。停止路径里 USB 任务先 HAL_PCD_Stop + USBD_DeInit,还要等在途的扇区写入完成——app_sd_write 本身是同步的,USB 任务串行执行保证了它结束在途写入后才处理退出请求。
3 秒超时的语义值得注意:超时后 s_wanted 强制清零并返回失败,但所有权不回退(日志写明 ownership_retained)。请求方(5.3 的切换流程)收到失败后进故障模式,绝不假设 USB 已停。
启动设备就是标准的 USBD 三步曲:
static int start_device(void)
{
memset(&hUsbDeviceFS, 0, sizeof(hUsbDeviceFS));
if (USBD_Init(&hUsbDeviceFS, &FS_Desc, DEVICE_FS) != USBD_OK) return -1;
if (USBD_RegisterClass(&hUsbDeviceFS, &USBD_MSC) != USBD_OK ||
USBD_MSC_RegisterStorage(&hUsbDeviceFS, &USBD_Storage_Interface_fops_FS) != USBD_OK ||
USBD_Start(&hUsbDeviceFS) != USBD_OK)
{
(void)USBD_DeInit(&hUsbDeviceFS);
return -1;
}
return 0;
}
把长按切换的完整时序画出来,四个参与者的职责一目了然:
7. 与其他业务配合
7.1 OTA 前的存储独占
OTA 擦写内部 Flash 前,必须先让存储业务安静下来。app_storage_ota_lock() 的准入与抓拍相同——本地模式且三标志全空:
int app_storage_ota_lock(void)
{
int accepted;
taskENTER_CRITICAL();
accepted = s_mode == STORAGE_LOCAL && !s_saving && !s_toggle && !s_ota;
if (accepted) s_ota = 1U;
taskEXIT_CRITICAL();
return accepted ? 0 : -1;
}
注意 s_mode == STORAGE_LOCAL 这个条件:USB 导出模式下 OTA 直接被拒绝。原因很直接——OTA 期间设备要复位,而 USB 模式下卡的所有权在电脑手里,电脑可能正在写卡;此时擦写固件后复位,电脑面对的是半途而废的写入。想升级,先长按切回本地。
OTA 与抓拍的互斥是双向的:OTA 占用期间 snapshot_begin 和 toggle 都进不去(5.1 的表格),反过来 OTA 想锁的时候如果正好在保存照片或切换中,也只能等或放弃。
7.2 与 MJPEG 推流共存
抓拍与推流共享三个资源,各有各的处理办法。
相机显示帧:两个任务都要读当前显示帧,抓拍用复制、推流用持有,两种方式都由 ov5640_dcmi.c 的 s_copying / s_read_frame 标志协调,同一时刻只允许一个读取者。抓拍取不到帧时以 5 ms 间隔重试最多 1 秒,让推流优先。
SDRAM:各用各的区域,互不重叠:
| 区域 | 起点 | 用途 |
|---|---|---|
| LCD 显示帧 | 0xD0000000 |
LTDC 帧缓冲(相机三帧之一) |
| 相机采集帧 | 0xD0300000 |
DCMI DMA 写入(相机三帧之二) |
| 相机待显示帧 | 0xD03BB000 |
换帧中转(相机三帧之三) |
| MJPEG YCbCr 工作区 | 0xD0600000 |
颜色转换中间数据 |
| JPEG 输出三缓冲 | 0xD0700000..0xD087FFFF |
推流共享 JPEG |
| 抓拍快照帧 | 0xD0900000 |
独立快照副本(768000 字节) |
CPU:抓拍的优先级(3)低于相机(5)和 MJPEG(4),并且在每个可能耗时的环节主动让出——复制帧每 4 ms 让一次,写卡每行让一次,SD 适配层每扇区让一次。最终效果是:按快门的几秒里,LCD 预览和网页推流基本无感。
8. 故障处理与恢复
存储链路涉及介质、文件系统、USB 三层,每一层的故障策略不同,先总览再逐项展开。
8.1 分层策略
| 层 | 故障 | 处理 |
|---|---|---|
| 介质 | SD I/O 失败 | 复位 SDMMC2、标记未初始化,下次使用时重新初始化 |
| 介质 | 挂载失败 | 不格式化,进故障模式等人工处理 |
| 文件系统 | 写入短少 | 按磁盘错误处理,清理半成品 |
| 文件系统 | f_close 失败 |
锁存故障,禁止一切后续存储操作,等复位 |
| USB | 启动失败 | 尝试恢复本地模式,恢复不了进故障 |
| USB | 停止超时(3 秒) | 所有权保留在 USB,进故障模式 |
| 业务 | OTA/切换/抓拍互相冲突 | 准入检查直接拒绝,打日志 |
8.2 逐项场景
抓拍失败。SNAPSHOT_FAIL 带 stage 字段,按阶段定位:camera(1 秒内取不到帧或 OTA 忙)、filename(枚举编号时文件系统错误)、open(打开失败,含编号用尽 FR_EXIST)、header/pixels(写入失败,含卡满)、sync、close。失败时若文件已干净关闭会删除半成品;删除失败打 SNAPSHOT_CLEANUP_FAIL,卡上会留下一个不完整的 BMP,不影响后续抓拍。
写卡时卡被拔出。HAL_SD_WriteBlocks 或 wait_ready 超时,打 SD_IO_FAIL 并复位 SDMMC2。此后 FatFs 的后续调用得到 RES_NOTRDY,抓拍以磁盘错误失败收尾。
USB 停不下来。USB_TRANSITION_TIMEOUT 表示 3 秒内 USB 任务没有确认停止。此时模式进 FAULT、所有权按"仍在 USB"处理,日志 USB_EXPORT_STOP_FAIL ownership=usb。复位是唯一出路。
切回本地失败。STORAGE_RECOVERY_FAIL unclosed_file 表示之前有文件没关干净;重挂载失败则看 STORAGE_LOCAL FR=... 里的具体错误码。
按键无反应。多数情况不是故障:SNAPSHOT_BUSY mode=... 说明不在本地模式(USB 模式下短按本来就该没反应)或上一张还没存完;BUTTON_LONG accepted=0 说明切换请求被 OTA 或切换中状态拒绝。
8.3 按现象定位
| 现象 | 优先查看 | 恢复判断与操作 |
|---|---|---|
| 短按没存照片 | SNAPSHOT_BUSY、SNAPSHOT_FAIL stage |
USB 模式属正常;看 stage 定位失败环节;OTA 期间等升级结束 |
| 照片文件打不开 | SNAPSHOT_FAIL、SNAPSHOT_CLEANUP_FAIL |
半成品被删或清理失败,卡上残留文件可手动删;反复出现则查卡 |
| 长按没出现 U 盘 | BUTTON_LONG、STORAGE_SWITCH_START、USB_EXPORT_START_FAIL |
accepted=0 是 OTA/切换中;卸载失败看 FR 码;可再长按重试 |
| U 盘出现但打不开 | 电脑磁盘管理器、SD_INIT |
卡格式电脑不识别或卡坏;本地模式抓拍正常而 U 盘打不开多半是文件系统问题 |
| U 盘突然消失 | USB_TRANSITION_TIMEOUT、USB_EXPORT_STOP_FAIL |
停止超时进故障,复位恢复;拔线后再切回需确认日志 |
| 切回本地后不能抓拍 | STORAGE_LOCAL FR、STORAGE_RECOVERY_FAIL |
FR=0 仍不能抓拍查按键日志;RECOVERY_FAIL 只能复位 |
| OTA 请求被拒 | BUTTON_LONG、OTA 日志 |
USB 模式先切回本地;保存/切换进行中稍等重试 |
上电即 STORAGE_INIT FR!=0 |
SD_INIT status、卡是否插好 |
不格式化;换卡或电脑上修复后复位 |
9. 验证与效果
9.1 操作步骤与实机效果
验证按"抓拍 → 导出 → 写回 → 回归"的闭环进行:
| 步骤 | 操作 | 预期 |
|---|---|---|
| 1 | 等系统启动完成 | STORAGE_INIT FR=0 mode=0,本地模式 |
| 2 | 短按一次 KEY0 | SNAPSHOT_PASS path=0:/CAM0001.BMP ...,LCD 预览不受影响 |
| 3 | 再短按一次 | CAM0002.BMP,编号递增 |
| 4 | 按住两秒 | BUTTON_LONG accepted=1 → STORAGE_SWITCH_START previous=0 → USB_EXPORT_READY |
| 5 | 电脑打开"此电脑" | 出现可移动磁盘,容量等于 SD 卡容量 |
| 6 | 复制 BMP 到电脑 | 1152054 字节,看图软件直接打开为 800×480 |
| 7 | 从电脑复制一个文件进 U 盘 | 写回成功(写路径真实落卡) |
| 8 | 再按住两秒 | previous=2 → SD_INIT status=0 → STORAGE_LOCAL FR=0 mode=0 |
| 9 | 再短按一次 | SNAPSHOT_PASS 新编号,且电脑写回的文件仍在卡上 |
这里展示了正确操作下可能遇到情况:
正常抓拍 -> 按下后快速再按一次 -> 长按打开 U 盘 -> 期间再抓拍 -> 退出 U 盘
[I][SNAPSHOT] SNAPSHOT_START
[I][SNAPSHOT] SNAPSHOT_PASS path=0:/CAM0001.BMP size=1152054 800x480 ms=3321
[I][SNAPSHOT] SNAPSHOT_START
[I][SNAPSHOT] SNAPSHOT_BUSY mode=0
[I][SNAPSHOT] SNAPSHOT_PASS path=0:/CAM0002.BMP size=1152054 800x480 ms=3310
[I][STORAGE] BUTTON_LONG accepted=1
[I][STORAGE] STORAGE_SWITCH_START previous=0
[I][STORAGE] USB_EXPORT_READY readonly=0
[I][SNAPSHOT] SNAPSHOT_BUSY mode=2
[I][SNAPSHOT] SNAPSHOT_BUSY mode=2
[I][SNAPSHOT] SNAPSHOT_BUSY mode=2
[I][STORAGE] BUTTON_LONG accepted=1
[I][STORAGE] STORAGE_SWITCH_START previous=2
[I][STORAGE] SD_INIT status=0 sectors=983040 HAL=0x0
[I][STORAGE] STORAGE_LOCAL FR=0 mode=0
文件列表:

电脑看图软件打开的 800×480 BMP 图片:

9.2 性能观察
抓拍耗时直接看 SNAPSHOT_PASS 日志的 ms 字段。一次完整抓拍包含四个阶段:竞争并复制显示帧、枚举文件名、逐行转换写入、sync + close。
| 指标 | 数值 | 口径与来源 |
|---|---|---|
| 抓拍全程耗时 | 约 3.3 s | SNAPSHOT_PASS ms 字段;此前等价实现实测 CAM0013/0014/0015 分别为 3319 / 3259 / 3289 ms |
| 单张文件大小 | 1152054 字节 | 54 头 + 800×480×3,恒定 |
| 写卡净数据量 | 1125 KiB | 每张 1152000 字节像素数据 |
| 复制显示帧耗时 | 暂无独立日志 | 可从全程耗时中粗略估算,其余时间为转换与写卡 |
| USB 顺序读速率 | 待实测 | 电脑侧复制大文件计时,或对比拷贝耗时 |
| USB 顺序写速率 | 待实测 | 电脑侧写入大文件计时 |
本次演示了 10 次正常抓拍:
[I][SNAPSHOT] SNAPSHOT_START
[I][SNAPSHOT] SNAPSHOT_PASS path=0:/CAM0001.BMP size=1152054 800x480 ms=3318
[I][SNAPSHOT] SNAPSHOT_START
[I][SNAPSHOT] SNAPSHOT_PASS path=0:/CAM0002.BMP size=1152054 800x480 ms=3314
[I][SNAPSHOT] SNAPSHOT_START
[I][SNAPSHOT] SNAPSHOT_PASS path=0:/CAM0003.BMP size=1152054 800x480 ms=3339
[I][SNAPSHOT] SNAPSHOT_START
[I][SNAPSHOT] SNAPSHOT_PASS path=0:/CAM0004.BMP size=1152054 800x480 ms=3297
[I][SNAPSHOT] SNAPSHOT_START
[I][SNAPSHOT] SNAPSHOT_PASS path=0:/CAM0005.BMP size=1152054 800x480 ms=3260
[I][SNAPSHOT] SNAPSHOT_START
[I][SNAPSHOT] SNAPSHOT_PASS path=0:/CAM0006.BMP size=1152054 800x480 ms=3328
[I][SNAPSHOT] SNAPSHOT_START
[I][SNAPSHOT] SNAPSHOT_PASS path=0:/CAM0007.BMP size=1152054 800x480 ms=3284
[I][SNAPSHOT] SNAPSHOT_START
[I][SNAPSHOT] SNAPSHOT_PASS path=0:/CAM0008.BMP size=1152054 800x480 ms=3340
[I][SNAPSHOT] SNAPSHOT_START
[I][SNAPSHOT] SNAPSHOT_PASS path=0:/CAM0009.BMP size=1152054 800x480 ms=3285
[I][SNAPSHOT] SNAPSHOT_START
[I][SNAPSHOT] SNAPSHOT_PASS path=0:/CAM0010.BMP size=1152054 800x480 ms=3254
9.3 写回再生验证
USB 模式是读写的,电脑可以往卡里写任何文件。这带来一个必须验证的问题:电脑写过的卡,切回本地后 FatFs 还能正常工作吗?写回的文件和板端新照片会互相破坏吗?
这是"两个文件系统解析者先后接手同一张卡"的完整闭环,比单纯的往返切换多了一层写回:
- 本地模式抓拍两张,确认编号连续。
- 长按切 USB,电脑识别 U 盘。
- 从 U 盘复制照片到电脑,校验大小(可选:SHA-256)。
- 电脑向 U 盘写入新文件(例如一个文本文件)。
- 长按切回本地,确认
STORAGE_LOCAL FR=0。 - 短按抓拍,确认新照片正常保存且编号延续。
- 再次切到 USB,确认电脑写回的文件仍然完好。
其中步骤 4、7 的主机写回路径是当前读写版本的关键差异点;纯切换往返(不含写回)已在此前固件上完成过实测闭环。验证时应同步采集串口日志,重点看 STORAGE_SWITCH_START、SD_INIT、STORAGE_LOCAL、SNAPSHOT_PASS 四条是否按序出现。
整个流程的串口打印信息:
[I][STORAGE] BUTTON_LONG accepted=1
[I][STORAGE] STORAGE_SWITCH_START previous=0
[I][STORAGE] USB_EXPORT_READY readonly=0
[I][STORAGE] BUTTON_LONG accepted=1
[I][STORAGE] STORAGE_SWITCH_START previous=2
[I][STORAGE] SD_INIT status=0 sectors=983040 HAL=0x0
[I][STORAGE] STORAGE_LOCAL FR=0 mode=0
[I][SNAPSHOT] SNAPSHOT_START
[I][SNAPSHOT] SNAPSHOT_PASS path=0:/CAM0003.BMP size=1152054 800x480 ms=3345
[I][STORAGE] BUTTON_LONG accepted=1
[I][STORAGE] STORAGE_SWITCH_START previous=0
[I][STORAGE] USB_EXPORT_READY readonly=0
在原有 2 张 BMP 的基础上新增一个混淆名称的 txt 文件:

退出 U 盘后重新抓拍,再打开 U 盘查看情况:

博客导航
本文来自博客园,作者:膝盖中箭卫兵,转载请注明原文链接:https://www.cnblogs.com/hai-feng-dev/p/23449159

浙公网安备 33010602011771号
https://orcid.org/0000-0001-5102-772X