
做瑞芯微平台 Linux 驱动开发最常被问到的不是怎么写一个驱动而是同一个驱动怎么同时管好几个设备。我接触过的项目里RK3568 板子上挂两路 I2C 触摸屏、RV1126 上接四颗摄像头、RK3588 上一组 GPIO LED 要分路控制都是典型场景。很多朋友第一次接手时单设备代码跑得很顺畅一旦要多设备复用就开始出现第二个设备 probe 失败寄存器互相踩踏一个设备卸载把另一个设备也带崩这类问题。这类问题的根子其实就两个一是驱动和设备树的匹配关系没有设计清楚二是驱动代码里用了太多全局状态。这篇文章就围绕这两个根子讲两个小技巧用of_device_id的.data字段承载不同硬件版本的差异配置以及用 per-instance 私有数据加devm_管理接口来撑起多实例共存。适合正在 RK3568/RK3588 这类平台上做 Linux 驱动开发、被多设备问题卡住的朋友参考。1. 先分清场景你遇到的多设备是匹配问题还是实例问题1.1 两种多设备场景本质完全不同我见过太多人把多设备当成一个笼统的问题去解决结果代码越写越拧巴。其实做驱动的人心里得有一杆秤所谓一个驱动支持多个设备通常是指下面两种完全不同的情况。第一种叫异构多设备也就是同一个驱动要兼容多个型号的硬件。比如一颗 I2C 触摸芯片有标准版和精简版寄存器地址差几个、初始化序列不一样再比如音频 codec同一系列的几颗料主控寄存器地址一致但增益表和耳机检测逻辑不同。这种情况的核心矛盾是同一份驱动代码如何根据硬件型号自动选择正确的配置参数。第二种叫同构多实例也就是同一型号的硬件在系统里出现多次。典型例子是一块板子上有 4 颗同型号的 LED 灯、两路同型号的 ADC 按键、三个同型号的 PWM 背光控制器。这种情况的核心矛盾是驱动代码如何在同一时间被多次进入、每个实例不相互干扰。把这两者分开看解法路径完全不同。前者靠的是匹配表 静态配置后者靠的是私有数据 动态资源管理。如果混在一起处理比如为了兼容两个型号就开一个全局数组去记录配置然后多实例的时候又共用这个数组那不出问题才怪。1.2 为什么跑通一个设备不等于跑通一群设备很多初学者都有这个错觉单个设备 probe 成功、读写正常就觉得驱动没问题了。但驱动从单设备扩展到多设备其实是一次静默的架构升级考验的是你有没有做对下面这几件事有没有把设备相关和实例相关的数据分清楚有没有保证两个设备在不同的设备树节点下读到的配置是各自的有没有考虑并发访问比如两个设备同时产生中断时中断处理函数里的共享变量是否会串扰有没有在 remove 路径上做到一个设备卸载不影响另一个。在瑞芯微平台上设备树节点都是按硬件实际连接来写的同一个驱动在树里往往存在多个匹配节点。如果你在驱动里用了全局变量存寄存器基地址、IRQ 号、设备状态这些内容第二个设备 probe 的时候就会把第一个设备的信息覆盖掉表面上看两个节点都 match 了实际只有最后一个设备能工作。这是我在评审代码时反复看到的问题。所以明确你要解决的是匹配问题还是实例问题是这篇文章所有内容的前提。接下来我就按这两个方向分别展开。2. 技巧一用of_device_id匹配表和.data字段覆盖多个硬件版本2.1 设备树 compatible 与驱动的匹配流程先说第一个技巧解决异构多设备的匹配问题。在 Linux 4.x 之后的统一设备模型里驱动和设备是通过compatible字符串建立关联的。设备树里每个节点可以写一个或多个 compatible比如i2c0 { touch14 { compatible goodix,gt911; reg 0x14; }; };驱动侧则在of_device_id表里声明自己支持哪些 compatiblestatic const struct of_device_id touch_of_match[] { { .compatible goodix,gt911 }, { .compatible goodix,gt928 }, { } }; MODULE_DEVICE_TABLE(of, touch_of_match);当 device tree 里的某个节点 compatible 和这张表里的某一项匹配上内核就会调用驱动的 probe 回调。MODULE_DEVICE_TABLE的作用是让编译系统生成模块别名这样设备出现时modprobe能自动找到驱动模块千万别漏。很多人用匹配表就到此为止了剩下的差异全靠在 probe 里 if/else 判断。比如if (of_machine_is_compatible(goodix,gt928)) { ... }或者更粗暴的根据 client-name 字符串去比较。这种写法在只有两颗设备的时候勉强能跑但设备一旦变多三颗、四颗、不同版本混用代码里全是 if/else 和 magic string而且还会遇到一个问题——从设备树里拿到的 compatible 不准确时比较根本不会命中。正确做法是在匹配表阶段就把这台设备需要什么确定下来。2.2.data字段的用法与完整代码骨架of_device_id结构体里有几个字段大多数人都只用了.compatible忽略了一个非常有用的.data字段。它的作用是把匹配项和一个自定义指针绑定匹配成功之后probe 里只需要一行代码就能拿到这个设备专属的配置数据。以触摸驱动为例假设驱动要支持 GT911 和 GT928 两颗芯片它们的寄存器基址、坐标分辨率、中断触发方式都不一样代码可以这样组织struct touch_cfg { u32 reg_ctrl; u32 max_x; u32 max_y; unsigned long irq_flag; const char *fw_name; }; static const struct touch_cfg gt911_cfg { .reg_ctrl 0x8040, .max_x 1024, .max_y 600, .irq_flag IRQF_TRIGGER_FALLING, .fw_name goodix/gt911.fw, }; static const struct touch_cfg gt928_cfg { .reg_ctrl 0x8040, .max_x 720, .max_y 720, .irq_flag IRQF_TRIGGER_RISING, .fw_name goodix/gt928.fw, }; static const struct of_device_id touch_of_match[] { { .compatible goodix,gt911, .data gt911_cfg }, { .compatible goodix,gt928, .data gt928_cfg }, { } }; MODULE_DEVICE_TABLE(of, touch_of_match); static int touch_probe(struct i2c_client *client) { struct device *dev client-dev; const struct touch_cfg *cfg; cfg device_get_match_data(dev); if (!cfg) { dev_err(dev, no matching device data\n); return -ENODEV; } dev_info(dev, fingerprint %s, max %ux%u, irq %lu\n, dev_get_match_data(dev) ? ok : none, cfg-max_x, cfg-max_y, cfg-irq_flag); /* 后续所有初始化、报点解析都用 cfg 里的参数 */ ... }这里有几个细节值得说清楚。第一device_get_match_data()是通用接口它内部会判断是 OF 匹配还是 ACPI 匹配我们在 ARM 平台上基本都是 OF用它准没错。在 I2C 子系统里它和i2c_of_match_device(...)拿到的匹配项一样。第二.data指向的配置结构建议全部用static const修饰。const 保证这段数据放在只读段多线程、多个 probe 并发读取时不会有写竞争static 则是让它的生命周期贯穿整个驱动生命周期。这两个修饰符缺一不可。第三如果不同设备对应的配置结构类型不一样可以把.data指向一个公共的描述头结构里面放类型枚举和一个 void 指针probe 拿到后再按类型转换。不过以我经验绝大多数场景下把所有差异参数收敛到一个统一结构体里就够了没必要引入更复杂的多态。2.3 设备树 compatible 的写法与 fallback 设计搞定了驱动侧再看设备树侧。瑞芯微平台的 kernel 里官方 dts 大量使用了这种写法touch14 { compatible goodix,gt911, goodix,gt9xx; reg 0x14; interrupt-parent gpio1; interrupts RK_PA0 IRQ_TYPE_LEVEL_LOW; reset-gpios gpio1 RK_PA1 GPIO_ACTIVE_LOW; };这里同时写了两个 compatible是有讲究的。第一个是具体型号第二个是通用兼容族。Linux 的 OF 匹配逻辑会依次尝试先用第一个去找驱动找不到再用第二个。这种写法让一个驱动能兼顾精确匹配和宽松匹配。比如驱动里我只写了{ .compatible goodix,gt9xx }那么任何 compatible 里有 gt9xx 的节点都会匹配上包括未来新出的型号。而如果同一颗芯片在不同项目上有寄存器差异则可以再通过.data精确区分。但要提醒一句compatible的命名有规范必须是厂商名,型号这种小写逗号形式不能写Goodix,GT911否则匹配直接失败。这个坑我在项目评审里见过不止一次。还有一个常见误区有些人喜欢在设备树里通过某个自定义 property比如goodix,fw-version来区分硬件版本然后在 probe 里用of_property_read_u32去判断。这个思路不是不行但它把配置差异从静态的编译时数据变成了设备树里的运行时数据一旦设备树写错驱动保护能力就弱了。我的建议是能放进.data的差异配置就不要放到设备树里。设备树里的 property 应该留给那些项目级可变的参数比如 GPIO 引脚、中断号、最大频率而不是芯片本身固有的特性。3. 技巧二去掉全局变量用私有数据与devm_系列接口管理多实例3.1 为什么全局数组是第一个要消灭的如果说第一个技巧解决的是一颗驱动适配多种硬件那第二个技巧解决的就是一个驱动同时服务多个同型号设备。这时的头号敌人就是全局变量。看这段代码是不是很眼熟static void __iomem *base; static int irq_num; static struct gpio_led leds[4];当系统里只有一个设备时一切正常。但设备树里挂了两个节点驱动 probe 被调用两次base第二次会被覆盖第一个设备的读写就全乱了。更麻烦的是卸载时第一个设备触发的 remove 会把iounmap执行一遍第二个设备再用base就成了悬空指针。正确做法是彻底消灭全局状态把每个实例的所有信息塞进一个私有结构体用container_of或dev_get_drvdata取回来。这也是 Linux 设备驱动最基本的设计原则一个设备节点对应一个私有数据对象所有回调都从私有的角度去拿状态。3.2 一个多路 GPIO LED 驱动的实例拆分拿瑞芯微板子上最常见的 GPIO LED 举例。设备树通常是这样的leds { compatible gpio-leds; pinctrl-names default; pinctrl-0 led_pins; work-led { gpios gpio4 RK_PA0 GPIO_ACTIVE_HIGH; linux,default-trigger heartbeat; }; wifi-led { gpios gpio4 RK_PA1 GPIO_ACTIVE_LOW; linux,default-trigger phy0tx; }; };注意gpio-leds这个驱动在一个 probe 调用里会遍历所有子节点每个子节点对应一个 LED 实例。这类一个节点下挂多个子实例的驱动处理方式和多个顶层节点略有不同但核心思路是一样的循环创建 per-instance 私有数据而不是预先分配固定数组。代码骨架大致如下struct gpio_led_priv { struct gpio_desc *gpiod; struct led_classdev cdev; bool active_low; }; static int gpio_led_probe(struct platform_device *pdev) { struct device *dev pdev-dev; struct fwnode_handle *child; int count 0; device_for_each_child_node(dev, child) { struct gpio_led_priv *led; led devm_kzalloc(dev, sizeof(*led), GFP_KERNEL); if (!led) return -ENOMEM; led-gpiod devm_fwnode_gpiod_get(dev, child, NULL, GPIOD_OUT_LOW, fwnode_get_name(child)); if (IS_ERR(led-gpiod)) { fwnode_handle_put(child); return PTR_ERR(led-gpiod); } /* 注册 led_classdev私有数据带进去 */ ... count; } if (!count) { dev_err(dev, no LED subnodes found\n); return -ENODEV; } return 0; }有几个点值得专门说一下。一是devm_fwnode_gpiod_get这类devm_前缀的接口。它们把资源的生命周期绑定到了 struct device 上probe 成功之后用户空间只要把设备 unbind 或者驱动模块卸载内核会自动释放 GPIO、irq、iomap、时钟等资源不需要在 remove 里一个个手动清。在瑞芯微平台这种经常要热插拔外设模块的场景下这是防止泄漏的关键。二是fwnode系列接口。它同时兼容 OF 和 ACPI 两种固件描述方式设备树平台下fwnode底层就映射到device_node。如果你习惯老的of_get_child_by_namefor_each_available_child_of_node那套也能跑但device_for_each_child_node更通用而且它配合devm_语义更完整。三是一个节点下多个子实例的 LED 驱动remove 路径其实也是内核自动处理的。每个led_classdev的注册会绑定到设备和私有数据上卸载时内核会逐个调用注销。你不需要维护一个局部count数组来管理哪几个实例注册了。这就是不维护列表的列表管理。3.3 中断、I2C 和并发状态多实例最容易翻车的地方多实例驱动的第二个翻车高发区是中断处理。如果一个驱动同时管理两路外部中断比如两颗同型号传感器中断处理函数里如果引用全局变量来取寄存器基址第二个设备触发中断时就会读到第一个设备的寄存器。正确的做法是这样的struct sensor_priv { void __iomem *base; struct device *dev; int irq; }; static irqreturn_t sensor_isr(int irq, void *dev_id) { struct sensor_priv *priv dev_id; u32 status readl(priv-base REG_STATUS); ... return IRQ_HANDLED; } static int sensor_probe(struct platform_device *pdev) { struct sensor_priv *priv; int irq, ret; priv devm_kzalloc(pdev-dev, sizeof(*priv), GFP_KERNEL); if (!priv) return -ENOMEM; priv-base devm_platform_ioremap_resource(pdev, 0); if (IS_ERR(priv-base)) return PTR_ERR(priv-base); irq platform_get_irq(pdev, 0); ret devm_request_threaded_irq(pdev-dev, irq, NULL, sensor_threaded_isr, IRQF_TRIGGER_RISING | IRQF_SHARED, pdev-name, priv); ... }重点在于devm_request_threaded_irq的最后一个参数传的是priv这是中断处理函数的dev_id而中断处理函数用它来取回当前实例的私有数据。这样做的好处是即使两个中断共享同一个 IRQ 号在瑞芯微的 GPIO 中断扩展上很常见多个 GPIO 通过同一个 GIC SPI 上报中断处理也能通过dev_id区分到底是哪个设备触发的。注意IRQF_SHARED没有这个标志共享中断会注册失败。另外很多驱动在 I2C 子系统中容易犯一个错用client-addr去从全局表里反查设备。正确做法是把client指针直接放到私有结构体里probe 时保存之后所有操作只从私有结构体取。struct my_i2c_priv { struct i2c_client *client; ... }; static int my_i2c_probe(struct i2c_client *client) { struct my_i2c_priv *priv; priv devm_kzalloc(client-dev, sizeof(*priv), GFP_KERNEL); if (!priv) return -ENOMEM; priv-client client; i2c_set_clientdata(client, priv); ... }i2c_set_clientdata底层就是dev_set_drvdata之后你的 read/write 回调里用struct my_i2c_priv *priv i2c_get_clientdata(client)就能拿到当前实例。整个链路从匹配到中断没有任何全局变量。4. 在 RK3568 上完整走一遍从设备树到多实例 probe 的实操验证4.1 内核编译与模块加载的基础流程前面讲完两个技巧这一节我用瑞芯微 RK3568 平台把整个验证流程串起来。官方 SDK 里内核通常在kernel/目录设备树在kernel/arch/arm64/boot/dts/rockchip/下。改完驱动和设备树后编译命令一般是# 编译 dtb make ARCHarm64 CROSS_COMPILEaarch64-linux-gnu- rockchip/rk3568-evb.dtb # 编译内核模块假设驱动编译为 module make ARCHarm64 CROSS_COMPILEaarch64-linux-gnu- Mdrivers/input/touchscreen modules # 把 dtb 和 ko 复制到开发板 adb push rk3568-evb.dtb /boot/ adb push goodix_ts.ko /data/如果你用的瑞芯微 SDK 自带的 build 脚本比如./build.sh kernel那它会一键完成内核、dtb、模块的编译。我建议平时调试多设备驱动时优先用模块方式m这样迭代速度快不用每次刷整个 boot.img只要把 dtb 和 ko 推进去重启或者insmod即可。设备树覆盖机制的用法也要掌握。瑞芯微的 EVB 板子 dts 通常会 include 一个基础 dtsi再在 board 级 dts 里覆盖节点。我们做定制项目时不要直接改 EVB 的 dts而是新建一个 board 级 dts比如rk3568-myproject.dts里面 include 官方 dtsi再覆盖i2c3、leds这样的节点。这样后续同步官方 SDK 改动时不会被冲突折磨。4.2 验证多实例是否真的都 probe 了模块加载之后第一件事不是看业务功能而是确认每个设备节点都 probe 成功、每个实例都有独立的相关设备。查看方法有几种我按顺手程度排列。第一种是看/sys/bus/platform/drivers/或者/sys/bus/i2c/drivers/目录下的驱动绑定情况。以 I2C 触摸驱动为例ls /sys/bus/i2c/devices/ # 会看到类似 3-0014、4-0014 这样的设备 ls /sys/bus/i2c/drivers/goodix-ts/ # 下面有 3-0014 和 4-0014 两个软链接说明两个实例都绑定了如果 driver 目录下只看到一个或没有说明有个设备没有成功 probe。这时候去看dmesg。第二种是通过/sys/kernel/debug/device_component或debugfs里的devices节点。不过更直接的是 dmesgdmesg | grep goodix如果probe函数里用了dev_info、dev_dbg每条日志会带设备总线号前缀比如i2c-3、i2c-4一眼就能看出两次 probe 是否都发生了。第三种也是多实例调试最实用的是直接操作/sys/class/下的实例接口。比如 LED 驱动ls /sys/class/leds/ # work-led wifi-led每个实例都是一个独立的目录如果出现两个节点只有一个 sysfs 接口问题基本就出在驱动内存分配上——大概率是设备树子节点解析有问题device_for_each_child_node循环在第二个子节点就返回错误了。4.3 看起来匹配了却没 probe的常见原因我自己在瑞芯微平台上调试时遇到过不少设备树里明明写了 compatible驱动也编译进去了但就是不调 probe的情况。整理成表格方便你排查现象可能原因排查方向两个节点只有第一个 probe第二个节点 status disabled 或有未解析的 phandle检查设备树 status、pinctrl 引用一个节点 probe 后另一个报 -ENODEVcompatible 拼写不一致或驱动表没匹配核对 dts 和 of_device_id 的字符串probe 里 GPIO 请求失败两个实例申请了同一个 GPIO检查 pinctrl 配置、对应引脚是否被其他节点用了I2C 驱动不 probe设备地址不对或总线上没有应答i2cdetect -y -r 3 探测地址模块加载后 modprobe 不自动加载缺 MODULE_DEVICE_TABLE模块别名没生成modinfo xxx.ko 看 aliasmodprobe --show-depends有个很隐蔽的问题我要单独拎出来说在设备树里写status okay之外很多瑞芯微 dtsi 会把同一颗 I2C 控制器下的子节点在别的文件里重新定义覆盖。如果你在 board dts 里i2c3 { touch14 { ... } };但 dtsi 里已经有一个touch14两个节点的 compatible 可能因为覆盖顺序问题只保留一个。建议在改动后执行make dtbs然后用dtc -I dtb -O dts rk3568-myproject.dtb反编译确认节点确实存在且 compatible 正确。这一步能省下半小时的排查时间。5. 多设备调试的排查链路从随机失败到稳定运行的定位过程5.1 故障现象分类与根因方向多设备驱动一旦出问题现象往往不是完全不能工作而是时好时坏两个设备互相干扰。这类随机性故障最难弄。我根据经验把现象大致分成三类第一类第二个设备 probe 失败第一个工作正常。这通常是资源冲突比如 GPIO、中断、regulator 被第一个实例占用了。排查方向是看 -EBUSY、-EINVAL 的具体返回值定位是哪一个request失败了。第二类两个都 probe 成功但读写数据互相串扰。这种几乎可以断定是驱动里用了共享缓冲区或全局寄存器基址。两个实例虽然各自调用了readl(priv-base)但priv-base可能被赋值成了同一个地址。排查方向就是全局变量搜索。第三类两个独立的设备一个卸载后另一个报错。这是 remove 路径没有把资源归还干净或者相反一个实例 remove 时把另一个实例也释放了。典型错误是 remove 函数里用全局的irq_num和base而不是从platform_get_drvdata里取当前实例。5.2 一条可复现的排查命令链面对随机失败我建议按下面这个顺序走不要一上来就怀疑驱动算法# 1. 先确认两个实例都 probe 了 dmesg | grep -E probe|fail|error | tail -50 # 2. 检查资源占用情况 cat /proc/interrupts | grep gpio\|pinctrl cat /sys/kernel/debug/gpio cat /sys/kernel/debug/pinctrl/pinctrl-rockchip-pinctrl/pinmux-pins | grep {需要检查的引脚} # 3. 检查 I2C 总线如果是 I2C 设备 i2cdetect -y -r 3 i2cdump -y 3 0x14 # 4. 动态检查 sysfs ls -l /sys/bus/i2c/devices/3-0014/ cat /sys/bus/i2c/devices/3-0014/name/proc/interrupts是个好东西多设备共享中断时你会看到同一个 IRQ 上挂了两个设备名。如果只剩一个说明另一个设备的中断注册失败了结合 dmesg 里devm_request_irq的报错信息能快速判定是 GPIO 坏了还是IRQF_SHARED没加。5.3 瑞芯微平台上特别容易踩的坑pinctrl、regulator 与节点复用最后说几个我在 RK3568/RK3588 项目里反复踩过的坑都是多设备场景下的地雷。第一个是 pinctrl 冲突。瑞芯微的 pinctrl 和 GPIO 子系统是一体的同一个引脚不能同时被两个设备声明。多设备时最容易踩的坑是第二个设备的reset-gpios和第一个设备的某个pinctrl-0引用了同一根脚。这种情况 probe 不一定会失败但第二个设备的 GPIO 请求会返回-EBUSY表现在外部就是第二个设备没响应或系统启动时 GPIO 电平被拉乱。排查手段就是上面给的pinmux-pins和/sys/kernel/debug/gpio看引脚是否已经被占用。第二个是 regulator 供电。瑞芯微方案里外设的电源经常挂在 PMIC 的 regulator 上如vcc_3v3设备树里写vmmc-supply vcc_3v3。如果一个 regulator 被两个设备共用其中一个设备 unbind 时如果你在 remove 里手动regulator_disable另一个设备就会瞬间断电。正确做法是依赖devm_regulator_get 引用计数内核自己会在最后一个使用者释放时才真正关掉电源。不要手动配对disable。第三个是 I2C 地址冲突。两路 I2C 总线接到同型号触摸屏如果它们的地址一样比如 0x14没问题因为是不同总线。但如果两个设备挂在同一 I2C 总线上且地址一样那从硬件上就无解只能改地址线电阻或者换一颗料。这个要在硬件评审阶段就确认别等到驱动阶段再发现。第四个也是最容易被忽略的是设备树别名和路径混用。在多实例回调里如果你为了拿到某个寄存器偏移或配置而用of_find_node_by_path(/leds/work-led)这种方式全局查找节点那你又把实例问题变成了全局问题注定在多设备时拿错节点。所有从设备树拿信息的操作都应该发生在 probe 阶段并且基于pdev-dev.of_node或fwnode句柄之后只保存数据不保存 node 指针去到处of_find_*。6. 最后说几句个人体会这两个技巧说起来都很简单一个查表匹配一个私有数据但我在实际项目里见过太多因为这两点没做好而返工三四轮的代码。每次帮同事 review 多设备驱动时我几乎先不读业务逻辑直接搜索全局变量、数组声明以及 probe 里有没有用dev_get_drvdata之外的路径取状态基本就能预判问题会出现在哪里。如果你正卡在瑞芯微平台的多设备调试里我建议按这个顺序动手先把设备树反编译出来确认每个设备节点的 compatible、地址、status 都没问题再在驱动里把所有全局变量全部清掉统一改成私有结构体加devm_接口最后用/sys/bus/下的绑定关系和dmesg验证每个实例都独立 probe。做完这三步你会发现多设备其实并没有传说中那么玄乎它考验的只是你对设备模型最基本的理解是否扎实。瑞芯微的 SDK 更新节奏很快内核版本一换有些接口名字会变比如老的of_get_named_gpio慢慢都被devm_gpiod_get取代了。但匹配表带数据实例带私有数据这两个原则从 Linux 2.6 一直到现在的 6.x 都没变过。把这套东西内化成习惯不管平台怎么换你写出来的驱动在多设备场景下的稳定性都会有质的提升。