资讯动态

Linux多设备驱动开发:of_device_id与drvdata隔离实例数据的实战技巧

发布时间:2026/9/8 6:05:44 来源:尧图企业网站定制
在瑞芯微平台上写 Linux 驱动“一个驱动怎么管理多个设备”这个问题几乎绕不开。RK3568 上双 GMAC、双 CAN或者一块板子上并联两片同型号 ADCprobe 函数会被同一驱动调用多次更常见的是同一个模块要同时兼容 RK3568 和 RV1126寄存器布局、中断行为都有一点差别。不少人第一版驱动是这么写的定义几个全局变量probe 时往全局变量里塞地址中断回调里直接用全局变量——等两个实例一挂第二个 probe 把第一个的配置覆盖掉各种诡异问题就来了。我基于 RK3568 SDK 调过几轮这种多设备驱动之后总结出两个非常实用的小技巧用of_device_id 的 data 字段承载不同型号设备的配置差异用platform_set_drvdata 加 container_of把每个设备实例的私有数据彻底隔离。这两个技巧不涉及复杂框架属于“正确姿势”的层面学会了之后写多设备驱动会顺手很多。1. 多设备适配为什么容易写崩先把问题拆清楚1.1 你迟早会碰到的“一驱多设”先说说我遇到过的几种典型场景。第一种是同一 SoC 上有多个同类控制器。RK3568 自带两个 GMAC 以太网控制器、两个 CAN 控制器寄存器布局几乎一样内核往往只写一份驱动平台总线会为两个节点各调用一次 probe。第二种是板级设计里并联多个同型号外设。比如一侧接了 4 路 ADC 采集电压另一侧还接了一片同样的 ADC 做温度采集两个 I2C 地址不同、中断引脚不同但驱动是同一个。第三种是同一驱动需要兼容不同版本芯片。RK3568 和 RV1126 的外设 IP 很多是同源演进v1 的寄存器偏移和 v2 不完全一致原厂往往用同一份驱动通过 compatible 区分。不管哪种场景本质都是一份 driver 要面对多个 device每个 device 有自己独立的寄存器地址、中断号、配置参数。而 Linux 的 platform 总线天然支持这种一对多关系前提是 driver 侧不能把实例状态写到全局共享位置。1.2 全局变量的诱惑和它的后遗症我见过很多第一次写多设备驱动的同事包括我自己早期都喜欢先定义static struct xxx_dev *g_xdev;然后在 probe 里g_xdev kzalloc(...)。单实例时没问题双实例时第二个 probe 会无脑覆盖第一个的指针。结果就是第一个实例的中断来了handler 里拿g_xdev去操作寄存器实际上操作的是第二个实例的 base 地址。更隐蔽的是 remove 顺序第一个实例先被移除g_xdev已经 kfree第二个实例再进中断直接访问野指针。这类问题最坑的地方在于不是每次必现要看中断触发顺序debug 起来特别耗时间。你加打印看 probe 流程两个实例都正常看 /proc/interrupts也都有。但数据就是不对最后才发现是全局变量把实例搞混了。1.3 两个技巧各自解决哪一半问题后来我意识到多设备驱动写崩归根到底是两个问题没分开设备到底是什么型号这个实例的数据放在哪里第一个问题交给of_device_id 的 data 字段去回答第二个问题交给devm_ 分配和 drvdata去回答。把这两个问题从逻辑上拆开代码结构就清晰了。下面分别详细说。2. 技巧一用 of_device_id 的 data 字段区分不同型号设备2.1 从 compatible 到驱动配置的那条匹配链设备树里每个节点都有 compatible比如compatible rockchip,rk3568-ledext。内核启动时platform_bus_type 的 match 函数会把设备树节点注册成 platform_device然后逐个比较 driver 的 of_match_table 里每一项的 compatible 是否等于节点的 compatible。匹配成功后驱动侧最常用的提取方式是of_device_get_match_data(pdev-dev)它返回匹配项中的 data 成员。这个 data 成员是void *可以指向任何静态定义的结构体。也就是说compatible 不只是用来匹配驱动它本身就可以作为“查表键”把设备型号映射到一个配置块。这是设备树驱动开发的常见套路瑞芯微原厂 SDK 里 vop、isp 等复杂驱动都在这么用。2.2 把型号差异写进数据表而不是写进逻辑假设我写的这个驱动叫ledext是一个 LED 扩展控制器。RK3568 上的是 v1 版本支持 4 路寄存器偏移从 0 开始RV1126 上的是 v2 版本支持 8 路还多了一个硬件错误检测引脚。如果不做任何抽象probe 里会写满条件分支。用 of_device_id 的 data 字段写法是这样的/* ledext.c */ struct ledext_cfg { unsigned int max_leds; unsigned int reg_stat_off; bool has_hw_fault; }; static const struct ledext_cfg rk3568_ledext_cfg { .max_leds 4, .reg_stat_off 0x00, .has_hw_fault false, }; static const struct ledext_cfg rv1126_ledext_cfg { .max_leds 8, .reg_stat_off 0x10, .has_hw_fault true, }; static const struct of_device_id ledext_of_match[] { { .compatible rockchip,rk3568-ledext, .data rk3568_ledext_cfg }, { .compatible rockchip,rv1126-ledext, .data rv1126_ledext_cfg }, {}, }; MODULE_DEVICE_TABLE(of, ledext_of_match); static struct platform_driver ledext_driver { .probe ledext_probe, .remove ledext_remove, .driver { .name rk-ledext, .of_match_table ledext_of_match, }, };注意MODULE_DEVICE_TABLE(of, ledext_of_match)这行不能省。它的作用是让模块编译时生成 modinfo 别名depmod 和 hotplug 才能知道这个模块支持哪些 compatible。如果不做模块加载直接编进内核不写也能匹配但只要用了 module最好习惯性带上。2.3 probe 里如何安全拿回型号参数probe 里拿配置的写法很直接static int ledext_probe(struct platform_device *pdev) { struct device *dev pdev-dev; const struct ledext_cfg *cfg of_device_get_match_data(dev); if (!cfg) { dev_err(dev, missing of_device_id data\n); return -EINVAL; } dev_info(dev, probe ledext, max_leds%u, has_hw_fault%d\n, cfg-max_leds, cfg-has_hw_fault); ... }这里有个细节容易忽略of_device_get_match_data返回的是 of_match_table 中第一个匹配项的 data。如果设备树里 compatible 写成了多个字符串作为兼容列表比如compatible rockchip,rk3568-ledext, rockchip,ledext;那么内核匹配时如果驱动表里既有rockchip,rk3568-ledext也有rockchip,ledext返回的是表中顺序靠前且命中的那一项。如果表的前面是 v1 配置后面是通用配置就要特别注意表顺序。如果驱动不是设备树环境而是老式的 id_table 注册平台设备可以用platform_get_device_id拿 id_table 里的 driver_dataconst struct platform_device_id *id platform_get_device_id(pdev); if (id) cfg (const struct ledext_cfg *)id-driver_data;现代 ARM64 平台基本都是设备树环境这个老接口用得少但了解它对排查问题有帮助。2.4 新增一个型号需要改几行假设瑞芯微出了 RK3528IP 是 v3配置又有变化。你需要做什么dts 节点 compatible 写rockchip,rk3528-ledext驱动里加一个rk3528_ledext_cfgof_device_id 表里加一行。probe 函数一行都不用动。如果哪天 IP 行为变了需要走不同的初始化序列可以给 cfg 里再加函数指针字段struct ledext_cfg { ... int (*init)(struct ledext_dev *ldev); };probe 里统一调用cfg-init(ldev)所有型号差异继续保持在数据层面。这样新增型号时主流程代码完全不改review 的人压力也小很多。2.5 为什么别在 probe 里 strcmp 判断 compatible最容易想到的替代写法是 probe 里做strcmp(node-compatible, rockchip,rk3568-ledext)来判断当前是什么芯片。这里有两个问题。第一设备树节点的name不是 compatible节点名可能是ledext10100000多个实例节点名很容易相同靠 name 区分型号不可靠。虽然可以of_node_is_compatible()去判断 compatible 字符串但那就是把匹配逻辑重复实现了一遍。第二代码会随着型号增加越来越长。每加一个新型号就在 probe 主流程里塞一个分支probe 函数越来越臃肿最后没人敢动。数据表的方式是 O(1) 查表分支方式是 O(n) 增长差别在维护成本上非常明显。3. 技巧二用 drvdata 加 container_of 隔离每个实例的私有数据3.1 多实例的 probe 流程当 dts 里有两个同 compatible 节点时平台总线会依次为每个节点调用一次 probe。第一次 probe 分配实例 0第二次 probe 分配实例 1。这两次 probe 之间不应该有任何全局状态依赖。这是 Linux 驱动模型的基本契约每个设备实例的生命周期由各自的 struct device 管理驱动只是被复用的“模板”。如果驱动里用了全局变量去存实例数据就违反了这个契约。3.2 核心每个实例都有一块自己的私有数据每个实例要有一个独立的私有数据结构体probe 时分配并初始化struct ledext_dev { struct device *dev; const struct ledext_cfg *cfg; void __iomem *base; unsigned int irq; struct miscdevice mdev; unsigned int instance_no; }; static int ledext_probe(struct platform_device *pdev) { struct ledext_dev *ldev; ldev devm_kzalloc(pdev-dev, sizeof(*ldev), GFP_KERNEL); if (!ldev) return -ENOMEM; ldev-dev pdev-dev; ldev-cfg of_device_get_match_data(pdev-dev); ldev-base devm_platform_ioremap_resource(pdev, 0); if (IS_ERR(ldev-base)) return PTR_ERR(ldev-base); ldev-irq platform_get_irq(pdev, 0); if (ldev-irq 0) return ldev-irq; platform_set_drvdata(pdev, ldev); ... }关键操作就是把ldev存到 drvdata 里platform_set_drvdata(pdev, ldev)。之后无论 probe、remove 还是某个文件操作回调只要手里有 pdev就能通过platform_get_drvdata(pdev)拿回自己的实例数据。所有devm_kzalloc、devm_platform_ioremap_resource都由 device 生命周期管理。实例 remove 时驱动不用手动 kfreedevm 框架会自动释放。这能省掉一批“忘记释放导致泄漏”的隐患。3.3 中断回调是怎么区分谁的中断注册是最容易踩坑的地方。ret devm_request_irq(dev, ldev-irq, ledext_irq_handler, 0, ledext, ldev); if (ret) return ret;handler 的实现static irqreturn_t ledext_irq_handler(int irq, void *dev_id) { struct ledext_dev *ldev dev_id; u32 sts readl(ldev-base ldev-cfg-reg_stat_off); ... return IRQ_HANDLED; }注意devm_request_irq的最后一个参数必须传ldev。这是整个多设备驱动里最容易写错的地方如果传 NULL 或传全局变量多个实例注册同样的 handlerfree_irq 或共享中断时都没法正确关联。另一个容易被忽略的点是共享中断。如果两个设备挂在同一个中断控制器下或者注册时使用了IRQF_SHARED内核在中断来的时候会遍历所有注册了该中断号的 handlerdev_id是区分每个回调身份的唯一依据。拿错 dev_id数据就错拿不到 dev_id共享中断根本注册不上去。3.4 从子对象回到主对象container_of 的使用时机很多时候回调不是直接回调在 probe 所在的函数里而是通过子对象回调。比如 miscdevice 的 open、ioctlgpio_chip 的 get/seti2c_client 的 read/write。回调拿到的是子对象的指针。如果子对象正好内嵌在ledext_dev里就可以用 container_of 反查外层实例static int ledext_misc_open(struct inode *inode, struct file *file) { struct miscdevice *misc file-private_data; struct ledext_dev *ldev container_of(misc, struct ledext_dev, mdev); file-private_data ldev; return 0; }为什么这个写法成立因为 misc_open 在 drivers/char/misc.c 里会先把file-private_data指向 miscdevice所以在 misc 设备自己的 open 回调里可以安全地把 private_data 替换成外层实例指针后续 read/write/ioctl 直接用 ldev。这就是为什么推荐把 miscdevice 作为嵌入式字段放进ledext_dev而不是单独 kzalloc 一个 miscdevice 再存一个ledext_dev指针。嵌入式布局能让 container_of 直接用也能用 devm 生命周期一起管理一举两得。3.5 释放顺序和资源生命周期多实例驱动在 remove 时最容易出现资源释放顺序问题。我见过一个案例驱动在 remove 里先kfree(ldev)再free_irq(irq, ldev)结果中断还在飞handler 已经访问了野指针。正确顺序应该是先让设备停止产生中断再注销文件节点最后释放私有数据。如果所有资源都是用 devm_* 申请的remove 可以直接 return 0但中断共享时还得在主流程里先做中断屏蔽或停止硬件避免 devm 释放顺序和自己的需求不一致。用 devm_request_irq 时驱动可以少写手动 free_irq但要清楚devm 框架的释放顺序是注册顺序的逆序probe 里先注册了什么remove 时就后释放什么。如果后面注册的资源依赖前面注册的资源存在就要特别小心。比如你先devm_request_irq后misc_registerremove 时会先注销 misc 再释放中断这个顺序基本是对的。但如果你手动 kfree 了 ldevdevm 框架就不会再管它了后续任何回调访问都会崩溃。4. 设备树侧怎么配合4.1 两种典型的 dts 写法写法 A同 compatible 多节点。设备型号完全相同靠节点属性区分差异/ { compatible rockchip,rk3568-evb; ledext0: ledext10100000 { compatible rockchip,ledext; reg 0x0 0x10100000 0x0 0x100; max-leds 4; interrupt-parent gpio1; interrupts RK_PA2 IRQ_TYPE_LEVEL_LOW; }; ledext1: ledext10101000 { compatible rockchip,ledext; reg 0x0 0x10101000 0x0 0x100; max-leds 8; interrupt-parent gpio1; interrupts RK_PA3 IRQ_TYPE_LEVEL_LOW; }; };写法 B不同 compatible 多节点。设备型号有差异靠 of_device_id 的 data 区分ledext0: ledext10100000 { compatible rockchip,rk3568-ledext; reg 0x0 0x10100000 0x0 0x100; }; ledext1: ledext10101000 { compatible rockchip,rv1126-ledext; reg 0x0 0x10101000 0x0 0x100; };实际中两种写法会混合。判断标准就一句话如果配置差异属于 IP 型号本身的差异用 compatible 加 data如果属于板级配置差异同一型号芯片在不同板上挂了几个灯用属性节点。4.2 每个实例的属性怎么读dts 里写的max-leds、read-channels这类自定义属性probe 里不要用老接口of_property_read_u32建议用device_property_read_u32这组通用属性访问接口。好处是它对 FWNODE 和设备树解耦换到其他 ABI 也能用。u32 max_leds; if (device_property_read_u32(dev, max-leds, max_leds)) max_leds cfg-max_leds; /* 缺省时用型号配置 */这里有个组合技巧属性优先级可以高于型号配置。比如 v2 型号默认 8 路但某板子上只用 4 路dts 里覆写max-leds 4probe 里先用型号 cfg 做默认值再被 dts 属性覆盖。这样既保持型号默认行为又允许板级定制不用为一个小差异复制一份配置结构体。4.3 瑞芯微平台 dts 里的细节在瑞芯微平台写多设备 dts有几个细节需要特别注意。第一节点名带地址。ledext10100000的地址必须和 reg 的第一项一致否则 dtc 编译会报错或告警。这个容易在复制粘贴时写错。第二reg 长度要对应 ioremap 范围不要写太大。写大了不会直接报错但 /proc/iomem 里区域描述不干净将来排查地址冲突时会多一层困惑。第三pinctrl 子节点里写自己的引脚不要放到父节点导致两个实例共用一个 pin。瑞芯微的 dtsi 里经常把 pinctrl 统一写在节点下面复制节点时很容易把 pinctrl-0 也复制过去结果两个实例抢同一个 GPIOprobe 不报错但中断和工作状态全乱。第四中断格式。RK 平台 GPIO 中断格式是gpioN RK_PXx 触发类型触发类型不要随便写IRQ_TYPE_NONE否则中断行为不确定。用IRQ_TYPE_LEVEL_LOW或IRQ_TYPE_EDGE_FALLING要看硬件规格不能想当然。第五多实例的时钟、复位资源也要独立。dtsi 阶段把clocks、resets都写在每个实例自己的节点里板级 dts 只做 enable/disable不要重复定义。否则两个实例共用同一个 clock handleremove 时关了一个另一个就跟着出问题。最后提醒修改 dts 后要确保 dtb 真的更新了而不是 kernel 还在用旧 dtb。很多“为什么我改了 dts 没生效”的问题最后都发现是烧录或者打包环节没有把新 dtb 刷进去。5. 板级验证与排查思路5.1 怎么确认两个实例都真正跑起来了驱动 probe 里我习惯打印一句带地址的话dev_info(dev, ledext probed, id%u, base%px\n, instance_no, ldev-base);启动后 dmesg 里应该能看到两条地址不同。同时/sys/bus/platform/devices/下会多出ledext10100000和ledext10101000两个目录。中断注册后/proc/interrupts里能看到对应中断号且同一中断名下会出现两个设备名如果是共享中断。如果是模块热加载modprobe 后驱动会自动绑定所有 compatible 匹配的设备。如果不自动绑先查 modinfo 里有没有生成 alias很可能是MODULE_DEVICE_TABLE写漏了。5.2 多设备驱动三大经典故障我在实际调试中多设备驱动的问题基本集中在下面几类。第一个是只有一个实例在跑。先查 dts 节点是不是真的编译进 dtb用ls /proc/device-tree/或find /proc/device-tree -name *ledext*确认。再查 of_device_id 的 compatible 与 dts 完全一致包括大小写。驱动先注册、设备后添加的情况platform bus 会自动 bind一般不用手动管。第二个是第二个实例 probe 返回错误。看具体 errno-EBUSY表示资源冲突-ENXIO表示 IRQ 或 reg 没解析到。两个实例是否用了同一个 interrupt 引脚GPIO 不能随便共享要么IRQF_SHARED要么单独分配引脚。还有没有全局变量记录了第一个实例的状态第二个 probe 里不要依赖任何全局状态。第三个是中断回调读到的数据不对。重点检查 request_irq 的 dev_id 是否传了实例私有数据检查 handler 里所用基址是否来自 dev_id 而非全局变量。这个问题在 3.3 里已经详细展开也是在多设备驱动中间歇性出现、最难定位的一类。5.3 一次真实调试记录中断服务拿错了 base前阵子在一张 RK3568 板卡上调试板上并联两片扩展 IO 芯片。现象是第一个实例收不到中断第二个实例完全正常。dmesg 显示两个 probe 都成功/proc/interrupts 也都有。加打印后发现在第一个实例的中断回调里读ldev-base打印出来的地址是第二个实例的10101000。顺着代码找下去原因很老套我在 probe 里写了个static struct ledext_dev *last_dev;没有用 drvdata。第二次 probe 把 last_dev 指向了新实例第一个实例的中断触发后 handler 拿last_dev-base去操作自然全乱。修复方式就是技巧二的标准做法平台驱动用platform_set_drvdata保存实例irq handler 的 dev_id 直接传 ldev回调里不再碰全局变量。改完以后两个实例的中断各自读各自的寄存器问题消失。这个案例说明一个规律多设备驱动的数据隔离问题症状往往不在 probe 阶段爆发而在中断、worker、文件操作这类异步路径上爆发。probe 时看起来好好的等异步回调一进来全局变量的覆盖效应就现出原形。所以写多设备驱动时从一开始就别在全局变量里放任何实例相关数据。最后再分享一个我总结的小经验。接到多设备需求时先不要着急写代码拿出设备树把每个节点的 compatible、reg、中断号列一张表然后问自己两个问题这个节点的型号差异靠什么识别这个节点实例的数据结构里要放哪些东西两个问题有了答案probe 函数的骨架也就出来了。上面的两个技巧一个是关于型号识别的一个是关于实例数据的基本覆盖了多设备驱动 90% 的代码组织需求。剩下的就是多碰碰实际 bug积累细节了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价