Linux I2C框架的整体分层设计
I2C是嵌入式系统中应用最广泛的低速串行总线之一,小到温度传感器、EEPROM、OLED显示屏,大到基带芯片、传感器模组,几乎都离不开I2C通信。在Linux内核中,I2C驱动并不是由各个厂商各自实现,而是设计了一套分层、通用的核心框架,将总线adapter和设备driver解耦分离,既简化了驱动开发,又保证了代码的可复用性和可维护性。很多嵌入式开发者在开发I2C设备驱动时,只知道调用i2c_smbus_read_byte_data这类API,却不理解内核框架是怎么把数据传到物理总线上的。今天我们就来深入分析Linux内核中I2C驱动框架的设计结构、核心逻辑和工作流程,帮大家理清整个框架的脉络。
一、Linux I2C框架的整体分层设计
Linux I2C驱动框架最核心的设计思想就是分离分层,它把整个I2C体系拆分成了三个核心层次,每个层次只负责自己的功能,低层次给高层次提供统一接口,高层次不需要关心底层硬件细节:
最底层是I2C总线适配器(I2C Adapter),也叫I2C控制器,对应Soc上的硬件I2C控制器,或者用GPIO模拟的软件I2C总线。这一层的核心工作是实现具体的I2C传输——发起起始信号、发送地址、读写数据、发送停止信号,它只负责和硬件打交道,不关心总线上接了什么设备。内核定义了struct i2c_adapter结构体来描述一个总线适配器,它本质上就是一个总线控制器的抽象,提供一组读写操作的方法。
中间层是I2C核心层(I2C Core),这一层是整个框架的核心,负责管理系统中的所有I2C适配器和所有I2C设备,提供统一的API给上层设备驱动调用,同时完成设备和驱动的匹配,向下转发读写请求到具体的I2C适配器。核心层不涉及具体硬件操作,它只做管理和中转,所有硬件相关的操作都交给底层适配器去完成。核心层导出了比如i2c_add_adapter、i2c_new_device、i2c_smbus_read_word_data等通用API,不管底层是什么适配器,上层调用的接口都是一样的。
最上层是I2C设备驱动(I2C Driver),对应具体的I2C设备,比如BME280温湿度传感器、AT24C02 EEPROM,这一层负责实现具体设备的业务逻辑,根据设备的通信协议,调用核心层提供的统一API完成数据读写,不需要关心数据是怎么通过硬件总线发出去的。内核用struct i2c_driver描述一个设备驱动,当设备和驱动匹配成功后,就会调用驱动的probe函数完成设备初始化。
这种分层分离设计带来的好处非常明显:比如我们换了一款Soc,硬件I2C控制器变了,只需要修改底层适配器的实现,上层所有的设备驱动完全不用改就能正常工作;反过来,我们新增一款I2C传感器,只需要写上层的设备驱动,不需要修改任何核心层和底层适配器的代码,可扩展性非常好,符合Linux内核驱动开发的设计思想。
二、核心数据结构分析:理解框架的基础
Linux I2C框架的所有逻辑都围绕几个核心结构体展开,理清这几个结构体的作用,就能理解整个框架的脉络,我们来看几个最重要的结构体:
1. struct i2c_adapter:总线适配器的抽象
i2c_adapter是对一个物理I2C总线控制器的抽象,它定义在include/linux/i2c.h中,核心成员有两个:
struct i2c_adapter {
struct module *owner; // 模块所有者,一般填THIS_MODULE
unsigned int id; // I2C总线编号,比如i2c-0、i2c-1,系统中唯一
struct i2c_algorithm *algo; // I2C通信算法,就是具体实现收发的方法
// ...其他成员
};
其中最重要的是struct i2c_algorithm *algo,这个结构体里存放了适配器实现的具体传输函数:
struct i2c_algorithm {
// 核心传输函数,完成一次I2C消息传输
int (*master_xfer)(struct i2c_adapter *adap, struct i2c_msg *msgs, int num);
// smbus传输函数,如果没有实现,核心层会用通用I2C传输模拟
int (*smbus_xfer) (struct i2c_adapter *adap, u16 addr, unsigned short flags,
char read_write, u8 command, int size, union i2c_smbus_data *data);
// ...其他成员
};
也就是说,不管你是硬件I2C还是软件模拟I2C,只要实现了master_xfer函数,就能注册成一个I2C适配器,核心层就可以用它来做传输,这就是面向对象的抽象思想,把具体实现和调用分离。
2. struct i2c_client:具体I2C设备的抽象
每个挂在I2C总线上的具体设备,内核都会用一个i2c_client结构体来描述,它保存了设备的核心信息:
struct i2c_client {
unsigned short addr; // 设备的7位I2C地址,这是设备在总线上的唯一标识
struct i2c_adapter *adapter; // 设备挂在哪个I2C总线上,指向对应的适配器
struct device dev; // 内嵌的通用设备结构体,兼容设备驱动模型
char name[I2C_NAME_SIZE]; // 设备名称,用来和驱动匹配
// ...其他成员
};
当设备树中声明了一个I2C设备,或者内核动态注册了一个设备,核心层就会分配一个i2c_client结构体,保存这些信息,之后所有和这个设备相关的传输都要用到这个结构体。
3. struct i2c_driver:I2C设备驱动的抽象
i2c_driver对应具体的设备驱动,和内核的设备驱动模型兼容,核心成员和平台驱动类似:
struct i2c_driver {
int (*probe)(struct i2c_client *client, const struct i2c_device_id *id);
int (*remove)(struct i2c_client *client);
// 匹配ID表,支持旧的设备匹配方式
const struct i2c_device_id *id_table;
// 设备树匹配表,现在设备树开发都用这个
struct device_driver driver;
// ...其他成员
};
当核心层检测到一个i2c_client设备,并且名称和i2c_driver匹配成功,就会调用i2c_driver的probe函数,驱动就在probe函数中完成设备的初始化、申请资源、注册字符设备这些操作,和其他驱动的probe函数逻辑一致。
4. struct i2c_msg:I2C传输消息的抽象
Linux I2C框架一次可以传输多个消息,每个消息用一个i2c_msg描述:
struct i2c_msg {
__u16 addr; // 从设备地址
__u16 flags; // 标志位,比如读写标志,I2C_M_RD表示读
__u16 len; // 消息长度
__u8 *buf; // 数据缓冲区
};
比如我们要先写寄存器地址再读数据,就需要定义两个i2c_msg,第一个是写消息,写寄存器地址,第二个是读消息,读寄存器数据,一次传给master_xfer完成整个传输,这样比分开两次传输更高效,也符合I2C协议的要求。
三、设备与驱动的匹配流程
现在我们来看,当系统启动后,I2C框架是怎么完成设备和驱动匹配的,这里分两种情况:设备树场景和传统无设备树场景,现在嵌入式Linux开发基本都用设备树,我们重点说设备树的匹配流程。
首先,Soc层面的I2C控制器驱动会先执行,完成i2c_adapter的初始化,实现master_xfer等传输函数,然后调用核心层提供的i2c_add_adapter或者i2c_add_numbered_adapter把这个适配器注册到核心层中,注册完成后,系统就会多出一个比如/dev/i2c-1的设备节点,代表这个I2C总线。
接下来,设备树解析的时候,会解析到I2C总线节点下的各个子节点,每个子节点就是一个I2C设备,比如:
&i2c1 {
status = "okay";
bme280@76 {
compatible = "bosch,bme280";
reg = <0x76>; // I2C设备地址
};
};
核心层解析到这个节点后,会自动分配一个i2c_client结构体,把reg属性的值赋值给addr字段,把节点名称赋值给client的name字段,然后把这个client添加到I2C总线的设备链表中。
然后,当我们注册i2c_driver的时候,i2c_driver中的of_match_table会保存驱动支持的设备树compatible列表,核心层会用compatible属性做匹配——如果设备树节点的compatible和驱动of_match_table中的某一项匹配成功,就认为匹配成功,调用驱动的probe函数,完成设备初始化。
如果是没有设备树的传统场景,就用i2c_device_id匹配:驱动定义一个i2c_device_id数组,列出所有支持的设备名称,核心层把设备名称和数组中的名称对比,匹配成功就调用probe。整个匹配流程都是由I2C核心层完成,不需要驱动开发者自己处理,框架已经把所有匹配逻辑封装好了。
四、一次I2C读写请求的完整流程
我们以最常用的smbus读一个字节寄存器为例,梳理一下一次I2C读写请求从上层设备驱动到硬件的完整流程,看看整个框架是怎么运转的:
上层设备驱动发起调用:上层BME280驱动要读芯片的id寄存器,调用核心层提供的API:
// 从client对应设备的reg地址读一个字节
id = i2c_smbus_read_byte_data(client, reg_addr);
这个API是核心层导出的,不管底层是什么适配器,调用方式都一样。
核心层做参数检查和中转:i2c_smbus_read_byte_data会检查参数是否合法,拿到client对应的i2c_adapter,然后检查适配器有没有实现smbus_xfer函数:
如果适配器实现了smbus_xfer,就直接调用适配器的smbus_xfer完成传输;
如果没有实现,核心层会把smbus请求转换成标准I2C消息,构造两个i2c_msg,第一个写寄存器地址,第二个读数据,然后调用适配器的master_xfer完成传输。
底层适配器执行传输:适配器的master_xfer函数是硬件相关的,以硬件I2C控制器为例,它会操作Soc的I2C控制器寄存器,按照I2C协议发起起始信号,发送从设备地址,发送寄存器地址,然后发起读操作,把读到的数据放到i2c_msg的buf中,完成传输后返回。如果是软件模拟I2C,master_xfer会操作GPIO高低电平模拟I2C时序,完成同样的流程。
数据返回给上层驱动:传输完成后,master_xfer返回成功还是失败,核心层把读到的数据整理好,返回给上层的设备驱动,整个读写流程就完成了。
整个流程非常清晰,核心层做中转,下层做硬件,上层做业务,每个层次只做自己的事,分工明确。我们可以看到,上层驱动完全不需要关心底层是硬件I2C还是软件模拟,只要调用统一的API就行,这就是分层框架带来的好处。
五、用户空间I2C访问机制
Linux I2C框架还提供了用户空间访问的机制,我们可以不用写内核驱动,直接在用户空间读写I2C设备,非常适合调试和快速开发,这个功能是通过/dev/i2c-X设备节点实现的。
当我们注册一个I2C适配器,内核会自动创建对应的字符设备节点,用户空间程序可以打开这个节点,通过ioctl设置要访问的从设备地址,然后直接通过read、write或者ioctl完成I2C读写,最常用的工具就是i2c-tools中的i2cdetect、i2cdump、i2cget、i2cset。
比如我们要检测i2c-1总线上有哪些设备,直接执行:
i2cdetect -y 1
i2cdetect就是打开/dev/i2c-1,然后遍历所有7位地址,逐个发起probe,看有没有设备响应,把有响应的地址打印出来,这是调试I2C设备最常用的方法,整个过程不需要加载任何内核驱动就能完成,非常方便。
如果我们要做一个简单的应用,比如读一个I2C传感器的温度,不需要写内核驱动,直接在用户空间读写就能实现,大大缩短了开发周期,这个机制给开发者带来了非常大的便利。当然,用户空间访问有一定局限性,它不适合对实时性要求很高、需要和内核其他子系统交互的场景,这种场景还是需要写内核驱动。
六、Linux I2C框架设计的优点与不足
优点
Linux I2C框架的分层设计非常成熟,它的优点很明显: 第一,解耦了适配器和设备驱动,代码复用性非常高,一个适配器可以复用给所有I2C设备,一个设备驱动可以用在任何Soc的I2C总线上,不需要重复开发。 第二,统一的API接口,不管底层硬件怎么变,上层调用方式不变,降低了驱动开发的门槛,开发者不需要从头实现整个I2C协议,只需要实现少量硬件相关代码。 第三,支持动态设备管理和用户空间访问,调试非常方便,不管是内核开发还是用户空间开发都能满足需求。
不足
当然,Linux I2C框架也存在一些不足: 第一,框架层次比较多,一次读写请求需要经过多层函数调用,有一定的开销,在一些对延迟要求非常高的场景,会比直接操作寄存器慢一些,但对于I2C这种本来就是低速的总线来说,这点开销基本可以忽略。 第二,对于多主机I2C、复杂的电源管理这些高级功能,框架的支持还在完善中,有些场景需要驱动自己做额外处理。 第三,不同厂商的硬件I2C控制器都有自己的个性,有些厂商的控制器会有一些特殊功能,需要在适配器层做很多适配工作,不过这是硬件设计的问题,不是框架本身的问题。
七、对嵌入式驱动开发的启示
Linux I2C框架作为一个非常成熟的内核驱动框架,它的设计思想值得我们学习:分层分离、面向对象抽象、接口与实现分离,这些设计思想可以用到我们自己的驱动开发中。很多开发者喜欢把所有逻辑写在一起,硬件相关和业务逻辑不分离,换个硬件就要改大量代码,而Linux I2C框架告诉我们,把不变的部分抽象出来,把变化的部分放到下层,上层只需要调用统一接口,这样代码的可维护性和可扩展性会好很多。
对于嵌入式开发者来说,理解Linux I2C框架不仅能帮我们更快开发I2C设备驱动,还能帮我们更快定位问题:当I2C读写出错的时候,我们知道问题出在哪个层次——如果i2cdetect都扫不到设备,那就是底层适配器或者硬件连线的问题;如果i2cdetect能扫到,内核驱动读写出错,那就是上层驱动的地址或者协议问题,能帮我们更快缩小问题范围。
Linux的I2C驱动框架是内核中分层驱动设计的经典案例,它通过三层分离的架构,把I2C总线、核心管理和设备驱动清晰地分离开,既保证了灵活性,又降低了开发难度,支撑了Linux内核中数百种I2C设备驱动的管理和运行。对于嵌入式Linux开发者来说,理清这个框架的结构和流程,是开发I2C驱动的基础,也是理解Linux内核驱动设计思想的一个很好的切入点。
现在I2C依然是嵌入式领域最常用的低速总线,Linux I2C框架也在不断更新完善,新增了对更多新特性的支持,但它核心的分层设计思想不会变,依然是Linux I2C驱动的核心骨架。





