做瑞芯微平台驱动开发的朋友,对这类场景应该不陌生:同样是I2C总线,板子上焊了两片同型号的温湿度传感器;同样是SPI,主控下面挂了4片ADC;甚至RK3588这种多核SoC里,光串口控制器就好几个。如果每个实例都单独写一个驱动,那代码量直接爆炸,维护起来更是噩梦。
我最初在RK3288上做双Camera项目时就踩过这个坑,同一款Sensor,前后各一颗,愣是写了两个差不多的驱动文件,后来发现一个驱动完全能handle住。这篇文章就专门聊聊在Linux驱动开发中支持多个设备实例的两个核心技巧:怎么让一个driver匹配多个设备节点,以及probe之后怎么确保每个实例的数据不串味。这两个东西搞明白,瑞芯微平台上的多路外设驱动基本都能写得干净又稳。
1. 内容整体设计与思路拆解
1.1 为什么"一个驱动支持多个设备"是Linux驱动开发的必修课
很多刚开始写驱动的朋友,脑子里的模型还是单片机时代的样子:一个外设对应一个驱动文件,驱动里写死寄存器地址、写死中断号、写死GPIO。这套思维搬到Linux内核里,第一个问题就是没法做通用适配。
Linux的设备模型是分开的:device表示硬件实体,driver表示驱动逻辑,两边通过bus(如platform_bus、i2c_bus、spi_bus)来匹配。所以理论上,一个driver可以对应多个device,这是内核的底层设计机制决定的,不是硬凑出来的技巧。
在瑞芯微平台上,这个需求尤其明显。比如RK3566/RK3568的SDIO接口可以同时接WiFi模组和SD卡,二者共用一套控制器驱动;RK3588的I2C控制器有好几路,每路都可以接不同的外设。如果驱动不支持多实例,直接用全局变量存寄存器映射地址,第二个设备probe的时候就把第一个设备的信息冲掉了。轻则功能异常,重则内核panic,这种事我在实际调试中见过太多次。
1.2 拆解"多设备支持"的实际场景与核心难点
根据瑞芯微Linux SDK(kernel 4.19/5.10/6.1)的常见用法,多设备支持主要分两类:
一类是同总线同型号多实例。比如I2C0上挂两片AT24C02,或者SPI总线上挂两片MCP3008。这类设备在设备树里是两个不同的node,但compatible完全一样。驱动的probe会被调用两次,每次传入一组独立的platform_device/i2c_client,资源各自独立。
另一类是同型号多控制器实例。比如瑞芯微SoC内部有多个I2C控制器、多个SPI控制器、多个UART。设备树里写i2c0、i2c1、i2c2……虽然硬件IP相同,但寄存器基地址、中断号都不一样。驱动要能通过传入的struct resource或设备树属性来区分当前操作的是哪一路控制器。
核心难点在于:
- probe函数中获取的本实例资源(寄存器地址、中断号、GPIO、时钟)要存在"每个实例自己的空间"里,而不是存到全局变量。
- 数据读写、中断处理时,要从传入的参数(比如i2c_client、platform_device)反推出对应的私有数据结构。
- 设备树节点的compatible、reg、中断属性等要在probe时准确读取,不能读串。
搞清楚这几个点,两个技巧自然就浮现出来了。
2. 核心细节解析与实操要点
2.1 技巧一:用of_match_table与compatible实现一对多匹配
Linux驱动与设备节点的匹配方式有好几种,但瑞芯微平台基于设备树,最常用的是of_match_table,也就是通过设备树节点里的compatible属性来匹配。
设备树里这么写:
&i2c0 { status = "okay"; clock-frequency = <400000>; temp_sensor0: tmp117@48 { compatible = "ti,tmp117"; reg = <0x48>; }; temp_sensor1: tmp117@49 { compatible = "ti,tmp117"; reg = <0x49>; }; };两个节点compatible相同,address不同。然后驱动里只需要声明一次匹配表:
static const struct of_device_id tmp117_of_match[] = { { .compatible = "ti,tmp117" }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, tmp117_of_match); static struct i2c_driver tmp117_driver = { .probe = tmp117_probe, .remove = tmp117_remove, .id_table = tmp117_id_table, .driver = { .name = "tmp117", .of_match_table = tmp117_of_match, }, };内核的i2c-core会自动把总线上的0x48和0x49两个client分别与该driver匹配,probe执行两次,每次传入各自的struct i2c_client,其中client->addr分别是0x48和0x49,client->dev.of_node指向各自的设备树节点。
这个机制的关键点在于:of_match_table解决的是"哪些设备归我管"的问题,而真正区分"现在管的是哪个设备"的是probe传入的struct device指针,它封装了当前实例的全部上下文信息。
很多新人会犯一个错误:在probe里用of_find_node_by_name或of_find_compatible_node去"手动找"设备树节点。这非常坑,因为这类全局搜索函数返回的可能是任意一个匹配节点,多实例时就乱了。正确做法是直接使用传入的client->dev.of_node,它就是当前这个实例对应的节点,不需要你再去找。
如果你做的是纯platform驱动,比如驱动瑞芯微的某个内部外设控制器,匹配表同样适用,但要注意compatible的取名规范:
watchdog: wdt@ff848000 { compatible = "rockchip,rk3568-wdt"; reg = <0x0 0xff848000 0x0 0x100>; interrupts = <GIC_SPI 42 IRQ_TYPE_LEVEL_HIGH>; };驱动里用rockchip,rk3568-wdt去匹配即可。而如果这颗SoC有多个WDT控制器,每个控制器有独立node,同样是probe多次、每次资源不同。
2.2 技巧二:probe时把资源与私有数据绑定,禁止裸用全局变量
匹配问题解决之后,第二个关键问题就是数据隔离。我先直接说结论:一个支持多实例的驱动,原则上所有per-device的数据都不能放全局变量。
推荐做法是定义一个私有数据结构体,统称"xxx_dev"或"xxx_data",里面放这个实例专属的内容:
struct tmp117_data { struct i2c_client *client; struct regmap *regmap; struct device *dev; int irq; u32 vref_mv; struct hwmon_chip_info *chip_info; };probe时用devm_kzalloc为当前实例分配一块独立内存,把从设备树读到的资源都存进去,然后用dev_set_drvdata或i2c_set_clientdata把这个结构体和当前设备绑定:
static int tmp117_probe(struct i2c_client *client) { struct device *dev = &client->dev; struct tmp117_data *data; u32 vref_mv; int ret; data = devm_kzalloc(dev, sizeof(*data), GFP_KERNEL); if (!data) return -ENOMEM; >static int rk_xxx_probe(struct platform_device *pdev) { struct device *dev = &pdev->dev; struct rk_xxx_data *data; struct resource *res; data = devm_kzalloc(dev, sizeof(*data), GFP_KERNEL); if (!data) return -ENOMEM; >temp_sensor0: tmp117@48 { compatible = "ti,tmp117"; reg = <0x48>; label = "pa_temp"; }; temp_sensor1: tmp117@49 { compatible = "ti,tmp117"; reg = <0x49>; label = "power_temp"; };3.2 驱动框架与多实例注册过程
驱动主体是标准i2c_driver,重点是probe怎么写。先看匹配表:
static const struct i2c_device_id tmp117_id_table[] = { { "tmp117", 0 }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(i2c, tmp117_id_table); static const struct of_device_id tmp117_of_match[] = { { .compatible = "ti,tmp117" }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, tmp117_of_match); static struct i2c_driver tmp117_driver = { .driver = { .name = "tmp117", .of_match_table = tmp117_of_match, }, .probe = tmp117_probe, .remove = tmp117_remove, .id_table = tmp117_id_table, };这里同时注册了i2c_device_id和of_match_table。前者的作用是为了兼容非设备树的场景,设备树平台主要靠后者。不过要注意:如果开了CONFIG_OF,of_match_table优先级更高。
probe函数里,核心逻辑是建立实例上下文并完成硬件初始化:
static int tmp117_probe(struct i2c_client *client) { struct device *dev = &client->dev; struct tmp117_data *data; const char *label; u32 vref_mv; int ret; /* 1. 分配本实例私有数据 */ data = devm_kzalloc(dev, sizeof(*data), GFP_KERNEL); if (!data) return -ENOMEM; >static int tmp117_read(struct device *dev, enum hwmon_sensor_types type, u32 attr, int channel, long *val) { struct tmp117_data *data = dev_get_drvdata(dev); unsigned int regval; int ret; switch (attr) { case hwmon_temp_input: ret = regmap_read(data->regmap, TMP117_REG_TEMP, ®val); if (ret) return ret; *val = sign_extend32(regval, 15) * 1000 / 16; return 0; default: return -EOPNOTSUPP; } }注意这里读取时用的是data->regmap,这个regmap由regmap_init_i2c创建时已经绑定了当前实例的i2c_client,因此读的时候地址就是对应实例的地址。这就是"私有上下文"的价值所在:即使两个实例并排工作,读到0x48的就是PA温度,读到0x49的就是电源温度,寄存器操作互不干扰。
3.3 中断与异步事件的多实例处理细节
很多驱动不止做轮询读取,还要处理中断。比如TMP117支持温度告警中断,两个传感器分别把ALERT引脚接到RK3568的两个GPIO,设备树里各自声明interrupts属性:
temp_sensor0: tmp117@48 { compatible = "ti,tmp117"; reg = <0x48>; label = "pa_temp"; interrupt-parent = <&gpio3>; interrupts = <RK_PA2 IRQ_TYPE_EDGE_FALLING>; }; temp_sensor1: tmp117@49 { compatible = "ti,tmp117"; reg = <0x49>; label = "power_temp"; interrupt-parent = <&gpio3>; interrupts = <RK_PA3 IRQ_TYPE_EDGE_FALLING>; };probe里这样申请中断:
>static irqreturn_t tmp117_irq_handler(int irq, void *dev_id) { struct tmp117_data *data = dev_id; unsigned int status; regmap_read(data->regmap, TMP117_REG_CONFIG, &status); if (status & TMP117_CONFIG_ALERT) { /* 上报高温事件 */ sysfs_notify(&data->dev->kobj, NULL, "temp1_input"); } regmap_update_bits(data->regmap, TMP117_REG_CONFIG, TMP117_CONFIG_ALERT, 0); return IRQ_HANDLED; }从这里能看出多实例驱动的安全性:中断handler拿到的dev_id是probe时传入的data,指向当前中断对应的那个实例。GPIO3_PA2中断进来时,data是temp_sensor0的上下文;GPIO3_PA3中断进来时,data是temp_sensor1的上下文。如果写成全局变量dev_id,两个中断同时到达时根本无法确定操作的是哪个设备。
3.4 从sysfs验证多实例是否工作正常
驱动加载后,可以在板子上执行:
ls /sys/class/hwmon/正常会看到hwmon0和hwmon1,分别对应两片TMP117。再用:
cat /sys/class/hwmon/hwmon0/temp1_input cat /sys/class/hwmon/hwmon1/temp1_input两颗传感器的温度值会各自独立更新。这里需要说明:内核注册hwmon设备时,hwmon0/hwmon1的编号取决于枚举顺序,不是固定的。如果应用层需要固定映射,建议通过设备树alias或者在驱动里使用label属性辅助用户空间识别。也可以读取:
cat /sys/class/hwmon/hwmon0/name cat /sys/class/hwmon/hwmon1/name都显示tmp117,然后用temp1_input和label关联:
cat /sys/class/hwmon/hwmon0/label cat /sys/class/hwmon/hwmon1/label如果label在probe里保存且通过hwmon注册时的chip_info提供,这里就能看到pa_temp和power_temp,方便应用层精确定位。
实测中我遇到过一个有意思的现象:有时候hwmon0对应的是addr=0x49的传感器,因为内核枚举顺序和设备树地址编址有关,不是严格按照reg排序。所以应用层千万别写死hwmonX的编号,用label或自定义属性来识别才是稳妥做法。
4. 常见问题与排查技巧实录
4.1 两个节点只probe了一次,另一个设备没绑定成功
这个是最常见的。排查思路按顺序来:
先看设备树节点有没有被正确识别。在板子上:
ls /sys/bus/i2c/devices/如果只看到0-0048,没有0-0049,说明设备树枚举阶段就漏了一个。大概率是I2C节点里device的status写错了,或者reg地址冲突、总线上实际没焊、被内核跳过。
如果两个都在,但只有其中一个绑定了驱动:
cat /sys/bus/i2c/devices/0-0049/name ls -l /sys/bus/i2c/devices/0-0049/driver如果name是tmp117但driver是空的,说明kernel的of_match_table没有匹配上。检查compatible字符串是不是完全一致,特别注意设备树里的写法是"ti,tmp117"还是"tmp117",多一个前缀少一个前缀都匹配不上。
还有一个隐蔽问题:如果驱动里of_match_table没写MODULE_DEVICE_TABLE宏,且驱动编译成模块(.ko),那么modprobe的时候模块不会自动加载,导致设备存在但没有任何驱动被绑定。这是新手最容易忽略的坑。
4.2 两个设备共用一个中断号导致的混乱
某些场景下,两个设备的中断信号会被设计成并联到同一个GPIO。这种情况下,两个实例的client->irq会是同一个中断号。如果probe各自request_irq同一个中断号,第二个会失败,或者必须用IRQF_SHARED共享标志。实测中如果硬件设计没法改,可以用中断状态寄存器来区分是谁触发的,但一定要申请共享中断:
ret = devm_request_threaded_irq(dev,>dev_info(data->dev, "data=%px regs=%px addr=0x%02x\n", data,>static const struct of_device_id rk_xxx_of_match[] = { { .compatible = "rockchip,rk3568-xxx", .data = (void *)RK3568 }, { .compatible = "rockchip,rk3588-xxx", .data = (void *)RK3588 }, { /* sentinel */ } };这条匹配表里可以携带.data字段,probe时通过of_device_get_match_data(dev)拿到是哪个SoC版本,从而做差异化的寄存器和初始化。这又是多设备匹配的一个进阶技巧:同一个驱动支持不同型号的同类芯片,在瑞芯微不同芯片平台移植时特别有用。
实测中,这种方式省掉了大量#ifdef CONFIG_SOC_RK3568之类的条件编译,代码干净很多。我后来在RK3568和RK3588上复用同一份GPIO扩展驱动就是用的这个模式,改动量很小。
5. 一点个人体会与延伸建议
写驱动这些年,最大的体会是:Linux设备模型本身就是一套精密的"面向对象"设计,device是对象实例,driver是类方法,匹配表是虚函数表。你顺着这个思路去写,多实例支持就是水到渠成的事;逆着来,非要用全局变量硬扛,越到后期越痛苦。
瑞芯微平台因为SoC集成度高、外设控制器多,多实例场景特别密集。无论是I2C、SPI、UART,还是内部的PWM、ADC、WDT,都会用到同样的套路。建议新手多读几遍SDK里现成的多实例驱动,比如pinctrl-rockchip.c和i2c-rk3x.c,结合这里的两个技巧去对照理解,很快就能建立起正确的内核驱动开发心智模型。
另外还有一个非常实用的调试技巧:如果多实例驱动运行中怀疑数据串了,在关键路径加一个dump_stack或者ftrace,确认当前上下文绑定的device节点到底是谁。我见过不少诡异问题,最后定位下来都是中断上下文拿错了data指针。只要每个入口都从参数反推实例,而不是从全局量猜实例,这类问题基本能避免。
最后再分享一个配置层面的习惯:多实例的设备树节点尽量用有意义的label,比如"dsi0_backlight"、"dsi1_backlight",比单纯用reg地址可读性强太多。设备树是给人看的,也是给内核用的,注释和命名规范一点,后面接手的人会少很多无谓的排查时间。