4. I2C总线协议与驱动开发:I2C时序、Linux I2C子系统、编写I2C客户端驱动

各位同学,今天我们来聊聊I2C。这个协议在嵌入式世界里太常见了——传感器、EEPROM、ADC、触摸屏,几乎每个板子上都能找到它的身影。我个人习惯把I2C比作一个“多设备聊天的会议室”,两根线(SCL和SDA)就能让几十个设备互相通信,确实很巧妙。

4.1 I2C总线协议基础

先说说I2C的物理层。它只用两根线:

  • SCL(串行时钟线):由主设备驱动,控制通信节奏
  • SDA(串行数据线):双向数据线,主从设备都能拉

这两根线都是开漏输出,需要上拉电阻。嗯,这里要注意:上拉电阻的取值很关键。我在项目中遇到过因为上拉电阻选太大,导致信号上升沿太慢,通信偶尔出错的情况。一般4.7kΩ是个比较稳妥的起点,具体要看总线电容和速率。

4.2 I2C时序详解

I2C的时序其实不复杂,但细节决定成败。我们来看几个关键信号:

信号 描述 时序要求
起始条件 SCL高电平时,SDA从高变低 tHD:STA ≥ 4μs(100kHz)
停止条件 SCL高电平时,SDA从低变高 tSU:STO ≥ 4μs
数据采样 SCL低电平时改变SDA,高电平时采样 tSU:DAT ≥ 250ns
应答位 第9个时钟周期,从设备拉低SDA表示ACK NACK时从设备释放SDA

为什么会强调这些时序参数?说白了,很多I2C通信问题都是时序违规导致的。我曾经调试一个温湿度传感器,读出来的数据总是跳变,用逻辑分析仪一看——起始条件保持时间不够,从设备没正确识别到起始信号。把延时加长一点,问题就解决了。

核心要点:I2C通信的原子操作是“起始+地址+数据+停止”。每个字节后面必须跟一个应答位,这是协议的铁律。

4.3 Linux I2C子系统架构

Linux内核的I2C子系统分三层,你想想看,这种分层设计的好处是什么?

  1. I2C核心层:提供总线管理、设备注册、驱动匹配等通用功能
  2. I2C总线驱动层:操作具体的I2C控制器硬件(比如STM32的I2C外设)
  3. I2C客户端驱动层:我们写驱动时主要打交道的地方,控制具体的I2C从设备

我个人习惯把客户端驱动想象成“设备的代言人”。内核通过i2c_client结构体来描述一个I2C设备,通过i2c_driver结构体来描述驱动。匹配成功后,probe函数就会被调用。

小技巧:在设备树中描述I2C设备时,reg属性就是设备的7位地址。注意!很多数据手册给的是8位地址(带读写位),需要右移一位才是7位地址。我刚开始做的时候就被这个坑过。

4.4 编写I2C客户端驱动

好了,理论说完了,咱们直接上代码。写一个I2C客户端驱动,核心就三步:

// 1. 定义i2c_driver结构体
static struct i2c_driver my_sensor_driver = {
    .driver = {
        .name = "my_sensor",
        .of_match_table = my_sensor_of_match,
    },
    .probe = my_sensor_probe,
    .remove = my_sensor_remove,
    .id_table = my_sensor_id,
};

// 2. 实现probe函数
static int my_sensor_probe(struct i2c_client *client, 
                           const struct i2c_device_id *id)
{
    struct my_sensor_data *data;
    
    data = devm_kzalloc(&client->dev, sizeof(*data), GFP_KERNEL);
    if (!data)
        return -ENOMEM;
    
    i2c_set_clientdata(client, data);
    
    // 初始化设备
    my_sensor_init(client);
    
    return 0;
}

// 3. 注册驱动
module_i2c_driver(my_sensor_driver);

嗯,这里要注意:probe函数里千万别做耗时操作。我曾经在probe里加了msleep(1000),结果系统启动慢了好几秒,被同事吐槽了好久。初始化操作可以放到工作队列或者异步任务里做。

4.5 I2C数据传输实战

读写I2C设备,Linux提供了两组API:

  • SMBus兼容接口:i2c_smbus_read_byte_data()、i2c_smbus_write_word_data()等,适合简单设备
  • 原始传输接口:i2c_transfer(),适合需要自定义时序的场景

我个人更推荐用i2c_transfer,虽然代码多几行,但控制力更强。来看一个实际例子:

static int sensor_read_reg(struct i2c_client *client, u8 reg, u8 *val)
{
    struct i2c_msg msg[2];
    int ret;
    
    // 第一条消息:写寄存器地址
    msg[0].addr = client->addr;
    msg[0].flags = 0;  // 写
    msg[0].len = 1;
    msg[0].buf = ®
    
    // 第二条消息:读数据
    msg[1].addr = client->addr;
    msg[1].flags = I2C_M_RD;  // 读
    msg[1].len = 1;
    msg[1].buf = val;
    
    ret = i2c_transfer(client->adapter, msg, 2);
    if (ret != 2) {
        dev_err(&client->dev, "I2C read failed: %d\n", ret);
        return -EIO;
    }
    
    return 0;
}

避坑指南:我曾经遇到一个传感器,读寄存器时必须先写寄存器地址,然后重新发送起始条件(restart)再读数据。如果用了i2c_smbus_read_byte_data(),它内部会发停止条件再发起始条件,结果传感器就不认了。换成i2c_transfer用重复起始条件,问题解决。

4.6 调试技巧与常见问题

调试I2C驱动,我常用的三板斧:

  1. 逻辑分析仪:抓波形,看起始条件、地址、数据、应答位是否正常
  2. i2c-tools工具集:i2cdetect探测设备、i2cget/i2cset读写寄存器,快速验证硬件
  3. 内核调试开关:打开CONFIG_I2C_DEBUG_BUS和CONFIG_I2C_DEBUG_ALGO,看内核日志

常见问题就那几个:

  • 设备地址不对——检查数据手册,确认是7位还是8位地址
  • 时钟延展没处理——有些慢速设备会拉低SCL,主设备要支持时钟延展
  • 上拉电阻不合适——信号畸变导致通信失败

说白了,I2C驱动开发就是个熟能生巧的过程。多写几个驱动,多踩几个坑,自然就熟练了。下一章我们讲SPI总线,那个速度更快,但线也更多,到时候再聊。