RK3568多设备驱动开发:设备树匹配与实例隔离实战技巧

RK3568多设备驱动开发:设备树匹配与实例隔离实战技巧 在我最近一次基于 RK3568 的定制板卡调试里碰到一个很典型的问题主板上挂了 4 颗同一型号的 UART 转接芯片设备树里也老老实实写了 4 个节点可上电之后 /dev 下面只冒出来两个。查了一圈不是芯片虚焊也不是内核配置少开选项真正的问题出在“驱动如何同时支持多个设备”这件事上。Linux 驱动模型里一个 driver 可以绑定多个 device这套机制本身非常成熟。但工程上容易翻车的地方在于设备树里多个节点怎么共用同一个 compatible驱动里怎么隔离不同设备实例的私有状态资源申请又怎么避免互相踩踏。这里就围绕瑞芯微平台分享我在实际项目中反复验证过的两个小技巧可以帮你少走不少弯路。1. 先搞明白一个驱动是如何同时照顾多个设备的1.1 Linux device/driver 模型的匹配过程很多刚开始写驱动的朋友会有个错觉一个驱动文件对应一个设备probe 只会执行一次。实际上在 Linux 里driver 是一个“方法集合”注册到总线上之后会和总线上所有未匹配的 device 做匹配。每匹配成功一个设备probe 就会被调用一次。换句话说如果系统里有 4 个设备节点的 compatible 都指向同一个 of_match_table 项那么驱动入口就会被调用 4 次每次传入的 struct device 都不一样。在设备树体系下匹配的语义更加直白。内核会把设备树里每个带 compatible 的节点变成相应的 platform_device也可能是 i2c_client、spi_device然后和驱动注册时的 of_match_table 做字符串匹配。匹配通过后总线核心调用驱动的 probe。这段逻辑对所有使用设备树的平台都适用瑞芯微的 SDK 也遵循这个流程只是它的 BSP 里预先塞了一堆 compatible 字符串很多人改着改着就把“设备树节点”和“设备树匹配表”搞混了。1.2 瑞芯微设备树下多设备实例到底长什么样在瑞芯微的 DTS 里最常见的多设备形态是这两种。第一种是同一款 SoC 内部有多个相同的控制器比如 RK3568 有 4 组 UART、3 组 I2C、多组 SPI每个控制器都有自己的 reg、中断、时钟和 pinctrl节点名不同但 compatible 往往相同。第二种是外部总线上挂了多颗同型号芯片比如两片 ADC分别挂在 I2C0 和 I2C1或者挂在同一个 I2C 总线上的不同地址此时 compatible 相同reg/地址不同。这两类场景在硬件上都是“多个设备同一个驱动”但很多人的驱动代码只按“一个设备”的思路写把寄存器基址、中断号、当前状态全部塞进全局变量。第一次 probe 正常第二次 probe 时基地址被覆盖中断处理程序里拿到的又是后一个设备的信息调试起来非常痛苦。所以要支持多个设备必须从两个层面下手一是设备树匹配层面的“怎么让驱动找到每个设备”二是驱动数据层面的“怎么让每个设备有自己的独立状态”。下面两个技巧分别解决这两个问题。2. 技巧一用 compatible of_device_id.data 做一驱多配的精确匹配2.1 设备树侧的多节点编排同一 compatible多个 reg先说我在 RK3568 上用得很顺的一种设备树写法。假设我有两片型号一样但基准电压不同的 ADC一片挂在 I2C0 上地址 0x36另一片挂在 I2C1 上地址 0x37设备树可以这么写i2c0 { status okay; adc0: adc36 { compatible rockchip,my-adc; reg 0x36; vref-mv 3300; calib-offset 0x10; }; }; i2c1 { status okay; adc1: adc37 { compatible rockchip,my-adc; reg 0x37; vref-mv 2500; calib-offset 0x11; }; };这里两个节点用的是同一个 compatible差异通过自定义属性表达。内核会把每个节点都变成 i2c_client驱动只要注册一次就会和这两个节点分别匹配probe 执行两次。这种写法的好处是驱动代码里不需要判断“我是第几个设备”只需要在 probe 时从 device property 里把 vref-mv 和 calib-offset 读出来存到当前设备实例的私有数据里就行。2.2 驱动侧 of_device_id 的两种用法靠 compatible 匹配靠 data 区分如果只是“多路相同设备”上面已经够用。但如果硬件上有两个型号比如同一颗芯片的 A 版本和 B 版本寄存器布局略有差异很多人的做法是在 probe 里用of_device_is_compatible()去做判断if (of_device_is_compatible(dev-of_node, rockchip,my-adc-a)) config cfg_a; else config cfg_b;不是说不能用但这样很啰嗦而且每次新增型号都要改驱动。更干净的做法是把两个 compatible 都写进同一个of_device_id表用data字段挂不同的配置结构体static const struct my_adc_config cfg_a { .resolution 12, .default_vref 3300, }; static const struct my_adc_config cfg_b { .resolution 16, .default_vref 2500, }; static const struct of_device_id my_adc_of_match[] { { .compatible rockchip,my-adc-a, .data cfg_a }, { .compatible rockchip,my-adc-b, .data cfg_b }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, my_adc_of_match);MODULE_DEVICE_TABLE这个宏不只是给模块工具生成别名用的它也能让内核知道这张表存在。很多人在设备树平台里忘了加这一行结果设备树节点明明 compatible 对上了驱动却始终 probe 不成功多半就是少了它。2.3 在 probe 里把节点差异读回私有配置有了上面的表probe 函数里可以用of_device_get_match_data()直接拿到对应的配置指针再叠加设备节点里的自定义属性static int my_adc_probe(struct i2c_client *client) { struct my_adc_device *adc; const struct my_adc_config *cfg; cfg of_device_get_match_data(client-dev); if (!cfg) return -ENODEV; adc devm_kzalloc(client-dev, sizeof(*adc), GFP_KERNEL); if (!adc) return -ENOMEM; adc-cfg cfg; adc-vref_mv cfg-default_vref; adc-calib_offset 0; device_property_read_u32(client-dev, vref-mv, adc-vref_mv); device_property_read_u32(client-dev, calib-offset, adc-calib_offset); ... }注意device_property_read_u32的返回值可以不检查如果设备树节点没写 vref-mvadc-vref_mv 会保持不变因为内核 API 默认不会帮你把属性清零。我习惯先给一个默认值再读属性这样设备树里漏写属性时驱动依然能按默认参数跑起来。2.4 为什么这种写法比写死型号判断更抗改版把型号差异下沉到 of_device_id.data 里相当于把“查表”的工作交给了内核。新增一个型号时驱动里只需要多加一行 compatible 和一份配置结构体probe 逻辑完全不用变。设备树里换型号时也只需要改 compatible 字符串不需要重新编译内核。实测中这个技巧对 SPI 驱动同样适用。瑞芯微 SPI 总线上挂多片 NOR Flash 时可以在 dts 里写多个 compatible 为 jedec,spi-nor 的节点spi-nor 驱动内部也会根据每个节点属性配置不同的 flash 参数。道理是相通的。3. 技巧二多实例驱动绝对不能忽略的私有数据隔离3.1 不要用全局变量保存寄存器基址我在一开始说过最典型的多设备翻车现场就是全局变量存放寄存器基址。比如static void __iomem *g_base; static int g_irq;第一路设备 probe 时把 base 存进去第二路设备 probe 时又把 base 覆盖成自己的地址。这时如果第一路设备发生中断中断服务程序里读的是第二路设备的寄存器轻则功能错乱重则直接访问到错误的外设。这类错误很难查因为日志看起来像随机偶发。正确的思路是每次 probe 时给当前设备分配一份独立的上下文结构体把所有跟该设备相关的资源、状态、配置都放进去。对于多个同型设备每个设备有自己的 base、irq、spinlock、等待队列、字符设备等信息。3.2 每个设备实例私有结构体 platform_set_drvdata 的标准姿势以 platform 设备为例推荐的结构体设计和绑定方式如下struct my_device_data { void __iomem *base; int irq; struct device *dev; int id; struct cdev cdev; /* 其他状态 */ }; static int my_driver_probe(struct platform_device *pdev) { struct my_device_data *data; int ret; data devm_kzalloc(pdev-dev, sizeof(*data), GFP_KERNEL); if (!data) return -ENOMEM; >data-base devm_platform_ioremap_resource(pdev, 0); if (IS_ERR(data-base)) return PTR_ERR(data-base);这个函数内部封装了 platform_get_resource devm_ioremap_resource资源重叠时会直接报错省得自己写一堆判断。中断申请也推荐devm_request_irq这样可以少写一堆 goto err。3.4 字符设备场景用次设备号把多个实例暴露给用户空间如果驱动要给用户空间提供节点那么多实例场景还有个常见需求每个设备一个 /dev 名字。很多人会想到注册多个字符设备但更优雅的方式是分配一个主设备号给每个实例分配不同的次设备号然后用 device_create 创建不同的设备节点。我在一个多路 ADC 驱动里是这么做的#define MY_ADC_MAX_DEVICES 8 static int my_adc_major; static int my_adc_next_minor; static int my_adc_create_chardev(struct my_device_data *data, int id) { int minor my_adc_next_minor; cdev_init(data-cdev, my_adc_fops); >uart0 { status okay; pinctrl-names default; pinctrl-0 uart0_xfer; }; uart1 { status okay; pinctrl-names default; pinctrl-0 uart1_xfer; }; uart4 { status okay; pinctrl-names default; pinctrl-0 uart4_xfer; }; uart5 { status okay; pinctrl-names default; pinctrl-0 uart5_xfer; };这四路 UART 的 compatible 在 rk3568.dtsi 里可能是相同的最终都由 8250 或瑞芯微自己的串口驱动绑定。四路串口同时打开驱动 probe 会执行四次每个串口实例有各自独立的 reg、irq、时钟和 pinctrl。很多人在这个阶段遇到的问题是只打开了 uart0 和 uart1没有打开 uart4/uart5 的 pinctrl导致后面两路的寄存器也能访问但引脚复用不对数据出不来。这其实不是驱动支持多个设备的问题而是设备树 pinctrl 和 status 的配合问题但排查时经常被误认为是驱动没适配多设备。4.2 驱动 probe 日志与常见报错解释如果你使用瑞芯微官方内核在 dmesg 里能看到类似这样的日志[ 1.203452] rockchip-i2c fe050000.i2c: Initialized RK3xxx I2C bus at 0xfe050000 [ 1.211033] rockchip-i2c fe060000.i2c: Initialized RK3xxx I2C bus at 0xfe060000这其实就是同一个 i2c 驱动绑定到了两个不同的设备节点分别初始化了两套 I2C 控制器。地址 fe050000 和 fe060000 来自设备树里的 reg 属性。如果驱动里用了全局变量保存适配器指针第二条日志出来时前一条记录的适配器可能已经被覆盖后续在上面挂载的任何客户端设备都会出问题。如果 probe 失败dmesg 常见的错误有-EBUSY资源被占用比如两路设备抢同一个中断号或者 ioremap 的区域重叠。-EINVAL设备树属性解析错误比如 reg 的格式不对或者 interrupts 写错导致 platform_get_irq 返回无效值。-ENODEVof_match_table 里找不到 compatible或者节点 status 被禁用。遇到这些错误时不要急着改驱动代码先确认节点是否真的被内核注册。最简单的办法ls /sys/bus/platform/devices/或者对 i2c 设备ls /sys/bus/i2c/devices/ ls /sys/bus/i2c/drivers/rockchip-i2c/如果节点没出现在总线上说明设备树解析阶段就有问题probe 根本不会执行。4.3 让两个技巧配合同一驱动管理四路外设的最终效果我在一个 RK3568 项目里用这套思路写了一个多路 ADC 驱动。硬件上是两片同样的 ADC 芯片分别挂在 SPI0 的 CS0 和 CS1 上。设备树写两个子节点compatible 相同reg 分别为 0 和 1。驱动里用 of_device_id 的 data 区分两片芯片的固化配置probe 时给每个节点分配独立的私有结构体保存各自的 spi_device 指针、转换完成标志、spinlock 和字符设备节点。最终效果是驱动模块加载一次生成两个 /dev/adc0 和 /dev/adc1。应用层同时打开两个设备各自读取数据互不干扰。中途如果硬件上只焊接了一片 ADC只需在设备树里删掉一个子节点驱动代码一行不改系统自动少一个设备。这就是“设备树与驱动数据结构各司其职”带来的维护便利。5. 复用这套打法时最容易踩的坑和排查思路5.1 compatible 没写进驱动表设备树怎么配都不 probe最常看见的问题是这个。DTS 里写了 compatible rockchip,my-adc但是驱动里的 of_match_table 写成了static const struct of_device_id my_adc_of_match[] { { .compatible rockchip,my-adc-v2 }, { /* sentinel */ } };内核在匹配时严格按字符串比较多一个字符、少一个字符都不行。排查时先确认设备树的 compatible 和驱动表是否完全一致包括厂商前缀。瑞芯微的 SDK 里大量使用 rockchip,xxx如果你自己定义的 compatible 用了 rockchip,my-adc但设备树节点里多写了一个状态后缀比如 rockchip,my-adc,disabled同样匹配不上。5.2 status “disabled” 的节点不会触发 probe还有不少人会把节点写在 dtsi 里状态默认 disabled板级 dts 忘了改为 okay。此时节点虽然在设备树源码里存在但内核不会把它转换成 platform_device驱动自然没有探测机会。检查手段ls /sys/firmware/devicetree/base/soc/ find /sys/firmware/devicetree/base/ -name *adc*如果节点在 sysfs 的设备树视图里能看到 status 属性为 disabled就说明设备树解析阶段已经把它过滤掉了。5.3 中断号或者寄存器重叠导致第二次 probe 失败多设备实例的 probe 顺序不一定和设备树里的节点顺序完全一致资源重叠的冲突也不一定发生在第一个设备上。比如两个 SPI 子节点都写了同一个 reg内核在注册 spi_device 时可能会允许但是在驱动用 platform_get_resource 或 spi_get_resource 获取寄存器区域时有可能发现区域已经被占用返回 -EBUSY。这个问题在瑞芯微这种多控制器 SoC 上尤其容易遇到。排查时可以看 /proc/iomemcat /proc/iomem | grep fe0确认每个节点的 reg 对应的物理地址区间是否重叠。5.4 dmesg 定位驱动实例错乱的三种人话排查法当怀疑“多个设备实例之间状态串扰”时我一般按下面的顺序排查。首先看 dmesg 里同一驱动名的 probe 日志出现了几次。如果只出现一次说明设备树节点没全部匹配或者只有一部分节点被注册。如果出现多次但日志里的地址都一样说明读取 reg 的代码写死了或者设备树 reg 没写对。然后在驱动的 probe 里临时打印dev_name(dev)和platform_get_resource(pdev, IORESOURCE_MEM, 0)-start看每次 probe 拿到的地址是否和设备树节点对应。最后打开两个用户空间节点同时访问如果访问 A 设备时中断服务程序里读到的状态是 B 设备的基本可以确定是上下文指针没有按实例隔离检查platform_get_drvdata和中断函数的 data 参数传的是不是同一个指针。这三个方法都是很直接的排查手段不需要复杂的工具但在多设备驱动调试里往往比静态分析代码更有效。在实际项目里我见过不少驱动写得非常复杂各种全局变量、各种标志位最后只能靠“设备树里只保留一个节点”来规避问题。但设备数量一旦上来这套思路就崩了。个人建议是写驱动之前先把结构体设计好让每个设备实例的数据天然独立设备树负责描述硬件拓扑驱动负责按节点差异初始化。按这个思路做下来RK3568 上同时管理几路同型外设并不会增加多少复杂度真正的复杂度都来自一开始没把实例隔离开。