啃透 I2C 驱动开发,才算入门嵌入式 Linux 内核驱动

大家好,今天咱们聊聊嵌入式开发里绕不开的坎------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 的操作。

往期文章推荐

图解 TCP/IP协议,看图秒懂

图解 C/C++ 多线程,看图秒懂

爆肝整理!嵌入式开发必知10种调试手段

【图解】SSH(安全外壳协议)工作原理

👉 【就业避坑】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 = &REG_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 = &REG_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); |

相关推荐
青山木8 小时前
Hot 100 --- 搜索插入位置
java·数据结构·算法·leetcode
Embedded-Xin11 小时前
Rust学习——Cargo工具
linux·学习·架构·rust·嵌入式
Livia要学习12 小时前
Python2和Python3字典底层原理
数据结构
青梅橘子皮12 小时前
STL---map/set... “家族“详解(从使用到底层)(1)
数据结构·算法
Adios79412 小时前
搜索二维矩阵 II
java·数据结构·算法
dok1217 小时前
Johannes 《Linux内核模块与设备驱动开发:编写Linux驱动程序》 (9)manual_cdev
linux·运维·驱动开发
灵枢时代17 小时前
小程序在生鲜配送行业,如何设计夜间预约和次日达的订单处理流程?
数据结构·人工智能·小程序
一条大祥脚17 小时前
ABC468 扫描线|贡献法|二阶差分|线段树优化DP
数据结构·算法
Nebula嵌入式1 天前
【C语言】09-深入解析main函数
linux·c语言·开发语言·嵌入式