当前位置:首页 > 技术学院 > 技术前线
[导读]I2C是嵌入式系统中应用最广泛的低速串行总线之一,小到温度传感器、EEPROM、OLED显示屏,大到基带芯片、传感器模组,几乎都离不开I2C通信。在Linux内核中,I2C驱动并不是由各个厂商各自实现,而是设计了一套分层、通用的核心框架,将总线adapter和设备driver解耦分离,既简化了驱动开发,又保证了代码的可复用性和可维护性。很多嵌入式开发者在开发I2C设备驱动时,只知道调用i2c_smbus_read_byte_data这类API,却不理解内核框架是怎么把数据传到物理总线上的。

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驱动的核心骨架。 

本站声明: 本文章由作者或相关机构授权发布,目的在于传递更多信息,并不代表本站赞同其观点,本站亦不保证或承诺内容真实性等。需要转载请联系该专栏作者,如若文章内容侵犯您的权益,请及时联系本站删除( 邮箱:macysun@21ic.com )。
换一批
延伸阅读

I2C 偶发不响应时,复位主控往往只能暂时恢复,因为总线状态可能已经被外部器件留在半截事务里。单片机若没有处理 SDA 锁低和上拉边界,软件重新初始化也未必能重新拿回总线。

关键字: 单片机 I2C SDA

在嵌入式系统开发中,SPI和I2C作为最常用的同步串行通信协议,其驱动实现直接影响硬件交互的稳定性。本文以STM32 HAL库为基础,阐述从协议栈架构设计到错误处理的完整开发流程,实现微秒级时序控制与毫秒级错误恢复。

关键字: 驱动开发 SPI I2C

在单片机系统开发中,外设扩展是提升功能多样性的关键环节。I2C(Inter-Integrated Circuit)通信协议凭借其简洁的硬件设计、高效的传输机制和广泛的设备支持,成为连接传感器、存储器、显示器等外设的首选方...

关键字: I2C 单片机

在嵌入式系统开发中,GPIO、I2C与SPI接口如同三把钥匙,分别解锁了简单控制、多设备协同与高速传输的场景。从机械臂的精密运动到OLED显示屏的实时渲染,这些接口的协同工作构成了智能硬件的核心脉络。本文将通过机械臂、传...

关键字: GPIO I2C SPI

在现代电子系统中,总线作为连接各个组件的关键通信通道,起着至关重要的作用。CANBUS 总线和 I2C 总线是众多总线类型中应用广泛的两种,它们各自具备独特的特性,适用于不同的应用场景。深入了解这两种总线的区别,对于电子...

关键字: 总线 CANBUS I2C

在嵌入式开发领域,UART、I2C、SPI等接口技术被广泛使用,它们为微控制器与外部设备之间的通信提供了高效、可靠的途径。本文将详细介绍这三种常用的外设接口。

关键字: UART I2C

在现代嵌入式系统开发中,串行通信协议扮演着至关重要的角色。其中,UART(通用异步收发传输器)、I2C(Inter-Integrated Circuit)和SPI(Serial Peripheral Interface)...

关键字: UART I2C SPI 串行总线

在嵌入式系统的开发过程中,调试是至关重要的一环。调试工具的选择直接影响到开发效率、系统稳定性以及后期的维护成本。在众多通信协议中,UART(通用异步收发传输器)因其简单性、灵活性以及广泛的工具支持,成为嵌入式调试中的首选...

关键字: 嵌入式 UART SPI I2C

I2C通信协议使用两根线(串行数据线SDA和串行时钟线SCL)进行通信,其中SDA用于传输数据,SCL用于传输时钟信号;支持多主设备和多从设备的通信,通过地址来识别不同的设备,并支持数据的读取和写入操作。

关键字: I2C SDA

RTC模块作为一个独立的定时器,能够提供精确的实时时间,并为电子系统提供精确的时间基准。本文将详细阐述RTC实时时钟的基本概念、工作原理以及其在现代电子设备中的应用。

关键字: RTC I2C SPI
关闭