1. 引言
I2C(Inter-Integrated Circuit)总线是一种由 Philips 公司(现 NXP)开发的两线式串行通信总线,广泛应用于嵌入式系统中连接低速外设,如传感器、EEPROM、RTC 时钟芯片、触摸控制器等。在 Linux 内核中,I2C 子系统提供了完整的驱动框架,屏蔽了底层硬件差异,让开发者可以专注于设备驱动的业务逻辑。本文记录笔者学习 Linux I2C 驱动开发的过程,从硬件协议基础到内核框架,再到实际驱动编写,逐步梳理关键知识点。
2. I2C 协议基础
在深入 Linux 驱动之前,先回顾 I2C 总线的硬件协议要点,这对理解内核框架的设计至关重要。
2.1 物理层特性
- 两线制:SCL(串行时钟线)和 SDA(串行数据线),均为开漏输出,需外接上拉电阻。
- 多主多从:总线上可挂载多个主机和从机,通过地址区分设备。
- 7 位或 10 位地址:常用 7 位地址,一个总线上最多挂载 112 个设备(保留地址除外)。
- 传输速率:标准模式 100 kbit/s,快速模式 400 kbit/s,高速模式 3.4 Mbit/s。
2.2 数据传输格式
一次完整的 I2C 传输包含起始条件(START)、地址帧、数据帧和停止条件(STOP)。读写操作的基本格式如下:
text
写操作:START + 从机地址(写) + ACK + 寄存器地址 + ACK + 数据 + ACK + STOP
读操作:START + 从机地址(写) + ACK + 寄存器地址 + ACK + START + 从机地址(读) + ACK + 数据 + NACK + STOP
理解这个时序是编写驱动的基础,因为 Linux 内核的 I2C 传输接口本质上就是对这些时序的封装。
3. Linux I2C 子系统架构
Linux 内核将 I2C 子系统划分为三个层次:控制器驱动(Adapter)、核心层(Core)和设备驱动(Client Driver)。理解这三层的关系是学习 I2C 驱动的关键。
3.1 三层结构总览
- I2C Adapter(控制器驱动):直接操作硬件寄存器,负责在总线上产生时序,对应 SoC 上的 I2C 控制器外设。
- I2C Core(核心层):提供统一的设备模型、总线注册机制、数据传输接口,以及设备与驱动的匹配逻辑,对上层屏蔽控制器差异。
- I2C Client Driver(设备驱动):针对具体挂在总线上的设备(如某型号传感器)编写,通过核心层提供的 API 与设备通信,不直接操作硬件寄存器。
3.2 关键数据结构
I2C 子系统的核心数据结构主要有以下几个:
| 数据结构 | 作用 |
|---|---|
struct i2c_adapter |
描述一个 I2C 控制器,包含总线号、算法、锁等。 |
struct i2c_algorithm |
定义控制器收发数据的主函数(master_xfer)。 |
struct i2c_client |
描述一个挂在总线上的设备,包含地址、驱动名等。 |
struct i2c_driver |
描述一个设备驱动,包含 probe、remove 回调及 id 表。 |
struct i2c_msg |
描述一次传输的消息,包含地址、标志、缓冲区等。 |
4. 设备驱动开发流程
编写一个 I2C 设备驱动通常遵循固定的步骤。下面以读取一个虚拟传感器为例,梳理完整流程。
4.1 驱动框架搭建
首先定义一个 i2c_driver 结构体,并实现 probe 和 remove 回调:
c
#include <linux/i2c.h>
#include <linux/module.h>
#include <linux/init.h>
static int my_sensor_probe(struct i2c_client *client)
{
pr_info("my_sensor: probe success, addr=0x%02x\n", client->addr);
return 0;
}
static void my_sensor_remove(struct i2c_client *client)
{
pr_info("my_sensor: remove\n");
}
static const struct i2c_device_id my_sensor_id[] = {
{ "my-sensor", 0 },
{ }
};
MODULE_DEVICE_TABLE(i2c, my_sensor_id);
static struct i2c_driver my_sensor_driver = {
.driver = {
.name = "my-sensor",
},
.probe = my_sensor_probe,
.remove = my_sensor_remove,
.id_table = my_sensor_id,
};
module_i2c_driver(my_sensor_driver);
MODULE_LICENSE("GPL");
4.2 数据传输接口
在 probe 中获取设备后,就可以通过 i2c_transfer 或更便捷的 i2c_smbus_read_byte_data 等接口读写数据。以读取传感器寄存器为例:
c
static int my_sensor_read_reg(struct i2c_client *client, u8 reg, u8 *val)
{
int ret;
struct i2c_msg msgs[2] = {
{
.addr = client->addr,
.flags = 0,
.len = 1,
.buf = ®,
},
{
.addr = client->addr,
.flags = I2C_M_RD,
.len = 1,
.buf = val,
},
};
ret = i2c_transfer(client->adapter, msgs, 2);
if (ret != 2) {
dev_err(&client->dev, "read reg 0x%02x failed, ret=%d\n", reg, ret);
return ret < 0 ? ret : -EIO;
}
return 0;
}
4.3 设备树匹配
在现代内核中,通常通过设备树描述硬件。在设备树中声明设备节点后,驱动通过 of_match_table 完成匹配:
c
static const struct of_device_id my_sensor_of_match[] = {
{ .compatible = "vendor,my-sensor" },
{ }
};
MODULE_DEVICE_TABLE(of, my_sensor_of_match);
static struct i2c_driver my_sensor_driver = {
.driver = {
.name = "my-sensor",
.of_match_table = my_sensor_of_match,
},
.probe = my_sensor_probe,
.remove = my_sensor_remove,
};
对应的设备树节点示例:
dts
&i2c1 {
my_sensor@48 {
compatible = "vendor,my-sensor";
reg = <0x48>;
};
};
5. SMBus 接口与辅助函数
对于大多数简单设备,不必每次都构造 i2c_msg 数组。内核提供了 SMBus 风格的封装函数,使用更加简洁。
5.1 常用 SMBus API
| 函数 | 功能 |
|---|---|
i2c_smbus_read_byte() |
读取一个字节,不带寄存器地址。 |
i2c_smbus_write_byte() |
写入一个字节,不带寄存器地址。 |
i2c_smbus_read_byte_data() |
读取指定寄存器的值。 |
i2c_smbus_write_byte_data() |
向指定寄存器写入值。 |
i2c_smbus_read_word_data() |
读取 16 位寄存器值。 |
i2c_smbus_write_word_data() |
写入 16 位寄存器值。 |
5.2 使用示例
使用 SMBus 接口后,前面的读寄存器函数可以简化为:
c
static int my_sensor_read_reg(struct i2c_client *client, u8 reg, u8 *val)
{
int ret;
ret = i2c_smbus_read_byte_data(client, reg);
if (ret < 0) {
dev_err(&client->dev, "read reg 0x%02x failed, ret=%d\n", reg, ret);
return ret;
}
*val = (u8)ret;
return 0;
}
6. 用户空间访问
除了编写内核驱动,Linux 还提供了用户空间访问 I2C 设备的接口,方便调试和快速验证。通过 /dev/i2c-N 设备节点,配合 i2c-dev 内核模块,可以在应用层直接读写设备。
6.1 使用 i2c-tools
在开发板上安装 i2c-tools 后,可以快速扫描总线和读写设备:
bash
# 扫描总线上的设备
i2cdetect -y 1
读取 0x48 设备 0x00 寄存器的值
i2cget -y 1 0x48 0x00
向 0x48 设备 0x01 寄存器写入 0xff
i2cset -y 1 0x48 0x01 0xff
6.2 应用层编程
在 C 程序中,通过 ioctl 系统调用与 I2C_RDWR 命令完成读写:
c
#include <stdio.h>
#include <fcntl.h>
#include <linux/i2c-dev.h>
#include <sys/ioctl.h>
int main(void)
{
int fd = open("/dev/i2c-1", O_RDWR);
if (fd < 0) {
perror("open");
return -1;
}
struct i2c_msg msgs[2];
unsigned char reg = 0x00;
unsigned char val = 0;
msgs[0].addr = 0x48;
msgs[0].flags = 0;
msgs[0].len = 1;
msgs[0].buf = &reg;
msgs[1].addr = 0x48;
msgs[1].flags = I2C_M_RD;
msgs[1].len = 1;
msgs[1].buf = &val;
struct i2c_rdwr_ioctl_data data = {
.msgs = msgs,
.nmsgs = 2,
};
if (ioctl(fd, I2C_RDWR, &data) < 0) {
perror("ioctl");
close(fd);
return -1;
}
printf("reg 0x00 = 0x%02x\n", val);
close(fd);
return 0;
}
7. 调试与常见问题
I2C 驱动开发中遇到问题,可以从以下几个方向排查。
7.1 常用调试手段
- 开启内核调试日志 :在
drivers/i2c目录下开启CONFIG_I2C_DEBUG_CORE和CONFIG_I2C_DEBUG_ALGO,观察传输细节。 - 逻辑分析仪:抓取 SCL/SDA 波形,确认时序是否符合协议。
- i2cdetect 扫描:确认设备地址是否正确,设备是否真的挂在总线上。
- 检查上拉电阻:SDA/SCL 必须有上拉电阻,否则通信不稳定。
7.2 常见错误码
| 错误码 | 含义 |
|---|---|
-ENXIO |
无此设备,通常是没有 ACK 应答,地址错误或设备未上电。 |
-ETIMEDOUT |
传输超时,总线可能被拉死或时钟异常。 |
-EBUSY |
总线忙,可能被其他主机占用。 |
-EINVAL |
参数无效,检查消息长度和标志位。 |
8. 总结
Linux I2C 驱动开发的核心在于理解三层架构:控制器驱动负责时序,核心层负责统一抽象,设备驱动负责业务逻辑。实际开发中,大部分工作集中在编写 i2c_driver 和利用 SMBus 辅助函数读写寄存器。建议初学者先在用户空间用 i2c-tools 验证硬件通路,再编写内核驱动,这样能有效缩小问题排查范围。后续可以进一步学习 I2C 的 10 位地址、多主机仲裁、DMA 传输等进阶内容。