4、硬件接口与协议(I2C):I2C协议时序、Linux I2C子系统架构、I2C适配器与设备驱动、使用i2c-dev操作摄像头寄存器

各位同学,咱们今天聊聊I2C。做摄像头驱动,I2C是绕不开的坎儿。为什么?因为摄像头传感器那些寄存器配置——分辨率、帧率、曝光、增益——全得靠I2C去写。说白了,I2C就是CPU和摄像头之间的“传话筒”。

我个人习惯把I2C比作一个“半双工对讲机”。两根线,一根时钟(SCL),一根数据(SDA)。谁先开口谁就是主设备,谁应答谁就是从设备。摄像头传感器,永远是从设备。我们CPU,永远是主设备。

I2C协议时序:从START到STOP

先看最基本的时序。我当年刚接触I2C时,总觉得这东西玄乎。其实拆开看,就几个动作。

  • 起始条件(START):SCL高电平时,SDA从高变低。这是“我要开始说话了”的信号。
  • 停止条件(STOP):SCL高电平时,SDA从低变高。这是“我说完了”的信号。
  • 数据采样:数据在SCL低电平时变化,在SCL高电平时被采样。记住这个节奏就行。
  • 应答位(ACK):每发送8位数据后,接收方要拉低SDA表示“收到了”。如果没拉低(NACK),说明对方没响应。

一个典型的I2C写寄存器流程:

START → 设备地址(7位)+ 写位(0)→ 等待ACK → 寄存器地址(8位)→ 等待ACK → 数据(8位)→ 等待ACK → STOP

读寄存器流程稍微复杂点:先写寄存器地址,再重新发START,然后读数据。

嗯,这里要注意:摄像头传感器的I2C地址通常是7位的。比如OV5640的地址是0x3C(写)和0x3D(读)。但有些数据手册会给你8位地址,你得自己右移一位。我踩过这个坑,当时对着数据手册看了半天,怎么发地址都不对,后来才发现是地址位宽搞混了。

Linux I2C子系统架构:三层结构

Linux内核的I2C子系统,说白了就是三层:

  • I2C核心层:提供通用的API和总线管理。你不需要动它。
  • I2C适配器驱动:对应硬件上的I2C控制器。比如你的SoC内部那个I2C外设。这部分一般是芯片厂商写好的。
  • I2C设备驱动:对应具体的I2C从设备。比如摄像头传感器。这部分,就是我们要写的。

为什么这么分层?说白了就是为了解耦。你写摄像头驱动时,根本不用管I2C控制器是怎么把数据发出去的。你只需要调用i2c_transfer()或者i2c_smbus_write_byte_data()这些接口就行。

我的经验:刚开始写I2C设备驱动时,别急着去折腾适配器层。先确认你的I2C控制器在设备树里已经使能了,然后直接在驱动里调用i2c_master_send()和i2c_master_recv()。等跑通了,再考虑优化。

I2C适配器与设备驱动:设备树是关键

在Linux里,I2C设备和适配器是怎么关联的?靠设备树。

举个例子,你的摄像头传感器挂在I2C2总线上,设备树里大概长这样:

&i2c2 {
    status = "okay";
    clock-frequency = <400000>;  /* 400kHz,快速模式 */

    ov5640: camera@3c {
        compatible = "ovti,ov5640";
        reg = <0x3c>;           /* I2C设备地址 */
        clocks = <&clks IMX8MM_CLK_CLK_OUT1>;
        ...
    };
};

这里有个细节:reg = <0x3c> 是7位地址。有些平台要求填8位地址,你得看具体平台的绑定文档。我曾经在一个项目里,设备树填了0x78(8位地址),结果内核怎么都枚举不到设备。后来发现是平台要求填7位地址,改成0x3c就好了。

驱动侧,你只需要注册一个i2c_driver结构体:

static const struct i2c_device_id ov5640_id[] = {
    { "ov5640", 0 },
    { }
};
MODULE_DEVICE_TABLE(i2c, ov5640_id);

static struct i2c_driver ov5640_i2c_driver = {
    .driver = {
        .name = "ov5640",
        .of_match_table = ov5640_of_match,
    },
    .probe    = ov5640_probe,
    .remove   = ov5640_remove,
    .id_table = ov5640_id,
};
module_i2c_driver(ov5640_i2c_driver);

probe函数里,你会拿到一个struct i2c_client指针。这个client就代表你的摄像头传感器。之后所有的I2C读写操作,都通过这个client进行。

使用i2c-dev操作摄像头寄存器

在实际调试阶段,我强烈建议先用i2c-dev工具直接操作寄存器。为什么?因为你可以先验证硬件通路是不是通的,再写驱动。

i2c-dev是内核提供的一个通用I2C设备驱动。它会在/dev/下创建i2c-N节点。你可以在用户空间用ioctl来读写I2C设备。

举个例子,我想读OV5640的芯片ID寄存器(地址0x300A):

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

int fd = open("/dev/i2c-2", O_RDWR);
if (fd < 0) {
    perror("open");
    return -1;
}

/* 设置从设备地址 */
int addr = 0x3c;  /* 7位地址 */
if (ioctl(fd, I2C_SLAVE, addr) < 0) {
    perror("ioctl");
    close(fd);
    return -1;
}

/* 写寄存器地址 */
unsigned char reg_addr[2] = {0x30, 0x0A};
if (write(fd, reg_addr, 2) != 2) {
    perror("write");
    close(fd);
    return -1;
}

/* 读数据 */
unsigned char data;
if (read(fd, &data, 1) != 1) {
    perror("read");
    close(fd);
    return -1;
}

printf("Chip ID: 0x%02x\n", data);
close(fd);

注意:i2c-dev方式只适合调试阶段。正式产品里,一定要写内核驱动。因为i2c-dev没有电源管理、没有中断处理、没有并发保护。你想想看,摄像头在休眠时,用户空间程序还能通过i2c-dev去读写寄存器,这会导致什么后果?

另外,i2c-tools工具包里有个i2cget和i2cset命令,调试时非常方便:

# 读寄存器
i2cget -y 2 0x3c 0x300a w

# 写寄存器
i2cset -y 2 0x3c 0x300a 0x56 w

这里的-w参数表示16位寄存器地址。很多摄像头传感器的寄存器地址是16位的,要注意区分。

避坑指南

最后分享几个我实际项目中遇到的坑:

  • 时钟频率不匹配:摄像头传感器支持的I2C频率可能只有100kHz或400kHz。别一股脑设成1MHz。我曾经把频率设成1MHz,结果传感器偶尔响应,偶尔不响应,查了两天才发现是频率太高了。
  • 地址偏移:有些传感器要求寄存器地址是16位的,有些是8位的。写驱动前一定要看数据手册的I2C时序图。
  • 应答超时:传感器在内部处理时(比如自动曝光计算),可能会暂时不响应I2C请求。驱动里要加重试机制。
  • 电平不匹配:如果CPU是1.8V的I2C,传感器是3.3V的,中间必须加电平转换芯片。别直接连,会烧芯片。

好了,I2C这部分就讲到这里。下一节我们聊聊SPI协议,那是另一种常用的摄像头控制接口。到时候你会发现,I2C和SPI各有各的脾气。