Linux驱动多设备支持:设备树匹配与字符设备次设备号实战 做瑞芯微平台的嵌入式Linux开发这几年最常遇到的一个需求就是“同一个驱动得伺候好几路一模一样的外设”。举个例子RK3568上挂两个SPI接口的传感器或者I2C总线上接两片同型号触摸IC再或者调试工装里同时插着两路的USB转串口芯片这些看起来都是小事真写到driver里就发现坑不少要么驱动只认第一个设备要么第二个设备一probe就把前一个的状态覆盖了要么设备节点注册不上、打开失败。这篇就围绕平台开发里最实用的两个技巧展开帮你把“Linux驱动支持多个设备”这件事彻底理顺。1. 这个需求到底在解决什么问题1.1 什么场景下必须考虑多设备支持先说场景很多人写驱动是从单个硬件开始的比如先用一个开发板点亮一块屏、读到一个传感器驱动能跑就交差了。但一旦进入量产阶段单板上的外设数量往往不止一路。典型场景一RK3568的SPI0上挂了两片同型号的NOR Flash一片放系统一片放用户数据。两片Flash挂在同一个SPI控制器下用不同的片选驱动必须能同时管理两个设备并且读写A时不能影响B。典型场景二一款带主屏和副屏的设备两块屏幕分别接了同型号的触摸IC。两颗触摸IC挂在不同的I2C控制器上内核驱动需要为每一颗各自维护状态上报的触摸点不能串。典型场景三调试工装上同时插了多路USB转串口芯片CH340、CP2102这类。插上之后系统出现多个ttyUSB节点每个节点对应一个物理串口。这背后就是同一个驱动注册了多个字符设备实例。这些场景在瑞芯微平台上非常常见尤其是RK3568、RV1106这类管脚多、外设接口丰富的芯片板级工程师经常会为了省事把同型号外设铺满。驱动开发如果一开始没按“多实例”的方式设计后面返工的代价很大。1.2 多设备问题的本质数据归属Linux的驱动模型其实是支持多实例的。设备树里写了两个相同compatible的节点内核就会为它们分别创建platform_device然后你的probe函数会被调用两次。这个机制本身不需要你做任何额外工作真正出问题的地方在于probe跑第二遍的时候驱动代码并不知道自己在处理第二个设备。最常见的问题就是全局变量。很多驱动为了方便喜欢在文件顶部定义一个全局的结构体指针比如static struct my_dev *g_dev;第一个设备probe的时候给它赋值第二个设备probe的时候直接覆盖。等用户空间操作第一个设备时驱动拿着的是第二个设备的数据整个逻辑全乱。把这些现象抽象一下多设备支持的本质是两个问题第一如何在内核侧为每一路设备保存独立的私有数据第二如何让用户空间打开不同节点时能找到对应的那一份私有数据。这两个问题正好对应下面要说的两个技巧。注意写多设备驱动时先问自己一句“这个变量是所有实例共享的还是每个实例独有的”。大多数状态变量都应该是实例私有的只有那些真正的全局资源比如分配设备号用的位图、全局注册表才需要共享和加锁。2. 技巧一设备树匹配私有数据让每个probe管一路设备2.1 设备树里先声明两路同类设备驱动是跟着设备树走的。这里我用一个自定义platform设备举例一套板子上有两路同型号的“多传感器”分别接到不同的GPIO、不同的时钟源上。设备树里写两个节点compatible相同/ { multi_sensor_a: multi-sensor0 { compatible vendor,multi-sensor; reg 0x0 0x100; interrupt-parent gpio3; interrupts RK_PB2 IRQ_TYPE_EDGE_FALLING; reset-gpios gpio3 RK_PB3 GPIO_ACTIVE_LOW; clocks cru SCLK_SPI0; clock-names sclk; }; multi_sensor_b: multi-sensor1 { compatible vendor,multi-sensor; reg 0x100 0x100; interrupt-parent gpio4; interrupts RK_PA1 IRQ_TYPE_EDGE_FALLING; reset-gpios gpio4 RK_PA2 GPIO_ACTIVE_LOW; clocks cru SCLK_SPI1; clock-names sclk; }; };两个节点都叫multi-sensor但reg地址不同、使用的GPIO/中断/时钟各不相同。节点a挂在GPIO3和SCLK_SPI0上节点b挂在GPIO4和SCLK_SPI1上资源完全隔离。强调一点如果设备挂在I2C或SPI总线上驱动模型对应的是i2c_driver或spi_driver不是platform_driver但“多实例私有数据”的思路完全一致。这里用platform设备来举例是为了避开总线协议细节把核心逻辑讲清楚。2.2 用of_device_id的data字段区分硬件版本驱动里最常见的一个需求是两个设备虽然compatible相同但硬件版本可能不同初始化参数也不同。此时不需要写两份驱动只需要在of_device_id里挂上不同版本的配置数据。struct multi_sensor_cfg { int max_sample_rate; int trigger_mode; }; static const struct multi_sensor_cfg sensor_v1_cfg { .max_sample_rate 1000, .trigger_mode 0, }; static const struct multi_sensor_cfg sensor_v2_cfg { .max_sample_rate 2000, .trigger_mode 1, }; static const struct of_device_id multi_sensor_of_match[] { { .compatible vendor,multi-sensor, .data sensor_v1_cfg }, { .compatible vendor,multi-sensor-v2, .data sensor_v2_cfg }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, multi_sensor_of_match); static struct platform_driver multi_sensor_driver { .probe multi_sensor_probe, .remove multi_sensor_remove, .driver { .name multi_sensor, .of_match_table multi_sensor_of_match, }, }; module_platform_driver(multi_sensor_driver);即使两个设备树节点compatible都是vendor,multi-sensor驱动也能正常匹配。如果以后出现了新版本硬件只需要把节点compatible改成vendor,multi-sensor-v2probe里通过of_device_get_match_data拿到v2配置驱动代码一行都不用改。2.3 私有结构体与devm资源管理probe函数的核心任务就是为这一个设备实例分配独立的私有结构体并把所有资源都挂到这个结构体上。struct multi_sensor_dev { struct device *dev; const struct multi_sensor_cfg *cfg; struct gpio_desc *reset_gpio; struct clk *sclk; int irq; struct mutex lock; void __iomem *base; }; static int multi_sensor_probe(struct platform_device *pdev) { struct device *dev pdev-dev; const struct multi_sensor_cfg *cfg of_device_get_match_data(dev); struct multi_sensor_dev *sensor; struct resource *res; int irq, ret; if (!cfg) return -EINVAL; sensor devm_kzalloc(dev, sizeof(*sensor), GFP_KERNEL); if (!sensor) return -ENOMEM; sensor-dev dev; sensor-cfg cfg; mutex_init(sensor-lock); res platform_get_resource(pdev, IORESOURCE_MEM, 0); sensor-base devm_ioremap_resource(dev, res); if (IS_ERR(sensor-base)) return PTR_ERR(sensor-base); sensor-reset_gpio devm_gpiod_get(dev, reset, GPIOD_OUT_HIGH); if (IS_ERR(sensor-reset_gpio)) return PTR_ERR(sensor-reset_gpio); sensor-sclk devm_clk_get(dev, sclk); if (IS_ERR(sensor-sclk)) return PTR_ERR(sensor-sclk); ret clk_prepare_enable(sensor-sclk); if (ret) return ret; irq platform_get_irq(pdev, 0); if (irq 0) return irq; sensor-irq irq; ret devm_request_threaded_irq(dev, irq, NULL, multi_sensor_irq_handler, IRQF_TRIGGER_FALLING | IRQF_ONESHOT, dev_name(dev), sensor); if (ret) return ret; platform_set_drvdata(pdev, sensor); dev_info(dev, probed, irq%d, max_sample_rate%d\n, irq, cfg-max_sample_rate); return 0; }这段代码里有两个关键点值得展开。第一所有资源都用devm_开头的API申请。devm_kzalloc、devm_ioremap_resource、devm_gpiod_get、devm_clk_get、devm_request_threaded_irq这些资源会在设备解绑时自动释放。多设备驱动最怕的就是probe走到一半出错你得写一堆goto标签逐个清理前面申请的资源devm_系列直接帮你把这个雷区拆掉。第二中断注册时最后一个参数传的是sensor不是全局指针。中断回调函数拿到这个参数后调用container_of之类的操作直接获取当前实例的私有数据。两个设备各注册各的中断各触发各的回调互不干扰。static irqreturn_t multi_sensor_irq_handler(int irq, void *data) { struct multi_sensor_dev *sensor data; /* 使用sensor-xxx不会串到另一个实例 */ return IRQ_HANDLED; }这里还要提醒一个反模式有些人为了省事会在驱动里写一个固定大小的数组static struct multi_sensor_dev *sensors[2];然后probe时按index存进去。这种写法在设备数量固定的板级驱动里能跑但非常脆弱——一旦你在设备树里调整了节点顺序index就变了如果支持热插拔更是灾难。正确做法就是“每个probe一份私有数据”通过结构体传递不依赖任何顺序或索引。注意clk_prepare_enable之后如果没有对应的clk_disable_unprepare在remove时要配对清理。使用devm_clk_get虽然会在设备移除时自动释放clk句柄但不会自动关闭时钟使能所以多设备驱动里要记得在remove回调里处理。3. 技巧二字符设备次设备号分发让用户空间分别访问每一路3.1 内核侧多实例和用户侧多实例是两回事probe跑通只代表内核侧驱动管理好了多个设备用户空间的应用程序要访问具体某一路还需要一套“分发机制”。Linux字符设备的标准做法是注册一个主设备号给每个物理设备分配一个次设备号。用户打开/dev/multi_sensor0和/dev/multi_sensor1时内核通过次设备号找到对应的私有结构体然后走各自的read/write。这个原理和CH340、CP2102这类USB转串口芯片是完全一致的。插两路CH340系统里出现两个ttyUSB节点关掉一个不影响另一个底层就是同一套驱动用次设备号区分多实例。用一个生活化的类比一个主设备号就是一栋楼的门牌号段次设备号是具体的门牌号。用户打开哪扇门驱动就把那个房间里的数据交给你。3.2 cdev多实例的实现步骤与代码先看整体代码框架再拆开解释。模块初始化时先分配一段设备号#define MULTI_MAX_MINORS 16 static dev_t multi_devno; static struct class *multi_class; static DEFINE_IDA(multi_minor_ida); static int __init multi_module_init(void) { int ret; ret alloc_chrdev_region(multi_devno, 0, MULTI_MAX_MINORS, multi_sensor); if (ret 0) return ret; multi_class class_create(THIS_MODULE, multi_sensor); if (IS_ERR(multi_class)) { unregister_chrdev_region(multi_devno, MULTI_MAX_MINORS); return PTR_ERR(multi_class); } return platform_driver_register(multi_sensor_driver); } module_init(multi_module_init);alloc_chrdev_region让内核自动分配一个空闲主设备号并预留16个次设备号。主设备号在整个系统中只用一次不是每个probe都分配。probe里为每个实例分配次设备号、初始化cdev、创建设备节点static int multi_sensor_create_cdev(struct multi_sensor_dev *sensor) { struct device *dev sensor-dev; int minor, ret; minor ida_alloc(multi_minor_ida, GFP_KERNEL); if (minor 0) return minor; sensor-devno MKDEV(MAJOR(multi_devno), minor); sensor-minor minor; cdev_init(sensor-cdev, multi_sensor_fops); sensor-cdev.owner THIS_MODULE; ret cdev_add(sensor-cdev, sensor-devno, 1); if (ret 0) goto err_ida_remove; sensor-device device_create(multi_class, dev, sensor-devno, sensor, multi_sensor%d, minor); if (IS_ERR(sensor-device)) { ret PTR_ERR(sensor-device); goto err_cdev_del; } return 0; err_cdev_del: cdev_del(sensor-cdev); err_ida_remove: ida_free(multi_minor_ida, minor); return ret; }device_create的倒数第二个参数会把sensor指针作为私有数据挂到设备结构体上。不过真正关键的是open回调里的查找逻辑static int multi_sensor_open(struct inode *inode, struct file *filp) { struct multi_sensor_dev *sensor container_of(inode-i_cdev, struct multi_sensor_dev, cdev); filp-private_data sensor; return 0; }inode-i_cdev指向这个设备对应的cdev结构体而cdev结构体是嵌在multi_sensor_dev里的用container_of就能反推出整个私有结构体。这是Linux驱动里最常见的“从内核对象拿到包含它的对象”的经典手法。之后所有的read、write、ioctl都从filp-private_data里拿实例static ssize_t multi_sensor_read(struct file *filp, char __user *buf, size_t count, loff_t *off) { struct multi_sensor_dev *sensor filp-private_data; ssize_t ret; mutex_lock(sensor-lock); /* 读取sensor-base寄存器、上报数据等业务逻辑 */ mutex_unlock(sensor-lock); return ret; }这里每把锁都是实例私有的两个设备同时read时互不阻塞并发性能也好。remove回调里的清理顺序也很讲究static void multi_sensor_remove(struct platform_device *pdev) { struct multi_sensor_dev *sensor platform_get_drvdata(pdev); device_destroy(multi_class, sensor-devno); cdev_del(sensor-cdev); ida_free(multi_minor_ida, sensor-minor); }先销毁用户空间可见的设备节点再删除cdev最后释放次设备号。顺序反了可能导致用户空间在节点删除的瞬间还能打开设备但实际cdev已经被移除出现奇怪的错误。3.3 miscdevice的简洁替代与边界如果你的设备比较简单不想写这么多cdev样板代码可以用miscdevice。miscdevice封装了cdev、设备号分配和/dev节点创建驱动里只要填一个结构体static struct miscdevice sensor_miscdev { .minor MISC_DYNAMIC_MINOR, .name multi_sensor0, .fops multi_sensor_fops, };然后调用misc_register(sensor_miscdev)即可。但多设备场景下用misc有两个坑需要注意。第一每个实例的name必须唯一。两个实例都叫“multi_sensor”时第二个注册会失败报错信息通常是“device or resource busy”。所以name要动态拼接比如按次设备号生成。第二misc设备共用主设备号10内核可分配的次设备号一共255个。对外设数量不多的板级驱动来说够用但如果你需要在一大段设备号区间里自定义管理或者需要自定义class、属性组cdev方案更灵活。我的建议是做实验、写小工具时用miscdevice省事做正式的板级驱动、或者可能扩展到多路相同外设的量产项目直接走cdevida方案一次把框架搭好。4. 瑞芯微平台上多设备驱动容易踩的资源坑4.1 pinctrl和GPIO复用冲突瑞芯微的IO是复用型的同一个引脚同一时间只能被一个外设占用。多设备驱动最隐蔽的问题不是驱动代码本身而是设备树里两个设备不小心配到了同一个引脚上。比如两个设备的中断脚都写成了gpio3 RK_PB2表面上设备树解析不会报错但运行时第二个设备申请GPIO中断会失败或者两个中断都注册到了同一个引脚上触发一个引脚上的中断时两个handler都被执行数据错乱。排查方法很直接cat /sys/kernel/debug/gpio cat /sys/kernel/debug/pinctrl/pinctrl/pinmux-pins | grep -i multi如果看到某个引脚被重复申请驱动里devm_gpiod_get返回-EBUSYdmesg里会出现类似“pin XXX already requested by XXX”的提示。dts里配置pinctrl时注意rockchip,pins的写法pinctrl { multi_sensor { multi_sensor_a_int_l: multi-sensor-a-int-l { rockchip,pins 3 RK_PB2 RK_FUNC_GPIO pcfg_pull_up; }; }; };然后是RK_FUNC_GPIO还是具体的function编号比如I2C、UART功能需要查RK3568的TRM或参考SDK里现成的dtsi不要凭记忆填写。尤其是复用功能引脚配错了不影响编译但跑起来功能完全不对。4.2 clock、power、regulator与多实例的绑定瑞芯微平台的多路外设各自有独立的时钟、电源轨、复位控制。设备树里写清楚驱动里用devm_clk_get、devm_regulator_get获取时内核会按这“这一个设备实例”的dt节点去解析天然不会串。以时钟为例两个设备节点分别引用不同的时钟multi_sensor_a: multi-sensor0 { compatible vendor,multi-sensor; clocks cru SCLK_SPI0; clock-names sclk; }; multi_sensor_b: multi-sensor1 { compatible vendor,multi-sensor; clocks cru SCLK_SPI1; clock-names sclk; };probe里用devm_clk_get(dev, sclk)拿到的就是本节点对应的时钟。如果某个节点确实不需要时钟用devm_clk_get_optional更合适返回-ENOENT时不会直接让probe失败。电源域power domain在瑞芯微平台上通常不需要普通外设驱动显式操作总线控制器驱动和平台固件会处理。但如果你外接的传感器/编解码芯片有独立电源引脚设备树里建议配成supply属性vcc1v8_a-supply vcc_1v8_a; vcc1v8_b-supply vcc_1v8_b;驱动里用devm_regulator_get(dev, vcc1v8)获取并regulator_enable。两个设备两个regulator各自管理互不影响。4.3 中断、并发与多实例多设备驱动还有一个必须重视的点并发访问。两个设备的中断handler可能在两个CPU核心上同时执行如果它们访问了共享的静态变量、共享链表必须用锁保护。但实例内的私有数据不需要全局锁。比如struct multi_sensor_dev里的mutex lock只保护当前实例的寄存器操作。两个不同实例的中断各自处理各自的锁不会互相阻塞。如果确实有跨实例共享的数据结构比如一个全局的设备列表、一个全局的统计计数建议单独加一把锁不要和实例锁混用。中断上下文里访问共享数据时用spin_lock_irqsave不能在中断handler里调用mutex_lock。另外RK3568是多核ARM驱动里的原子操作、内存屏障也要注意。多实例下并行度更高踩到并发问题的概率比单设备大得多。写代码时就要有意识地区分“实例私有数据”和“全局共享数据”这比事后调bug高效得多。5. 实测问题排查速查与调试习惯5.1 多设备驱动故障速查表现象可能原因定位方式probe只执行一次两节点reg重复、status状态不对、compatible拼写错误dmesg、ls /sys/bus/platform/devices/第二个设备GPIO申请失败pinctrl引脚冲突或GPIO被复用/sys/kernel/debug/gpio、pinmux-pins/dev节点打开失败device_create没执行、name重复、cdev_add失败dmesg、ls -l /dev/中断一直触发IRQ触发类型配置错误、共享IRQ没加IRQF_SHARED/proc/interrupts、示波器测量引脚两个设备数据串了全局变量保存实例状态、open里未正确设置private_datacode review、加pr_info确认probe顺序打开设备后read阻塞多实例共用一把大锁、死锁lockdep日志、cat /proc/lockdep设备节点存在但读写失败cdev删除后仍被引用、release未清理private_datadmesg、fuser查看占用进程5.2 调试手段与命令实录加载驱动之间先清空内核日志方便后面定位sudo dmesg -C sudo insmod multi_sensor.ko dmesg | tail -50确认设备树节点有没有被解析成platform设备ls /sys/bus/platform/devices/确认设备节点和主次设备号ls -l /dev/multi_sensor* cat /proc/devices | grep multi_sensor确认中断有没有注册成功cat /proc/interrupts | grep multi_sensor确认引脚复用状态sudo cat /sys/kernel/debug/pinctrl/pinctrl/pinmux-pins | grep -E multi|gpio3如果把设备树里写SATA当作普通GPIO用、或者GPIO被别的驱动占用GPIO申请失败的报错信息会直接告诉你被谁占用了顺着这个信息去查找对应驱动通常能很快定位。5.3 一个值得养成的好习惯多设备驱动的调试最怕的就是“一次把所有逻辑都写完再测试”。我现在的习惯是先把设备树节点配上probe里只做资源和私有数据的申请一路一路地加打印确认两个设备都能probe、各自设备节点都能创建再往里面填业务逻辑。先把“骨架”跑通有一个很大的好处一旦后续业务逻辑出问题你可以确定问题不在设备树匹配、不在资源申请而在具体功能代码。如果一开始就把寄存器读写、中断处理、buffer管理全部塞进probe出了问题你根本不知道是哪一个环节挂了。另外建议在probe、open、release这些关键函数里加dev_info或pr_debug打印当前处理的设备名、资源信息。多设备场景下dev_name(dev)会自动带上设备树节点名日志里能清楚看到是哪一路设备在操作这比自己在驱动里维护一个“设备编号”全局变量要靠谱得多。我在实际开发里还有一个体会多实例驱动的代码审查重点看两处一处是全局变量有没有被用在不该用的地方另一处是filp-private_data和中断注册的dev_id参数有没有正确赋值。把这两处看住了大多数多设备驱动的问题都能在代码审查阶段拦下来而不是等到上板调试时再抓狂。做多设备驱动做得多了之后我的习惯已经变成新驱动默认每个probe都新建一个私有结构体与设备树节点严格一一对应遇到要保存状态的变量先问自己“这是所有实例共享的还是每个实例独有的”用户空间交互时优先通过filp-private_data取实例而不是在驱动里用全局数组索引。瑞芯微平台本身没有给多设备驱动增加额外的复杂度它只是把标准Linux驱动模型、pinctrl、clock、regulator这些资源按设备树节点分配好真正决定驱动能不能支持多设备的还是写驱动的人有没有把数据归属理清楚。希望这两个小技巧能帮你在RK3568、RV1106这类平台上少踩几个坑。