2. I2C驱动框架:Linux I2C子系统架构、设备树绑定

好,咱们进入第二章。这一章我打算把Linux下的I2C驱动框架给你拆开揉碎了讲清楚。很多初学者一上来就翻芯片手册、写寄存器,结果代码写得乱七八糟,根本没法维护。说白了,你得先理解这个框架是怎么设计的,才能写出符合规范的驱动。

我个人习惯,在讲任何子系统之前,先画一张全局架构图。这样你心里有个谱,知道每个模块在哪个位置、干什么活。

Linux I2C 子系统架构图 Linux I2C 子系统架构 用户空间 (Userspace) 应用程序 / i2c-tools (i2cget, i2cset, i2cdetect) 内核空间 (Kernel Space) 系统调用接口 / VFS I2C 核心层 (I2C Core) i2c-core.c / i2c-dev.c / 设备模型抽象 I2C 总线驱动层 i2c-imx.c / i2c-s3c2410.c / i2c-omap.c 适配器 (Adapter) 实现 I2C 设备驱动层 eeprom.c / bmp085.c / 传感器驱动 客户端 (Client) 驱动 硬件抽象层 (HAL) GPIO模拟I2C / 平台设备抽象 设备树 (Device Tree) dts / dtb 绑定描述 硬件层 (Hardware) I2C 总线 (SCL/SDA) / 从设备 (Slave Devices) 分层解耦 驱动可移植

2.1 三层架构:核心、总线、设备

Linux I2C 子系统分三层。这个分层设计,我当年刚接触时觉得多此一举,后来被坑过几次才明白它的好处。

层级 核心文件/模块 职责
I2C 核心层 i2c-core.c, i2c-dev.c 设备模型抽象、总线注册、驱动匹配、ioctl 接口
I2C 总线驱动层 i2c-imx.c, i2c-s3c2410.c 适配器 (Adapter) 实现,操作硬件寄存器产生时序
I2C 设备驱动层 eeprom.c, bmp085.c 客户端 (Client) 驱动,与具体外设交互

核心层 是骨架。它管理着总线的注册、设备的枚举、驱动的匹配。你写驱动时,大部分 API 都是从这层暴露出来的。比如 i2c_add_adapter()、i2c_new_device(),都是核心层的函数。

总线驱动层 是干活的人。它直接操作 I2C 控制器的寄存器,负责产生 SCL 时钟、控制 SDA 数据线。不同的 SoC 有不同的 I2C 控制器,所以这一层是平台相关的。我在项目里移植过 i2c-imx 驱动,当时为了调一个时钟延时的参数,折腾了两天——嗯,硬件上的坑,后面会专门讲。

设备驱动层 是具体业务。比如你接了一个温度传感器,你只需要调用 i2c_smbus_read_word_data() 这类接口,不用关心底层时序怎么产生。这就是分层的好处——写设备驱动的人,可以完全不懂硬件寄存器。

关键理解: 总线驱动负责「怎么传」,设备驱动负责「传什么」。两者通过核心层的 adapter 和 client 结构体连接。

2.2 核心数据结构:adapter 与 client

这两个结构体,是 I2C 子系统的灵魂。你理解了它们,就理解了整个框架。

struct i2c_adapter

代表一个 I2C 控制器。每个硬件 I2C 总线对应一个 adapter。

struct i2c_adapter {
    struct module *owner;
    unsigned int class;               // 设备类,用于自动探测
    const struct i2c_algorithm *algo; // 算法,核心!实现收发函数
    void *algo_data;

    struct device dev;                // 内核设备模型
    int nr;                           // 总线编号,如 i2c-0, i2c-1
    struct list_head userspace_clients;
    // ... 其他字段
};

这里最重要的就是 algo 字段。它是一个 i2c_algorithm 结构体,里面定义了 master_xfer() 函数指针。总线驱动必须实现这个函数,核心层通过它来发起 I2C 传输。

我的经验: 调试新板子的 I2C 时,我第一个查的就是 adapter 的 nr 对不对。有一次设备树配错了,adapter 注册成了 i2c-2,但应用层硬编码了 i2c-0,结果死活读写不了。这种低级错误,排查起来最浪费时间。

struct i2c_client

代表一个挂在 I2C 总线上的从设备。每个外设芯片对应一个 client。

struct i2c_client {
    unsigned short addr;    // 7位或10位从机地址
    char name[I2C_NAME_SIZE];
    struct i2c_adapter *adapter; // 所属的总线
    struct device dev;           // 内核设备模型
    int irq;                     // 中断号,如果有的话
    struct list_head detected;   // 用于设备探测链表
};

client 的 addr 就是你在芯片手册上看到的那个从机地址。注意,Linux 内核里用的是 7 位地址(左对齐),而数据手册上经常写 8 位地址(含读写位)。这个转换我吃过亏,后面会专门提醒。

2.3 设备树绑定:让硬件描述与驱动分离

设备树 (Device Tree) 是 ARM Linux 的标配。它把硬件信息从内核代码里抽出来,放到一个 .dts 文件里。这样同一个内核可以适配不同的板子。

我记得第一次接触设备树时,觉得这东西好麻烦。但后来维护过几个项目后,发现它确实香——换一个传感器,改几行 dts 就行,不用重新编译内核。

I2C 控制器节点

在设备树里,I2C 控制器通常这样描述:

&i2c1 {
    status = "okay";
    clock-frequency = <100000>;  // 100kHz 标准模式
    pinctrl-names = "default";
    pinctrl-0 = <&pinctrl_i2c1>;

    // 挂载在总线上的从设备
    temperature_sensor: tmp102@48 {
        compatible = "ti,tmp102";
        reg = <0x48>;
        interrupt-parent = <&gpio1>;
        interrupts = <15 IRQ_TYPE_EDGE_FALLING>;
    };

    eeprom@50 {
        compatible = "atmel,24c02";
        reg = <0x50>;
        pagesize = <8>;
    };
};

关键字段解释:

  • compatible:驱动匹配的关键字符串。内核通过它找到对应的驱动。
  • reg:从机地址。注意是 7 位地址,比如 0x48。
  • clock-frequency:总线频率。标准模式 100kHz,快速模式 400kHz。
  • interrupts:中断配置。如果设备支持中断,这里指定。

避坑指南: 我曾经在一个项目里,设备树里 reg 写了 0x90(8位地址),结果内核怎么都匹配不上。后来查了半天才发现,内核用的是 7 位地址 0x48。记住:reg 填 7 位地址,不要左移一位!

驱动中的匹配

设备驱动通过 of_match_table 来匹配设备树节点:

static const struct of_device_id tmp102_of_match[] = {
    { .compatible = "ti,tmp102" },
    { /* sentinel */ }
};
MODULE_DEVICE_TABLE(of, tmp102_of_match);

static struct i2c_driver tmp102_driver = {
    .driver = {
        .name = "tmp102",
        .of_match_table = tmp102_of_match,
    },
    .probe = tmp102_probe,
    .id_table = tmp102_id,  // 传统ID表,用于非设备树平台
};

当内核解析设备树时,发现 compatible = "ti,tmp102",就会去遍历已注册的 i2c_driver,找到匹配的驱动,然后调用它的 probe 函数。

2.4 驱动注册与 probe 流程

整个流程是这样的:

  1. 总线驱动注册:调用 i2c_add_adapter(),将 adapter 注册到核心层。
  2. 设备树解析:内核解析 dts,为每个 I2C 子节点创建 i2c_client。
  3. 驱动注册:调用 i2c_add_driver(),将 i2c_driver 注册到核心层。
  4. 匹配与 probe:核心层遍历已注册的 client,找到 compatible 匹配的 driver,调用 probe。

在 probe 函数里,你通常会做这些事情:

  • 分配并初始化私有数据结构
  • 读取设备树中的属性(如中断号、时钟频率)
  • 注册中断处理函数
  • 创建 sysfs 文件或输入设备
  • 初始化硬件(如设置传感器模式)

核心原则: probe 函数不要做耗时操作。如果初始化需要时间,用工作队列或定时器延后处理。我见过有人在 probe 里用 msleep(1000),结果系统启动慢了 1 秒,被项目经理骂了一顿。

2.5 本章小结

这一章我们讲了 Linux I2C 子系统的三层架构:核心层、总线驱动层、设备驱动层。核心数据结构 adapter 和 client 是理解整个框架的关键。设备树让硬件描述与驱动代码分离,提高了代码的可移植性。