STM32F103+OV7670无FIFO摄像头采集与SCCB时序实战
发布时间:2026/11/5 7:04:27 锦皓数字建站

简介这份资源面向使用 STM32F1 系列驱动 OV7670 摄像头、又不打算外接 FIFO 的嵌入式学习者与开发者解决的是无 FIFO 方案缺少可参考工程、网上难以找到完整源码的问题。作者在自行摸索后整理出可运行的工程并在基础采集之外补充了实用的图像处理函数可实现亮度、饱和度与对比度调节按下 KEY1 即可完成灰度化与二值化转换同时预留了颜色识别接口便于在循迹、色块识别、简单机器视觉等场景中继续扩展。压缩包共 312 个文件约 10.8MB包含 71 个 c 源文件与 63 个 h 头文件、Keil 工程文件uvprojx、uvoptx与编译中间产物以及 hex、axf、map 等固件与链接结果另附若干 png 截图和 txt 说明方便对照调试。资源已有 4561 人学习适合具备一定 C 语言与 STM32 基础、希望快速跑通无 FIFO 摄像头采集与基础图像处理的读者参考。1. 一块 F103 加一个十几块的摄像头这个无 FIFO 工程到底解决了什么很多人第一次把 OV7670 无 FIFO 版本接到 STM32F1 上遇到的第一件事并不是成功读到图像SCCB 能读回 ID、串口也能打印寄存器值但屏幕上始终只有雪花或一条条彩带。原因不复杂无 FIFO 的模块不会帮你缓存数据每个 PCLK 周期它就把 8 位数据推到总线上下一个周期立刻换掉MCU 哪怕慢半个微秒这个像素也永久丢了。这跟你用 SPI 读个温湿度传感器完全是两码事。这个工程就是围绕这个限制搭建的一套完整实现TIM1 输出 8MHz 的 XCLK 给摄像头当主时钟GPIO 模拟 SCCB 把寄存器表刷进去PA0~PA7 一次性读走 8 位数据再用 VSYNC、HREF、PCLK 三个外部中断组成一个三级状态机把字节流拼成帧缓存。它适合已经会配置 F1 的 GPIO、中断和定时器想把摄像头真正接进自己板子上的开发者如果你只是想找个能立刻出图的傻瓜模块带 FIFO 的方案更省事但你会失去对时序的控制权。2. 硬件连线与时钟让 OV7670 先输出一个像素2.1 电源、复位与 PWDN 这三个“不起眼但要命”的脚OV7670 模组本身是 2.8V 核心器件市面上的小板大多板载了一颗 LDO所以你给 3.3V 进去是没问题的但要留意两件事。第一是 PWDN 和 RESET。PWDN 是高电平进入掉电模式很多人按“悬空即默认”的习惯不接结果模块时好时坏本质是引脚浮空被干扰拉高了。稳妥的做法是直接飞线到 GND把它钉死在唤醒状态。RESET 是低电平复位接一个 10k 上拉到 3.3V或者干脆接 MCU 的一个 GPIO 由程序统一拉高这样上电顺序可控。第二是电平匹配。OV7670 的输出高电平按 2.8V 算F1 在 3.3V 供电下 VIH 大约 2.31V理论上能识别但余量不大。实际布线里如果数据线拉得长、旁边又有 PWM 走线采样错位就是这么来的。模组板上一般串了限流电阻问题不大如果是自己画的板建议数据线上各串 33Ω 到 100Ω。提示带 FIFO 的 OV7670 小板和不带 FIFO 的板子外形接近、排针定义不同。带 FIFO 的板你从 D0~D7 读到的是 AL422B 吐出来的缓存数据不是摄像头的实时输出。插错板子以后时序怎么调都不对。2.2 8 位数据口与 VSYNC/HREF/PCLK 的引脚分配引脚分配有一套讲究核心原则是数据口必须在同一个 GPIO 端口上连续排列这样一条GPIO_ReadInputData()就能把 8 位一次取回省掉逐位拼装的几十个时钟周期。中断采样里这点时间很关键慢一拍数据就没了。工程里的分配如下信号引脚方向说明D0~D7PA0~PA7输入一次读回 8 位浮空输入PCLKPB0输入像素时钟EXTI0 下降沿触发HREFPB1输入行有效EXTI1 双边沿VSYNCPB2输入帧同步EXTI2 上升沿SIOCPB10输出SCCB 时钟SIODPB11双向SCCB 数据XCLKPA8复用推挽TIM1_CH1 输出 8MHzPWDNGND—钉死在工作模式RESET3.3V—上拉不复位这里有个绕不开的代价数据口占了 PA0~PA7等于把 EXTI0~EXTI7 这八条外部中断线全占了。而 EXTI 线号是按引脚号映射的PA0 和 PB0 共用 EXTI0同一时刻只能选一个。所以三个同步信号只能全部挪到 B 口用 EXTI0/1/2 分别对应 PB0/PB1/PB2。这个映射关系是在 AFIO 的GPIO_EXTILineConfig()里指定的配错的话中断永远不会进。数据口的初始化没有花头浮空输入即可因为 OV7670 是强推挽输出不需要 MCU 这边提供上拉static void CameraDataPort_Init(void) { GPIO_InitTypeDef gpio; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); gpio.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_6 | GPIO_Pin_7; gpio.GPIO_Mode GPIO_Mode_IN_FLOATING; /* 摄像头侧强驱动浮空即可 */ gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, gpio); }GPIO_Mode_IN_FLOATING是关键参数。如果误配成GPIO_Mode_IPU内部上拉会和摄像头的输出形成分压暗像素数据 0x00会被拉成 0x03 之类的怪值画面表现为整体泛白、暗部细节全丢这种问题查起来非常费时间。2.3 用 TIM1 PWM 给 XCLK 喂一个 8MHz 主时钟OV7670 没有内部振荡器XCLK 不给它它一个像素都不会输出。这个时钟可以直接用定时器的 PWM 通道产生占空比固定 50%频率决定了 PCLK 的上限内部还有分频寄存器可以再降。F103 的 TIM1 挂在 APB2 上72MHz 主频下计数时钟就是 72MHz。取 PSC0、ARR8输出频率为 72MHz / (01) / (81) 8MHzstatic void CameraXclk_Init(void) { GPIO_InitTypeDef gpio; TIM_TimeBaseInitTypeDef tb; TIM_OCInitTypeDef oc; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_TIM1, ENABLE); gpio.GPIO_Pin GPIO_Pin_8; /* TIM1_CH1 */ gpio.GPIO_Mode GPIO_Mode_AF_PP; /* 复用推挽由定时器直接驱动 */ gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, gpio); TIM_TimeBaseStructInit(tb); tb.TIM_Prescaler 0; /* 72MHz 不分频 */ tb.TIM_Period 8; /* 72 / (81) 8MHz */ tb.TIM_ClockDivision TIM_CKD_DIV1; tb.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM1, tb); TIM_OCStructInit(oc); oc.TIM_OCMode TIM_OCMode_PWM1; oc.TIM_OutputState TIM_OutputState_Enable; oc.TIM_OCPolarity TIM_OCPolarity_High; oc.TIM_Pulse 4; /* 4/8占空比 50% */ TIM_OC1Init(TIM1, oc); TIM_CtrlPWMOutputs(TIM1, ENABLE); /* 高级定时器必须开这一句 */ TIM_Cmd(TIM1, ENABLE); }TIM_CtrlPWMOutputs()是 TIM1/TIM8 这类高级定时器特有的普通定时器没有这个调用漏掉它的话引脚上就是一条直线示波器一测就知道。频率怎么定8MHz 的 XCLK 经过内部寄存器分频后PCLK 大概落在 4MHz 上下也就是每秒 400 万个字节。QVGA 的 RGB565 一帧是 320×240×2 153600 字节理论上限约 26 fps但中断方式采样每字节要几十个时钟周期实际能到 1~3 fps 就不错了。如果你把 XCLK 提到 24MHzPCLK 跟着涨采到的只会是错位数据。降压比升压有用这是这套方案的铁律。3. SCCB 配置链路把 OV7670 从复位态叫到 RGB565 输出3.1 软件模拟 SCCB为什么硬件 I2C 在这里容易翻车SCCB 是 OV 系列自己的控制总线电气上跟 I2C 几乎一样但协议细节有差异它不支持重复起始条件Repeated Start从机在第 9 位返回的是“dont care”主机侧不需要判 ACK/NACK。STM32 的硬件 I2C 外设是按标准 I2C 状态机设计的碰上不返回 ACK 的从机很容易进 BUSY 卡死或者触发 AF 错误得靠反复 DeInit 才能恢复。所以工程里直接用两个 GPIO 手打时序稳定得多也方便在示波器上看波形。#define SCCB_SCL_H() GPIO_SetBits(GPIOB, GPIO_Pin_10) #define SCCB_SCL_L() GPIO_ResetBits(GPIOB, GPIO_Pin_10) #define SCCB_SDA_H() GPIO_SetBits(GPIOB, GPIO_Pin_11) #define SCCB_SDA_L() GPIO_ResetBits(GPIOB, GPIO_Pin_11) #define SCCB_SDA_IN() GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11) #define OV7670_ADDR_W 0x42 /* 0x21 1写地址 */ static void SCCB_Delay(void) { /* 72MHz 下若干空操作实测拉出约 200kHz 的 SCL */ __NOP(); __NOP(); __NOP(); __NOP(); __NOP(); } static void SCCB_Start(void) { SCCB_SDA_H(); SCCB_SCL_H(); SCCB_Delay(); SCCB_SDA_L(); SCCB_Delay(); /* SCL 高电平期间 SDA 下降 起始 */ SCCB_SCL_L(); SCCB_Delay(); } static void SCCB_Stop(void) { SCCB_SDA_L(); SCCB_SCL_H(); SCCB_Delay(); SCCB_SDA_H(); SCCB_Delay(); /* SCL 高电平期间 SDA 上升 停止 */ } static uint8_t SCCB_WriteByte(uint8_t dat) { uint8_t i, ack; for (i 0; i 8; i) { if (dat 0x80) SCCB_SDA_H(); else SCCB_SDA_L(); dat 1; SCCB_Delay(); SCCB_SCL_H(); SCCB_Delay(); SCCB_SCL_L(); SCCB_Delay(); } SCCB_SDA_H(); /* 释放 SDA让从机驱动第 9 位 */ SCCB_Delay(); SCCB_SCL_H(); SCCB_Delay(); ack SCCB_SDA_IN(); /* SCCB 里这一位是 dont care读回来仅作参考 */ SCCB_SCL_L(); SCCB_Delay(); return ack; } void OV7670_WriteReg(uint8_t reg, uint8_t val) { SCCB_Start(); SCCB_WriteByte(OV7670_ADDR_W); SCCB_WriteByte(reg); SCCB_WriteByte(val); SCCB_Stop(); Delay_ms(1); /* 给寄存器一个生效窗口批量写时尤其重要 */ }几个参数值得单独说。OV7670_ADDR_W是 0x42也就是 7 位地址 0x21 左移一位。写寄存器时千万别把 0x21 直接填进去那样从机根本不会响应读回全是 0xFF。末尾的Delay_ms(1)看着多余但连续写几十个寄存器时某些位特别是时钟控制和复位位需要时间落地省掉它就会出现“前面写的被后面覆盖”的怪现象。3.2 初始化寄存器表的组织方式寄存器有上百个逐个调函数写会非常难看工程里用结构体数组加一个循环搞定typedef struct { uint8_t reg; uint8_t val; } ov7670_reg_t; /* 只列关键项完整表按数据手册补 */ static const ov7670_reg_t ov7670_init_seq[] { {0x12, 0x80}, /* COM7软复位所有寄存器 */ {0x12, 0x04}, /* COM7输出 RGBVGA 基准 */ {0x11, 0x80}, /* CLKRC内部时钟不分频 */ {0x0C, 0x00}, /* COM3关闭缩放保留全分辨率 */ {0x3A, 0x04}, /* TSLBRGB 模式下字节顺序 */ {0x40, 0xD0}, /* COM15RGB565 输出范围 */ {0x8C, 0x00}, /* RGB444 关闭保持 565 */ {0x15, 0x00}, /* COM10PCLK/VSYNC/HREF 默认极性 */ {0x1E, 0x01}, /* MVFP垂直翻转关闭 */ }; void OV7670_Init(void) { uint16_t i; for (i 0; i sizeof(ov7670_init_seq) / sizeof(ov7670_init_seq[0]); i) { OV7670_WriteReg(ov7670_init_seq[i].reg, ov7670_init_seq[i].val); } }COM100x15这一项要重点盯。它同时控制 PCLK 极性、VSYNC 极性、HREF 极性三个位默认 0x00 表示“PCLK 上升沿有效、VSYNC 高有效、HREF 高有效”。如果你的中断按下降沿配而这里是 0x00那采到的就是移位后的数据画面表现为整体偏色加斜纹。极性必须和中断配置一一对上这是排查时第一个要看的地方。COM150x40设 0xD0 是 RGB565 的标准值设成 0x10 会变成 RGB444虽然也能出图但颜色会严重失真而且每像素字节数变了帧缓存的偏移全要跟着改。3.3 写完必须读回PID/VER 校验与失败处理配置完不能直接开始采图先读两个身份寄存器确认通信链路是通的PID0x0A应该返回 0x76VER0x0B返回 0x73。uint8_t OV7670_ReadReg(uint8_t reg) { uint8_t val; SCCB_Start(); SCCB_WriteByte(OV7670_ADDR_W); SCCB_WriteByte(reg); SCCB_Stop(); /* SCCB 不允许重复起始必须停一次再起 */ Delay_us(10); SCCB_Start(); SCCB_WriteByte(OV7670_ADDR_W | 0x01); /* 读地址 0x43 */ val SCCB_ReadByte(); SCCB_Stop(); return val; } uint8_t OV7670_CheckID(void) { if (OV7670_ReadReg(0x0A) ! 0x76) return 1; /* PID 不对接线或地址有问题 */ if (OV7670_ReadReg(0x0B) ! 0x73) return 2; /* VER 不对供电或复位有问题 */ return 0; }读时序里那个SCCB_Stop()是 SCCB 和标准 I2C 最大的一处差异。I2C 习惯用重复起始把“写寄存器地址”和“读数据”连在一起SCCB 不行必须先发停止位、总线空闲再重新发起始位。如果照抄 I2C 的读时序从机不会吐出数据读回来永远是 0xFF 或者 0x00。如果 PID 读出来是 0xFF先量 SIOD 上有没有上拉电阻——很多模组板自带 4.7k裸芯片没有。如果读到 0x00通常是 SDA 被硬件拉死检查是不是把 SIOC/SIOD 接反了。这两种情况在第一批板子上我各遇到过一次。4. 无 FIFO 采样的时序状态机VSYNC、HREF、PCLK 三级怎么配合4.1 一帧数据的结构从 VSYNC 脉冲到最后一个 PCLK要采样先得看懂 OV7670 输出一帧的节奏。一帧开始时VSYNC 会打一个脉冲宽度几十个 PCLK。脉冲之后进入若干行的消隐期这段时间 HREF 是低的数据总线上有电平但不代表有效像素必须丢掉。然后 HREF 拉高表示一行有效数据开始接下来每来一个 PCLK总线上就换一个字节。一行采完 HREF 拉低进入行消隐接着下一行的 HREF 又拉高。重复 480 次VGA之后VSYNC 再来一个脉冲下一帧开始。所以三个信号的分工非常清晰VSYNC 负责“帧对齐”HREF 负责“行对齐”PCLK 负责“字节对齐”。三个中断各管一级构成一个天然的三层状态机。RGB565 模式下每个像素占两个 PCLK、两个字节先高字节后低字节。灰度模式YUV 里的 Y 分量或者直接配成灰度输出每像素一个字节。这一点直接决定了帧缓存大小的算法。4.2 三个外部中断的具体分工与优先级先配 EXTI。注意GPIO_EXTILineConfig()里指定的是端口源EXTI 线号由引脚号决定static void CameraSync_Init(void) { GPIO_InitTypeDef gpio; EXTI_InitTypeDef exti; NVIC_InitTypeDef nvic; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB | RCC_APB2Periph_AFIO, ENABLE); gpio.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2; gpio.GPIO_Mode GPIO_Mode_IPU; /* 同步信号用上拉输入防浮空误触发 */ gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, gpio); GPIO_EXTILineConfig(GPIO_PortSourceGPIOB, GPIO_PinSource0); /* PB0 - EXTI0 */ GPIO_EXTILineConfig(GPIO_PortSourceGPIOB, GPIO_PinSource1); /* PB1 - EXTI1 */ GPIO_EXTILineConfig(GPIO_PortSourceGPIOB, GPIO_PinSource2); /* PB2 - EXTI2 */ /* PCLK下降沿COM10 配成默认极性时数据在下降沿稳定 */ exti.EXTI_Line EXTI_Line0; exti.EXTI_Mode EXTI_Mode_Interrupt; exti.EXTI_Trigger EXTI_Trigger_Falling; exti.EXTI_LineCmd ENABLE; EXTI_Init(exti); /* HREF双边沿上升沿开始一行下降沿结束一行 */ exti.EXTI_Line EXTI_Line1; exti.EXTI_Trigger EXTI_Trigger_Rising_Falling; EXTI_Init(exti); /* VSYNC上升沿一帧的起点 */ exti.EXTI_Line EXTI_Line2; exti.EXTI_Trigger EXTI_Trigger_Rising; EXTI_Init(exti); /* 优先级PCLK HREF VSYNCPCLK 被打断就等于丢像素 */ NVIC_InitStructure.NVIC_IRQChannel EXTI0_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 0; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(nvic); NVIC_InitStructure.NVIC_IRQChannel EXTI1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_Init(nvic); NVIC_InitStructure.NVIC_IRQChannel EXTI2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 2; NVIC_Init(nvic); }优先级的安排有依据PCLK 的频率最高一旦它在等待 HREF 或 VSYNC 的中断返回这段时间里新来的像素就会覆盖掉旧数据采出来的图就是错位的。所以 PCLK 给最高的抢占优先级 0HREF 次之VSYNC 最低因为一帧的开头丢几个时钟不影响整体。再来看三个 ISR 的实现#define IMG_W 160 #define IMG_H 120 #define IMG_BPP 1 /* 1灰度 2RGB565C8T6 只有 20KB SRAM */ #define FRAME_BYTES (IMG_W * IMG_H * IMG_BPP) volatile uint8_t g_frame[FRAME_BYTES]; volatile uint32_t g_byte_idx 0; /* 帧内已写入字节数 */ volatile uint16_t g_line_idx 0; /* 已采到的有效行数 */ volatile uint8_t g_href 0; /* HREF 当前电平 */ volatile uint8_t g_frame_done 0; /* 一帧封口标志 */ /* VSYNC一帧的起点所有指针归零 */ void EXTI2_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line2) ! RESET) { EXTI_ClearITPendingBit(EXTI_Line2); g_byte_idx 0; g_line_idx 0; g_href 0; g_frame_done 0; /* 上一帧还没处理完就直接作废 */ } } /* HREF行边界负责计数和封帧 */ void EXTI1_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line1) ! RESET) { EXTI_ClearITPendingBit(EXTI_Line1); g_href GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_1); if (g_href) { g_line_idx; /* 新的一行开始 */ } else { if (g_line_idx IMG_H) { /* 采够目标行数就封帧 */ g_frame_done 1; } } } } /* PCLK真正的数据搬运每来一个下降沿取一个字节 */ void EXTI0_IRQHandler(void) { uint8_t b; if (EXTI_GetITStatus(EXTI_Line0) ! RESET) { EXTI_ClearITPendingBit(EXTI_Line0); if (!g_href) return; /* 行消隐期的 PCLK 一律丢弃 */ if (g_byte_idx FRAME_BYTES) return; /* 越界保护这条是保命的 */ b (uint8_t)(GPIO_ReadInputData(GPIOA) 0x00FF); g_frame[g_byte_idx] b; } }EXTI_GetITStatus和EXTI_ClearITPendingBit这两句不能用EXTI_GetFlagStatus替代后者查的是挂起标志而不是中断使能状态在某些边沿组合下会漏清标志导致中断不停地重入。这是 F1 标准库上的老问题。g_byte_idx FRAME_BYTES这个判断看着是废话但它防的是“HREF 抖动导致多采一行”的情况。没有它一旦行计数错位缓冲区后面的全局变量会被顺着写穿表现为程序莫名其妙跑飞。4.3 字节流到图像的拼接与缓存中断只负责把原始字节按顺序塞进数组至于这些字节怎么组成像素留给主循环处理别在 ISR 里做运算。灰度模式下一个字节就是一个像素主循环直接拿去送串口或者刷 OLEDint main(void) { uint32_t i; uint16_t x, y; SystemInit(); CameraXclk_Init(); CameraDataPort_Init(); OV7670_SCCB_Init(); CameraSync_Init(); if (OV7670_CheckID() ! 0) { while (1); /* ID 不对就停在这里方便打断点 */ } OV7670_Init(); while (1) { if (!g_frame_done) continue; g_frame_done 0; for (y 0; y IMG_H; y) { for (x 0; x IMG_W; x) { i y * IMG_W x; /* 灰度直接输出RGB565 时这里要按 16 位拼 */ LCD_DrawPixel(x, y, g_frame[i]); } } } }RGB565 模式下IMG_BPP要改成 2字节缓冲里每个像素占两个位置高字节在前拼装方式如下uint16_t px ((uint16_t)g_frame[2 * i] 8) | g_frame[2 * i 1];高字节在前是 OV7670 的默认输出顺序。如果显示出来颜色整体偏绿或者红蓝互换八成是字节序反了把移位方向调过来就能修好不需要去改摄像头寄存器。这个现象我碰到过一次调了半天寄存器最后发现是这两行写反了。帧缓存大小需要提前算好。F103C8T6 只有 20KB SRAMQQVGA160×120的 RGB565 需要 38400 字节根本放不下灰度只要 19200 字节勉强能跑但要留出栈空间。如果一定要 RGB565得换 RAM 大一点的型号或者把窗口裁到 160×60。这个限制是硬件决定的代码怎么优化都绕不过去。5. 避坑与排查无 FIFO 采样最常见的几个翻车点5.1 上电自检的排查顺序出问题的时候不要一头扎进中断代码里看按信号链从后往前查效率最高。先量电源3.3V 进了模组没有PWDN 是不是真的在 0V。再量 XCLK示波器接 PA8应该看到一个 8MHz 的方波幅值接近 3.3V。这一步没过后面全白搭。然后量 VSYNC上电后应该有周期性脉冲如果是一条直线说明摄像头根本没在输出回去查 SCCB本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。