1. 为什么单设备思维在驱动里走不通
在瑞芯微平台上做 Linux 驱动开发,很多人最开始写的驱动都是“一板一眼”的教科书风格:一个设备树节点、一个全局的client指针、一个probe函数、一个remove函数,跑起来一切正常。但等你真把板子上的同型号传感器从 1 个加到 2 个、4 个,或者在一根 I2C 总线上挂两片一模一样的触摸屏控制芯片时,问题就来了——驱动好像“只认”第一个设备,第二个要么探测不到,要么操作第二个设备时数据串到了第一个上。
这不是瑞芯微特有的问题,而是 Linux 驱动模型下“单设备思维”的必然结果。我自己第一次遇到是在 RK3568 的板子上同时驱动两路 I2C 光照传感器,现象非常典型:i2cdetect两个地址都能扫到,但应用层打开设备节点后,读到的永远是第一路传感器的数据。排查半天,最后发现根因就是驱动里写了一个全局指针,第二次probe的时候直接把第一个设备的指针覆盖掉了。
要真正解决“一个驱动支持多个设备”这个问题,得先理解 Linux 的设备模型:probe不是只调用一次的,每当设备树里有一个节点与驱动的of_match_table匹配成功,内核就会为这个实例单独调用一次probe。也就是说,如果你的设备树里写了两个节点,probe会被调用两次。那问题就变成:两次probe之间,你的驱动状态是不是隔离的?资源是不是独立的?注册出去的操作接口能不能区分出这次打开的是哪一个设备?
只要想清楚这三件事,多设备支持其实没那么玄乎。下面这两个技巧,是我在瑞芯微平台上反复实践后觉得最实用、覆盖场景最广的。一个是设备树侧的“多节点 + 配置变体”写法,一个是驱动侧的“私有数据 + container_of”组织方式。两者结合起来,同一个驱动驱动 4 个、8 个设备都不需要改核心逻辑。
2. 技巧一:设备树一节点一实例,配置走 of_match_table 数据变体
2.1 两个同型号 I2C 设备在设备树里怎么描述
先说设备树。以 RK3568 为例,假设 I2C2 总线上挂了两个同型号光照传感器,地址分别是 0x48 和 0x49,两者硬件完全相同,但校准偏移量不一样。很多人的第一版设备树是这样写的:
&i2c2 { status = "okay"; pinctrl-names = "default"; pinctrl-0 = <&i2c2m1_xfer>; light-sensor@48 { compatible = "vendor,light-sensor"; reg = <0x48>; reset-gpios = <&gpio1 RK_PB0 GPIO_ACTIVE_LOW>; offset = <50>; }; light-sensor@49 { compatible = "vendor,light-sensor"; reg = <0x49>; reset-gpios = <&gpio1 RK_PB1 GPIO_ACTIVE_LOW>; offset = <120>; }; };这里有两个关键点。第一,reg属性决定了 I2C 地址,驱动里绝对不要硬编码地址,而是通过i2c_client->addr去拿。probe框架会把匹配到的设备节点自动绑定成一个i2c_client,你只需要为这个client服务即可。
第二,offset这个自定义属性,用来区分两个“同型号但参数不同”的实例。驱动里用device_property_read_u32()读取:
static int light_sensor_probe(struct i2c_client *client) { struct device *dev = &client->dev; struct light_sensor *sensor; u32 offset; int ret; ret = device_property_read_u32(dev, "offset", &offset); if (ret) offset = 0; /* 默认值,健壮性处理 */ sensor = devm_kzalloc(dev, sizeof(*sensor), GFP_KERNEL); if (!sensor) return -ENOMEM; sensor->client = client; sensor->offset = offset; /* ... */ }这样第一个probe进来,offset是 50;第二个probe进来,offset是 120。数据天然隔离,互不干扰。
2.2 of_match_table 的 data 字段,解决“同 compatible 不同型号”的问题
上面这种“同型号不同参数”用自定义属性就够了。但还有一种情况更麻烦:两个设备功能相似、驱动可以共用,但寄存器地址、初始化序列甚至底层操作方式有差异。比如传感器 V1 和 V2,都是“光感”,但 V1 用的是 8 位寄存器,V2 用的是 16 位寄存器。
最干净的做法是给设备树两个不同的compatible:
light-sensor@48 { compatible = "vendor,light-sensor-v1"; reg = <0x48>; }; light-sensor@49 { compatible = "vendor,light-sensor-v2"; reg = <0x49>; };然后在驱动里声明两张 match 表:
static const struct light_sensor_cfg v1_cfg = { .reg_width = 8, .default_offset = 50, }; static const struct light_sensor_cfg v2_cfg = { .reg_width = 16, .default_offset = 120, }; static const struct of_device_id light_sensor_of_match[] = { { .compatible = "vendor,light-sensor-v1", .data = &v1_cfg }, { .compatible = "vendor,light-sensor-v2", .data = &v2_cfg }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, light_sensor_of_match);probe里用of_device_get_match_data()把配置取出来:
static int light_sensor_probe(struct i2c_client *client) { const struct light_sensor_cfg *cfg; struct device *dev = &client->dev; cfg = of_device_get_match_data(dev); if (!cfg) return -EINVAL; /* 后续所有按 cfg->reg_width 分叉的读写逻辑 */ }这里最关键的一点是:of_match_table的data字段类型是const void *,你可以让它指向任意结构体。很多驱动只把它当“标志位”用(比如非空就匹配),实际上更应该把它当作“配置描述符”。这种方法的好处是,设备树里只表达“我是什么硬件”,驱动只根据compatible挑选对应配置,新增一个硬件版本时不用改probe逻辑,只需要新增一个cfg结构体和一条of_device_id记录。
2.3 同总线同地址的设备,用 i2c-mux 在设备树层隔离
还有一个高频问题:如果两个设备型号一样、I2C 地址也一样,都在同一根总线上,怎么办?地址冲突,设备树里写两个reg = <0x48>是没法工作的。硬件上需要加 I2C 多路选择器,比如 PCA9546,软件上在设备树里用 i2c-mux 描述:
&i2c3 { status = "okay"; i2c-mux@70 { compatible = "nxp,pca9546"; #address-cells = <1>; #size-cells = <0>; reg = <0x70>; i2c@0 { #address-cells = <1>; #size-cells = <0>; reg = <0>; light-sensor@48 { compatible = "vendor,light-sensor"; reg = <0x48>; }; }; i2c@1 { #address-cells = <1>; #size-cells = <0>; reg = <1>; light-sensor@48 { compatible = "vendor,light-sensor"; reg = <0x48>; }; }; }; };这样内核会创建i2c-3总线下的两条虚拟子总线,每个子总线上各挂一个地址为 0x48 的设备。驱动侧完全不用感知 mux 的存在,probe照样调用两次,i2c_client的adapter自动指向不同的子总线,数据传输由 I2C 框架在访问前自动切换 mux 通道。
我在 RK3588 上这么干过,接 4 路同样地址的温湿度传感器,效果稳定。这个方案的隐藏收益是:每个设备的数据通路在底层就是隔离的,驱动并发访问时不会被 mux 切来切去切串,省掉了很多应用层加锁的麻烦。
2.4 设备树层的几个实操细节
写了这么多设备树,有几个小细节是瑞芯微平台上特别容易踩的:
第一,status = "disabled"的节点不会触发probe。调试时想临时关掉某个设备,直接把这个节点置为disabled是最快的,不用改驱动代码。但要注意,如果硬件上只有一个设备,设备树里却写了一个disabled一个okay,内核日志里只会出现一次probe,别以为是驱动 bug。
第二,获取 GPIO 时用devm_gpiod_get_optional(),它会自动绑定到当前设备的 device 节点。两个设备各自的reset-gpios不会冲突,因为 GPIO 子系统会做占用检查,如果两个节点指向同一个 GPIO 且方向配置冲突,probe会直接失败并打印清晰的错误,这其实是好事。
第三,pinctrl配置最好每个外设节点自己带自己的pinctrl-0,而不是都挂在父 I2C 节点上。比如两个设备各自需要不同的复位引脚默认状态,就应该在各自的节点里写pinctrl-names = "default"; pinctrl-0 = <&light_reset_pin>;。瑞芯微的 pinctrl 对引脚复用冲突管得很严,把引脚状态写在不合适的层级,经常会出现“第一个设备正常,第二个设备 probe 失败”的诡异问题。
3. 技巧二:私有数据 + container_of,一套驱动注册出 N 个设备节点
3.1 问自己一个问题:打开的到底是哪个设备
设备树和 match 表解决的问题是“怎么描述硬件、怎么让设备树匹配到驱动”,但设备树配得再好,驱动内部如果还是用一个全局变量保存所有状态,多设备照样跑不起来。
第二个技巧,核心就是每个设备一份私有数据,禁止用全局变量保存设备相关状态。以 I2C 设备为例,probe每次被调用时,都分配一个独立的struct light_sensor,然后把这个结构体的指针存到对应 device 上。后续所有操作,都从这个指针出发,通过container_of找回完整的私有数据。
先看probe怎么写:
static int light_sensor_probe(struct i2c_client *client) { struct light_sensor *sensor; int ret; sensor = devm_kzalloc(&client->dev, sizeof(*sensor), GFP_KERNEL); if (!sensor) return -ENOMEM; sensor->client = client; mutex_init(&sensor->lock); sensor->id = atomic_inc_return(&light_sensor_ida); /* 为每个设备创建一个 /dev/lightN 节点 */ sensor->mdev.minor = MISC_DYNAMIC_MINOR; sensor->mdev.name = devm_kasprintf(&client->dev, GFP_KERNEL, "light%d", sensor->id); sensor->mdev.fops = &light_sensor_fops; sensor->mdev.parent = &client->dev; ret = misc_register(&sensor->mdev); if (ret) return ret; i2c_set_clientdata(client, sensor); dev_info(&client->dev, "registered as /dev/light%d, offset=%d\n", sensor->id, sensor->offset); return 0; }注意devm_kzalloc的用法:内存挂在&client->dev的生命周期上,probe失败或者设备移除时,内存自动释放,不用自己写kfree。这个在单设备驱动里优势不明显,多设备驱动里简直是救命稻草——因为一旦某个设备probe到一半失败,前面的资源没释放,第二个设备再来probe,可能出现内存泄漏或者资源被占用的连锁问题。
atomic_inc_return是给每个设备生成全局唯一的序号,这个序号用来命名设备节点,保证/dev/light0、/dev/light1不冲突。如果你不想用原子变量,也可以用ida机制,ida_alloc()更正规,还能回收 ID 复用,不过对于一般场景原子变量已经够用。
3.2 file_operations 里怎么区分是哪个设备:container_of
设备节点注册出去了,应用层 open/dev/light1,内核怎么知道这次操作的设备是哪一个?关键在struct file的private_data字段。
miscdevice 框架在 open 时会自动把miscdevice的指针放到file->private_data里,所以我们可以在 open 里用container_of反推出包含它的struct light_sensor:
static int light_sensor_open(struct inode *inode, struct file *file) { struct light_sensor *sensor = container_of(file->private_data, struct light_sensor, mdev); file->private_data = sensor; return 0; } static long light_sensor_ioctl(struct file *file, unsigned int cmd, unsigned long arg) { struct light_sensor *sensor = file->private_data; int ret; mutex_lock(&sensor->lock); /* 操作 sensor->client 上的设备 */ mutex_unlock(&sensor->lock); return ret; }container_of的原理是:mdev是struct light_sensor里的一个成员,file->private_data指向的是mdev的地址,那么用mdev的偏移量往前推,就能得到整个结构体的起始地址。这是内核里最常用的“从子结构找父结构”的手法,理解了它,你就理解了多设备驱动的一半精髓。
每个设备实例有自己独立的mutex,所以两个设备并发操作时互不干扰。如果所有设备共用一个全局锁,性能会下降,更糟的是可能出现“一个设备被占用,另一个设备也操作不了”的荒谬场景。
3.3 为什么选择 miscdevice 而不是手动分配主次设备号
有人可能问,用cdev+class_create+device_create也可以创建设备节点,为什么推荐 miscdevice?
miscdevice 的定位是“轻量级字符设备”,它使用系统保留的 misc 主设备号(10),每个设备子节点自动获得一个动态次设备号。在内核里,MISC_DYNAMIC_MINOR会让 misc 子系统自动分配没有冲突的次设备号。你只需要调用misc_register(),剩下的class、device、devtmpfs节点创建全部由框架完成。
而对于多设备场景,miscdevice 还有一个隐藏优势:每个设备实例注册一个miscdevice,次设备号不同,设备节点名称可以通过devm_kasprintf动态生成。如果你用传统的cdev全套流程,也不是不行,但要自己管理次设备号分配、device_create的参数、设备移除时的清理,代码量翻倍,还容易在多个设备remove时搞错顺序。
当然,如果你的应用层需要固定的主设备号来做mknod,或者需要多个设备共享同一个主设备号下的连续次设备号,那就必须走cdev+alloc_chrdev_region路线。对绝大多数传感器、执行器、辅助功能类设备来说,miscdevice 是性价比最高的选择。
3.4 中断回调里的 dev_id,别传 NULL
多设备驱动里中断也是个重灾区。两个设备共用同一个中断 GPIO(或者同一个中断控制器),request_irq/devm_request_irq时要注意:
ret = devm_request_irq(dev, irq, light_sensor_irq_handler, IRQF_SHARED | IRQF_TRIGGER_LOW, "light_sensor", sensor);dev_id参数一定要传每个设备自己的私有数据指针,而不是 NULL。作用有两个:第一,内核在request_irq时,会检查同一个中断号上是否已经有IRQF_SHARED的 handler,如果有,且新注册请求也声明了IRQF_SHARED,就允许共享;第二,free_irq和中断回调时,内核会把dev_id原样传回来,你的 handler 才能知道“这次中断是哪个设备触发的”。
在中断 handler 里:
static irqreturn_t light_sensor_irq_handler(int irq, void *dev_id) { struct light_sensor *sensor = dev_id; /* 读取 sensor->client 对应设备的中断状态寄存器,确认是否是自己触发 */ if (!light_sensor_is_pending(sensor)) return IRQ_NONE; /* 处理数据 */ return IRQ_HANDLED; }为什么IRQ_NONE很重要?共享中断场景下,同一根线上挂了多个设备,一个设备触发中断,所有注册的 handler 都会被调用。收到中断后先读状态寄存器确认是不是自己的事件,如果不是就返回IRQ_NONE,让内核继续调用下一个 handler。如果直接返回IRQ_HANDLED,轻则另一个设备的中断事件丢一次,重则导致中断风暴。
3.5 私有数据组织方式的扩展:把“设备组”当整体管理
如果遇到的不只是“N 个独立设备”,而是“N 组设备,每组内部还有从设备”,比如一个摄像头模组里有 sensor + EEPROM + 马达驱动三个子设备同地址挂在同一总线上,就更需要分层组织私有数据。我的做法是:
struct camera_module { struct i2c_client *sensor_client; struct i2c_client *eeprom_client; struct i2c_client *vcm_client; struct device *dev; struct mutex lock; int index; };在 sensor 的probe里分配一个camera_module,然后dev_set_drvdata(&sensor_client->dev, module),EEPROM 和 VCM 的probe通过父设备或者设备树属性把对应的i2c_client填充进同一个module。这种“聚合根”的思路,比三个子驱动各自维护全局变量要健壮得多。
4. 瑞芯微平台上的资源冲突与电源管理雷区
4.1 devm_ 系列背后的生命周期魔法
在单设备时代,很多人写驱动不重视资源释放,反正remove也不一定会被调用,驱动卸载直接 rmmod 好像也没事。但多设备场景下,资源管理是硬约束。
devm_(managed device resource)系列函数解决的核心问题是:资源的释放绑定在 device 生命周期上,而不是驱动模块的生命周期上。比如devm_kzalloc、devm_gpiod_get、devm_regulator_get、devm_clk_get、devm_ioremap、devm_request_threaded_irq。
关键在于“部分失败”的处理。假设第二个设备probe时,GPIO 申请成功了,但 regulator 获取失败了。如果你的代码用gpio_request+regulator_get的经典流程,就得自己写goto err_free_gpio回滚。而devm_gpiod_get+devm_regulator_get的话,probe返回错误后,内核自动逆序释放已经申请成功的资源,你一行错误处理都不用写。
在多设备探测顺序不确定的情况下(谁先probe不固定),这种自动回滚能力非常关键。我曾经遇到过两个设备,设备 A 先probe成功,设备 Bprobe失败,B 之前申请的一个公共 GPIO 如果不释放,A 下次 remove 后再 probe 就会失败,问题非常隐蔽。换成devm_系列后彻底杜绝了这类问题。
4.2 多个设备共用同一路 LDO 的坑
瑞芯微平台的板子,经常多个外设共用一路 LDO 供电。设备树里如果每个设备节点都写了自己的vcc-supply,且这路 LDO 的regulator节点被多个supply引用,内核 regulator 框架会做引用计数,设备各自开关自己的电源时可以正常工作。
但如果你的驱动在probe里直接调用regulator_disable(),而不是用devm_regulator_get_enable或者正确管理引用计数,就会出现“关掉设备 A 的电源,设备 B 也断电了”的问题。
正确做法:
struct regulator *vcc = devm_regulator_get(dev, "vcc"); if (IS_ERR(vcc)) return PTR_ERR(vcc); ret = regulator_enable(vcc); if (ret) return ret;regulator_enable/regulator_disable内部有 use_count 保护,同一路 regulator 被两个设备分别 enable 后,只有当两个设备都 disable 它,才会真正关断输出。多设备驱动里千万不要自己去操作“电源总闸”,要让 regulator 框架来协调。
4.3 时钟、复位、中断的资源独占性检查
在 RK3568/RK3588 上,多个外设共用同一个父时钟是很常见的。devm_clk_get拿到的是struct clk指针,clk_prepare_enable同样有引用计数。但要注意,有些外设的时钟开启后会对其他外设产生时序影响(比如 I2C 的时钟频率被另一个设备拉低),这种问题设备树层面通常就要用assigned-clock-rates固定好,别指望驱动运行时去动态调频。
复位 GPIO 则必须每个设备独占。如果一个复位信号控制两颗芯片,你就得确认硬件上 od门隔离或者电平兼容,否则一个设备触发的复位会把另一个设备也复位掉。这是硬件设计问题,软件只能在设备树里用reset-gpios描述归属,如果两个节点指向同一个 GPIO,后者probe时 GPIO 申请会失败,其实也是一种保护。
中断资源前面已经聊过,IRQF_SHARED+dev_id是标配。很多初学者在设备树里给两个设备配了同一路中断 GPIO 但驱动里没写IRQF_SHARED,第二个request_irq会直接返回 -EBUSY,日志里只留一句“IRQ handler type mismatch”,排查起来相当费劲。
5. 多设备驱动调试:从设备树到 sysfs 的完整排查链路
5.1 第一步:确认设备树节点到底有没有生效
多设备调试和单设备最大的不同是:问题可能出在“设备树没解析出设备”而不是“驱动代码跑错”。所以第一步永远是确认节点是否挂上。
瑞芯微平台上,节点解析失败最常见的原因是语法错误——reg写错、compatible字符串拼错、节点名/地址不匹配。检查手段很简单,cd /proc/device-tree或者/sys/firmware/devicetree/base,找到对应的 I2C 控制器节点,查看子节点数量和属性:
ls /proc/device-tree/i2c@feac0000/ cat /proc/device-tree/i2c@feac0000/light-sensor@48/compatible如果这里只有一个子节点,说明设备树没生效或没编译进去。还有一种情况比较坑:节点在.dtsi里定义的是status = "disabled",你的板级.dts里没打开。可以搜一下grep -r "light-sensor" arch/arm64/boot/dts/rockchip/对比一下最终生成的 dtb。
5.2 第二步:I2C 总线上是否真的能扫到设备
设备树节点存在不代表 I2C 通信正常。多设备场景下,地址冲突、总线上的上拉电阻问题、设备上电时序不对,都可能导致设备无 ACK。
用i2cdetect验证:
i2cdetect -y 2如果设备树里写了两个节点,但i2cdetect只看到一个地址有设备,先查硬件;如果两个地址都有设备,但 /dev 下只多出了一个节点,那就是驱动probe第二个设备时失败了,继续看内核日志。
5.3 第三步:用 dev_dbg 和动态调试替代 printk
多设备驱动里最忌讳用printk(KERN_INFO "probe ok\n")这种不带设备上下文的日志。因为日志里你分不清是哪个设备probe成功了。
正确做法是用dev_dbg(&client->dev, ...)/dev_info(&client->dev, ...)。这类接口会在日志输出中自动带上设备路径标识,比如:
i2c 2-0048: registered as /dev/light0, offset=50 i2c 2-0049: registered as /dev/light1, offset=120一眼就能看出是总线 2 上的 0x48 还是 0x49。设备唯一标识比你自己在日志里拼字符串要可靠得多。
如果觉得dev_info太多,可以用动态调试:
echo 'file drivers/iio/light/light_sensor.c +p' > /sys/kernel/debug/dynamic_debug/control这样只有打开动态调试后dev_dbg才输出,平时零开销。瑞芯微 SDK 默认内核开了CONFIG_DYNAMIC_DEBUG的话,这个机制是可用的。我调试多设备驱动时一般先全部dev_dbg,定位到具体问题后再降级为dev_info或者关掉。
5.4 第四步:内核日志里的“二次 probe”线索
多设备调试还有一个技巧:不要只看dmesg | grep light_sensor,要把 grep 关键字扩展到整个 probe 流程。比如 second probe 失败原因可能是 GPIO 冲突,那日志里会有gpio_request: GPIO 32 already requested类似信息。这时候去/sys/kernel/debug/gpio看 GPIO 占用情况,是谁占了这个引脚,比对设备树,基本就能定位。
还有一类问题:deferred probe。如果设备 A 依赖的 regulator 驱动的probe还没执行,设备 A 会被挂到 deferred probe 列表里,dmesg会显示probe deferred。这种情况不是驱动写错,是设备树里电源/时钟依赖没准备好。可以看cat /sys/kernel/debug/devices_deferred。多设备环境下,这种“依赖未就绪”的 defer 问题更容易出现,因为设备 B 的probe可能提前消费掉了某个还没注册的依赖。
5.5 第五步:确认 /dev 节点与设备的一一对应
最后,应用层打开/dev/light1后操作的是不是设备树里的 0x49?可以通过在probe里打印client->addr验证,更优雅的做法是在light_sensor_ioctl里把client->addr和client->adapter->nr返回给用户态,应用层自检用。多设备驱动调试到这一层,基本就是跑功能逻辑而不是跑环境问题了。
6. 写在最后:一点个人体会
这两个技巧——设备树多节点 + 配置变体、私有数据 + container_of——其实背后是同一个思路:Linux 驱动模型的本质就是“设备实例”与“驱动逻辑”的分离,一个驱动实例要能服务多个设备,所有状态都必须挂在设备实例上,而不是挂在驱动模块上。
在瑞芯微 RK3568、RK3588 这类多核 SoC 上,一个板子同时挂多个同型号外设是常态。LLM 和 AI 应用的流行让“一会接 4 个摄像头、8 路传感器”的需求越来越多,驱动侧如果还停留在单设备思维,后面应用层怎么写都很别扭。
我个人的实操习惯是:新写一个驱动框架时,先假设这个驱动未来一定会被用到多个设备上,哪怕当前硬件只需要一个。设备树命名、私有数据结构、miscdevice 注册这些基础功架,一开始就按多设备标准来写,多花的成本不到半小时,但后面扩展时能省掉大量排查时间。尤其是devm_系列资源管理和dev_dbg日志规范,这些不是性能选项,而是多设备环境下的生存底线。
如果你正在做的项目需要让一个 Linux 驱动同时驱动多个设备,不妨先把这两个技巧落到代码里。设备树多节点解决“硬件怎么描述”,私有数据 + container_of 解决“驱动怎么区分”,剩下的,就是内核框架帮你处理的匹配和调度问题了。