3、摄像头驱动接口设计:SCCB/I2C总线协议、摄像头寄存器配置、HLS实现I2C控制器

摄像头驱动,说白了就是让摄像头芯片听话。你给它供电、给时钟,它不会自己就开始工作。你得通过一根两线的总线,告诉它:输出多大分辨率、帧率多少、曝光时间多长。这根总线,就是SCCB或I2C。

我个人习惯把SCCB看成I2C的“近亲”。OV系列摄像头用SCCB,但绝大多数FPGA的I2C控制器可以直接兼容。嗯,这里要注意:SCCB的“无应答”机制和I2C略有不同,后面我会细说。

3.1 SCCB与I2C总线协议

先看一张图,这是我画的总线时序对比。左边是I2C,右边是SCCB。你一眼就能看出差别。

I2C vs SCCB 时序对比 I2C 写时序 SCL SDA S ADDR[6:0] R/W ACK DATA[7:0] ACK SCCB 写时序 SIO_C SIO_D S ID[6:0] W X DATA[7:0] X I2C:每个字节后都有ACK(从机应答) SCCB:写操作无应答,读操作有应答

看到没?I2C每个字节后面都有个ACK位,从机拉低表示收到了。SCCB呢?写操作时,从机根本不回应。你发完数据就完事了。读操作时,主机需要发送一个“NA”位(高电平)来结束传输。

我在项目中遇到过一个问题:用标准I2C控制器去读写OV5640,结果读回来的数据全是0xFF。查了半天,发现是SCCB的读时序里,主机在最后一个字节后需要发送NACK,而我的I2C控制器自动发了ACK。摄像头一看:你还在确认?那我继续发数据。结果总线就乱套了。

核心区别总结:

  • 应答机制:I2C每字节后ACK;SCCB写无应答,读有NACK
  • 起始/停止:I2C用SDA/SCL边沿组合;SCCB用SIO_D的跳变
  • 速率:I2C标准100kHz/400kHz;SCCB通常100kHz~400kHz
  • 地址位宽:I2C 7位或10位;SCCB固定7位+1位读写

3.2 摄像头寄存器配置

摄像头芯片内部有几百个寄存器。你要配置的,其实就那么几个关键组:

寄存器组 典型地址 作用 我踩过的坑
系统控制 0x3000~0x300F 软复位、时钟分频、PLL配置 复位后要等至少10ms才能写其他寄存器
分辨率设置 0x3800~0x380F 水平/垂直起始、结束、输出尺寸 起始坐标和结束坐标要算对,否则图像偏移
ISP控制 0x5000~0x500F 自动曝光、白平衡、增益 关闭自动功能时,手动值要合理
帧率控制 0x4200~0x420F VTS/HTS设置 VTS太小会导致帧率异常

举个例子,配置OV5640输出640x480分辨率,你需要写这么一串:

// OV5640 640x480 配置序列(部分关键寄存器)
0x3103, 0x11    // 系统时钟使能
0x3008, 0x82    // 软复位(需要等待10ms)
0x3800, 0x00    // 水平起始高字节
0x3801, 0x00    // 水平起始低字节
0x3802, 0x00    // 垂直起始高字节
0x3803, 0x00    // 垂直起始低字节
0x3804, 0x0A    // 水平结束高字节
0x3805, 0x3F    // 水平结束低字节 (0x0A3F = 2623)
0x3806, 0x07    // 垂直结束高字节
0x3807, 0x9F    // 垂直结束低字节 (0x079F = 1951)
0x3808, 0x02    // 输出宽度高字节
0x3809, 0x80    // 输出宽度低字节 (0x0280 = 640)
0x380A, 0x01    // 输出高度高字节
0x380B, 0xE0    // 输出高度低字节 (0x01E0 = 480)

你想想看,如果我不告诉你这些寄存器地址的含义,你对着数据手册一个个查,得花多少时间?我建议你把常用摄像头的寄存器配置做成头文件,像这样:

// ov5640_regs.h
#define OV5640_ADDR     0x3C
#define REG_SYSTEM_CTRL 0x3008
#define REG_HSTART_H    0x3800
#define REG_HSTART_L    0x3801
#define REG_VSTART_H    0x3802
#define REG_VSTART_L    0x3803
#define REG_HEND_H      0x3804
#define REG_HEND_L      0x3805
#define REG_VEND_H      0x3806
#define REG_VEND_L      0x3807
#define REG_OUTW_H      0x3808
#define REG_OUTW_L      0x3809
#define REG_OUTH_H      0x380A
#define REG_OUTH_L      0x380B

我的小技巧: 配置摄像头时,先写一个“初始化序列”,包含软复位和时钟配置。然后单独写“分辨率切换”函数。这样你调试时,只需要改分辨率参数,不用每次都从头初始化。

3.3 HLS实现I2C控制器

用HLS写I2C控制器,说白了就是把时序翻译成C代码。我习惯用状态机的方式,清晰又好调试。

先看顶层接口:

// i2c_master.h
#include "ap_int.h"
#include "hls_stream.h"

typedef enum {
    IDLE,
    START,
    SEND_ADDR,
    SEND_REG,
    SEND_DATA,
    STOP,
    WAIT_ACK
} i2c_state_t;

void i2c_master(
    ap_uint<7>  dev_addr,    // 设备地址
    ap_uint<16> reg_addr,    // 寄存器地址
    ap_uint<8>  data_in,     // 要写入的数据
    ap_uint<1>  rw,          // 0:写 1:读
    ap_uint<8>  *data_out,   // 读出的数据
    ap_uint<1>  *busy,       // 忙标志
    ap_uint<1>  *error,      // 错误标志
    // 物理接口
    ap_uint<1>  &scl,
    ap_uint<1>  &sda_in,
    ap_uint<1>  &sda_out,
    ap_uint<1>  &sda_oe
);

核心逻辑是这样的:

// i2c_master.cpp - 核心状态机
void i2c_master(...) {
    #pragma HLS INTERFACE ap_ctrl_none port=return
    #pragma HLS INTERFACE ap_none port=scl
    #pragma HLS INTERFACE ap_none port=sda_in
    #pragma HLS INTERFACE ap_none port=sda_out
    #pragma HLS INTERFACE ap_none port=sda_oe
    
    static i2c_state_t state = IDLE;
    static ap_uint<4> bit_cnt = 0;
    static ap_uint<16> shift_reg;
    
    switch(state) {
        case IDLE:
            scl = 1;
            sda_oe = 1;
            sda_out = 1;
            *busy = 0;
            if(start) {
                state = START;
                *busy = 1;
            }
            break;
            
        case START:
            // 产生起始条件:SCL高时SDA从高变低
            scl = 1;
            sda_out = 0;
            state = SEND_ADDR;
            bit_cnt = 0;
            // 将地址+读写位装入移位寄存器
            shift_reg = (dev_addr << 1) | (rw & 0x01);
            shift_reg = (shift_reg << 8) | reg_addr;
            break;
            
        case SEND_ADDR:
            // 发送设备地址+读写位
            scl = 0;
            sda_out = shift_reg[15];
            shift_reg = shift_reg << 1;
            bit_cnt++;
            if(bit_cnt == 8) {
                state = WAIT_ACK;
                sda_oe = 0;  // 释放SDA,等待应答
            }
            break;
            
        // ... 后续状态类似
    }
}

曾经踩过的坑: 我曾经在HLS里直接用 sda = 0 这样的赋值,结果综合出来是三态门冲突。记住:I2C的SDA是开漏输出,必须用 sda_oe 控制输出使能,sda_out 控制输出值。读的时候把 sda_oe 置0,让外部上拉电阻拉高,然后读 sda_in。

完整的I2C控制器,我建议你包含以下功能:

  • 单字节写:发送设备地址+寄存器地址+数据
  • 单字节读:发送设备地址+寄存器地址,然后重新发送起始条件+读命令
  • 多字节读写:连续读写多个寄存器(摄像头配置常用)
  • 超时检测:防止总线卡死

最后,给你一个测试用的顶层代码片段:

// test_i2c.cpp - 测试用顶层
void test_i2c(
    ap_uint<7>  cam_addr,
    ap_uint<16> reg,
    ap_uint<8>  val,
    ap_uint<1>  &scl,
    ap_uint<1>  &sda
) {
    #pragma HLS INTERFACE ap_ctrl_none port=return
    #pragma HLS INTERFACE ap_none port=scl
    #pragma HLS INTERFACE ap_none port=sda
    
    ap_uint<8> read_back;
    ap_uint<1> busy, error;
    
    // 写寄存器
    i2c_master(cam_addr, reg, val, 0, &read_back, &busy, &error, scl, sda, sda, sda);
    // 等待写完成
    while(busy);
    
    // 读回来验证
    i2c_master(cam_addr, reg, 0, 1, &read_back, &busy, &error, scl, sda, sda, sda);
    while(busy);
    
    // 如果read_back != val,说明配置失败
}

嗯,到这里,摄像头驱动接口的核心内容就讲完了。你只要把I2C控制器调通,剩下的就是对着数据手册填寄存器值了。记住:先读ID寄存器确认通信正常,再配置其他参数。这是最稳妥的调试步骤。


可可精品资料库