Linux I2C字符设备驱动开发实战:框架解析与调试 前阵子帮同事调一块传感器板i2c总线上的设备死活读不出数据折腾了两天最后发现不是硬件问题而是驱动框架没吃透字符设备节点注册好了数据读写却一直挂在半路。顺着这个坑把整个i2c子系统捋了一遍才发现只要你理解了“字符设备负责暴露接口i2c子系统负责管总线”这条主线写一个能跑的驱动其实没那么玄乎。这篇东西就是把我踩过的坑和最终梳理清楚的框架一起倒出来面向需要“照着写就能用”的人也顺带讲讲为什么这么写、写的时候哪些地方不能改、哪些地方必须注意。如果你正准备在Linux下驱动一个i2c温度传感器、eeprom、触摸屏控制器之类的小设备这篇应该能省你不少时间。1. 先看清楚两个子系统是怎么合体的1.1 字符设备和i2c子系统各自管什么很多人一上来就找“i2c驱动模板”其实容易绕晕。Linux下的i2c设备驱动本质上是两套机制叠在一起一套是字符设备机制负责在/dev下生成节点让用户态程序能有文件句柄去open、read、write、ioctl另一套是i2c子系统机制负责找到挂在总线上的设备、构造i2c_msg消息、发起传输、处理ACK/NACK。这两者不冲突反而是一前一后的关系。字符设备解决“怎么暴露给用户”i2c子系统解决“怎么把数据真的送到硬件上”。你写驱动时通常是先注册一个字符设备把它和某个私有数据结构绑在一起然后在读写回调里调用i2c子系统提供的传输接口。用户态感知不到i2c的存在只看到打开了一个文件读写这个文件内核这边则干了两层活。可以打个比方字符设备像小区门口的传达室用户把信投进去就完事i2c子系统像小区里的邮政系统负责把信按门牌号送到每户。你作为驱动作者既要在传达室安排人值班又得知道门牌号怎么编。1.2 为什么不用纯字符设备直接操作GPIO模拟i2c还有个常见的思路是自己用GPIO模拟i2c时序写成字符设备直接操作寄存器。这个方案在某些极简单的单片机上可行但在Linux下强烈不建议。Linux的i2c子系统帮你处理了几件麻烦事总线复用仲裁多客户端共享一条总线时避免冲突、时钟频率控制、ACK/NACK错误检测、系统休眠唤醒时的总线状态恢复以及和设备树机制的无缝衔接。你自己用GPIO模拟等于所有这些都得重造轮子还容易因为时序不标准导致芯片工作不稳定。更关键的是如果你不用子系统自带的i2c传输接口就没法和Linux里其他机制配合比如regmap框架、input子系统、hwmon框架后续扩展起来很痛苦。所以正规做法是结构上依赖i2c子系统接口上暴露字符设备。这样既保留了驱动的通用性又能给应用层提供最简单的文件操作方式。2. 动手前必须熟悉的几个关键API2.1 四个核心结构体写i2c字符设备驱动前先把下面这几个结构体刻在脑子里后面代码基本就是围着它们转i2c_adapter表示一条i2c总线控制器。通常由SoC的I2C控制器驱动注册设备驱动不需要自己创建只需要通过client-adapter拿指针。i2c_client表示挂在总线上的设备。在设备树匹配成功后内核会自动分配并填充。驱动里最常用的是client-addr7位设备地址和client-adapter总线控制器指针。i2c_driver驱动主体。需要填充id_table设备ID匹配表和probe、remove回调然后通过i2c_add_driver注册。i2c_msg一条传输消息。包含addr、flags、len、buf。flags里可以标记是否读操作I2C_M_RD、是否设置10位地址等。这个小集合里i2c_msg是传输的关键。你要读芯片寄存器就得构造两条消息第一条是写寄存器地址write第二条是读数据read然后通过i2c_transfer一次性发出去。很多人第一次写的时候只发了一条写消息然后单独发一条读消息结果发现读回来的数据不对——就是因为没有先写寄存器地址再发读命令。2.2 i2c_transfer是怎么工作的i2c_transfer的原型是int i2c_transfer(struct i2c_adapter *adap, struct i2c_msg *msgs, int num);它接收一个i2c_msg数组数组里每条消息可以独立设置方向和目标地址。内核会尽量把这些消息组织成一次原子化的总线事务中间不会被其他客户端插入能保证多消息序列的连续性。这个特性重要在哪调试过i2c的都懂如果你先发一个写消息再发一个读消息中间被别的驱动插一脚总线时序就乱了读回的数据可能就不是你要的。具体使用方式struct i2c_msg msgs[2]; int ret; msgs[0].addr client-addr; msgs[0].flags 0; // 写 msgs[0].len 1; msgs[0].buf reg_addr; msgs[1].addr client-addr; msgs[1].flags I2C_M_RD; // 读 msgs[1].len sizeof(buf); msgs[1].buf buf; ret i2c_transfer(client-adapter, msgs, 2); if (ret 0) { /* 错误处理 */ }注意返回值i2c_transfer返回的是成功传输的消息条数不是字节数。如果返回2说明两条都完成返回1说明第一条完成第二条失败返回负数说明传输过程出现问题。按条数判断别按字节数判断这是最容易写错的地方。2.3 i2c_master_send和i2c_master_recv是什么内核还封装了两个便捷函数int i2c_master_send(const struct i2c_client *client, const char *buf, int count); int i2c_master_recv(const struct i2c_client *client, char *buf, int count);它们内部就是把一条消息塞进i2c_transfer适合那种不需要两步操作的简单设备。比如某块eeprom纯顺序读直接i2c_master_recv就行。但如果你面对的是标准寄存器型传感器要指定寄存器地址再读那就要自己构造两条消息i2c_master_send/recv反而不够灵活。我的习惯是凡是涉及寄存器操作的一律用i2c_transfer手动构造消息可控性高。3. 手写一个完整的i2c字符设备驱动3.1 驱动骨架先搭起来直接上一份可以编译的骨架这里假设你要驱动一颗假的“xm_sensor”芯片这颗芯片支持用寄存器读取温度和状态。代码风格我尽量保持内核习惯不搞花活。#include linux/module.h #include linux/kernel.h #include linux/init.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/i2c.h #include linux/uaccess.h #include linux/slab.h #include linux/mutex.h #define XM_SENSOR_DEV_NAME xm_sensor #define XM_SENSOR_CLASS_NAME xm_sensor_class #define XM_SENSOR_REG_TEMP 0x01 #define XM_SENSOR_REG_CFG 0x02 struct xm_sensor_data { struct i2c_client *client; struct cdev cdev; struct device *device; struct mutex lock; dev_t devno; }; static struct class *xm_sensor_class; static int xm_sensor_major; static int xm_sensor_read_reg(struct xm_sensor_data *data, u8 reg, u8 *val) { struct i2c_client *client >static int xm_sensor_open(struct inode *inode, struct file *filp) { struct xm_sensor_data *data container_of(inode-i_cdev, struct xm_sensor_data, cdev); filp-private_data data; return 0; } static int xm_sensor_release(struct inode *inode, struct file *filp) { return 0; }read回调用来看user buffer然后从寄存器读值填进去。这里要注意一点尽量别在read里做复杂的i2c事务而不加锁。i2c_transfer本身是线程安全的吗不完全。同一时刻如果多个进程同时open这个设备并发读写底层的传输可能出现交错。解决方式就是每笔事务之间加一把锁static ssize_t xm_sensor_read(struct file *filp, char __user *buf, size_t len, loff_t *off) { struct xm_sensor_data *data filp-private_data; u8 val 0; int ret; mutex_lock(data-lock); ret xm_sensor_read_reg(data, XM_SENSOR_REG_TEMP, val); if (ret) { mutex_unlock(data-lock); return ret; } mutex_unlock(data-lock); if (copy_to_user(buf, val, sizeof(val))) return -EFAULT; return sizeof(val); }读一个字节直接copy_to_user一个字节不要搞花哨。真实项目里遇到多字节读取可以通过ioctl让用户态传入寄存器地址和长度驱动侧循环或批量构造i2c_msg。我建议多字节读取也一次性构造多条消息来读减少多次传输造成的时间开销。3.3 ioctl是高效交互的关键很多现实项目只靠read不够比如要写设备配置寄存器、要循环读一组数据、要设置工作时间模式。这时候可以统一走ioctl让用户态传一个结构体进来struct xm_sensor_rw_arg { u8 reg; u8 *buf; u8 len; u8 is_read; }; static long xm_sensor_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { struct xm_sensor_data *data filp-private_data; struct xm_sensor_rw_arg rw_arg; struct i2c_msg msgs[2]; int ret; if (copy_from_user(rw_arg, (void __user *)arg, sizeof(rw_arg))) return -EFAULT; mutex_lock(data-lock); msgs[0].addr >static int xm_sensor_probe(struct i2c_client *client, const struct i2c_device_id *id) { struct xm_sensor_data *data; int ret; if (!i2c_check_functionality(client-adapter, I2C_FUNC_I2C)) { dev_err(client-dev, i2c functionality not supported\n); return -EOPNOTSUPP; } data kzalloc(sizeof(*data), GFP_KERNEL); if (!data) return -ENOMEM; mutex_init(data-lock); >static const struct i2c_device_id xm_sensor_id[] { { xm_sensor, 0 }, { } }; static const struct of_device_id xm_sensor_of_match[] { { .compatible xm,xm-sensor }, { } }; static struct i2c_driver xm_sensor_driver { .probe xm_sensor_probe, .remove xm_sensor_remove, .id_table xm_sensor_id, .driver { .name xm_sensor, .of_match_table xm_sensor_of_match, }, }; module_i2c_driver(xm_sensor_driver); MODULE_LICENSE(GPL);注意compatible里确保和设备树里写的完全一致包括逗号前后大小写。设备树里写“xm,xm-sensor”驱动里也写“xm,xm-sensor”内核才能匹配上。每一项of_match_table都用花括号包起来最后留空项结束这是规矩。4. 设备树配置与模块加载流程4.1 设备树节点到底要写什么用设备树匹配驱动是你的dts里要有一个i2c总线下的子节点。i2c1 { status okay; clock-frequency 100000; xm_sensor: xm-sensor2c { compatible xm,xm-sensor; reg 0x2c; }; };reg就是芯片的7位i2c地址。等等这里有个经典问题芯片数据手册上写“地址0x58”但设备树reg填写的是0x2c为什么因为7位地址和8位地址的区别。手册上喜欢给你完整字节含读写位比如写地址0xB0、读地址0xB1对应的7位地址是0x58右移一位。而设备树reg要填7位地址也就是0x58。如果不做这个转换驱动就会一直在跟一个错地址的设备通信表现是传输返回成功但NACK或者根本没反应。4.2 设备和驱动是怎么牵上线的内核启动后或模块加载后i2c子系统会扫描所有adapter总线上的设备或者通过设备树解析为每个设备创建i2c_client。随后i2c子系统遍历已注册的i2c_driver用of_match_table或者id_table去匹配client。匹配成功就调用probe。这里有个容易被坑的地方设备树节点本身不会触发i2c地址扫描它是直接让内核知道“这条总线上这个地址有一个设备”。而如果不用设备树传统方式是在板级文件里加入i2c_board_info也会生成i2c_client。对现代内核来说能设备树就设备树干净利落。4.3 insmod之后发生了什么模块加载后module_i2c_driver会展开成module_init和module_exit的组合自动调用i2c_add_driver。此时如果设备树解析已完成符合匹配条件的i2c_client已经在总线上等着probe会被立即调用。字符设备的/dev节点也是在这个阶段创建的。所以验证顺序很关键先确认dts节点存在、compatible匹配再确认/sys/bus/i2c/devices下有对应设备目录最后再检查/dev/xm_sensor节点。我在调试时习惯按这个顺序排查ls /sys/bus/i2c/devices/ ls -l /dev/xm_sensor dmesg | tail -30如果/dev下有节点但/proc或sysfs里没设备目录说明设备树节点有问题如果设备目录在但/dev下没有节点那就是probe没走完多半是cdev_add或device_create出错了看dmesg就能定位。5. 用户态测试链路怎么搭5.1 一个简单的测试程序驱动写得好不好最终要应用层验证。写个简洁的C程序测试#include stdio.h #include stdlib.h #include string.h #include fcntl.h #include unistd.h #include sys/ioctl.h #include errno.h /* 与驱动里的结构体保持一致 */ struct xm_sensor_rw_arg { unsigned char reg; unsigned char *buf; unsigned char len; unsigned char is_read; }; #define XM_SENSOR_IOC_RW _IOWR(x, 1, struct xm_sensor_rw_arg) int main(void) { int fd open(/dev/xm_sensor, O_RDWR); if (fd 0) { perror(open); return 1; } struct xm_sensor_rw_arg arg; unsigned char val 0; arg.reg 0x01; arg.buf val; arg.len 1; arg.is_read 1; if (ioctl(fd, XM_SENSOR_IOC_RW, arg) 0) { printf(temp reg 0x%02x\n, val); } else { perror(ioctl); } close(fd); return 0; }开设备、发ioctl、读寄存器。就这么简单。很多内核驱动教程讲了半天驱动侧却忘了告诉你怎么验证我自己早期就被这个卡过。5.2 在没有驱动时先用i2c-tools验证硬件时序写驱动之前强烈建议先用i2c-tools直接验证芯片是否存在、地址是否正确。Linux里一般自带或者可以装i2c-toolsi2cdetect -y 1 i2cget -y 1 0x2c 0x01 i2cset -y 1 0x2c 0x02 0xff先看i2cdetect能不能扫到0x2c这个地址再用i2cget/i2cset测读写。如果这个阶段就不通别急着写驱动先用示波器或逻辑分析仪看SDA/SCL波形。工具验证通过基本能确定芯片时序没问题后面驱动写不好就是软件问题。设备树里clock-frequency我平时调100k很多传感器支持400k但是新手阶段先用100k稳妥。如果你有几个设备挂同一总线有的芯片时序要求严格把速率调太高容易出问题100k是不容易出事的起点。6. 高频问题排查和踩坑实录6.1 i2c_transfer返回-EREMOTEIO这是最常见的报错。含义是总线传输时收到了NACK。可能原因清单设备地址写错了7位/8位混淆或者芯片实际地址和手册不一致。芯片没上电或者复位引脚没拉对。总线频率过快芯片不支持。上拉电阻缺失或阻值不对导致信号质量差。设备不在这个总线上可能挂到另一路i2c控制器上了。排查顺序先确认设备树节点reg正确再用i2cdetect扫地址然后用i2cget手动读最后再看驱动代码逻辑。有个项目里经验是某芯片手册上地址是“0x90写/0x91读”但实际芯片A2/A1/A0引脚全部接地后i2cdetect显示0x48当时就靠“先硬件验证、再软件排查”定位到了硬件地址驱动没改一行。6.2 /dev下没有设备节点多半是probe没跑成功。用dmesg看有没有cdev_add失败的日志或者of_match_table没匹配上。这里有个常见误解以为只要加载了驱动模块就会自动生成节点忽略了probe被调用需要“设备存在”。设备树节点没写好、compatible写错probe根本不执行自然没有节点。6.3 读到的一直是0xff或者0x00这类情况看起来像是“读到了”却全是固定值。可能原因寄存器地址没写对芯片返回默认值或空值。芯片处于掉电模式或者配置寄存器没初始化。读时序不对比如手册要求先写寄存器地址必须带停止位你的两条消息被i2c子系统合并成一条事务导致芯片状态机错乱。这时可以把msgs数组拆成两次i2c_transfer中间加一点延迟看看是否变成正常读数。6.4 总线锁死SDA一直低i2c总线的SDA线被某个设备拉低通常是通信过程中设备端出了一个非正常状态。解决办法是总线恢复i2cdetect -y -r 1或者通过i2c-tools工具触发恢复时序。设计上尽量让设备驱动在remove时把总线状态清干净。调试时如果总线已经死了最简单的是复位所有设备或重新上电别老想在软件层面硬解。6.5 多进程访问导致的混乱如果不做锁保护两个进程同时ioctl读写传感器底层可能出现交叉消息比如A的消息和B的消息混在一起寄存器读写对不上。用了mutex之后同一时刻只有一笔transaction在执行简单有效。不过要注意mutex_lock放在i2c_transfer外面而不是消息构造外面代码里我通常是把消息构造和传输都包在同一把锁里确保不会出现“构造到一半被切走”的情况。6.6 数据长度不对齐有些芯片写寄存器时需要先写两个字节的寄存器地址比如16位寄存器地址或者读数据时地址按页偏移。这时候i2c_msg长度必须精确匹配芯片要求多一个字节少一个字节都会出问题。解决办法是死死对照芯片datasheet里的时序图数清楚每个字节是什么角色。7. 避开这些坑驱动才能长期稳定7.1 别忘了检查i2c_check_functionality有些adapter不支持重复起始条件或者不支持某种flags确实存在。如果驱动里用了特殊flags比如I2C_M_RECV_LEN这种一定要在probe阶段提前检测。宁可在probe时返回错误也不要等用户操作到一半才发现底层不支持。7.2 设备树改完后要吃透生效条件改了dts后要么重新编译设备树烧写要么在支持设备树overlay的系统上动态加载。有些人改了dts后只reboot不重新编译然后发现没生效浪费时间排查。确认加载的dtb确实包含你改的节点可以用ls /proc/device-tree/查看实际加载的设备树内容这个目录下能看到所有节点。7.3 寄存器读写接口要统一一个驱动里读寄存器可能用i2c_transfer写寄存器可能用i2c_master_send功能上没错但出错时排查路径不统一。我习惯把所有的寄存器读写都封装成两个函数一个read_regs一个write_regs里面统一走i2c_transfer。后续要加日志、加统计、加错误重试都只改这两个函数其他地方不用动。7.4 多字节读用一次事务而不是循环单字节读如果你要读一组连续寄存器循环单字节读会占用大量总线时间而且每次都要重新发起地址数据的传输很多芯片在连续读时有自动地址递增特性一条长读事务就能搞定。所以能合并的消息尽量合并性能差距在低速i2c上特别明显。7.5 消息缓冲的生命周期要盯死kmalloc出来的buf在i2c_transfer返回之前不能释放返回之后立刻释放没问题。i2c_transfer同步执行所以这个生命周期很容易管理但如果你以后引入异步传输框架就得小心了。交付前做个内存检查kmalloc和kfree一一配对。8. 一点额外的收获和习惯养成如果让我给刚接触这块的人一个建议那就是“先工具验证硬件再设计字符设备最后才写具体传输逻辑”。这个顺序我踩过几次坑才建立起来。工具验证通过后驱动写起来心里踏实得多反过来如果驱动半天调不通至少你知道不是硬件时序的问题排查范围一下子就缩小了。另外建议在驱动里多加点调试打印。驱动开发阶段开着dev_dbg或者dev_info记录每条i2c传输的寄存器地址和返回值等一切稳定后再关掉或改成tracepoint。我自己见过不少驱动调试困难都是因为日志太少出问题时无从下手。打印不是给别人看的是给自己排障用的该加就加。最后想说的是字符设备加i2c子系统这套组合看起来代码量不小但真正核心的思想很朴素字符设备让用户态好使i2c子系统让底层传输可靠两者通过i2c_client和i2c_transfer衔接。你把这两个纽带吃透之后无论换什么传感器、什么eeprom代码结构都不用大改最多改一下消息构造和寄存器表而已。这个框架的价值就在于它能陪着你把一个又一个新设备快速接入系统而且接入之后心里有底。