OV7670这块摄像头的资料,网上铺天盖地,但多数人的第一块FPGA图像板卡偏偏就卡在它那个只有两根线的配置接口上。SCCB(Serial Camera Control Bus)是OmniVision定的一套串行控制总线,OV7670内部上百个寄存器全靠在SCCB上写值来设置。很多人以为摄像头接上电、把D0~D7和PCLK、VSYNC、HSYNC接好就能出图,实际情况是上电后OV7670的输出格式、分辨率、增益、曝光全是默认值,未必符合你的RGB565显示需求,必须先把SCCB配置流程跑通,后面的图像采集才有意义。这篇文章就把SCCB的时序细节、FPGA主机实现、配置数据组织以及上板调试经验完整过一遍,适合正在用Verilog调OV7670的开发者,也适合刚入门FPGA图像处理、想搞懂配置总线原理的同学。
1. 一颗摄像头上板,为什么先得解决SCCB
1.1 从“上电就能出图”的误解说起
我第一次调OV7670的时候,也以为摄像头这种东西插上电就该有图像输出。实际把数据线接好、PCLK采起来,屏幕上是一片乱码和雪花,折腾了一个晚上也没找到原因。后来用逻辑分析仪量了一下SCCB的两根线,发现上电之后我压根就没跟OV7670说过一句话,它凭什么按我想要的RGB565格式工作?
OV7670上电后内部寄存器是厂商默认配置,默认输出一般是YUV422格式的VGA图像。你当然可以去设计一个YUV422转RGB的采集链路,但问题远不止格式:默认的增益、曝光、白平衡、镜像、窗口大小不一定适合你的场景,很多时候画面是暗的、偏色的,甚至因为时钟分频配置不对导致PCLK频率异常,采集模块根本采不到稳定的帧。
所以OV7670这类ToF、cmos传感器的使用逻辑都一样:上电之后,先通过控制总线把寄存器初始化好,把分辨率、输出格式、时钟分频、自动增益开关、彩条模式等全部定下来,再开始读像素数据。对OV7670来说,这个控制总线就是SCCB,在它内部由SIO_C和SIO_D两根线组成。SCCB跑不通,后面所有图像处理代码都是空中楼阁。
1.2 SCCB在整条图像链路里的定位
很多人把注意力放在PCLK采样、FIFO缓冲、VGA显示这些“看得见”的模块上,却忽视了SCCB其实是整条链路的地基。你可以把OV7670想象成一台没有面板的数码相机,SCCB就是遥控器;你按遥控器设置白平衡、分辨率、输出格式,相机才按你的要求出图。
从FPGA工程结构上看,SCCB主机模块通常承担三件事:
- 上电后给OV7670的所有关键寄存器写入配置值
- 在需要时读回某个寄存器,验证写入是否成功
- 在运行中切换配置,比如从VGA切到QVGA、从RGB565切到YUV,或者打开彩条测试模式
SCCB主机写完配置后,会输出一个配置完成信号,采集模块收到这个信号再开始采集。很多工程的问题恰恰出在这里:采集模块在SCCB还没配置完成时就启动,结果采到了半张默认格式、半张新格式的“混合帧”,看起来像花屏。
我在自己的模块设计里,规定SCCB配置完成前,采集模块始终保持在复位状态。这个约定看起来简单,却省掉了大量排查花屏的时间。
2. SCCB时序拆解:写一次寄存器,控制器到底在忙什么
2.1 起始、停止、字节与NA位
SCCB物理上就是两根线:SIO_C是时钟,SIO_D是数据。OV7670作为从机,FPGA侧的主机模块负责产生时钟、发起传输。两根线都是开漏结构,外部必须接上拉电阻到3.3V或2.8V,典型值4.7kΩ到10kΩ。
时序上,SCCB和I2C非常接近:
| 事件 | SIO_C | SIO_D |
|---|---|---|
| 空闲 | 高 | 高 |
| 起始(Start) | 保持高 | 高→低 |
| 停止(Stop) | 保持高 | 低→高 |
| 数据位 | 高电平期间必须稳定 | 只在SIO_C为低时变化 |
数据传输是MSB先行,每个字节8位,传完后紧跟一个NA位。NA全称是No Acknowledge,名字容易误导人,但它的作用和I2C的ACK一样:第9个时钟周期里,接收方把SIO_D拉低表示“收到”,保持高表示“没收到”。地址、寄存器地址、数据三个阶段都有各自的NA位。采样NA位必须在第9个时钟的高电平中间,不能在低电平时去采样,否则SIO_D上刚变化的数据还没稳定下来,容易采到不确定电平。
2.2 两阶段写传输和三阶段读传输
SCCB写一个寄存器,是两阶段传输:
起始 + 从机地址(写) + NA + 寄存器地址 + NA + 写数据 + NA + 停止
整个过程里主机一直控制SIO_D,从机只在每个NA位把数据线拉低表示应答。OV7670的从机地址是8位0x42,其中bit0为0表示写。注意这里有个坑:OV7670的7位设备ID是0x21,左移一位变成8位地址0x42。很多初学同学直接把0x21作为8位地址发出去,等于发了个0x21,和OV7670根本不匹配,从机自然不理会。
SCCB读一个寄存器,是三阶段传输:
起始 + 从机地址(写) + NA + 寄存器地址 + NA + 重新起始 + 从机地址(读) + NA + 从机发来8位数据 + 主机回NA + 停止
和写传输的差别在于,发完寄存器地址后不能直接停下来,必须再来一个重新起始(Restart),把从机地址的最低位置1,变成0x43,然后从机接管SIO_D,把寄存器值一个字节一个字节吐出来。主机在第9个时钟回一个NA表示“我已经读够了,别再发了”,然后拉停止。
很多I2C控制器也能配置OV7670,原因就在这里:写传输几乎完全一样,读传输的流程也基本等同于I2C的random read。但如果你要做读回校验,直接抄I2C的状态机可能漏掉重新起始或者NA的回法,导致读回的数据错位。
2.3 SCCB与I2C的差异:写兼容,读要小心
虽然SCCB经常被说成“和I2C兼容”,但严格来讲有几个差异,FPGA实现时值得注意:
- 从机地址写法:I2C里是7位地址加读写位,SCCB文档里直接把这个9位过程定义成“CCA”(Camera Control Address),读写位就是地址的bit0。实际电平完全一样,但理解方式不同,查手册时不要对着I2C的7位地址去换算错。
- 读传输的命名:I2C叫Repeated Start,SCCB叫Restart。功能上等价,都是总线还处于起始后的状态时,再一次把SIO_D从高拉低,重新开始一个传输阶段。
- 多主机仲裁:I2C规范支持多主机挂同一条总线、带仲裁和时钟同步;SCCB设计思路上就是单主机的摄像头控制总线,OV7670永远不会主动发起传输,主机只管发,从机只管回。
- 速率定义:I2C标准模式100kbps、快速模式400kbps;SCCB规范上也把SRB(SCCB Rate of Bus)定为最大400kHz。实际调试时第一次最好用100kHz,配置数据量也不大,几百个字节,慢一点完全无感,却能避开很多边沿问题。
我在实现时没有刻意去区分“SCCB模式”和“I2C模式”,而是直接按SCCB的状态机做:写路径用两阶段,读路径用三阶段,NA采样统一在第9个时钟高电平中间。这样做出来的控制器,后面挂OV5640、挂其他SCCB接口的摄像头也都能用。
3. Verilog实现SCCB主机:状态机、三态IO与时钟分频
3.1 顶层接口:把sda做成inout
先给一个完整的顶层接口定义。SCCB主机模块的核心是scl和sda,scl是输出,sda必须做成inout三态口,因为写和读都要用到。
module sccb_master #( parameter CLK_FREQ = 50_000_000, parameter SCCB_FREQ = 400_000 )( input wire clk, input wire rst_n, // SCCB物理接口 output reg scl, inout wire sda, // 控制接口 input wire start, input wire op_write, // 1:写寄存器, 0:读寄存器 input wire [7:0] dev_addr, // 从机地址,OV7670写地址0x42 input wire [7:0] reg_addr, // 寄存器地址 input wire [7:0] wr_data, // 写入数据 output reg done, // 一次传输完成 output reg [7:0] rd_data, // 读回数据 output reg ack_error // 从机NA异常 );sda的三态处理是SCCB模块最容易出错的地方。标准写法是:
reg sda_oe; reg sda_out; wire sda_in; assign sda = sda_oe ? sda_out : 1'bz; assign sda_in = sda;sda_oe为1时,FPGA驱动SIO_D;为0时,释放总线,由外部上拉电阻拉高,这时如果从机拉低,FPGA通过sda_in读到低电平。NA采样必须在sda_oe拉低之后进行,而且要给数据线一个稳定时间,不能在释放总线的同一个时钟周期立刻采样。
3.2 写路径状态机
我习惯把所有读写路径放在同一个状态机里,状态定义如下:
localparam S_IDLE = 5'd0; localparam S_START = 5'd1; localparam S_WR_ADDR = 5'd2; // 发送从机地址(写) localparam S_ACK_ADDR = 5'd3; localparam S_WR_REG = 5'd4; // 发送寄存器地址 localparam S_ACK_REG = 5'd5; localparam S_WR_DATA = 5'd6; // 发送8位数据 localparam S_ACK_DATA = 5'd7; localparam S_RESTART = 5'd8; localparam S_RD_ADDR = 5'd9; // 发送从机地址(读) localparam S_ACK_RD = 5'd10; localparam S_RD_DATA = 5'd11; // 接收8位数据 localparam S_NA = 5'd12; // 主机回NA localparam S_STOP = 5'd13; localparam S_DONE = 5'd14;写操作走:IDLE → START → WR_ADDR → ACK_ADDR → WR_REG → ACK_REG → WR_DATA → ACK_DATA → STOP → DONE。
读操作走:IDLE → START → WR_ADDR → ACK_ADDR → WR_REG → ACK_REG → RESTART → RD_ADDR → ACK_RD → RD_DATA → NA → STOP → DONE。
发送每个字节时,内部用一个bit_cnt从0数到7,一个SCL周期发送一位,第8个SCL周期结束之后,紧接着进入ACK阶段。发送数据时只能在SCL低电平期间改变SDA,SCL高电平期间必须保持稳定。如果这个顺序搞反,从机采样到的就是错误数据。
这里有一个容易忽略的细节:起始条件要求SCL为高时SDA由高变低,所以进入S_START状态时,先要把SDA拉低,再把SCL拉低,然后才能开始发送第一个字节。停止条件则相反:先把SCL拉低,SDA拉低,然后把SCL拉高,最后把SDA释放/拉高。在S_STOP状态里,SDA的上升沿必须发生在SCL高电平期间,否则从机不认为这是停止条件。
3.3 读路径的restart和NA
读路径的S_RESTART状态,其实就是要重复一次起始条件的电平变化:在SCL为高时把SDA从低拉高,然后再拉低,之后把SCL拉低,准备发送读地址0x43。关键是不能在中间插入停止条件,一旦插入了停止,本次读传输就结束了,OV7670不会再理你后面的读地址。
读数据阶段S_RD_DATA和发送阶段不同,FPGA要释放总线,让从机驱动SDA。也就是说,进入S_RD_DATA之前就要把sda_oe拉低。每个SCL高电平中间采样一次sda_in,把8个比特收进来。接收完8位后,主机需要一个NA:把sda_oe置1、sda_out置1,释放总线由上拉拉高,或者干脆主动输出高电平。第9个时钟过去后,再进入停止条件。
我在调试中还发现,读数据阶段如果忘了把sda_oe拉低,FPGA会一直驱动SDA,从机拉低的应答信号和FPGA输出的电平打架,轻则读回全0xFF,重则把从机状态机搞乱。排查这类问题最好的工具是逻辑分析仪,直接看SDA在NA位到底是谁在驱动。
3.4 时钟分频与信号采样位置
SCCB时钟和系统时钟的关系,我建议用分频使能而不是分频时钟。很多刚入门的写法是先用pll生成一个400kHz时钟,直接接到scl引脚上,然后在scl上升沿的always块里改sda。这种做法综合后容易出现时钟毛刺,而且跨时钟域采样sda_in容易产生亚稳态。
我的做法是:系统时钟50MHz,用一个计数器分频产生scl_en脉冲,400kHz时每125个系统时钟产生一次。scl信号本身是寄存器输出,在scl_en脉冲到来时根据状态机的阶段拉高或拉低。这样scl是干净的寄存器输出,不是到处穿过的分频时钟。
发送每一位数据时,节奏大致是:
- 第一个scl_en脉冲:SCL为低,更新SDA数据
- 第二个scl_en脉冲:SCL拉高,SDA保持稳定
- 第三个scl_en脉冲:SCL拉低,准备下一位
这样一遍下来,每一位用2到3个SCL周期完成,400kHz只是标称值,实际稍慢一点没关系,OV7670对SCL频率的上限是400kHz,下限没有严格要求,配置几百个字节很快。
4. 配置数据如何组织:寄存器ROM、上电时序和回读校验
4.1 寄存器表存在哪里
SCCB主机模块本身只负责传输,不关心要配哪些寄存器。配置序列应该放在一个单独的加载器模块里,或者更简单地,直接做成ROM数组。
我常用两种方式:
第一种,把寄存器表写成parameter二维数组。这种方式直观,适合几十条记录的小表。
reg [15:0] cfg_rom [0:20]; initial begin cfg_rom[0] = 16'h1280; // COM7 = 0x80,复位寄存器 cfg_rom[1] = 16'h1204; // COM7 = 0x04,RGB输出,VGA分辨率 cfg_rom[2] = 16'h40D0; // COM15 = 0xD0,RGB565格式 cfg_rom[3] = 16'h3D03; // COM13 = 0x03,输出时序参数 cfg_rom[4] = 16'h1500; // COM10 = 0x00,默认PCLK极性 cfg_rom[5] = 16'h13BF; // COM8 = 0xBF,打开AGC/AEC/AWB end第二种,把配置表放到外部hex文件,用$readmemh加载。工程规模变大后,这个方式更好维护,想切换分辨率、想开彩条测试,只需要改文件内容,不用改状态机代码。
加载器的工作逻辑很简单:每拍从ROM取一条记录,高8位是寄存器地址,低8位是数据,通过sccb_master发出,等done信号回来后再取下一条。全部发完后输出config_done。这个模块要注意,每条记录之间最好加几个空闲周期,不要上一跳刚结束立刻发起下一跳,给OV7670内部一点处理时间。
以我调试时常用的一组核心寄存器为例:
| 寄存器地址 | 寄存器名 | 常用值 | 配置目的 |
|---|---|---|---|
| 0x12 | COM7 | 0x80 | 复位所有SCCB寄存器 |
| 0x12 | COM7 | 0x04 | RGB输出,VGA分辨率 |
| 0x40 | COM15 | 0xD0 | 输出RGB565 |
| 0x3D | COM13 | 0x03 | 输出时序模式 |
| 0x15 | COM10 | 0x00 | 默认PCLK/HSYNC/VSYNC极性 |
| 0x13 | COM8 | 0xBF | 打开自动增益、自动曝光、自动白平衡 |
| 0x11 | CLKRC | 按XCLK计算 | 内部时钟分频 |
这不算完整初始化表,真正工程里通常有一百多条。但看懂了这些核心寄存器,后面拿到任何一份初始化表,你都能知道它在干什么。需要特别提醒的是CLKRC分频值:不同版本手册、不同XCLK晶振,计算方式略有差异。我的习惯是先照抄一份验证过的开源工程配置,跑通后看PCLK实际波形再微调,而不是拿着手册闷头算半天。
4.2 上电后多久可以开始配置
OV7670对SCCB配置的时机很讲究。上电之后,XVCLK要先稳定,SCCB才能操作。我一般会等10ms以上再发起配置。更稳妥的流程是:
- 上电,OV7670的XVCLK正常输入
- 等待至少10ms
- 先写0x12=0x80,让OV7670内部寄存器全部复位
- 等待5ms到10ms,再开始写正式配置序列
为什么要分两步?因为0x12=0x80这个复位操作会清掉前面写入的所有寄存器值,如果你写完整个寄存器表才发现漏了这一步,等于白写。很多同学的板子显示不出画面,就是因为配置序列第一条没写复位,或者复位之后没有延时,紧接着写正式配置,结果OV7670内部的复位时序把配置冲掉了。
如果FPGA里不方便写大延时,可以把延时计数器和SCCB加载器合到一起:加载器进入一条特殊记录时,不发送数据,而是启动一个计数器,延时结束后再取下一条。比如复位配置写完后,插入一个“延时5ms”的空操作,整体流程就顺了。
4.3 读回校验:把回读当作调试工具
很多工程只写寄存器,从来不读寄存器。这在SCCB能正常工作的时候没问题,但一旦画面异常,你就得猜是配置没写进去,还是采集模块有问题。我建议在加载器里加一个可选的读回校验功能:写完0x12之后,立刻读一次0x12,确认读回值是0x04(RGB VGA模式)。
读回校验的实现也不复杂,调用sccb_master的读路径,把读回数据和期望值比较。如果不匹配,拉高一个error信号,点亮板上的LED,或者把出错寄存器的编号通过串口发出来。这样定位问题非常快:如果0x12读回来不是0x04,那根本不是采集模块的事,是SCCB链路没通。
调试的时候还可以用OV7670自带的彩条模式。把COM7的bit0置1,也就是0x12=0x05(RGB输出+彩条),摄像头会直接输出标准的彩条信号,不需要镜头、不需要光照。我每次调试新板卡,都会先配成彩条模式,确认SCCB能写、格式能配、采集链路能出图了,再关掉彩条、装镜头看真实场景。
5. 从无波形到正常出图:一套完整的调试路径
5.1 SCL整天不动,先怀疑复位和时钟
把逻辑分析仪或者示波器探针放到SCL引脚上,如果发现SCL纹丝不动,高也不高、低也不低,或者恒高、恒低,先从下面几条开始查:
- rst_n是不是一直被拉低了?很多FPGA开发板上的复位按键是异步复位,按下时低电平,但不按的时候可能悬空,必须接上拉电阻
- 系统时钟有没有起来?如果状态机根本没法跳转,SCL自然没有任何波形
- start信号是不是只给了一个时钟周期?如果顶层只把start拉高了一个周期,而状态机在IDLE状态没有锁存这个请求,那它就永远在等下一次start
- 引脚分配有没有错?我踩过一次,把scl分配到了板上一个被其他外设驱动的引脚上,导致波形完全不对
其中start锁存这个点,是最常见的初学者问题。解决办法是在sccb_master内部对start做一次边沿检测,或者把start设计成电平使能信号,加载器在发起传输时一直保持start为高,直到done信号回来再拉低。这样状态机永远不会错过请求。
5.2 有波形但回读不对,检查上拉和从机地址
SCL有波形,说明主机模块在跑了。接下来把SDA也挂上逻辑分析仪,观察起始、停止和NA位。如果发现N A位一直采不到低电平,回读数据全是0xFF,优先排查这几件事:
- 从机地址:确认发的是0x42(写)和0x43(读),不要拿0x21当8位地址直接发
- SIO_C和SIO_D是否都有上拉电阻:OV7670模块板上一般自带,但如果是自己画的板子,忘了上拉会导致总线状态不定
- OV7670有没有正常工作:XCLK有没有输入、供电是不是正常,没有时钟的OV7670完全不参与SCCB通信
- 复位延时够不够:上电后直接就开始配,很可能第一条就写失败
这里分享一个我排查过的真实案例:板子上XCLK没有接,SCL波形正常,SDA也跳得很欢,但OV7670就像个聋子,所有寄存器写进去都石沉大海。后来用示波器一量XCLK引脚,电平基本为0,接上XCLK之后立刻就能配置了。SCCB主机波形正常不一定代表摄像头在线,因为如果从机不应答,主机也会照常跑完整个状态机,只是每个NA位都读到高电平而已。
5.3 能出图但颜色怪异,去查寄存器表和PCLK极性
到了这一步,SCCB基本是通的,画像也出来了,但颜色不对、画面偏移。这时候不要再去怀疑SCCB了,重点查格式配置和采集端拼接。
颜色偏蓝、偏红,先检查COM7和COM15是不是配成了RGB565,再检查TSLB寄存器里的字节顺序。OV7670输出RGB565时,两个字节的先后顺序由TSLB和相关位控制;如果采集端把高字节和低字节拼反了,颜色就会错乱。我见过一个工程,SCCB配置完全正确,但采集模块把先到的字节放在了低8位,导致图像颜色明显不对,改一下拼接顺序立刻正常。
画面滚动、偏移,大概率是HSTART/HSTOP、VSTART/VSTOP这些窗口寄存器没配对,或者HSYNC、VSYNC极性反了。COM10可以配置这些同步信号的极性,默认值0x00表示不反转。如果你的采集模块是在VSYNC上升沿开始采集,而OV7670给的是下降沿有效,那就得在采集端或者COM10里统一。
按照我的习惯,出图的调试顺序是:先用彩条模式确认格式链路没问题,再关彩条看真实图像;先出图像,再追求清晰度;先固定30fps,再优化分辨率。顺序一乱,你会把很多问题混在一起,越查越晕。
6. 把SCCB控制器真正变成自己模块库的一部分
6.1 支持多从机、突发写和多分辨率切换
SCCB主机模块做好之后,别急着丢进抽屉,稍微抽象一下就能复用到其他工程。当前这个sccb_master已经把dev_addr、reg_addr、wr_data都作为输入了,上层加载器可以很方便地扩展:
- 多从机:OV7670的设备ID引脚,部分封装可以通过外部拉高拉低改变地址。同一总线上多挂几个摄像头时,只需要在加载器里按需切换dev_addr
- 多分辨率切换:把VGA、QVGA、RGB565、YUV422各存一份寄存配置表,运行时根据外部按键或者上位机指令选择加载哪张表
- 批量连续配置:加载器加一个状态计数器,自动从ROM逐条取出记录发送,发送完所有记录后置config_done
我在实际工程里还会在加载器里加入一个“心跳”输出,每配置成功一条寄存器就翻转一次某个LED,调试时能看到进度,不至于配置卡死时一头雾水。
6.2 与采集模块的握手设计
SCCB模块和图像采集模块的握手,我强烈建议用config_done信号,而不是让采集模块自己猜。最简单的握手逻辑是:
- 上电后SCCB加载器开始配置,busy信号拉高
- 配置完成,config_done拉高,busy拉低
- 图像采集模块只有等到config_done之后才走出复位状态,开始等待VSYNC、采样PCLK
这个设计可以避免一种很隐蔽的bug:SCCB配置过程中,OV7670的输出格式是变化的,采集模块如果一直在跑,可能把格式切换中的半帧数据当成有效帧存入FIFO,显示出来就是一条花屏横带。config_done握手之后,这种问题从根上消失。
另外,我习惯给SCCB加载器加一个软复位输入。分辨率切换、彩条测试、长时间运行后重新初始化,都可以直接软复位重置加载器,重新加载配置表,不需要重新上电。这个操作在调试镜头和图像效果时非常实用,配合LED心跳,按一下按键就能切换配置,效率比反复烧写比特流高得多。
如果只是照抄别人的寄存器表,你很难知道自己卡在哪一步;把SCCB主机、ROM加载器、LED调试灯这几个小模块做扎实,后面换OV5640、换分辨率都只是改寄存器表的事。SCCB这个基础打通了,FPGA图像处理这条路才算真正站在了起跑线上。