搞Linux驱动的人应该都有同感:在瑞芯微(Rockchip)这类SoC上调驱动,做到后面真正头疼的往往不是某个外设“点不亮”,而是“一个驱动要同时伺候好几个设备”。我最早碰到这个需求是在一块RK3568的板卡上做四路ADC采集,硬件上挂了四片同型号的芯片,驱动如果写成“单设备思维”,一路正常、两路错乱、三路直接卡死,那种排查过程谁做谁知道。后来在瑞芯微Linux SDK里泡久了,慢慢整理出两个非常实用的小技巧,一个解决“一份驱动怎么区分不同型号”,一个解决“一份驱动怎么管理多个同类实例”。这篇文章就把这两个技巧展开聊聊,顺带把我在RK平台上工程落地时踩过的坑也一并写出来,希望对正在做Linux驱动、特别是嵌入式Linux驱动开发的朋友有参考价值。
1. 先搞清楚场景:驱动凭什么是“一份代码、多个设备”
很多刚入门Linux驱动的同学有个误区,觉得“一个设备一个驱动文件”,代码里用全局变量存一大坨状态也没关系。但真实项目里,驱动和设备的对应关系远比这个复杂。尤其是在瑞芯微这类内部集成了大量控制器、又经常需要外挂多颗同型号芯片的平台上,多设备支持不是“加分项”,而是“必选项”。
1.1 瑞芯微平台上最常见的三类“多设备”诉求
瑞芯微平台上的“一个驱动管多个设备”,本质上分三种场景,每一种的处理方式都有差异。
第一类是SoC自带的控制器多实例。RK系列芯片内部通常不止一路I2C、UART、SPI,比如常用的RK3568、RK3588,I2C和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里给设备的compatible,id_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_irq,remove里要手动逐一释放,稍有遗漏就会内存泄漏。而且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总线上挂两片同型号的ADC:
&i2c4 { status = "okay"; adc0: adc@48 { compatible = "rockchip,demo-adc"; reg = <0x48>; ... }; adc1: adc@4a { 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在启动时会扫描设备树,发现serial@fdd50000、serial@fe660000等等多个控制器节点,就会逐个创建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, io=0x%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 *这种带着单个设备指针的,一律标记为多实例高风险点位,优先重构掉,省得后面踩雷。