瑞芯微Linux驱动:一个驱动支持多设备的两个核心技巧

瑞芯微Linux驱动:一个驱动支持多设备的两个核心技巧 瑞芯微平台的 Linux 驱动开发绕不开“一个驱动管多个设备”这件事。我最早是在 RK3288 上被折腾醒的板子上挂了两个同型号的音频 codec一个 I2C 地址是 0x1a另一个是 0x1b结果驱动写完之后第二个设备死活 probe 不起来。后来才明白不是代码逻辑错而是匹配表和资源分配没处理好。这篇文章就把这两个高频技巧掰开揉碎讲清楚一是用of_device_id让一个驱动兼容多个型号二是让同一个驱动完美支持同型号设备的多个实例。适用对象是正在做 Linux BSP、嵌入式驱动或者刚接手瑞芯微方案的工程师内容基于瑞芯微 RK3568 这类常见平台展开但思路可以平移到任何设备树 Linux 平台。1. 多设备支持问题的来源与基础机制1.1 驱动面对的“多设备”到底是什么在实际项目里“一个驱动支持多个设备”通常分三种情况。第一种是同一个硬件 IP 在芯片内部有多个实例比如瑞芯微 RK3568 有 4 组 UART、3 组 I2C、多路 PWM它们的主控逻辑完全一样只是寄存器基地址和中断号不同。这种情况驱动写成一份绑定到不同的设备树节点上自然就支持多设备。第二种是板级设计上有多个同型号的外部芯片比如一个 I2C 总线上挂两个相同的温湿度传感器地址不同或者同一块板上用了两片相同的 GPIO 扩展芯片挂在不同的 I2C 总线上。它们对应设备树里多个 compatible 相同的节点驱动被多次 probe。第三种是硬件迭代新的主控和外设芯片型号变了但驱动逻辑大体兼容。比如同一个外设接口一代用芯片 A二代用芯片 B寄存器布局略有差异。这时候要么写两个驱动要么在一个驱动里做兼容。这篇文章说的两个技巧本质上是针对第二和第三种情况。理解了需求再看内核的匹配机制就不会发懵。1.2 Linux 里驱动和设备是怎么绑定的在设备树时代platform 设备和驱动匹配的核心是设备树节点里的compatible属性。驱动侧通过of_device_id表声明自己支持哪些 compatible 字符串内核在注册 platform 设备时会把设备树节点变成platform_device然后用platform_match去遍历驱动链表找到of_match_table中匹配的项再调用驱动的probe。这个流程听起来简单但有几个关键细节直接影响多设备支持驱动里的of_device_id表必须以空结构体结尾内核代码里一般写成{ /* sentinel */ }漏掉这个编译期可能不报错但运行时匹配会出问题。compatible字符串的匹配是严格字符串比较必须和设备树里完全一致最好把厂商前缀加上比如vendor,my-device。每个设备节点独立触发一次probe。也就是说设备树里写了两个 compatible 相同的节点驱动代码会被执行两次每次拿到的struct platform_device *pdev不同里面的资源也各自独立。理解到这个层次你就会明白所谓“支持多个设备”其实就是要做好两件事——匹配表覆盖所有需要的设备变体以及 probe 逻辑能根据当前pdev正确获取各自的资源、分配独立的运行实例。下面的两个技巧分别对应这两件事。2. 技巧一一张匹配表搞定多个硬件型号2.1 of_device_id 不只是 compatible 列表很多刚上手的人以为of_device_id就是单纯的字符串数组写几个 compatible 就行。确实可以这么用但它还有第二个字段.data这才是兼容多型号的关键。先看一个典型的错误写法驱动要支持芯片 A 和芯片 B于是of_match_table写两个字符串probe 函数里用of_device_is_compatible(pdev-dev.of_node, vendor,chip-a)来判断当前设备是哪个型号。这样功能没问题但每增加一种型号就要改 probe 函数代码越来越难维护。更好的做法是让匹配表成为“型号参数表”。在of_device_id的.data字段里存放一个指向该型号私有配置的结构体指针。内核在匹配时会把匹配到的.data回填给驱动probe 里用of_device_get_match_data()一行取出来。这样新增型号不需要动 probe 逻辑只需要在匹配表里加一条记录。/* 定义不同型号的差异参数 */ struct mydev_cfg { u32 reg_layout; u32 default_rate; bool has_irq; }; static const struct mydev_cfg chip_a_cfg { .reg_layout 1, .default_rate 48000, .has_irq true, }; static const struct mydev_cfg chip_b_cfg { .reg_layout 2, .default_rate 96000, .has_irq false, }; static const struct of_device_id mydev_of_match[] { { .compatible vendor,chip-a, .data chip_a_cfg }, { .compatible vendor,chip-b, .data chip_b_cfg }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, mydev_of_match); static int mydev_probe(struct platform_device *pdev) { const struct mydev_cfg *cfg of_device_get_match_data(pdev-dev); if (!cfg) return -EINVAL; dev_info(pdev-dev, reg_layout%d, default_rate%d\n, cfg-reg_layout, cfg-default_rate); /* 后续所有初始化都基于 cfg 参数执行 */ ... }这里MODULE_DEVICE_TABLE(of, mydev_of_match);也不能漏它会把匹配表导出到模块的别名信息里方便热插拔和模块自动加载。瑞芯微的 SDK 里绝大多数驱动都带这行但你如果是自己新建的驱动很容易忘记。2.2 用 driver_data 替代 if-else 判断.data字段本质上是一个unsigned long你可以直接放一个整数编号也可以放指针。我建议放指针指向结构体因为一个外设型号的差异项通常不只一个参数。比如瑞芯微平台上常见的 RGB 接口 LCD 屏驱动不同屏幕的时序参数、初始化序列完全不同这些差异如果散落在 probe 里做分支判断代码会膨胀得很厉害但如果每种屏把时序参数放到一个struct panel_desc里由匹配表带进来probe 就只剩“读参数、配寄存器、注册设备”三步。另一个常见的应用场景是 MIPI DSI 驱动。同一颗 SoC 可能搭配不同厂商的 DSI 屏屏 IC 的初始化命令、分辨率、porch 值都不同。正确做法就是把这些信息放到.data里而不是在驱动里写满if defined(...)。瑞芯微官方内核里的panel-simple.c就是教科书级别的例子它的of_device_id表里有几十个显示面板型号每个型号对应一个panel_desc结构体驱动主体部分完全不用感知具体型号。这个技巧带来的直接收益是新硬件适配时你只需要去 datasheet 里抄参数、在匹配表和参数结构体里各加一条记录编译烧录即可不用动驱动核心逻辑。对产品线长的公司来说这种驱动维护成本低很多。3. 技巧二同型号多实例设备的注册与资源隔离3.1 一个驱动如何对应多个设备节点如果板子上有两个完全相同的外设设备树里就对应两个节点它们 compatible 相同。比如两个vendor,my-sensor分别挂在 I2C0 和 I2C1 上或者两个 LED 控制器挂在不同地址。注意这里我们面对的节点类型在瑞芯微平台上通常是 platform 设备在 soc 地址空间内或者是 i2c_client。我们先说 platform 设备I2C 驱动的原理类似I2C client 的匹配表和资源获取方式稍有区别但“一个驱动多个实例”的设计思想一致。设备树里节点的写法像这样/ { my_device_0: my-device10000000 { compatible vendor,my-device; reg 0x0 0x10000000 0x0 0x1000; interrupt-parent gic; interrupts 0 32 IRQ_TYPE_LEVEL_HIGH; }; my_device_1: my-device20000000 { compatible vendor,my-device; reg 0x0 0x20000000 0x0 0x1000; interrupt-parent gic; interrupts 0 33 IRQ_TYPE_LEVEL_HIGH; }; };内核在启动时会为这两个节点分别创建platform_device然后逐个匹配到你的驱动probe被调用两次。两次 probe 的pdev不同但驱动代码是同一份代码。因此probe 中必须严格从当前的pdev资源里取寄存器和中断号不能用任何全局变量保存硬件资源。这一点是初级驱动工程师最容易踩的坑习惯了写单片机裸机代码用全局变量记录 base_addr第二个设备 probe 时直接覆盖了第一个导致第一个设备功能全乱。3.2 probe 中获取独立资源的标准姿势获取设备树节点里的寄存器地址和中断号最稳妥的是用内核提供的接口static int mydev_probe(struct platform_device *pdev) { struct resource *res; void __iomem *base; int irq; res platform_get_resource(pdev, IORESOURCE_MEM, 0); if (!res) return -ENXIO; base devm_platform_ioremap_resource(pdev, 0); if (IS_ERR(base)) return PTR_ERR(base); irq platform_get_irq(pdev, 0); if (irq 0) return irq; dev_info(pdev-dev, base%px, irq%d\n, base, irq); return 0; }devm_platform_ioremap_resource是of_address_to_resource加devm_ioremap_resource的封装它有两个好处一是自动处理了res的有效性检查二是和devm资源管理框架绑定驱动卸载或 probe 失败时自动释放不会内存泄漏。瑞芯微 SDK 里的驱动基本都是这套写法你可以打开一个 rk3568 的 pwm 驱动对照看。这里还想强调一个容易被忽略的点reg属性在 64 位 ARM 平台下可能带父地址单元你的#address-cells和#size-cells在设备树里如果设了2那reg里的地址就是 64 位platform_get_resource依然能正确返回但你不能自己在驱动里用of_property_read_u32去读reg否则拿到的只是低 32 位。除非你写的是片内外设且地址在低 32 位范围否则还是老老实实走platform_get_resource。3.3 实例编号与字符设备节点分配每个 probe 执行的实例需要有自己的名字、自己的私有数据区、自己的设备节点如果是字符设备。用全局数组struct mydev_dev devs[MAX_DEV]这种办法不推荐因为上限写死了而且并发 probe 时同步处理麻烦。内核给你准备了现成的 ID 分配器ida可以动态分配一个不重复的编号。static DEFINE_IDA(mydev_ida); struct mydev_dev { int id; void __iomem *base; int irq; struct miscdevice miscdev; }; static int mydev_probe(struct platform_device *pdev) { struct mydev_dev *dev; int ret; dev devm_kzalloc(pdev-dev, sizeof(*dev), GFP_KERNEL); if (!dev) return -ENOMEM; dev-id ida_alloc(mydev_ida, GFP_KERNEL); if (dev-id 0) return dev-id; dev-base devm_platform_ioremap_resource(pdev, 0); if (IS_ERR(dev-base)) { ret PTR_ERR(dev-base); goto err_free_ida; } dev-miscdev.minor MISC_DYNAMIC_MINOR; dev-miscdev.name devm_kasprintf(pdev-dev, GFP_KERNEL, mydev%d, dev-id); dev-miscdev.fops mydev_fops; dev_set_drvdata(pdev-dev, dev); ret misc_register(dev-miscdev); if (ret) goto err_free_ida; dev_info(pdev-dev, registered as /dev/%s\n, dev-miscdev.name); return 0; err_free_ida: ida_free(mydev_ida, dev-id); return ret; } static int mydev_remove(struct platform_device *pdev) { struct mydev_dev *dev dev_get_drvdata(pdev-dev); misc_deregister(dev-miscdev); ida_free(mydev_ida, dev-id); return 0; }miscdevice是管理单字符设备的便捷框架每个实例注册后会自动在/dev/下生成名字不同的设备节点比如/dev/mydev0、/dev/mydev1。这种方案比手动分配主次设备号简单得多非常适合“同类设备多实例”的场景。不过要注意miscdevice的主设备号是共享的10靠次设备号区分节点如果设备节点需要大量数据传输还是建议直接用cdev加自定义主设备号。如果是 I2C 客户设备probe 的入参是struct i2c_client *client获取资源的方式略有不同寄存器地址不是用platform_get_resource而是用client-addr中断号用client-irqi2c_driver的匹配表同样用of_device_id设备树里节点的compatible会匹配到这个 driver。整体多实例思想不变。4. 基于瑞芯微 RK3568 的实战案例4.1 需求定义两个自定义 LED 控制器纸上谈兵没意思我结合 RK3568 平台设计一个可以直接实操的场景板子上有两个相同的“呼吸灯控制器 IP”分别挂在两个不同的寄存器地址空间每个控制器有独立的控制寄存器和中断。为了演示我们假设它们的基地址分别是0xFE100000和0xFE200000寄存器映射为0x00控制开关0x04设置亮度。设备树里声明两个节点驱动需要分别初始化这两个控制器并在内核中注册成两个 led 设备或两个 misc 设备。考虑到工程量我们不用真正的 RK3568 硬件地址重点是流程。你可以在 QEMU 或你自己的实验板上模拟。4.2 设备树编写设备树里要正确描述两个控制器。我把它们挂在根节点下面使用 compatiblevendor,led-blaster/ { compatible rockchip,rk3568; led_blaster_0: led-blasterfe100000 { compatible vendor,led-blaster; reg 0x0 0xfe100000 0x0 0x1000; interrupt-parent gic; interrupts 0 64 IRQ_TYPE_LEVEL_HIGH; label left; }; led_blaster_1: led-blasterfe200000 { compatible vendor,led-blaster; reg 0x0 0xfe200000 0x0 0x1000; interrupt-parent gic; interrupts 0 65 IRQ_TYPE_LEVEL_HIGH; label right; }; };这里我用了一个自定义label属性用来区分两个设备的逻辑名字。你也可以直接用节点名字区分但label更灵活。在驱动里读取label可以用of_property_read_string(pdev-dev.of_node, label, name)。这样让每个实例拥有语义化的名字。4.3 驱动代码实现驱动需要做到三点匹配vendor,led-blaster节点每个 probe 实例从设备树拿到基地址和中断号为每个实例注册一个 misc 设备并通过label属性生成设备节点名。代码我写个核心版本可直接编译实验#include linux/module.h #include linux/platform_device.h #include linux/of.h #include linux/of_device.h #include linux/of_address.h #include linux/miscdevice.h #include linux/fs.h #include linux/uaccess.h #include linux/interrupt.h #include linux/io.h #include linux/ida.h #define LED_BLASTER_BRIGHTNESS_REG 0x04 struct led_blaster_dev { struct device *dev; void __iomem *base; int irq; int id; char name[32]; struct miscdevice mdev; }; static DEFINE_IDA(led_blaster_ida); static irqreturn_t led_blaster_isr(int irq, void *data) { struct led_blaster_dev *ldev data; u32 v ioread32(ldev-base 0x08); dev_info(ldev-dev, irq %d handled, status0x%x\n, irq, v); return IRQ_HANDLED; } static long led_blaster_ioctl(struct file *file, unsigned int cmd, unsigned long arg) { struct led_blaster_dev *ldev file-private_data; switch (cmd) { case 0x01: /* 设置亮度 */ { u32 val; if (copy_from_user(val, (void __user *)arg, sizeof(val))) return -EFAULT; iowrite32(val, ldev-base LED_BLASTER_BRIGHTNESS_REG); return 0; } default: return -ENOTTY; } } static int led_blaster_open(struct inode *inode, struct file *file) { struct miscdevice *mdev container_of(inode-i_cdev, struct miscdevice, this_device); file-private_data container_of(mdev, struct led_blaster_dev, mdev); return 0; } static const struct file_operations led_blaster_fops { .owner THIS_MODULE, .open led_blaster_open, .unlocked_ioctl led_blaster_ioctl, }; static int led_blaster_probe(struct platform_device *pdev) { struct device *dev pdev-dev; struct led_blaster_dev *ldev; int ret; ldev devm_kzalloc(dev, sizeof(*ldev), GFP_KERNEL); if (!ldev) return -ENOMEM; ldev-dev dev; ldev-base devm_platform_ioremap_resource(pdev, 0); if (IS_ERR(ldev-base)) return PTR_ERR(ldev-base); ldev-irq platform_get_irq(pdev, 0); if (ldev-irq 0) return ldev-irq; ret devm_request_irq(dev, ldev-irq, led_blaster_isr, IRQF_SHARED, dev_name(dev), ldev); if (ret) return ret; ldev-id ida_alloc(led_blaster_ida, GFP_KERNEL); if (ldev-id 0) return ldev-id; { const char *label led; of_property_read_string(dev-of_node, label, label); snprintf(ldev-name, sizeof(ldev-name), %s-%d, label, ldev-id); } ldev-mdev.minor MISC_DYNAMIC_MINOR; ldev-mdev.name ldev-name; ldev-mdev.fops led_blaster_fops; ret misc_register(ldev-mdev); if (ret) { ida_free(led_blaster_ida, ldev-id); return ret; } dev_set_drvdata(dev, ldev); dev_info(dev, probed, device node /dev/%s\n, ldev-name); return 0; } static int led_blaster_remove(struct platform_device *pdev) { struct led_blaster_dev *ldev dev_get_drvdata(pdev-dev); misc_deregister(ldev-mdev); ida_free(led_blaster_ida, ldev-id); return 0; } static const struct of_device_id led_blaster_of_match[] { { .compatible vendor,led-blaster }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, led_blaster_of_match); static struct platform_driver led_blaster_driver { .probe led_blaster_probe, .remove led_blaster_remove, .driver { .name led-blaster, .of_match_table led_blaster_of_match, }, }; module_platform_driver(led_blaster_driver); MODULE_LICENSE(GPL); MODULE_DESCRIPTION(Sample shared driver for multiple devices on Rockchip); MODULE_AUTHOR(Your Name);这段代码里有一个值得注意的地方devm_request_irq用了IRQF_SHARED。如果在同一块板子上两个实例共享同一个中断号或者内核里其他驱动也注册了同一个中断IRQF_SHARED是必须的否则请求会失败。实战中瑞芯微多个外设共用 GIC SPI 中断的情况不多但 GPIO 中断被多个设备共享的情况很常见这里先留个心眼。另一个设计点是led_blaster_open里通过container_of从struct miscdevice反推出struct led_blaster_dev。这是内核驱动最常见的反向查找手法。你也可以在misc_register前把指针塞进mdev.parent但本质上container_of更干净。4.4 在 RK3568 上验证多设备生效编译这个驱动为模块后把它放到板子的文件系统里然后加载insmod led_blaster.ko正常情况内核日志中会出现两次probed, device node /dev/left-0和/dev/right-1这样的打印label分别为left、rightida从 0 开始分配 id所以 first node 是 left-0second node 是 right-1。查看/dev/下是否存在对应节点ls -l /dev/left-0 /dev/right-1如果看到c 10:xxx的字符设备说明两个实例都注册成功。用 ioctl 测试亮度设置echo -e \x64\x00\x00\x00 /tmp/val.bin ./ioctl_test /dev/left-0 1 /tmp/val.bin如果没有现成的 ioctl 测试工具可以直接在驱动里加一个 write 回调或者用 busybox 的devmem检查寄存器值是否变化读 0xfe100004 看到 0x64读 0xfe200004 还是原始值说明只有第一个实例被点亮。这一步能验证资源隔离是否正确。5. 常见问题与排查心得5.1 驱动已加载但 probe 没有被调用这是多设备场景里最让人头疼的问题之一。我通常在瑞芯微平台排查三步检查设备树节点状态status属性是否等于okay如果设备树里写的是disabled内核不会创建 platform_device。检查 compatible 字符串是否完全一致设备树里多一个空格、少一个厂商前缀都会匹配失败。可以用/sys/firmware/devicetree/base/下的信息确认实际设备树内容。检查驱动是否注册到了正确总线。如果节点挂在 I2C 节点下内核创建的是i2c_client而不是platform_device此时必须用i2c_driver而不是platform_driver。这个错误在瑞芯微平台上很常见因为很多外设的控制器节点在 SoC 内部地址空间是 platform 设备但外部芯片挂在 I2C/SPI 总线上就应该使用相应的 subsystem driver。如果以上都正常再用ls /sys/bus/platform/drivers/led-blaster/看看驱动是否绑定到两个platform_device没有的话就手动echo设备地址到bind接口触发绑定排查。5.2 第二个设备 probe 失败寄存器操作影响第一个设备典型症状第一个设备正常工作加载第二个设备后第一个设备输出异常。多半是驱动里用了静态全局变量保存基地址或实例结构体指针。比如static void __iomem *global_base; static int probe(struct platform_device *pdev) { global_base devm_platform_ioremap_resource(pdev, 0); ... }第二次 probe 调用时global_base被覆盖第一个设备的中断处理函数里仍然读global_base自然就读到了第二个设备的寄存器。解决办法很简单把硬件资源放到每个实例自己的结构体里中断处理、读写函数都通过container_of或者dev_get_drvdata拿到当前实例的指针不要使用任何共享变量保存资源。5.3 设备节点名称冲突或次设备号混乱如果直接用misc_register且mdev.name是固定字符串比如mydev第一个设备注册成功后第二个设备注册时会返回-EBUSY因为内核中不允许两个相同名字的 misc 设备节点。解决方案就是像前面代码那样用ida分配实例号名字带-0、-1后缀。使用ida时还要小心资源回收remove里面必须对应ida_free否则反复 insmod/rmmod 后编号会一直增长直到溢出。短时间调试没什么问题长期运行就可能出现负编号之类的怪事。我自己的习惯是remove和probe的每个错误路径都检查一遍是否漏了ida_free。另一个坑和miscdevice的minor有关。如果你用MISC_DYNAMIC_MINOR内核自动分配一般没问题但如果你手动指定一个固定次设备号比如 100而系统中已经有设备占用misc_register会失败。手动编号只有在确需固定节点名时才有必要大部分场景动态分配就够了。5.4 中断号与资源索引混淆platform_get_irq(pdev, 0)里的索引和device tree的interrupts属性是对应的。如果一个设备节点里有多个中断比如主中断和错误中断第二个中断用platform_get_irq(pdev, 1)。同一个设备树里两个节点各自有自己的父中断域索引不会互相干扰但驱动开发人员容易在复制粘贴时搞混把第一个设备的index1用到第二个设备上导致拿到错误中断。我的建议是给中断定义宏#define MYDEV_IRQ_MAIN 0 #define MYDEV_IRQ_ERR 1这样写代码时语义清晰也不容易在多个实例间搞错。5.5 设备树里带父地址单元时资源读取错误RK3568 是 64 位 SoC如果你定义自己的总线节点时把#address-cells设成了2但驱动的probe里用of_property_read_u32(np, reg, res)那结果大概率不对。因为reg是一个多单元数组u32 只能读到第一个 cell也就是高 32 位地址。这种问题很难查因为地址看起来像0x00000000读函数不报错但后续ioremap解引用就会挂掉。标准做法一定是用platform_get_resource(pdev, IORESOURCE_MEM, 0)拿统一资源或者用of_address_to_resource让内核的地址解析函数帮你处理 cell 大小。千万不要自己拿of_property_read_u32去拼地址。最后再分享一点实际体会这两个技巧看起来简单但它们决定了驱动能不能从“能跑”进化到“好维护”。我在瑞芯微平台做了不少驱动适配最后发现写驱动时多花十分钟把of_device_id的.data字段用起来多花十分钟把实例资源封装进私有结构体后面能省下大量排障时间。遇到个新硬件版本加一行匹配表板子上多加一片芯片设备树加一个节点驱动里面什么都不用改。反倒是那些一开始图省事、把型号判断写死在 probe 里的驱动后期每次硬件改版都要提心吊胆生怕漏掉一个分支。如果你刚开始写瑞芯微驱动建议直接去翻 SDK 里现成的驱动比如drivers/pwm/pwm-rockchip.c和drivers/tty/serial/8250/8250_dw.c看看它们是怎么用of_device_id和platform_get_resource管理多个串口和 PWM 的。把这两个文件读透今天讲的这两个技巧就基本掌握了一半。