初始化大纲
上电
↓
初始化 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 (常见为 12MHz 或 24MHz)。
没有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);
}
}