简介:这套工程面向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 COM7 | 0x0C | 选择 QQVGA 分辨率、RGB 输出 |
| 0x11 CLKRC | 0x1F | 内部时钟分频拉满,先让 PCLK 足够慢 |
| 0x0C COM3 | 0x00 | 关闭缩放,避免输出被二次采样 |
| 0x3A TSLB | 0x04 | 输出顺序,配合 RGB565 字节序 |
| 0x40 COM15 | 0xD0 | RGB565 全范围输出 |
| 0x8C RGB444 | 0x00 | 关闭 444 截断,保持 565 |
| 0x17 HSTART | 0x13 | 行起始(QQVGA 窗口) |
| 0x18 HSTOP | 0x01 | 行结束高 3 位 |
| 0x32 HREF | 0x80 | HREF 起始边沿位置 |
| 0x19 VSTART | 0x02 | 场起始 |
| 0x1A VSTOP | 0x7A | 场结束 |
| 0x03 VREF | 0x0A | 垂直窗口控制 |
| 0x0E COM5 | 0x01 | 内部 PLL 使能,稳定帧率 |
| 0x1E MVFP | 0x00 | 不镜像不翻转,需要时改这两位 |
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 | 灰度 8bit | YUV422 |
|---|---|---|---|---|
| VGA 640×480 | 307200 | 614400 | 307200 | 614400 |
| QVGA 320×240 | 76800 | 153600 | 76800 | 153600 |
| QQVGA 160×120 | 19200 | 38400 | 19200 | 38400 |
| QCIF 176×144 | 25344 | 50688 | 25344 | 50688 |
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 的坑大多不是原理不懂,而是少了一次实测,希望帮到你。
本文还有配套的精品资源,点击获取