尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Linux驱动多设备支持:of_device_id匹配与实例私有数据管理

Linux驱动多设备支持:of_device_id匹配与实例私有数据管理 1. 瑞芯微平台上最常见的多设备驱动翻车现场如果你在瑞芯微平台上写过Linux驱动大概率碰到过这种场景板子上接了不止一个同型号设备两个触摸屏、四颗温湿度传感器或者两块同规格Codec驱动加载之后只有最后注册的那个能工作前面的设备要么找不到节点要么一读写就崩。这个问题在RK3568、RV1126项目里我都踩过而且翻车翻得非常规律。我在RK3568上调试过一个多路采集板I2C0和I2C1上各挂一颗同型号温湿度传感器。第一版驱动写得很“朴素”全局变量保存i2c_client指针read函数里直接用全局指针发命令。测试结果很迷惑——只有I2C1上的传感器能上报数据I2C0那颗“好像不工作”但单独把I2C0的设备挂上去又一切正常。后来打了一串日志才看明白probe被调用了两次第二次把全局指针覆盖成了第二颗传感器第一颗的命令全发到了第二颗头上。另一个项目更典型RK3568的商显主板上触摸屏有A、B两个型号寄存器布局不同但功能完全一样。一开始我想写两套驱动SDK里多两个文件维护两份差不多的代码怎么看都别扭。后来意识到这类问题其实都有成熟套路一个驱动完全可以同时支持多个型号也可以在多个实例之间井水不犯河水。把这两个场景放在一起你会发现“支持多个设备”本质上是两件事匹配层面如何让一套驱动代码同时识别多种型号/兼容设备并根据型号适配不同参数实例层面当一个驱动被多次probe时如何保证每个设备实例都有自己独立的状态、节点和生命周期互不干扰。问题维度要解决的核心对应手段匹配多个型号不同compatible怎么对到不同配置of_device_id data字段管理多个实例同一驱动多次probe后状态不串每实例私有结构体 动态设备节点这两件事都有通用答案下面分开讲。1.1 现象第二个设备一注册第一个设备就“瞎了”这不是驱动没配对的问题而是驱动状态被覆盖的问题。内核的platform_bus或i2c子系统在扫描到一个设备节点时就会调用一次驱动probe。你在设备树里写了几个节点probe就会被调用几次。如果驱动内部只有一个全局数据结构那每次probe都会把上一次写入的状态冲掉最终所有IO操作全部落在最后注册的实例上。更隐蔽的是这种覆盖不会报错日志看起来一切都正常只有当你同时操作两个设备时才会发现数据串了。1.2 问题拆解支持多个设备到底是哪两件事在进入具体代码之前先把思路理清。一个驱动要支持多个设备必须满足两个约束一是能“认”多种硬件型号这是of_match_table等匹配机制的事二是能“管”多个同时存在的实例这是实例化私有数据和设备节点的事。两个约束互相独立可以叠加使用。下面第2节讲匹配第3节讲实例第4节给瑞芯微平台上的落地验证清单。2. 第一个技巧一套驱动匹配多个型号核心在 of_device_id 的 data 字段2.1 设备树匹配机制compatible 不是随便写的Linux平台驱动在设备树体系中是靠driver.driver.of_match_table里的of_device_id数组和设备树节点的compatible属性完成绑定的。当内核遍历设备树创建platform_device或I2C设备时总线层会拿节点的compatible字符串去匹配驱动的of_device_id表匹配成功就调用probe。这里最大的一个误解是很多人以为compatible只是“驱动里写一个字符串设备树里相同就行”。实际上它包含两层含义。第一层是身份厂商前缀加型号例如rockchip,rk3568-i2c里的rockchip是厂商rk3568-i2c是模块型号第二层是兼容性一个节点可以写多个compatible字符串表示该设备兼容多个标准协议或型号。举个例子瑞芯微SDK的设备树里经常能看到类似写法i2c0 { compatible rockchip,rk3568-i2c, rockchip,rk3399-i2c; };这个节点声明自己同时兼容rk3568和rk3399两代I2C控制器的驱动逻辑。驱动侧如果只写了rockchip,rk3568-i2c它也能匹配这个节点如果只写了rockchip,rk3399-i2c同样能匹配。这就是设备树层面“多兼容”的匹配原则。新手最容易犯的错是在设备树里写一大堆compatible字符串驱动侧只有一个compatible加载后怎么都没反应或者反过来驱动侧写了一大堆设备树只写一个结果匹配优先级不符合预期。实际上匹配逻辑是只要节点compatible数组里的任意一个字符串和of_match_table里任意一项相等就算匹配成功。多字符串之间是并行关系并不是数组里第一个字符串优先级最高。2.2 用 data 字段携带不同型号的参数表明确了compatible是入口之后问题变成设备匹配上了驱动怎么知道自己该用哪套参数最直接的想法是在probe里用of_device_is_compatible()判断当前设备树节点是不是某个型号然后分支处理。这种做法能用但代码一旦扩展就容易变成一坨if-else而且每加一个型号就要改probe。比较优雅的做法是利用of_device_id的data字段在匹配表里就把型号和参数结构体绑在一起。struct mychip_cfg { const char *model; u32 reg_version; u32 max_channels; bool need_soft_reset; }; static const struct mychip_cfg mychip_a_cfg { .model A, .reg_version 0x10, .max_channels 4, .need_soft_reset false, }; static const struct mychip_cfg mychip_b_cfg { .model B, .reg_version 0x20, .max_channels 8, .need_soft_reset true, }; static const struct of_device_id mychip_of_match[] { { .compatible mycorp,mychip-a, .data mychip_a_cfg }, { .compatible mycorp,mychip-b, .data mychip_b_cfg }, { /* sentinel */ }, }; MODULE_DEVICE_TABLE(of, mychip_of_match); static int mychip_probe(struct platform_device *pdev) { const struct mychip_cfg *cfg; cfg of_device_get_match_data(pdev-dev); if (!cfg) return -EINVAL; dev_info(pdev-dev, probe %s, max_channels%d\n, cfg-model, cfg-max_channels); /* 后续都用 cfg-xxx 访问型号差异 */ return 0; }of_device_get_match_data()会根据当前设备树的compatible在of_match_table里找到对应项并把那一项的data指针返回给你。这样A型号和B型号的差异被封装成了纯数据参数probe代码里不需要任何型号判断。后面如果再出C型号只需要加一个mychip_c_cfg和一行匹配表probe代码一行不用改。参数结构体建议用const static修饰它本质上是所有实例共享的只读模板。如果某个实例需要保存运行时的变化状态请放到后面要讲的“每实例私有数据”里不要往这个只读模板里塞。两者职责不同混在一起后面调试会非常痛苦。这种“匹配表数据”写法在Linux内核驱动里非常常见。瑞芯微的不少SoC内部外设驱动也是这么写的同一控制器IP在不同型号芯片上有寄存器差异时经常是多个compatible对应同一套匹配表data字段指向各自差异配置。2.3 别忘了 MODULE_DEVICE_TABLE 导出和 id_table 兜底很多人在调试模块驱动时遇到一个诡异问题设备树里compatible写得完全正确驱动也编译进内核了或者insmod了但probe就是不跑。查到最后往往发现MODULE_DEVICE_TABLE(of, mychip_of_match);这行没写。这行的作用是把of_match_table导出到模块的alias信息里。设备树环境下的自动匹配虽然不依赖alias但模块加载器udev/mdev/modprobe靠alias才能建立“设备树节点compatible - 哪个ko文件”的映射。如果驱动是编译进内核的少了这行还影响不大如果打算在调试阶段用模块方式加载少了这行就会出现设备树明明有节点但modprobe就是不知道加载哪个模块的情况。验证方法很简单modinfo mychip.ko | grep alias能看到类似aliasof:N...T...C...的输出说明导出成功。另外还要提一下platform_device_id表。设备树并不是平台设备唯一的描述方式在一些非设备树的旧平台或早期验证环境里平台设备通过name匹配驱动走的是platform_device_id表static const struct platform_device_id mychip_id_table[] { { mychip-a, (kernel_ulong_t)mychip_a_cfg }, { mychip-b, (kernel_ulong_t)mychip_b_cfg }, { }, }; MODULE_DEVICE_TABLE(platform, mychip_id_table);新内核的platform_match会优先尝试of_match_table再尝试platform_device_id表。所以对于一个既可能挂在设备树环境、又可能挂在传统平台环境的驱动两张表都写上是更稳妥的做法。probe里取配置时做一下区分就行static int mychip_probe(struct platform_device *pdev) { const struct mychip_cfg *cfg; const struct platform_device_id *id; if (pdev-dev.of_node) { cfg of_device_get_match_data(pdev-dev); } else { id platform_get_device_id(pdev); cfg id ? (const struct mychip_cfg *)id-driver_data : NULL; } if (!cfg) return -EINVAL; /* 继续初始化 */ return 0; }这段代码看着多其实也就几行换来的是不同平台环境下的兼容性。瑞芯微的SDK虽然以设备树为主但内核里这种兼容写法并不少见尤其是那些从老平台沿用下来的驱动。2.4 这个技巧的边界什么情况不该硬凑of_device_id.data解决了“多个型号、一套驱动”的问题但它不是万能胶。我见过有人把I2C接口的触摸屏和SPI接口的触摸屏塞进同一个驱动probe里用device_property_read_bool()分出两套完全不同的逻辑代码比两个驱动加起来还长最后维护自己都想吐。我的经验是两个设备如果在总线类型、寄存器访问方式、中断处理流程上有本质差异不应该硬凑成一个驱动。它们只是“碰巧同名”不是“同一个家族”。这种情况下宁可拆成两个驱动或者做成“核心层总线适配层”的架构把所有公共逻辑放核心层core把I2C、SPI各自入口放在各自的小文件里。这样反而符合内核里常见的驱动拆分思路。反过来说of_device_id.data适合的是型号之间有共性只有参数和少量初始化流程不同。比如不同的寄存器版本号、不同的通道数、是否需要额外复位、不同型号对某个特性的开关。这种差异用数据表来表示probe代码只处理“共性逻辑查表拿差异”维护成本最低。3. 第二个技巧每个设备实例一份私有数据彻底告别全局变量3.1 全局结构体怎么了一次probe一次覆盖如果说第2节解决的是“匹配多个型号”这一节解决的是“同一个型号多个实例”。假设一个板子上挂了3颗同型号传感器probe会被调用3次。我最初的错误写法很典型static struct my_dev g_dev; /* 错的 */ static int mychip_probe(struct i2c_client *client) { g_dev.client client; ... return 0; }第二次probe执行完g_dev.client就指向第二颗设备第三次执行完前两颗都彻底消失了。更危险的是如果第一颗设备被remove驱动如果没有及时清理g_dev.client依旧残留用户态再打开设备时就是访问悬空指针模块崩溃还是小事内核态野指针操作可能把整个系统打挂。这类问题在支持多设备的驱动里是最常见的入门坑。根本原因是把“驱动代码”和“设备实例”混为一谈了。内核的驱动模型从来都是“一份代码多个设备实例”probe被调用几次你的设备状态就应该有几个独立副本。每个副本的生命周期要跟对应struct device绑定而不是跟模块生命周期绑定。3.2 实例私有数据的标准姿势正确做法是在每次probe里分配一份私有结构体保存这个实例需要的全部运行时状态然后通过dev_set_drvdata挂到device上。以i2c_driver为例struct my_dev { struct device *dev; struct i2c_client *client; struct mutex lock; struct cdev cdev; dev_t devno; }; static int mychip_probe(struct i2c_client *client) { struct my_dev *d; d devm_kzalloc(client-dev, sizeof(*d), GFP_KERNEL); if (!d) return -ENOMEM; d-dev client-dev; d-client client; mutex_init(d-lock); i2c_set_clientdata(client, d); /* 注册字符设备/miscdevice 节点见 3.3 */ return 0; } static void mychip_remove(struct i2c_client *client) { struct my_dev *d i2c_get_clientdata(client); /* 先删设备节点/cdev再清理其他资源 */ }devm_kzalloc是devres机制的一部分设备移除时会自动释放这块内存。这里有个很重要的经验能用devm接口就用devm接口它能在probe中途出错时自动回滚已经申请的资源省掉一大片goto错误处理代码。不过要注意devm只接管devm_开头的资源。你自己手动misc_register、device_create、alloc_chrdev_region出来的东西devm都不会帮你清理必须在remove里手动做。如果你写的是platform_driver和i2c_driver的区别只是入口参数不同。probe参数换成struct platform_device *pdev保存指针用platform_set_drvdata(pdev, d)在read/write等函数里取私有数据的路径是platform_get_drvdata。这两组API本质都是dev_set_drvdata只是包装了不同总线设备类型方便你少写类型转换。关于锁的提醒很多驱动在多实例化之后只改了数据存储忘了锁也要跟着实例化。如果struct mutex lock被定义成模块级的静态变量那么两个实例之间的所有操作都会被同一把锁串行化操作不频繁时你可能感觉不到一旦两个设备同时做批量读写性能直接打折。更重要的是锁的语义也要区分——实例自己的锁保护实例自己的状态全局锁只用来保护模块级的共享数据。不要把两者混用。3.3 多个设备节点怎么注册miscdevice动态minor和字符设备方案实例状态解决了用户态还需要能区分访问每个设备的接口。最自然的做法是一个实例一个设备节点。对于中小型外设驱动我强烈建议优先用miscdevice。它的主设备号是固定的10次设备号用动态分配注册流程非常短d-miscdev.minor MISC_DYNAMIC_MINOR; d-miscdev.name dev_name(client-dev); d-miscdev.fops mychip_fops; d-miscdev.parent client-dev; ret misc_register(d-miscdev); if (ret) return ret;dev_name(client-dev)在I2C总线场景下会得到类似0-0048、0-0049这样的名字天然不会跟另一个实例重复。miscdevice内部的动态次设备号分配会帮你避免自己管理设备号区间注册后/dev/0-0048、/dev/0-0049自动出现udev依devnode规则生成。miscdevice唯一的问题是动态次设备号池有限。主设备号10下面的次设备号范围总共就256个而且不少已经被系统固定占用所以如果一台设备上同类外设数量可能达到几十甚至上百misc就不够稳了。这种场景应该用标准字符设备加class的方式/* 在模块init里只做一次 */ static dev_t mychip_devno_base; static struct class *mychip_class; alloc_chrdev_region(mychip_devno_base, 0, MAX_INSTANCES, mychip); mychip_class class_create(mychip); /* 在每次probe里给实例分配一段次设备号 */ d-devno MKDEV(MAJOR(mychip_devno_base), minor); cdev_init(d-cdev, mychip_fops); d-cdev.owner THIS_MODULE; cdev_add(d-cdev, d-devno, 1); device_create(mychip_class, client-dev, d-devno, d, mychip%d, index);这个方案可控性更强设备号从alloc_chrdev_region申请的主设备号下顺序分配每个实例一个次设备号节点名可以自己带索引。缺点是代码量多而且在探测到设备之前必须知道MAX_INSTANCES。这里有个容易踩的坑class_create、alloc_chrdev_region这类操作绝对不能放在probe里重复执行。你挂第二个设备时probe再次进来如果又class_create(mychip)一次内核会因为同名class已存在而报错或产生残留。这类“模块级资源”应该放在module_init或驱动入口函数里做一次probe里只负责创建和当前实例绑定的部分。3.4 file_operations 里怎么找回当前实例设备节点有了之后用户态open进来file_operations需要知道这次访问的是哪个实例。两类注册方式对应的找回路径略有不同但核心都离不开container_of。如果你用的是miscdevice内核的misc_open会把struct miscdevice *存到filp-private_data所以open里可以这样static int mychip_open(struct inode *inode, struct file *filp) { struct my_dev *d container_of(filp-private_data, struct my_dev, miscdev); filp-private_data d; return 0; } static ssize_t mychip_read(struct file *filp, char __user *buf, size_t count, loff_t *pos) { struct my_dev *d filp-private_data; /* 用 d-client / d-lock 操作当前实例 */ }如果你用的是cdev方式open里从inode找回cdev再container_of到包含它的私有结构体static int mychip_open(struct inode *inode, struct file *filp) { struct my_dev *d container_of(inode-i_cdev, struct my_dev, cdev); filp-private_data d; return 0; }container_of不是黑魔法它就是根据结构体成员和成员地址反推出结构体起始地址。但它要求你传入的成员类型必须和结构体定义完全一致比如struct my_dev里的成员是struct cdev cdev那container_of(inode-i_cdev, struct my_dev, cdev)就没问题如果传错了成员名编译不会报错但得到的基地址是错的后续读写直接访问错误内存而且这类错误很难一眼看出来。还有一点fops对整个驱动只需要一份不要给每个实例拷贝一份fops。struct file_operations本身就是只读共享的里面的open/read/write函数通过filp-private_data区分实例这样设计才符合内核模型。3.5 多实例还会碰到的其他坑实践中比上面更细的坑还有几个值得提前打个预防针。第一个是寄存器映射。platform_driver如果需要在probe里映射寄存器每个实例要各自映射自己的那一块地址。设备树节点里reg是按索引排列的比如reg 0x0 0xff3b0000 0x0 0x1000, 0x0 0xff3b1000 0x0 0x100;probe里分别用platform_get_resource(pdev, IORESOURCE_MEM, 0)和IORESOURCE_MEM, 1拿两个地址再devm_ioremap_resource映射。每个实例映射的物理地址不同映射出来的虚拟地址也互不相干。如果驱动里图省事把寄存器基址放在全局变量里那多实例的场景几乎必炸——第二个probe把第一个的寄存器基址覆盖掉第一个设备的所有寄存器读写全部打到第二个设备空间。第二个是中断共享。如果多个设备实例恰好共享一条中断线申请中断时要注意。普通情况下每个设备一个中断号各自request_irq没问题。但有一些外设通过同一个GPIO扩展芯片的中断输出脚上报事件多个设备共用同一个irq number。这种场景申请中断必须带IRQF_SHARED标志并且dev_id不能传NULLret request_threaded_irq(irq, NULL, mychip_irq_thread, IRQF_TRIGGER_FALLING | IRQF_SHARED, dev_name(client-dev), d); if (ret) return ret;多个驱动各自带着不同的dev_id注册到同一条中断线上内核在中断到来时逐个调用各handler。你的handler要做一次“是否为我的设备中断”的判断通常是读设备的INT状态寄存器不是自己的中断就返回IRQ_NONE。free_irq时dev_id也要传一样的指针用来精确释放自己的handler。这里如果传NULL内核会拒绝free因为一个irq线上可能挂着多个handler不用dev_id就无法区分要释放哪一个。第三个是资源清理顺序。devm确实能帮你释放内存和部分资源但misc_register出来的miscdevice、device_create出来的设备节点、cdev_add出来的cdev都需要在remove里手动清理。顺序建议是先注销设备节点misc_deregister / device_destroy cdev_del再释放动态申请的非devm内存。如果反过来节点还在却先释放了内存用户态恰好在remove期间打开节点很容易触发use-after-free。4. 瑞芯微板子上落地这两个技巧验证清单与组合用法4.1 设备树最小写法同一总线挂两个同型号设备以RK3568为例I2C0总线上挂两颗同型号温度传感器设备树最小写法如下i2c0 { status okay; clock-frequency 400000; sensor0: temp-sensor48 { compatible mycorp,tmp-sensor; reg 0x48; label board-temp; interrupt-parent gpio3; interrupts RK_PA5 IRQ_TYPE_EDGE_FALLING; }; sensor1: temp-sensor49 { compatible mycorp,tmp-sensor; reg 0x49; label cpu-temp; interrupt-parent gpio3; interrupts RK_PA6 IRQ_TYPE_EDGE_FALLING; }; };两颗设备compatible相同i2c子系统会创建两个i2c_client驱动probe被调用两次。每个节点的reg地址必须不同I2C从地址否则总线上操作系统无法区分。如果是平台设备类似地写两个子节点每个节点有自己的reg或interruptsplatform_bus也会生成两个platform_device。如果你要让两个实例在用户态有可读性强的名字建议利用label属性。在probe里读出来而不是直接用dev_name那种0-0048格式const char *label NULL; const char *node_name; node_name dev_name(client-dev); device_property_read_string(client-dev, label, label); if (label) node_name label;这样/dev下会出现board-temp、cpu-temp这种一看就懂的名字对后续配应用程序很有帮助。4.2 确认两个实例都正常 probe 的排查清单多设备驱动最常见的调试场景就是“第二个设备为什么不工作”。我的排查顺序一般是这样的首先看dmesg。probe里如果有dev_info正常情况应该看到两行类似mychip 0-0048: ...和mychip 0-0049: ...的日志。如果只看到一行说明第二个节点的i2c_client可能就没生成要检查设备树有没有被正确编译进dtb。然后检查sysfs。I2C设备在/sys/bus/i2c/devices/下会有0-0048、0-0049两个目录platform设备在/sys/bus/platform/devices/下能看到对应节点。如果目录存在但驱动没有绑定目录下没有driver符号链接那就是compatible或of_match_table没对上。最后再看/sys/kernel/debug下的一些总线信息如果内核开了DEBUG_FS。这一层信息量大但对于确认probe次数来说前两步已经足够。如果你正在用模块方式调试还要顺手检查一下modinfo mychip.ko | grep alias确认of别名导出成功。否则设备树节点明明存在modprobe也可能“假装”不知道要加载哪个模块。4.3 两个技巧叠加使用的实际场景前面两个技巧不是二选一的关系实际项目中经常是叠加的。比如RK3568的板子上有两路串口扩展芯片一共挂了8路串口其中两路芯片型号不同但功能相同。这种情况型号差异用of_device_id.data去区分每个型号对应一套寄存器参数同型号的多个实例用每实例私有结构体去管理每个实例有自己独立的保存状态和设备节点。probe里既能通过of_device_get_match_data拿到型号配置又能通过devm_kzalloc拿到实例私有数据两者互不冲突。我给自己的一个开发纪律是probe函数里第一件做的事是拿型号配置第二件事是分配实例私有数据第三件事才是解析设备树属性初始化硬件。顺序乱了后面排查的时间会成倍增加。多实例驱动的调试阶段最好把每个实例的私有数据地址和关键参数打出来。虽然这种日志看起来很啰嗦但多设备场景下“哪个实例对应哪份数据”非常容易搞混日志里能明确区分后续定位问题会轻松很多。我个人的习惯是probe里用dev_info打一行易于检索的实例信息例如probe ok, modelA, nameboard-temp并在remove里打对应行。加上节点名和型号开机日志里扫一眼就能确认系统里所有实例都正确注册和释放。这个习惯帮我在几次现场问题里省了大量时间也推荐你试试。最后再分享一个小技巧新驱动第一次接多实例硬件时别急着把功能全写完再验证。先只做probe和remove的框架确认probe调用次数、私有数据结构、设备节点三个环节都对了再往里填寄存器读写逻辑。这样一旦出问题你很容易定位是匹配问题、实例管理问题还是硬件操作问题而不是所有可能性搅在一起。这个顺序我踩过几次坑之后才固定下来现在写多设备驱动基本一遍过。
返回列表