一个驱动支持多个设备:瑞芯微平台的两个实用技巧
发布时间:2026/9/7 3:05:45来源:尧图网络
做瑞芯微平台驱动这段时间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_id的driver_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_id和i2c_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_datastatic 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: mcu36 { compatible rockchip,mcu-ctrl; reg 0x36; reset-gpios gpio3 RK_PA5 GPIO_ACTIVE_LOW; }; }; i2c1 { status okay; mcu_lite: mcu38 { compatible rockchip,mcu-lite; reg 0x38; reset-gpios gpio3 RK_PB1 GPIO_ACTIVE_LOW; }; };这里有几个匹配细节要注意。compatible字符串是精确匹配驱动侧写的rockchip,mcu-ctrl设备树里必须一字不差大小写、连字符、点号都不能错。如果一个设备树节点写了多个compatible内核会按顺序逐一匹配取第一个能匹配上的。另外reg对应的I2C从机地址不能在同一总线上重复。举个例子如果mcu_ctrl和mcu_lite都在i2c0上并且都是0x36那么第二个节点根本枚举不出来因为内核会认为地址冲突。不同I2C总线上则无所谓i2c0上的0x36和i2c1上的0x36是两个完全独立的设备。2.4 用函数指针区分行为差异参数差异好办但有些型号的差异不是“读一个寄存器用不同地址”这种级别而是初始化流程完全不同。这时候别在probe里堆if (cfg-model xxx)分支更好的做法是在配置结构体里放函数指针。你在上面已经看到了.init字段这就是函数指针的典型用法。每个型号实现自己的init函数probe里统一调用cfg-init(client)。如果某个型号不需要初始化字段置NULLprobe里判断跳过即可。这样做的好处是后续新增型号只需在对应文件里加一个init函数和一个结构体常量不需要去动probe的主逻辑代码评审时一眼就能看出影响范围。这套思路在内核源码里非常普遍比如drivers/leds、drivers/regulator、drivers/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: sensor1a { 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: sensor1b { 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-1a、sensor-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: sensor1a { 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_table和id_tableI2C总线匹配时会优先用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 sensor1a at 0x1a (-16)的日志。注意-16就是-EBUSY。同地址的设备想共存唯一的办法是挂到不同I2C总线上或修改从机硬件地址。4.3 快速确认实例是否生成的办法调多实例驱动时我最常用的确认手段就三条第一看设备是否枚举成功。ls /sys/bus/i2c/devices/列出当前所有I2C设备出现0-001a和2-001b就说明两个节点都被解析并生成了设备对象。第二看驱动是否绑定成功。ls -l /sys/bus/i2c/devices/0-001a/driver会显示驱动名如果没有driver链接说明probe没执行或失败了。第三看probe返回值。在probe函数入口加一句dev_info打印dmesg里看到两个probe日志说明都被调用到了如果只看到一个问题多半出在设备树节点没被解析如果两个都看到但后面的设备节点消失了那大概率是devm_request_threaded_irq或misc_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_MINORGPIO电平拉不动引脚被IOMUX复用成非GPIO功能在设备节点配pinctrl确认rockchip,pins正确设备能枚举但读写超时从机地址错误、I2C时钟太快、上拉电阻问题先用i2cdetect扫描地址再降频测试这套速查表里的问题我基本都在瑞芯微平台上遇到过一次。最典型的还是全局变量问题曾经在RK3568上调一个双路背光驱动两个实例的亮度总是互相影响查了半天才发现是驱动里一个static变量同时保存了两个实例的寄存器地址。改成私有数据后问题瞬间消失。6. 最后的实战体会瑞芯微平台的驱动开发说难不难说简单也不简单。设备树把硬件资源描述得明明白白内核驱动框架也把“多设备支持”的基础打好了剩下的就是看你写代码时有没有“每个实例独立”的意识。我自己最大的体会是写驱动前先想清楚哪些数据是设备相关的哪些是驱动共享的。设备相关的数据一律放进probe里分配的私有结构体共享的只放操作函数和常量表这样不管板子上挂两个还是挂八个同型号芯片驱动都不会乱。另外一个小建议调试多实例问题时不要只盯着dmesg。配合/sys/bus/i2c/devices/、/sys/kernel/debug/gpio、/proc/device-tree这几个伪文件系统一起看定位速度快很多。驱动开发很大程度上就是“内核模型 硬件规格 排查手段”三件事把这几个基本功练扎实瑞芯微也好其他平台也罢都不会太难。希望上面这两个技巧能帮你少走点弯路。
网站建设高端定制企业官网