☰
STM32F1驱动OV7670无FIFO:SCCB与PCLK/DMA采集
2026/11/5 5:48:27 网站建设 项目流程

简介:这套工程面向STM32F1平台与OV7670无FIFO摄像头,适合正在做图像采集、显示与基础处理实验的学生和开发者参考,可解决无FIFO方案缺少可运行源码、图像处理接口不完整的问题。包内提供可直接打开的Keil工程及外设驱动源码,围绕摄像头数据采集、LCD显示、SD卡与FatFs文件系统等模块组织,并补充亮度、饱和度、对比度调节函数;按下KEY1可切换二值化与灰度化,同时预留颜色识别接口,便于继续扩展。压缩包共312个文件,以C源文件与H头文件为主体,辅以Keil工程配置、编译中间文件、PNG截图和少量说明文本,整体约10.8MB,工程结构与外设代码分层较清晰。已有4561人学习,说明其在无FIFO方案中具备一定参考价值。读者可从中获取OV7670无FIFO的寄存器配置与数据读取思路、图像处理函数的组织方式,以及按键切换显示效果的实现框架,适合作为课程设计、毕业设计或嵌入式图像入门项目的对照代码。

1. 无 FIFO 的 OV7670 挂在 STM32F1 上,先想清楚你要的是几帧每秒

买 OV7670 时图便宜拿到的是不带 AL422B 的版本,接上 STM32F1 才发现网上代码全在讲 FIFO 的读写指针和行号计数。OV7670 无 FIFO 的意思很直接:像素时钟 PCLK、行同步 HREF、场同步 VSYNC 三根线直接甩给主控,主控必须在一个 PCLK 周期内把 8 位数据读走,慢一步这个像素就永远丢了。STM32F1 没有 DCMI 并行接口,72MHz 的主频还要被取指、中断响应和循环开销分掉一部分,所以这份资源的真正价值不是「跑通摄像头」,而是教你在一颗没有专用图像外设的片子上,把 OV7670 无 FIFO 的时序掰开、把帧率压到能稳住的区间,再谈图像处理。它适合做静态图像采集、简单二值化/寻线/色块识别 Demo、教学实验的人;要 30fps 视频流,这个组合从一开始就不该选。

2. SCCB 把 OV7670 叫醒:地址、上电顺序与那串寄存器

OV7670 上电后不会自己输出图像,它默认处于掉电模式,必须由主控通过 SCCB 总线把一串寄存器写完才肯工作。这一步看着简单,实际上大部分「接上了但没图像」的问题都出在这里——不是时序没对准,就是复位后没等够时间,或者地址写成了标准 I2C 的 0x42 却忘了 SCCB 第九位是「不必应答」。

2.1 SCCB 和标准 I2C 的三个差异

SCCB 是 OmniVision 自己定义的两线协议,物理层和 I2C 几乎一样,都是开漏加外部上拉,但 behavioural 上有三点必须区分开。

第一是地址。OV7670 的 7 位从机地址固定为 0x21,左移一位后写地址是 0x42、读地址是 0x43。很多新手直接抄 I2C 扫描代码,扫出 0x21 就以为对了,结果发送时用 0x21 当写地址,从机根本不响应。

第二是第九位。标准 I2C 主机发送完 8 位后释放 SDA 等从机拉低应答;SCCB 里这一位叫 Don't-Care 位,写操作时从机不一定会拉低,主机也不应该因为它没拉低就判失败。读操作则分两段:先写寄存器地址(三段式),再发起一次读传输。

第三是速度。SCCB 没有严格的 400kHz 上限,实际用 GPIO 模拟时把半周期延时做到 2~3 微秒就够了,没必要为了快而牺牲稳定性,配置一次也就几毫秒。

我一般把 SCL 接 PB10、SDA 接 PB11,两个引脚都配成开漏输出,外部各挂一个 4.7k 上拉到 3.3V。注意 OV7670 模组的 IO 电平标称 2.8V,但绝大多数带电平转换或者直接 3.3V 供电的模组能吃 3.3V 的 SCCB,前提是模组板上已经有 LDO。如果你的模组是裸的 2.8V 版本,SCL/SDA 上拉要接到 2.8V 而不是 3.3V,否则长期跑会损伤 IO。

/* 软件 SCCB:PB10=SCL,PB11=SDA,均配置为开漏输出 */ #define SCL_H() (GPIOB->BSRR = GPIO_BSRR_BS10) #define SCL_L() (GPIOB->BSRR = GPIO_BSRR_BR10) #define SDA_H() (GPIOB->BSRR = GPIO_BSRR_BS11) #define SDA_L() (GPIOB->BSRR = GPIO_BSRR_BR11) #define SDA_READ() ((GPIOB->IDR & GPIO_IDR_IDR11) ? 1 : 0) static void sccb_delay(void) { /* 72MHz 下约 2~3us,对应 SCCB 约 150~250kHz,够稳 */ for (volatile int i = 0; i < 40; i++) { __NOP(); } } static void sccb_start(void) { SDA_H(); SCL_H(); sccb_delay(); SDA_L(); sccb_delay(); /* SCL 高期间 SDA 下降沿 = 起始条件 */ SCL_L(); sccb_delay(); } static void sccb_stop(void) { SDA_L(); SCL_H(); sccb_delay(); SDA_H(); sccb_delay(); /* SCL 高期间 SDA 上升沿 = 停止条件 */ }

sccb_delay里的循环次数是可调的旋钮:如果你的模组排线比较长、上拉又偏弱,把这个值加大到 80 甚至 120,先保证能通信,再去优化速度。__NOP()是为了防止编译器把空循环整个优化掉,volatile修饰循环变量也是同一个目的。

2.2 上电时序与复位后的自检

OV7670 对供电顺序有要求:先给 DOVDD(1.8V 数字核)和 AVDD(2.8V 模拟),再给 DVDD(2.5V IO),最后给 XCLK 时钟。模组板通常已经帮你处理好了,你只需要保证一点:给 XCLK 之后再等至少 1ms,才能发 SCCB 命令。XCLK 由 STM32F1 的 MCO 引脚输出,把 HSE 的 8MHz 直接送出去是最省事的做法,因为 PLL/2 在 72MHz 主频下是 36MHz,远超 OV7670 的 27MHz 上限。

/* MCO 输出 HSE 8MHz 给 OV7670 的 XCLK */ static void xclk_output_8m(void) { RCC->APB2ENR |= RCC_APB2ENR_IOPAEN; /* PA8 = MCO,复用推挽输出,50MHz */ GPIOA->CRH &= ~(GPIO_CRH_MODE8 | GPIO_CRH_CNF8); GPIOA->CRH |= GPIO_CRH_MODE8_1 | GPIO_CRH_MODE8_0; /* 50MHz */ GPIOA->CRH |= GPIO_CRH_CNF8_1; /* 复用推挽 */ RCC->CFGR &= ~RCC_CFGR_MCO; RCC->CFGR |= RCC_CFGR_MCO_HSE; /* 直接输出 HSE */ }

配置完 MCO 后延时 10ms,再发软复位:向 0x12(COM7)写 0x80。软复位会把所有寄存器打回默认值,包括你刚设好的分辨率,所以复位之后必须重新写一遍完整配置表,不能只写增量。

复位后做一次自检:读 0x0A(PID)应该得到 0x76,读 0x0B(VER)应该得到 0x73。这两个值对不上,说明 SCCB 根本没通,后面所有寄存器写入都是无效操作,先别往下调图像。

static uint8_t sccb_read(uint8_t reg) { uint8_t val; sccb_start(); sccb_write_byte(0x42); /* 写地址:从机地址 0x21 << 1 */ sccb_write_byte(reg); /* 目标寄存器 */ sccb_stop(); sccb_start(); sccb_write_byte(0x43); /* 读地址 */ val = sccb_read_byte(); /* 读一个字节,主机回 NA */ sccb_stop(); return val; } /* 自检:不通就直接返回,省得后面瞎调 */ uint8_t ov7670_check_id(void) { if (sccb_read(0x0A) != 0x76) return 1; /* PID 不符 */ if (sccb_read(0x0B) != 0x73) return 2; /* VER 不符 */ return 0; }

ov7670_check_id返回非零值就代表硬件连接层面有问题,别急着怀疑寄存器表。PID 和 VER 是只读的工厂烧录值,不存在「配置错了导致读出来不对」这种情况,这是排查时最可靠的锚点。

2.3 把配置序列灌进去并读回校验

配置表建议用结构体数组,一条寄存器一个元素,好处是编译期就能算出条数,改的时候也不用维护计数器。下面这组值是社区里流传最广的 QQVGA、RGB565 起点配置,不同批次的模组默认值有差异,但按这组写基本能出图。

寄存器值作用
0x12 COM70x0C选择 QQVGA 分辨率、RGB 输出
0x11 CLKRC0x1F内部时钟分频拉满,先让 PCLK 足够慢
0x0C COM30x00关闭缩放,避免输出被二次采样
0x3A TSLB0x04输出顺序,配合 RGB565 字节序
0x40 COM150xD0RGB565 全范围输出
0x8C RGB4440x00关闭 444 截断,保持 565
0x17 HSTART0x13行起始(QQVGA 窗口)
0x18 HSTOP0x01行结束高 3 位
0x32 HREF0x80HREF 起始边沿位置
0x19 VSTART0x02场起始
0x1A VSTOP0x7A场结束
0x03 VREF0x0A垂直窗口控制
0x0E COM50x01内部 PLL 使能,稳定帧率
0x1E MVFP0x00不镜像不翻转,需要时改这两位
typedef struct { uint8_t reg; uint8_t val; } reg_cfg_t; static const reg_cfg_t ov7670_cfg[] = { {0x12, 0x0C}, {0x11, 0x1F}, {0x0C, 0x00}, {0x3A, 0x04}, {0x40, 0xD0}, {0x8C, 0x00}, {0x17, 0x13}, {0x18, 0x01}, {0x32, 0x80}, {0x19, 0x02}, {0x1A, 0x7A}, {0x03, 0x0A}, {0x0E, 0x01}, {0x1E, 0x00}, }; uint8_t ov7670_init(void) { sccb_write(0x12, 0x80); /* COM7 软复位 */ delay_ms(10); /* 必须等,不然下面全是白写 */ for (size_t i = 0; i < sizeof(ov7670_cfg)/sizeof(ov7670_cfg[0]); i++) { sccb_write(ov7670_cfg[i].reg, ov7670_cfg[i].val); delay_ms(1); /* 部分寄存器需要建立时间 */ } delay_ms(100); /* 等内部 PLL 和自动增益环路稳定 */ return 0; }

写完不能就算完,抽三个关键寄存器读回来比对:0x12 应该读回 0x0C、0x11 读回 0x1F、0x40 读回 0xD0。读回值不一致通常意味着两个原因:一是写的时候第九位处理错误,从机把数据位当成了别的;二是模组在某个寄存器上有硬件默认覆盖,比如某些批次的 0x0E 强制为 0x00。遇到覆盖不要硬顶,把那条从表里去掉,靠 0x11 分频来找合适的 PCLK。

提示:配置表里的 0x11 CLKRC 是无 FIFO 方案里最重要的一个旋钮。它直接决定 PCLK 频率,也就直接决定你的主控读得过来读不过来。先用 0x1F 这个偏大的分频把 PCLK 压到几百 kHz,等图像能稳定出来之后,再逐档减小试探上限。

3. 无 FIFO 的取数路径:PCLK、HREF、VSYNC 三个信号怎么配合

这一章是整个项目最硬的部分。有 FIFO 的模组,AL422B 帮你把一整帧存起来,主控想什么时候读就什么时候读;无 FIFO 之后,OV7670 就是一个纯粹的「流式输出」设备,它不管下游接不接受,PCLK 该来还是来。

3.1 一帧的时序骨架与采样窗口

OV7670 默认极性下,VSYNC 高电平表示帧消隐,下降沿表示一帧正式开始;HREF 高电平表示当前行有有效像素输出,低电平表示行消隐;PCLK 在 HREF 为高期间每一个上升沿对应数据总线上的一个字节。三个信号的关系是嵌套的:VSYNC 一低,先过一段消隐,然后进入第一个 HREF 高电平窗口,窗口内 PCLK 连续输出这一行的像素字节,窗口结束进下一行,重复 120 次(QQVGA),然后 VSYNC 拉高,一帧结束。

采样逻辑必须严格按这个嵌套顺序走,任何一层等错了,图像就会整体错位。我习惯写成四层循环:

/* 抓一帧 QQVGA RGB565,buf 需要 160*120*2 = 38400 字节 */ uint8_t ov7670_capture(uint8_t *buf) { uint32_t n = 0, timeout; /* 第 1 层:等 VSYNC 拉高,确认进入帧消隐,把上一帧甩掉 */ timeout = 2000000; while (VSYNC_READ() == 0) if (--timeout == 0) return 1; /* 第 2 层:等 VSYNC 拉低,这才是本帧的第一个像素的起点 */ timeout = 2000000; while (VSYNC_READ() != 0) if (--timeout == 0) return 2; for (uint32_t y = 0; y < 120; y++) { /* 第 3 层:等本行 HREF 拉高 */ timeout = 200000; while (HREF_READ() == 0) if (--timeout == 0) return 3; for (uint32_t x = 0; x < 160; x++) { /* 第 4 层:等 PCLK 上升沿后立刻读数据总线 */ while (PCLK_READ() == 0) {} uint8_t hi = DATA_READ(); while (PCLK_READ() != 0) {} while (PCLK_READ() == 0) {} uint8_t lo = DATA_READ(); while (PCLK_READ() != 0) {} buf[n++] = hi; /* RGB565 高字节在前 */ buf[n++] = lo; } /* 行尾:等 HREF 拉低,多出来的像素直接丢掉 */ timeout = 200000; while (HREF_READ() != 0) if (--timeout == 0) return 4; } return 0; }

每一层while都在等一个明确的边沿,timeout的作用不是兜底超时,而是防止 VSYNC 或 HREF 断线时整个程序死在这行循环里——现场调试时没有这个计数器,你连「卡在哪一层」都判断不出来。返回值 1/2/3/4 分别对应四个等待点,配合串口打印能快速定位问题层级。

DATAREAD()读的是 GPIO 的 IDR 低 8 位。数据线 D0~D7 要接在同一个 GPIO 端口上,比如 PA0~PA7,这样一次GPIOA->IDR & 0xFF就能拿到全部 8 位,不需要移位拼接。如果数据线被迫拆到两个端口,每个像素多两次读操作,帧率会掉一截,接线时尽量避开这种布局。

3.2 STM32F1 没有 DCMI,剩下三条路

STM32F1 全系都没有 DCMI 并行接口,这个外设是从 F2 系列才开始有的。所以「像 F4 那样开个 DMA 环形缓冲收图」这条路在 F1 上不存在,实际可选的是三种做法。

第一种是纯 GPIO 轮询,也就是上面那段代码。它的优点是逻辑透明、不依赖任何外设配置,出问题一眼能看出卡在哪一层;缺点是 CPU 全程被占用,采样期间不能做别的事,而且对 PCLK 频率有硬上限。72MHz 下一次「等边沿 + 读 IDR + 存字节」大概要十几个周期,加上循环开销,实测 PCLK 超过 1MHz 之后就开始丢字节,图像上表现为一行比一行短、最后整体花屏。

第二种是 EXTI 中断,把 PCLK 接到外部中断引脚,上升沿触发中断,在中断服务函数里读 IDR。这条路听起来比轮询优雅,实际在 F1 上并不划算:一次中断进出至少 12 个周期,加上现场保护和调度延迟,单像素开销比轮询还高,而且中断频率一上去,主循环基本被饿死。我试过在 72MHz 上跑到 500kHz 的 PCLK,中断占用率已经超过 60%,再往上系统就别想干别的了。

第三种是定时器输入捕获触发 DMA,这是 F1 上最接近 DCMI 的做法,也是我最终采用的方案。把 PCLK 接到 TIM2_CH1,配置为输入捕获、上升沿触发,同时把 CC1 事件映射到 DMA 请求,DMA 通道从GPIOA->IDR单次搬运一个半字到缓冲区。这样每个 PCLK 上升沿硬件自动完成一次「读端口 + 写内存」,CPU 只在帧结束中断里处理数据。

/* TIM2_CH1 输入捕获 + DMA1_Channel5,把 PCLK 边沿变成搬运请求 */ static void pclk_dma_setup(uint8_t *buf, uint32_t len) { RCC->APB1ENR |= RCC_APB1ENR_TIM2EN; RCC->AHBENR |= RCC_AHBENR_DMA1EN; /* PA0 = TIM2_CH1,浮空输入 */ GPIOA->CRL &= ~(GPIO_CRL_MODE0 | GPIO_CRL_CNF0); GPIOA->CRL |= GPIO_CRL_CNF0_0; /* 浮空输入 */ TIM2->CCMR1 = 0x01; /* CC1 映射到 TI1 */ TIM2->CCER = TIM_CCER_CC1E; /* 上升沿捕获,使能 */ TIM2->DIER = TIM_DIER_CC1DE; /* CC1 事件产生 DMA 请求 */ /* DMA1 通道 5 对应 TIM2_CH1,外设地址固定为 GPIOA->IDR */ DMA1_Channel5->CPAR = (uint32_t)&GPIOA->IDR; DMA1_Channel5->CMAR = (uint32_t)buf; DMA1_Channel5->CNDTR = len; DMA1_Channel5->CCR = DMA_CCR_MINC | DMA_CCR_PSIZE_0 | DMA_CCR_MSIZE_0 | DMA_CCR_CIRC; /* 16 位宽、循环模式 */ DMA1_Channel5->CCR |= DMA_CCR_EN; TIM2->CR1 |= TIM_CR1_CEN; }

参数说明:CNDTR设成160*120*2(38400)就能一次性收满一帧,但循环模式会在缓冲区尾部绕回开头,所以帧边界的判定要放在 VSYNC 的 EXTI 回调里做——VSYNC 下降沿时记录当前 DMA 剩余计数,作为这一帧的起点偏移。DMA_CCR_PSIZE_0和DMA_CCR_MSIZE_0都表示 16 位宽度,因为 GPIO IDR 是 32 位寄存器但只有低 16 位有效,16 位传输刚好把低 8 位数据带过来,高 8 位是相邻引脚的值,存进缓冲区后需要右移 8 位再取。

这条路的天花板比轮询高得多,实测 PCLK 到 2MHz 还能稳住,再往上就要考虑 DMA 请求响应延迟和内存带宽了。代价是调试难度陡增:任何一个配置位写错,现象都是「缓冲区全是 0」或者「数据整体错半个字节」,没有逻辑分析仪基本靠猜。

3.3 先跑通再提速的顺序

我给自己的顺序是固定的:先用轮询方案确认 SCCB 配置正确、图像轮廓能出来,再切到 DMA 方案做性能对比。反过来做的话,一旦 DMA 收出来的数据不对,你分不清是配置表错了还是 DMA 时序错了,两个变量同时动,排查成本翻倍。

轮询方案能出图,就是一个非常强的信号:XCLK 有了、PCLK 在动、HREF 和 VSYNC 极性都对、数据线接法没问题。这时候再切 DMA,如果不出图,问题一定收敛在 TIM2 和 DMA 的配置里,范围小得多。

还有一个容易被忽略的点:切换方案时不要改 XCLK 频率。XCLK 一变,PCLK 跟着变,前面调好的 CLKRC 分频就得重调。我一般把 XCLK 焊死在 8MHz,只用 0x11 这一个寄存器调 PCLK,变量少一个,血泪经验。

4. 一帧到底吃掉多少 RAM:QQVGA、灰度与分块缓冲的取舍

STM32F1 的 RAM 是这个项目里最先撞到的墙。F103C8T6 只有 20KB,F103RC 有 48KB,F103ZE 有 64KB,而一帧 QQVGA RGB565 是 38400 字节——20KB 的片子上连半帧都放不下。

4.1 把字节数算清楚再选型号

像素数乘字节宽度,再留出 20% 给栈、全局变量和 DMA 描述符,这是选型时的粗算方法。常见组合的实际占用如下:

分辨率像素数RGB565灰度 8bitYUV422
VGA 640×480307200614400307200614400
QVGA 320×2407680015360076800153600
QQVGA 160×12019200384001920038400
QCIF 176×14425344506882534450688

F103C8T6 的 20KB 要装下 QQVGA RGB565 整帧是不可能的,但装下 QQVGA 灰度整帧(19200 字节)刚好卡在边缘,栈和全局变量再占 2KB 就溢出了。F103ZE 的 64KB 装 QQVGA RGB565 整帧很宽松,装 QVGA 灰度(76800 字节)就超了。

所以选型逻辑很清楚:要么换大 RAM 的型号,要么降分辨率或降色彩深度,没有第三条路。想在 20KB 的片子上跑 QVGA RGB565,只能分块,而分块会带来帧内撕裂,后面 4.3 节展开。

4.2 分辨率、色彩深度、帧率的三角权衡

这三个参数互相拉扯,实际项目里必须有一个是固定的。

做色块识别、寻线这类任务,颜色信息是刚需,那就保 RGB565,把分辨率降到 QQVGA,帧率让到 1~2fps。160×120 对识别一个直径 30 像素的色块完全够用,再高就是浪费 RAM。

做二值化、边缘检测、字符识别这类灰度算法,直接让 OV7670 输出 YUV422 然后只取 Y 分量,字节数砍一半。YUV422 里每个像素占 2 字节,但 Y 和 UV 是交错排的,只取偶数位置的 Y 就能得到 8 位灰度值。这样 QQVGA 灰度只要 19200 字节,F103C8T6 勉强能整帧放下。

做静态拍照、需要细节的场景,可以保 QVGA,但必须分块,而且要有心理准备:分块采集一帧要几十毫秒到几百毫秒,期间场景一动就有撕裂。

/* 从 YUV422 缓冲区里抽出 Y 分量,得到 width*height 字节的灰度图 */ void yuv422_to_gray(const uint8_t *src, uint8_t *dst, uint32_t pixels) { for (uint32_t i = 0; i < pixels; i++) { /* YUV422 排列为 Y0 U Y1 V,Y 在偶数索引 */ dst[i] = src[i * 2]; } }

pixels是像素总数,不是字节数。这段代码的前提是 OV7670 配置成了 YUV422 输出(COM7 的 bit5:4 设为 00),并且 TSLB 的排列顺序和你的假设一致。如果抽出来的灰度图看着像噪声,先把 TSLB 改成 0x00 和 0x04 各试一次,这是 YUV 字节序最容易翻车的地方。

4.3 分块采集与乒乓缓冲

RAM 不够整帧时,最直接的做法是把图像分成 N 块,每次只收一块,处理完再收下一块。分块有两种切法:按行切和按区域切。

按行切最简单,比如 QQVGA RGB565 分成 4 块,每块 30 行,缓冲区 30×160×2 = 9600 字节。采集时先等 VSYNC 同步,然后跳过前 30 行、收第 31~60 行,收完处理,再等下一帧同步收下一块。缺点是一帧图像要跨 4 个 VSYNC 周期才能拼完,中间场景一变就有横向撕裂。

按区域切适合做局部感兴趣区域(ROI)处理,比如只关心画面中央 80×60 的区域,那就只收这片区域的像素,其余直接跳过。跳过的方式是在 HREF 为高时快速空转 PCLK,不读数据。这样 RAM 占用降到 80×60×2 = 9600 字节,而且一帧内就能收完,没有跨帧撕裂问题。

乒乓缓冲是另一个思路:准备两块半帧缓冲区 A 和 B,DMA 收满 A 之后自动切到 B,CPU 在处理 A 的同时 DMA 往 B 里写。这个方案要求 RAM 至少能放下两块,F103ZE 上做 QQVGA 灰度(19200×2 = 38400 字节)刚好可行。

/* 乒乓缓冲:DMA 收满一半触发中断,切换处理指针 */ static uint8_t buf_a[19200], buf_b[19200]; static volatile uint8_t *ready_buf = 0; void DMA1_Channel5_IRQHandler(void) { if (DMA1->ISR & DMA_ISR_TC5) { DMA1->IFCR = DMA_IFCR_CTC5; /* 前半满:A 可读,DMA 已自动绕到 B */ ready_buf = buf_a; } if (DMA1->ISR & DMA_ISR_HT5) { DMA1->IFCR = DMA_IFCR_CHT5; /* 后半满:B 可读 */ ready_buf = buf_b; } }

TC5是全满标志,HT5是半满标志,循环模式下这两个中断交替触发,正好对应两块缓冲区。ready_buf用volatile修饰,因为它在中断里写、在主循环里读,编译器不能把它优化进寄存器。主循环拿到指针后要在下一次中断到来之前处理完,处理时间超过半帧时间就会丢数据。

注意:乒乓缓冲解决的是「采集和处理并行」,不解决「RAM 不够」。如果整帧都放不下,乒乓只会让两块半帧交替撕裂,图像质量反而更差。先确认整帧能放下,再考虑乒乓。

5. 五个翻车现场:从全黑到跑一会儿就死

这一章列的都是我在无 FIFO 方案上真实踩过的坑,按现象分类,每条给出原因和解决路径。顺序按排查优先级排,越靠前越应该先查。

5.1 现象:图像全黑或全白,SCCB 读 ID 却正常

原因通常不在 OV7670 本身,而在 XCLK 或者 PCLK 的采样点。XCLK 没输出时,OV7670 内部 PLL 不工作,SCCB 照样能通(SCCB 用的是内部 RC),但像素接口完全不输出,DMA 收回来全是 0。另一种情况是 PCLK 极性搞反了,代码在下降沿采样,正好采在数据跳变的瞬间,收回来全是 0xFF 或 0x00。

解决:先用示波器量 PA8 上有没有 8MHz 方波,没有就回头查 MCO 配置和 RCC 使能。有方波但图像还是全黑,把采样沿从上升沿改到下降沿试一次,或者反过来。OV7670 的 PCLK 默认在上升沿有效,但模组上如果有反相器就不一定了。

5.2 现象:图像斜切,每行起始位置错开几个像素

原因是行同步没对齐。HREF 拉高之后,第一个 PCLK 上升沿对应的不一定是第一个有效像素,中间可能有一两个像素的建立时间。代码里如果 HREF 一高就立刻开始采样,会把行首的无效字节也收进来,累积到行尾就表现为整行右移。

解决:在 HREF 拉高之后先空转丢弃前 2~4 个 PCLK,再开始正式采样。具体丢几个要看模组,用逻辑分析仪同时抓 HREF 和 PCLK,数一下从 HREF 上升沿到第一个稳定数据之间有几个时钟。这个偏移量写成一个宏,换模组时只改这一个数。

5.3 现象:颜色明显偏,红蓝互换或者整体发绿

红蓝互换是 RGB565 的字节序问题。OV7670 输出 RGB565 时先发高字节还是先发低字节,受 TSLB(0x3A)控制,不同批次的默认值不一样。代码里如果固定按「先高后低」拼,遇到先低后高的模组就红蓝互换。

整体发绿通常是 COM15(0x40)的输出范围没配对。0x40 的 bit5:4 控制 RGB 输出范围,00 是全范围 0~255,01 是截断到 16~240。如果配成截断模式又没做映射,中间调会整体偏移,看上去就是发绿发灰。

解决:先改 TSLB 试 0x00 和 0x04 两个值,红蓝对了再调 0x40。调 0x40 时别一次改多位,bit5:4 两位一起动,一次只试一个组合。

5.4 现象:跑几秒钟就卡死,串口没输出

这是中断风暴或者栈溢出。中断风暴发生在 EXTI 方案里,PCLK 频率一高,中断服务函数还没返回下一个中断又来了,主循环永远拿不到 CPU。栈溢出发生在缓冲区开得太大,全局数组加上局部变量超过 RAM,程序跑着跑着就踩到栈区。

解决:先用调试器暂停,看 PC 停在哪个函数。停在中断服务函数里就是中断风暴,把 PCLK 分频加大或者换轮询方案。停在某个局部变量赋值附近就是栈溢出,把大数组改成全局或者static,局部变量能少就少。

5.5 现象:SCCB 读 ID 返回 0x00 或 0xFF

0x00 通常是从机根本没应答,主机读回来的是上拉电阻拉低的值;0xFF 是总线被拉高但没设备响应。两种都指向硬件层:SDA/SCL 接反、上拉电阻没焊、模组供电不足、地址写错。

解决顺序:万用表量 SCL 和 SDA 的静态电平,正常应该都是高(被上拉)。量 OV7670 的 DVDD 和 AVDD 有没有电压。用逻辑分析仪抓一次完整的 SCCB 写时序,看从机有没有在第九位拉低。三步都过了还读不到,换一个模组试,别在一个模组上耗超过半小时。

6. 用逻辑分析仪把时序钉死:验证与调优的几个硬办法

调无 FIFO 的 OV7670,最省时间的投入是买一个几十块的单片机逻辑分析仪。它能同时抓 VSYNC、HREF、PCLK、D0~D7 十根线,把一行的时序完整展开,比盲调寄存器快一个数量级。

我的固定流程是这样的:先抓 VSYNC 和 HREF,确认帧率和行数对不对。QQVGA 一帧应该是 120 个 HREF 脉冲,帧周期在 PCLK 分频为 0x1F 时大概几十毫秒。行数不对,说明 COM7 的分辨率位没配对,或者 VSTART/VSTOP 窗口设错了。

再抓 HREF 和 PCLK,数一行有多少个 PCLK。QQVGA 一行应该是 160 个有效像素,但 OV7670 实际输出的 PCLK 数会比 160 多,多出来的是行首行尾的 dummy 像素。这个差值决定你在 5.2 节里要丢几个字节。我遇到过的模组这个差值从 4 到 40 不等,全靠实测。

最后抓 PCLK 和 D0,看数据在 PCLK 的哪个沿稳定。这一步决定采样沿的选择,也决定 DMA 触发沿的配置。下面这张表是我常用的验证清单:

验证项测量点期望值不符时的动作
XCLK 频率PA8 对地8MHz ±5%查 MCO 配置和 RCC 使能
PCLK 频率PCLK 对地0.2~1MHz(先慢后快)调 CLKRC 0x11
帧周期VSYNC 上升沿间隔与 PCLK 分频匹配查 COM7 分辨率位
每帧行数VSYNC 低期间 HREF 脉冲数120(QQVGA)查 VSTART/VSTOP
每行像素单个 HREF 高期间 PCLK 数≥160查 HSTART/HSTOP
数据稳定沿PCLK 与 D0 同时抓数据在某一沿居中稳定改采样沿或加延时

调优的顺序也有讲究:先把 PCLK 压到最低(CLKRC 拉到 0x1F),把图像调出来,确认 160×120 的轮廓完整、颜色正确。然后逐档减小 CLKRC,每次减 4,重新抓一帧看有没有丢字节。丢字节的典型表现是一行里某个位置开始整体错位,或者行尾出现一段重复数据。找到临界点之后往回退两档,这就是这套硬件+代码组合的稳定上限。

还有一个小技巧:在缓冲区尾部留 16 个字节的哨兵值,抓完一帧后检查哨兵有没有被覆盖。被覆盖说明实际收到的字节数超过预期,DMA 写越界了,这时候要回头查 CNDTR 的设置和 HREF 的边界判定。这个检查我一般写成断言,只在调试版本里开,发布版本关掉。

从那以后我每次调一个新的 OV7670 模组,都强制先走一遍「示波器量 XCLK → 逻辑分析仪抓一行 → 哨兵检查」这三步,不再凭感觉改寄存器。无 FIFO 的坑大多不是原理不懂,而是少了一次实测,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询