☰
Linux I2C驱动开发实战:从设备树到内核驱动框架解析
2026/10/24 1:13:05 网站建设 项目流程

1. 一个被反复问起的问题:Linux I2C驱动到底该从哪入手

做嵌入式Linux也有几年了,几乎每隔一段时间就会有人拿着开发板来找我,说"I2C设备不通,驱动到底怎么改"。一开始我还挺耐心地讲协议、讲设备树,后来发现大部分人的问题根本不在驱动源码上,而是对整个I2C子系统的工作方式缺少一张完整的地图。

回想我自己第一次接触Linux I2C驱动,也是从一片迷茫开始的。当时接了一个工业数据采集项目,要驱动一块国产温湿度传感器芯片,板子上的SoC自带两个I2C控制器,传感器挂在外挂的I2C总线上,地址按手册配置。我以为的核心工作是写一个file_operations、实现read/write、然后编译成一个内核模块加载进去。结果折腾了两周,发现真正影响成败的反而是总线控制器注册、设备树节点、探测顺序和用户态工具验证这些基本到不能再基本的东西。

这篇文章我不想照着内核文档念给你听,而是把整个Linux I2C驱动开发链路掰开揉碎,从协议层到内核框架,从设备树到调试手段,按照一个实战项目的推进顺序来讲。适合刚接触嵌入式Linux、看手册半天摸不到重点的开发者,也适合那些已经从"点灯"进阶到"真的要把一颗I2C外设跑通"的工程师。

我尽量把每一步遇到过的怪问题、查过的源码路径和最后的解决方案都写出来,能少走弯路的就别硬踩。

2. 协议层别看太多,抓住这三个现象就够用了

很多讲I2C的文章开头就是时序图、START、STOP、ACK信号,说实话这对写驱动有帮助,但帮助没想象中那么大。在Linux里写I2C驱动,本质上是你告诉内核"我要往哪个地址发什么字节、收什么字节",具体的时序、时钟速率、起始位停止位,全部由控制器硬件和i2c_adapter接管了。你真正需要理解的协议知识,浓缩下来就三条。

2.1 从设备地址是两个层面的事

I2C通信的第一步是对总线上的设备进行寻址。常见的有7位地址和10位地址两种模式,绝大多数传感器芯片用的都是7位地址。这里有一个很容易让人困惑的地方:芯片手册上写"设备地址0x48",你拿i2cdetect去扫,看到的却可能是0x48或0x90,这取决于芯片手册把读写位算没算进去。

7位地址是真实挂在总线上的地址,读写位是最低位,读操作时地址是(addr << 1) | 1,写操作是addr << 1。有些手册图省事,直接给你8位地址,比如0x90,那它的7位地址就是0x90 >> 1 = 0x48。如果你在内核代码或设备树里写错了这个地址,总线上一片Silent,什么错误都报不出来,因为I2C没有像网络协议那样完备的握手反馈。

我在刚调驱动时犯过的错就是把手册上的8位地址直接填进设备树,结果i2cdetect扫不到,排查了一个多小时。后来养成了习惯:凡是手册里给地址,先在脑海里或纸条上换算一遍,7位还是8位,读写位在哪,这是第一道防线。

2.2 寄存器的读写本质上是"先写寄存器号,再读写数据"

绝大多数I2C外设的行为都可以抽象成寄存器访问。你想读温度值,其实是先发起一个写传输,把要读的寄存器地址发给设备,然后发一个读传输,把设备返回的数据收回来。这两步可以合并,也可以分开,取决于芯片支持不支持"先写后读"组合格式。

在Linux下,这种操作对应的是i2c_transfer()函数里的一组i2c_msg数组。有的芯片要求必须在同一STOP之前完成写寄存器地址和读数据两个阶段,这时你就要把两个i2c_msg放在同一个传输序列里,依靠适配器驱动把它们拼成一个连续总线事务来处理。有的芯片允许拆开操作,那我更推荐用独立的读操作,出了问题好定位。

理解这层关系后,你就会明白为什么有时候明明寄存器地址写对了,读取却返回0xFF或乱码——很可能你用了i2c_smbus_read_byte(),这个函数只发一个读字节请求,没有先指定寄存器号,设备自然不知道你要读哪里。

2.3 ACK/NACK是排查总线的第一手证据

I2C总线上,从设备收到自己地址后会回ACK;寄存器地址错了、设备不支持该功能,可能会回NACK。在Linux里,i2c_transfer()返回负数时,你可以从返回值和errno大致判断问题类型。

我比较常用的验证方式是先用i2cdetect -y 2扫总线,看设备在不在。扫描的本质是往可能的地址上发送写请求,能收到ACK的设备就会显示出来。如果扫描结果为空,接下来就要检查硬件:上拉电阻焊了没有、电源对不对、SDA/SCL有没有接反、地址线电平配置对不对。这一步如果跳过,后面写再多驱动代码都是白搭。

3. 搭建运行环境时最容易忽略的三件套

搞清楚了协议,先别急着写代码。Linux的I2C驱动开发不是随便创建一个.c文件就能跑的,内核编译选项、设备树、用户态工具这三样东西缺一不可。

3.1 内核配置项,很多人压根没注意到

I2C子系统对应的内核配置选项是CONFIG_I2C,字符设备接口是CONFIG_I2C_CHARDEV。很多嵌入式发行版默认只开启了前者,后者没开,这就导致你在用户态看不到/dev/i2c-N节点,于是开始怀疑驱动根本没加载。

我检查其他项目的代码时经常发现,开发者的驱动模块写得很完整,insmod也成功了,但最终任务要求的是在用户态通过/dev/i2c-1来操作传感器。结果因为没开CONFIG_I2C_CHARDEV,节点不存在,整个方案推倒重来。在修改内核配置时,记住要把CONFIG_I2C_ALGOBIT、CONFIG_I2C_GPIO这类跟你控制器实现相关的选项也一并检查,特别是当你使用的是GPIO模拟I2C的场景,后者是必须的。

3.2 设备树里的一小段描述,决定了内核能不能找到你的设备

现代Linux内核启动后,I2C控制器驱动会注册一个i2c_adapter,然后代码中的i2c_client就得靠设备树或ACPI描述来匹配。设备树里看起来很简单的一小段,其实就是内核查找设备的关键索引:

&i2c1 { status = "okay"; clock-frequency = <100000>; temp_sensor@48 { compatible = "example,temp-sensor"; reg = <0x48>; }; };

这段描述包含的信息量很大。i2c1是某SoC上的第二个I2C控制器节点,clock-frequency配置了总线速率,子节点里的@48代表设备挂在地址0x48上,compatible字符串则用来和驱动里的of_match_table配对。

我当时第一次写设备树子节点时犯的错有两个:一是父节点status没有写成okay,导致整个控制器没有使能;二是reg填成了手册里的8位地址0x90,导致实际地址0x48上永远等不到设备注册。从那次以后,我每次改完设备树,都会先在用户态用i2cdetect确认实际分配的总线编号和地址范围,再回头检查设备树描述。

3.3 用户态工具是调试神器,但权限问题会卡住新手

i2c-tools这个软件包的作用再怎么强调都不为过。i2cdetect用来扫描总线,i2cget和i2cset用来读写寄存器,i2cdump用来整块查看寄存器内容。

在调试阶段,我几乎总是先用这些工具手动验证芯片的通信行为,确认寄存器能读能写以后,再去写内核驱动代码。这就相当于先用手动挡把路试通,再考虑自动驾驶。

不过很多初学者的板子用的是普通用户账号,运行i2cdetect会报Permission denied。这不是驱动问题,而是/dev/i2c-N节点的权限没配好,需要用chmod 666 /dev/i2c-N或者把用户加入i2c用户组。权限这种问题看起来低级,但确实能卡住人很久。

4. 内核驱动框架拆解:从注册到数据交换的代码级理解

现在到了正题。Linux I2C驱动采用标准的"驱动-设备-总线"模型,理解这个模型的核心术语,比记住API函数签名更重要。

4.1 三个核心结构体的分工

i2c_adapter代表一个I2C控制器,它知道如何产生时钟、如何发送起始位、如何逐字节搬移数据。通常由SoC的I2C控制器驱动或GPIO模拟驱动来注册,驱动开发者一般不需要碰它。

i2c_client代表一个挂在总线上的从设备,就是我们在设备树里写的那个子节点。内核匹配成功后会创建一个i2c_client对象,里面保存了设备地址、适配器编号、驱动信息。

i2c_driver才是我们要写的主要部分。它包含一个id_table或of_match_table,用来和设备树里的compatible字符串匹配;还有一个probe函数,当匹配成功时被调用。probe函数里通常做这些事:创建私有数据结构、初始化设备、注册miscdevice或regmap接口。

这三个结构体之间的关系可以概括为:adapter提供了总线通道,client标识了通道后面的具体设备,driver则是操作设备的那段代码。内核通过匹配driver和client,把设备和驱动绑在一起。

4.2 一个最小驱动的骨架

写一个完整的I2C驱动其实不复杂,最简形式大概长这样:

#include <linux/i2c.h> #include <linux/module.h> #include <linux/init.h> #include <linux/err.h> static int my_sensor_probe(struct i2c_client *client) { dev_info(&client->dev, "probed, addr=0x%02x\n", client->addr); return 0; } static void my_sensor_remove(struct i2c_client *client) { dev_info(&client->dev, "removed\n"); } static const struct of_device_id my_sensor_of_match[] = { { .compatible = "example,temp-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, }; module_i2c_driver(my_sensor_driver); MODULE_LICENSE("GPL");

这段代码的核心逻辑就是声明一个i2c_driver,然后用module_i2c_driver宏把它注册到系统里。设备树里有example,temp-sensor这个compatible字符串时,内核就会创建i2c_client并调用probe。

很多新手会犯一个理解错误,以为驱动一旦加载就会立刻运行probe。实际上,如果你的驱动是模块方式加载,而设备树里的设备在系统启动时就已经注册了,那么probe会立即被调用;如果设备树没写对或设备还没注册,probe可能永远不执行。insmod成功只能说明模块加载成功了,不代表设备匹配成功了。

4.3 实际读写数据的API选择

probe函数里最重要的是建立起读写设备的能力。我常用的API分成两类。

一类是底层的i2c_transfer,直接操作i2c_msg数组,灵活性最高:

static int sensor_read_reg(struct i2c_client *client, u8 reg, u8 *val) { struct i2c_msg msgs[2]; u8 reg_buf = reg; int ret; msgs[0].addr = client->addr; msgs[0].flags = 0; msgs[0].len = 1; msgs[0].buf = &reg_buf; msgs[1].addr = client->addr; msgs[1].flags = I2C_M_RD; msgs[1].len = 1; msgs[1].buf = val; ret = i2c_transfer(client->adapter, msgs, 2); if (ret < 0) { dev_err(&client->dev, "i2c_transfer failed: %d\n", ret); return ret; } return 0; }

另一类是SMBus风格的快捷函数,比如i2c_smbus_read_byte_data(client, reg)、i2c_smbus_write_byte_data(client, reg, val)。这些函数在常见的"读寄存器、写寄存器"场景下更简洁,适应性也更好,因为不少适配器专门针对SMBus协议做了优化。如果不是特别复杂的总线事务,我一般优先用SMBus函数,代码干净,语义清晰。

实际调试中我发现,有些芯片对寄存器操作有时序要求,比如读操作之后必须间隔多少微秒才能读下一次。这种需求驱动层也解决不了,建议直接用usleep_range或让用户态应用自己控制读取频率。

4.4 数据怎么递到用户态:miscdevice与regmap的取舍

probe函数只是打通了内核侧的数据通路,但用户态程序怎么拿到数据?最常见的做法是在probe里注册一个miscdevice,通过read、write、ioctl暴露设备的读写能力。

如果你看过一些老驱动,它们会把file_operations里的read函数写成"读取寄存器后把结果拷贝给用户"。这种方式直接,但不适合复杂的设备。

如果芯片寄存器很多,我更推荐用内核里的regmap框架。regmap把寄存器映射抽象得很彻底,你可以把I2C通信当作一个底层物理接口,然后通过regmap_read、regmap_write来访问寄存器,省去自己管理i2c_msg的麻烦。代码写起来长一点,但可维护性好很多,特别是在驱动里要同时支持设备树、ACPI、并且将来可能要换寄存器映射的时候,优势非常明显。

5. 用户态直接操作与内核驱动:两条路线怎么选择

写驱动的过程中,我和很多同行交流过一个问题:既然i2c-tools可以通过/dev/i2c-N直接读写寄存器,那还有必要写内核驱动吗?这个问题没有标准答案,但根据项目性质有明显倾向。

5.1 从用户态能做什么

内核开启了CONFIG_I2C_CHARDEV之后,每个i2c_adapter都会生成一个/dev/i2c-N节点。用户态程序可以通过open该设备文件,然后使用ioctl里的I2C_RDWR操作发送任意i2c_msg序列,或者使用SMBus相关的ioctl命令。

这种方式的优点非常明显:开发速度快,调试方便,不需要编译内核模块,随便用Python或C就能操作。很多传感器项目,其实用一组脚本就能全部搞定。

缺点也很显著:每次ioctl都涉及用户态和内核态的上下文切换,频繁通信时性能不好;没有统一的设备抽象,每个应用都得自己处理硬件细节;如果多个进程同时操作同一设备,还得自己做好并发控制。

5.2 什么情况下值得写内核驱动

如果你只是想在调试阶段验证芯片能不能工作,完全没必要一上来就写驱动。先用i2cget/i2cset摸清寄存器,如果项目周期短、数据量不大,直接就用用户态方案交付了。

但如果你的设备要被系统里的多个服务使用,或者需要遵循标准子系统框架,又或者涉及中断、热插拔、电源管理等用户态不好覆盖的功能,那就必须走内核驱动路线。我做过的一次迁移经历很能说明问题:最开始我用Python脚本直接通过/dev/i2c-1轮询传感器,跑了一周没问题;后来系统里要同时接三块同型号芯片,并且希望它们各自有自己的设备节点、互不干扰,脚本方式就变得极其别扭,最终还是把代码迁回内核驱动,用i2c_client的addr区分不同芯片,每个设备注册成一个独立的miscdevice,整个架构清爽了不止一个层级。

5.3 既要又要的做法:内核驱动实现核心,用户态工具做验证

我的习惯一直是这样:产品层面用内核驱动,调试阶段用用户态工具。两者不冲突,反而是互相验证的。

在跑内核驱动之前,我会用i2cdetect扫描确认地址,然后用i2cset写寄存器、i2cget读寄存器,确认操作时序和寄存器定义。驱动写完以后,再通过/dev/i2c-N上的用户态程序做一遍同样的操作,对比结果。如果内核驱动读出来的数据和用户态工具读出来的一致,就说明数据路径没问题;如果内核态结果不对但用户态对,那就是驱动代码的问题,缩小排查范围。

6. 实操案例:让一个温湿度传感器在五分钟内跑起来

理论讲再多,不如直接过一个完整的实例。我以某款I2C接口的温湿度传感器为例,演示从设备树到用户态的完整流程。芯片结构很简单,地址0x48,读寄存器0x00获得温度高位,寄存器0x01获得温度低位,写寄存器0x02触发测量。

6.1 设备树配置

假设SoC的I2C控制器挂载在i2c1节点上,设备树里增加如下内容:

&i2c1 { status = "okay"; clock-frequency = <100000>; temp_hum@48 { compatible = "example,th-sensor"; reg = <0x48>; }; };

改完设备树重新编译并烧录启动,然后查看内核有没有生成对应的节点。启动完后,在用户态检查:

ls /dev/i2c-* i2cdetect -y 1

如果i2cdetect输出里能看到48,说明设备已经被内核识别,硬件链路也通了。

6.2 内核驱动初始化与读取

这里我用miscdevice做一个最简单的示例:

static int th_open(struct inode *inode, struct file *file) { return 0; } static ssize_t th_read(struct file *file, char __user *buf, size_t count, loff_t *ppos) { struct i2c_client *client = container_of(file->private_data, struct i2c_client, dev); u8 reg = 0x00; u8 val; int ret; ret = i2c_transfer(client->adapter, (struct i2c_msg[]) { { .addr = client->addr, .flags = 0, .buf = &reg, .len = 1 }, { .addr = client->addr, .flags = I2C_M_RD, .buf = &val, .len = 1 }, }, 2); if (ret < 0) return ret; if (put_user(val, buf)) return -EFAULT; return 1; }

运行效果先不细说,重点是整个调试过程一定要分阶段验证。我在实际项目中踩过最头疼的一个问题就是:设备树节点写好了,用户态i2cdetect也能看到设备,但驱动probe始终进不去。后来查了/sys/bus/i2c/devices/下的目录,发现内核其实已经创建了设备,只是匹配的compatible字符串大小写没对上,这属于最低级的错误,但排查起来也挺费劲。

6.3 加载验证

把驱动编译成模块后:

insmod th_sensor.ko dmesg | tail

能看到类似probed, addr=0x48的日志,说明绑定成功。之后使用:

cat /dev/th_sensor

就能读到原始数据。此时再用i2cget -y 1 0x48 0x00交叉验证,两者结果一致,整个链路就是通的。

7. 问题排查路径:从"完全没反应"到"数值不对"的分层判断

调试I2C驱动是门手艺活,我把它总结成一个分层判断模型。

7.1 整条总线无设备:硬件至上

先插上USB转I2C调试器或者用示波器看SDA/SCL波形。最常见的是上拉电阻没焊,导致总线钳在低电平,任何设备都扫不到。其次是地址线硬件配置错误,有些芯片通过A0/A1/A2引脚设定地址,焊错了就换了一片你永远找不到的地址。

这两类问题在i2cdetect面前表现一致:全为空。区别在于,如果只是地址不对,你用示波器应该能抓到总线活动;如果上拉缺失,通常波形完全是一根平线。

7.2 设备能扫到,但通信返回错误

i2cdetect能看到设备,说明寻址阶段是好的。接下来i2cget去读寄存器,如果返回错误值,先检查时序。有的芯片要求时钟频率不能太高,100kHz不行就试试400kHz?不对,应该反过来,有些芯片只支持100kHz,你配置成400kHz就会出现偶发错误。clock-frequency这个参数要从芯片手册里查,不能用经验值。

另一个常见坑是地址冲突。总线上有两个设备地址相同,I2C协议的仲裁机制会让通信变得时好时坏。i2cdetect一般都能判断出来,会显示UU表示设备被内核驱动占用,或者显示两个相同地址。实在不行就把其他设备逐个断开排查。

7.3 数据能读,但数值明显不对

如果驱动已经读到数值,但数值诡异,比如湿度是120%,温度跳变剧烈,先别怀疑驱动,先看电源噪声和传感器附近的电路布局。I2C是一种慢速同步总线,但模拟信号上的噪声一样会通过电源影响数字通信。我遇到过一个很隐蔽的问题:读数偶发出错,排查了很久,最后发现是给传感器供电的LDO纹波过大,加了一颗滤波电容后问题消失。

数值不对的另一个原因是寄存器补码格式没解析对。很多温湿度传感器的温度值是有符号数,高位字节的最高位代表符号位。如果你只做了(high << 8) | low,负数会变成很大的正数。我建议所有驱动代码里的数据解析部分,一定要用明确的掩码和符号扩展,不能靠感觉。

7.4 内核态读不到,用户态正常

这属于最让人抓狂的情况。用户态工具数据一切正常,驱动里用同样的地址却返回失败。我仔细查过,大多是因为你没有检查i2c_client->addr是否和设备树里配置的地址一致。有些场景下,内核会自动对地址做转换,或者client->addr因为适配器驱动的关系被改写了。

解决方案很简单:在probe函数里立刻打印出client->addr和client->adapter->nr,用dev_info打出来,启动一次就能看到实际值。如果实在对不上,直接用client->adapter和client->addr构造i2c_msg,不要去假设任何外部配置。

8. 经验沉淀:几个每次做项目都会用到的习惯

做Linux I2C驱动这几年,我养成了几个固定习惯,虽然看起来琐碎,但确实在多个项目里救过我。

第一,所有涉及地址、寄存器号的地方,都用宏定义或枚举,不要散落字符串。一个温湿度芯片不算复杂,如果换到有上百个寄存器的PMIC芯片,散落魔法数字的代码改一遍就得掉一层皮。

第二,通信失败一定要把errno和具体操作打出来。i2c_transfer返回负数时,不同错误码对应的问题很不一样。-EIO通常是总线层面的硬件问题,-EREMOTEIO则是NACK相关的从设备问题,-ENXIO要检查设备存不存在。打印日志加上出错码,比苦思冥想强得多。

第三,调试I2C设备时,用户态工具和内核驱动交叉验证是效率最高的手段。不要寄希望于一次写完美,先用i2c-tools把所有寄存器摸清楚,写驱动时心里就有底了。

第四,读懂芯片的数据手册比读内核源码更重要。I2C驱动本身的代码模式就那么几种,真正的复杂度全在芯片的行为和寄存器定义里。手册里描述的读数时序、转换时间、忙标志,这些才是驱动能否正确工作的关键。

把上面这些串联起来,你会发现Linux I2C驱动并没有想象中那么神秘。无非是先通过设备树让内核认识设备,再用i2c_driver把设备和驱动绑在一起,最后通过一组读写API完成数据交互。真正拉开差距的是你排查问题的思路和踩坑的经验,这些只能靠一个项目一个项目地积累。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询