I2C字符设备驱动注册链路与用户态访问实战 对 I2C 总线层和 i2c_driver 的 probe 流程上手之后绕不开一个问题内核里扫到了设备、寄存器也能读了可用户空间那边完全看不见——应用同事问你“设备节点在哪”你总不能让他每次都写个内核小模块来验证。这正是 I2C 字符设备该出场的位置。它的职责很明确把一条已经绑好驱动的 I2C 通道包装成 /dev 下的一个文件节点让用户态用 open/read/write/ioctl 这套最熟悉的方式去访问不需要重新编译内核、不需要改驱动、更不需要为了读一个寄存器去写测试模块。下面这篇内容围绕 I2C 字符设备的注册链路和实际使用展开从设备号怎么分、cdev 怎么挂、file_operations 怎么实现到 probe/remove 的时序、节点自动出现的条件、多实例怎么区分最后落到编译加载和用户态跑通的完整过程。适合已经能看懂设备树匹配、但卡在“驱动写完了用户态怎么调用”这一步的读者也适合想把现有 I2C 驱动补上用户接口的人对照检查。1. 为什么 I2C 驱动还要再挂一层字符设备1.1 三条把 I2C 设备暴露给用户空间的路线同一颗挂在 I2C 上的芯片内核里有好几种方式把它开放给用户态选错了后面全是返工。我把常见的三条路线列出来对比一下这几条路上的取舍基本决定了你后面要写多少代码。路线入口形态代码量灵活性适用场景内建 i2c-dev/dev/i2c-N零内核自带高但只到“字节/块”粒度调试、产线测试、简单寄存器读写自建字符设备/dev/自定义名中百来行完全可控需要封语义化命令、权限隔离、状态缓存归入子系统iio/hwmon/input/sys 或专用节点高中受框架约束标准传感器能被现成用户态库识别第一条路最省事内核里本来就有i2c-dev.c编译时打开CONFIG_I2C_CHARDEV每个 I2C 适配器就会多出/dev/i2c-0、/dev/i2c-1这样的节点。用户态 open 之后先ioctl(fd, I2C_SLAVE, addr)认领从机地址接着就能用I2C_RDWR或者 SMBus 系列命令收发。i2c-tools里那一堆i2cdetect、i2cget、i2cset全是踩在这条路上的。第二条路就是本篇的重点。你自己实现一个字符设备把i2c_client攥在手里用户态读的不再是裸寄存器而是你定义的语义。举个具体差别传感器有 6 个寄存器拼成一个 16 位有符号量走 i2c-dev 用户态得读两次再自己拼字节序、自己做符号扩展走自建字符设备内核里拼好、换算成物理量用户态read一次拿到一个int就完事。1.2 i2c-dev 的能力边界卡在哪i2c-dev 的定位是“透传”它刻意不做任何设备语义的解读。这个设计在调试阶段是优点在量产阶段就变成负担。第一个问题是权限粒度太粗。/dev/i2c-1这个节点对应的是整条总线谁拿到写权限谁就能对总线上任意地址发任意数据包括那些已经被别的驱动 probe 走的设备。一条总线上挂了电源管理芯片和传感器你把节点给应用层去读传感器等于顺手把电源芯片也交出去了。自建字符设备可以把节点粒度做到单个从机权限就能精确到“只能读这颗”。第二个问题是无状态。i2c-dev 每次ioctl都是一次独立的传输两次调用之间芯片内部状态可能被别的进程改掉也可能你自己漏了一次初始化。自建驱动可以在open时做一次上电检查、在read时做一致性校验、在release时清掉缓存。第三个问题是数据加工必须放在用户态。校验和、单位换算、量程判断这些逻辑,放在内核里做一次比在每个调用方各写一遍要可靠。尤其是多个应用共用同一颗芯片时逻辑散落在各处改一次要改好几处。提示这两条路不冲突。调试阶段用 i2c-dev 快速确认硬件通路量产阶段换自建字符设备做封装是很常见的推进节奏。不必一开始就上自建驱动。1.3 什么情况下该自己写字符设备我的判断标准有三条中一条就值得自己写。一是需要语义化接口。用户态不该知道寄存器地址ioctl传的是“读温度”“设量程”这种命令号而不是 0x07 这种偏移。这样芯片换代、寄存器映射变了用户态代码一行不用改。二是需要访问控制。比如某个校准命令只能在产线模式下执行那就在ioctl里判断当前进程权限或者一个模块参数开关i2c-dev 做不到这种拦截。三是需要做资源复用与状态同步。多个进程同时打开你得保证它们不会交错发出半截传输内核里一把 mutex 就能解决用户态要自己搞进程间锁麻烦且容易出漏洞。反过来说如果只是产线点个灯、测一下总线通不通或者芯片语义简单到就是一串 EEPROM 字节那老老实实用 i2c-dev别为了“看起来专业”去写一百多行驱动维护成本比收益高。2. 设备号与 cdev 的注册链路拆解2.1 主次设备号的两种分配方式与选择依据字符设备进内核的第一件事是拿到一个dev_t。这个 32 位数拆成主设备号和次设备号主设备号标识“哪一类驱动”次设备号标识“这一类里的第几个实例”。分配方式有两种选错会在多模块环境下直接冲突。静态分配用register_chrdev_region(devno, count, name)前提是你自己指定主设备号。除了少数有历史约定的号段比如 tty、misc 那些一般不要这么干因为主设备号是全局资源你写死一个 240别人也写死 240两个模块一碰就有一个加载失败报-EBUSY。动态分配用alloc_chrdev_region(devno, baseminor, count, name)内核从空闲号段里挑一个给你。这是推荐做法特别是产品驱动。代价是主设备号不固定设备节点必须靠 udev/mdev 自动创建或者加载后用cat /proc/devices查出来再mknod。/* 动态申请 4 个连续次设备号 */ int ret alloc_chrdev_region(demo_base, 0, DEMO_MAX_DEV, DEMO_NAME); if (ret 0) { pr_err(alloc_chrdev_region failed: %d\n, ret); return ret; } pr_info(major%d, base minor%d\n, MAJOR(demo_base), MINOR(demo_base));count给多少取决于你打算支持几个实例。一颗 I2C 芯片一个实例那就按最大可能挂载数量给。给多了浪费号段但不影响功能给少了后面device_create会因为次设备号越界出问题。2.2 cdev_init 与 cdev_add 到底做了什么拿到设备号只是占了坑还没告诉内核“这个号由谁来处理”。struct cdev就是这个绑定关系的载体它内部维护着kobj和ops把file_operations和一段设备号区间挂到一起。标准三步cdev_init初始化并把 ops 挂进去、cdev_add正式加入内核、cdev_del摘除。顺序不能反cdev_add之前必须cdev_init因为 add 的时候会去读cdev-ops。static const struct file_operations demo_fops { .owner THIS_MODULE, .open demo_open, .release demo_release, .read demo_read, .write demo_write, .unlocked_ioctl demo_ioctl, }; cdev_init(ddev-cdev, demo_fops); ddev-cdev.owner THIS_MODULE; ret cdev_add(ddev-cdev, ddev-devno, 1); if (ret) { pr_err(cdev_add failed: %d\n, ret); return ret; }这里有个细节容易被忽略cdev_init内部会把cdev-ops指向你传进去的file_operations但cdev_add之后如果返回失败cdev已经被部分初始化过必须走cdev_del或者直接放弃这个cdev结构体不能再用它做第二次cdev_add。所以在 probe 里写错误处理时每个失败分支的回滚要精确对应到已经成功的步骤不能一股脑全清。count参数是这次 add 覆盖的次设备号数量。如果你一次申请了 4 个次设备号可以一次cdev_add(cdev, base, 4)让同一个file_operations服务 4 个次设备也可以在循环里对每个实例单独 add 一个。前者代码少后者便于给每个实例挂不同的私有数据。我一般选后者因为多实例场景几乎总要区分client。2.3 新旧内核 API 差异与编译兼容写法字符设备这块最烦的就是class_create的签名变过。老接口是class_create(THIS_MODULE, name)较新的内核把owner参数去掉了直接class_create(name)。如果你的代码要在多个内核版本上编译硬写哪一边都会在另一边上炸。用版本宏做条件编译是最省心的办法#include linux/version.h #if LINUX_VERSION_CODE KERNEL_VERSION(6, 4, 0) demo_class class_create(DEMO_CLASS_NAME); #else demo_class class_create(THIS_MODULE, DEMO_CLASS_NAME); #endif if (IS_ERR(demo_class)) { ret PTR_ERR(demo_class); demo_class NULL; goto err_cdev; }device_create的返回值判断也常被写错。它返回的是struct device *失败时返回错误指针而不是 NULL所以必须用IS_ERR()判断用if (!dev)判断永远为假然后你在后续device_destroy里传进去一个错误指针直接 oops。这种错我在早期代码里写过不止一次。struct device *dev device_create(demo_class, NULL, ddev-devno, ddev, DEMO_NAME %d, ddev-index); if (IS_ERR(dev)) { ret PTR_ERR(dev); goto err_class; }2.4 class_create 与 device_create节点自动出现的条件很多人以为cdev_add成功后/dev下就该有节点结果ls /dev什么都没有就开始怀疑代码。实际上内核只负责“注册”节点文件是用户态工具根据 sysfs 里的信息创建的。链路是这样的class_create在/sys/class/下建一个目录device_create在这个目录下建一个设备条目条目里带着dev属性记录主次设备号。udev 或者 mdev 监听内核的 uevent看到新条目就按规则mknod出/dev/xxx。所以节点出不来的原因通常有三个没开 devtmpfs、没有 udev/mdev 在跑、或者device_create的 name 格式和你以为的不一样。device_create的 name 参数是格式化字符串DEMO_NAME %d配index会生成demo0、demo1。这个名字和/sys/class/demo_class/下的目录名一致也和最终的/dev/demo0一致。如果你在device_create里用了%d但传参类型不对名字会变成乱码或者干脆创建失败。系统里没有自动创建机制时只能手动补# 查主设备号 cat /proc/devices | grep demo # 手动创建c 表示字符设备 mknod /dev/demo0 c 240 0 chmod 660 /dev/demo0手动 mknod 的缺点很明显主设备号每次加载可能变脚本得动态解析。所以产品环境还是把 udev 规则配上一条规则解决权限和命名KERNELdemo[0-9]*, MODE0660, GROUPi2cgrp3. 把 i2c_client 塞进 file_operations读写的实现细节3.1 open 阶段的私有数据绑定与合法性校验file_operations里的函数拿不到i2c_client只能拿到struct file *和struct inode *。要把两者关联起来标准做法是在open里从inode-i_cdev反查容器结构然后塞进file-private_data后续read/write/ioctl全从private_data取。static int demo_open(struct inode *inode, struct file *file) { struct demo_dev *ddev container_of(inode-i_cdev, struct demo_dev, cdev); if (!ddev || !ddev-client) return -ENODEV; /* 上电自检确认从机还在总线上 */ if (i2c_smbus_read_byte_data(ddev-client, REG_CHIP_ID) 0) return -ENXIO; file-private_data ddev; return 0; }container_of能成立的前提是cdev是struct demo_dev的成员而不是一个独立分配的指针。很多人为了图省事写struct cdev *cdev然后单独kzalloc结果container_of算出来的地址是垃圾open一调用就崩。要么把cdev内嵌要么在cdev上挂dev_get_drvdata之类的方式反查别用裸指针。open里做一次芯片 ID 校验是我强烈建议的习惯。硬件没焊好、地址配错、供电异常这些问题在open阶段暴露出来用户态拿到的是-ENXIO一眼就知道是硬件问题如果拖到read阶段才炸报错栈会指向一次具体传输排查路径长得多。3.2 read 与 write 的 copy_to_user 与长度校验内核态和用户态的地址空间是隔离的read/write里绝对不能直接对buf做memcpy或者解引用必须走copy_to_user和copy_from_user。read的典型实现是读寄存器、按你约定的二进制布局打包、拷回用户态struct demo_sample { __s16 raw; __s32 milli; /* 换算后的物理量单位毫摄氏度 */ }; static ssize_t demo_read(struct file *file, char __user *buf, size_t count, loff_t *ppos) { struct demo_dev *ddev file-private_data; struct demo_sample s; s32 hi, lo, val; if (count sizeof(s)) return -EINVAL; hi i2c_smbus_read_byte_data(ddev-client, REG_TEMP_H); if (hi 0) return hi; lo i2c_smbus_read_byte_data(ddev-client, REG_TEMP_L); if (lo 0) return lo; val (hi 8) | lo; if (val 0x8000) val - 0x10000; /* 16 位补码转有符号 */ s.raw (__s16)val; s.milli val * 625 / 10; /* 0.0625℃ 一步换成毫摄氏度 */ if (copy_to_user(buf, s, sizeof(s))) return -EFAULT; return sizeof(s); }这段里有三个容易踩的点。第一i2c_smbus_read_byte_data返回int失败是负数直接赋给u8会把-EREMOTEIO变成一个合法数据 0x71然后你的温度读数莫名其妙变成 113 度。第二16 位补码的符号扩展必须自己做C 里(s16)val强转依赖编译器和实现用减法更稳。第三copy_to_user返回未拷贝的字节数非零就是失败返回-EFAULT是内核约定别返回-EIO。write侧关心的是“用户发来的字节怎么解释”。最简单的约定是buf[0]是寄存器地址buf[1..]是数据static ssize_t demo_write(struct file *file, const char __user *buf, size_t count, loff_t *ppos) { struct demo_dev *ddev file-private_data; u8 kbuf[8]; int i, ret; if (count 2 || count sizeof(kbuf)) return -EINVAL; if (copy_from_user(kbuf, buf, count)) return -EFAULT; mutex_lock(ddev-lock); for (i 1; i count; i) { ret i2c_smbus_write_byte_data(ddev-client, kbuf[0] i - 1, kbuf[i]); if (ret 0) { mutex_unlock(ddev-lock); return ret; } } mutex_unlock(ddev-lock); return count; }kbuf设了上限 8 不是随便写的。栈上数组如果按用户传的count来用理论上就能溢出所以要么固定上限要么kmalloc后kfree。另外注意中间某个字节写失败时前面的字节已经写进去了这是非原子操作用户态需要重试策略——这类语义最好在文档里写清楚别让调用方靠猜。3.3 ioctl 命令设计与 i2c_transfer 的承载方式read/write适合线性数据多参数、需要返回状态的操作用 ioctl 更合适。命令号不能随手#define成 0、1、2要和别的驱动撞车。标准做法是用_IOR/_IOW/_IOWR宏把方向、类型魔数和序号编进去#define DEMO_IOC_MAGIC D #define DEMO_IOC_GET_RANGE _IOR(DEMO_IOC_MAGIC, 1, int) #define DEMO_IOC_SET_RANGE _IOW(DEMO_IOC_MAGIC, 2, int) #define DEMO_IOC_RAW_XFER _IOWR(DEMO_IOC_MAGIC, 3, struct demo_xfer)unlocked_ioctl的签名里那个unsigned long arg是用户态地址所有指向结构的参数都得先copy_from_user拷进来用完再copy_to_user拷回去不能直接解引用。这一点和读写的规矩完全一样。需要一次收发多个缓冲区时i2c_transfer比 SMBus 系列更灵活。它接受一个struct i2c_msg数组可以把“写寄存器地址 重复起始 读数据”合成一次原子传输static int demo_read_block(struct i2c_client *client, u8 reg, u8 *out, int len) { u8 addr reg; struct i2c_msg msg[2] { { .addr client-addr, .flags 0, .len 1, .buf addr }, { .addr client-addr, .flags I2C_M_RD, .len len, .buf out }, }; return i2c_transfer(client-adapter, msg, 2); }返回值是成功传输的消息条数正常应该是 2。如果返回 1说明第一条写完第二条读失败了从机中途 NAK 了。这种部分成功的情况必须单独处理不能简单地if (ret 0)就完事——ret 1在语义上是失败但符号判断会漏掉它。3.4 并发、睡眠上下文与锁的取舍I2C 传输的底层是i2c_transfer它可能睡眠因为很多适配器实现走的是中断或者 DMA 等待。这意味着两件事不能持有自旋锁去调传输也不能在中断上下文里调传输。所以保护总线访问的锁只能是 mutex或者干脆交给 I2C 核心层——i2c_transfer内部已经对adapter-bus_lock加了锁同一适配器上的传输本身就是串行的。那你自己的 mutex 保护什么保护的是“两次传输之间的状态一致性”。举个例子read要读高字节和低字节两个寄存器如果中间被另一个进程的write插进去改了阈值寄存器你读出来的高低字节可能来自两次不同的采样。这种跨多次传输的原子性I2C 核心层不管得你自己用 mutex 圈起来。static DEFINE_MUTEX(demo_global_lock);在probe里mutex_init(ddev-lock)。注意别在open里 init多进程打开会把已经上锁的 mutex 重置行为未定义。还有一个上下文问题如果适配器驱动本身是靠工作队列模拟的有些平台的低速 I2C 会这么干在原子上下文里调用会触发BUG: scheduling while atomic。判断方法很简单看dmesg里有没有这句有就说明调用路径上某处进了原子上下文通常是持了 spinlock 或者关中断。4. 设备树的匹配与 probe/remove 的完整时序4.1 of_match_table 与 i2c_device_id 双表并存I2C 驱动的匹配有两条独立的路设备树匹配走of_match_table老式的板级注册走i2c_device_id数组。两张表都要给别只给一张。只给of_match_table在新式板子上没问题但一旦要兼容旧板子或者手动new_device就匹配不上只给i2c_device_id则设备树里的compatible白写了。static const struct of_device_id demo_of_match[] { { .compatible vendor,demo-sensor }, { } }; MODULE_DEVICE_TABLE(of, demo_of_match); static const struct i2c_device_id demo_id[] { { demo-sensor, 0 }, { } }; MODULE_DEVICE_TABLE(i2c, demo_id); static struct i2c_driver demo_driver { .driver { .name demo-sensor, .of_match_table demo_of_match, }, .probe demo_probe, .remove demo_remove, .id_table demo_id, }; module_i2c_driver(demo_driver);MODULE_DEVICE_TABLE不能省。它生成的是模块别名热插拔和自动加载靠这个匹配。少了它设备树里写了compatible但驱动不会自动加载只能手动insmod。4.2 probe 里的注册顺序与失败回滚probe里做的事情多顺序错了回滚会很乱。我的固定顺序是先申请私有数据、再申请设备号、再 cdev、再 class、再 device。前面的失败只需要清前面的后面每个goto标签精确对应一层。static int demo_probe(struct i2c_client *client, const struct i2c_device_id *id) { struct demo_dev *ddev; int idx, ret; idx demo_find_free_slot(); if (idx 0) return idx; ddev devm_kzalloc(client-dev, sizeof(*ddev), GFP_KERNEL); if (!ddev) return -ENOMEM; ddev-client client; ddev-index idx; ddev-devno MKDEV(MAJOR(demo_base), MINOR(demo_base) idx); mutex_init(ddev-lock); i2c_set_clientdata(client, ddev); cdev_init(ddev-cdev, demo_fops); ddev-cdev.owner THIS_MODULE; ret cdev_add(ddev-cdev, ddev-devno, 1); if (ret) return ret; ddev-dev device_create(demo_class, client-dev, ddev-devno, ddev, DEMO_NAME %d, idx); if (IS_ERR(ddev-dev)) { ret PTR_ERR(ddev-dev); ddev-dev NULL; cdev_del(ddev-cdev); return ret; } demo_slots[idx] ddev; dev_info(client-dev, registered /dev/%s%d\n, DEMO_NAME, idx); return 0; }主设备号在模块 init 里申请probe里只算次设备号这样多个实例共用同一个主设备号符合字符设备的常规约定。如果把alloc_chrdev_region放进probe每来一个设备就占一段号号段很快被吃光而且/proc/devices里会出现好几行同名不同号的记录排查时很乱。4.3 remove 里的逆序释放与野指针规避remove的顺序必须是probe的严格逆序先device_destroy、再cdev_del、最后释放槽位。顺序反了节点还在但背后的file_operations已经摘了用户态此时open会拿到一个悬空指针或者直接崩在内核里。static int demo_remove(struct i2c_client *client) { struct demo_dev *ddev i2c_get_clientdata(client); if (!ddev) return 0; demo_slots[ddev-index] NULL; if (ddev-dev) { device_destroy(demo_class, ddev-devno); ddev-dev NULL; } cdev_del(ddev-cdev); /* devm_kzalloc 分配的内存由框架自动释放不能手动 kfree */ return 0; }这里有个高频错误用了devm_kzalloc又在remove里kfree结果是双重释放。devm_系列的内存生命周期绑定在client-dev上驱动解绑时框架自动释放你只要保证不再引用就行。反之如果用的是普通kzalloc那必须自己kfree忘了就是内存泄漏重复加载卸载几十次就能看出来。还有一个隐性问题remove执行时可能还有进程持有该设备节点的 fd。内核不会强制关闭这些 fdfile_operations释放后这些 fd 上再来一次ioctl就会踩到已释放的ddev。严格的写法是在remove里等引用计数归零或者用cdev_del之后的kobject引用保护。实际产品里更常见的做法是接受这个窗口因为热插拔场景本身就要求应用层配合重新打开。4.4 多实例场景下的次设备号索引分配同一颗芯片在一条总线上挂两个地址不同或者同一型号挂在两条总线上都会触发多实例。此时probe需要给每个实例分配一个唯一的index用它算次设备号、拼设备节点名。最简单的做法是维护一个固定大小的指针数组probe时找第一个空位remove时清空static struct demo_dev *demo_slots[DEMO_MAX_DEV]; static int demo_find_free_slot(void) { int i; for (i 0; i DEMO_MAX_DEV; i) { if (!demo_slots[i]) return i; } return -ENOSPC; }用数组而不是ida的好处是节点名稳定remove之后空出的槽位可以复用不会出现/dev/demo7这种大号。缺点是需要自己处理并发——两个设备同时probe时可能抢到同一个槽。稳妥一点加个 mutex 圈住查找和占位或者直接用ida_simple_get/ida_simple_remove让内核帮你分配。槽位复用还有个隐患如果应用层缓存了/dev/demo0对应的是哪颗芯片remove再probe之后demo0可能换成了另一颗。要避免混淆可以在read返回的数据里带上芯片唯一 ID或者用总线号加地址拼节点名比如/dev/demo-1-0050一眼就知道是 1 号总线 0x50 地址。5. 实测从 insmod 到用户态跑通的全过程5.1 编译、加载与节点确认写个最小 Makefile走内核的 kbuild 体系别自己拼 gcc 命令行——头文件路径和编译选项一多手拼必错。obj-m i2c_demo.o KDIR ? /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) clean编译通过后加载确认三件事按顺序发生模块加载成功、probe被调用、设备节点出现。sudo insmod i2c_demo.ko dmesg | tail -20 cat /proc/devices | grep demo ls -l /sys/class/demo_class/ ls -l /dev/demo*dmesg里应该能看到registered /dev/demo0。如果probe压根没被调用说明设备树匹配失败——先看compatible拼写再看/sys/bus/i2c/devices/下有没有对应的i2c-N-XXXX目录。如果目录存在但驱动没绑上cat /sys/bus/i2c/devices/i2c-1/1-0050/name看看名字再对比id_table。节点没出现但/proc/devices里有记录说明device_create到节点的最后一步断了通常是 udev 没跑或者规则没匹配。手动mknod验证一下能打开就说明是创建设备节点的问题不是驱动的问题。5.2 用户态测试程序与常见返回码写个最小测试程序把 open、ioctl、read、write 四条路都走一遍#include stdio.h #include fcntl.h #include unistd.h #include string.h #include errno.h #include sys/ioctl.h struct demo_sample { short raw; int milli; }; int main(void) { int fd open(/dev/demo0, O_RDWR); if (fd 0) { fprintf(stderr, open failed: %s\n, strerror(errno)); return 1; } struct demo_sample s; if (read(fd, s, sizeof(s)) ! sizeof(s)) { fprintf(stderr, read failed: %s\n, strerror(errno)); close(fd); return 1; } printf(raw%d, milli%d\n, s.raw, s.milli); int range; if (ioctl(fd, DEMO_IOC_GET_RANGE, range) 0) printf(range%d\n, range); unsigned char cmd[2] { 0x01, 0x40 }; if (write(fd, cmd, sizeof(cmd)) ! sizeof(cmd)) fprintf(stderr, write failed: %s\n, strerror(errno)); close(fd); return 0; }errno和内核返回值的对应关系值得背下来排查时能省一半时间。我把常遇到的整理成表errno内核返回典型原因ENODEV-ENODEV节点不存在或驱动未 probe /client为空ENXIO-ENXIO从机无应答地址错、芯片未供电、上拉缺失EREMOTEIO-EREMOTEIO传输过程中从机 NAK寄存器地址越界或芯片忙EFAULT-EFAULTcopy_to_user/copy_from_user失败缓冲区非法EINVAL-EINVAL长度或命令号不合法参数校验没过EAGAIN-EAGAIN适配器忙或仲裁丢失重试通常能成功EBUSY-EBUSY设备号被占或某资源未释放-ENXIO是最常见的硬件侧问题。先量电压再查上拉电阻最后确认设备树里的reg和芯片实际地址跳线一致。地址是 7 位的设备树里写 0x50代码里就是client-addr不要自己左移。5.3 /proc/devices、sysfs、dmesg 三板斧排查驱动调试我基本不看代码先看这三个地方。/proc/devices回答“设备号有没有占上”。字符设备区段里有你的名字说明alloc_chrdev_region和cdev_add都成功了问题在后面。/sys/class/demo_class/回答“device_create 有没有成功”。目录存在但/dev下没节点问题在用户态创建设备节点目录都不存在问题在内核里device_create之前。dmesg回答“到底在哪一步失败的”。probe里的每个dev_err都要写清楚是第几步别只写failed。我习惯在每步失败时打印带步骤编号的信息比如probe: cdev_add failed -16一眼就知道卡在 cdev。还有一个常被忽略的点cat /sys/bus/i2c/devices/i2c-1/1-0050/name能看到 i2c 设备的绑定名如果你在这里看到的是demo-sensor而驱动模块还没加载说明设备树识别到了但驱动没自动加载检查MODULE_DEVICE_TABLE和depmod -a。5.4 踩过的坑与对策清单下面这些是我在实际项目里真踩过的按发生频率排。第一个坑节点名带斜杠。device_create的 name 参数如果写成demo/0内核会在/sys/class下面建子目录最终节点路径变成/dev/demo/0而你的用户态程序在找/dev/demo0怎么都打不开。名字里不要带/。第二个坑cdev_add成功了但open崩溃。九成是container_of用错了cdev是单独分配的指针而不是内嵌成员。检查结构体定义确保struct cdev cdev;是值类型。第三个坑read返回值随机错误。i2c_smbus_read_byte_data的返回值没判断负值直接赋给u8或者参与运算。所有 SMBus 系列函数的返回值都是int必须先判 0再转换。第四个坑多个进程同时打开时数据错乱。跨多次传输的读没加 mutex被写操作插进去了。加锁范围要覆盖整个逻辑操作而不是单次i2c_transfer。第五个坑卸载模块报Device or resource busy。有进程还开着设备节点。lsof /dev/demo0找出来关掉或者设计时接受这个限制在remove里不做强制等待。第六个坑内核版本升级后编译不过。class_create的签名、file_operations里ioctl改unlocked_ioctl、compat_ioctl是否必需这些在不同版本上有差异。用版本宏隔离别硬改成一边。第七个坑设备树里地址写错但probe照样成功。probe只在匹配成功时调用不会验证从机是否真的有应答。所以open里那次芯片 ID 校验不能省它才是真正确认“芯片活着”的一步。最后分享一个操作上的小习惯写完驱动先别急着写用户态程序用i2cget/i2cset在同一颗芯片上验一遍底层通路确认硬件没问题再跑自建驱动的测试。这样一旦出问题能立刻分清是驱动代码的锅还是硬件连线的锅排查效率差好几倍。