3. 传感器驱动开发:I2C/SPI配置寄存器、初始化序列编写、PLL与时钟树配置

摄像头传感器不是焊上去就能用的。你得跟它“对话”,告诉它怎么工作。这个对话,就是通过 I2C 或 SPI 总线来读写寄存器。说白了,传感器就是一堆寄存器,你往里面写特定的值,它就按你的要求干活。

我刚开始做摄像头驱动时,总觉得这步很简单。结果呢?图像出来全是花的,或者干脆没信号。后来才明白,寄存器配置的顺序、时序、甚至某个 bit 的延迟,都能决定成败。这一章,咱们就把这些坑填上。

3.1 通信接口选型:I2C vs SPI

大部分摄像头传感器都支持 I2C 和 SPI 两种配置接口。怎么选?我列个表,你一看就明白。

对比项 I2C SPI
引脚数 2 根线(SDA, SCL) 4 根线(MOSI, MISO, SCLK, CS)
速度 标准 100kHz / 快速 400kHz 通常 10MHz ~ 50MHz
寻址方式 7位或10位设备地址 片选信号(CS)选择设备
多设备支持 总线挂载多个设备,地址不能冲突 每个设备独立CS,数量受GPIO限制
典型场景 配置寄存器、读取状态 配置寄存器 + 高速数据读取

我个人习惯:配置寄存器用 I2C。因为引脚少,布线方便。除非传感器要求 SPI 才能达到某个配置速度,或者你需要同时读写大量寄存器,否则 I2C 足够了。

小技巧: 很多传感器芯片的 I2C 地址可以通过引脚电平修改。比如 OV2640,默认地址是 0x60,拉高某个引脚就变成 0x62。设计电路时留个上拉/下拉电阻,万一地址冲突还能改。

3.2 I2C 寄存器读写:从时序到代码

I2C 的读写流程其实很固定。写一个寄存器,就是:起始信号 → 设备地址(写)→ 寄存器地址 → 数据 → 停止信号。读稍微复杂点,要先写寄存器地址,再重新发起始信号和设备地址(读),然后收数据。

嗯,这里要注意:很多传感器在连续读取时,地址会自动递增。你只需要发一次寄存器地址,后面连续读就行。但有些老芯片不会自动递增,你得每个字节都重新发地址。我踩过这个坑,读回来的数据全是同一个寄存器的值,图像颜色完全不对。

下面是一个典型的 I2C 写寄存器函数,我用的是 Linux 用户态 i2c-dev 接口:

#include <linux/i2c-dev.h>
#include <sys/ioctl.h>
#include <fcntl.h>
#include <unistd.h>

int sensor_write_reg(int fd, uint8_t dev_addr, uint16_t reg_addr, uint8_t data) {
    struct i2c_msg msg;
    struct i2c_rdwr_ioctl_data ioctl_data;
    uint8_t buf[3];

    // 假设寄存器地址是16位,高字节在前
    buf[0] = (reg_addr >> 8) & 0xFF;
    buf[1] = reg_addr & 0xFF;
    buf[2] = data;

    msg.addr = dev_addr;
    msg.flags = 0;          // 写操作
    msg.len = 3;
    msg.buf = buf;

    ioctl_data.msgs = &msg;
    ioctl_data.nmsgs = 1;

    if (ioctl(fd, I2C_RDWR, &ioctl_data) < 0) {
        perror("I2C write failed");
        return -1;
    }
    return 0;
}

这段代码看起来简单,但有个细节:寄存器地址的字节序。有些传感器用 8 位地址,有些用 16 位。16 位地址里,还有高字节在前和低字节在前的区别。你得看数据手册,千万别想当然。

避坑指南: 我曾经在一个项目里,把寄存器地址的字节序搞反了。结果写进去的值全跑到了错误的地址上,传感器直接“死机”。排查了整整两天,最后用逻辑分析仪抓波形才发现。从那以后,我每次写 I2C 驱动,第一件事就是用逻辑分析仪确认地址字节序。

3.3 SPI 配置:速度与模式

SPI 比 I2C 快,但配置项也多一些。主要是 时钟极性(CPOL)和时钟相位(CPHA)。这两个参数决定了数据在时钟的哪个边沿采样。

  • CPOL=0:空闲时时钟为低电平
  • CPOL=1:空闲时时钟为高电平
  • CPHA=0:在第一个时钟边沿采样数据
  • CPHA=1:在第二个时钟边沿采样数据

大部分摄像头传感器用 Mode 0(CPOL=0, CPHA=0)或 Mode 3(CPOL=1, CPHA=1)。你想想看,为什么?因为这两种模式下,数据都是在时钟的上升沿采样,下降沿变化。很多 MCU 的 SPI 外设默认就支持这两种模式。

SPI 写寄存器的流程跟 I2C 类似,但多了一个片选信号:

int sensor_spi_write_reg(int spi_fd, uint8_t reg_addr, uint8_t data) {
    uint8_t tx_buf[2];

    // 很多传感器SPI写操作,寄存器地址的最高位要置1表示写
    tx_buf[0] = reg_addr | 0x80;
    tx_buf[1] = data;

    struct spi_ioc_transfer tr = {
        .tx_buf = (unsigned long)tx_buf,
        .rx_buf = 0,
        .len = 2,
        .speed_hz = 10000000,   // 10MHz
        .delay_usecs = 0,
        .bits_per_word = 8,
        .cs_change = 0,
    };

    if (ioctl(spi_fd, SPI_IOC_MESSAGE(1), &tr) < 0) {
        perror("SPI write failed");
        return -1;
    }
    return 0;
}

注意那个 reg_addr | 0x80。很多 SPI 传感器的协议里,地址字节的最高位用来区分读写。1 表示写,0 表示读。这个细节很容易被忽略,但错了就完全不通。

3.4 初始化序列:顺序决定成败

传感器的初始化不是随便写一堆寄存器值就完事的。它有一个严格的上电时序和初始化序列。我一般把初始化分成几个阶段:

  1. 硬件复位:拉低复位引脚至少 1ms,然后释放。
  2. 等待稳定:释放复位后,等待 10-20ms,让内部 PLL 和时钟稳定。
  3. 软件复位:写软件复位寄存器(通常是 0x12 或 0xFF),让传感器进入已知状态。
  4. PLL 和时钟配置:先配时钟源和分频,再配 PLL。
  5. 输出格式配置:分辨率、帧率、数据格式(YUV/RGB/RAW)。
  6. 图像效果配置:增益、曝光、白平衡等。
  7. 启动输出:写启动寄存器,开始输出图像数据。

这个顺序不能乱。比如,你还没配 PLL 就去配输出格式,传感器可能根本不响应,因为内部时钟还没跑起来。

关键经验: 初始化序列里的每个寄存器写操作之间,最好加一点小延迟(1-5ms)。有些传感器内部状态机切换需要时间,连续写太快会丢寄存器。我习惯在每个写操作后加一个读回验证,确保写进去了再写下一个。

3.5 PLL 与时钟树:让传感器跑在正确频率上

摄像头传感器内部有一个复杂的时钟树。外部晶振通常是 24MHz 或 27MHz,但传感器内部需要多种频率:像素时钟、MIPI 时钟、内部逻辑时钟。这些频率都是通过 PLL 倍频和分频得到的。

我画了一张典型的传感器时钟树结构图,你看完就明白了:

摄像头传感器时钟树结构 外部晶振 24MHz / 27MHz PLL 倍频 ×N / ÷M 系统时钟分频 ÷P 参考时钟 VCO 输出 像素时钟 PCLK MIPI 时钟 MIPI_CLK 内部逻辑时钟 LOGIC_CLK 时钟树配置要点: 1. PLL 输出频率 = 晶振频率 × N / M,不能超过传感器最大 VCO 频率 2. 像素时钟 PCLK = PLL 输出 / P,决定了帧率和分辨率 3. MIPI 时钟通常需要是像素时钟的整数倍(如 4x 或 8x) 4. 所有时钟频率必须在数据手册规定的范围内,否则图像会出问题

配置 PLL 时,你通常需要设置三个参数:倍频系数(N)、分频系数(M)、后分频系数(P)。公式很简单:

PLL 输出 = 晶振频率 × N / M

像素时钟 = PLL 输出 / P

举个例子。假设晶振是 24MHz,你想让传感器输出 1080p@30fps。查数据手册发现,这个模式下像素时钟需要 74.25MHz。那 PLL 怎么配?

  • 选 N=148, M=48,PLL 输出 = 24 × 148 / 48 = 74MHz
  • 或者 N=99, M=32,PLL 输出 = 24 × 99 / 32 = 74.25MHz(更精确)

你看,不同的 N/M 组合能得到接近的频率。但要注意,PLL 的 VCO 频率有范围限制。比如某个传感器要求 VCO 在 400MHz~800MHz 之间。那你的 PLL 输出(VCO 频率)必须落在这个区间内。如果算出来是 74MHz,那说明还需要一个后分频器把 VCO 降下来。

避坑指南: 我曾经在一个项目里,PLL 配置算错了,VCO 频率跑到了 900MHz,超出传感器上限。结果图像出现严重的条纹干扰,而且传感器发热严重。后来用示波器量 MIPI 时钟,发现频率完全不对。所以,配完 PLL 一定要用示波器或逻辑分析仪验证时钟频率,别光靠算。

3.6 实战:初始化序列代码示例

下面是一个完整的传感器初始化序列示例。我以 OV5640 为例,它是一款常用的 500 万像素摄像头传感器。

// OV5640 初始化序列(简化版)
typedef struct {
    uint16_t reg;
    uint8_t val;
} sensor_reg_t;

// 初始化寄存器表
sensor_reg_t ov5640_init_seq[] = {
    // 1. 软件复位
    {0x3008, 0x80},     // 软件复位寄存器
    // 等待 20ms
    {0xFFFF, 0x00},     // 延迟标记

    // 2. 配置 PLL
    {0x3034, 0x1A},     // PLL 配置:倍频系数
    {0x3035, 0x21},     // PLL 配置:分频系数
    {0x3036, 0x46},     // PLL 配置:VCO 分频
    {0x3037, 0x08},     // PLL 配置:后分频

    // 3. 配置输出格式(1080p@30fps, YUV)
    {0x3800, 0x00},     // 水平起始高字节
    {0x3801, 0x00},     // 水平起始低字节
    {0x3802, 0x00},     // 垂直起始高字节
    {0x3803, 0x00},     // 垂直起始低字节
    {0x3804, 0x0A},     // 水平结束高字节
    {0x3805, 0x3F},     // 水平结束低字节 (0x0A3F = 2623)
    {0x3806, 0x07},     // 垂直结束高字节
    {0x3807, 0x9F},     // 垂直结束低字节 (0x079F = 1951)
    {0x3808, 0x07},     // 输出宽度高字节
    {0x3809, 0x80},     // 输出宽度低字节 (0x0780 = 1920)
    {0x380A, 0x04},     // 输出高度高字节
    {0x380B, 0x38},     // 输出高度低字节 (0x0438 = 1080)

    // 4. 配置数据格式
    {0x4300, 0x30},     // YUV 格式,YUV422

    // 5. 启动输出
    {0x3008, 0x02},     // 启动传感器输出
};

void sensor_init(int i2c_fd, uint8_t dev_addr) {
    int i = 0;
    while (ov5640_init_seq[i].reg != 0xFFFF || 
           ov5640_init_seq[i].val != 0x00) {
        
        if (ov5640_init_seq[i].reg == 0xFFFF) {
            // 延迟标记
            usleep(20000);  // 20ms
        } else {
            sensor_write_reg(i2c_fd, dev_addr, 
                           ov5640_init_seq[i].reg, 
                           ov5640_init_seq[i].val);
            // 读回验证
            uint8_t readback;
            sensor_read_reg(i2c_fd, dev_addr, 
                          ov5640_init_seq[i].reg, &readback);
            if (readback != ov5640_init_seq[i].val) {
                printf("Error: reg 0x%04X write failed!\n", 
                       ov5640_init_seq[i].reg);
                // 重试或报错
            }
            usleep(1000);  // 1ms 延迟
        }
        i++;
    }
    printf("Sensor initialized successfully.\n");
}

这段代码里,我加了一个读回验证。每次写完寄存器,马上读回来检查。如果值不对,说明通信有问题或者传感器没准备好。这个习惯帮我省了很多调试时间。

小技巧: 初始化序列里的延迟可以用一个特殊标记(比如 0xFFFF)来表示。这样整个序列可以放在一个数组里,代码更简洁。另外,建议把初始化序列做成外部文件(如 .h 或 .cfg),方便不同型号的传感器复用。

3.7 调试技巧:用逻辑分析仪抓 I2C/SPI 波形

写驱动时,最怕的就是“写了但没写进去”。我强烈建议你准备一个逻辑分析仪,便宜的几十块钱的就行。调试时,把 SDA/SCL(或 SPI 的四根线)夹上去,看波形。

你主要看这几个点:

  • 起始条件:SCL 高电平时,SDA 从高变低。
  • 设备地址:7 位地址 + 读写位,后面跟着 ACK。
  • 寄存器地址:8 位或 16 位,注意字节序。
  • 数据:8 位数据,后面跟着 ACK。
  • 停止条件:SCL 高电平时,SDA 从低变高。

如果波形不对,比如没有 ACK,那多半是设备地址错了,或者传感器没上电。如果数据不对,那就是字节序或 bit 顺序搞反了。

嗯,说到这个,我想起一个案例。有一次我调一个 SPI 接口的传感器,死活读不到正确的 ID 寄存器。用逻辑分析仪一看,发现 MISO 线上有数据,但 MCU 读到的全是 0xFF。后来才发现,SPI 的 CPOL 和 CPHA 配置反了,数据在错误的边沿采样,全采到了高电平。改过来就好了。

所以,逻辑分析仪是调试 I2C/SPI 的必备工具。别省这个钱,它能帮你省下几天的排查时间。