瑞芯微平台多设备驱动开发:设备树匹配与次设备号管理技巧
1. 多设备支持究竟在解决什么问题如果你做过嵌入式Linux驱动开发一定遇到过这种场景板子上挂了两个同型号的UART转串口芯片驱动模块insmod加载后/dev下面只出来一个节点又或者同一颗codec芯片同时挂在I2C0和I2C2上寄存器读写一切正常但音频通路怎么都只走第一路。这种“驱动代码本身没报错硬件也没烧坏就是设备起不来”的问题十有八九是驱动没有真正支持多设备实例。瑞芯微平台RV1126、RK3399、RK3588这些在工业、AIoT项目里非常常见板子上同型号外设一挂就是三四个的场景特别多。比如带六个摄像头的视觉网关、带四路隔离串口的协议转换盒子、双以太网加双CAN的工控板。每种外设背后都对应一个驱动而驱动能不能同时handle多个实例直接决定你的产品能不能从“原型板”走到“量产”。这篇文章想聊的就是我在瑞芯微平台上调试多设备驱动时总结下来的两个核心技巧一个解决“同一驱动如何被内核匹配到多个硬件节点”另一个解决“多个设备实例如何区分读写对象”。这两件事想通了多设备支持的基本盘就稳了。2. 方案选型单驱动多实例的两种实现路径在动手写代码之前先要想清楚一个问题Linux驱动模型里一个驱动到底是怎么和一个硬件设备对应起来的内核里有两条链一条是device链描述硬件来自设备树或总线枚举一条是driver链描述驱动逻辑。驱动框架做的核心事情就是把这两条链配对配上一对就调一次probe没配上就一直等。所以“支持多个设备”本质上是让同一个driver结构体能够和多个device节点完成多次配对每次配对都走一遍独立的probe分配独立的实例数据。在瑞芯微平台上实现这种多实例支持最常见的有两条技术路线实现策略核心手段适用场景坑点设备树多节点匹配在DTS里声明多个相同compatible的节点驱动通过of_match_table匹配SPI/I2C/PIO等平台设备需要处理好资源申请冲突动态次设备号分发使用alloc_chrdev_region自动分配设备号用次设备号区分实例字符设备如串口、GPIO、传感器需要自己管理实例映射表仔细看你会发现这两条路径并不冲突。设备树多节点匹配解决的是“probe怎么被多次调用”的问题次设备号分发解决的是“open/read/write时内核怎么知道你要访问哪一个设备”。实际项目里这两个技巧经常要配合使用。比如我在RK3588上调试四路RS485驱动就是设备树里写了四个uart节点驱动里用一个次设备号偏移量把四个串口映射成/dev/rs485_0到/dev/rs485_3。有人可能会问为什么不干脆写四个独立的驱动模块或者用一个驱动内嵌四个静态变量如果你只是实验室里点亮一块板子那怎么省事怎么来。但量产固件里设备树是随着硬件配置变化的可能出货A版本挂三个串口B版本挂四个C版本外接扩展板还要加一路。驱动代码如果写死了实例数每改一次硬件就重新编译一次内核模块这个维护成本很快就会让你崩溃。3. 技巧一基于设备树的多实例匹配3.1 设备树节点与驱动匹配的核心机制设备树DTS是Linux平台设备驱动与硬件建立联系的桥梁。瑞芯微的SDK里芯片默认的dtsi文件比如rk3588s.dtsi定义了芯片上所有控制器资源板级dts文件比如rv1126-evb.dts或产品自定义dts则负责声明“这块板子上实际用了哪些设备”。多设备支持的第一步就是在板级dts或产品dts里把同型号外设的多个节点声明出来。以最常见的I2C设备为例i2c0 { status okay; sensor48 { compatible vendor,temperature_sensor; reg 0x48; interrupt-parent gpio1; interrupts RK_PB2 IRQ_TYPE_EDGE_FALLING; }; }; i2c2 { status okay; sensor48 { compatible vendor,temperature_sensor; reg 0x48; interrupt-parent gpio3; interrupts RK_PA1 IRQ_TYPE_EDGE_FALLING; }; };这里有一个关键点两个节点的compatible字段完全相同都是vendor,temperature_sensor。驱动注册时声明的of_match_table里包含这个字符串那么内核在扫描设备树的时候只要发现一个compatible匹配的节点就会触发一次probe。两个节点就触发两次三个节点就触发三次。驱动代码不需要为每个设备写一份内核的platform bus机制会自动完成“一对多”的配对。3.2 probe函数内的实例隔离处理既然probe会被调用多次那么驱动里就不能再使用全局变量或者静态数组来保存设备的私有数据。我在瑞芯微平台上写驱动时习惯在probe入口处立刻分配一个struct private_data结构体把所有buffer、锁、中断号、GPIO编号全部放在这个结构体里然后把这个结构体指针通过platform_set_drvdata(pdev, data)保存起来。static int sensor_probe(struct platform_device *pdev) { struct sensor_priv *priv; struct resource *res; int ret; priv devm_kzalloc(pdev-dev, sizeof(*priv), GFP_KERNEL); if (!priv) return -ENOMEM; priv-dev pdev-dev; /* 从设备树节点读取中断号和GPIO配置 */ priv-gpio_irq platform_get_irq(pdev, 0); if (priv-gpio_irq 0) return priv-gpio_irq; res platform_get_resource(pdev, IORESOURCE_MEM, 0); priv-regs devm_ioremap_resource(pdev-dev, res); if (IS_ERR(priv-regs)) return PTR_ERR(priv-regs); /* 每路设备独立的锁和状态 */ mutex_init(priv-lock); init_waitqueue_head(priv-waitq); platform_set_drvdata(pdev, priv); return 0; }注意这里用到的devm_kzalloc和devm_ioremap_resource它们是设备管理device managedAPI。好处是任何一步出错返回时内核会自动释放前面已经申请成功的资源不需要手动写一堆goto错误清理标签。对于多实例驱动来说这个优势特别明显——两个设备先后probe如果second设备在中途失败了first设备的资源完全不受影响。3.3 瑞芯微平台上的资源冲突避坑在多实例场景下最容易踩的坑就是资源冲突。两类冲突最常见第一类是GPIO冲突。两个设备节点如果配了同一组GPIOprobe的时候不会报错但运行时会互相拉扯电平。瑞芯微平台pinctrl子系统会自动进行GPIO复用申请所以这种冲突在设备树编译阶段根本看不出来要等实际调试时用示波器量电平才发现。我的建议是每个设备的GPIO分配完成后在驱动里显式调用devm_gpio_request并检查返回值一旦返回-EBUSY立刻在日志里打清楚的错误信息。第二类是寄存器地址冲突。如果你的设备挂在某个总线控制器下面DTS里的reg属性必须落在该控制器合法的地址窗口内。瑞芯微的RK3588有多个I2C控制器每个控制器的地址范围在TRM里有明确规定。不核对寄存器地址随便照着一个已有的节点改个地址就上板轻则probe失败重则引起总线挂死。3.4 如何验证设备树是否被正确解析驱动写完之后先别急着跑应用层优先验证设备树解析对不对。瑞芯微平台的串口调试台下执行# 查看设备树中sensor节点的绑定状态 ls /proc/device-tree/i2cfe830000/sensor48/ # 查看驱动与设备的绑定关系 ls -l /sys/bus/platform/drivers/vendor, temperature_sensor/如果驱动正确绑定你会看到platform_driver目录下出现两个符号链接分别指向两个I2C总线上的sensor设备。如果只出现一个说明另一个节点没有被扫描到——大概率是status没有改成okay或者compatible拼写和驱动里的of_match_table不一致。你也可以在触发probe之前用dtc工具反编译设备树来检查dtc -I fs -O dts /proc/device-tree/ -o debug.dts grep -A 5 sensor48 debug.dts看到两个节点的compatible、reg、interrupt-parent都与你预期一致再继续往下调。4. 技巧二字符驱动用次设备号管理多个实例4.1 主次设备号的分工逻辑设备树层面打通了“pair多个硬件节点”之后下一个问题就是用户空间的应用程序open(/dev/xxx0)和open(/dev/xxx1)内核怎么知道它们访问的是哪一路设备答案就是主次设备号。Linux字符设备的设备号由一个dev_t类型表示高12位是主设备号低20位是次设备号。主设备号用于确定操作这个设备对应哪个驱动程序次设备号则用来区分同一驱动下的不同设备实例。你有两个串口芯片主设备号都是240次设备号一个是0一个是1。内核调用驱动注册的file_operations时你可以通过iminor(inode)拿到次设备号从而知道用户访问的是第几路设备。老一代的驱动开发者前辈喜欢用register_chrdev一次性注册主设备号并自动绑定0~255的次设备号这个接口简单粗暴但有两个缺点一是次设备号不可控二是无法灵活指定某一段次设备号。现在的内核推荐用register_chrdev_region已知起始设备号或alloc_chrdev_region让内核自动分配空闲主设备号。4.2 一套完整的次设备号管理实例下面这段代码是我在瑞芯微平台上管理多路单线总线传感器驱动的模板可以直接套到大多数字符驱动上#define MAX_DEVICES 4 #define DEVICE_BASE_MINOR 0 static dev_t g_sensor_devt; static struct cdev g_sensor_cdev; static struct class *g_sensor_class; static int sensor_open(struct inode *inode, struct file *filp) { unsigned int minor iminor(inode); struct sensor_priv *priv; if (minor MAX_DEVICES) return -ENODEV; /* 通过数组拿到该次设备号对应的私有数据 */ priv g_sensor_privs[minor]; if (!priv) return -ENODEV; filp-private_data priv; return nonseekable_open(inode, filp); } static ssize_t sensor_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { struct sensor_priv *priv filp-private_data; /* 对priv里的数据做操作天然隔离多个实例 */ ... } static int __init sensor_module_init(void) { int ret, i; /* 动态分配设备号主设备号内核自动分配次设备号给连续段 */ ret alloc_chrdev_region(g_sensor_devt, DEVICE_BASE_MINOR, MAX_DEVICES, multi_sensor); if (ret) return ret; /* 注册字符设备cdev_add后/dev节点才能工作 */ cdev_init(g_sensor_cdev, sensor_fops); g_sensor_cdev.owner THIS_MODULE; ret cdev_add(g_sensor_cdev, g_sensor_devt, MAX_DEVICES); if (ret) { unregister_chrdev_region(g_sensor_devt, MAX_DEVICES); return ret; } /* 创建设备类配合udev/mdev自动生成/dev节点 */ g_sensor_class class_create(multi_sensor); if (IS_ERR(g_sensor_class)) { cdev_del(g_sensor_cdev); unregister_chrdev_region(g_sensor_devt, MAX_DEVICES); return PTR_ERR(g_sensor_class); } /* 对应每个probe出的实例都创建一个设备节点 */ for (i 0; i MAX_DEVICES; i) { if (g_sensor_privs[i]) device_create(g_sensor_class, NULL, MKDEV(MAJOR(g_sensor_devt), i), NULL, multi_sensor%d, i); } return 0; } module_init(sensor_module_init);这里面有三个细节值得展开讲第一alloc_chrdev_region和MKDEV配合使用让每个实例获得唯一的dev_t。假如主设备号自动分到了240那么实例0的dev_t就是MKDEV(240,0)实例1就是MKDEV(240,1)以此类推。第二filp-private_data是在open阶段把实例数据绑定到文件描述符的关键。这样后续的read/write/ioctl只需要从filp-private_data取数据完全不需要再区分是第几路设备内核替你做完了分发。第三device_create触发uevent后busybox mdev或systemd-udevd会在/dev目录下自动创建设备节点。瑞芯微的buildroot系统默认启用mdev开机时会扫描设备类自动生成节点如果你的系统没有自动生成要检查一下/etc/mdev.conf或/lib/udev/rules.d里是否放了对应规则。4.3 避免用全局数组改用idr或容器宏上面的示例为了方便起见用了g_sensor_privs[MAX_DEVICES]这个全局数组来保存各实例的指针。实例数量少时这样写没问题但实例数量不固定、支持热插拔的时候这个方案就不好用了。我推荐两个替代方案方案一是内核提供的idr数据结构。idr可以动态地把一个ID映射到一个指针申请和释放都非常方便适合“不知道最多会有多少个实例”的场景。方案二是用container_of。你在platform_device的私有数据里放一个字符设备结构体然后通过container_of(inode-i_cdev, struct sensor_priv, cdev)反推出实例指针。这个方案更“内核范儿”代码看起来也优雅struct sensor_priv { ... struct cdev cdev; struct device *dev; }; static int sensor_open(struct inode *inode, struct file *filp) { struct sensor_priv *priv container_of(inode-i_cdev, struct sensor_priv, cdev); filp-private_data priv; return 0; }这种写法不需要全局数组也不需要idr映射。无论probe了多少个设备实例每个实例的cdev都被嵌在私有结构体里open时内核根据i_cdev自然就能定位到对应实例。4.4 设备节点权限与多用户访问的控制设备节点生成在/dev下之后默认权限是root:root和0600。如果产品的应用程序以非root用户运行就要注意权限问题。我一般在device_create里不传device_create的mode参数因为该接口不支持设置权限。更推荐的做法是在udev规则里统一设置KERNELmulti_sensor*, MODE0666或者在驱动初始化之后调用sysfs_create_group创建属性文件配合udev的TAGuaccess让登录用户直接获得访问权限。5. 瑞芯微平台上的常见问题与调试实录5.1 两个设备只能识别到一个另一个在/sys/bus/platform/drivers里查无此人这个问题的排查思路按照“设备树-内核扫描-驱动匹配”的顺序来。先看设备树是否真的包含了两个节点。瑞芯微的SDK里有时候dtsi里定义的节点被板级dts覆盖时status被改回disabled或者I2C/SPI总线的pinctrl-0覆盖了错误引脚组。别信编译阶段的dtc输出一定要在板子上实际反读设备树方法前面已经说过了用dtc -I fs读取运行时的/proc/device-tree。再看内核日志dmesg | grep -i of_unittest\|sensor\|platform如果看到类似OF: fsl,spi40000000: could not get #gpio-cells的消息说明DTS里引用的节点或属性缺失。如果看到platform sensor.0: Driver sensor requests probe deferral说明依赖的资源还没ready需要检查依赖模块是不是没加载。5.2open两个节点返回同一个设备的数据这是一个典型的次设备号使用错误。常见原因是在file_operations里用了container_of(file-private_data)、但private_data被错误地赋成了同一个指针或者是open里只判断了主设备号没判断次设备号。你可以用下面的方法快速验证次设备号分发是否正确ls -l /dev/multi_sensor*如果设备节点的主设备号和次设备号都正确那么继续在open函数里加一行调试打印pr_info(open called: major%d minor%d\n, MAJOR(inode-i_rdev), MINOR(inode-i_rdev));打印出来的minor值和/dev节点显示的值不一致说明device_create创建节点时MKDEV参数传错了如果一致但数据错乱那问题就出在private_data赋值上。5.3 模块卸载时出现Unregister chrdev region的告警多实例驱动卸载时最常见的告警是Device or resource busy或者unregister_chrdev_region: bad unregister。前者说明有文件仍然被打开后者说明设备号和cdev_del的顺序错了。正确顺序应该是先device_destroy删除/dev下的节点再cdev_del注销字符设备最后unregister_chrdev_region释放设备号。顺序反过来就会出现各种奇怪告警。如果你用了devm_*系列接口这些资源的释放顺序是由内核帮你保证的这也是为什么我推荐在新的驱动里尽量用devm系列。5.4 排查工具清单目的命令/方法查看当前系统占用的设备号cat /proc/devices查看设备节点绑定驱动情况ls -l /sys/class/multi_sensor/动态跟踪驱动probe/remove调用trace-cmd record -e probe*或ftrace的function_graph验证设备树运行时解析dtc -I fs -O dts /proc/device-tree/查看设备节点创建事件udevadm monitor --property判定硬件资源冲突cat /proc/iomem、cat /sys/kernel/debug/gpio瑞芯微平台还有一个官方工具值得提一句/sys/kernel/debug/dri/和/sys/kernel/debug/pinctrl/目录下可以看到各个引脚复用的当前状态。多设备调试中一旦发现“某一路设备工作正常另一路完全没反应”大概率就是pinctrl冲突去这个目录下查一眼能省不少时间。6. 写在最后我在一块RK3568的板子上调试双路温度采集时也踩过整晚的坑。当时两个芯片挂在同一条I2C总线上设备树里只写了一个节点驱动代码里加了一堆全局变量来存两路数据最后的结果就是第一路数据稳定第二路数据偶尔飘。改成设备树多节点次设备号分发的方式之后整个驱动模块干净了不止一个档次数据隔离问题也随之消失。这就是这两个小技巧的核心价值它们不是炫技而是把你的驱动从“一台设备一个状态机”的思维里解放出来让它回归到Linux驱动模型本来的设计逻辑上。硬件扩展了驱动不用改驱动上了一个实例的资源管理方式其他实例自动享受同样待遇。配合瑞芯微平台完整的设备树生态这套方案在代码维护上能给你省下非常多的工时。如果你手上正好有块RK开发板不妨拿一路简单的GPIO驱动练练手把两个节点和两个次设备号打通再回头看那些复杂的外设驱动思路会清晰得多。