ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Linux驱动多实例支持:从设备树到probe的完整实践

2026/9/7 13:35:51 拓冰建站 浏览量
Linux驱动多实例支持:从设备树到probe的完整实践 前阵子在一块 RK3568 板子上做多路传感器接入的驱动适配遇到一个很典型的坎驱动写好了、模块加载正常、首次 probe 也成功了可一旦设备树里挂上第二个同类型设备要么第一个实例的数据被覆盖要么第二个实例直接 probe 失败。当时查了两三天最后把 Linux 驱动模型里“一个驱动对应多个设备”的匹配和设备管理机制彻底捋了一遍才把问题解决掉。这篇文章就把两个最实用的技巧掰开揉碎了讲清楚顺带整理一批基于瑞芯微平台实测出来的避坑记录希望能给正在做相关开发的人省下排查时间。1. 为什么你写的驱动只能伺候一个设备先说一个现象。很多写驱动的同学尤其是从单片机裸机开发转过来的会在驱动里习惯性地写一堆全局变量static void __iomem *g_base; static int g_irq; static struct work_struct g_work;单设备场景下这套写法完全没问题probe 一次、remove 一次所有数据都有地方放。但一旦设备树里同一个 compatible 对应的节点有两个、三个甚至更多内核就会对每个节点分别调用一次你的 probe。这时候所有实例共用同一个全局变量第二次 probe 就会把第一次的地址、中断号、工作队列状态全部冲掉。更隐蔽的是如果两次 probe 错开时间第一次实例的中断处理函数可能拿着被篡改的全局指针去操作寄存器直接导致系统异常。这就是最核心的概念Linux 的设备驱动模型本质上是“多实例”的。一个 driver 可以绑定多个 device每次 probe 都是一个独立的实例。驱动代码里所有跟设备实例相关的数据都应该放进独立的实例上下文结构体里而不是放在文件作用域的全局 static 变量里。1.1 单设备驱动的代码是怎么写死的我举一个实际例子。早期我写的某个 sensor 驱动大概是这个骨架static struct i2c_client *g_client; static int g_irq; static u8 g_chip_id; static irqreturn_t sensor_irq_handler(int irq, void *dev_id) { // 这里用了 g_client、g_chip_id return IRQ_HANDLED; } static int sensor_probe(struct i2c_client *client) { g_client client; g_irq client-irq; // 读取芯片ID g_chip_id read_reg(g_client, REG_CHIP_ID); dev_info(client-dev, chip id: 0x%02x\n, g_chip_id); return 0; }接入第二个 sensor 时probe 会把 g_client 指向第二个 device第一次注册的中断 handler 在触发时会用新的 g_client 去读老设备的中断状态寄存器结果读回来的是第二个设备的状态两个设备的逻辑彻底错乱。这种问题在代码 review 时很难看出来因为没有语法错误逻辑也“说得通”只有实际挂多设备跑起来才会爆发。1.2 驱动模型的多实例匹配机制要理解怎么改先得知道内核是怎么决定“实例”的。以 I2C 子系统为例设备树里每个子节点在系统初始化时会被创建为一个struct i2c_clienti2c3 { status okay; clock-frequency 400000; sensor0: pressure48 { compatible acme,pressure-sensor; reg 0x48; interrupt-parent gpio1; interrupts 14 IRQ_TYPE_EDGE_FALLING; }; sensor1: pressure49 { compatible acme,pressure-sensor; reg 0x49; interrupt-parent gpio1; interrupts 15 IRQ_TYPE_EDGE_FALLING; }; };内核 I2C 核心会把sensor0和sensor1两个节点分别和acme,pressure-sensor这个 driver 的 id_table 做匹配。匹配成功的每个节点都会调用一次 probe参数struct i2c_client *client指向各自独立的设备对象。所以你的驱动本质上天生就具备“多设备支持”的能力前提是你别用那些全局变量把这条路堵死。平台设备platform device的逻辑也完全一样设备树每个节点生成一个struct platform_devicedriver 的of_match_table里有对应 compatible 就会逐个 probe。搞清楚这层机制接下来的两个技巧才有落脚点。2. 技巧一设备树里把多个节点的资源安排明白设备树是驱动多实例支持的第一道关卡。很多资源是跟着设备节点走的比如寄存器地址、中断号、GPIO、时钟、电源域、pinctrl。一个驱动要同时支持多个设备第一步就是确保每个节点把这些资源定义清楚并且互不冲突。2.1 compatible 匹配和 driver_data 的妙用大多数情况下多个设备使用同一个 compatible 就能被同一个驱动接管。但如果板子上有两种规格的 sensor寄存器布局不一样你又不希望写两个驱动这里就有个小技巧使用of_device_id.data字段。static const struct of_device_id acme_pressure_dt_match[] { { .compatible acme,pressure-sensor, .data pressure_v1_ops }, { .compatible acme,pressure-sensor-v2, .data pressure_v2_ops }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, acme_pressure_dt_match);在 probe 里用of_match_device()取到匹配项然后从 data 里拿出对应的操作函数集合static int sensor_probe(struct i2c_client *client) { const struct of_device_id *match; struct sensor_priv *priv; match of_match_device(sensor_dt_match, client-dev); if (!match) return -ENODEV; priv devm_kzalloc(client-dev, sizeof(*priv), GFP_KERNEL); priv-ops (const struct pressure_ops *)match-data; // 后续所有寄存器读写都通过 priv-ops 间接调用 ret priv-ops-init(priv); ... }这样一来即使两种硬件版本在寄存器地址、初始化序列上完全不同驱动核心代码也只需要一份差异收敛到 ops 结构体里。同类硬件迭代升级时新增一个 compatible 和一组 ops 就够了不需要动核心逻辑。platform 设备同理比如瑞芯微平台上常见的 spi 从设备、自定义总线设备都可以用of_device_id.data区分硬件版本。这个做法在设备模型中属于非常成熟的手段但实际项目里真正用起来的反而少可能是因为大多数项目的硬件版本比较单一等遇到兼容需求时才想起来补。2.2 reg、interrupts、pinctrl 的多实例细节资源定义方面最常见的错误是把中断号、GPIO 编号写死成全局宏。比如#define SENSOR_INT_GPIO GPIO1_PD5这种写法在设备树时代就是死路一条老内核里 GPIO 编号在板级文件里还算固定设备树架构下中断和 GPIO 都必须从节点属性里动态获取。正确的做法是每个节点定义自己的interrupts并且通过interrupt-parent指定具体 GPIO 控制器sensor0: pressure48 { compatible acme,pressure-sensor; reg 0x48; interrupt-parent gpio1; interrupts 14 IRQ_TYPE_EDGE_FALLING; };驱动里统一用client-irq拿到中断号而不是硬编码。两个设备只要中断引脚不同驱动代码里不用做任何区分client-irq自动就是各自的中断号。这一点很容易忽略却决定了多实例能不能正常跑起来。还有一个容易踩的坑是 I2C 子节点的reg。每个 I2C 地址只能有唯一一个节点如果你有两个同型号芯片地址相同那就得靠外部地址引脚区分设备树里把两个节点都写成reg 0x48会导致后一个覆盖前一个I2C 核心直接报invalid device。这种情况下要不改硬件地址要不就得想别的办法枚举。pinctrl 部分瑞芯微平台比较敏感。I2C 节点本身的 pinctrl 通常放在父节点i2c3里子设备如果需要额外引脚做中断或复位就要在子节点里配置 pinctrl。这里有个常见问题如果两个子设备的 pinctrl 引用了同一个 pinctrl group内核的 pinctrl 子系统会拒绝第二次 request报pin is already requested。解决办法是给不同设备拆分不同的 pin group或者在硬件允许的情况下直接把两个设备的复位、中断线合并成一个设备使用。3. 技巧二驱动内部用实例上下文撑起多设备设备树层面的资源理顺之后真正的重头戏是驱动内部的数据组织。多设备支持的核心就一句话每个设备实例拥有自己独立的数据结构和资源管理。3.1 priv 结构体和 devm 资源的分配我习惯在每个驱动里都定义一个xxx_priv结构体把所有和实例相关的数据都放进去struct sensor_priv { struct device *dev; struct i2c_client *client; const struct pressure_ops *ops; void __iomem *base; // 如果是 platform 设备io 映射基址 int irq; u8 chip_id; struct cdev cdev; struct class *sensor_class; // 字符设备相关如需要 spinlock_t lock; struct work_struct irq_work; // 寄存器缓存、校准参数、统计信息等 u32 calib[4]; };probe 里用devm_kzalloc分配用i2c_set_clientdata/platform_set_drvdata保存指针static int sensor_probe(struct i2c_client *client) { struct sensor_priv *priv; priv devm_kzalloc(client-dev, sizeof(*priv), GFP_KERNEL); if (!priv) return -ENOMEM; priv-dev client-dev; priv-client client; priv-irq client-irq; spin_lock_init(priv-lock); i2c_set_clientdata(client, priv); if (priv-irq) { ret devm_request_threaded_irq(client-dev, priv-irq, NULL, sensor_irq_handler, IRQF_TRIGGER_FALLING | IRQF_ONESHOT, acme-pressure, priv); if (ret) return ret; } return 0; }注意中断处理函数的最后一个参数传的是priv而不是全局变量。这样每个实例的中断进来拿到的都是自己的上下文互不干扰。用devm_系列资源管理函数还有一个好处remove 和 probe 失败的路径不用手动释放内存、注销中断、释放 io 映射内核会在设备解绑时自动处理。多设备场景下一个实例失败不应该影响另一个实例的运行devm_机制可以确保资源释放的顺序和时机都正确避免泄漏。3.2 设备号、probe、remove 的正确姿势如果驱动要创建字符设备节点多实例场景下最大的坑在设备号分配。静态指定次设备号是最容易出问题的做法。比如你写死了#define SENSOR_MINOR 0第一次 register_chrdev 成功第二次再注册同一个主设备号下的相同次设备号就会失败。所以多实例驱动里要么用register_chrdev_region一次性分配连续多个次设备号要么干脆用动态分配static int sensor_setup_cdev(struct sensor_priv *priv) { dev_t devno; alloc_chrdev_region(devno, 0, 1, acme-pressure); priv-devno devno; cdev_init(priv-cdev, sensor_fops); priv-cdev.owner THIS_MODULE; cdev_add(priv-cdev, devno, 1); priv-sensor_device device_create(sensor_class, priv-dev, devno, priv, pressure%d, priv-index); return 0; }这样每个 probe 进来的实例都会分配到独立的设备号/dev/pressure0、/dev/pressure1各自对应不同物理设备。前提是给priv-index赋值priv-index atomic_inc_return(sensor_index) - 1;用atomic_t计数器保证多实例并发 probe 时索引不冲突。如果你不想管理这么多字符设备细节也有更简单的方案用miscdevice。它每次注册自动分配次设备号多实例之间天然隔离适合功能简单的驱动priv-miscdev.minor MISC_DYNAMIC_MINOR; priv-miscdev.name devm_kasprintf(client-dev, GFP_KERNEL, pressure%d, priv-index); priv-miscdev.fops sensor_fops; priv-miscdev.parent client-dev; ret misc_register(priv-miscdev);注意miscdev.name不能是静态字符串否则第二个实例注册时节点名冲突。用动态生成的名字每个实例一个独立节点。remove 方面devm_资源可以自动释放但如果自己注册了 miscdevice 或者 cdev还是要显式注销static void sensor_remove(struct i2c_client *client) { struct sensor_priv *priv i2c_get_clientdata(client); misc_deregister(priv-miscdev); // 其他 devm 资源自动释放 }只要每个实例的数据都放在自己的 priv 里remove 的次序就无所谓先删哪个实例之间不共享可变状态即使一个实例 remove另一个照常工作。4. 瑞芯微平台实测这些坑我替你先踩了在 RK3568 上做双 sensor 驱动适配时除了上面两个通用技巧还撞上了几个平台特有的问题。这里整理成清单大家做瑞芯微方案时少走弯路。4.1 RK3568 环境下的 irq 与 power domain 资源瑞芯微 RK3568 的 GPIO 中断和 iomux 配置比较讲究。设备树里子节点的interrupt-parent必须指向正确的 GPIO 控制器且interrupts里的第一个参数是 GPIO bank 内部的偏移不是全局 GPIO 编号。很多人把gpio1和 GPIO 编号搞混结果 irq 申请失败或者申请成了别的引脚。我在双 sensor 场景里遇到过一种情况两个 sensor 的中断分别接到 GPIO1_A5 和 GPIO1_A6设备树配置没问题但驱动运行一段时间后其中一个中断丢失。最后定位到原因是 RK3568 的 GPIO 中断需要设置wakeup能力否则在低功耗状态切换时中断注册会被裁剪。解决办法是irq_set_irq_wake(priv-irq, 1);或者在设备树的 GPIO 节点里配置wakeup-source。这个点不太容易想到因为单设备场景下从来没出过问题双设备并发唤醒时才暴露。另一个坑是 power domain。RK3568 有多个电源域某些外设挂在独立 PD 上。多实例驱动的所有设备如果分散在不同电源域probe 时最好检查pm_runtime状态否则唤醒顺序不对会偶发寄存器读超时。稳妥做法是在 probe 里主动pm_runtime_get_sync(dev)remove 时pm_runtime_put。4.2 pinctrl pin already requested 的排查这是我这次调驱动最费劲的地方。两个 sensor 节点都在自己的 pinctrl 里引用了同一组引脚复用配置结果第二个设备 probe 时内核打出了pin is already requested直接 probe 失败。一开始我没往 pinctrl 想以为是中断冲突毕竟两个设备用的中断引脚不同。排查过程是这样的先看内核日志dmesg | tail -20看到pinctrl-rockchip fdd60000.pinctrl: pin 28 already requested by 1-0048; cannot claim for 1-0049锁定是 pinctrl 的 pin 复用冲突。接着用 debugfs 确认 pin 占用状态cat /sys/kernel/debug/pinctrl/fdd60000.pinctrl/pins cat /sys/kernel/debug/pinctrl/fdd60000.pinctrl/pinmux-pins果然两个 sensor 节点在 pinctrl-0 里都引用了同一个int_pin_group。硬件上两个中断脚是独立的理论上不该冲突但我在 dts 里写 pinctrl 时图省事直接复用了一个 group。解决方式也很简单给 sensor1 单独拆一个 pin group或者直接把子节点的 pinctrl 去掉让引脚保持默认 GPIO 模式。瑞芯微平台上 GPIO 中断本身就只需要 GPIO 模式不需要额外配置 iomux 功能复用所以中断脚对应的 pinctrl 通常可以省略。真正需要细分 pinctrl 的场景是 SPI、I2C、UART 等特殊功能引脚当两个设备的同一种功能复用同一个 controller 引脚时才会冲突。4.3 SDK 内核和主线内核的行为差异瑞芯微官方 SDK 内核和主线内核在多设备驱动的行为上也有一些差别主要集中在设备树解析和 pinctrl 初始化顺序。SDK 内核以 5.10 为例对 I2C 子节点的探测时机和主线略有不同SDK 下如果子设备 probe 耗时过长会和父节点的i2c_detect机制抢锁表现为偶发 probe 超时或全部 probe 失败。主线内核6.1这类问题少很多因为 I2C 核心的重试机制更完善但主线内核对自己点名的设备树属性要求更严格compatible写得不规范会直接忽略节点而不是 warn 之后继续跑。两种内核下多实例驱动都能用但建议先在 SDK 内核上调通再拿到主线内核验证。反过来调会比较痛苦因为主线对 pinctrl 和中断解析报错更硬容易把隐藏问题放大。5. 再教你一套验证多实例驱动的完整方法代码写完了、模块加载了、设备树也改了怎么判断你的驱动真的“支持多个设备”光看dmesg里 probe 打印了两行还不够我一般会在内核里和用户空间各做一轮检查。5.1 通过 sysfs 和 proc 核对实例先确认内核底层是否真的识别了两个实例。I2C 设备ls /sys/bus/i2c/devices/能看到类似1-0048、1-0049两个目录。再确认每个目录下的 driver 链接指向同一个驱动ls -l /sys/bus/i2c/devices/1-0048/driver ls -l /sys/bus/i2c/devices/1-0049/driver两个链接应该指向同一个 driver 目录说明驱动确实绑定到了两个 device。如果第二个设备没有出现这个链接说明 probe 没成功去dmesg里翻失败原因。如果用了中断还需要确认每个实例的中断号是否独立cat /proc/interrupts | grep pressure正常情况应该能看到两个不同 irq 号各自统计中断次数。如果只有一个 irq 或者中断号相同说明你的设备树中断配置或者 probe 里的 irq 分配有问题。设备号验证cat /proc/devices | grep pressure ls -l /dev/pressure*正常情况/dev/pressure0和/dev/pressure1主设备号相同、次设备号不同。5.2 怎么快速判断实例之间的数据有没有串有时候 probe 全成功、开发节点也建好了但驱动实际工作起来数据还是串。这种问题最隐蔽可以在用户空间做一个快速自测读一个设备的寄存器同时读另一个设备交叉验证数据是否是每个实例独立维护的。比如两个 sensor先各自读取 chip_idcat /sys/class/pressure/pressure0/chip_id cat /sys/class/pressure/pressure1/chip_id如果两个输出不一样说明实例隔离正常。如果读出来一样基本就是 priv 数据或全局缓存被污染了重点审计全局变量和共享 buffer。驱动内部也可以加一个自检在 probe 成功后用dev_info打印每个实例的priv指针、irq 和设备名dev_info(client-dev, probe ok, priv%p irq%d\n, priv, priv-irq);好好的日志里两个实例的priv指针必然不同这是实例隔离的直观证据。最后再说一个实用的调试习惯多实例驱动调试时尽量用dev_dbg(dev, ...)而不是pr_info因为dev_dbg会自动带上设备和驱动名一眼就能看出日志属于哪个实例。我在两路 sensor 调试时靠日志前缀区分数据来源比对中断频率和寄存器值时特别快。这个习惯单设备驱动里可有可无多设备场景下是刚需。