4. I2C协议详解与驱动实现:从总线架构到Linux子系统

I2C协议,说实话,是我在项目中打交道最多的串行总线之一。它只有两根线,却能挂载几十个设备,这种简洁高效的设计,让我每次用起来都觉得舒服。但舒服归舒服,坑也不少——时序不对、地址冲突、ACK丢失,哪个都够你调一整天。

这一章,我们就从I2C的总线架构讲起,把7位/10位地址、读写时序、ACK/NACK机制掰开揉碎,最后再深入Linux I2C子系统的核心数据结构。嗯,准备好了吗?

4.1 I2C总线架构:SCL与SDA

I2C总线就两条线:SCL(串行时钟线)和SDA(串行数据线)。所有设备都挂在这两根线上,通过开漏输出+上拉电阻的方式实现“线与”逻辑。

核心特点:

  • 主从架构:主设备产生时钟,发起通信;从设备响应地址,收发数据
  • 多主支持:多个主设备可以共享总线,通过仲裁机制决定谁占用
  • 7位/10位地址:标准模式用7位地址,最多挂127个设备;扩展模式用10位地址
  • 速率等级:标准模式100kHz,快速模式400kHz,高速模式3.4MHz

我个人习惯把I2C总线想象成一条“电话线”——SCL是振铃信号,SDA是通话内容。主设备控制振铃节奏,从设备在振铃间隙说话。谁先开口,谁就是主设备。

I2C总线架构示意图 主设备 (Master) 从设备1 (Slave 0x50) 从设备2 (Slave 0x68) 从设备3 (Slave 0x76) Rp Rp SDA SCL VDD 所有设备通过开漏输出 连接到SDA和SCL

我的经验:上拉电阻的取值很关键。4.7kΩ适合大多数场景,但总线电容大时要用2.2kΩ甚至1kΩ。我曾经在一个项目里用10kΩ上拉,结果400kHz模式下波形完全变形——上升沿太慢,从设备根本识别不了。

4.2 7位地址模式与10位地址模式

I2C的地址模式,说白了就是怎么给设备编号。7位地址是标准,10位地址是扩展。

4.2.1 7位地址模式

7位地址模式下,地址范围是0x00~0x7F。但实际可用的地址没那么多——0x00是广播地址,0x01~0x07保留,0x78~0x7F也保留。真正能用的,大概112个。

地址范围 用途 说明
0x00 广播/通用呼叫地址 所有从设备都应响应
0x01~0x07 保留 用于特殊总线协议
0x08~0x77 可用地址 普通设备地址,共112个
0x78~0x7F 保留 10位地址扩展用

你想想看,一个系统里挂十几个I2C设备很常见。如果每个设备都固定地址,冲突是迟早的事。所以很多芯片会提供多个地址引脚,让你通过硬件电平来配置地址。

4.2.2 10位地址模式

10位地址模式是为了解决7位地址不够用的问题。它的实现方式挺巧妙——用两个字节来传输地址。

10位地址传输流程:

  1. 第一个字节:11110XX + R/W,其中XX是10位地址的高2位
  2. 第二个字节:低8位地址
  3. 从设备识别:第一个字节的11110是标志位,告诉从设备这是10位地址

我记得第一次用10位地址时,差点被坑了。当时有个传感器芯片用了10位地址,我按7位地址去读,死活读不到。后来查手册才发现,原来第一个字节的格式不一样——不是简单的地址左移,而是有固定前缀的。

4.3 读写时序:起始条件、停止条件、ACK/NACK

I2C的时序,说白了就是一套“握手”规则。主设备通过SCL控制节奏,SDA上传输数据。每个bit的采样都在SCL的高电平期间完成。

4.3.1 起始条件与停止条件

这两个条件定义了通信的开始和结束:

  • 起始条件(S):SCL高电平时,SDA从高电平跳变到低电平
  • 停止条件(P):SCL高电平时,SDA从低电平跳变到高电平

为什么这样设计?因为数据在SCL高电平时采样,所以SDA在SCL高电平期间不能变化。起始和停止条件利用了SDA在SCL高电平时的跳变——这是一种“带外信号”,不会和数据混淆。

4.3.2 数据字节传输

每个字节8位,MSB先传。每传完一个字节,从设备要回复一个ACK(或NACK)。

  • ACK:第9个SCL时钟,SDA被从设备拉低——表示“我收到了,继续”
  • NACK:第9个SCL时钟,SDA保持高电平——表示“我没收到”或“我不想再收了”

避坑指南:我曾经在一个项目中遇到从设备偶尔不回复ACK的情况。排查了半天,发现是主设备时钟频率设置得太快,从设备跟不上。把频率从400kHz降到100kHz,问题就解决了。所以,遇到ACK丢失,先检查时钟频率是否匹配。

4.3.3 完整读写时序

写操作和读操作的流程不太一样,我习惯用表格来对比:

步骤 写操作 读操作
1 起始条件 起始条件
2 发送从设备地址 + W (0) 发送从设备地址 + W (0)
3 等待ACK 等待ACK
4 发送寄存器地址 发送寄存器地址
5 等待ACK 等待ACK
6 发送数据字节 重复起始条件
7 等待ACK 发送从设备地址 + R (1)
8 重复步骤6-7直到数据发完 等待ACK
9 停止条件 读取数据字节,发送ACK/NACK
10 - 重复步骤9直到读完,最后发NACK
11 - 停止条件

读操作比写操作多了一个“重复起始条件”。为什么要重复起始?因为总线方向要切换——从写寄存器地址切换到读数据。如果不重新起始,总线会一直处于写方向,没法读。

4.4 Linux I2C子系统:i2c_adapter与i2c_client

Linux内核的I2C子系统,说白了就是一套框架,把I2C控制器和I2C设备抽象成两个核心数据结构:i2c_adapter和i2c_client。

4.4.1 i2c_adapter:I2C控制器抽象

i2c_adapter代表一个I2C总线控制器。它负责:

  • 管理总线上的时钟和时序
  • 实现主设备的数据收发
  • 处理总线仲裁和错误

每个i2c_adapter都有一个唯一的编号(nr),对应系统中的一条I2C总线。比如i2c-0、i2c-1。

struct i2c_adapter {
    struct module *owner;
    unsigned int class;               /* 适配器支持的设备类 */
    const struct i2c_algorithm *algo; /* 算法实现 */
    void *algo_data;

    /* 数据字段 */
    struct rt_mutex bus_lock;
    int timeout;                      /* 超时时间,单位jiffies */
    int retries;                      /* 重试次数 */
    struct device dev;                /* 设备模型 */
    int nr;                           /* 总线编号 */
    char name[48];                    /* 适配器名称 */
    struct completion dev_released;
    struct mutex userspace_clients_lock;
    struct list_head userspace_clients;
};

这里最关键的是i2c_algorithm,它定义了实际的数据收发函数:

struct i2c_algorithm {
    int (*master_xfer)(struct i2c_adapter *adap, struct i2c_msg *msgs, int num);
    int (*smbus_xfer)(struct i2c_adapter *adap, u16 addr, unsigned short flags,
                      char read_write, u8 command, int size, union i2c_smbus_data *data);
    u32 (*functionality)(struct i2c_adapter *adap);
};

嗯,master_xfer是核心函数,所有I2C数据传输最终都会调用它。你写驱动时,主要就是实现这个函数。

4.4.2 i2c_client:I2C设备抽象

i2c_client代表一个挂在I2C总线上的从设备。它包含设备的地址、驱动绑定信息等。

struct i2c_client {
    unsigned short flags;       /* 标志位,如10位地址标志 */
    unsigned short addr;        /* 从设备地址,7位或10位 */
    char name[I2C_NAME_SIZE];  /* 设备名称 */
    struct i2c_adapter *adapter; /* 所属的适配器 */
    struct device dev;          /* 设备模型 */
    int irq;                    /* 中断号,如果有的话 */
    struct list_head detected;  /* 用于设备检测 */
};

我的习惯:写I2C设备驱动时,我一般先通过i2c_new_client_device注册设备,然后在probe函数里获取i2c_client指针。之后所有的读写操作都通过i2c_client来发起——用i2c_master_send和i2c_master_recv,或者更灵活的i2c_transfer。

4.4.3 驱动开发流程

写一个I2C设备驱动,大致分三步:

  1. 定义i2c_driver:填充probe、remove、id_table等回调
  2. 注册驱动:调用i2c_add_driver将驱动注册到内核
  3. 实现读写:在probe中获取client,通过i2c_transfer进行数据交换
static const struct i2c_device_id mydevice_id[] = {
    { "my_sensor", 0 },
    { }
};
MODULE_DEVICE_TABLE(i2c, mydevice_id);

static int mydevice_probe(struct i2c_client *client)
{
    struct mydevice_data *data;
    int ret;

    data = devm_kzalloc(&client->dev, sizeof(*data), GFP_KERNEL);
    if (!data)
        return -ENOMEM;

    data->client = client;
    i2c_set_clientdata(client, data);

    /* 初始化设备 */
    ret = mydevice_init(data);
    if (ret)
        return ret;

    return 0;
}

static struct i2c_driver mydevice_driver = {
    .driver = {
        .name = "my_sensor",
    },
    .probe = mydevice_probe,
    .remove = mydevice_remove,
    .id_table = mydevice_id,
};

module_i2c_driver(mydevice_driver);

避坑指南:我曾经在probe函数里直接调用i2c_transfer,结果系统卡死了。为什么?因为probe执行时,I2C总线可能还没完全初始化。正确的做法是在probe里只做资源分配和注册,真正的硬件初始化放到一个工作队列或定时器里延迟执行。

4.5 总结

I2C协议看似简单,但细节很多。从总线架构到地址模式,从时序到Linux子系统,每一步都有坑。我个人觉得,掌握I2C的关键在于理解“主从关系”和“时序约束”——主设备控制一切,从设备只能被动响应。写驱动时,多看看芯片手册的时序图,多想想ACK/NACK的含义,很多问题就能迎刃而解。