搞Linux驱动的人应该都有同感在瑞芯微Rockchip这类SoC上调驱动做到后面真正头疼的往往不是某个外设“点不亮”而是“一个驱动要同时伺候好几个设备”。我最早碰到这个需求是在一块RK3568的板卡上做四路ADC采集硬件上挂了四片同型号的芯片驱动如果写成“单设备思维”一路正常、两路错乱、三路直接卡死那种排查过程谁做谁知道。后来在瑞芯微Linux SDK里泡久了慢慢整理出两个非常实用的小技巧一个解决“一份驱动怎么区分不同型号”一个解决“一份驱动怎么管理多个同类实例”。这篇文章就把这两个技巧展开聊聊顺带把我在RK平台上工程落地时踩过的坑也一并写出来希望对正在做Linux驱动、特别是嵌入式Linux驱动开发的朋友有参考价值。1. 先搞清楚场景驱动凭什么是“一份代码、多个设备”很多刚入门Linux驱动的同学有个误区觉得“一个设备一个驱动文件”代码里用全局变量存一大坨状态也没关系。但真实项目里驱动和设备的对应关系远比这个复杂。尤其是在瑞芯微这类内部集成了大量控制器、又经常需要外挂多颗同型号芯片的平台上多设备支持不是“加分项”而是“必选项”。1.1 瑞芯微平台上最常见的三类“多设备”诉求瑞芯微平台上的“一个驱动管多个设备”本质上分三种场景每一种的处理方式都有差异。第一类是SoC自带的控制器多实例。RK系列芯片内部通常不止一路I2C、UART、SPI比如常用的RK3568、RK3588I2C和UART都集成很多路。这些控制器的IP是一样的只不过在芯片内部映射到了不同的寄存器基地址。内核的做法是同一个驱动比如drivers/i2c/busses/i2c-rockchip.c在设备树里匹配多个控制器节点一个驱动probe多次每次拿到不同的resource和platform_device。这种“控制器级多实例”属于内核框架已经帮你处理好的典型场景。第二类是板级同型号外设出现多次。比如我前面说的四片ADC或者板子上装了四颗同样的电机驱动芯片、两片同样的音频Codec、多个相同的温湿度传感器。它们在设备树里通常是同一总线比如I2C0/I2C1或者同一路I2C下的不同地址下的多个节点compatible字符串完全一样靠reg地址区分。这部分就需要驱动本身具备实例隔离能力否则probe两次之后第一次的数据会被第二次覆盖。第三类是同一个驱动要兼容不同型号或者不同版本。硬件工程师经常干这种事上一版设计用的芯片停产了换一颗引脚兼容但寄存器略有差异的替代料希望软件改动越小越好。这时候同一个compatible自然没法匹配新芯片于是大家会另外加一个compatible驱动结构里用两个of_device_id匹配内核再把“当前匹配到的是谁”告诉probe函数。1.2 两个技巧分别解决哪一类问题这两个技巧其实是配套使用的。第一个技巧用ID表和driver_data区分“我是谁”。它主要解决第三类问题一个驱动要支持多个型号或者版本时怎么在probe阶段就快速知道当前驱动伺候的是哪颗芯片、哪个版本。核心是借助of_device_id里的.data字段或者I2C、SPI框架里的id_table-driver_data把型号信息直接绑定在匹配表里probe时用一行代码拿出来。第二个技巧用设备私有数据做实例隔离。它主要解决第二类问题同一个compatible匹配了多个设备节点驱动被probe多次时每一路实例都必须有自己独立的“现场”寄存器映射地址、中断号、状态锁、缓冲区等。核心就是抛弃全局变量把所有状态放进struct xxx_dev这样的私有数据结构里并借助devm_系列API自动管理生命周期。这两个技巧合起来就是一套成熟的“单驱动多设备”开发范式。下面展开说。2. 技巧一用driver_data和ID表一份驱动适配多款芯片先看一个最常见的工程场景芯片A的第一版和第二版寄存器不太一样或者某颗替代芯片大部分寄存器兼容、个别功能位不同。如果写两个驱动文件代码重复率80%以上维护起来会让人崩溃。正确做法是在同一个驱动里做型号区分。2.1 Linux驱动的匹配机制of_match_table与id_table只是“入口”我们写platform驱动时最经典的结构是这样的static const struct of_device_id demo_of_match[] { { .compatible rockchip,demo-ctrl-v1 }, { .compatible rockchip,demo-ctrl-v2 }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, demo_of_match); static struct platform_driver demo_driver { .probe demo_probe, .remove demo_remove, .driver { .name demo-ctrl, .of_match_table demo_of_match, }, }; module_platform_driver(demo_driver);这里demo_of_match[]里列了两个compatible。设备树里不管哪个节点写的是这两个字符串之一都会被这个驱动接住。但问题来了probe函数拿到struct platform_device *pdev之后怎么知道这次匹配的是v1还是v2总不能每次probe都去读芯片寄存器里的版本号吧。很多新手会这么干probe里先i2c读一下芯片的chip id寄存器或者直接解析pdev-dev.of_node里的某个自定义属性。这种方法不是不能用但有几个问题某些芯片没有可读的ID寄存器或者要等电源稳定、时钟开启之后才能读而probe阶段可能还没准备好另外每次都读芯片也增加启动时间。更优雅的方式是让匹配表本身就把型号信息带过来。2.2 driver_data字段probe阶段就知道“我是谁”Linux内核早就想到了这个问题。of_device_id结构体里有一个.data字段类型是const void *而I2C、SPI框架里的i2c_device_id、spi_device_id则有一个kernel_ulong_t driver_data字段。它们的作用一模一样把和这个匹配项关联的型号标识直接挂在匹配表上。以platform驱动为例enum demo_ctrl_variant { DEMO_CTRL_V1, DEMO_CTRL_V2, }; static const struct of_device_id demo_of_match[] { { .compatible rockchip,demo-ctrl-v1, .data (void *)DEMO_CTRL_V1 }, { .compatible rockchip,demo-ctrl-v2, .data (void *)DEMO_CTRL_V2 }, { /* sentinel */ } }; static int demo_probe(struct platform_device *pdev) { struct device *dev pdev-dev; const struct demo_ctrl_variant *variant; // 实际常用枚举直接转int . . . }实际使用更常见的写法是static int demo_probe(struct platform_device *pdev) { struct device *dev pdev-dev; enum demo_ctrl_variant variant; int ret; variant (enum demo_ctrl_variant)of_device_get_match_data(dev); if (variant ! DEMO_CTRL_V1 variant ! DEMO_CTRL_V2) { dev_err(dev, unknown variant\n); return -EINVAL; } if (variant DEMO_CTRL_V1) { /* 初始化v1寄存器配置 */ } else { /* 初始化v2寄存器配置 */ } ... }of_device_get_match_data()是内核提供的辅助函数实现在drivers/of/device.c里它会从当前设备的of_node匹配到的of_device_id中把.data取出来比你自己遍历of_match_table要省事得多。那I2C设备怎么办I2C驱动通常是靠i2c_device_id匹配的static const struct i2c_device_id demo_i2c_id[] { { demo-ctrl-v1, (kernel_ulong_t)DEMO_CTRL_V1 }, { demo-ctrl-v2, (kernel_ulong_t)DEMO_CTRL_V2 }, { } }; MODULE_DEVICE_TABLE(i2c, demo_i2c_id); static int demo_i2c_probe(struct i2c_client *client) { enum demo_ctrl_variant variant (enum demo_ctrl_variant)client-name; // 注意如果是通过of匹配用下面这行拿数据 variant (enum demo_ctrl_variant)of_device_get_match_data(client-dev); ... }用of_device_get_match_data()的时候有个小细节它要求驱动结构体里.of_match_table已经正确挂上而且设备树节点匹配到的是of_match_table中的某一项。对于I2C驱动如果设备和驱动是通过设备树compatible匹配的走client-dev.of_node同样可以用这个API如果用board info非设备树方式注册那就要回退到id_table里的driver_data。工程上通常是两种匹配表都挂of_match_table里给设备的compatibleid_table里给通用的I2C name字符串probe时根据实际情况取。2.3 工程建议型号差异尽量收敛到一个初始化函数有了driver_data之后驱动代码要注意别把if-else散得到处都是。我的习惯是把型号相关的差异全部收敛进一个初始化函数和退出函数里static int demo_ctrl_init(struct demo_dev *ddev) { switch (ddev-variant) { case DEMO_CTRL_V1: demo_write(ddev, DEMO_REG_CFG, 0x01); break; case DEMO_CTRL_V2: demo_write(ddev, DEMO_REG_CFG, 0x02); demo_write(ddev, DEMO_REG_EXTRA, 0x10); break; default: return -EINVAL; } return 0; }这样一来probe函数很干净其它业务逻辑读写、中断处理里就不需要再关心型号差异了。特别是在中断处理函数或者数据通路里千万别做“读寄存器判断当前是哪个型号”这种操作既慢又容易因为总线访问异常导致系统卡死。型号信息在probe阶段锁死之后运行期就不要再变了。2.4 注意事项probe里别再用“读到芯片ID再四处分支”的笨办法我见过一些驱动probe函数第一件事就是发命令读芯片的chip ID然后用一个全局变量存下来之后好多函数都用这个全局变量做分支。这种做法的坑在于设备是异步probe的全局变量可能会被覆盖某些芯片在系统休眠唤醒后会重新复位但驱动里的id缓存没有更新更关键的是热插拔或者多实例时根本无法区分每个实例的身份。所以型号信息一定要跟着“这个设备”走而不是跟着“这个驱动”走。把型号存进私有数据结构就是最自然的归宿。3. 技巧二用设备私有数据做实例隔离彻底告别全局变量如果说技巧一是“认识自己”技巧二就是“管好自己的现场”。在Linux驱动开发里每个设备实例都必须有一个独立的状态容器这就是struct xxx_dev中文社区一般叫它“设备私有数据结构”。3.1 全局变量在multi-instance场景下的“三宗罪”先说说我为什么对驱动里的全局变量零容忍。假如驱动里有这么一句static struct demo_dev *g_ddev;probe函数里每次都g_ddev priv;那么当驱动被probe两次的时候后一次就会覆盖前一次。带来的问题很典型第一个问题是寄存器操作错乱。两路实例共用同一个g_ddev第二路初始化的时候可能会把第一路的电源、中断、时钟配置改掉表现为“一路正常二路起来之后一路挂了”。第二个问题是remove只会清理最后一次的状态。两路设备都注册了卸载驱动时remove被调用两次每次都用g_ddev最后结果是内存资源只释放了一次第一路的priv直接泄漏。第三个问题是共享中断回调里取不到正确的数据。很多外设是走共享中断的中断处理函数需要拿着priv去操作对应的寄存器。如果全局指针指向的是第二个实例第一路的中断来了也会去操作第二路的寄存器轻则数据错乱重则系统崩溃。所以多实例驱动的第一原则就是绝不用全局变量保存任何和设备实例相关的状态。3.2 设备私有数据结构与platform_set_drvdata/get_drvdata正确的做法是在probe里为每个设备分配独立的私有数据结构然后把它和struct device绑定需要用的时候再用dev_get_drvdata()取出来。先看一个典型的多实例驱动骨架这里用瑞芯微平台上很常见的GPIO控制类外设举例struct demo_dev { struct device *dev; void __iomem *base; struct resource *res; int irq; int id; // 实例编号 enum demo_ctrl_variant variant; struct mutex lock; struct cdev cdev; struct class *cls; ... }; static int demo_probe(struct platform_device *pdev) { struct device *dev pdev-dev; struct demo_dev *ddev; int ret; ddev devm_kzalloc(dev, sizeof(*ddev), GFP_KERNEL); if (!ddev) return -ENOMEM; ddev-dev dev; ddev-variant (enum demo_ctrl_variant)of_device_get_match_data(dev); ddev-res platform_get_resource(pdev, IORESOURCE_MEM, 0); // 注意不要用devm_platform_ioremap_resource裸取 // 多实例时每一路的base必须来自各自的resource。 ddev-base devm_platform_ioremap_resource(pdev, 0); if (IS_ERR(ddev-base)) return PTR_ERR(ddev-base); ddev-irq platform_get_irq(pdev, 0); if (ddev-irq 0) return ddev-irq; platform_set_drvdata(pdev, ddev); // 关键绑定到pdev ret demo_ctrl_init(ddev); if (ret) return ret; ret demo_create_cdev(ddev); // 按实例创建设备节点 if (ret) return ret; dev_info(dev, demo probed, variant%d, base%px, irq%d\n, ddev-variant, ddev-base, ddev-irq); return 0; }这里面最重要的一行就是platform_set_drvdata(pdev, ddev)。它把私有数据和当前这个platform_device绑定之后无论谁拿到这个pdev都能通过platform_get_drvdata(pdev)拿到正确的priv。在remove、中断、sysfs回调里都是如此。3.3 devm_系列API偷懒又安全的资源管理细心的朋友会发现上面的probe里大量用了devm_kzalloc、devm_platform_ioremap_resource这就是Linux设备驱动里最推荐的资源管理方式。devm_是“device managed”的缩写。比如devm_kzalloc分配的内存会在设备被释放driver detach时自动释放devm_platform_ioremap_resource会把ioremap出来的虚拟地址映射和设备生命周期绑定devm_request_irq申请的中断也会在设备销毁时自动释放。这意味着remove函数可以写得非常简洁static void demo_remove(struct platform_device *pdev) { struct demo_dev *ddev platform_get_drvdata(pdev); // devm资源会自动回收这里只需要做业务上的收尾 // 比如删除cdev、销毁设备节点、关闭某个硬件状态。 demo_destroy_cdev(ddev); }如果用老式的kmalloc、request_mem_region、ioremap、request_irqremove里要手动逐一释放稍有遗漏就会内存泄漏。而且probe中途出错时还要自己维护“部分成功”的清理逻辑非常繁琐。devm_就是把你从这种繁琐里解放出来的。实测下来瑞芯微官方SDK里的驱动几乎都在用devm_这个习惯值得所有人学。3.4 多实例的字符设备次设备号怎么规划驱动要支持多个设备实例最后还要解决“用户空间怎么区分访问哪个实例”的问题。常见方案是一个主设备号下分配多个连续的次设备号每个实例占用其中一个次设备号。举个例子#define DEMO_NR_DEVS 4 // 最多支持4个实例 static int demo_create_cdev(struct demo_dev *ddev) { dev_t devno; int ret; // 每个实例分配一个次设备号 devno MKDEV(demo_major, ddev-instance_id); cdev_init(ddev-cdev, demo_fops); ddev-cdev.owner THIS_MODULE; ret cdev_add(ddev-cdev, devno, 1); if (ret) return ret; ddev-devnode device_create(demo_class, ddev-dev, devno, ddev, demo%d, ddev-instance_id); return PTR_ERR_OR_ZERO(ddev-devnode); }主设备号可以在驱动入口统一申请比如register_chrdev_region(devno_base, DEMO_NR_DEVS, demo)然后probe每次按实例编号分配次设备号。用户空间就会看到/dev/demo0、/dev/demo1这样的节点。open操作里一直有一个常见的坑开发者喜欢在open里重新分配一个新的缓冲区挂到file-private_data。如果是多实例设备更合理的做法是先从container_of拿到驱动私有数据再结合实例去初始化。举个例子static int demo_open(struct inode *inode, struct file *filp) { struct demo_dev *ddev container_of(inode-i_cdev, struct demo_dev, cdev); filp-private_data ddev; // 每个实例的open都拿到自己的priv return 0; }注意这里的inode-i_cdev指向的还是cdev不要根据全局变量去猜实例。只要保证每个实例的cdev和priv是一一对应的这套链路就是准的。4. 在瑞芯微平台上的工程配置与实测讲完通用机制回到瑞芯微平台看看这些技巧具体怎么落地。4.1 设备树节点怎么写多个节点共用同一个compatible设备树里支持多个同类设备最简单的写法就是重复使用同一个compatible。比如I2C总线上挂两片同型号的ADCi2c4 { status okay; adc0: adc48 { compatible rockchip,demo-adc; reg 0x48; ... }; adc1: adc4a { compatible rockchip,demo-adc; reg 0x4a; ... }; };市面上很多I2C外设芯片支持地址引脚配置板子上可以通过硬件把地址引脚拉高拉低分配多个地址设备树里就按不同reg写多个node。匹配同一个compatible时内核会为每个node创建一个i2c_client然后调用同一个驱动的probe两次。这里有个细节点I2C控制器驱动的probe和I2C外设驱动的probe不是一回事。外设驱动只需要跟i2c_client打交道芯片地址已经写在设备树里了probe函数直接使用client-addr访问即可。多个实例时只要按照技巧二的模式处理私有数据就不会出问题。4.2 SoC自带的控制器多实例为什么一个串口驱动能管九路很多人在瑞芯微上做UART驱动时会有一个疑惑为什么一个8250驱动或者dw8250驱动能同时管理芯片上的好几路UART没有为每一路写单独的驱动文件也没有看到哪个文件里写了9份probe调用。这是因为内核的platform bus在启动时会扫描设备树发现serialfdd50000、serialfe660000等等多个控制器节点就会逐个创建platform_device然后去和注册的platform_driver做匹配。匹配成功后platform_driver的probe会被调用多次每次传入的platform_device不同platform_get_resource取到的基地址和中断号也不同。换句话说“一个驱动支持多个设备”其实是Linux设备模型的基本能力驱动开发者的任务是确保自己在probe多次时不出错。只要不碰全局变量、所有状态都放进私有数据、所有资源都用devm管理控制器类多实例天然就是安全的。我单独强调这点是想让大家明白这不是什么神奇魔法而是Linux总线机制的常规操作。4.3 一块板子上同型号外设挂多路probe的调用顺序与调试方法多实例场景下还有一个容易被忽略的问题probe的先后顺序是不保证的。设备树的节点虽然按书写顺序解析但总线上的枚举顺序、设备依赖比如先注册I2C控制器还是先注册I2C外设、驱动加载顺序都会影响probe时序。我在一块RK3588板子上同时挂了两个codec芯片一个放在I2C6上一个放在I2C7上理论上I2C6先注册它的codec应该先probe。但实际上I2C7上的codec先probe了因为I2C7对应的控制器dts节点在i2c7里配置了更早的时钟使能。后来我不再依赖顺序而是给每个实例分配了一个有意义的name和index启动日志里也能看得清清楚楚dev_info(dev, demo%d: probed at %px\n, ddev-instance_id, ddev-base);除此之外调试多实例还可以看这几个地方/sys/bus/platform/devices/列出所有platform设备能看到每个实例的节点名/sys/bus/i2c/devices/看I2C总线上注册了哪些client/proc/device-tree/直接查看设备树内容确认compatible、reg写没写对ls -l /dev/demo*看创建的设备节点实例个数和次设备号。5. 常见问题与排查技巧实录理论说了不少最后把实际动手时最容易踩的坑按“现象-原因-方案”整理成一张速查表这些基本是我自己或身边同事在瑞芯微平台上一路踩出来的经验。5.1 现象设备树明明有节点驱动就是不被probe常见原因有三个。一是compatible字符串不一致设备树里写着rockchip,demo-adc驱动里却写成了rockchip,demo_adc或者少了个前缀这个只能肉眼慢慢对或者直接读/proc/device-tree对照。二是of_match_table被宏优化掉了比如有些平台配置了CONFIG_OF但是开发者在结构体里用了of_match_ptr()而设备树节点匹配需要保留of_match_table此时要确认宏展开情况。三是驱动模块根本没加载到系统里modprobe后忘了看dmesg有没有报错或者module_platform_driver()宏没写对。排查技巧加载驱动后用ls /sys/bus/platform/drivers/demo-ctrl/看一下有哪些设备已经绑定再用dmesg看匹配过程。如果设备树和匹配表都没问题这里应该能看到设备节点的符号链接。5.2 现象两路实例寄存器互相“串扰”这是多实例开发里最经典的问题几乎百分百是全局变量惹的祸。我之前调试一个双路DAC驱动时第一路输出正确的波形第二路打开后第一路波形直接漂移后来加了打印才发现两路共用了同一个基地址因为代码里把base写成了全局变量第二路ioremap后直接把全局base覆盖了。解决办法就是按技巧二全部改成私有数据每个probe里用自己的platform_get_resource和devm_platform_ioremap_resource确保ddev-base指向各自的寄存器空间。另外还要检查中断处理函数里是不是用了全局priv如果用了共享中断务必改成dev_id参数传入priv。5.3 现象同款芯片不同版本probe成功但行为不一致这种情况多半是型号差异没有正确下发。比如v1芯片没有某个额外控制位v2新增了而驱动probe里没有读取driver_data默认按v1初始化导致v2上某个功能不正常。解决方案就是技巧一里的of_device_get_match_data()方案。还有一个小细节enum demo_ctrl_variant如果定义在驱动源文件里使用.data (void *)DEMO_CTRL_V1时C语言编译器可能会对整数到指针的转换告警建议用(void *)DEMO_CTRL_V1显式转换或者在Linux内核里直接用(void *)1、(void *)2这类魔数也可以在结构体里放一个字段。业界常见的做法是声明一个结构体.data指向这个结构体里面包含各种差异参数比裸枚举更灵活。比如struct demo_config { const char *name; u32 reg_cfg_default; u32 reg_extra_mask; }; static const struct demo_config demo_cfg_v1 { .name v1, .reg_cfg_default 0x01, .reg_extra_mask 0x00, }; static const struct demo_config demo_cfg_v2 { .name v2, .reg_cfg_default 0x02, .reg_extra_mask 0x10, }; static const struct of_device_id demo_of_match[] { { .compatible rockchip,demo-ctrl-v1, .data demo_cfg_v1 }, { .compatible rockchip,demo-ctrl-v2, .data demo_cfg_v2 }, { /* sentinel */ } };probe里直接const struct demo_config *cfg of_device_get_match_data(dev);后续所有以cfg-方式读取参数扩展性最好。这是我专门踩过一次坑之后沉淀下来的做法。5.4 调试手段dev_info/dev_dbg、sysfs属性最后聊聊调试。我强烈建议驱动里一律用dev_info、dev_err、dev_dbg而不是裸的printk。因为dev_系列会带上设备名和实例信息多实例时日志能一眼看出是哪一路在说话排查效率完全不同。比如dev_info(dev, demo%d probed, variant%d, io0x%px, irq%d\n, ddev-instance_id, ddev-variant, ddev-base, ddev-irq);启动日志里如果看到两行“demo0 probed”和“demo1 probed”就知道两个实例都正常进来了。如果只看到一行赶紧检查设备树的status和reg有没有冲突。另外给每个实例做几个sysfs属性节点也会让调试舒服很多static ssize_t demo_show_reg(struct device *dev, struct device_attribute *attr, char *buf) { struct demo_dev *ddev dev_get_drvdata(dev); u32 val readl(ddev-base DEMO_REG_STATUS); return sysfs_emit(buf, 0x%08x\n, val); }注意这里是dev_get_drvdata(dev)不是全局变量。多实例时用户空间读哪个节点的sysfs属性得到的就是哪一路的寄存器状态一眼就能对比出两路的差异。我个人在实际操作中的体会是Linux驱动“支持多个设备”这件事七成靠纪律三成靠机制。机制方面内核已经提供了of_match_table、driver_data、platform_set_drvdata、devm_资源管理这些好用的工具纪律方面就是管住自己别写全局变量、别偷懒复用静态缓冲区、别在probe里搞一堆魔鬼分支。只要这两个技巧用熟了瑞芯微平台上的多实例驱动基本能一次写对后面调试会轻松很多。最后再分享一个小技巧新拿到一块开发板我第一件事就是搜驱动代码里的全局变量凡是看到static struct xxx_dev *这种带着单个设备指针的一律标记为多实例高风险点位优先重构掉省得后面踩雷。