在各类嵌入式Linux系统(无论是物联网设备还是工控主板)中,I2C总线是最常见的低速片上外设通信标准。无论是读取温湿度传感器数据、获取陀螺仪姿态,还是配置电源管理芯片(PMIC)、触控IC或显示接口转换芯片,绝大多数外设都依赖I2C进行控制与数据交互。
本文将从零开始,手把手教你构建一个完整的I2C设备驱动,掌握从设备树(DTS)硬件描述到内核驱动代码实现的全流程,并深入介绍工业级驱动开发中广泛使用的regmap寄存器抽象框架,以提升代码的健壮性与可维护性。
1. I2C在Linux内核中的分层架构
Linux I2C子系统通过清晰的分层设计实现硬件无关性,开发者只需聚焦自身负责的层级即可。下图清晰地展示了内核中各组件的交互关系:

- I2C Core (核心层):提供统一的API(如
i2c_add_driver, i2c_transfer),负责管理总线驱动和设备驱动的注册与匹配,是整个子系统的调度中枢。
- I2C Adapter Driver (控制器驱动):对应SoC内部的硬件I2C控制器(例如
rk3x-i2c)。这部分通常由芯片原厂提供并已集成到内核,驱动开发者很少需要修改。
- I2C Client Driver (设备驱动):这是我们需要开发的核心部分。它对应挂载在I2C总线上的具体从设备(如某个型号的温度传感器),负责解析设备数据、实现业务逻辑并向用户空间提供访问接口(如字符设备、IIO子系统等)。
简单总结三者的职责:Core管“调度”,Adapter管“硬件时序”,Client管“外设业务逻辑”。
2. 在设备树中定义I2C设备节点
在编写驱动代码之前,必须先在设备树(Device Tree)中准确描述硬件连接关系。我们需要告知内核:目标设备挂载在哪条I2C总线上、它的7位从机地址是多少、以及是否需要配置中断或复位引脚。
/* 以SoC的I2C控制器i2c1为例 */
&i2c1 {
status = "okay"; // 确保控制器使能
/* 定义具体的从设备节点 */
my_sensor@40 {
compatible = "abc,my-sensor"; /* 用于驱动匹配的关键字符串 */
reg = <0x40>; /* I2C 7位从机地址 */
/* 可选配置:如果设备有中断或复位引脚 */
// interrupt-parent = <&gpio0>;
// interrupts = <RK_PC0 IRQ_TYPE_LEVEL_LOW>;
};
};
关键配置说明:
&i2c1:引用SoC的I2C1控制器节点,需根据硬件原理图确认实际使用的控制器编号。
reg = <0x40>:设备的I2C地址(7位格式)。内核在与硬件控制器通信时会自动处理读写位,驱动中只需填写此7位地址。
compatible:驱动匹配的唯一标识符字符串。此字符串必须与驱动代码中的 of_device_id 表项完全一致(大小写敏感)。
3. I2C设备驱动代码实现
I2C设备驱动是标准的Linux内核驱动,其核心结构体是 struct i2c_driver。
3.1 定义设备树匹配表
内核依靠 of_device_id 表来将驱动与设备树节点进行匹配。
static const struct of_device_id my_sensor_dt_ids[] = {
{ .compatible = "abc,my-sensor" }, // 与设备树中的compatible属性对应
{ /* sentinel */ } // 哨兵项,标记数组结束
};
MODULE_DEVICE_TABLE(of, my_sensor_dt_ids);
3.2 实现Probe初始化函数
当设备树节点与驱动匹配成功时,内核会自动调用驱动的 probe 函数。这是驱动初始化的核心入口。
static int my_sensor_probe(struct i2c_client *client,
const struct i2c_device_id *id)
{
int ret;
u8 val;
/* client->dev 是内嵌的struct device, client->addr 是I2C地址 */
dev_info(&client->dev, "Probing device at addr: 0x%02x\n", client->addr);
/* 尝试读取寄存器0x00(通常是芯片ID寄存器)以验证硬件连接 */
ret = i2c_smbus_read_byte_data(client, 0x00);
if (ret < 0) {
dev_err(&client->dev, "Failed to read reg 0x00: %d\n", ret);
return ret;
}
val = (u8)ret;
dev_info(&client->dev, "Chip ID (Reg 0x00) = 0x%02x\n", val);
/* 后续步骤:可在此注册字符设备、Input子系统或IIO子系统等 */
return 0;
}
static void my_sensor_remove(struct i2c_client *client)
{
/* 执行资源释放操作,如注销设备、释放内存等 */
}
3.3 注册I2C驱动
将上述组件组装到 i2c_driver 结构体中,并使用专用宏进行注册。
static struct i2c_driver my_sensor_driver = {
.driver = {
.name = "my_sensor",
.of_match_table = my_sensor_dt_ids, // 绑定匹配表
},
.probe = my_sensor_probe,
.remove = my_sensor_remove,
};
/* module_i2c_driver 宏等价于 module_init 和 module_exit 的简化版 */
module_i2c_driver(my_sensor_driver);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("dump linux");
MODULE_DESCRIPTION("A simple I2C driver example");
4. 使用Regmap框架(进阶实践)
Regmap是Linux内核提供的一套通用寄存器映射抽象层。它将I2C、SPI、MMIO等不同总线对寄存器的读写操作封装成统一且简洁的API,使驱动开发者无需关心底层总线差异,专注于寄存器层面的业务逻辑,这对于管理复杂的网络与系统外围芯片寄存器尤其高效。
Regmap核心优势:
- 接口统一:一套API兼容I2C、SPI等多种总线。
- 并发安全:内置互斥锁,保证多线程访问安全。
- 性能优化:支持寄存器缓存,减少对低速总线的实际访问次数。
- 调试友好:集成debugfs支持,方便在用户空间直接查看和修改寄存器值。
4.1 在驱动中初始化Regmap
#include <linux/regmap.h>
/* 配置regmap属性,定义寄存器位宽和地址范围 */
static const struct regmap_config my_sensor_regmap_config = {
.reg_bits = 8, /* 寄存器地址宽度为8位 */
.val_bits = 8, /* 寄存器值宽度为8位 */
.max_register = 0xFF, /* 最大寄存器地址 */
};
/* 驱动私有数据结构体 */
struct my_sensor_data {
struct i2c_client *client;
struct regmap *map;
};
static int my_sensor_probe(struct i2c_client *client,
const struct i2c_device_id *id)
{
struct my_sensor_data *data;
data = devm_kzalloc(&client->dev, sizeof(*data), GFP_KERNEL);
if (!data)
return -ENOMEM;
data->client = client;
/* 核心:基于i2c_client初始化regmap */
data->map = devm_regmap_init_i2c(client, &my_sensor_regmap_config);
if (IS_ERR(data->map))
return PTR_ERR(data->map);
/* 将私有数据绑定到i2c_client,便于在其他函数中获取 */
i2c_set_clientdata(client, data);
return 0;
}
4.2 使用Regmap API读写寄存器
/* 示例:使用regmap进行寄存器读写操作 */
static void my_sensor_read_write(struct i2c_client *client)
{
int ret;
unsigned int val;
struct regmap *map;
struct my_sensor_data *data;
/* 从i2c_client获取驱动私有数据 */
data = i2c_get_clientdata(client);
map = data->map;
/* 写寄存器:将值0xFF写入地址为0x01的寄存器 */
regmap_write(map, 0x01, 0xFF);
/* 读寄存器:读取地址为0x02的寄存器值到变量val中 */
ret = regmap_read(map, 0x02, &val);
if (ret == 0)
dev_info(&client->dev, "Read value from reg 0x02: 0x%x\n", val);
}
5. 驱动调试实用技巧
驱动编写完成后,调试是验证其正确性的关键步骤。以下是一些常用的诊断手段,熟练掌握这些运维与DevOps调试技巧能极大提升效率。
5.1 确认设备树加载与驱动匹配状态
检查内核启动日志,确认设备树节点已被解析且驱动probe函数被成功调用。
sudo dmesg | grep -i "my_sensor\|i2c1"
i2c-tools 是直接与I2C硬件交互的利器,可以在不加载驱动的情况下排查硬件连接问题。
-
扫描总线:查看I2C总线上所有在线设备。
# 假设设备挂载在I2C总线1上
sudo i2cdetect -y 1
输出示例:UU表示该地址已被内核驱动占用,40表示地址0x40有设备响应但无驱动。
0 1 2 3 4 5 6 7 8 9 a b c d e f
00: -- -- -- -- -- -- -- --
40: UU -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
-
直接读写寄存器(绕过驱动):
# 从总线1、地址0x40的设备读取寄存器0x00的值
sudo i2cget -y 1 0x40 0x00
# 向总线1、地址0x40的设备的寄存器0x01写入值0xff
sudo i2cset -y 1 0x40 0x01 0xff
5.3 常见故障排查速查表
| 现象 |
可能原因与解决建议 |
| Probe函数未触发 |
1. 设备树节点未使能(检查status = "okay")。<br>2. compatible字符串与驱动不匹配。<br>3. 驱动模块未编译或未加载(使用lsmod检查)。 |
| 读写报错 -EREMOTEIO |
1. 物理连接问题(检查SCL/SDA线路、上拉电阻)。<br>2. 设备供电异常。<br>3. I2C地址错误(确认是7位地址,非8位)。 |
| i2cdetect扫描极慢或超时 |
1. 总线缺少上拉电阻。<br>2. 某个从设备故障将总线拉低(尝试复位设备或排查GPIO)。 |
初学者容易混淆这两类驱动,下表清晰列出了它们的核心差异:
| 特性 |
Platform Driver |
I2C Driver |
| 应用场景 |
SoC内部集成的硬件模块(如LCD控制器、USB控制器) |
挂载在I2C总线上的外部芯片(如传感器、EEPROM) |
| 绑定依据 |
设备树节点的名称或compatible属性 |
i2c_client结构体,核心是I2C从机地址 |
| 资源获取 |
通过设备树获取内存映射地址(reg)、中断号等 |
通过I2C从机地址,使用I2C总线API进行通信 |
| 通信方式 |
内存映射I/O(MMIO),CPU直接读写寄存器物理地址 |
总线事务,由I2C控制器产生时序波形进行通信 |
7. 总结
I2C驱动开发是嵌入式Linux驱动工程师必须掌握的核心技能之一。它标志着开发者具备了与外部芯片“对话”的能力。通过本文,你不仅了解了从设备树配置到驱动注册的完整流程,更接触到了工业级开发中不可或缺的 regmap框架。强烈建议在实际项目中使用regmap来抽象寄存器操作,它能显著减少代码量,并提升驱动的稳定性、可维护性和可移植性。