STM32F4-OV7670-QRCODE解码-USBHID

初始化大纲

复制代码
上电
 ↓
初始化 HAL
 ↓
配置系统时钟
 ↓
初始化调试串口
 ↓
初始化 USB
 ↓
初始化 OV7670、DCMI、DMA
 ↓
初始化 quirc
 ↓
启动第一帧采集
 ↓
进入 while(1)
函数 当前阶段只需要知道
HAL_Init() 初始化 HAL、SysTick 和中断优先级
SystemClock_Config() 将 STM32F407 配置为 168 MHz
DEBUG_Init() 初始化 USART2 调试串口
USB_HID_Init() 让 USB 准备枚举成键盘
CAMERA_Init() 初始化摄像头、DCMI 和 DMA
QR_Init() 创建并初始化 quirc 解码器
CAMERA_Start() 启动一次 DMA 摄像头采集

while(1)循环

c 复制代码
while (1)
{
    DEBUG_Process();
    USB_HID_Process();
    CAMERA_Process();

    if (CAMERA_IsFrameReady())
    {
        QR_PrepareFrame(CAMERA_GetFrame());

        CAMERA_ReleaseFrame();
        CAMERA_Start();

        QR_DecodePreparedFrame();
    }
} 

可以把一次循环理解成:

复制代码
处理串口
  ↓
推进USB协议
  ↓
检查摄像头
  ↓
有没有完整图像?
  ├─ 没有:立即进入下一轮
  └─ 有:
       复制并预处理灰度
          ↓
       释放DMA帧缓冲区
          ↓
       启动下一帧采集
          ↓
       解码上一帧二维码

数据流

复制代码
OV7670
  ↓
DCMI接收像素
  ↓
DMA写入frame_words
  ↓
CAMERA_IsFrameReady()返回真
  ↓
CAMERA_GetFrame()取得YUYV图像
  ↓
QR_PrepareFrame()提取Y亮度
  ↓
重新启动下一帧DMA
  ↓
QR_DecodePreparedFrame()调用quirc
  ↓
识别成功
  ↓
串口打印 + USB HID发送

先解码当前帧再采集下帧

原因是 quirc 解码可能需要几十毫秒。如果解码结束后才启动摄像头,这几十毫秒内摄像头采集链路一直闲置。

复制代码
摄像头:  [采集帧1] [采集帧2] [采集帧3]
CPU:         [解码帧1] [解码帧2]
c 复制代码
QR_PrepareFrame(CAMERA_GetFrame());

CAMERA_ReleaseFrame();
CAMERA_Start();

QR_DecodePreparedFrame();

DMA谁写给谁

DMA 把 DCMI 接收到的摄像头数据,写入 STM32 内部 SRAM 的 frame_words 数组。

复制代码
OV7670
  │ 输出 D0~D7
  ▼
STM32 DCMI 数据寄存器 DCMI->DR
  │ DMA自动搬运
  ▼
STM32 SRAM中的 frame_words[]

数据提供者:DCMI->DR

数据搬运者:DMA2 Stream7

数据接收者:SRAM 中的 frame_words[]

为什么用使用DMA

c 复制代码
frame[i] = DCMI->DR;

这样 CPU 就得一直等待和搬运像素,几乎没有时间处理 USB 和二维码。

使用 DMA 后:

复制代码
CPU:启动DMA后去处理USB和串口
DMA:后台持续搬运摄像头数据

DMA 完成整帧搬运后,通过中断通知 CPU:

复制代码
"38400字节已经全部写完了。"

然后程序把 frame_ready 设置为 1。

帧缓冲区(大小类型设置)

代码位于 camera_capture.c (line 18)(C:/Users/Lenovo/Desktop/stm32f407_0v7670_QRcode/User/camera/camera_capture.c:18):

c 复制代码
static uint32_t frame_words[CAMERA_FRAME_BYTES / 4U];

已知:

c 复制代码
/*
CAMERA_WIDTH  160  图像宽度:160 像素
CAMERA_HEIGHT  120  图像高度:120 像素
CAMERA_BYTES_PER_PIXEL  2  每个像素占 2 字节(16 位)
*/
#define CAMERA_WIDTH           160U
#define CAMERA_HEIGHT          120U
#define CAMERA_BYTES_PER_PIXEL 2U

因此:

复制代码
CAMERA_FRAME_BYTES
= 160 × 120 × 2
= 38400 字节

数组元素是 uint32_t,每个元素占4字节:

复制代码
38400 ÷ 4 = 9600个uint32_t

所以等价于:

c 复制代码
static uint32_t frame_words[9600];

实际占用:

复制代码
9600 × 4 = 38400字节

为什么不用 uint8_t frame[38400]

使用 uint32_t 是为了满足当前 DMA 的32位对齐方式:

c 复制代码
hdma_dcmi.Init.PeriphDataAlignment = DMA_PDATAALIGN_WORD;/* 外设端数据宽度:32位字 */
hdma_dcmi.Init.MemDataAlignment = DMA_MDATAALIGN_WORD;/* 内存端数据宽度:32位字 */

DMA 每次从 DCMI->DR 读取一个32位数据,然后写入 frame_words 一个元素。

为什么有些变量需要 volatile

c 复制代码
static volatile uint8_t frame_ready;
static volatile uint8_t capturing;
static volatile uint8_t frame_event_seen;

volatile 告诉编译器:

这些变量可能在当前代码看不到的地方发生变化,每次使用时都要真正访问内存,不要只相信寄存器中的旧值。

原因是它们可能同时被:

  • 主循环访问;
  • DCMI中断回调访问;
  • DMA相关流程访问。

CAMERA_Start()函数解析

注释已经写的很详细了,具体就是使用DMA去搬运DCMI的一帧图片,并且更新状态

c 复制代码
/** @brief 清状态并启动一次固定长度的 DCMI 快照 DMA。 */
HAL_StatusTypeDef CAMERA_Start(void)
{
    /* DMA 以 32 位宽读取 DCMI->DR,长度参数单位也是 word。 */
    /*上一帧还没结束*/
    if (capturing)  
        return HAL_BUSY;
    /*
    清除旧状态
    上一帧不再处于就绪状态
    新一帧还没有出现帧结束事件
    */
    frame_ready = 0;
    frame_event_seen = 0U;
    /*标记正在采集*/
    capturing = 1;
    /*记录开始时间*/
    capture_tick = HAL_GetTick();
    /*清除旧 DMA 错误*/
    hdma_dcmi.ErrorCode = HAL_DMA_ERROR_NONE;
    /*更新调试状态*/
    DEBUG_CaptureStarted();
    /*
    启动 DCMI 和 DMA
    DCMI_MODE_SNAPSHOT:快照模式 --- 只捕获 一帧 的数据到 DMA,然后停止(适合拍照)
    当前 DMA 的单位是32位 word,所以不能传38400,而应传:
    38400 ÷ 4 = 9600
    */
    if (HAL_DCMI_Start_DMA(&hdcmi, DCMI_MODE_SNAPSHOT, (uint32_t)frame_words, CAMERA_FRAME_BYTES / 4U) != HAL_OK)
    {
        capturing = 0;
        return HAL_ERROR;
    }
    return HAL_OK;
}

启动后的硬件过程

HAL_DCMI_Start_DMA() 返回以后,CPU不负责逐个像素搬运。

硬件自动运行:

复制代码
OV7670输出VSYNC
       ↓
表示新的一帧开始
       ↓
HREF进入有效状态
       ↓
OV7670随PCLK输出D0~D7
       ↓
DCMI在配置的PCLK边沿采样
       ↓
DCMI把连续4个字节放入DCMI->DR
       ↓
DMA读取DCMI->DR
       ↓
DMA写入frame_words[]
       ↓
DMA目标地址自动增加
       ↓
重复9600次
       ↓
NDTR变成0
       ↓
一帧搬运完成

CPU这段时间继续执行:

复制代码
DEBUG_Process();
USB_HID_Process();
CAMERA_Process();

这正是DMA的意义:让专用硬件搬运大量数据,CPU可以处理其他任务。

CAMERA_Process()函数解析

任务1:判断DMA是否已经搬完一帧

任务2:判断摄像头采集是否超时

代码中有详细注释过程

c 复制代码
/**
 * @brief DCMI DMA 帧采集状态机处理函数
 * 
 * 本函数由主循环周期调用,用于轮询 DMA 传输状态和超时状态。
 * 采用简单状态机设计,只有两种处理分支:
 * 
 * 【分支1】帧完成检测 (成功路径)
 *   - 条件:capturing==1 AND NDTR==0 AND 无DMA错误
 *   - 含义:DMA 已完全搬运一整帧数据到 SRAM
 *   - 动作:
 *     1. 停止 DCMI 和 DMA
 *     2. 清空错误标志(准备下一帧)
 *     3. 设置 frame_ready=1 向主程序发信号
 *     4. 调用 DEBUG_FrameComplete() 增加成功帧计数
 *   - 返回:立即返回,主程序可调用 CAMERA_GetFrame() 读取帧数据
 * 
 * 【分支2】超时处理 (故障恢复路径)
 *   - 条件:capturing==1 AND 距离 CAMERA_Start() 已超过 CAMERA_TIMEOUT_MS
 *   - 含义:采集阻塞、摄像头无信号、极性错误等硬件故障
 *   - 动作:
 *     1. 快照保存 6 个诊断寄存器(SR、RISR、NDTR、CR、FCR、ErrorCode)
 *     2. 调用 DEBUG_CaptureTimeout() 记录同步脚状态和 DMA 状态
 *     3. 打印 ERROR 日志显示完整的寄存器现场(供调试分析)
 *     4. 自动调用 CAMERA_Start() 重启采集(单次失败自恢复)
 * 
 * @note
 *   - 【调用要求】必须在主循环中高频调用(建议 10ms 内一次以上)
 *   - 【非中断】这是轮询函数,不是中断处理,允许长执行时间
 *   - 【可靠性】不依赖 DCMI FRAME 中断(某些 OV7670 模块 VSYNC 不稳定)
 *   - 【幂等性】重复调用相同状态时无副作用
 * 
 * @see CAMERA_Start(), CAMERA_IsFrameReady(), CAMERA_GetFrame()
 */
void CAMERA_Process(void)
{
    /*
     * With a fixed-size non-JPEG frame, DMA transfer-complete is sufficient to
     * publish the frame. Some OV7670 modules produce a VSYNC pulse that the
     * F407 DCMI does not latch reliably, although all expected pixels arrived.
     */
    /*
        capturing:标记 DCMI DMA 是否正在采集
        NDTR 是 DMA 的数据数量寄存器:
        HAL_DMA_ERROR_TE :Transfer Error,传输错误。
        HAL_DMA_ERROR_FE :FIFO Error,FIFO错误(当前工程未启用该功能)
        HAL_DMA_ERROR_DME:Direct Mode Error,直接模式错误。
    */
    if (capturing && (DMA2_Stream7->NDTR == 0U) &&
        ((hdma_dcmi.ErrorCode & (HAL_DMA_ERROR_TE | HAL_DMA_ERROR_FE |
                                 HAL_DMA_ERROR_DME)) == 0U))
    {
        // 一帧采集完成
        (void)HAL_DCMI_Stop(&hdcmi);
        /* HAL_DMA_Abort reports NO_XFER after an already completed transfer. */
        hdma_dcmi.ErrorCode = HAL_DMA_ERROR_NONE;
        hdcmi.ErrorCode = HAL_DCMI_ERROR_NONE;
        capturing = 0U;        // 采集结束
        frame_ready = 1U;      // 标记主流程可读
        DEBUG_FrameComplete(); // 标记帧完成并增加成功帧计数
        return;
    }

    if (capturing && (uint32_t)(HAL_GetTick() - capture_tick) >= CAMERA_TIMEOUT_MS) // 超时1秒
    {
        uint32_t sr = DCMI->SR;                   // 读取 DCMI 状态寄存器,包含 VSYNC、HSYNC、FIFO 等标志
        uint32_t risr = DCMI->RISR;               // 读取 DCMI 原始中断状态寄存器,包含 VSYNC、HSYNC、FIFO 等标志
        uint32_t ndtr = DMA2_Stream7->NDTR;       // 读取 DMA 剩余传输数据数量寄存器,单位是 word
        uint32_t dma_cr = DMA2_Stream7->CR;       // 读取 DMA 控制寄存器,包含启用、方向、优先级等配置
        uint32_t dma_fcr = DMA2_Stream7->FCR;     // 读取 DMA FIFO 控制寄存器,包含 FIFO 模式、阈值、突发等配置
        uint32_t dma_error = hdma_dcmi.ErrorCode; // 读取 DMA 错误码,包含传输错误、FIFO 错误、直接模式错误等标志
        (void)HAL_DCMI_Stop(&hdcmi);
        capturing = 0;
        DEBUG_CaptureTimeout((sr & DCMI_SR_VSYNC) ? 1U : 0U, (sr & DCMI_SR_HSYNC) ? 1U : 0U, (uint32_t)hdma_dcmi.State, dma_error); // 记录同步脚、DMA 状态和错误码组成的超时现场
        LOG_E("[CAM] RISR=0x%08lX NDTR=%lu/%lu DMA_CR=0x%08lX FCR=0x%08lX",
              (unsigned long)risr, (unsigned long)ndtr,
              (unsigned long)(CAMERA_FRAME_BYTES / 4U),
              (unsigned long)dma_cr, (unsigned long)dma_fcr); // 打印 DMA 寄存器现场
        LOG_I("[CAM] Restart capture");
        (void)CAMERA_Start(); // 超时后自动重启采集
    }
}

CAMERA_Init()函数解析

相机DCMI外设初始化及DMA初始化绑定DCMI

复制代码
xclk_init()
    ↓
pins_init()
    ↓
初始化DMA
    ↓
初始化DCMI
    ↓
OV7670_Init()
    ↓
信号探测
    ↓
DCMI极性扫描

关系如下:

复制代码
hdcmi
  │
  └── DMA_Handle ──► hdma_dcmi

hdma_dcmi
  │
  └── Parent ──────► hdcmi
cpp 复制代码
/** @brief 建立完整采集链路,任一关键外设失败都返回 HAL_ERROR。 */
HAL_StatusTypeDef CAMERA_Init(void)
{
    /* 初始化顺序:时钟/引脚 -> DMA -> DCMI -> 摄像头寄存器 -> 自检。 */
    xclk_init();
    pins_init();
    __HAL_RCC_DMA2_CLK_ENABLE();
    __HAL_RCC_DCMI_CLK_ENABLE();
    /* F407 的 DCMI DMA 请求映射到 DMA2 Stream7 Channel1。 */
    hdma_dcmi.Instance = DMA2_Stream7;           /* 使用 DMA2 的 Stream7 通道传输 DCMI 数据 */
    hdma_dcmi.Init.Channel = DMA_CHANNEL_1;      /* 连接到 DCMI 的请求通道 1 */
    hdma_dcmi.Init.Direction = DMA_PERIPH_TO_MEMORY;  /* 方向:从外设(DCMI->DR)读到内存(SRAM) */
    hdma_dcmi.Init.PeriphInc = DMA_PINC_DISABLE; /* 外设地址不递增,DCMI->DR 固定不变 */
    hdma_dcmi.Init.MemInc = DMA_MINC_ENABLE;     /* 内存地址递增,每次传输后指针移动一个字(4字节) */
    hdma_dcmi.Init.PeriphDataAlignment = DMA_PDATAALIGN_WORD;  /* 外设端数据宽度:32位字 */
    hdma_dcmi.Init.MemDataAlignment = DMA_MDATAALIGN_WORD;     /* 内存端数据宽度:32位字 */
    hdma_dcmi.Init.Mode = DMA_NORMAL;            /* 传输模式:NORMAL(一次性),不循环 */
    hdma_dcmi.Init.Priority = DMA_PRIORITY_VERY_HIGH;  /* 优先级:最高,确保实时性 */
    /* 保守的单次传输模式避免 DCMI 数据流的 FIFO 错误 */
    hdma_dcmi.Init.FIFOMode = DMA_FIFOMODE_DISABLE;   /* 禁用 FIFO,直接阈值模式 */
    hdma_dcmi.Init.FIFOThreshold = DMA_FIFO_THRESHOLD_1QUARTERFULL;  /* FIFO 阈值:1/4 满 */
    hdma_dcmi.Init.MemBurst = DMA_MBURST_SINGLE;      /* 内存端突发大小:单次传输(无突发) */
    hdma_dcmi.Init.PeriphBurst = DMA_PBURST_SINGLE;   /* 外设端突发大小:单次传输(无突发) */
    if (HAL_DMA_Init(&hdma_dcmi) != HAL_OK)
        return HAL_ERROR;
    /* 使用外部同步、8位并口、非JPEG连续像素格式。 */
    hdcmi.Instance = DCMI;
    /* DCMI 同步模式选择:硬件同步(PCLK/HREF/VSYNC 独立管脚信号) vs 嵌入式同步(数据流中带同步码) */
    hdcmi.Init.SynchroMode = DCMI_SYNCHRO_HARDWARE;
    /* OV7670 outputs pixel data valid on the PCLK falling edge; sampling on the
     * rising edge captured all-zero frames. */
    /* 像素时钟极性:PCLK 下降沿时数据有效(OV7670 默认值,若全零帧改成 RISING) */
    hdcmi.Init.PCKPolarity = DCMI_PCKPOLARITY_FALLING;
    /* 垂直同步极性:VSYNC 高电平时为帧有效期(OV7670 COM11 的默认配置) */
    hdcmi.Init.VSPolarity = DCMI_VSPOLARITY_HIGH;
    /* OV7670 COM10=0x00: HREF is high while pixel data is valid. */
    /* 水平同步极性:HREF 高电平时为行有效期,此时可采样像素数据 */
    hdcmi.Init.HSPolarity = DCMI_HSPOLARITY_HIGH;
    /* 采集速率:ALL_FRAME=捕获全部帧,ALTERNATE_2_FRAME=每2帧1帧,ALTERNATE_4_FRAME=每4帧1帧 */
    hdcmi.Init.CaptureRate = DCMI_CR_ALL_FRAME;
    /* 数据宽度模式:8B=8位(OV7670输出),10B/12B/14B=高位扩展(当前用8位) */
    hdcmi.Init.ExtendedDataMode = DCMI_EXTEND_DATA_8B;
    /* JPEG 编码模式:DISABLE=连续像素格式(用于 QR 检测),ENABLE=JPEG 流格式 */
    hdcmi.Init.JPEGMode = DCMI_JPEG_DISABLE;
    /* DCMI 与 DMA 的双向绑定,建立完整的采集链路关联 */
    hdcmi.DMA_Handle = &hdma_dcmi; // ① DCMI 持有 DMA 句柄的指针,初始化时告诉 DCMI 用哪个 DMA
    hdma_dcmi.Parent = &hdcmi;  // ② DMA 持有 DCMI 句柄的指针,中断时能找回 DCMI 上下文
    if (HAL_DCMI_Init(&hdcmi) != HAL_OK)
        return HAL_ERROR;
    HAL_NVIC_SetPriority(DMA2_Stream7_IRQn, 4, 0);
    HAL_NVIC_EnableIRQ(DMA2_Stream7_IRQn);
    HAL_NVIC_SetPriority(DCMI_IRQn, 4, 0);
    HAL_NVIC_EnableIRQ(DCMI_IRQn);
    /* 先完成 DCMI 配置,再启动 OV7670 输出并执行信号检查。 */
    if (OV7670_Init() != HAL_OK)
        return HAL_ERROR;
    signal_probe(); // 采集前快速检查 PCLK/HREF/VSYNC/D0~D7 是否活动
    dcmi_register_probe();  // 自动探测 PCLK/HREF/VSYNC 极性,避免全零帧
    LOG_I("[SYS] DMA Init OK");
    LOG_I("[SYS] DCMI Init OK");
    return HAL_OK;
}

OV7670摄像头数据传输

同步信号

信号 作用
VSYNC 标记一帧图像的边界
HREF 标记一行中的有效像素区域
PCLK 告诉MCU什么时候采样D0~D7

D0 D1 D2 D3 D4 D5 D6 D7 每个有效PCLK周期携带一个8位数据。

时序

复制代码
VSYNC:____|‾‾|________________________________|‾‾|____
               一帧图像数据

HREF :________|‾‾‾‾‾有效行‾‾‾‾‾|____|‾‾下一行‾‾|___

PCLK :_________↑_↓_↑_↓_↑_↓_↑_↓____________________

DATA :________Y0__U0__Y1__V0__Y2___________________

YUV422

YUV422 = 每个像素都有自己的亮度 Y,但每两个相邻像素共享一组颜色 U、V。

复制代码
Y = 亮度
U = 蓝色色度信息
V = 红色色度信息

YUV422认为:两个挨得很近的像素,亮度可能差别很大,但颜色信息可以共享。

于是把:

复制代码
像素0:Y0 U0 V0
像素1:Y1 U1 V1

简化成:

复制代码
像素0:Y0 ─┐
           ├── 共享 U0 V0
像素1:Y1 ─┘

所以两个像素只需要:

复制代码
Y0  U0  Y1  V0

4个字节。

采集链完整过程

复制代码
OV7670获得16MHz XCLK
        ↓
OV7670按照寄存器配置输出YUYV
        ↓
VSYNC标记帧边界
        ↓
HREF标记有效行
        ↓
PCLK指定每个字节的时间
        ↓
DCMI在下降沿采样D0~D7
        ↓
DCMI把4个字节组合进32位DR
        ↓
DCMI向DMA2 Stream7发出请求
        ↓
DMA读取固定的DCMI->DR
        ↓
DMA依次写入frame_words[]
        ↓
NDTR从9600减到0
        ↓
CAMERA_Process()发布完整帧

OV7670 XCLK、RESET、PWDN 与 SCCB通信

XCLK

OV7670自身需要外部主时钟,模块引脚名称为XCLK

典型工作频率为 10MHz ~ 48MHz (常见为 12MHz24MHz)。

没有XCLK时,摄像头内部的:

  • 寄存器逻辑;
  • 像素阵列扫描;
  • PCLK产生电路;
  • 图像处理电路;
  • SCCB访问逻辑;

可能无法正常工作。

因此必须先产生XCLK,再读取摄像头ID。

STM32F407的PA8可以复用成MCO1给OV7670输出时钟COPY芯片内部HSI的16MHz时钟源

STM32-MCO(Microcontroller Cloc...

c 复制代码
/** @brief 通过 PA8/MCO1 输出 16MHz HSI 作为摄像头 XCLK。 */
static void xclk_init(void)
{
    /* MCO1 输出 HSI 16 MHz,作为 OV7670 的外部主时钟 XCLK。 */
    GPIO_InitTypeDef g = {0};
    __HAL_RCC_GPIOA_CLK_ENABLE();
    g.Pin = CAM_XCLK_PIN;
    g.Mode = GPIO_MODE_AF_PP;
    g.Pull = GPIO_NOPULL;
    g.Speed = GPIO_SPEED_FREQ_VERY_HIGH;
    g.Alternate = GPIO_AF0_MCO;
    HAL_GPIO_Init(GPIOA, &g);
    HAL_RCC_MCOConfig(RCC_MCO1, RCC_MCO1SOURCE_HSI, RCC_MCODIV_1);
    LOG_I("[CAM] XCLK 16MHz");
}

RESET和PWDN

OV7670控制引脚:

引脚 有效电平 功能
RESET 低有效 硬件复位摄像头
PWDN 高有效 让摄像头进入掉电状态

正常工作时需要保持RESET是高电平,PWDN是低电平的状态

SCCB

SCCB是OV系列摄像头使用的控制总线,信号包括:SIOC:串行时钟 SIOD:串行数据

它的基本时序与I²C很相似,所以很多工程也会直接使用STM32硬件I²C访问OV7670。

SCCB器件地址:

c 复制代码
#define OV7670_ADDR_W 0x42U
#define OV7670_ADDR_R 0x43U

关于I2C的驱动编写就不再多赘述

读取PID和VER

OV7670初始化时:

c 复制代码
OV7670_ReadReg(0x0A, &pid);
OV7670_ReadReg(0x0B, &ver);

正常结果:

复制代码
0x0A → 0x76
0x0B → 0x73

只有ID正确,才继续写整个图像配置表。表示SCCB通讯正常

可以证明:

  • OV7670得到基本供电;
  • STM32与摄像头共地;
  • XCLK基本有效;
  • RESET和PWDN状态基本正确;
  • SIOC能够输出;
  • SIOD能够双向通信;
  • SCCB地址正确;
  • 芯片身份正确。

OV7670寄存器初始化

寄存器编写一般都是根据摄像头的数据手册进行开发

c 复制代码
typedef struct
{
    uint8_t reg; //OV7670寄存器地址
    uint8_t value; //准备写入的数值
} RegValue;
c 复制代码
/* QQVGA 160x120 YUV422. TSLB/COM13 select Y0 U Y1 V (USB YUY2). */
/*
 * 按数组顺序写寄存器。第一项 COM7=0x80 软复位,最后 0xFF/0xFF
 * 是软件结束标记,不会写入摄像头。表中格式最终为 Y0 U Y1 V。
 */
static const RegValue qqvga_yuyv[] = {
    /* 【核心格式与复位】 */
    {0x12, 0x80},  /* COM7: 软复位(0x80=复位标志位) */
    {0x11, 0x01},  /* CLKRC: 内部分频器,控制像素时钟 (PCLK) 频率 */
    {0x3A, 0x04},  /* TSLB: 行/场信号输出延迟,YUYV 格式控制 */
    {0x12, 0x00},  /* COM7: 清除复位标志,QVGA(320x240) */
    
    /* 【图像大小与窗口裁剪】 QQVGA = 160x120 */
    {0x17, 0x13},  /* HSTART: 水平起始像素 (高字节) */
    {0x18, 0x01},  /* HSTOP: 水平停止像素 (高字节) */
    {0x32, 0xB6},  /* HREF: 水平参考边界控制 */
    {0x19, 0x02},  /* VSTART: 垂直起始行 (高字节) */
    {0x1A, 0x7A},  /* VSTOP: 垂直停止行 (高字节) */
    {0x03, 0x0A},  /* VREF: 垂直参考行的开始/结束 */
    {0x0C, 0x04},  /* COM3: 通用控制3(缩放相关) */
    {0x3E, 0x1A},  /* COM14: 缩放比例和色度信号处理 */
    
    /* 【伽玛曲线】色调/对比度调整 0x7A~0x89 */
    {0x70, 0x3A},  /* SCALING_XSC: 水平缩放系数 */
    {0x71, 0x35},  /* SCALING_YSC: 垂直缩放系数 */
    {0x72, 0x22},  /* SCALING_DCWCTR: 下采样控制 */
    {0x73, 0xF2},  /* SCALING_PCLK_DIV: PCLK 分频 */
    {0xA2, 0x02},  /* AFAFE: 模拟前端增益 */
    {0x15, 0x00},  /* COM10: PCLK 输出、VSYNC 极性等 */
    {0x40, 0xC0},  /* COM15: RGB565 输出格式选择 */
    {0x3D, 0xC0},  /* COM13: UV 序列 (0xC0=YUYV格式) */
    
    /* 【伽玛曲线】亮度响应曲线,共16级:从暗到亮的线性映射 */
    {0x7A, 0x20},  /* Gamma 曲线点 1 */
    {0x7B, 0x10},  /* Gamma 曲线点 2 */
    {0x7C, 0x1E},  /* Gamma 曲线点 3 */
    {0x7D, 0x35},  /* Gamma 曲线点 4 */
    {0x7E, 0x5A},  /* Gamma 曲线点 5 */
    {0x7F, 0x69},  /* Gamma 曲线点 6 */
    {0x80, 0x76},  /* Gamma 曲线点 7 */
    {0x81, 0x80},  /* Gamma 曲线点 8 (中点) */
    {0x82, 0x88},  /* Gamma 曲线点 9 */
    {0x83, 0x8F},  /* Gamma 曲线点 10 */
    {0x84, 0x96},  /* Gamma 曲线点 11 */
    {0x85, 0xA3},  /* Gamma 曲线点 12 */
    {0x86, 0xAF},  /* Gamma 曲线点 13 */
    {0x87, 0xC4},  /* Gamma 曲线点 14 */
    {0x88, 0xD7},  /* Gamma 曲线点 15 */
    {0x89, 0xE8},  /* Gamma 曲线点 16 */
    
    /* 【自动曝光/增益控制】AEC/AGC 使图像自适应亮度 */
    {0x13, 0xE0},  /* COM9: AEC/AGC 启用,快速收敛模式 */
    {0x00, 0x00},  /* GAIN: 自动增益控制寄存器 */
    {0x10, 0x00},  /* AECH: 自动曝光控制 (高字节) */
    {0x0D, 0x40},  /* COM4: 自动曝光参考 */
    {0x14, 0x38},  /* COM9: 自动增益上限和自动曝光偏移 */
    
    /* 【曝光调整】控制光圈和曝光时间 */
    {0x24, 0x95},  /* AEW: 自动曝露窗口:上边界 */
    {0x25, 0x33},  /* AEB: 自动曝露窗口:下边界 */
    {0x26, 0xE3},  /* VPT: 自动曝露点 */
    
    /* 【白平衡调整】AWB 自动校正色温 */
    {0x9F, 0x78},  /* AWBP: AWB 参考点设置 */
    {0xA0, 0x68},  /* AWBB: AWB 蓝色通道增益 */
    {0xA1, 0x03},  /* AWBR: AWB 红色通道增益 */
    {0xA6, 0xD8},  /* WBC1: 白平衡控制 1 */
    {0xA7, 0xD8},  /* WBC2: 白平衡控制 2 */
    {0xA8, 0xF0},  /* WBC3: 白平衡控制 3 */
    {0xA9, 0x90},  /* WBC4: 白平衡控制 4 */
    {0xAA, 0x94},  /* WBC5: 白平衡控制 5 */
    
    /* 【色彩矩阵】将 RGB 色彩空间变换到 YUV */
    {0x43, 0x0A},  /* MATRIX1: 色彩矩阵第 1 行 */
    {0x44, 0xF0},  /* MATRIX2: 色彩矩阵第 2 行 */
    {0x45, 0x34},  /* MATRIX3: 色彩矩阵第 3 行 */
    {0x46, 0x58},  /* MATRIX4: 色彩矩阵第 4 行 */
    {0x47, 0x28},  /* MATRIX5: 色彩矩阵第 5 行 */
    {0x48, 0x3A},  /* MATRIX6: 色彩矩阵第 6 行 */
    
    /* 【降噪和锐度】 */
    {0x59, 0x88},  /* LCCST: 镜头色差补偿控制 1 */
    {0x5A, 0x88},  /* LCCTR: 镜头色差补偿控制 2 */
    {0x5B, 0x44},  /* LCCSR: 镜头色差补偿控制 3 */
    {0x5C, 0x67},  /* LCCST: 镜头色差补偿控制 4 */
    {0x5D, 0x49},  /* LCCST: 镜头色差补偿控制 5 */
    {0x5E, 0x0E},  /* LCCST: 镜头色差补偿控制 6 */
    
    /* 【色度处理】UV 通道增益和饱和度 */
    {0x6C, 0x0A},  /* AWBCTR0: AWB 控制 0 */
    {0x6D, 0x55},  /* AWBCTR1: AWB 控制 1 */
    {0x6E, 0x11},  /* AWBCTR2: AWB 控制 2 */
    {0x6F, 0x9F},  /* AWBCTR3: AWB 控制 3 */
    
    /* 【自动白平衡启用】最终启用 AWB/AEC/AGC */
    {0x6A, 0x40},  /* OBJNC: 对象检测和噪声控制 */
    {0x01, 0x40},  /* BLUE: 蓝色通道增益 */
    {0x02, 0x40},  /* RED: 红色通道增益 */
    {0x13, 0xE7},  /* COM9: AEC/AWB/AGC 全部启用 */
    {0x26, 0xE3},  /* VPT: 重新设置曝露点 */
    {0x6B, 0x4A},  /* DBLC: 动态黑电平补偿 */
    
    /* 【UV 通道增益】U/V 分别增益 */
    {0x4F, 0x80},  /* UYVY: U 通道增益 */
    {0x50, 0x80},  /* VYUY: V 通道增益 */
    {0x51, 0x00},  /* USAT: U 饱和度调整 */
    {0x52, 0x22},  /* USAT: U 饱和度控制 */
    {0x53, 0x5E},  /* USAT: U 对比度 */
    {0x54, 0x80},  /* VSAT: V 饱和度调整 */
    {0x58, 0x9E},  /* VSAT: V 对比度控制 */
    
    /* 【其他控制】 */
    {0x41, 0x08},  /* COM16: 色调和对比度调整 */
    {0x3F, 0x00},  /* EDGE: 边界增强控制 */
    {0x75, 0x05},  /* EDGEU: U 边界增强 */
    {0x76, 0xE1},  /* EDGEV: V 边界增强 */
    {0x4C, 0x00},  /* DENOISE: 降噪控制 */
    {0x77, 0x01},  /* EDGE: 边界增强启用 */
    {0x3B, 0x0A},  /* COM11: 自动曝光的视频模式 */
    {0x4B, 0x09},  /* COM10: HREF 输出控制 */
    {0xC9, 0x60},  /* GAMMA: 伽玛调整控制 */
    {0x55, 0x10},  /* SDE: 特殊数字效应控制 */
    {0x56, 0x40},  /* CONTRAS: 对比度调整 */
    {0x34, 0x11},  /* ARBLM: 自动复位黑电平 */
    
    {0xFF, 0xFF}}; /* 配置表结束标记 (不会写入摄像头) */
c 复制代码
for (i = 0; qqvga_yuyv[i].reg != 0xFFU; i++)
{
    if (OV7670_WriteReg(
            qqvga_yuyv[i].reg,
            qqvga_yuyv[i].value) != HAL_OK)
    {
        return HAL_ERROR;
    }
}

操作顺序

复制代码
软件复位
  ↓
基础时钟
  ↓
输出格式
  ↓
窗口和缩放
  ↓
Gamma
  ↓
曝光、增益、白平衡
  ↓
颜色矩阵
  ↓
其他图像参数

摄像头自检

自检函数signal_probe()

统计摄像头同步信号和数据活跃度,诊断接线完整性

c 复制代码
/** 
 * @brief 在 100ms 窗口内统计摄像头同步信号和数据活跃度,诊断接线完整性
 * 
 * 本函数快速检查 PCLK/HREF/VSYNC 和 D0~D7 数据线是否正常工作:
 *   - PCLK 边沿计数:像素时钟是否有输出
 *   - VSYNC 边沿计数:场同步是否有周期性脉冲
 *   - HREF 采样数:行有效期内是否能读到数据
 *   - DATA_or:所有采样的数据位的逻辑或(非零 = 至少有某位出现过高电平)
 *   - DATA_and:所有采样的数据位的逻辑与(0xFF = 所有位都是高电平,可能数据线卡死)
 * 
 * @warning 此函数在初始化时调用,用于故障诊断。不要在采集过程中调用。
 */
static void signal_probe(void)
{
    /* 初始化阶段用 GPIO 输入状态快速判断时钟、同步及数据线是否活动。 */
    uint32_t start_tick = HAL_GetTick();                                    /* 记录开始时间,用于 100ms 计时 */
    uint32_t pclk_edges = 0U, href_samples = 0U, vsync_edges = 0U;        /* 计数器:PCLK 边沿数、HREF 采样点、VSYNC 边沿数 */
    uint8_t previous_pclk = (GPIOA->IDR & CAM_PCLK_PIN) ? 1U : 0U;        /* 记录前一次 PCLK 电平(初值为当前电平) */
    uint8_t previous_vsync = (GPIOB->IDR & CAM_VSYNC_PIN) ? 1U : 0U;      /* 记录前一次 VSYNC 电平(初值为当前电平) */
    uint8_t data_or = 0U, data_and = 0xFFU;                               /* data_or:数据线 OR 结果(检测哪些位有变化) */
                                                                            /* data_and:数据线 AND 结果(检测是否有位恒定高电平) */

    while ((uint32_t)(HAL_GetTick() - start_tick) < 100U)                /* 持续采样 100ms(覆盖至少一个完整视频帧) */
    {
        /* 【每次循环读取当前 PCLK 和 VSYNC 电平】 */
        uint8_t pclk = (GPIOA->IDR & CAM_PCLK_PIN) ? 1U : 0U;            /* 读取 PCLK 当前电平(0 或 1) */
        uint8_t vsync = (GPIOB->IDR & CAM_VSYNC_PIN) ? 1U : 0U;          /* 读取 VSYNC 当前电平(0 或 1) */
        
        /* 【检测 PCLK 边沿(从 0→1 或 1→0)】 */
        if (pclk != previous_pclk)                                         /* 如果 PCLK 电平发生跳变 */
        {
            pclk_edges++;                                                   /* 计数边沿次数 */
            previous_pclk = pclk;                                          /* 更新前一个 PCLK 电平 */
        }
        
        /* 【检测 VSYNC 边沿(从 0→1 或 1→0)】 */
        if (vsync != previous_vsync)                                       /* 如果 VSYNC 电平发生跳变 */
        {
            vsync_edges++;                                                  /* 计数边沿次数 */
            previous_vsync = vsync;                                         /* 更新前一个 VSYNC 电平 */
        }
        
        /* 【只在 HREF 有效区间内采样数据线】 */
        /* HREF 为高时表示行数据有效;为低时表示水平消隐期,此时数据无意义 */
        if (GPIOA->IDR & CAM_HREF_PIN)                                    /* 如果 HREF 高电平(数据有效) */
        {
            uint8_t data = read_camera_bus();                              /* 读取 D0~D7 八根数据线的当前状态 */
            href_samples++;                                                 /* 统计 HREF 有效区的采样次数 */
            data_or |= data;                                               /* 将此次采样结果与 OR 结果合并(记录见过的高位) */
            data_and &= data;                                              /* 将此次采样结果与 AND 结果合并(只保留从未为 0 的位) */
        }
    }
    
    /* 【输出诊断结果】 */
    LOG_I("[CAM] Probe PCLK_edges=%lu VSYNC_edges=%lu HREF_samples=%lu DATA_or=0x%02X DATA_and=0x%02X",
          (unsigned long)pclk_edges,                                       /* PCLK 边沿数:>10000 正常,<1000 无时钟或接线问题 */
          (unsigned long)vsync_edges,                                      /* VSYNC 边沿数:应为 1~2(一次完整帧周期有 1~2 个边沿) */
          (unsigned long)href_samples,                                     /* HREF 采样数:应 > 1000(100ms 内多行数据) */
          data_or,                                                         /* DATA_or:!= 0 表示数据线有动作;== 0 表示数据线卡死或未接 */
          data_and);                                                       /* DATA_and:== 0xFF 表示所有数据线恒为高(接线反向)*/
}

read_camera_bus()

读取OV7670 D0~D7的8位数据并行

c 复制代码
/** @brief 直接读取八根数据线,返回按 OV7670 D0~D7 排列的字节。 */
static uint8_t read_camera_bus(void)
{
    /* 按 D0..D7 顺序重组分散在 GPIOA/B/C/E 上的数据线。 */
    uint8_t value = 0U;
    if (GPIOC->IDR & GPIO_PIN_6) value |= 0x01U;
    if (GPIOA->IDR & CAM_D1_PIN) value |= 0x02U;
    if (GPIOC->IDR & GPIO_PIN_8) value |= 0x04U;
    if (GPIOC->IDR & GPIO_PIN_9) value |= 0x08U;
    if (GPIOE->IDR & GPIO_PIN_4) value |= 0x10U;
    if (GPIOB->IDR & GPIO_PIN_6) value |= 0x20U;
    if (GPIOE->IDR & GPIO_PIN_5) value |= 0x40U;
    if (GPIOE->IDR & GPIO_PIN_6) value |= 0x80U;
    return value;
}

自检证明

复制代码
PID/VER正常
    → SCCB控制链路基本正常

PCLK、VSYNC、HREF有活动,DATA有变化
    → 摄像头并口确实在输出某些信号

DCMI->DR和帧缓冲区有有效数据
    → STM32采集链路进一步打通

DCMI极性自动探测函数dcmi_register_probe()

遍历 PCLK/HREF/VSYNC 的所有 8 种极性组合,找到能读到非零像素数据的正确极性。并且打印出来

步骤 说明
三层循环 遍历所有 2×2×2=8 种极性组合
复位 DCMI 清除前一个组合的残留状态
启动捕获 以当前极性配置开始采样
采样 60ms 读取 DCMI->DR,计算数据 OR 结果
判断有效 如果 DR_or ≠ 0,说明这个极性组合正确
记录组合 保存第一个有效的极性配置到 DCMI 初始化结构
继续扫描 完成全部 8 种的诊断打印
重新初始化 用有效极性重新初始化 DCMI
c 复制代码
/**
 * @brief DCMI 极性自动探测函数
 * 
 * 遍历 PCLK/HREF/VSYNC 的所有 8 种极性组合,找到能读到非零像素数据的正确极性。
 * 不同厂家的 OV7670 模块同步信号极性可能不一致,此函数自动适配。
 * 
 * 【工作原理】
 *   遍历 2×2×2=8 种极性组合:
 *   - p: PCLK 极性 (0=FALLING / 1=RISING)
 *   - h: HSYNC 极性 (0=LOW / 1=HIGH)
 *   - v: VSYNC 极性 (0=LOW / 1=HIGH)
 *   
 *   对每种组合:
 *   1. 复位 DCMI 清空旧状态
 *   2. 配置当前极性组合并启动 DCMI
 *   3. 持续采样 60ms 读取 DCMI->DR
 *   4. 统计采样数据的 OR 结果(如非零,说明这个极性组合有效)
 *   5. 记录第一个有效组合(found=1),但继续扫描后续组合完成诊断表
 *   6. 打印每个组合的诊断结果便于调试
 * 
 * 【诊断输出】
 *   对每种组合打印:
 *   - P: PCLK 极性 (RISE/FALL)
 *   - H: HSYNC 极性 (HIGH/LOW)
 *   - V: VSYNC 极性 (HIGH/LOW)
 *   - DR_or: DCMI 数据寄存器采样的 OR 结果(非零 = 正确极性)
 *   - samples: 采样次数(通常 > 1000)
 */
static void dcmi_register_probe(void)
{
    uint32_t p, h, v;                   /* 极性循环变量:0=极性0,1=极性1 */
    uint8_t found = 0U;                 /* 标志:是否已找到有效极性组合 */

    LOG_I("[CAM] DCMI polarity scan start");
    
    /* 【三层嵌套循环:遍历所有 8 种极性组合】 */
    for (p = 0U; p < 2U; p++)          /* PCLK 极性:0=FALLING, 1=RISING */
    {
        for (h = 0U; h < 2U; h++)      /* HSYNC 极性:0=LOW, 1=HIGH */
        {
            for (v = 0U; v < 2U; v++)  /* VSYNC 极性:0=LOW, 1=HIGH */
            {
                /* 【当前极性组合的配置寄存器值】 */
                uint32_t start_tick, dr_or = 0U, samples = 0U;
                /* 按极性变量组织 DCMI 控制寄存器的极性位 */
                uint32_t config = (p ? DCMI_CR_PCKPOL : 0U) |   /* 如果 p=1,设置 PCLK 上升沿采样;p=0 则下降沿 */
                                  (h ? DCMI_CR_HSPOL : 0U) |    /* 如果 h=1,设置 HSYNC 高电平有效;h=0 则低电平 */
                                  (v ? DCMI_CR_VSPOL : 0U);     /* 如果 v=1,设置 VSYNC 高电平有效;v=0 则低电平 */
                
                /* 【复位 DCMI 模块,清除前一个极性组合的残留状态】 */
                __HAL_RCC_DCMI_FORCE_RESET();                  /* 强制复位 DCMI */
                __HAL_RCC_DCMI_RELEASE_RESET();                /* 释放复位 */
                
                /* 【配置当前极性并启动 DCMI 捕获】 */
                DCMI->CR = config | DCMI_CR_ENABLE | DCMI_CR_CAPTURE;  /* 设置极性配置 + 启用 + 开始捕获 */
                start_tick = HAL_GetTick();
                
                /* 【持续采样 60ms(覆盖至少一个完整的 QQVGA 视频帧周期)】 */
                while ((uint32_t)(HAL_GetTick() - start_tick) < 60U)
                {
                    dr_or |= DCMI->DR;  /* 不断读取 DCMI 数据寄存器,将结果按位 OR(记录所有见过的高位) */
                    samples++;          /* 计数采样次数 */
                }
                DCMI->CR = 0U;          /* 停止捕获 */
                
                /* 【打印当前极性组合的诊断结果】 */
                LOG_I("[CAM] DCMI P=%s H=%s V=%s DR_or=%08lX samples=%lu",
                      p ? "RISE" : "FALL",          /* PCLK 极性标签 */
                      h ? "HIGH" : "LOW",           /* HSYNC 极性标签 */
                      v ? "HIGH" : "LOW",           /* VSYNC 极性标签 */
                      (unsigned long)dr_or,         /* 数据 OR 结果:非零 = 这个极性组合有效 */
                      (unsigned long)samples);      /* 采样次数(通常 > 1000 次/60ms) */
                
                /* 【记录第一个有效极性组合】 */
                /* 即使找到有效组合也继续扫描,以便打印完整诊断表 */
                if (!found && dr_or != 0U)          /* 如果尚未记录且当前组合有效 */
                {
                    /* 将当前极性配置存入 DCMI 初始化结构体 */
                    hdcmi.Init.PCKPolarity = p ? DCMI_PCKPOLARITY_RISING : DCMI_PCKPOLARITY_FALLING;
                    hdcmi.Init.HSPolarity = h ? DCMI_HSPOLARITY_HIGH : DCMI_HSPOLARITY_LOW;
                    hdcmi.Init.VSPolarity = v ? DCMI_VSPOLARITY_HIGH : DCMI_VSPOLARITY_LOW;
                    found = 1U;                     /* 标记已找到有效极性 */
                }
            }
        }
    }
    
    /* 【扫描完成后,复位 DCMI 并用记录的极性重新初始化】 */
    __HAL_RCC_DCMI_FORCE_RESET();
    __HAL_RCC_DCMI_RELEASE_RESET();
    hdcmi.State = HAL_DCMI_STATE_RESET;             /* 重置 DCMI 状态机为初始状态 */
    (void)HAL_DCMI_Init(&hdcmi);                    /* 用记录的有效极性重新初始化 DCMI */
    
    /* 【打印最终诊断结果】 */
    LOG_I("[CAM] DCMI polarity scan %s", found ? "selected valid mode" : "NO DATA");
                                        /* found=1 说明找到有效极性;found=0 说明 8 种都失败(硬件故障) */
}

YUYV格式

复制代码
Y0 U0 Y1 V0 | Y2 U1 Y3 V1 | ...
  • Y:亮度,决定像素明暗;
  • U/V:色度,决定颜色。

二维码识别主要关心黑白结构,所以只需要亮度:

复制代码
原始帧:160 × 120 × 2 = 38400 字节
灰度图:160 × 120     = 19200 字节

代码用 i * 2U 访问每个偶数字节:

c 复制代码
for (i = 0U; i < CAMERA_WIDTH * CAMERA_HEIGHT; i++)
    histogram[yuy2[i * 2U]]++;

i=0、1、2,读取的原始帧下标分别是 0、2、4,正好是 Y0、Y1、Y2

这一步依赖"摄像头实际输出是 YUYV"。如果实际顺序变成 UYVY,偶数字节就是色度,二维码识别也会受影响。

把 YUYV 图像变成灰度图QR_PrepareFrame()

完整函数代码

cpp 复制代码
/**
 * @brief 按帧率策略选择当前帧,并把它复制到 quirc 工作区。
 *
 * 设计上按 QR_SCAN_EVERY_N_FRAMES 控制扫描频率,以降低 CPU 负担;
 * 当本帧不参与识别时直接返回,避免在每个 DMA 采样都触发解码。
 * 只有成功拿到 quirc 的灰度缓冲区后,才会把原始 YUYV 转成灰度图
 * 并做亮度归一化;随后会由 QR_DecodePreparedFrame() 在下一阶段执行
 * 定位和解码。这样做的好处是:原始摄像头缓冲区可以在 quirc 提取
 * 完成之前被复用,减少额外内存和拷贝成本。
 */
uint8_t QR_PrepareFrame(const uint8_t *yuy2_frame)
{
    /*
    gray:准备保存quirc灰度缓冲区的地址
    width:准备接收灰度图宽度
    height:准备接收灰度图高度
    */
    uint8_t *gray;
    int width, height;

    /* 这帧尚未进入解码流程,先清理准备状态。 */
    frame_prepared = 0U;
    if ((decoder == NULL) || (yuy2_frame == NULL)) return 0U;

    /* 通过 frame_divider 实现节流;N=1 表示每帧都扫描,后续也可调大。 */
    if (++frame_divider < QR_SCAN_EVERY_N_FRAMES) return 0U;
    frame_divider = 0U;

    /* 记录本次识别起始时间,供耗时统计和调试输出。 */
    decode_start_tick = HAL_GetTick();

    /* begin 取得 quirc 灰度缓冲区;此阶段完成后原始 DMA 帧即可复用。 */
    gray = quirc_begin(decoder, &width, &height);
    if (gray == NULL) return 0U;

    /* 把 YUYV 中的 Y 分量取出并做 1%/99% 亮度裁剪增强,
     * 使二维码在暗场或强反光条件下依然可识别。 */
    QR_CopyAndNormalizeLuma(gray, yuy2_frame);

    /* 标记灰度图已准备好,等待后续解码线程/流程继续处理。 */
    frame_prepared = 1U;
    return 1U;
}

回顾主循环逻辑

cpp 复制代码
if (CAMERA_IsFrameReady())
{
    QR_PrepareFrame(CAMERA_GetFrame());  //把摄像头帧中的亮度复制到 quirc 内部。

    CAMERA_ReleaseFrame();  //告诉摄像头模块:原始帧已经用完。
    CAMERA_Start();  //DMA开始采集下一帧,覆盖原来的 frame_words。

    QR_DecodePreparedFrame();   //CPU解码 quirc 内部保存的上一帧灰度图。
}

假设现在 DMA 已经采集完一帧:

frame_words中有38400字节YUYV图像

frame_ready = 1,if成立

取得摄像头帧地址

cpp 复制代码
CAMERA_GetFrame()
函数返回(uint8_t *)frame_words
假设帧缓冲区首地址为:0x20001000
所以CAMERA_GetFrame()返回0x20001000地址
就相当于:QR_PrepareFrame((uint8_t *)0x20001000);

函数参数接住这个地址

cpp 复制代码
uint8_t QR_PrepareFrame(const uint8_t *yuy2_frame)

yuy2_frame = 0x20001000

yuy2_frame 指向完整的YUYV图像:

复制代码
yuy2_frame[0] = Y0
yuy2_frame[1] = U0
yuy2_frame[2] = Y1
yuy2_frame[3] = V0
yuy2_frame[4] = Y2

可以画成:

复制代码
地址          内容
0x20001000    Y0
0x20001001    U0
0x20001002    Y1
0x20001003    V0
0x20001004    Y2
0x20001005    U1
0x20001006    Y3
0x20001007    V1

到这里,二维码模块只是拿到了摄像头帧的地址。

检查两个地址是否有效

cpp 复制代码
if ((decoder == NULL) || (yuy2_frame == NULL))
    return 0U;
复制代码
decoder == NULL  代表 quirc 解码器没有初始化成功。
yuy2_frame == NULL  代表没有收到有效摄像头帧地址。

向 quirc 要一块灰度缓冲区

这是本函数最重要的一行。

cpp 复制代码
gray = quirc_begin(decoder, &width, &height);

之前 QR_Init() 已经执行:

cpp 复制代码
quirc_resize(decoder, 160, 120);

所以 quirc 内部已经准备了一块160×120的灰度内存。

调用 quirc_begin() 后:

复制代码
gray   = quirc内部灰度缓冲区首地址
width  = 160
height = 120

假设 quirc 灰度区从:

复制代码
0x2000A000

开始,那么:

复制代码
gray = 0x2000A000

现在有两个不同的地址:

复制代码
yuy2_frame → 摄像头原始YUYV缓冲区,38400字节
gray       → quirc内部灰度缓冲区,19200字节

画出来:

复制代码
摄像头缓冲区                    quirc缓冲区

yuy2_frame                      gray
    │                              │
    ▼                              ▼
Y0 U0 Y1 V0 Y2 U1 Y3 V1 ...     等待写入的灰度区
38400字节                       19200字节

把YUYV变成灰度图

cpp 复制代码
QR_CopyAndNormalizeLuma(gray, yuy2_frame);

现在把两个地址都传进去:

复制代码
输入地址:yuy2_frame
输出地址:gray

暂时把这个函数理解为:

复制代码
从 yuy2_frame 提取 Y(去除UV,只提取亮度去除颜色)
写到 gray

最简单的逻辑其实就是:

复制代码
gray[0] = yuy2_frame[0];  // Y0
gray[1] = yuy2_frame[2];  // Y1
gray[2] = yuy2_frame[4];  // Y2
gray[3] = yuy2_frame[6];  // Y3

假设原始数据为:

复制代码
yuy2_frame:
50  120  60  130  70  125  80  135
Y0   U0  Y1   V0  Y2   U1  Y3   V1

提取后:

复制代码
gray:
50  60  70  80
Y0  Y1  Y2  Y3

真实函数还会调整明暗对比度,但先不管。当前只记住:

从38400字节YUYV里取出19200个Y,写进quirc自己的灰度缓冲区。

标记准备完成

复制代码
frame_prepared = 1U;

现在说明:

复制代码
quirc内部已经有一张完整灰度图

最后:

复制代码
return 1U;

表示本帧准备成功。

函数走完后的内存状态

执行前:

复制代码
frame_words:完整YUYV图像
quirc gray:旧数据或空数据
frame_prepared:0

执行后:

复制代码
frame_words:原始YUYV图像仍然存在
quirc gray:新的160×120灰度图
frame_prepared:1

所以主循环接下来可以:

复制代码
CAMERA_ReleaseFrame();
CAMERA_Start();

让DMA覆盖 frame_words

二维码解码不会受影响,因为它使用的是 quirc 内部的 gray

因此两边同时工作:

复制代码
DMA:采集下一帧到frame_words
CPU:解码上一帧的gray

QR_CopyAndNormalizeLuma函数解析

c 复制代码
/*
 * 提取每两个字节中的 Y 分量。用 1%/99% 亮度百分位建立 LUT,既提升
 * 暗场对比度,又避免少量异常亮点把整幅图像的动态范围拉坏。
 */
/** @brief 从 YUYV 提取 Y,并用百分位裁剪进行全局对比度归一化。 */
static void QR_CopyAndNormalizeLuma(uint8_t *gray, const uint8_t *yuy2)
{
    uint8_t lut[QR_HISTOGRAM_BINS];
    uint32_t i, sum;
    uint16_t low = 0U, high = 255U;

    /* 第一遍扫描建立 0~255 亮度直方图。 */
    memset(histogram, 0, sizeof(histogram));
    for (i = 0U; i < (CAMERA_WIDTH * CAMERA_HEIGHT); i++)
        histogram[yuy2[i * 2U]]++;

    /* 从暗端累计到 1% 像素,得到抗离群点的黑电平。 */
    sum = 0U;
    for (i = 0U; i < QR_HISTOGRAM_BINS; i++) {
        sum += histogram[i];
        if (sum >= QR_CLIP_PIXELS) { low = (uint16_t)i; break; }
    }
    /* 从亮端累计到 1% 像素,得到抗离群点的白电平。 */
    sum = 0U;
    for (i = QR_HISTOGRAM_BINS; i > 0U; i--) {
        sum += histogram[i - 1U];
        if (sum >= QR_CLIP_PIXELS) { high = (uint16_t)(i - 1U); break; }
    }

    /* 动态范围足够时生成查找表,避免每个像素都执行除法。 */
    if (high > (uint16_t)(low + 24U)) {
        for (i = 0U; i < QR_HISTOGRAM_BINS; i++) {
            if (i <= low) lut[i] = 0U;
            else if (i >= high) lut[i] = 255U;
            else lut[i] = (uint8_t)(((i - low) * 255U) / (high - low));
        }
        for (i = 0U; i < (CAMERA_WIDTH * CAMERA_HEIGHT); i++)
            gray[i] = lut[yuy2[i * 2U]];
    } else {
        /* 近乎纯色的图像不做拉伸,避免把噪声误放大。 */
        for (i = 0U; i < (CAMERA_WIDTH * CAMERA_HEIGHT); i++)
            gray[i] = yuy2[i * 2U];
    }
}

c 复制代码
for (i = 0U; i < (CAMERA_WIDTH * CAMERA_HEIGHT); i++)
        histogram[yuy2[i * 2U]]++;

因为 YUYV 中每个像素平均占两个字节,而 Y 位于偶数下标:

复制代码
下标:  0   1   2   3   4   5   6   7
数据: Y0   U  Y1   V  Y2   U  Y3   V

所以:

复制代码
i=0 → yuy2[0] → Y0
i=1 → yuy2[2] → Y1
i=2 → yuy2[4] → Y2
i=3 → yuy2[6] → Y3

histogram[读到的亮度]++ 则表示"这种亮度又出现了一个像素"。

解码QR_DecodePreparedFrame()

复制代码
YUYV帧
 → 提取并增强Y亮度
 → 写入quirc灰度缓冲区
 → quirc_end()定位
 → quirc_count()取得候选数
 → quirc_extract()提取候选码
 → quirc_decode()得到payload
 → UART记录结果
 → 按当前USB模式发送
c 复制代码
/** @brief 定位所有候选码,尝试正常/反色解码,并执行重复码抑制。 */
void QR_DecodePreparedFrame(void)
{
    uint32_t elapsed;
    int count, index;

    if (!frame_prepared) return;
    frame_prepared = 0U;

    /* quirc_end() 会执行候选区域定位:在灰度图中查找潜在二维码边界和模块网格,
     * 之后才能进一步提取候选码并做纠错。由于本函数只处理一帧图像,
     * 所以调用后就立刻读取定位出的候选数 count。 */
    quirc_end(decoder);

    /* 一个画面可能包含多个候选区域;逐个遍历,直到找到一个可成功解码的结果。 */
    count = quirc_count(decoder);
    for (index = 0; index < count; index++)
    {
        quirc_decode_error_t error;
        /*
        quirc_extract():从定位结果中抽取出一个候选码的网格矩阵;
        quirc_decode():校验、纠错并解析二维码内容,结果写入 data。
        这里 data.payload 的长度为 payload_len,且在成功时表示有效载荷字节数。
        */
        quirc_extract(decoder, index, &code);
        error = quirc_decode(&code, &data);

        /* 部分二维码可能出现镜像/反色问题:如果正向解码失败,则翻转该候选网格,
         * 再试一次,兼容上下翻转、左右翻转或白底黑块的情况。 */
        if (error != QUIRC_SUCCESS)
        {
            quirc_flip(&code);
            error = quirc_decode(&code, &data);
        }

        if (error == QUIRC_SUCCESS)
        {
            /* 对外部载荷做边界截断,始终保留字符串结尾 NUL,避免越界写入。
             * 这里把 payload_len 截断到 last_result[] 的容量,防止长字符串破坏栈或 RAM。 */
            uint32_t length = (uint32_t)data.payload_len;
            uint32_t copy_length = length;
            if (copy_length >= sizeof(last_result))
                copy_length = sizeof(last_result) - 1U;
            miss_streak = 0U;

            /* 相同二维码持续停留时只上报一次,避免 HID/串口重复发送导致重复输入。 */
            if ((strlen(last_result) != copy_length) ||
                (memcmp(last_result, data.payload, copy_length) != 0))
            {
                memcpy(last_result, data.payload, copy_length);
                /* 保证末尾有 '\0',即使 payload_len == sizeof(last_result) 也不会越界。 */
                last_result[copy_length] = '\0';
                elapsed = HAL_GetTick() - decode_start_tick;
                DEBUG_QrResult(last_result, copy_length, elapsed);
#if USB_DEVICE_MODE == USB_MODE_HID
                (void)USB_HID_SendText(last_result);
#elif USB_DEVICE_MODE == USB_MODE_POS
                (void)USB_POS_SendData(last_result, USB_POS_SYM_QR);
#endif
            }
            return;
        }
    }

    elapsed = HAL_GetTick() - decode_start_tick;
    DEBUG_QrNotFound(elapsed);

    /* 当连续几帧都没有识别到二维码时,说明当前二维码已离开画面或视野丢失,
     * 这时重新允许同一内容再次触发,避免"离开后再回来"被静默吞掉。 */
    if (miss_streak < QR_REARM_MISSES) miss_streak++;
    if (miss_streak >= QR_REARM_MISSES) last_result[0] = '\0';
}

USB---HID

USB_HID_Init

取得 USB 设备寄存器地址

cpp 复制代码
USB_OTG_DeviceTypeDef *dev =
    (USB_OTG_DeviceTypeDef *)(USB_OTG_FS_PERIPH_BASE + USB_OTG_DEVICE_BASE);

dev 指向 STM32 USB OTG FS 的设备模式寄存器。后面要用 dev->DCTL 控制"软件断开/连接"。

这里不是申请内存,而是把固定的硬件地址解释成寄存器结构体。

2. 开启外设时钟

cpp 复制代码
__HAL_RCC_GPIOA_CLK_ENABLE();
__HAL_RCC_USB_OTG_FS_CLK_ENABLE();

分别让 GPIOA 和 USB OTG FS 外设工作。USB 协议还需要准确的 48 MHz 时钟,这个时钟之前已由 SystemClock_Config() 的 PLLQ 配出。

3. 把 PA11、PA12 交给 USB 外设

cpp 复制代码
gpio.Pin = GPIO_PIN_11 | GPIO_PIN_12;
gpio.Mode = GPIO_MODE_AF_PP;
gpio.Pull = GPIO_NOPULL;
gpio.Speed = GPIO_SPEED_FREQ_VERY_HIGH;
gpio.Alternate = GPIO_AF10_OTG_FS;
HAL_GPIO_Init(GPIOA, &gpio);

当前代码实际会初始化这两个 IO:

复制代码
PA11 → USB D-
PA12 → USB D+
AF10 → 引脚由 USB 外设控制

这不是普通 GPIO 软件拉高拉低。配置好复用后,USB 硬件负责 D+/D- 的电气信号。你以后想把 IO 初始化集中到自己的板级代码时,这一段可以迁移,但 AF10、对应引脚和时钟不能漏掉。

4. 打开 USB 中断

cpp 复制代码
HAL_NVIC_SetPriority(OTG_FS_IRQn, 6U, 0U);
HAL_NVIC_EnableIRQ(OTG_FS_IRQn);

之后 USB 总线有复位、控制请求、端点传输等事件时,CPU 才能进入 OTG_FS_IRQHandler(),再交给 TinyUSB 处理。

这里的优先级数字 6 低于摄像头 DCMI/DMA 使用的 4;STM32 中数字越小,硬件优先级越高。

5. 初始化 TinyUSB

复制代码
if (tusb_init())

TinyUSB 是项目使用的 USB 设备协议栈。这一步让协议栈准备设备控制逻辑、HID 类和端点等。

它还会使用 usb_descriptors.c (line 32)(C:/Users/Lenovo/Desktop/stm32f407_0v7670_QRcode/User/usb/usb_descriptors.c:32) 中 HID 分支的描述符。描述符相当于设备回答电脑的"身份说明书":

复制代码
我是 USB 设备
我的产品 ID 是 0x4004
我有一个 HID 键盘接口
我通过 IN 端点 0x81 发送键盘报告

TUD_HID_REPORT_DESC_KEYBOARD() 则说明报告里的字节应按键盘按键解释。

6. 软件断开、修改 VBUS 模式,再连接

tusb_init() 成功后:

复制代码
dev->DCTL |= USB_OTG_DCTL_SDIS;

SDIS 置位,设备在软件层面暂时与主机断开。

接下来代码关闭 VBUS 检测,启用 no-VBUS-sense 模式。这是当前 Type-C 扩展板接线的适配设置,不能不加判断地照搬到所有 USB 板子。

然后:

cpp 复制代码
HAL_Delay(20U);
dev->DCTL &= ~USB_OTG_DCTL_SDIS;

等待 20 ms 后清除 SDIS,让电脑看到一次新的设备连接,开始枚举。

最后打印:

复制代码
[USB] HID Keyboard initialized

这句话只表示 STM32 端初始化完成。

7. 什么时候才真正"电脑识别成功"?

电脑询问设备描述符、配置描述符、HID 报告描述符,并完成 USB 配置后,TinyUSB 调用:

复制代码
void tud_mount_cb(void)
{
    configured = 1U;
    DEBUG_UsbConfigured(1U);
}

这时才会看到:

复制代码
[USB] Device Configured

此后 USB_HID_SendText() 才允许把二维码内容入队。

记住这条启动路线就够了:

复制代码
配置USB时钟和PA11/PA12
→ 开USB中断
→ tusb_init()
→ 软件重新连接
→ 电脑读取HID描述符并完成枚举
→ tud_mount_cb()把configured置1
→ 才能发送扫码结果

quirc 给出识别结果

cpp 复制代码
error = quirc_decode(&code, &data);

解码返回了QUIRC_SUCCESS后,我们就可以得到data指针,带回来的数据payload 是内容,payload_len 是内容长度;

假设

复制代码
data.payload     = "UB-QR-23456" 的字节
data.payload_len = 11

复制为项目可用的字符串

cpp 复制代码
uint32_t copy_length = data.payload_len;

if (copy_length >= sizeof(last_result))
    copy_length = sizeof(last_result) - 1U;

memcpy(last_result, data.payload, copy_length);
last_result[copy_length] = '\0';

last_result 容量为 128 字节。对于这个例子,copy_length=11

cpp 复制代码
last_result[0..10] = UB-QR-23456
last_result[11]    = '\0'

最后补 '\0' 后,last_result 才能安全地传给 strlen()LOG_I("%s", ...) 和 HID 文本函数。

USB_HID_SendText(const char *text)

c 复制代码
/**
 * @brief 把待发送文本压入单深度 HID 队列,不会等待 USB 端点空闲。
 *
 * 设计目标:在二维码识别完成后,尽快把结果交给 HID 发送状态机;避免在 USB
 * 主机尚未读取完成时出现阻塞等待,从而影响相机采集和二维码处理流程。
 *
 * 这里使用单深度队列而不是无上限缓冲,是为了保证"当前只有一条待发文本",
 * 即使扫描到新二维码,也不会覆盖还在发送中的旧内容,避免混淆输入顺序。
 *
 * @param text 需要发送的 ASCII/UTF-8 可打印文本,函数内部会保留字符串结尾 NUL。
 * @return 若 USB 已枚举且成功排队返回 1;若未配置或队列已满则返回 0。
 */
uint8_t USB_HID_SendText(const char *text)
{
    /*
     * 单深度队列的语义是:最多只有一条待发送串。这样可保证二维码处理线程
     * 不会因为 USB 端点忙碌而阻塞等待,同时也确保旧值不会在发送过程中被新值
     * 覆盖,尤其适合"扫码一帧对应一次输入"的场景。
     */
    size_t len;

    /* 未枚举时仅记录日志,并让 UART 调试口继续输出原始扫描结果,避免把 HID
     * 发送失败当成二维码识别失败。 */
    if (!configured) { LOG_W("[USB] HID not configured; UART result only"); return 0U; }

    /* 仅允许在队列为空时接收新文本;如果前一条仍在发送中,则直接丢弃本次请求。 */
    if (queue_len != 0U || text == NULL) return 0U;

    /* 串口/二维码结果可能比 HID 缓冲区长;这里按容量截断,确保末尾保留 '\0'。
     * 这样即使扫描到超长字符串,也不会在 queue[] 和 sent_text[] 中越界写入。 */
    len = strlen(text);
    if (len >= HID_TEXT_SIZE) len = HID_TEXT_SIZE - 1U;

    memcpy(queue, text, len);
    queue[len] = '\0';
    memcpy(sent_text, queue, len + 1U);
    queue_len = (uint16_t)len;
    queue_pos = 0U;
    enter_pending = 1U;

    LOG_I("[USB] HID queued: %s", sent_text);
    return 1U;
}

HID 函数先检查能否接收这条文本

cpp 复制代码
if (!configured) return 0U;
if (queue_len != 0U || text == NULL) return 0U;
  • configured=0:电脑尚未完成 USB 枚举,不能发送;
  • queue_len!=0:上一条文本还在发送,这个单深度队列没有空位;
  • text==NULL:没有有效文本地址。

假设 USB 已枚举且队列为空,代码继续:

cpp 复制代码
len = strlen(text);
memcpy(queue, text, len);
queue[len] = '\0';
queue_len = len;
queue_pos = 0U;
enter_pending = 1U;

此时状态是:

复制代码
queue         = "UB-QR-23456"
queue_len     = 11
queue_pos     = 0,下一步从字符U开始
enter_pending = 1,字符发完后还要发送Enter

看到日志:

复制代码
[USB] HID queued: UB-QR-23456

只说明已入队 ,还不是发送完成。真正逐字符发送发生在主循环反复调用的 USB_HID_Process() 中,这就是下一步要学的内容。

还有一个实际代码细节值得记住:当前 QR 模块在调用 HID 前就更新了 last_result,而且没有检查 USB_HID_SendText() 的返回值。因此如果当时 USB 未枚举或队列正忙,这次文本不会入队,但相同二维码仍可能被当作"已经上报过";要等连续两帧漏检、去重状态解除后才会再次尝试。

HID进程USB_HID_Process

c 复制代码
/** @brief 每轮最多推进一个按下或释放报告,整个发送过程不阻塞。 */
void USB_HID_Process(void)
{
    uint8_t keys[6] = {0};
    uint8_t modifier = 0U;
    uint32_t now;
    /* 先处理 TinyUSB 控制请求和端点完成事件,确保 USB 设备状态和报告写入时序正确。 */
    tud_task();
    now = HAL_GetTick();
    if (!configured && (uint32_t)(now - last_diag) >= 5000U)
    {
        last_diag = now;
        LOG_W("[USB] HID waiting, irq=%lu", (unsigned long)irq_count);
    }
    if (!configured || queue_len == 0U || !tud_hid_ready())
        return;
    /* 每个字符都按照"按下 -> 释放"两个 HID 报告完成,避免主机把键状态长按住。 */
    if (key_down)
    {
        /* 上一轮已经按下按键,本轮发送全零报告释放,保证下一个字符不会被吞掉。 */
        if (tud_hid_keyboard_report(0U, 0U, NULL))
            key_down = 0U;
    }
    else if (queue_pos < queue_len)
    {
        /* 从 ASCII 映射表中取出需要的 Modifier 和 HID Usage ID;
         * 例如 'A' 会生成 Shift + 'a',其他普通字符则只发单键。 */
        uint8_t ch = (uint8_t)queue[queue_pos++];
        if (ch < 128U && ascii_map[ch][1] != 0U)
        {
            if (ascii_map[ch][0])
                modifier = KEYBOARD_MODIFIER_LEFTSHIFT;
            keys[0] = ascii_map[ch][1];
            if (tud_hid_keyboard_report(0U, modifier, keys))
                key_down = 1U;
        }
    }
    else if (enter_pending)
    {
        /* 字符串发送完毕后,追加 Enter 键,方便扫码结果直接提交到输入框。 */
        keys[0] = HID_KEY_ENTER;
        if (tud_hid_keyboard_report(0U, 0U, keys))
        {
            key_down = 1U;
            enter_pending = 0U;
        }
    }
    else
    {
        /* Enter 也被释放后,清空队列并登记一次成功发送。 */
        queue_len = 0U;
        queue_pos = 0U;
        DEBUG_UsbSendResult(sent_text, 1U);
    }
}

相关推荐
高升说2 小时前
智能相机的工程解剖:算力进机身之后,系统怎么设计
嵌入式·边缘计算·机器视觉·智能相机
潘潘的嵌入式日记19 小时前
I²C 主机读到的 U16 偶尔跳变?先修复多字节快照发布
嵌入式·并发·数据一致性·i2c·快照
AAA代码批发商1 天前
Days 46 i.MX6ULL 裸机开发|从内核切入外设,汇编点亮 LED(补充)
arm开发·嵌入式·裸机开发
嵌入式阿蔡1 天前
嵌入式安全认证2026:PSA Certified从Arm主导到全球标准
嵌入式
阿源-1 天前
PCI 总线协议解析
嵌入式·x86·固件
2301_815091012 天前
ARM汇编入门笔记(二):启动代码、指令详解与C语言混合编程
嵌入式·arm汇编
DaTou大头2 天前
STM32第三篇
单片机·嵌入式·电路基础
纯音乐sjc2 天前
SPI 通信协议详解:从四线制到 ADXL345
面试·嵌入式
纯音乐sjc2 天前
嵌入式面试复习准备
面试·嵌入式