十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux驱动开发:用设备树与私有数据实现多设备支持与适配

Linux驱动开发:用设备树与私有数据实现多设备支持与适配 做过瑞芯微平台的Linux驱动开发的人应该都有过这种体会一个驱动写好了刚开始只接一个设备跑得挺好等硬件改版或者产品要扩展同一个驱动要同时管两个甚至更多的同类设备时各种问题就跟着来了——资源冲突、设备号错乱、probe时数据覆盖、日志分不清是哪个设备打印的。最近我刚好在一个RK3568的项目上处理了一轮这样的适配工作把一套原本只适配单设备的驱动改成了能同时支持多个实例的状态过程中总结出两个非常实用的小技巧正好借这篇文章完整梳理一遍。先说清楚这个场景的本质。在瑞芯微这种高度依赖设备树Device Tree的BSP环境下所谓“驱动支持多个设备”通常指的是两种情况一种是同一个I2C/SPI总线上挂了两颗相同型号的芯片比如两个触摸屏控制器另一种是一套驱动要兼容多颗不同型号、但功能逻辑相近的芯片比如同系列的不同版本。前者考验的是驱动对多实例的资源管理能力后者考验的是对设备差异的建模能力。这两个问题看起来不搭边但在代码层面其实是同一件事让驱动不要把自己绑定在“一个设备”的假设上。下面我把这次总结出的两个核心技巧分别展开讲每个技巧都配合了实例说明和瑞芯微平台上的注意事项。1. 技巧一用of_device_id的data字段做“型号分诊台”先聊聊“一套驱动兼容多个不同型号设备”这件事。很多新手写驱动会倾向于在probe函数里通过判断设备名或者version register来走不同的初始化流程。我见过最夸张的代码是这样的if (!strcmp(np-name, xxx,ctl-580)) { reg 0x10; rate 48000; } else if (!strcmp(np-name, xxx,ctl-590)) { reg 0x20; rate 96000; }这种写法在瑞芯微的BSP驱动里非常容易看到有的甚至是从原厂SDK里带出来的。它的问题很明显每加一个型号就要往probe里塞一个else if代码越来越长逻辑越来越乱。而且一旦某个型号的参数被另一个流程提前改掉了排查起来非常痛苦。正确做法是把差异收进数据里用设备树compatible来索引。在驱动probe时通过of_device_get_match_data()拿到对应当前设备的私有数据指针所有初始化差异都从这个指针里读。这就像医院的分诊台——来了一个病人先根据症状分到对应科室而不是让一个医生从头到尾处理所有可能的情况。1.1 核心数据结构设计先定义一个描述设备差异的结构体把芯片型号、寄存器配置、工作频率、甚至差异化的操作函数指针都放进去struct xxx_chip_info { const char *name; u32 version_reg; u32 default_rate; int (*init_hw)(struct xxx_dev *dev); int (*calibrate)(struct xxx_dev *dev, u32 params); };接着定义两个型号各自的实例static const struct xxx_chip_info xxx_580_info { .name xxx,ctl-580, .version_reg 0x10, .default_rate 48000, .init_hw xxx_580_init, .calibrate xxx_580_calibrate, }; static const struct xxx_chip_info xxx_590_info { .name xxx,ctl-590, .version_reg 0x20, .default_rate 96000, .init_hw xxx_590_init, .calibrate xxx_590_calibrate, };1.2 匹配表的正确写法关键来了of_device_id数组里必须把.data指向上面的结构体static const struct of_device_id xxx_of_match[] { { .compatible xxx,ctl-580, .data xxx_580_info }, { .compatible xxx,ctl-590, .data xxx_590_info }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, xxx_of_match); static struct platform_driver xxx_driver { .probe xxx_probe, .remove xxx_remove, .driver { .name xxx_ctrl, .of_match_table xxx_of_match, .pm xxx_pm_ops, }, };在probe中只需要一行就能取到当前设备对应的配置const struct xxx_chip_info *info of_device_get_match_data(dev); if (!info) return -EINVAL; priv-chip info; priv-rate info-default_rate; if (info-init_hw) { ret info-init_hw(priv); if (ret) return ret; }设备树这边也相应写清楚compatiblei2c3 { status okay; xxx_ctrl: xxx1a { compatible xxx,ctl-590; reg 0x1a; interrupt-parent gpio1; interrupts RK_PB2 IRQ_TYPE_LEVEL_LOW; pinctrl-names default; pinctrl-0 xxx_ctl_int_l; }; };1.3 为什么这套在瑞芯微平台上特别顺手瑞芯微的BSP中dts几乎承载了所有硬件差异描述。常见的外设控制器的compatible都是“rockchip,xxx”的格式比如rockchip,rk3568-i2c、rockchip,rk3568-dw-mshc。在这种体系下定制设备尽量遵循“厂商,型号”的命名规则并且把不同型号的定义归到同一个驱动里上层的产品适配工作就完全转换成了dts的修改驱动代码不需要频繁改动。这里有一个隐藏的好处当硬件工程师说“这次小批量用了另一个供应商的兼容芯片”你只需要确认寄存器差异新增一个info结构体和对应的compatible条目然后把新结构体的指针挂上去。加一个新型号的成本从改代码逻辑降到了加一个结构体风险指数级下降。另外一个容易被忽略的点of_device_id在匹配时是严格按设备树节点的compatible属性顺序进行的。如果你的设备树里写了两个compatiblecompatible xxx,ctl-590, xxx,ctl-580;那么驱动会优先匹配到xxx,ctl-590。这个特性可以用于“先用新版本驱动跑不行再回退旧版逻辑”的双保险策略在瑞芯微平台bring-up阶段特别好用。2. 技巧二把私有数据塞进“容器”多实例运行时永远不打架第二个技巧解决的是“同一个驱动同时管理两个以上同型号设备”的问题。这类场景在音频编解码芯片、触摸控制器、Sensor HUB驱动里非常常见。比如一个RK3568的板子上可能挂两个相同的音频Codec一个走I2C3一个走I2C4驱动如果还是按单设备那种写法十有八九要出问题。我在这次项目中一开始就把状态存在一堆全局变量里全局的寄存器缓存数组、全局的设备指针、全局的引用计数。结果两个设备同时probe的时候后一个直接覆盖了前一个的指针和状态导致第一个设备的控制接口完全失效。这就是典型的“单设备思维”留下的坑。2.1 一切运行时状态归入priv结构体解决办法其实很多人知道但真正落地容易偷懒所有运行时状态全部放进probe时动态分配的一个私有结构体中。这个结构体相当于每个设备独立的“房间”里头放这个设备的寄存器缓存、互斥锁、工作队列、中断状态、sysfs相关属性等等。struct xxx_dev { struct device *dev; struct i2c_client *client; /* 或 platform_device */ const struct xxx_chip_info *chip; struct mutex lock; u8 reg_cache[256]; u32 rate; int irq; /* multi-instance support */ unsigned int idx; /* 设备序号, 见下文 */ };probe中分配也很关键直接用devm_kzallocstatic int xxx_probe(struct i2c_client *client, const struct i2c_device_id *id) { struct device *dev client-dev; struct xxx_dev *priv; const struct xxx_chip_info *info; info of_device_get_match_data(dev); if (!info) return -EINVAL; priv devm_kzalloc(dev, sizeof(*priv), GFP_KERNEL); if (!priv) return -ENOMEM; priv-dev dev; priv-client client; priv-chip info; priv-irq client-irq; mutex_init(priv-lock); i2c_set_clientdata(client, priv); /* 后续初始化都基于priv, 不再使用任何全局状态 */ return xxx_init_all(priv); }devm_kzalloc最大的好处就是生命周期跟着struct device走probe后续某一步失败内核会自动释放之前分配的资源不需要在错误路径里一个个手动kfree。在瑞芯微平台调试时你会发现用了devm系列API之后驱动的remove函数往往只剩几行——devm_已经把中断释放、GPIO释放、时钟关闭全接管了。2.2 动态次设备号分配别再手工指定了当同一驱动管理多个设备时最让人头疼的就是字符设备节点的创建。如果每个设备都提前规划一个固定的次设备号时间长了根本记不住哪个号对应哪颗芯片。正确的做法是用alloc_chrdev_region一次性把主设备号和可用次设备号区间都申请下来static dev_t xxx_dev_no; static int xxx_register_chrdev(struct xxx_drvdata *drv) { int ret; ret alloc_chrdev_region(xxx_dev_no, 0, XXX_MAX_DEVICES, xxx_ctrl); if (ret 0) return ret; cdev_init(drv-cdev, xxx_fops); drv-cdev.owner THIS_MODULE; ret cdev_add(drv-cdev, xxx_dev_no, XXX_MAX_DEVICES); if (ret) { unregister_chrdev_region(xxx_dev_no, XXX_MAX_DEVICES); return ret; } return 0; }然后每次probe时从主设备号区域中取一个未被占用的次设备号并创建对应的设备节点static int xxx_create_devnode(struct xxx_drvdata *drv, struct xxx_dev *priv) { int minor; struct device *dev; minor ida_alloc(drv-minor_ida, GFP_KERNEL); if (minor 0) return minor; priv-minor minor; priv-devt MKDEV(MAJOR(xxx_dev_no), minor); dev device_create(drv-class, priv-dev, priv-devt, priv, xxx-ctrl%d, priv-idx); if (IS_ERR(dev)) { ida_free(drv-minor_ida, minor); return PTR_ERR(dev); } return 0; }这里的priv-idx就是在多设备初始化时顺序分配的序号利用它来生成文件名比如xxx-ctrl0、xxx-ctrl1既直观又不会重复。用户态看一眼设备节点就知道自己在操作哪一颗芯片。2.3 日志里必须带设备名这个细节看起来小实际调试时能救命。我在排查一个两个Codec互相干扰的问题时dmesg里所有打印都用的是pr_info(xxx: rate set to 48000)结果根本分不清这个日志是哪个I2C地址设备打印的。后来全部改成dev_info(dev, ...)之后问题立刻暴露——日志里能直接看到是i2c-3 001a: rate set to 48000还是i2c-4 001a: rate ...哪颗芯片在报异常一目了然。/* 错误写法 */ pr_info(%s: rate set to %d\n, __func__, rate); /* 多实例正确写法 */ dev_info(priv-dev, rate set to %d\n, priv-rate);在瑞芯微平台的kernel log里经常有多个I2C控制器同时工作混用pr_info和dev_info在单设备时看不出问题多设备场景下会直接增加半小时的排查成本。2.4 共享中断与dev_id多设备最容易踩的坑瑞芯微的GPIO中断资源很紧张很多外设的中断都共用同一个GPIO bank甚至同一个GPIO线会同时接到两个控制器的INT输出脚上。这种情况下注册中断时如果不用IRQF_SHARED第二个设备会直接probe失败。而一旦用了IRQF_SHARED就一定不能忽略dev_id的作用ret request_threaded_irq(priv-irq, NULL, xxx_irq_handler, IRQF_TRIGGER_LOW | IRQF_ONESHOT | IRQF_SHARED, dev_name(dev), priv);这个dev_id参数在多设备共用中断线时必须传priv指针中断到来时内核会把当前设备的设备上下文交给处理函数。如果不传——或者图省事传成同一个全局值——Handler里根本无法判断这次中断到底是哪个设备发起的甚至还会因为内存访问错误直接oops。static irqreturn_t xxx_irq_handler(int irq, void *dev_id) { struct xxx_dev *priv dev_id; /* 只有当前设备自己才知道要读哪个寄存器的状态 */ mutex_lock(priv-lock); xxx_irq_process(priv); mutex_unlock(priv-lock); return IRQ_HANDLED; }多实例场景下这里有两个容易踩的坑共享中断时不能加IRQF_NOAUTOEN之外又禁止了共享申请第二个设备时会返回-EINVAL或者-EBUSY。处理函数里不要做耗时操作。如果两个设备同时触发中断而第一个设备的中断处理占用了太长时间第二个设备的中断状态就会堆积表现为偶发性的状态丢失。3. 瑞芯微平台适配时绕不开的三个细节文章里反复提到瑞芯微平台很多人可能知道RK3568、RK3588这种SoC。但在驱动层面瑞芯微的BSP有一些自己的约定。多设备驱动要在这套环境里落地以下这三个细节我会在项目开始前就先确认清楚能省掉后面一连串的麻烦。3.1 设备树compatible的“主备”写法瑞芯微SDK的dts文件非常庞大经常有大量的i2c0 { }、spi1 { }节点覆盖。我在适配多设备时习惯在板级dts里给外设节点写两个compatiblecompatible vendor,xxx-ctl-590, vendor,xxx-ctl-580;具体匹配原则上面提过谁写在前面驱动优先走谁的data。配合的技巧是在开发阶段如果发现新版本芯片逻辑有部分回退需求可以直接调换compatible顺序不用重新编译驱动。生产固化后尽量只保留真实的那个型号避免内核匹配到非预期配置。另外瑞芯微的pinctrl体系对GPIO引脚的复用很严格。同一个引脚如果既被一个设备的pinctrl-0占用又被另一个设备的pinctrl-1引用设备树加载时会直接报pinctrl core: failed to register。所以在写多个设备节点之前先确认这些设备使用的引脚没有重叠。这个我用/sys/kernel/debug/pinctrl/pinctrl-rockchip-pinctrl/pinmux-pins核过一两次基本不会再碰到引脚冲突的玄学问题。3.2 时钟与复位信号各自独立多设备挂在同一总线上时往往共享同一个时钟源。比如两个Sensor都接在clk_48m上驱动里如果有一个设备probe时关掉了这个时钟另一个设备立刻停止工作。我在RK3568上碰到过类似情况音频Codec和蓝牙PCM接同一个MCLKCodec驱动初始化时任性调用clk_disable_unprepare蓝牙那边的时钟信号直接也没了。约束自己的原则是驱动不主动关闭可能被别人共享的时钟。如果时钟确实需要配置频率用devm_clk_get拿到指针后只做clk_set_rate在remove或suspend里也尽量只clk_disable不clk_unprepare——除非你确认这个时钟是设备私有的。复位引脚也是同一个道理。多个设备如果硬件上复用了同一个reset-gpio任何一个设备触发复位另一个也被拉低重启。这种问题不会在首次bring-up时暴露通常是在做完整的电源管理测试时才会被发现。/* 设备私有reset引脚 */ reset-gpios gpio3 RK_PA5 GPIO_ACTIVE_LOW; /* 多设备共享时钟时的配置示例 */ clocks cru CLK_XXX_MCLK; clock-names mclk;3.3 驱动匹配要留足“私有数据”的空间在瑞芯微平台上很多原厂SDK驱动里会直接通过platform_get_resource和of_iomap来做寄存器映射。多设备情况下要特别小心寄存器基地址是否在不同设备实例之间有重叠。我之前见过一个驱动两个设备都是通过of_remap_iomap直接映射了同一段寄存器地址最后发现第二个probe时把第一个设备配置好的寄存器全冲了。遇到这种情况应该为每一个设备实例单独预留寄存器映射空间并且把所有寄存器操作都通过priv-reg_base进行偏移访问。priv-reg_base devm_platform_ioremap_resource(pdev, 0); if (IS_ERR(priv-reg_base)) return PTR_ERR(priv-reg_base);多设备的寄存器读写建议统一封装成两个函数xxx_reg_read(priv, offset)和xxx_reg_write(priv, offset, val)里面通过priv-reg_base加偏移去访问而不是直接裸地址读写。这样代码可维护性一下子就能提高很多。4. 完整的多设备驱动验证思路驱动写完以后验证方式也有讲究。单设备验证通过不代表多设备场景没问题。这里分享一套我在RK3568板子上验证多设备支持时用的检查清单基本覆盖了从加载到运行的全链路。4.1 先查设备树枚举设备树改了以后先确认内核正确枚举出所有设备# 查看i2c总线上的设备 ls /sys/bus/i2c/devices/ # 更详细的设备信息 cat /sys/bus/i2c/devices/3-001a/name cat /sys/bus/i2c/devices/3-001a/of_node/compatible如果设备没有在对应总线上出现优先查设备节点status okay是否设置I2C地址是否被其他设备占用i2cdetect -y 3可以扫总线电源域或复位引脚是否被别的节点占用。4.2 确认设备节点和中断都注册成功# 查看字符设备分配情况 cat /proc/devices | grep xxx # 查看中断注册情况 cat /proc/interrupts | grep xxx # 如果驱动带class, 在/sys/class下也能看到 ls /sys/class/xxx_ctrl/多设备时重点看中断号是否分配了两个不同的IRQ或者共享中断是否都注册上了。如果只有第一个设备的中断出现多半是第二个设备的request_threaded_irq由于参数不对被内核拒绝了。4.3 动态调试dmesg与dev_dbg瑞芯微的SDK默认不一定打开CONFIG_DYNAMIC_DEBUG但一般内核都编译了。可以通过以下命令打开驱动文件的动态日志# 打开某个文件的动态调试 echo file drivers/misc/xxx_ctrl.c p /sys/kernel/debug/dynamic_debug/control # 只看最近内核日志 dmesg | tail -n 100我一般会同时开两个终端一个持续看dmesg一个反复读写两个设备的接口。如果某个操作的日志里设备名一直指向同一个地址那肯定是设备号/私有数据管理出了岔子——回到第二节的思路去查。4.4 压力测试重复bind/unbind多设备驱动最怕的是反复加载卸载时资源泄漏。瑞芯微的内核提供了sysfs接口可以直接做bind/unbind测试# 找到驱动绑定的设备 ls /sys/bus/i2c/drivers/xxx_ctrl/ # 解绑第一个设备 echo 3-001a /sys/bus/i2c/drivers/xxx_ctrl/unbind # 重新绑定 echo 3-001a /sys/bus/i2c/drivers/xxx_ctrl/bind连续做几十次然后观察/proc/slabinfo中的kmalloc-*是否持续增长/proc/interrupts中的中断计数有没有异常累计dmesg里有没有alloc_irq被拒绝或者device_create失败的报错。这一步在验证“是否真的做到多实例安全”时特别有效。很多驱动在第一次probe时资源管理没问题反复bind/unbind到第二次、第三次就崩基本都是全局状态没有清理干净。5. 这次在RK3568上遇到的一个典型问题记录写这种文章光讲抽象技巧没意思我把这次实际遇到的一个问题完整复盘一遍也解释一下上面两个技巧是如何帮我定位问题的。板子上接了两片同样的Sensor芯片都挂I2C一个地址是0x1a一个地址是0x1b。资源全部按前两节设计好了但是系统起来后第二个设备注册的sysfs属性一读写就报错而且读写结果经常跟第一个设备混在一起。排查思路先看dmesg确认probe都成功了没有返回错误看/sys/bus/i2c/devices/两个设备都在看/proc/interrupts两个中断号都注册成功手动访问第二个设备的寄存器发现写入后读回来的是第一个设备的值。这个现象说明读写访问并没有真正区分设备。进一步检查发现自己最初用来区分两个设备的全局dev指针数组写错了初始化时两个设备节点都指向了同一个priv/* 错误写法 */ static struct xxx_dev *g_devs[2]; static int g_idx; static int xxx_probe(...) { ... g_devs[g_idx] priv; }问题在于g_idx是一个全局变量一旦两个设备的probe顺序变化比如某个设备初始化慢了一点两个设备可能都被塞到同一个下标里。正确做法是直接用i2c_get_clientdata或者container_of从设备结构拿到自己的privstatic ssize_t xxx_sysfs_show(struct device *dev, struct device_attribute *attr, char *buf) { struct xxx_dev *priv dev_get_drvdata(dev); ... }这个问题最终就是靠“屏蔽全局共享状态一切从dev_get_drvdata取私有数据”解决的。事后回看如果一开始就完全遵循第二节的“一切运行时状态归入priv结构体”根本不会走到这一步。6. 多设备驱动设计的几点经验体会文章走到最后不做那种“综上所述”只写一些这次踩坑后沉淀下来的实在体感。第一点设备树数据驱动确实比流程if else香得多。尤其是面对两三个同系列芯片时一两个结构体就能兜住所有差异调试时看到哪个型号就匹配哪份data脑子里不用存一张“哪种设备走哪个分支”的对照表。第二点多实例驱动最大的敌人是“省事”两个字。我这次为了少写几个参数把两个设备共用一个全局状态变量省了五分钟结果后面排查花了两个小时。写驱动时但凡遇到“要不要把这个变量做成per-device”的犹豫一定选per-device。这个直觉在多设备场景下几乎不会有错。第三点瑞芯微平台调试多设备日志规范比逻辑复杂度更影响效率。如果一开始所有日志都严格用dev_info、dev_err带设备名很多问题从dmesg里就能一眼定位根本不需要打断点。这也是为什么我把这个细节单独拿出来写——它太容易被忽略了但实际收益又特别大。如果你正在做的项目也面临类似“一套驱动要管多个设备”的需求无论是瑞芯微平台还是其他平台把上面两个技巧融入初始设计后续适配新硬件的时候能少走很多弯路。硬件改版永远比驱动改版来得快数据驱动的设计能让你的驱动在硬件版本迭代中站得更稳。
返回列表