Linux驱动如何支持多设备?瑞芯微平台设备树与container_of实战解析 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-sensor48 { compatible vendor,light-sensor; reg 0x48; reset-gpios gpio1 RK_PB0 GPIO_ACTIVE_LOW; offset 50; }; light-sensor49 { 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 位寄存器。最干净的做法是给设备树两个不同的compatiblelight-sensor48 { compatible vendor,light-sensor-v1; reg 0x48; }; light-sensor49 { 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-mux70 { compatible nxp,pca9546; #address-cells 1; #size-cells 0; reg 0x70; i2c0 { #address-cells 1; #size-cells 0; reg 0; light-sensor48 { compatible vendor,light-sensor; reg 0x48; }; }; i2c1 { #address-cells 1; #size-cells 0; reg 1; light-sensor48 { 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_sensorstatic 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 而不是手动分配主次设备号有人可能问用cdevclass_createdevice_create也可以创建设备节点为什么推荐 miscdevicemiscdevice 的定位是“轻量级字符设备”它使用系统保留的 misc 主设备号10每个设备子节点自动获得一个动态次设备号。在内核里MISC_DYNAMIC_MINOR会让 misc 子系统自动分配没有冲突的次设备号。你只需要调用misc_register()剩下的class、device、devtmpfs节点创建全部由框架完成。而对于多设备场景miscdevice 还有一个隐藏优势每个设备实例注册一个miscdevice次设备号不同设备节点名称可以通过devm_kasprintf动态生成。如果你用传统的cdev全套流程也不是不行但要自己管理次设备号分配、device_create的参数、设备移除时的清理代码量翻倍还容易在多个设备remove时搞错顺序。当然如果你的应用层需要固定的主设备号来做mknod或者需要多个设备共享同一个主设备号下的连续次设备号那就必须走cdevalloc_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_requestregulator_get的经典流程就得自己写goto err_free_gpio回滚。而devm_gpiod_getdevm_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_SHAREDdev_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/i2cfeac0000/ cat /proc/device-tree/i2cfeac0000/light-sensor48/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, offset50 i2c 2-0049: registered as /dev/light1, offset120一眼就能看出是总线 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 解决“驱动怎么区分”剩下的就是内核框架帮你处理的匹配和调度问题了。