一个驱动支持多个设备:瑞芯微平台的两个实用技巧
2026/9/7 3:05:43 网站建设 项目流程

做瑞芯微平台驱动这段时间,RK3288、RV1126、RK3568、RK3588都摸过一轮,最常被问到的问题反而不是怎么移植某个子系统,而是那句让人有点哭笑不得的:“我这个驱动只支持一个设备,板子上要挂两个同型号芯片,怎么办?”其实“一个驱动支持多个设备”这件事,内核在设备模型层面已经替你铺好了路,真正卡人的往往是思维惯性。今天就把我在瑞芯微平台上实际用过的两个小技巧拆开聊,一个管“不同型号”,一个管“同型号多路”,都是可以直接抄走的套路。

这篇内容更偏向Linux驱动开发的工程实战,适合正在调设备树的BSP工程师,也适合刚入门内核驱动、被“一个驱动怎么同时伺候好几个设备”问懵的朋友。我会从原理讲到代码,把设备树和驱动侧的写法一起对齐,最后附上踩坑记录,尽量让你看完就能在自己板子上复现。

1. 先搞清楚一个驱动为什么能支持多个设备

1.1 内核设备模型的基本逻辑

Linux的设备管理核心是“设备-总线-驱动”三件套。一个驱动注册到总线上之后,并不会只服务某一个具体的设备,而是由总线根据匹配规则,把驱动挂到所有符合条件的设备上。也就是说,“一个驱动对应多个设备”不是靠什么魔法实现的,而是内核与生俱来的设计。

这里必须把两个概念分清楚:驱动是一个“方法集合”,它描述的是“我这类设备该怎么操作”;而设备是“资源实例”,它描述的是“这块板子上到底有哪些硬件、寄存器在哪、中断是哪条”。驱动和设备的匹配,由bus层负责。比如platform总线匹配设备树节点里的compatible字符串,I2C总线会同时匹配compatible和i2c device id表。

所以,真正决定“能不能支持多个设备”的,不是驱动注册方式,而是你的驱动代码里有没有把“设备相关的东西”和“驱动共享的东西”分开。如果你在probe里用了一堆静态全局变量保存寄存器地址、中断号、设备指针,那么第二个设备probe的时候,就会把第一个设备的数据覆盖掉,表现就是“明明两个设备都枚举出来了,却只有一个能正常工作”,或者“两个设备交叉干扰”。

瑞芯微平台上这类问题尤其常见。很多人拿到一个BSP给的驱动模板,直接复制一份改个名字,两个同型号外设就搞了两个驱动文件,短期能用,但以后改一处要同步另一处,迟早出事。正确的思路是让一份驱动具备实例化能力,被总线调用多少次probe,就创建多少个独立的私有数据对象。

1.2 我要解决的两种“多设备”场景

实际项目里“一个驱动支持多个设备”通常对应两种完全不同的诉求,处理方式也不一样。

第一种:同一份驱动,要适配同厂商的多个型号,比如一颗IO扩展芯片有A版本和B版本,寄存器地址不一样、默认配置不一样,但操作流程几乎相同。这时候你不想维护两个驱动文件,更希望能有一张“型号-参数对照表”,probe时根据实际硬件型号取对应参数。

第二种:板子上挂了两个完全相同的芯片,比如两路I2C温度传感器、两路LCD背光控制器。每个芯片有自己独立的I2C地址、独立的中断引脚、独立的复位GPIO,但操作逻辑是一模一样的。这时候驱动只需要写一份,靠设备树节点区分实例。难点在于驱动代码不能再用全局变量,所有状态都得放进每个实例自己的私有数据结构里。

下面的两个技巧,就是分别解决这两种情况。

2. 技巧一:用驱动ID表适配不同型号

2.1 复制驱动的坑,我建议你别踩

我见过不少人在瑞芯微平台上同时接了两款同系列触摸屏IC,代码是从原厂驱动里复制两份,然后各自改probe里的寄存器初始化序列。这种做法的隐患在于:一旦后续要改一个公共的函数(比如I2C读取超时时间),你得同时改两个文件;如果两个文件在后续版本里被不同的人维护,迟早会出现行为分叉。内核社区里处理这种问题的标准做法,是用of_device_id.data字段或者i2c_device_iddriver_data字段,把“型号差异”变成“数据差异”,一份代码通吃。

这个思路其实特别接地气。你可以把驱动理解成一个餐厅的厨师,型号差异就是菜单上的不同菜品。厨师不用为每道菜单独学一套厨艺,他只需要知道每道菜的配方。配方就是那张配置表,菜谱变化时改配方就行,不用换厨师。

2.2 配置结构体与probe取数

具体做法是定义一个描述型号差异的结构体,然后把它挂到匹配表上。以I2C驱动为例,先看结构体的定义:

/* 每个型号各不相同的东西,全部塞进这个结构体 */ struct mcu_cfg { const char *model; u8 run_cmd; u8 sleep_cmd; u16 poll_interval_ms; int (*init)(struct i2c_client *client); }; static int mcu_a_init(struct i2c_client *client) { /* 型号A特有的初始化流程 */ return 0; } static const struct mcu_cfg mcu_ctrl_cfg = { .model = "mcu-ctrl", .run_cmd = 0x01, .sleep_cmd = 0x02, .poll_interval_ms = 10, .init = mcu_a_init, }; static const struct mcu_cfg mcu_lite_cfg = { .model = "mcu-lite", .run_cmd = 0x10, .sleep_cmd = 0x20, .poll_interval_ms = 50, .init = NULL, };

然后分别放到of_device_idi2c_device_id两张表里:

static const struct of_device_id mymcu_of_match[] = { { .compatible = "rockchip,mcu-ctrl", .data = &mcu_ctrl_cfg }, { .compatible = "rockchip,mcu-lite", .data = &mcu_lite_cfg }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, mymcu_of_match); static const struct i2c_device_id mymcu_id_table[] = { { "mcu-ctrl", (kernel_ulong_t)&mcu_ctrl_cfg }, { "mcu-lite", (kernel_ulong_t)&mcu_lite_cfg }, { } }; MODULE_DEVICE_TABLE(i2c, mymcu_id_table); static struct i2c_driver mymcu_driver = { .driver = { .name = "rockchip-mymcu", .of_match_table = mymcu_of_match, }, .probe = mymcu_probe, .id_table = mymcu_id_table, }; module_i2c_driver(mymcu_driver);

probe里取配置数据时,有一个容易忽略的细节:I2C设备可能是通过设备树匹配,也可能是通过古老的board info注册的,两种情况下id参数的行为不完全一样。最稳妥的写法是优先用id->driver_data,如果拿不到再退回device_get_match_data

static int mymcu_probe(struct i2c_client *client, const struct i2c_device_id *id) { const struct mcu_cfg *cfg; struct mymcu_dev *dev; if (id) cfg = (const struct mcu_cfg *)id->driver_data; else cfg = device_get_match_data(&client->dev); if (!cfg) return -ENODEV; dev = devm_kzalloc(&client->dev, sizeof(*dev), GFP_KERNEL); if (!dev) return -ENOMEM; dev->cfg = cfg; dev->client = client; i2c_set_clientdata(client, dev); if (cfg->init) { ret = cfg->init(client); if (ret) return ret; } /* 后续读写都通过 dev->cfg 来区分型号 */ return 0; }

这样新增一个型号时,你要做的事情就是:加一个mcu_cfg常量、在两张id表里各加一行、在设备树节点里把compatible改成新型号字符串。驱动主流程一行都不用动。

2.3 设备树侧如何配合

瑞芯微上的设备树节点一般长这样:

&i2c0 { status = "okay"; clock-frequency = <400000>; mcu_ctrl: mcu@36 { compatible = "rockchip,mcu-ctrl"; reg = <0x36>; reset-gpios = <&gpio3 RK_PA5 GPIO_ACTIVE_LOW>; }; }; &i2c1 { status = "okay"; mcu_lite: mcu@38 { compatible = "rockchip,mcu-lite"; reg = <0x38>; reset-gpios = <&gpio3 RK_PB1 GPIO_ACTIVE_LOW>; }; };

这里有几个匹配细节要注意。compatible字符串是精确匹配,驱动侧写的"rockchip,mcu-ctrl",设备树里必须一字不差,大小写、连字符、点号都不能错。如果一个设备树节点写了多个compatible,内核会按顺序逐一匹配,取第一个能匹配上的。

另外,reg对应的I2C从机地址不能在同一总线上重复。举个例子,如果mcu_ctrlmcu_lite都在i2c0上,并且都是0x36,那么第二个节点根本枚举不出来,因为内核会认为地址冲突。不同I2C总线上则无所谓,i2c0上的0x36和i2c1上的0x36是两个完全独立的设备。

2.4 用函数指针区分行为差异

参数差异好办,但有些型号的差异不是“读一个寄存器用不同地址”这种级别,而是初始化流程完全不同。这时候别在probe里堆if (cfg->model == xxx)分支,更好的做法是在配置结构体里放函数指针。

你在上面已经看到了.init字段,这就是函数指针的典型用法。每个型号实现自己的init函数,probe里统一调用cfg->init(client)。如果某个型号不需要初始化,字段置NULL,probe里判断跳过即可。这样做的好处是,后续新增型号只需在对应文件里加一个init函数和一个结构体常量,不需要去动probe的主逻辑,代码评审时一眼就能看出影响范围。

这套思路在内核源码里非常普遍,比如drivers/ledsdrivers/regulatordrivers/mfd里的驱动,几乎都是“一张配置表 + 函数指针”的套路。瑞芯微的很多外设驱动也是这么组织的。你如果愿意翻开drivers目录下任意一个支持多家芯片的驱动,会发现骨架跟我上面写的几乎一致。

3. 技巧二:一个驱动服务同型号多个实例

3.1 核心:别碰全局变量

如果说技巧一解决的是“型号多”,那技巧二解决的就是“数量多”。板子上两颗同型号传感器,I2C地址不同,中断脚不同,复位脚不同,但驱动逻辑完全一样。这时候最自然的方式是设备树里写两个节点,驱动只写一份,让probe被调用两次。

但很多人第一步就栽了。他们习惯在驱动文件顶部写几个static变量来保存当前设备的状态,比如:

static struct i2c_client *g_client; static struct gpio_desc *g_reset_gpio;

第一个设备probe时,g_client指向设备A;第二个设备probe时,g_client改成设备B。然后中断回调里用g_client去读寄存器,结果每次中断都在操作设备B。更隐蔽的是,如果两个设备的I2C地址恰好一样(在不同总线上),你甚至会看到“设备A的状态值突然跑到设备B里”这种诡异现象。

正确做法是:每次probe都通过devm_kzalloc分配一个独立的私有数据结构,把一个实例需要的所有信息都放进去。这个结构体就是“每个设备的记忆”,驱动里的公共函数通过它来区分当前操作的是谁。

3.2 多实例驱动的完整代码骨架

下面是一个典型的I2C多实例驱动骨架,我用一个虚构的rockchip,sensor传感器为例。设备树里两个节点:

&i2c0 { status = "okay"; sensor_0: sensor@1a { compatible = "rockchip,sensor"; reg = <0x1a>; reset-gpios = <&gpio3 RK_PA5 GPIO_ACTIVE_LOW>; interrupts = <&gpio3 RK_PA6 IRQ_TYPE_EDGE_RISING>; }; }; &i2c2 { status = "okay"; sensor_1: sensor@1b { compatible = "rockchip,sensor"; reg = <0x1b>; reset-gpios = <&gpio3 RK_PB1 GPIO_ACTIVE_LOW>; interrupts = <&gpio3 RK_PB2 IRQ_TYPE_EDGE_RISING>; }; };

驱动侧,私有数据结构定义如下:

struct sensor_dev { struct i2c_client *client; struct gpio_desc *reset_gpio; struct mutex lock; int irq; u32 index; u16 reg_conf; };

probe函数核心逻辑:

static int sensor_probe(struct i2c_client *client, const struct i2c_device_id *id) { struct device *dev = &client->dev; struct sensor_dev *sdata; int ret; sdata = devm_kzalloc(dev, sizeof(*sdata), GFP_KERNEL); if (!sdata) return -ENOMEM; sdata->client = client; sdata->irq = client->irq; mutex_init(&sdata->lock); i2c_set_clientdata(client, sdata); /* 复位引脚,每个节点独立配置 */ sdata->reset_gpio = devm_gpiod_get(dev, "reset", GPIOD_OUT_HIGH); if (IS_ERR(sdata->reset_gpio)) return PTR_ERR(sdata->reset_gpio); /* 注册中断,注意最后一个参数传sdata自己 */ if (sdata->irq > 0) { ret = devm_request_threaded_irq(dev, sdata->irq, NULL, sensor_irq_thread, IRQF_TRIGGER_RISING | IRQF_ONESHOT, "sensor", sdata); if (ret) return ret; } dev_info(dev, "sensor probed, irq=%d\n", sdata->irq); return 0; }

中断线程函数里通过传入的私有数据操作对应的实例:

static irqreturn_t sensor_irq_thread(int irq, void *data) { struct sensor_dev *sdata = data; u8 val; int ret; mutex_lock(&sdata->lock); ret = i2c_smbus_read_byte_data(sdata->client, sdata->reg_conf); if (ret < 0) { mutex_unlock(&sdata->lock); return IRQ_HANDLED; } val = ret; /* 处理数据... */ mutex_unlock(&sdata->lock); return IRQ_HANDLED; }

这里有个容易忽略的点:devm_request_threaded_irq最后一个参数是传给中断处理函数的void *data,一定要传sdata,不能用client再转一层。因为中断发生时不保证还能安全地从client反查私有数据,直接传struct sensor_dev *最干净。

remove函数里,由于用了devm_系列接口,内存和GPIO的释放都由内核在设备移除时自动完成,我们只需要把mutex销毁掉:

static void sensor_remove(struct i2c_client *client) { struct sensor_dev *sdata = i2c_get_clientdata(client); mutex_destroy(&sdata->lock); }

3.3 中断、锁与设备节点怎么处理

多实例场景下,锁要特别注意。你要保证每个实例有一把独立的锁,而不是所有实例共用一把全局锁。如果把mutex定义成static,那么设备A在读寄存器期间,设备B的中断处理只能干等,两个设备就互相拖累,性能断崖式下跌。放私有结构体里,每个实例各锁各的,互不干扰。

如果用户空间需要同时访问多个设备,建议用miscdevice给每个实例注册独立的设备节点。miscdevice支持动态次设备号,注册时指定MISC_DYNAMIC_MINOR即可。在probe里对每个实例都调用一次misc_register,用户层就能看到/dev/sensor0/dev/sensor1两个节点。

注意miscdevice结构体里的name字段不能重复,如果两个实例都叫sensor,第二个注册会失败。简单做法是name里带上I2C总线号和地址,比如sensor-0-1asensor-2-1b,既不会冲突,用户看到节点名也能反推是哪个总线上的设备。

3.4 瑞芯微平台下的额外注意点

瑞芯微平台,尤其是RK3568、RK3588这类芯片,引脚复用(IOMUX)非常严格。你在设备树里写了reset-gpios,并不代表这个引脚真的变成了GPIO功能,还要确认对应节点的pinctrl配置。如果某个引脚默认被复用成I2C、UART或PWM功能,devm_gpiod_get可能不会报错,但拉电平根本没效果。

建议在每个设备节点里显式声明pinctrl:

/* 在pinctrl节点里定义 */ &pinctrl { sensor { sensor_reset_pin: sensor-reset-pin { rockchip,pins = <3 RK_PA5 RK_FUNC_GPIO &pcfg_pull_up>; }; }; }; /* 设备节点引用 */ &i2c0 { sensor_0: sensor@1a { compatible = "rockchip,sensor"; reg = <0x1a>; pinctrl-names = "default"; pinctrl-0 = <&sensor_reset_pin>; reset-gpios = <&gpio3 RK_PA5 GPIO_ACTIVE_LOW>; }; };

这里rockchip,pins的第一个参数是GPIO bank号,第二个是引脚号,第三个是功能选择,第四个是上下拉配置。RK3568的bank号从0开始,gpio3 RK_PA5对应的就是GPIO3_A5。如果你不确定引脚编号,可以在内核文档或SDK的dtsi里搜RK_PA5宏定义,不同芯片的宏可能略有差异。

4. 设备树侧的配套细节与避坑

4.1 compatible匹配的细节

设备树节点和驱动匹配,本质是字符串比较。很多新手觉得“差不多就行了”,结果驱动就是probe不起来。这里的规则非常死板:compatible的第一个字符串必须与驱动of_device_id表里的某一项完全一致,差一个字符都不行。

一个比较隐蔽的坑是:of_device_id表末尾必须有哨兵项{ /* sentinel */ },否则内核遍历可能越界。另一个坑是,有的驱动同时设置了of_match_tableid_table,I2C总线匹配时会优先用of_match_table去匹配设备树节点的compatible,如果of_match_table里没有对应条目,但id_table里有,仍然可能导致匹配失败。所以两张表都要维护,别只填一张。

4.2 status、地址和总线资源

设备树节点默认是使能的,但它的父节点如果status = "disabled",子节点写了status = "okay"也没用。瑞芯微的SDK里,很多I2C控制器节点默认是disabled的,需要你在板级dts里打开。所以排查“第二个设备没枚举出来”时,第一件事不是看驱动,而是看父总线是否处于可用状态。

I2C从机地址冲突也是高频问题。内核在注册I2C设备时,如果发现同一总线上已经存在相同地址的设备,会直接放弃注册,并且dmesg里会打印类似i2c i2c-0: Failed to register i2c client sensor@1a at 0x1a (-16)的日志。注意-16就是-EBUSY。同地址的设备想共存,唯一的办法是挂到不同I2C总线上,或修改从机硬件地址。

4.3 快速确认实例是否生成的办法

调多实例驱动时,我最常用的确认手段就三条:

第一,看设备是否枚举成功。ls /sys/bus/i2c/devices/列出当前所有I2C设备,出现0-001a2-001b就说明两个节点都被解析并生成了设备对象。

第二,看驱动是否绑定成功。ls -l /sys/bus/i2c/devices/0-001a/driver会显示驱动名,如果没有driver链接,说明probe没执行或失败了。

第三,看probe返回值。在probe函数入口加一句dev_info打印,dmesg里看到两个probe日志,说明都被调用到了;如果只看到一个,问题多半出在设备树节点没被解析;如果两个都看到但后面的设备节点消失了,那大概率是devm_request_threaded_irqmisc_register阶段出错,看dmesg里的error信息就能定位。

5. 我整理过的排查速查表

现象典型原因排查手段
两个节点只有一个出现probe日志第二个节点status disabled、compatible不匹配、父I2C总线未使能检查设备树,cat /proc/device-tree确认节点是否存在
两个节点都probe,但一个立即失败I2C地址冲突、中断号非法、GPIO被复用dmesg查看-EBUSY或-EINVAL,检查reg和interrupts
两个设备都能工作,但数据互相串驱动用了static全局变量保存实例状态把客户端指针、GPIO、锁全部移到私有结构体
中断触发后操作的是另一个设备中断回调里硬编码了某个实例指针中断注册时把私有数据作为data参数传入
用户层只能打开一个设备节点miscdevice的name重复或minor冲突确保name包含总线号或地址,使用MISC_DYNAMIC_MINOR
GPIO电平拉不动引脚被IOMUX复用成非GPIO功能在设备节点配pinctrl,确认rockchip,pins正确
设备能枚举但读写超时从机地址错误、I2C时钟太快、上拉电阻问题先用i2cdetect扫描地址,再降频测试

这套速查表里的问题,我基本都在瑞芯微平台上遇到过一次。最典型的还是全局变量问题,曾经在RK3568上调一个双路背光驱动,两个实例的亮度总是互相影响,查了半天才发现是驱动里一个static变量同时保存了两个实例的寄存器地址。改成私有数据后,问题瞬间消失。

6. 最后的实战体会

瑞芯微平台的驱动开发,说难不难,说简单也不简单。设备树把硬件资源描述得明明白白,内核驱动框架也把“多设备支持”的基础打好了,剩下的就是看你写代码时有没有“每个实例独立”的意识。我自己最大的体会是:写驱动前先想清楚哪些数据是设备相关的,哪些是驱动共享的。设备相关的数据一律放进probe里分配的私有结构体,共享的只放操作函数和常量表,这样不管板子上挂两个还是挂八个同型号芯片,驱动都不会乱。

另外一个小建议:调试多实例问题时,不要只盯着dmesg。配合/sys/bus/i2c/devices//sys/kernel/debug/gpio/proc/device-tree这几个伪文件系统一起看,定位速度快很多。驱动开发很大程度上就是“内核模型 + 硬件规格 + 排查手段”三件事,把这几个基本功练扎实,瑞芯微也好,其他平台也罢,都不会太难。希望上面这两个技巧能帮你少走点弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询