【以太网摄像头采集存储终端】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),它承担两种操作,触发后走完全不同的路径:

flowchart TB subgraph BOARD["STM32H757 · CM7"] direction TB KEY["KEY0 按键 PA8<br/>输入下拉 · 高电平按下"] BTN["按键任务<br/>30 ms 消抖 · 长短按识别"] subgraph SNAP["抓拍任务 · 存储管理"] direction TB ADMIT["模式准入<br/>仅本地模式接受"] COPY["复制显示帧<br/>到独立快照缓冲"] BMP["RGB565 → BGR888<br/>组装 BMP 逐行写入"] FF["FatFs<br/>f_open / f_write / f_close"] ADMIT --> COPY --> BMP --> FF end SD["SD 卡<br/>SDMMC2 · 4 bit · 25 MHz"] subgraph USBSIDE["USB 任务 · MSC"] direction TB ENUM["USB 设备启动<br/>枚举为 U 盘"] SECTOR["READ10 / WRITE10<br/>扇区透传"] ENUM --> SECTOR end OWNER["存储所有权管理<br/>本地 / 切换中 / USB / 故障"] KEY --> BTN BTN -->|"松开(短按)"| ADMIT BTN -->|"按住 2 s(长按)"| OWNER FF --> SD SECTOR --> SD OWNER -.->|"卸载 / 重挂 FatFs"| FF OWNER -.->|"启停 USB 设备"| USBSIDE end PC["电脑 / USB 主机"] classDef key fill:#EAF3FF,stroke:#3973AC,color:#16324F classDef snap fill:#FFF3DE,stroke:#BC8526,color:#573B12 classDef storage fill:#EDE9FA,stroke:#8064AD,color:#382653 classDef usb fill:#E5F4EB,stroke:#43865D,color:#20432E classDef ext fill:#F2F4F7,stroke:#788596,color:#293544 class KEY,BTN key class ADMIT,COPY,BMP,FF snap class SD,OWNER storage class ENUM,SECTOR usb class PC ext

短按路径自上而下:图像从显示帧复制出来,转成 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 故障 无人访问,等待复位
stateDiagram-v2 [*] --> LOCAL : f_mount 成功 [*] --> FAULT : 挂载失败(不格式化) LOCAL --> SWITCHING : 长按,service 接受 USB --> SWITCHING : 长按,service 接受 SWITCHING --> USB : FatFs 卸载成功且 USB 设备启动 SWITCHING --> LOCAL : USB 停止确认且 FatFs 重新挂载成功 SWITCHING --> FAULT : 切换任一步失败 LOCAL --> FAULT : 文件关闭失败(锁存) note right of FAULT 故障是吸收态 只有复位才能离开 end note

切换不是瞬时完成的:卸载文件系统、停止 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 阅读线索

阅读后文时可以围绕三个问题展开:

  1. 一帧图像怎么安全地变成卡上的 BMP 文件?
  2. 一张卡怎么在 FatFs 和 USB 主机两个主人之间交接?
  3. 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 里看到生成结果):

image

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 只改了两项,其余默认:

image

参数 取值 说明
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),由用户填五个磁盘接口函数。

image
image

关键配置项:

配置项 取值 说明
勾选 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 → 通用驱动层 → 用户实现"三层:

image

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:

imageimage

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

参数配置

CubeMX 中 USB_OTG_FS 的配置:

image

配置项 取值
模式 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)。

image
image

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;
}

把长按切换的完整时序画出来,四个参与者的职责一目了然:

sequenceDiagram autonumber participant K as 按键任务 participant S as 抓拍任务(storage service) participant U as USB 任务 participant H as USB 主机(电脑) K->>S: 长按 2 秒,app_storage_toggle() 提交请求 S->>S: 等待无保存、无 OTA,模式 LOCAL→SWITCHING S->>S: f_mount(NULL) 卸载 FatFs S->>U: app_usb_export_set(1) U->>U: USBD_Init + 注册 MSC + USBD_Start U->>H: 连接上拉 D+,主机检测到插入 H->>U: 枚举,INQUIRY / READ_CAPACITY10 loop USB 导出期间 H->>U: READ10 / WRITE10(BOT 三段式) U->>U: 同步读写 SD 扇区,CSW 应答 end K->>S: 再次长按,提交切换请求 S->>U: app_usb_export_set(0) U->>U: 处理完在途扇区,PCD_Stop + USBD_DeInit U-->>S: s_done 确认停止完成 S->>S: 注销重链驱动,f_mount 重新挂载 S-->>S: 模式 LOCAL,恢复抓拍

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

文件列表:
image

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

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 还能正常工作吗?写回的文件和板端新照片会互相破坏吗?

这是"两个文件系统解析者先后接手同一张卡"的完整闭环,比单纯的往返切换多了一层写回:

  1. 本地模式抓拍两张,确认编号连续。
  2. 长按切 USB,电脑识别 U 盘。
  3. 从 U 盘复制照片到电脑,校验大小(可选:SHA-256)。
  4. 电脑向 U 盘写入新文件(例如一个文本文件)。
  5. 长按切回本地,确认 STORAGE_LOCAL FR=0。
  6. 短按抓拍,确认新照片正常保存且编号延续。
  7. 再次切到 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 文件:
image

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

博客导航

博客导航

posted @ 2026-10-27 08:32  膝盖中箭卫兵  阅读(8)  评论(0)    收藏  举报
ORCID iD icon https://orcid.org/0000-0001-5102-772X