大家好,今天咱们聊聊嵌入式开发里绕不开的坎------I2C驱动开发!!
**为啥驱动开发绕不开I2C呢?**因为I2C设备多啊!嵌入式开发里, I2C堪称"江湖大佬"------它简单、成本低,两根线(SCL和SDA)就能搞定一堆设备:传感器、EEPROM、RTC......简直是嵌入式硬件的"万能胶"。
一、I2C 通信协议
前几天写过,这里就不重复了,大家可以去看前文→一文吃透I2C协议:信号、地址、速率与读写

二、Linux I2C 子系统软件框架(软件框架)
很多朋友第一次看Linux I2C驱动源码时,会有一种感觉:"这也太难了吧", 各种adapter、client、driver、algorithm、core,名字长得跟套娃一样。
其实Linux I2C框架本质就两件事:控制器驱动和设备驱动,把这俩拆开理解,就很清晰明了了。
2.1 总体架构:用户空间 ↔ 内核空间
大家好,今天咱们聊聊嵌入式开发里绕不开的坎------I2C驱动开发!!
**为啥驱动开发绕不开I2C呢?**因为I2C设备多啊!嵌入式开发里, I2C堪称"江湖大佬"------它简单、成本低,两根线(SCL和SDA)就能搞定一堆设备:传感器、EEPROM、RTC......简直是嵌入式硬件的"万能胶"。
一、I2C 通信协议
前几天写过,这里就不重复了,大家可以去看前文→一文吃透I2C协议:信号、地址、速率与读写

二、Linux I2C 子系统软件框架(软件框架)
很多朋友第一次看Linux I2C驱动源码时,会有一种感觉:"这也太难了吧", 各种adapter、client、driver、algorithm、core,名字长得跟套娃一样。
其实Linux I2C框架本质就两件事:控制器驱动和设备驱动,把这俩拆开理解,就很清晰明了了。
2.1 总体架构:用户空间 ↔ 内核空间

Linux下访问I2C有两条路。
第一条是用户空间直接访问,通过 /dev/i2c-0 等设备节点,配合ioctl、read、write或者直接用i2c-tools,适合调试、验证硬件和快速测试。
第二条就是写内核驱动,也是今天文章的重点,内核里有一整套I2C框架,大概结构是:用户空间 → /dev/i2c-N → i2c-dev → i2c-core → i2c-adapter → I2C控制器,设备驱动也挂在i2c-core下面。Linux把"总线"和"设备"解耦了,这个设计非常经典。
2.2 I2C 适配器驱动(Controller 端)

adapter本质就是I2C控制器,也就是SoC里那个I2C硬件模块,RK3568有、STM32有、IMX6ULL也有。Linux会把它抽象成 struct i2c_adapter,里面最关键的是 struct i2c_algorithm *algo,algo里最核心的函数是 master_xfer,这才是真正干活的人。设备驱动调用 i2c_transfer() 最后都会走到 master_xfer(),然后控制硬件寄存器发时序。你会发现Linux驱动模型特别像分层架构,上层根本不关心底层控制器,因为全被抽象掉了。一个简化的adapter注册就是填充好算法和adapter结构体,调用 i2c_add_adapter() 就注册进内核了。当然,真正芯片驱动会复杂很多,要处理中断、DMA、时钟、FIFO、错误恢复、仲裁丢失,这些东西写起来能把人头发搞没。
一个简化版adapter注册示例:
static struct i2c_algorithm demo_algo = {
.master_xfer = demo_master_xfer,
.functionality = demo_func,
};
static struct i2c_adapter demo_adapter = {
.owner = THIS_MODULE,
.class = I2C_CLASS_HWMON,
.algo = &demo_algo,
.name = "demo-i2c",
};
然后:
i2c_add_adapter(&demo_adapter);
当然,真正芯片驱动会复杂很多,要处理中断、DMA、时钟、FIFO、错误恢复、仲裁丢失等等。
2.3 I2C 设备驱动(Client 端)

i2c_driver是我们设备驱动开发者的主要工作对象。它核心包含一个probe函数指针和一个remove函数指针,以及用于设备匹配的id_table和of_match_table。当i2c_driver和i2c_client匹配成功后,probe函数被调用,我们在里面完成设备初始化、字符设备注册等操作。
i2c_client代表挂在I2C总线上的从设备,每个i2c_client描述一个真实的物理设备,包含设备地址、名字、依附的适配器等信息。
**创建i2c_client的方法有四种:**第一种是通过i2c_register_board_info静态注册板级信息(这是内核2.6时代的做法,现在基本被淘汰了)。第二种是在代码中获取adapter后调用i2c_new_device动态创建。第三种是通过设备树,这是当前主流方式,后面会详细展开。第四种是用户空间实例化,即通过/sys/bus/i2c/devices/下的new_device属性文件动态创建i2c_client。后两种方式在实际项目中使用最频繁,设备树用于系统启动时自动挂载,用户空间实例化用于调试和热插拔场景。
三、设备树里的 I2C
以前Linux内核里设备信息很多写在板级文件,现在基本都设备树,如果你不会设备树,做ARM Linux已经很难混了,真的。
3.1 I2C 控制器节点怎么写
以一款基于 ARM 的嵌入式开发板为例,假设其 I2C1 控制器的设备树节点如下:
&i2c1 {
status = "okay";
pinctrl-names = "default";
pinctrl-0 = <&i2c1m0_xfer>;
clock-frequency = <100000>;
};
status 属性用于表示该控制器是否启用,"okay" 表示已启用 。pinctrl-names 和 pinctrl-0 用于配置引脚复用,这里将引脚复用为 I2C1 的功能引脚 。clock-frequency 属性设置 I2C 总线的时钟频率,这里设置为 100kHz,这是 I2C 标准模式下的常见频率 。不同的硬件平台可能会有不同的引脚配置和属性设置,需要根据具体的硬件手册进行配置 。例如,某些平台可能需要设置更多的电气属性,如引脚的驱动强度、上下拉电阻等,以确保 I2C 通信的稳定性 。
3.2 I2C 设备子节点的标准属性

reg(设备地址)
reg 属性用于指定 I2C 设备在总线上的地址 。例如,对于一个地址为 0x50 的 I2C 设备,其 reg 属性的设置如下:
reg = <0x50>;
I2C 设备地址通常为 7 位或 10 位 。在设备树中设置 reg 属性时,对于 7 位地址,直接填写 7 位地址值;对于 10 位地址,需要按照特定的格式进行设置 。例如,10 位地址 0x123,在设备树中可能需要拆分为两个部分进行设置 。
compatible(匹配字符串)
compatible 属性是设备树与驱动匹配的关键 。它是一个字符串,用于描述设备的兼容性信息 。在驱动程序中,会定义一个 of_device_id 数组,其中包含了与设备兼容的字符串 。当内核解析设备树时,会将设备节点的 compatible 属性与驱动中的 of_device_id 数组进行匹配 。例如,在一个温度传感器的驱动中,of_device_id 数组可能如下:
static const struct of_device_id temperature_sensor_of_match[] = {
{.compatible = "acme,temperature-sensor-123"},
{}
};
而在设备树中,温度传感器的设备节点的 compatible 属性设置为:
compatible = "acme,temperature-sensor-123";
这样,当内核加载驱动时,就能够通过 compatible 属性找到对应的设备节点,完成设备与驱动的匹配 。
自定义属性(如中断、时钟)
除了标准属性外,设备树中还可以添加自定义属性,例如,对于一个带有中断功能的 I2C 设备,可能需要添加中断相关的属性:
interrupt-parent = <&gpio0>;
interrupts = <RK_PA0 IRQ_TYPE_EDGE_FALLING>;
interrupt-parent 属性指定中断的父设备,这里为 gpio0 。interrupts 属性定义了中断的引脚和触发方式,RK_PA0 表示中断引脚,IRQ_TYPE_EDGE_FALLING 表示下降沿触发 。如果设备需要特定的时钟源,还可以添加时钟相关的属性:
clocks = <&clk1>;
clock-names = "sensor_clk";
clocks 属性指定时钟源,这里为 clk1 。clock-names 属性为时钟命名,方便在驱动中引用 。通过这些自定义属性,能够更加准确地描述设备的特性和需求,为驱动开发提供更多的信息 。
3.3 设备树与 i2c_driver 的匹配过程

内核启动时解析设备树,遍历I2C控制器下的子节点,为每个子节点创建一个i2c_client并注册到I2C总线上。当i2c_driver通过module_i2c_driver宏注册到内核时,I2C核心会遍历总线上的所有i2c_client,用of_match_table进行匹配。匹配规则是从"最具体"到"最通用",优先精确匹配compatible字符串。
具体来说,匹配走两条路径。第一条是OF风格匹配(设备树匹配):比较i2c_client的设备树节点中compatible属性与i2c_driver中of_match_table的每一项。第二条是传统ID表匹配:比较i2c_client的name字段与id_table中的name字段。两条路径任意一条匹配成功,probe函数就会被调用,传入匹配到的i2c_client指针。
3.4 实战:在设备树中添加一个 AT24C02 EEPROM
假设我们要在设备树中添加一个 AT24C02 EEPROM,其连接到 I2C1 总线上,地址为 0x50 。首先,找到设备树中 I2C1 控制器的节点,在其下面添加 AT24C02 的子节点:
&i2c1 {
status = "okay";
pinctrl-names = "default";
pinctrl-0 = <&i2c1m0_xfer>;
clock-frequency = <100000>;
at24c02@50 {
compatible = "atmel,at24c02";
reg = <0x50>;
};
};
compatible 属性设置为 "atmel,at24c02",表示该设备与 AT24C02 EEPROM 兼容 。reg 属性设置为 0x50,指定了设备在 I2C 总线上的地址 。保存设备树文件后,重新编译设备树 。编译完成后,将新的设备树文件烧录到开发板中 。启动开发板,内核会解析新的设备树 。如果一切配置正确,内核会找到 AT24C02 EEPROM 的设备节点,并根据 compatible 属性匹配到相应的驱动程序 。此时,可以通过 dmesg 命令查看内核日志,确认设备是否成功注册 。如果设备注册成功,日志中会显示类似 "i2c i2c-1: Added multiplexed i2c bus 2" 的信息 。通过这样的步骤,就完成了在设备树中添加 AT24C02 EEPROM 的操作。
👉 【就业避坑】C++ 就业前景全解析:为什么劝退声不断,大厂核心岗仍刚需 C++?
👉 【大厂技术栈路线】Linux C/C++ 后端开发系统学习路线
👉 【音视频】音视频流媒体高级开发核心学习路径
👉 【Qt进阶】C++ Qt 桌面 & 嵌入式开发一条龙学习攻略
👉 【内核底层】Linux 内核硬核修炼指南
👉 【面试冲刺】C/C++ 高频八股面试题 1000 题(三)
👉 【项目实战】手撕线程池:C++ 程序员的能力试金石
四、手把手写一个 I2C 设备驱动
这是本文最核心部分了,咱们今天自己从零搭一个,目标很简单:写一个最基础的I2C字符设备驱动,能probe、读写寄存器、创建设备节点、用户空间访问,够用了。

4.1 驱动骨架搭建
驱动开发最核心的就是搭建骨架,例如,<linux/init.h>和<linux/module.h>是 Linux 内核模块初始化和管理的基础头文件,它们为驱动模块的加载和卸载提供了必要的函数和宏定义 。<linux/i2c.h>则是 I2C 子系统的核心头文件,其中定义了 I2C 通信所需的各种结构体、函数和常量,如 i2c_client、i2c_driver 等结构体,以及 i2c_transfer、i2c_smbus_read_byte_data 等函数 。
#include <linux/init.h>
#include <linux/module.h>
#include <linux/i2c.h>
然后声明驱动的许可证类型,通过添加MODULE_LICENSE("GPL");声明,表明该驱动遵循 GPL 开源协议 。
MODULE_LICENSE("GPL");
定义 i2c_driver 结构体是驱动骨架搭建的核心环节 。i2c_driver 结构体包含了驱动的各种关键信息和操作函数指针 。其中,probe函数是当驱动与设备匹配成功后被调用的函数,用于设备的初始化工作 。在probe函数中,通常会完成设备资源的申请、寄存器的配置、中断的设置等操作 。例如,对于一个 I2C 接口的加速度传感器,在probe函数中,可能需要申请传感器的中断号,配置传感器的工作模式寄存器,使其能够正常采集加速度数据 。remove函数则在设备从系统中移除时被调用,用于释放设备占用的资源,如释放中断号、释放内存等,确保系统资源的有效管理和回收 。
static int xxx_probe(struct i2c_client *client, const struct i2c_device_id *id) {
// 设备初始化代码
return 0;
}
static int xxx_remove(struct i2c_client *client) {
// 资源释放代码
return 0;
}
struct i2c_driver xxx_driver = {
.probe = xxx_probe,
.remove = xxx_remove,
.driver = {
.name = "xxx_device",
.of_match_table = of_match_ptr(xxx_of_match),
},
.id_table = xxx_id_table,
};
在上述代码中,xxx_driver是定义的 i2c_driver 结构体实例,probe和remove成员分别指向对应的函数 。driver成员是一个struct device_driver结构体,其中name字段定义了驱动的名称,of_match_table字段用于设备树匹配,通过of_match_ptr宏指向一个of_device_id数组,该数组中包含了与设备兼容的字符串,用于在设备树中查找匹配的设备节点 。id_table成员则是一个i2c_device_id数组,用于传统的设备 ID 匹配 。通过这样的定义,一个基本的 i2c_driver 结构体就搭建完成了,为后续的设备驱动功能实现奠定了基础 。
4.2 实现基本读写函数
i2c_smbus_read_byte_data和i2c_smbus_write_byte_data是 Linux 内核 I2C 子系统提供的便捷函数,用于实现对 I2C 设备单个字节数据的读写操作 。
#include <linux/i2c.h>
// 读取设备指定寄存器的一个字节数据
s32 i2c_smbus_read_byte_data(struct i2c_client *client, u8 command) {
return i2c_smbus_access(client, I2C_SMBUS_READ, command, I2C_SMBUS_BYTE_DATA, NULL);
}
// 向设备指定寄存器写入一个字节数据
s32 i2c_smbus_write_byte_data(struct i2c_client *client, u8 command, u8 value) {
union i2c_smbus_data data;
data.byte = value;
return i2c_smbus_access(client, I2C_SMBUS_WRITE, command, I2C_SMBUS_BYTE_DATA, &data);
}
i2c_smbus_read_byte_data函数用于读取设备指定寄存器的一个字节数据 。它接收两个参数,client是指向 i2c_client 结构体的指针,代表要操作的 I2C 设备;command是要读取的寄存器地址 。函数内部通过调用i2c_smbus_access函数来完成实际的读取操作,I2C_SMBUS_READ表示读操作,I2C_SMBUS_BYTE_DATA表示按字节数据模式进行访问 。 i2c_smbus_write_byte_data函数则用于向设备指定寄存器写入一个字节数据 。它接收三个参数,client和command的含义与读取函数相同,value是要写入的数据 。在函数内部,首先将value存储在union i2c_smbus_data联合体中,然后调用i2c_smbus_access函数进行写入操作,I2C_SMBUS_WRITE表示写操作 。
原始的i2c_transfer接口则提供了更底层、更灵活的 I2C 数据传输方式 。它可以支持多个消息的批量发送和接收,适用于一些复杂的通信场景 。i2c_transfer函数的原型如下:
int i2c_transfer(struct i2c_adapter *adap, struct i2c_msg *msgs, int num);
adap是指向 i2c_adapter 结构体的指针,代表 I2C 适配器;msgs是一个struct i2c_msg结构体数组,每个i2c_msg结构体描述了一次 I2C 数据传输的相关信息,如目标设备地址、读写标志、数据缓冲区等;num表示msgs数组中消息的数量 。例如,当需要向 I2C 设备连续写入多个寄存器的值时,可以通过构造多个i2c_msg结构体,并将它们组成数组传递给i2c_transfer函数来实现 。在一些对时序要求严格或者需要进行特殊通信协议实现的场景下,i2c_transfer接口能够提供更精细的控制,但同时也需要开发者对 I2C 通信协议有更深入的理解和掌握 。相比之下,i2c_smbus_read_byte_data和i2c_smbus_write_byte_data函数更适合于简单的单字节数据读写场景,使用起来更加便捷 。
4.3 创建字符设备接口
在 Linux 系统中,创建字符设备接口是实现用户空间与 I2C 设备驱动交互的重要途径 。首先,需要注册设备号 。设备号是内核识别设备的关键标识,分为主设备号和次设备号 。可以使用alloc_chrdev_region函数动态分配设备号,也可以使用register_chrdev_region函数静态指定设备号 。以下是动态分配设备号的示例代码:
#include <linux/fs.h>
dev_t devno;
if (alloc_chrdev_region(&devno, 0, 1, "xxx_device")) {
printk(KERN_ERR "Failed to allocate device number\n");
return -1;
}
alloc_chrdev_region函数接收四个参数,第一个参数&devno用于返回分配的设备号;第二个参数0表示起始次设备号;第三个参数1表示要分配的设备数量;第四个参数"xxx_device"是设备的名称 。如果分配成功,devno将包含分配到的设备号;如果分配失败,函数将返回一个负数 。
接下来,需要注册字符设备类(class)和字符设备(cdev) 。字符设备类是一种逻辑上的分类,用于在/sys/class目录下创建一个对应的类目录,方便用户空间通过该目录访问设备 。使用class_create函数创建字符设备类:
#include <linux/device.h>
struct class *class;
class = class_create(THIS_MODULE, "xxx_class");
if (IS_ERR(class)) {
printk(KERN_ERR "Failed to create class\n");
unregister_chrdev_region(devno, 1);
return -1;
}
class_create函数接收两个参数,第一个参数THIS_MODULE表示当前模块;第二个参数"xxx_class"是类的名称 。如果创建成功,将返回一个指向struct class结构体的指针;如果创建失败,返回一个错误指针 。
创建字符设备 cdev 并将其添加到系统中:
#include <linux/cdev.h>
struct cdev *cdev;
cdev = cdev_alloc();
if (!cdev) {
printk(KERN_ERR "Failed to allocate cdev\n");
class_destroy(class);
unregister_chrdev_region(devno, 1);
return -1;
}
cdev->ops = &xxx_fops;
cdev->owner = THIS_MODULE;
if (cdev_add(cdev, devno, 1)) {
printk(KERN_ERR "Failed to add cdev\n");
cdev_del(cdev);
class_destroy(class);
unregister_chrdev_region(devno, 1);
return -1;
}
cdev_alloc函数用于分配一个struct cdev结构体 。然后设置cdev的操作函数集合ops,指向预先定义好的xxx_fops结构体,该结构体中包含了open、read、write、ioctl等函数指针,实现了对设备的具体操作逻辑 。cdev->owner设置为THIS_MODULE,表示该设备属于当前模块 。最后,使用cdev_add函数将cdev添加到系统中,如果添加失败,需要进行相应的资源释放操作 。
实现open、read、write、ioctl等函数是字符设备接口的核心功能 。以read函数为例,其实现可能如下:
ssize_t xxx_read(struct file *filp, char __user *buf, size_t count, loff_t *offt) {
// 从I2C设备读取数据
// 将数据复制到用户空间buf中
return count;
}
在read函数中,首先从 I2C 设备读取数据,然后使用copy_to_user函数将数据复制到用户空间的缓冲区buf中 。count表示要读取的数据长度,offt表示文件偏移量 。write函数和ioctl函数的实现类似,根据具体的设备功能和需求,完成相应的数据写入和控制操作 。
在用户空间,可以使用echo和cat命令对设备进行简单的测试 。例如,假设设备节点为/dev/xxx_device,可以使用以下命令向设备写入数据:
echo "test data" > /dev/xxx_device
使用以下命令从设备读取数据:
cat /dev/xxx_device
通过这些命令,可以验证字符设备接口的正确性和设备驱动的功能是否正常 。
4.4 编译与加载验证
编写 Makefile 是将驱动代码编译成可加载模块的关键步骤 。Makefile 定义了编译的规则和依赖关系,指导编译器如何将源文件编译成目标文件,并最终生成可加载的内核模块(.ko 文件) 。以下是一个简单的 Makefile 示例:
# 指定内核源码路径(通常通过环境变量传递)
KERNEL_DIR := /lib/modules/$(shell uname -r)/build
# 指定目标模块名称(生成xxx.ko)
obj-m := xxx.o
# 默认构建目标
all:
make -C $(KERNEL_DIR) M=$(PWD) modules
clean:
make -C $(KERNEL_DIR) M=$(PWD) clean
在这个 Makefile 中,KERNEL_DIR变量指定了内核源码的路径,通常通过uname -r命令获取当前内核版本,并拼接上/lib/modules/路径 。obj-m变量指定了要编译的目标模块,xxx.o表示源文件 。all目标是默认的构建目标,使用make -C (KERNEL_DIR) M=(PWD) modules命令,-C选项指定切换到内核源码目录,M=(PWD)表示当前驱动代码所在的目录,modules表示编译模块 。clean目标用于清理编译生成的文件,使用make -C (KERNEL_DIR) M=$(PWD) clean命令 。
使用insmod命令加载驱动模块,并通过dmesg命令分析日志,是验证驱动是否正常工作的重要方法 。在终端中执行以下命令加载驱动:
sudo insmod xxx.ko
加载成功后,可以使用dmesg命令查看内核日志,查看驱动加载过程中是否有错误信息输出 。例如,如果驱动在probe函数中出现错误,dmesg日志中会显示相应的错误提示 。
dmesg | grep xxx
/dev节点的生成与权限设置也非常重要 。当驱动加载成功后,内核会根据之前注册的设备号和字符设备类,在/dev目录下生成对应的设备节点 。为了确保用户能够正常访问设备节点,需要设置合适的权限 。可以使用chmod命令设置设备节点的权限,例如:
sudo chmod 666 /dev/xxx_device
这样,普通用户就可以对/dev/xxx_device设备节点进行读写操作,从而实现与 I2C 设备的交互 。通过以上编译与加载验证的步骤,可以逐步排查和解决驱动开发过程中可能出现的问题,确保 I2C 设备驱动能够正常工作 。
五、用户空间访问方案
很多人学Linux I2C时会先接触 i2cdetect,然后感觉"好像不用写驱动也能通信?" 对,确实可以。

5.1 i2c-tools 工具集
i2cdetect 是工具集中用于扫描设备的利器,使用 i2cdetect -l 命令可以列出系统中所有的 I2C 总线,例如在基于 ARM 的嵌入式开发板上,执行该命令后,可能会显示类似如下信息:
i2c-0 i2c imx6q-i2c I2C adapter
i2c-1 i2c imx6q-i2c I2C adapter
这表明系统中有两个 I2C 总线,分别为 i2c-0 和 i2c-1 。若要扫描 i2c-1 总线上的设备,可使用命令 i2cdetect -y 1 ,其中 -y 选项表示在扫描过程中自动确认,无需用户手动输入确认信息 。扫描结果会以矩阵形式呈现,例如:
0 1 2 3 4 5 6 7 8 9 a b c d e f
00: -- -- -- -- -- -- -- -- -- -- -- -- --
10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
30: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
40: -- -- -- -- -- -- -- -- 48 -- -- -- -- -- -- --
50: 50 -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
60: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
70: 70 -- -- -- -- -- -- --
从结果中可以看出,在 i2c-1 总线上检测到了地址为 0x48、0x50 和 0x70 的设备 。这对于快速定位总线上的设备非常方便,在调试 I2C 设备时,通过 i2cdetect 扫描,能够迅速确认设备是否正确连接以及设备地址是否正确 。
i2cdump、i2cset 和 i2cget 则主要用于寄存器的读写操作 。i2cdump 用于读取 I2C 设备的所有寄存器内容 。例如,要读取地址为 0x50 的 EEPROM 设备的所有寄存器,可使用命令 i2cdump -y 1 0x50 ,其中 1 表示 I2C 总线编号,0x50 为设备地址 。命令执行后,会以表格形式输出 EEPROM 的寄存器值,每一行代表 16 个寄存器,方便查看设备的配置和数据存储情况 。
0 1 2 3 4 5 6 7 8 9 a b c d e f 0123456789abcdef
00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
10: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
20: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
i2cget 用于读取设备特定寄存器的值 。假设要读取地址为 0x48 的温度传感器的温度寄存器(假设地址为 0x00),可使用命令 i2cget -y 1 0x48 0x00 ,执行后会返回该寄存器的值,通过解析这个值,就可以得到温度传感器当前测量的温度数据 。 i2cset 用于向设备寄存器写入数据 。例如,要向地址为 0x20 的 GPIO 扩展器的某个寄存器(假设地址为 0x01)写入值 0x55,可使用命令 i2cset -y 1 0x20 0x01 0x55 ,这样就可以配置 GPIO 扩展器的相应引脚状态 。这些工具的使用,极大地简化了 I2C 设备寄存器的读写操作,提高了开发和调试的效率 。
5.2 用户空间直接操作 I2C 设备
在用户空间,还可以直接通过打开 /dev/i2c-N 设备节点并使用 ioctl 系统调用来操作 I2C 设备 。
以读取一个 I2C 设备的寄存器值为例:
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <linux/i2c.h>
#include <linux/i2c-dev.h>
#define I2C_ADDR 0x50 // 设备地址
#define REG_ADDR 0x00 // 寄存器地址
int main() {
int fd;
char buf[2];
struct i2c_rdwr_ioctl_data packets;
struct i2c_msg messages[2];
// 打开I2C设备文件
fd = open("/dev/i2c-1", O_RDWR);
if (fd < 0) {
perror("Failed to open I2C device");
return 1;
}
// 设置I2C设备地址
if (ioctl(fd, I2C_SLAVE, I2C_ADDR) < 0) {
perror("Failed to set I2C address");
close(fd);
return 1;
}
// 构造写消息,发送寄存器地址
messages[0].addr = I2C_ADDR;
messages[0].flags = 0;
messages[0].len = 1;
messages[0].buf = ®_ADDR;
// 构造读消息,读取寄存器值
messages[1].addr = I2C_ADDR;
messages[1].flags = I2C_M_RD;
messages[1].len = 1;
messages[1].buf = buf;
// 构造I2C读写数据包
packets.nmsgs = 2;
packets.msgs = messages;
// 执行I2C读写操作
if (ioctl(fd, I2C_RDWR, &packets) < 0) {
perror("Failed to perform I2C transfer");
} else {
printf("Register value: 0x%02x\n", buf[0]);
}
close(fd);
return 0;
}
在这段代码中,通过 open 函数打开 /dev/i2c-1 设备文件,获取文件描述符 fd 。使用 ioctl 函数设置 I2C 设备地址 。然后构造了两个 i2c_msg 结构体,一个用于发送寄存器地址(写操作),另一个用于读取寄存器值(读操作) 。将这两个消息组合成一个 i2c_rdwr_ioctl_data 结构体,并通过 ioctl 函数执行 I2C 读写操作 。最后,关闭设备文件 。
与内核驱动方案相比,用户空间直接操作 I2C 设备的优点是开发简单,不需要深入了解内核机制,对于一些简单的 I2C 设备测试和应用场景非常适用 。但它由于用户空间和内核空间的隔离,每次 I2C 操作都需要进行用户态和内核态的切换,这会带来一定的性能开销 。在需要频繁进行 I2C 通信的场景下,这种性能损耗会比较明显 。
5.3 为何需要内核驱动
用户空间驱动听上去很美好------开发快,调起来也方便,不用跟内核模块的编译加载斗智斗勇。
但实际产品中,很少有人用纯用户空间方案。为什么呢?
第一是中断处理------很多I2C传感器都有中断引脚,比如加速度计的data ready中断,你不用内核驱动怎么响应?写个用户空间程序轮询GPIO?延迟高、功耗大,基本没法商用。
第二是功耗管理------内核的runtime PM框架能自动管理设备休眠唤醒,用户空间程序很难做到这一点。
第三是多进程互斥------多个进程同时访问同一个I2C设备怎么办?内核驱动可以用mutex保护临界区,用户空间程序只能靠文件锁,死锁风险大了好几个量级。
第四是框架集成------如果传感器数据要通过IIO子系统上报给Android的Sensor HAL,那你必须写内核驱动,用户空间程序完全无法融入这个框架。
六、Linux 内核 I2C 调试技巧分享
i2cdetect前面已经说过了,这里重点聊聊其他调试手段。

在编译内核时,可以开启CONFIG_I2C_DEBUG_CORE和CONFIG_I2C_DEBUG_BUS等选项,或者在模块加载时通过内核命令行参数i2c_core.dyndbg=+p来开启I2C核心的动态调试输出。
最直接的排查手段就是看内核日志,用dmesg | grep i2c过滤I2C相关的日志,重点关注probe失败、通信超时、ACK错误等信息。如果看到"i2c i2c-0: sendbytes: NAK bailout",基本可以确定是设备地址错误或者设备不在线。
I2C通信故障定位中,ACK失败是最常见的,如果i2cdetect能扫到设备地址但驱动probe失败,先检查驱动的of_match_table是否包含了正确的compatible字符串,然后确认设备树中reg地址是不是7位地址。如果i2cdetect完全扫不到设备,先检查硬件连接------用万用表量一下SCL和SDA线上是否有上拉电压(通常是3.3V或1.8V),再用示波器看看有没有信号。如果是时序异常,可能是因为上拉电阻不合适或总线电容过大,导致上升沿太慢无法在采样窗口内达到高电平,时钟频率越高对时序的要求就越苛刻。
推荐大家一个很实用的工具------i2c-stub。这是一个内核模块,可以模拟一个假的I2C芯片,不需要任何硬件就能测试你的驱动逻辑。它的使用流程是:加载i2c-stub模块(指定要模拟的芯片地址),然后使用i2cset预加载一些寄存器数据,再加载你的设备驱动模块,驱动就会把它当成真实的I2C设备来操作。i2c-tools包里还附带了一个i2c-stub-from-dump脚本,能从真实的芯片转储数据中自动加载寄存器值到stub设备中。这对于没有硬件但是要开发或测试驱动的场景来说简直是救了大命!
七、I2C DMA 与性能优化
虽然I2C是个低速总线,但当数据量大到一定程度时,传统的PIO模式会成为瓶颈。DMA传输可以减少CPU参与,让CPU在I2C传输期间去干别的事情。

在内核官方文档中有说到:I2C消息的buffer并不强制要求是DMA安全的,因为大部分I2C传输的数据量都很小,设置DMA的开销可能比PIO传输还大。但对于消息大小超过8字节的场景,建议使用DMA安全的buffer。对于16字节及以上的消息,使用DMA基本是稳赚不赔的。具体做法是在i2c_msg的flags中设置I2C_M_DMA_SAFE标志:
msg.flags |= I2C_M_DMA_SAFE;
高通平台有一种叫BEI(Block Event Interrupt)的优化机制。传统模式下,一个I2C传输中的每条消息完成都会产生一个中断,N条消息就N个中断,中断处理的开销相当可观。BEI机制将多条消息分组,每组64条消息只产生一次中断,从而大幅降低中断延迟。实测数据很亮眼------对于200条I2C写消息的传输,优化前耗时168ms,优化后仅需48ms,性能提升了3.5倍。
I2C频率调优方面,标准模式100kHz、快速模式400kHz、快速+模式1MHz是三种常见的速率。但要注意,频率越高对硬件要求越苛刻。上拉电阻的典型值在4.7kΩ(100kHz)到2.2kΩ(400kHz)之间,具体要根据总线电容和电压来计算。如果总线电容太大(比如布线太长或设备太多),高频信号的上升沿可能无法在有效采样窗口内达到逻辑高电平,导致通信错误。
八、项目实战:智能家居I2C设备管理系统
接下来我们实现一个抽象的、高鲁棒性的多设备管理系统。
它的核心思想是只对外暴露一个统一的主设备节点(比如 /dev/gordon_house_manager),内部通过一套精妙的协议框架,来动态管理、识别、和调度所有挂在总线上的智能设备:
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/i2c.h>
#include <linux/fs.h>
#include <linux/uaccess.h>
#include <linux/cdev.h>
#include <linux/slab.h>
#include <linux/mutex.h>
#include <linux/delay.h>
#define MAX_SLOTS 4
#define MAGIC_CMD_GET_DATA _IOR('H', 0x01, struct house_user_data)
/* 1. 抽象出具体设备的类型 */
enum dev_type {
TYPE_UNKNOWN = 0,
TYPE_AHT20, /* 温湿度 */
TYPE_AT24C02, /* 存储器 */
};
/* 2. 与应用层打交道的统一数据结构 */
struct house_user_data {
int slot_id;
int is_alive;
u32 device_type;
u8 raw_payload[8]; /* 吐出来的原始裸数据 */
};
/* 3. 每一个内部虚拟卡槽的抽象描述 */
struct smart_slot {
int id;
u16 i2c_addr;
enum dev_type type;
int online_status;
};
/* 4. 整个管理系统的核心私有大结构体 */
struct house_manager_priv {
struct i2c_client *client;
struct cdev cdev;
struct class *class;
dev_t dev_num;
struct mutex sys_lock;
struct smart_slot slots[MAX_SLOTS];
};
staticstruct house_manager_priv *g_mgr = NULL;
/* 5. 核心探针函数:负责去盲测摸底某个插槽上的设备还在不在 */
static int probe_slot_hardware(struct i2c_adapter *adapter, u16 addr)
{
struct i2c_msg msg;
u8 test_reg = 0x00;
int ret;
/* 试探性地发一个字节的写请求,看对方回不回ACK */
msg.addr = addr;
msg.flags = 0;
msg.len = 1;
msg.buf = &test_reg;
ret = i2c_transfer(adapter, &msg, 1);
return (ret == 1) ? 1 : 0; /* 回了ACK返回1,装死返回0 */
}
/* 6. 统一的系统控制台:应用层通过ioctl发起命令,瞬间调取任意卡槽的数据 */
static long house_ioctl(struct file *file, unsigned int cmd, unsigned long arg)
{
struct house_manager_priv *priv = file->private_data;
struct house_user_data udata;
int ret = 0;
if (cmd != MAGIC_CMD_GET_DATA) return -EINVAL;
if (copy_from_user(&udata, (void __user *)arg, sizeof(struct house_user_data))) {
return -EFAULT;
}
if (udata.slot_id < 0 || udata.slot_id >= MAX_SLOTS) return -EINVAL;
mutex_lock(&priv->sys_lock);
/* 实时探阵:检查这颗芯片此时此刻还在不在总线上 */
if (!probe_slot_hardware(priv->client->adapter, priv->slots[udata.slot_id].i2c_addr)) {
priv->slots[udata.slot_id].online_status = 0;
udata.is_alive = 0;
dev_warn(&priv->client->dev, "警报!位于插槽 %d 的设备失联了!\n", udata.slot_id);
} else {
priv->slots[udata.slot_id].online_status = 1;
udata.is_alive = 1;
udata.device_type = priv->slots[udata.slot_id].type;
/* 针对不同的设备类型,执行不同的时序读取算法 */
if (priv->slots[udata.slot_id].type == TYPE_AHT20) {
/* 假装读取温湿度传感器数据 */
udata.raw_payload[0] = 0x3C; /* 模拟温度 */
udata.raw_payload[1] = 0x5A; /* 模拟湿度 */
} else if (priv->slots[udata.slot_id].type == TYPE_AT24C02) {
/* 从存储器特定位置抠个字节出来 */
int val = i2c_smbus_read_byte_data(priv->client, 0x00);
udata.raw_payload[0] = (val >= 0) ? (u8)val : 0x00;
}
}
mutex_unlock(&priv->sys_lock);
if (copy_to_user((void __user *)arg, &udata, sizeof(struct house_user_data))) {
return -EFAULT;
}
return ret;
}
static int house_open(struct inode *inode, struct file *file)
{
file->private_data = g_mgr;
return 0;
}
static conststruct file_operations house_fops = {
.owner = THIS_MODULE,
.open = house_open,
.unlocked_ioctl = house_ioctl,
};
/* 总线匹配上后的初始化工作 */
static int house_manager_probe(struct i2c_client *client, const struct i2c_device_id *id)
{
int ret;
dev_info(&client->dev, "智能家居管理中枢系统正式启动...\n");
g_mgr = devm_kzalloc(&client->dev, sizeof(struct house_manager_priv), GFP_KERNEL);
if (!g_mgr) return -ENOMEM;
g_mgr->client = client;
mutex_init(&g_mgr->sys_lock);
/* 初始化我们的虚拟卡槽配置 */
g_mgr->slots[0].id = 0; g_mgr->slots[0].i2c_addr = 0x50; g_mgr->slots[0].type = TYPE_AT24C02;
g_mgr->slots[1].id = 1; g_mgr->slots[1].i2c_addr = 0x38; g_mgr->slots[1].type = TYPE_AHT20;
/* 动态骨架包装 */
ret = alloc_chrdev_region(&g_mgr->dev_num, 0, 1, "house_mgr");
if (ret < 0) return ret;
cdev_init(&g_mgr->cdev, &house_fops);
ret = cdev_add(&g_mgr->cdev, g_mgr->dev_num, 1);
if (ret < 0) goto err_chrdev;
g_mgr->class = class_create(THIS_MODULE, "house_mgr_class");
if (IS_ERR(g_mgr->class)) {
ret = PTR_ERR(g_mgr->class);
goto err_cdev;
}
device_create(g_mgr->class, NULL, g_mgr->dev_num, NULL, "gordon_house_manager");
dev_info(&client->dev, "工业级中枢系统部署大功告成!\n");
return 0;
err_cdev:
cdev_del(&g_mgr->cdev);
err_chrdev:
unregister_chrdev_region(g_mgr->dev_num, 1);
return ret;
}
static int house_manager_remove(struct i2c_client *client)
{
device_destroy(g_mgr->class, g_mgr->dev_num);
class_destroy(g_mgr->class);
cdev_del(&g_mgr->cdev);
unregister_chrdev_region(g_mgr->dev_num, 1);
dev_info(&client->dev, "中枢系统安全关闭卸载!\n");
return 0;
}
static conststruct of_device_id house_of_match[] = {
{ .compatible = "gordon,house-manager" },
{ }
};
MODULE_DEVICE_TABLE(of, house_of_match);
staticstruct i2c_driver house_driver = {
.driver = {
.name = "house_manager_driver",
.of_match_table = house_of_match,
},
.probe = house_manager_probe,
.remove = house_manager_remove,
};
module_i2c_driver(house_driver);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Gordon");
这套架构对外的接口缩减成了一个,应用层App只需要定时通过 ioctl 轮询这个设备节点。如果用户半路把0x38地址的传感器模块给拔了,probe_slot_hardware 会在几微秒之内敏锐地捕捉到波形异常(没有ACK),立马把 is_alive 置零并安全返回。上层App一看这个标志位变零了,就能非常体面地在UI上显示一行"传感器已断开连接",而绝对不会导致整个系统卡死或者内核崩溃。
附录
I2C 时序图示例
单字节写时序图
在单字节写时序中,主设备首先发送起始条件(START),即 SDA 在 SCL 高电平时由高变低 。接着发送 7 位从设备地址和 1 位写位(SLAVE ADDR+W),从设备接收到地址后返回 ACK 信号 。然后主设备发送一个字节的数据(DATA),从设备接收数据后再次返回 ACK 信号 。最后主设备发送停止条件(STOP),即 SDA 在 SCL 高电平时由低变高,完成单字节写操作 。
多字节读时序图

多字节读时序相对复杂一些 。主设备先发送起始条件和从设备地址(SLAVE ADDR+W),从设备应答后,主设备再次发送起始条件和从设备地址(SLAVE ADDR+R),表示即将进行读操作 。从设备开始发送数据(DATA1、DATA2 等),每接收一个字节的数据,主设备都会发送 ACK 信号,直到最后一个字节,主设备发送 NACK 信号,通知从设备数据接收完毕,然后发送停止条件,结束多字节读操作 。
关键 API 函数速查表
在 Linux 内核 I2C 开发中,以下是一些关键的 API 函数速查表,方便开发者快速查阅和使用:
|--------------------------------|----------------------------|-----------------------------------------------------------------------------|
| 函数名 | 功能描述 | 示例代码 |
| i2c_add_adapter | 注册一个 I2C 适配器 | i2c_add_adapter(&my_i2c_adapter); |
| i2c_del_adapter | 注销一个 I2C 适配器 | i2c_del_adapter(&my_i2c_adapter); |
| i2c_new_device | 在 I2C 总线上创建一个新的 I2C 设备 | i2c_new_device(&my_i2c_adapter, &my_i2c_client); |
| i2c_unregister_device | 注销一个 I2C 设备 | i2c_unregister_device(&my_i2c_client); |
| i2c_transfer | 进行 I2C 数据传输,支持多个消息的批量发送和接收 | i2c_transfer(&my_i2c_adapter, &msgs, num_msgs); |
| i2c_smbus_read_byte_data | 读取 I2C 设备指定寄存器的一个字节数据 | i2c_smbus_read_byte_data(&my_i2c_client, reg_addr); |
| i2c_smbus_write_byte_data | 向 I2C 设备指定寄存器写入一个字节数据 | i2c_smbus_write_byte_data(&my_i2c_client, reg_addr, data); |
| i2c_smbus_read_word_data | 读取 I2C 设备指定寄存器的一个字(2 字节)数据 | i2c_smbus_read_word_data(&my_i2c_client, reg_addr); |
| i2c_smbus_write_word_data | 向 I2C 设备指定寄存器写入一个字(2 字节)数据 | i2c_smbus_write_word_data(&my_i2c_client, reg_addr, data); |
| i2c_smbus_read_i2c_block_data | 读取 I2C 设备指定寄存器的多个字节数据 | i2c_smbus_read_i2c_block_data(&my_i2c_client, reg_addr, num_bytes, data); |
| i2c_smbus_write_i2c_block_data | 向 I2C 设备指定寄存器写入多个字节数据 | i2c_smbus_write_i2c_block_data(&my_i2c_client, reg_addr, num_bytes, data); |
| i2c_get_adapter | 根据适配器编号获取 I2C 适配器 | struct i2c_adapter *adapter = i2c_get_adapter(adapter_num); |
| i2c_put_adapter | 释放 I2C 适配器 | i2c_put_adapter(adapter); |
| i2c_set_adapter_quirks | 设置 I2C 适配器的特性 | i2c_set_adapter_quirks(&my_i2c_adapter, I2C_QUIRK_IGNORE_NAK); |
Linux下访问I2C有两条路。
第一条是用户空间直接访问,通过 /dev/i2c-0 等设备节点,配合ioctl、read、write或者直接用i2c-tools,适合调试、验证硬件和快速测试。
第二条就是写内核驱动,也是今天文章的重点,内核里有一整套I2C框架,大概结构是:用户空间 → /dev/i2c-N → i2c-dev → i2c-core → i2c-adapter → I2C控制器,设备驱动也挂在i2c-core下面。Linux把"总线"和"设备"解耦了,这个设计非常经典。
2.2 I2C 适配器驱动(Controller 端)

adapter本质就是I2C控制器,也就是SoC里那个I2C硬件模块,RK3568有、STM32有、IMX6ULL也有。Linux会把它抽象成 struct i2c_adapter,里面最关键的是 struct i2c_algorithm *algo,algo里最核心的函数是 master_xfer,这才是真正干活的人。设备驱动调用 i2c_transfer() 最后都会走到 master_xfer(),然后控制硬件寄存器发时序。你会发现Linux驱动模型特别像分层架构,上层根本不关心底层控制器,因为全被抽象掉了。一个简化的adapter注册就是填充好算法和adapter结构体,调用 i2c_add_adapter() 就注册进内核了。当然,真正芯片驱动会复杂很多,要处理中断、DMA、时钟、FIFO、错误恢复、仲裁丢失,这些东西写起来能把人头发搞没。
一个简化版adapter注册示例:
static struct i2c_algorithm demo_algo = {
.master_xfer = demo_master_xfer,
.functionality = demo_func,
};
static struct i2c_adapter demo_adapter = {
.owner = THIS_MODULE,
.class = I2C_CLASS_HWMON,
.algo = &demo_algo,
.name = "demo-i2c",
};
然后:
i2c_add_adapter(&demo_adapter);
当然,真正芯片驱动会复杂很多,要处理中断、DMA、时钟、FIFO、错误恢复、仲裁丢失等等。
2.3 I2C 设备驱动(Client 端)

i2c_driver是我们设备驱动开发者的主要工作对象。它核心包含一个probe函数指针和一个remove函数指针,以及用于设备匹配的id_table和of_match_table。当i2c_driver和i2c_client匹配成功后,probe函数被调用,我们在里面完成设备初始化、字符设备注册等操作。
i2c_client代表挂在I2C总线上的从设备,每个i2c_client描述一个真实的物理设备,包含设备地址、名字、依附的适配器等信息。
**创建i2c_client的方法有四种:**第一种是通过i2c_register_board_info静态注册板级信息(这是内核2.6时代的做法,现在基本被淘汰了)。第二种是在代码中获取adapter后调用i2c_new_device动态创建。第三种是通过设备树,这是当前主流方式,后面会详细展开。第四种是用户空间实例化,即通过/sys/bus/i2c/devices/下的new_device属性文件动态创建i2c_client。后两种方式在实际项目中使用最频繁,设备树用于系统启动时自动挂载,用户空间实例化用于调试和热插拔场景。
三、设备树里的 I2C
以前Linux内核里设备信息很多写在板级文件,现在基本都设备树,如果你不会设备树,做ARM Linux已经很难混了,真的。
3.1 I2C 控制器节点怎么写
以一款基于 ARM 的嵌入式开发板为例,假设其 I2C1 控制器的设备树节点如下:
&i2c1 {
status = "okay";
pinctrl-names = "default";
pinctrl-0 = <&i2c1m0_xfer>;
clock-frequency = <100000>;
};
status 属性用于表示该控制器是否启用,"okay" 表示已启用 。pinctrl-names 和 pinctrl-0 用于配置引脚复用,这里将引脚复用为 I2C1 的功能引脚 。clock-frequency 属性设置 I2C 总线的时钟频率,这里设置为 100kHz,这是 I2C 标准模式下的常见频率 。不同的硬件平台可能会有不同的引脚配置和属性设置,需要根据具体的硬件手册进行配置 。例如,某些平台可能需要设置更多的电气属性,如引脚的驱动强度、上下拉电阻等,以确保 I2C 通信的稳定性 。
3.2 I2C 设备子节点的标准属性

reg(设备地址)
reg 属性用于指定 I2C 设备在总线上的地址 。例如,对于一个地址为 0x50 的 I2C 设备,其 reg 属性的设置如下:
reg = <0x50>;
I2C 设备地址通常为 7 位或 10 位 。在设备树中设置 reg 属性时,对于 7 位地址,直接填写 7 位地址值;对于 10 位地址,需要按照特定的格式进行设置 。例如,10 位地址 0x123,在设备树中可能需要拆分为两个部分进行设置 。
compatible(匹配字符串)
compatible 属性是设备树与驱动匹配的关键 。它是一个字符串,用于描述设备的兼容性信息 。在驱动程序中,会定义一个 of_device_id 数组,其中包含了与设备兼容的字符串 。当内核解析设备树时,会将设备节点的 compatible 属性与驱动中的 of_device_id 数组进行匹配 。例如,在一个温度传感器的驱动中,of_device_id 数组可能如下:
static const struct of_device_id temperature_sensor_of_match[] = {
{.compatible = "acme,temperature-sensor-123"},
{}
};
而在设备树中,温度传感器的设备节点的 compatible 属性设置为:
compatible = "acme,temperature-sensor-123";
这样,当内核加载驱动时,就能够通过 compatible 属性找到对应的设备节点,完成设备与驱动的匹配 。
自定义属性(如中断、时钟)
除了标准属性外,设备树中还可以添加自定义属性,例如,对于一个带有中断功能的 I2C 设备,可能需要添加中断相关的属性:
interrupt-parent = <&gpio0>;
interrupts = <RK_PA0 IRQ_TYPE_EDGE_FALLING>;
interrupt-parent 属性指定中断的父设备,这里为 gpio0 。interrupts 属性定义了中断的引脚和触发方式,RK_PA0 表示中断引脚,IRQ_TYPE_EDGE_FALLING 表示下降沿触发 。如果设备需要特定的时钟源,还可以添加时钟相关的属性:
clocks = <&clk1>;
clock-names = "sensor_clk";
clocks 属性指定时钟源,这里为 clk1 。clock-names 属性为时钟命名,方便在驱动中引用 。通过这些自定义属性,能够更加准确地描述设备的特性和需求,为驱动开发提供更多的信息 。
3.3 设备树与 i2c_driver 的匹配过程

内核启动时解析设备树,遍历I2C控制器下的子节点,为每个子节点创建一个i2c_client并注册到I2C总线上。当i2c_driver通过module_i2c_driver宏注册到内核时,I2C核心会遍历总线上的所有i2c_client,用of_match_table进行匹配。匹配规则是从"最具体"到"最通用",优先精确匹配compatible字符串。
具体来说,匹配走两条路径。第一条是OF风格匹配(设备树匹配):比较i2c_client的设备树节点中compatible属性与i2c_driver中of_match_table的每一项。第二条是传统ID表匹配:比较i2c_client的name字段与id_table中的name字段。两条路径任意一条匹配成功,probe函数就会被调用,传入匹配到的i2c_client指针。
3.4 实战:在设备树中添加一个 AT24C02 EEPROM
假设我们要在设备树中添加一个 AT24C02 EEPROM,其连接到 I2C1 总线上,地址为 0x50 。首先,找到设备树中 I2C1 控制器的节点,在其下面添加 AT24C02 的子节点:
&i2c1 {
status = "okay";
pinctrl-names = "default";
pinctrl-0 = <&i2c1m0_xfer>;
clock-frequency = <100000>;
at24c02@50 {
compatible = "atmel,at24c02";
reg = <0x50>;
};
};
compatible 属性设置为 "atmel,at24c02",表示该设备与 AT24C02 EEPROM 兼容 。reg 属性设置为 0x50,指定了设备在 I2C 总线上的地址 。保存设备树文件后,重新编译设备树 。编译完成后,将新的设备树文件烧录到开发板中 。启动开发板,内核会解析新的设备树 。如果一切配置正确,内核会找到 AT24C02 EEPROM 的设备节点,并根据 compatible 属性匹配到相应的驱动程序 。此时,可以通过 dmesg 命令查看内核日志,确认设备是否成功注册 。如果设备注册成功,日志中会显示类似 "i2c i2c-1: Added multiplexed i2c bus 2" 的信息 。通过这样的步骤,就完成了在设备树中添加 AT24C02 EEPROM 的操作。
四、手把手写一个 I2C 设备驱动
这是本文最核心部分了,咱们今天自己从零搭一个,目标很简单:写一个最基础的I2C字符设备驱动,能probe、读写寄存器、创建设备节点、用户空间访问,够用了。

4.1 驱动骨架搭建
驱动开发最核心的就是搭建骨架,例如,<linux/init.h>和<linux/module.h>是 Linux 内核模块初始化和管理的基础头文件,它们为驱动模块的加载和卸载提供了必要的函数和宏定义 。<linux/i2c.h>则是 I2C 子系统的核心头文件,其中定义了 I2C 通信所需的各种结构体、函数和常量,如 i2c_client、i2c_driver 等结构体,以及 i2c_transfer、i2c_smbus_read_byte_data 等函数 。
#include <linux/init.h>
#include <linux/module.h>
#include <linux/i2c.h>
然后声明驱动的许可证类型,通过添加MODULE_LICENSE("GPL");声明,表明该驱动遵循 GPL 开源协议 。
MODULE_LICENSE("GPL");
定义 i2c_driver 结构体是驱动骨架搭建的核心环节 。i2c_driver 结构体包含了驱动的各种关键信息和操作函数指针 。其中,probe函数是当驱动与设备匹配成功后被调用的函数,用于设备的初始化工作 。在probe函数中,通常会完成设备资源的申请、寄存器的配置、中断的设置等操作 。例如,对于一个 I2C 接口的加速度传感器,在probe函数中,可能需要申请传感器的中断号,配置传感器的工作模式寄存器,使其能够正常采集加速度数据 。remove函数则在设备从系统中移除时被调用,用于释放设备占用的资源,如释放中断号、释放内存等,确保系统资源的有效管理和回收 。
static int xxx_probe(struct i2c_client *client, const struct i2c_device_id *id) {
// 设备初始化代码
return 0;
}
static int xxx_remove(struct i2c_client *client) {
// 资源释放代码
return 0;
}
struct i2c_driver xxx_driver = {
.probe = xxx_probe,
.remove = xxx_remove,
.driver = {
.name = "xxx_device",
.of_match_table = of_match_ptr(xxx_of_match),
},
.id_table = xxx_id_table,
};
在上述代码中,xxx_driver是定义的 i2c_driver 结构体实例,probe和remove成员分别指向对应的函数 。driver成员是一个struct device_driver结构体,其中name字段定义了驱动的名称,of_match_table字段用于设备树匹配,通过of_match_ptr宏指向一个of_device_id数组,该数组中包含了与设备兼容的字符串,用于在设备树中查找匹配的设备节点 。id_table成员则是一个i2c_device_id数组,用于传统的设备 ID 匹配 。通过这样的定义,一个基本的 i2c_driver 结构体就搭建完成了,为后续的设备驱动功能实现奠定了基础 。
4.2 实现基本读写函数
i2c_smbus_read_byte_data和i2c_smbus_write_byte_data是 Linux 内核 I2C 子系统提供的便捷函数,用于实现对 I2C 设备单个字节数据的读写操作 。
#include <linux/i2c.h>
// 读取设备指定寄存器的一个字节数据
s32 i2c_smbus_read_byte_data(struct i2c_client *client, u8 command) {
return i2c_smbus_access(client, I2C_SMBUS_READ, command, I2C_SMBUS_BYTE_DATA, NULL);
}
// 向设备指定寄存器写入一个字节数据
s32 i2c_smbus_write_byte_data(struct i2c_client *client, u8 command, u8 value) {
union i2c_smbus_data data;
data.byte = value;
return i2c_smbus_access(client, I2C_SMBUS_WRITE, command, I2C_SMBUS_BYTE_DATA, &data);
}
i2c_smbus_read_byte_data函数用于读取设备指定寄存器的一个字节数据 。它接收两个参数,client是指向 i2c_client 结构体的指针,代表要操作的 I2C 设备;command是要读取的寄存器地址 。函数内部通过调用i2c_smbus_access函数来完成实际的读取操作,I2C_SMBUS_READ表示读操作,I2C_SMBUS_BYTE_DATA表示按字节数据模式进行访问 。 i2c_smbus_write_byte_data函数则用于向设备指定寄存器写入一个字节数据 。它接收三个参数,client和command的含义与读取函数相同,value是要写入的数据 。在函数内部,首先将value存储在union i2c_smbus_data联合体中,然后调用i2c_smbus_access函数进行写入操作,I2C_SMBUS_WRITE表示写操作 。
原始的i2c_transfer接口则提供了更底层、更灵活的 I2C 数据传输方式 。它可以支持多个消息的批量发送和接收,适用于一些复杂的通信场景 。i2c_transfer函数的原型如下:
int i2c_transfer(struct i2c_adapter *adap, struct i2c_msg *msgs, int num);
adap是指向 i2c_adapter 结构体的指针,代表 I2C 适配器;msgs是一个struct i2c_msg结构体数组,每个i2c_msg结构体描述了一次 I2C 数据传输的相关信息,如目标设备地址、读写标志、数据缓冲区等;num表示msgs数组中消息的数量 。例如,当需要向 I2C 设备连续写入多个寄存器的值时,可以通过构造多个i2c_msg结构体,并将它们组成数组传递给i2c_transfer函数来实现 。在一些对时序要求严格或者需要进行特殊通信协议实现的场景下,i2c_transfer接口能够提供更精细的控制,但同时也需要开发者对 I2C 通信协议有更深入的理解和掌握 。相比之下,i2c_smbus_read_byte_data和i2c_smbus_write_byte_data函数更适合于简单的单字节数据读写场景,使用起来更加便捷 。
4.3 创建字符设备接口
在 Linux 系统中,创建字符设备接口是实现用户空间与 I2C 设备驱动交互的重要途径 。首先,需要注册设备号 。设备号是内核识别设备的关键标识,分为主设备号和次设备号 。可以使用alloc_chrdev_region函数动态分配设备号,也可以使用register_chrdev_region函数静态指定设备号 。以下是动态分配设备号的示例代码:
#include <linux/fs.h>
dev_t devno;
if (alloc_chrdev_region(&devno, 0, 1, "xxx_device")) {
printk(KERN_ERR "Failed to allocate device number\n");
return -1;
}
alloc_chrdev_region函数接收四个参数,第一个参数&devno用于返回分配的设备号;第二个参数0表示起始次设备号;第三个参数1表示要分配的设备数量;第四个参数"xxx_device"是设备的名称 。如果分配成功,devno将包含分配到的设备号;如果分配失败,函数将返回一个负数 。
接下来,需要注册字符设备类(class)和字符设备(cdev) 。字符设备类是一种逻辑上的分类,用于在/sys/class目录下创建一个对应的类目录,方便用户空间通过该目录访问设备 。使用class_create函数创建字符设备类:
#include <linux/device.h>
struct class *class;
class = class_create(THIS_MODULE, "xxx_class");
if (IS_ERR(class)) {
printk(KERN_ERR "Failed to create class\n");
unregister_chrdev_region(devno, 1);
return -1;
}
class_create函数接收两个参数,第一个参数THIS_MODULE表示当前模块;第二个参数"xxx_class"是类的名称 。如果创建成功,将返回一个指向struct class结构体的指针;如果创建失败,返回一个错误指针 。
创建字符设备 cdev 并将其添加到系统中:
#include <linux/cdev.h>
struct cdev *cdev;
cdev = cdev_alloc();
if (!cdev) {
printk(KERN_ERR "Failed to allocate cdev\n");
class_destroy(class);
unregister_chrdev_region(devno, 1);
return -1;
}
cdev->ops = &xxx_fops;
cdev->owner = THIS_MODULE;
if (cdev_add(cdev, devno, 1)) {
printk(KERN_ERR "Failed to add cdev\n");
cdev_del(cdev);
class_destroy(class);
unregister_chrdev_region(devno, 1);
return -1;
}
cdev_alloc函数用于分配一个struct cdev结构体 。然后设置cdev的操作函数集合ops,指向预先定义好的xxx_fops结构体,该结构体中包含了open、read、write、ioctl等函数指针,实现了对设备的具体操作逻辑 。cdev->owner设置为THIS_MODULE,表示该设备属于当前模块 。最后,使用cdev_add函数将cdev添加到系统中,如果添加失败,需要进行相应的资源释放操作 。
实现open、read、write、ioctl等函数是字符设备接口的核心功能 。以read函数为例,其实现可能如下:
ssize_t xxx_read(struct file *filp, char __user *buf, size_t count, loff_t *offt) {
// 从I2C设备读取数据
// 将数据复制到用户空间buf中
return count;
}
在read函数中,首先从 I2C 设备读取数据,然后使用copy_to_user函数将数据复制到用户空间的缓冲区buf中 。count表示要读取的数据长度,offt表示文件偏移量 。write函数和ioctl函数的实现类似,根据具体的设备功能和需求,完成相应的数据写入和控制操作 。
在用户空间,可以使用echo和cat命令对设备进行简单的测试 。例如,假设设备节点为/dev/xxx_device,可以使用以下命令向设备写入数据:
echo "test data" > /dev/xxx_device
使用以下命令从设备读取数据:
cat /dev/xxx_device
通过这些命令,可以验证字符设备接口的正确性和设备驱动的功能是否正常 。
4.4 编译与加载验证
编写 Makefile 是将驱动代码编译成可加载模块的关键步骤 。Makefile 定义了编译的规则和依赖关系,指导编译器如何将源文件编译成目标文件,并最终生成可加载的内核模块(.ko 文件) 。以下是一个简单的 Makefile 示例:
# 指定内核源码路径(通常通过环境变量传递)
KERNEL_DIR := /lib/modules/$(shell uname -r)/build
# 指定目标模块名称(生成xxx.ko)
obj-m := xxx.o
# 默认构建目标
all:
make -C $(KERNEL_DIR) M=$(PWD) modules
clean:
make -C $(KERNEL_DIR) M=$(PWD) clean
在这个 Makefile 中,KERNEL_DIR变量指定了内核源码的路径,通常通过uname -r命令获取当前内核版本,并拼接上/lib/modules/路径 。obj-m变量指定了要编译的目标模块,xxx.o表示源文件 。all目标是默认的构建目标,使用make -C (KERNEL_DIR) M=(PWD) modules命令,-C选项指定切换到内核源码目录,M=(PWD)表示当前驱动代码所在的目录,modules表示编译模块 。clean目标用于清理编译生成的文件,使用make -C (KERNEL_DIR) M=$(PWD) clean命令 。
使用insmod命令加载驱动模块,并通过dmesg命令分析日志,是验证驱动是否正常工作的重要方法 。在终端中执行以下命令加载驱动:
sudo insmod xxx.ko
加载成功后,可以使用dmesg命令查看内核日志,查看驱动加载过程中是否有错误信息输出 。例如,如果驱动在probe函数中出现错误,dmesg日志中会显示相应的错误提示 。
dmesg | grep xxx
/dev节点的生成与权限设置也非常重要 。当驱动加载成功后,内核会根据之前注册的设备号和字符设备类,在/dev目录下生成对应的设备节点 。为了确保用户能够正常访问设备节点,需要设置合适的权限 。可以使用chmod命令设置设备节点的权限,例如:
sudo chmod 666 /dev/xxx_device
这样,普通用户就可以对/dev/xxx_device设备节点进行读写操作,从而实现与 I2C 设备的交互 。通过以上编译与加载验证的步骤,可以逐步排查和解决驱动开发过程中可能出现的问题,确保 I2C 设备驱动能够正常工作 。
五、用户空间访问方案
很多人学Linux I2C时会先接触 i2cdetect,然后感觉"好像不用写驱动也能通信?" 对,确实可以。

5.1 i2c-tools 工具集
i2cdetect 是工具集中用于扫描设备的利器,使用 i2cdetect -l 命令可以列出系统中所有的 I2C 总线,例如在基于 ARM 的嵌入式开发板上,执行该命令后,可能会显示类似如下信息:
i2c-0 i2c imx6q-i2c I2C adapter
i2c-1 i2c imx6q-i2c I2C adapter
这表明系统中有两个 I2C 总线,分别为 i2c-0 和 i2c-1 。若要扫描 i2c-1 总线上的设备,可使用命令 i2cdetect -y 1 ,其中 -y 选项表示在扫描过程中自动确认,无需用户手动输入确认信息 。扫描结果会以矩阵形式呈现,例如:
0 1 2 3 4 5 6 7 8 9 a b c d e f
00: -- -- -- -- -- -- -- -- -- -- -- -- --
10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
30: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
40: -- -- -- -- -- -- -- -- 48 -- -- -- -- -- -- --
50: 50 -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
60: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
70: 70 -- -- -- -- -- -- --
从结果中可以看出,在 i2c-1 总线上检测到了地址为 0x48、0x50 和 0x70 的设备 。这对于快速定位总线上的设备非常方便,在调试 I2C 设备时,通过 i2cdetect 扫描,能够迅速确认设备是否正确连接以及设备地址是否正确 。
i2cdump、i2cset 和 i2cget 则主要用于寄存器的读写操作 。i2cdump 用于读取 I2C 设备的所有寄存器内容 。例如,要读取地址为 0x50 的 EEPROM 设备的所有寄存器,可使用命令 i2cdump -y 1 0x50 ,其中 1 表示 I2C 总线编号,0x50 为设备地址 。命令执行后,会以表格形式输出 EEPROM 的寄存器值,每一行代表 16 个寄存器,方便查看设备的配置和数据存储情况 。
0 1 2 3 4 5 6 7 8 9 a b c d e f 0123456789abcdef
00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
10: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
20: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
i2cget 用于读取设备特定寄存器的值 。假设要读取地址为 0x48 的温度传感器的温度寄存器(假设地址为 0x00),可使用命令 i2cget -y 1 0x48 0x00 ,执行后会返回该寄存器的值,通过解析这个值,就可以得到温度传感器当前测量的温度数据 。 i2cset 用于向设备寄存器写入数据 。例如,要向地址为 0x20 的 GPIO 扩展器的某个寄存器(假设地址为 0x01)写入值 0x55,可使用命令 i2cset -y 1 0x20 0x01 0x55 ,这样就可以配置 GPIO 扩展器的相应引脚状态 。这些工具的使用,极大地简化了 I2C 设备寄存器的读写操作,提高了开发和调试的效率 。
5.2 用户空间直接操作 I2C 设备
在用户空间,还可以直接通过打开 /dev/i2c-N 设备节点并使用 ioctl 系统调用来操作 I2C 设备 。
以读取一个 I2C 设备的寄存器值为例:
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <linux/i2c.h>
#include <linux/i2c-dev.h>
#define I2C_ADDR 0x50 // 设备地址
#define REG_ADDR 0x00 // 寄存器地址
int main() {
int fd;
char buf[2];
struct i2c_rdwr_ioctl_data packets;
struct i2c_msg messages[2];
// 打开I2C设备文件
fd = open("/dev/i2c-1", O_RDWR);
if (fd < 0) {
perror("Failed to open I2C device");
return 1;
}
// 设置I2C设备地址
if (ioctl(fd, I2C_SLAVE, I2C_ADDR) < 0) {
perror("Failed to set I2C address");
close(fd);
return 1;
}
// 构造写消息,发送寄存器地址
messages[0].addr = I2C_ADDR;
messages[0].flags = 0;
messages[0].len = 1;
messages[0].buf = ®_ADDR;
// 构造读消息,读取寄存器值
messages[1].addr = I2C_ADDR;
messages[1].flags = I2C_M_RD;
messages[1].len = 1;
messages[1].buf = buf;
// 构造I2C读写数据包
packets.nmsgs = 2;
packets.msgs = messages;
// 执行I2C读写操作
if (ioctl(fd, I2C_RDWR, &packets) < 0) {
perror("Failed to perform I2C transfer");
} else {
printf("Register value: 0x%02x\n", buf[0]);
}
close(fd);
return 0;
}
在这段代码中,通过 open 函数打开 /dev/i2c-1 设备文件,获取文件描述符 fd 。使用 ioctl 函数设置 I2C 设备地址 。然后构造了两个 i2c_msg 结构体,一个用于发送寄存器地址(写操作),另一个用于读取寄存器值(读操作) 。将这两个消息组合成一个 i2c_rdwr_ioctl_data 结构体,并通过 ioctl 函数执行 I2C 读写操作 。最后,关闭设备文件 。
与内核驱动方案相比,用户空间直接操作 I2C 设备的优点是开发简单,不需要深入了解内核机制,对于一些简单的 I2C 设备测试和应用场景非常适用 。但它由于用户空间和内核空间的隔离,每次 I2C 操作都需要进行用户态和内核态的切换,这会带来一定的性能开销 。在需要频繁进行 I2C 通信的场景下,这种性能损耗会比较明显 。
5.3 为何需要内核驱动
用户空间驱动听上去很美好------开发快,调起来也方便,不用跟内核模块的编译加载斗智斗勇。
但实际产品中,很少有人用纯用户空间方案。为什么呢?
第一是中断处理------很多I2C传感器都有中断引脚,比如加速度计的data ready中断,你不用内核驱动怎么响应?写个用户空间程序轮询GPIO?延迟高、功耗大,基本没法商用。
第二是功耗管理------内核的runtime PM框架能自动管理设备休眠唤醒,用户空间程序很难做到这一点。
第三是多进程互斥------多个进程同时访问同一个I2C设备怎么办?内核驱动可以用mutex保护临界区,用户空间程序只能靠文件锁,死锁风险大了好几个量级。
第四是框架集成------如果传感器数据要通过IIO子系统上报给Android的Sensor HAL,那你必须写内核驱动,用户空间程序完全无法融入这个框架。
六、Linux 内核 I2C 调试技巧分享
i2cdetect前面已经说过了,这里重点聊聊其他调试手段。

在编译内核时,可以开启CONFIG_I2C_DEBUG_CORE和CONFIG_I2C_DEBUG_BUS等选项,或者在模块加载时通过内核命令行参数i2c_core.dyndbg=+p来开启I2C核心的动态调试输出。
最直接的排查手段就是看内核日志,用dmesg | grep i2c过滤I2C相关的日志,重点关注probe失败、通信超时、ACK错误等信息。如果看到"i2c i2c-0: sendbytes: NAK bailout",基本可以确定是设备地址错误或者设备不在线。
I2C通信故障定位中,ACK失败是最常见的,如果i2cdetect能扫到设备地址但驱动probe失败,先检查驱动的of_match_table是否包含了正确的compatible字符串,然后确认设备树中reg地址是不是7位地址。如果i2cdetect完全扫不到设备,先检查硬件连接------用万用表量一下SCL和SDA线上是否有上拉电压(通常是3.3V或1.8V),再用示波器看看有没有信号。如果是时序异常,可能是因为上拉电阻不合适或总线电容过大,导致上升沿太慢无法在采样窗口内达到高电平,时钟频率越高对时序的要求就越苛刻。
推荐大家一个很实用的工具------i2c-stub。这是一个内核模块,可以模拟一个假的I2C芯片,不需要任何硬件就能测试你的驱动逻辑。它的使用流程是:加载i2c-stub模块(指定要模拟的芯片地址),然后使用i2cset预加载一些寄存器数据,再加载你的设备驱动模块,驱动就会把它当成真实的I2C设备来操作。i2c-tools包里还附带了一个i2c-stub-from-dump脚本,能从真实的芯片转储数据中自动加载寄存器值到stub设备中。这对于没有硬件但是要开发或测试驱动的场景来说简直是救了大命!
七、I2C DMA 与性能优化
虽然I2C是个低速总线,但当数据量大到一定程度时,传统的PIO模式会成为瓶颈。DMA传输可以减少CPU参与,让CPU在I2C传输期间去干别的事情。

在内核官方文档中有说到:I2C消息的buffer并不强制要求是DMA安全的,因为大部分I2C传输的数据量都很小,设置DMA的开销可能比PIO传输还大。但对于消息大小超过8字节的场景,建议使用DMA安全的buffer。对于16字节及以上的消息,使用DMA基本是稳赚不赔的。具体做法是在i2c_msg的flags中设置I2C_M_DMA_SAFE标志:
msg.flags |= I2C_M_DMA_SAFE;
高通平台有一种叫BEI(Block Event Interrupt)的优化机制。传统模式下,一个I2C传输中的每条消息完成都会产生一个中断,N条消息就N个中断,中断处理的开销相当可观。BEI机制将多条消息分组,每组64条消息只产生一次中断,从而大幅降低中断延迟。实测数据很亮眼------对于200条I2C写消息的传输,优化前耗时168ms,优化后仅需48ms,性能提升了3.5倍。
I2C频率调优方面,标准模式100kHz、快速模式400kHz、快速+模式1MHz是三种常见的速率。但要注意,频率越高对硬件要求越苛刻。上拉电阻的典型值在4.7kΩ(100kHz)到2.2kΩ(400kHz)之间,具体要根据总线电容和电压来计算。如果总线电容太大(比如布线太长或设备太多),高频信号的上升沿可能无法在有效采样窗口内达到逻辑高电平,导致通信错误。
八、项目实战:智能家居I2C设备管理系统
接下来我们实现一个抽象的、高鲁棒性的多设备管理系统。
它的核心思想是只对外暴露一个统一的主设备节点(比如 /dev/gordon_house_manager),内部通过一套精妙的协议框架,来动态管理、识别、和调度所有挂在总线上的智能设备:
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/i2c.h>
#include <linux/fs.h>
#include <linux/uaccess.h>
#include <linux/cdev.h>
#include <linux/slab.h>
#include <linux/mutex.h>
#include <linux/delay.h>
#define MAX_SLOTS 4
#define MAGIC_CMD_GET_DATA _IOR('H', 0x01, struct house_user_data)
/* 1. 抽象出具体设备的类型 */
enum dev_type {
TYPE_UNKNOWN = 0,
TYPE_AHT20, /* 温湿度 */
TYPE_AT24C02, /* 存储器 */
};
/* 2. 与应用层打交道的统一数据结构 */
struct house_user_data {
int slot_id;
int is_alive;
u32 device_type;
u8 raw_payload[8]; /* 吐出来的原始裸数据 */
};
/* 3. 每一个内部虚拟卡槽的抽象描述 */
struct smart_slot {
int id;
u16 i2c_addr;
enum dev_type type;
int online_status;
};
/* 4. 整个管理系统的核心私有大结构体 */
struct house_manager_priv {
struct i2c_client *client;
struct cdev cdev;
struct class *class;
dev_t dev_num;
struct mutex sys_lock;
struct smart_slot slots[MAX_SLOTS];
};
staticstruct house_manager_priv *g_mgr = NULL;
/* 5. 核心探针函数:负责去盲测摸底某个插槽上的设备还在不在 */
static int probe_slot_hardware(struct i2c_adapter *adapter, u16 addr)
{
struct i2c_msg msg;
u8 test_reg = 0x00;
int ret;
/* 试探性地发一个字节的写请求,看对方回不回ACK */
msg.addr = addr;
msg.flags = 0;
msg.len = 1;
msg.buf = &test_reg;
ret = i2c_transfer(adapter, &msg, 1);
return (ret == 1) ? 1 : 0; /* 回了ACK返回1,装死返回0 */
}
/* 6. 统一的系统控制台:应用层通过ioctl发起命令,瞬间调取任意卡槽的数据 */
static long house_ioctl(struct file *file, unsigned int cmd, unsigned long arg)
{
struct house_manager_priv *priv = file->private_data;
struct house_user_data udata;
int ret = 0;
if (cmd != MAGIC_CMD_GET_DATA) return -EINVAL;
if (copy_from_user(&udata, (void __user *)arg, sizeof(struct house_user_data))) {
return -EFAULT;
}
if (udata.slot_id < 0 || udata.slot_id >= MAX_SLOTS) return -EINVAL;
mutex_lock(&priv->sys_lock);
/* 实时探阵:检查这颗芯片此时此刻还在不在总线上 */
if (!probe_slot_hardware(priv->client->adapter, priv->slots[udata.slot_id].i2c_addr)) {
priv->slots[udata.slot_id].online_status = 0;
udata.is_alive = 0;
dev_warn(&priv->client->dev, "警报!位于插槽 %d 的设备失联了!\n", udata.slot_id);
} else {
priv->slots[udata.slot_id].online_status = 1;
udata.is_alive = 1;
udata.device_type = priv->slots[udata.slot_id].type;
/* 针对不同的设备类型,执行不同的时序读取算法 */
if (priv->slots[udata.slot_id].type == TYPE_AHT20) {
/* 假装读取温湿度传感器数据 */
udata.raw_payload[0] = 0x3C; /* 模拟温度 */
udata.raw_payload[1] = 0x5A; /* 模拟湿度 */
} else if (priv->slots[udata.slot_id].type == TYPE_AT24C02) {
/* 从存储器特定位置抠个字节出来 */
int val = i2c_smbus_read_byte_data(priv->client, 0x00);
udata.raw_payload[0] = (val >= 0) ? (u8)val : 0x00;
}
}
mutex_unlock(&priv->sys_lock);
if (copy_to_user((void __user *)arg, &udata, sizeof(struct house_user_data))) {
return -EFAULT;
}
return ret;
}
static int house_open(struct inode *inode, struct file *file)
{
file->private_data = g_mgr;
return 0;
}
static conststruct file_operations house_fops = {
.owner = THIS_MODULE,
.open = house_open,
.unlocked_ioctl = house_ioctl,
};
/* 总线匹配上后的初始化工作 */
static int house_manager_probe(struct i2c_client *client, const struct i2c_device_id *id)
{
int ret;
dev_info(&client->dev, "智能家居管理中枢系统正式启动...\n");
g_mgr = devm_kzalloc(&client->dev, sizeof(struct house_manager_priv), GFP_KERNEL);
if (!g_mgr) return -ENOMEM;
g_mgr->client = client;
mutex_init(&g_mgr->sys_lock);
/* 初始化我们的虚拟卡槽配置 */
g_mgr->slots[0].id = 0; g_mgr->slots[0].i2c_addr = 0x50; g_mgr->slots[0].type = TYPE_AT24C02;
g_mgr->slots[1].id = 1; g_mgr->slots[1].i2c_addr = 0x38; g_mgr->slots[1].type = TYPE_AHT20;
/* 动态骨架包装 */
ret = alloc_chrdev_region(&g_mgr->dev_num, 0, 1, "house_mgr");
if (ret < 0) return ret;
cdev_init(&g_mgr->cdev, &house_fops);
ret = cdev_add(&g_mgr->cdev, g_mgr->dev_num, 1);
if (ret < 0) goto err_chrdev;
g_mgr->class = class_create(THIS_MODULE, "house_mgr_class");
if (IS_ERR(g_mgr->class)) {
ret = PTR_ERR(g_mgr->class);
goto err_cdev;
}
device_create(g_mgr->class, NULL, g_mgr->dev_num, NULL, "gordon_house_manager");
dev_info(&client->dev, "工业级中枢系统部署大功告成!\n");
return 0;
err_cdev:
cdev_del(&g_mgr->cdev);
err_chrdev:
unregister_chrdev_region(g_mgr->dev_num, 1);
return ret;
}
static int house_manager_remove(struct i2c_client *client)
{
device_destroy(g_mgr->class, g_mgr->dev_num);
class_destroy(g_mgr->class);
cdev_del(&g_mgr->cdev);
unregister_chrdev_region(g_mgr->dev_num, 1);
dev_info(&client->dev, "中枢系统安全关闭卸载!\n");
return 0;
}
static conststruct of_device_id house_of_match[] = {
{ .compatible = "gordon,house-manager" },
{ }
};
MODULE_DEVICE_TABLE(of, house_of_match);
staticstruct i2c_driver house_driver = {
.driver = {
.name = "house_manager_driver",
.of_match_table = house_of_match,
},
.probe = house_manager_probe,
.remove = house_manager_remove,
};
module_i2c_driver(house_driver);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Gordon");
这套架构对外的接口缩减成了一个,应用层App只需要定时通过 ioctl 轮询这个设备节点。如果用户半路把0x38地址的传感器模块给拔了,probe_slot_hardware 会在几微秒之内敏锐地捕捉到波形异常(没有ACK),立马把 is_alive 置零并安全返回。上层App一看这个标志位变零了,就能非常体面地在UI上显示一行"传感器已断开连接",而绝对不会导致整个系统卡死或者内核崩溃。
附录
I2C 时序图示例
单字节写时序图
在单字节写时序中,主设备首先发送起始条件(START),即 SDA 在 SCL 高电平时由高变低 。接着发送 7 位从设备地址和 1 位写位(SLAVE ADDR+W),从设备接收到地址后返回 ACK 信号 。然后主设备发送一个字节的数据(DATA),从设备接收数据后再次返回 ACK 信号 。最后主设备发送停止条件(STOP),即 SDA 在 SCL 高电平时由低变高,完成单字节写操作 。
多字节读时序图

多字节读时序相对复杂一些 。主设备先发送起始条件和从设备地址(SLAVE ADDR+W),从设备应答后,主设备再次发送起始条件和从设备地址(SLAVE ADDR+R),表示即将进行读操作 。从设备开始发送数据(DATA1、DATA2 等),每接收一个字节的数据,主设备都会发送 ACK 信号,直到最后一个字节,主设备发送 NACK 信号,通知从设备数据接收完毕,然后发送停止条件,结束多字节读操作 。
关键 API 函数速查表
在 Linux 内核 I2C 开发中,以下是一些关键的 API 函数速查表,方便开发者快速查阅和使用:
|--------------------------------|----------------------------|-----------------------------------------------------------------------------|
| 函数名 | 功能描述 | 示例代码 |
| i2c_add_adapter | 注册一个 I2C 适配器 | i2c_add_adapter(&my_i2c_adapter); |
| i2c_del_adapter | 注销一个 I2C 适配器 | i2c_del_adapter(&my_i2c_adapter); |
| i2c_new_device | 在 I2C 总线上创建一个新的 I2C 设备 | i2c_new_device(&my_i2c_adapter, &my_i2c_client); |
| i2c_unregister_device | 注销一个 I2C 设备 | i2c_unregister_device(&my_i2c_client); |
| i2c_transfer | 进行 I2C 数据传输,支持多个消息的批量发送和接收 | i2c_transfer(&my_i2c_adapter, &msgs, num_msgs); |
| i2c_smbus_read_byte_data | 读取 I2C 设备指定寄存器的一个字节数据 | i2c_smbus_read_byte_data(&my_i2c_client, reg_addr); |
| i2c_smbus_write_byte_data | 向 I2C 设备指定寄存器写入一个字节数据 | i2c_smbus_write_byte_data(&my_i2c_client, reg_addr, data); |
| i2c_smbus_read_word_data | 读取 I2C 设备指定寄存器的一个字(2 字节)数据 | i2c_smbus_read_word_data(&my_i2c_client, reg_addr); |
| i2c_smbus_write_word_data | 向 I2C 设备指定寄存器写入一个字(2 字节)数据 | i2c_smbus_write_word_data(&my_i2c_client, reg_addr, data); |
| i2c_smbus_read_i2c_block_data | 读取 I2C 设备指定寄存器的多个字节数据 | i2c_smbus_read_i2c_block_data(&my_i2c_client, reg_addr, num_bytes, data); |
| i2c_smbus_write_i2c_block_data | 向 I2C 设备指定寄存器写入多个字节数据 | i2c_smbus_write_i2c_block_data(&my_i2c_client, reg_addr, num_bytes, data); |
| i2c_get_adapter | 根据适配器编号获取 I2C 适配器 | struct i2c_adapter *adapter = i2c_get_adapter(adapter_num); |
| i2c_put_adapter | 释放 I2C 适配器 | i2c_put_adapter(adapter); |
| i2c_set_adapter_quirks | 设置 I2C 适配器的特性 | i2c_set_adapter_quirks(&my_i2c_adapter, I2C_QUIRK_IGNORE_NAK); |