行业资讯
📅 2026/9/7 10:21:11
Linux多设备驱动注册实战:设备树、设备号分配与实例数据隔离
1. 多设备注册的先天缺陷一个设备号配一个设备的时代早就过去了做Linux驱动开发的兄弟应该都有过这种经历第一个版本写得飞快结构体一定义、file_operations一注册、udev规则一配板子上一跑设备节点出来了一切完美。但等产品从开发板转向正式硬件需求从点亮一个LED变成控制八个电机的时候问题就来了——你总不能给每个电机都写一份一模一样的驱动吧瑞芯微平台RK3568、RK3588这些上这个问题尤其明显。因为这类SoC本身就是为多外设场景设计的I2C总线挂个七八个传感器、SPI上串四五个设备、一个USB控制器带多个同型号的采集模块都是家常便饭。我在用RK3568做工业网关的时候单是I2C0上就挂了三个不同地址的温湿度传感器GPIO上还控制着四路继电器再加上两路UART的模组——如果还抱着一个device_id对应一个设备的驱动思路代码量直接翻四倍而且后续每改一个参数都要同步改四份简直是噩梦。先想清楚多设备支持到底要解决什么问题。本质上就两个一个是同种设备多个实体的复用一个是异构设备如何被同一个驱动框架有序管理。前者是同一份逻辑跑多份实例后者是一份代码如何区分不同客户。在瑞芯微的Linux内核官方BSP一般基于5.10或4.19里驱动和设备的匹配是依靠设备树Device Tree完成的。设备树里定义了硬件拓扑驱动侧通过compatible字符串来认领硬件。这个模型天生就支持一对多——一份驱动代码可以匹配同类设备的不同实例配套的file_operations也只需要实现一个副本。真正需要动脑筋的地方是驱动的私有数据如何跟具体的device实例绑死怎么保证四个传感器各调各的互不干扰以及热插拔或者多实例注册时设备号怎么分配不冲突。我最早解决这个问题的思路很朴素直接给驱动分配一大段设备号比如主设备号统一次设备号从0到255全占了然后通过次设备号推导实例编号。这种做法能做但粗放对资源是种浪费而且一旦设备数超过256就得重构。后来在瑞芯微社区看到有人用miscdevice一个misc设备消耗一个次设备号但misc设备本身是共享同一个主设备号10的数量一多管理也麻烦。2. 第一个技巧利用设备树compatible与platform_driver注册多个同型号子设备2.1 为什么瑞芯微的设备树是理想的多设备注册入口在讲具体代码前先聊一个底层逻辑瑞芯微平台为什么在设备树驱动模型里做多设备支持特别顺这跟它的SoC内部架构有关。RK3568为例芯片内部的许多外设控制器I2C控制器、SPI控制器、串口在硬件层面就是多通道的每个通道在设备树里是一个独立节点但驱动逻辑共用。SoC厂商已经把设备树写好了大部分比如i2c0和i2c1就是两个节点但compatible都是rockchip,rk3568-i2c。Linux内核的platform bus在启动时会遍历设备树中的所有节点与platform_driver逐一匹配匹配上了就会调用驱动的probe函数而且有多少个匹配节点probe就会被调用多少次——这就是多设备注册的底层基础。举个我在RK3568上做的实际项目。需求是底板上有三个气压传感器用的都是同一个型号挂在同一组SPI总线上片选分别是CS0、CS1、CS2。如果把三个设备当成三个独立节点写进设备树spi0 { status okay; pinctrl-names default; pinctrl-0 spi0_pins; pressure0: pressure0 { compatible vendor,press-sensor; reg 0; spi-max-frequency 2000000; }; pressure1: pressure1 { compatible vendor,press-sensor; reg 1; spi-max-frequency 2000000; }; pressure2: pressure2 { compatible vendor,press-sensor; reg 2; spi-max-frequency 2000000; }; };驱动的probe会被调用三次每次传入的struct spi_device指针不同通过spi_device-chip_select能区分是哪个片选。但如果驱动里把三个实例的数据混在一个全局变量里管理第二次probe就会把第一次probe的数据覆盖掉。所以核心问题根本不是怎么注册多个设备而是注册后怎么隔离多份实例数据。2.2 核心代码结构container_of与私有数据绑定看一个我实际调试通过的驱动骨架去掉业务逻辑保留核心结构#include linux/module.h #include linux/spi/spi.h struct press_chip { struct spi_device *spi; struct mutex lock; int chip_select; u16 pressure_raw; u32 last_reading_ms; }; static int press_probe(struct spi_device *spi) { struct press_chip *chip; chip devm_kzalloc(spi-dev, sizeof(*chip), GFP_KERNEL); if (!chip) return -ENOMEM; chip-spi spi; chip-chip_select spi-chip_select; mutex_init(chip-lock); spi_set_drvdata(spi, chip); /* 每个实例单独创建设备节点 */ device_create(press_class, spi-dev, MKDEV(press_major, chip-chip_select), chip, press%d, chip-chip_select); dev_info(spi-dev, press sensor on CS%d probed\n, chip-chip_select); return 0; }这段代码的关键就在于每个probe调用都会重新分配一份struct press_chip而这份私有数据会通过spi_set_drvdata挂到对应的struct spi_device上。这样CS0、CS1、CS2三个设备各持有一份独立的气压缓存和互斥锁互不干扰。在read/write回调里怎么拿到自己的那份数据用spi_get_drvdatastatic ssize_t press_read(struct file *file, char __user *buf, size_t count, loff_t *ppos) { struct press_chip *chip file-private_data; if (mutex_lock_interruptible(chip-lock)) return -ERESTARTSYS; /* 触发SPI读取、更新chip-pressure_raw */ mutex_unlock(chip-lock); return 0; }如果操作的是字符设备open的时候把spi_get_drvdata()得到的指针存入file-private_data后面所有read/write/ioctl都从private_data里拿实例数据。这里有个非常容易犯的错误有些新手喜欢把file_operations里的open做成static全局状态机——比如在open里不做任何绑定到了read里才根据次设备号找数据。这样不是不行但代码会变得特别绕而且稍不注意就出现CS1的数据被CS0的read请求读到的诡异bug。正确的心法是:file_operations永远是无状态的模板实例状态全部藏在struct file-private_data指向的驱动私有结构体里。这也是Linux驱动开发里反复强调的面向对象思维——device实例就是对象drvdata就是this指针。2.3 设备节点的自动编号策略上面代码里用了MKDEV(press_major, chip-chip_select)也就是设备号直接跟片选号挂钩。这种做法的好处是设备节点名可以直接体现硬件位置/dev/press0永远对应CS0的设备出问题时方便用示波器在底板上定位。但也有个前提——设备树里reg必须连续从0开始排否则会有空洞。假如你的板子只焊了两个传感器一个在CS0一个在CS3那dev/press1和/dev/press2不会出现/dev/press3直接跳到CS3上层应用如果写死打开/dev/press1就会打开失败。遇到这种硬件跳过的情况我对设备号的策略会改成动态分配次设备号——不是用chip_select直接当次设备号而是在probe里维护一个原子计数器每次probe分配一个单调递增的编号同时用idr或者简单的数组把chip_select映射到实例ID。这样应用层只看到0、1、2这样的连续设备号驱动内部通过查询表找到对应的片选。static DEFINE_IDA(press_ida); static int press_probe(struct spi_device *spi) { int id; id ida_alloc(press_ida, GFP_KERNEL); if (id 0) return id; /* 后续用 id 作为次设备号 */ }关于设备节点自动创建瑞芯微的官方BSP里通常已经跑着mdev或者udev如果驱动用的是标准设备模型、在probe成功时调用了device_create并且设置了正确的dev_t和class那么/dev下的节点会自动出现不需要自己写mknod脚本。调试时可以手动创建设备节点确认设备号mknod /dev/press0 c 240 0不过这只用于临时调试规范做法还是靠设备模型自动创建。3. 第二个技巧主设备号分配不当引发的连环崩盘3.1 直接静态注册主设备号的风险前面讲的多设备支持还避不开一个基础问题设备号怎么分配。刚入门的时候很多人喜欢在驱动里写死主设备号#define PRESS_MAJOR 240 register_chrdev_region(MKDEV(PRESS_MAJOR, 0), 3, press);这个数字240可能当前系统里没被占用但你敢保证三个月后加入第二个驱动时240还空着吗而且这个3个设备号的数量上限在编译时就定死了如果硬件改版CS5和CS6各加了一个传感器总设备数变成5个那你还得改代码重新编译驱动模块。如果你做的是产品级的瑞芯微BSP驱动的加载顺序不可控某些驱动编译进内核、某些是insmod加载那么主设备号冲突是大概率事件。我曾经在一个项目里遇到过挺典型的场景厂家的另外一个内核模块占用了主设备号240我们的驱动insmod时不报错因为register_chrdev_region不会真正检测物理设备是否占用等到应用层打开/dev/press0的时候内核才沿着主设备号找到别人的file_operations然后一顿操作猛如虎要么返回ENODEV要么直接触发空指针——驱动开发里最恶心的就是这种注册时不报错、用的时候才崩的问题太难排查了。3.2 alloc_chrdev_region让内核帮你挑一个空号第二个关键技巧就是不要自己去挑主设备号让内核帮你分配dev_t press_devt; static int __init press_init(void) { int ret; ret alloc_chrdev_region(press_devt, 0, MAX_PRESS_DEVICES, press); if (ret 0) return ret; press_major MAJOR(press_devt); press_class class_create(press_class); cdev_init(press_cdev, press_fops); press_cdev.owner THIS_MODULE; cdev_add(press_cdev, press_devt, MAX_PRESS_DEVICES); }alloc_chrdev_region会自动找到一个空闲的主设备号并把它写到press_devt里。MAJOR(press_devt)就能拿到实际分配到的号。这样做的好处有两个一是从根源上避免了跟其他模块的主设备号撞车二是如果你给cdev_add指定了MAX_PRESS_DEVICES个次设备号的范围以后设备数量小幅扩展时不用改主设备号只调整这个常量即可。第一次跑起来以后建议在板子上敲一下cat /proc/devices | grep press会看到类似240 press的输出这就是当前系统动态分配给你的主设备号。设备节点可以通过mdev规则自动生成但如果你在驱动的init里已经确保class正确注册并且build-in到内核的udev或者mdev会触发uevent那它自己就知道该创建什么节点了。3.3 用一个具体案例说明分配失败时的崩溃现场拿我之前调试的一个具体驱动说事。当时做的是RK3568上的GPIO模拟中断按键驱动注册了3个按键最初用register_chrdev_region(MKDEV(230, 0), 3, gpio_keys)开机测试一切正常。后来系统里集成进一个外部厂商提供的看门狗驱动那个驱动也用了230这个主设备号。结果就是——看门狗驱动先加载成功了我的按键驱动insmod时没有报错因为register_chrdev_region只做占用登记不检查底层的cdev结构体。然后有一个用户态进程尝试打开/dev/gpio_keys0内核按设备号去查找cdev结果找到了看门狗驱动的cdev直接调用看门狗的open看门狗open里又尝试访问一些非法寄存器整个内核Oops。那次排查花了大概三小时最后靠检查相同主设备号模块列表找到了问题。从那以后我给自己定了一条规矩所有新写的驱动一律强制使用alloc_chrdev_region不用静态主设备号除非某个驱动必须被bootloader直接访问、或依赖固定的设备节点路径做启动验证否则没有任何理由自己挑号。当然如果你做的是miscdevice框架下的驱动那连设备号都无需关心内核已经帮你管理了misc主设备号10只需要申请一个唯一的次设备号就行。4. 从device到file_operationsopen时如何正确路由到实例4.1 为什么private_data是个好东西前面埋了个伏笔这里展开说。file_operations是所有进程共享同一份的函数指针集合它本身不区分当前操作的是哪台设备。内核在open设备节点时会给每个open调用分配一个独立的struct file实例驱动在open里处理好的数据放在file-private_data里后续read/write都从private_data取——这是Linux驱动里最基本的数据隔离方式。在瑞芯微平台上跑多设备驱动时我见过不少野路子全局数组保存实例然后open通过iminor(file-f_path.dentry-d_inode)拿到次设备号作为数组下标也能实现多设备区分。但问题是这种方式应对简单场景尚可一旦代码里某处疏忽把index写错轻则数据错乱重则越界写坏内核堆栈。而private_data方案从机制上杜绝了这种问题因为每个file实例天生携带各自的上下文根本不需要全局状态下标查找。我在驱动里常用的open写法是这样的static int press_open(struct inode *inode, struct file *file) { struct press_chip *chip; int minor iminor(inode); /* 通过次设备号找到对应的芯片私有数据 */ chip idr_find(press_idr, minor); if (!chip) return -ENODEV; file-private_data chip; return 0; }4.2 用次设备号作为索引时注意空洞上面代码里有个隐含前提次设备号能唯一对应一个实例。如果我们在设备树里给三个SPI设备配了reg0、reg1、reg2那么次设备号0、1、2就用idr管理没啥问题。但是请留意我在2.3节提到的硬件空洞情况。假设硬件跳线导致实际接在CS0和CS7上如果你依旧把次设备号直接设成0和7那/dev目录下会出现press0和press7中间的1~6压根不存在。底层不觉得有什么但应用层受不了——它一般会写for循环从0到N扫描设备节点遇到press1不存在就直接退出或者报错。稳妥做法不是硬编码设备号跟硬件挂钩而是动态分配ID并用记录表维护对应关系。具体来说驱动初始化时调用alloc_chrdev_region分配一大段连续的dev_t然后通过一个基数树IDR来管理实例的分配和释放static int press_probe(struct spi_device *spi) { int minor; struct press_chip *chip; chip devm_kzalloc(spi-dev, sizeof(*chip), GFP_KERNEL); if (!chip) return -ENOMEM; minor ida_alloc(press_ida, GFP_KERNEL); if (minor 0) return minor; chip-minor minor; idr_alloc(press_idr, chip, minor, minor 1, GFP_KERNEL); device_create(press_class, spi-dev, MKDEV(press_major, minor), NULL, press%d, minor); spi_set_drvdata(spi, chip); return 0; }以后应用层open/dev/pressN后内核根据inode里的次设备号从idr中找回chip指针再放到file-private_data里。4.3 多实例共享同一套fops时的并发注意点当多个设备实例共用同一份file_operations并发访问就是必然要考虑的事情。三个传感器如果被三个不同进程同时打开驱动里的read/write回调会在不同CPU核上并发执行。如果实例数据不做互斥会出现SPI总线并发访问的混乱——两个CPU同时对同一SPI控制器发起传输底层FIFO直接被打乱。解决思路分两层。一层是实例内互斥每个struct press_chip里都有一把mutexread/write的时候锁住自己这份实例保证同一个设备的操作是串行的。另一层是SPI总线级别的传输互斥spi_sync传输本身要求不能并发调用同一个spi_device上的传输即使不同chip_select但共享同一SPI controller底层驱动框架对每个controller也有自己的bus lock通常情况下不用担心但如果用spi_async异步接口就没了这个保护需要自己加锁。我实际遇到的一个坑是我们一度在read回调里把SPI传输和寄存器解析放在两把锁里以为SPI锁已经保护了总线寄存器解析无锁问题不大。结果多进程同时读不同传感器SPI传输这层确实没乱解析的时候因为共享了一个静态的校准系数缓冲导致偶发读到错误的测量值。排查了两天最后用排除法定位到是静态缓冲区的竞争问题改成每实例一份配置缓冲后才彻底消停。所以凡是跟具体设备相关的数据全部塞进实例结构体尽量不要在驱动源码里定义static变量。5. 调试实录RK3568平台多设备驱动最常踩的五个坑5.1 坑一驱动加载顺序导致cdev_add失败瑞芯微BSP中很多外设驱动被编译成内核模块放在/etc/init.d里自启。如果你的驱动依赖的class或者基础框架还没起来就尝试cdev_add大概率返回失败因为没有对应的sysfs class存在。实践中建议在insmod脚本里先检查/sys/class目录下对应的class是否出现或者直接把驱动编译进内核并由device_initcall顺序保证它晚于核心框架初始化。如果实在需要insmod并且依赖了其它模块可以在modprobe里加softdep声明softdep press pre: spi_dev这样modprobe会自动把spi_dev排在前面加载。这个方法在瑞芯微的很多第三方驱动打包里都能看到比如WiFi驱动的依赖顺序就是通过softdep解决的。5.2 坑二probe被多次调用但全局初始化只做一次多设备支持最容易出现的问题不是多实例数据冲突而是初始化逻辑被重复执行。比如你在probe里调用了一个函数函数内部用静态标志位做过一次gpio_request第二次probe直接跳过gpio_request那么第二个设备的GPIO就没有被正确申请后面操作GPIO时内核会返回-EBUSY。规范做法是probe里只做完全实例化的操作公共资源统一放到module_init里不要在probe里依赖静态标志位来控制初始化流程。如果你的驱动要跟io memory打交道记得用devm_platform_ioremap_resource这类devm_系列函数在实例释放时自动回收避免重复probe导致资源泄漏。5.3 坑三设备节点权限和udev规则没配好瑞芯微平台buildroot或yocto的udev规则默认情况下只会创建设备节点不会自动改权限。如果应用层想用非root用户直接打开/dev/press0需要在/etc/udev/rules.d/下写规则KERNELpress[0-9]*, MODE0666别觉得这是小事我在RK3588上调试时上层QT程序跑到open函数直接返回Permission denied排查半天才发现是忘了给设备节点加权限。有时候报错不会直接告诉你权限不够而是程序卡在某次ioctl上因为标准的open失败处理被上层忽略了。5.4 坑四设备树节点名与驱动匹配字段写岔设备树里写的是vendor,press-sensor驱动里of_match_table写成vendor,press_sensor中间多了个下划线就会匹配不上。这种错误在瑞芯微的dts调试中很容易出现因为dts语法检查并不严格拼写错误只在boot log的OF: fdt_device_node_phandle或者drivers/base/platform.c层面输出一条不痛不痒的提示。排查方法很直接看内核打印dmesg | grep -i vendor\|press如果驱动模型里没有任何匹配消息先确认设备树节点status是否等于okay再检查compatible字段是否完全一致包括大小写和标点。5.5 坑五单元号与linux设备号的关系搞混设备树里的reg属性、片选编号和Linux字符设备次设备号没有硬性对应关系。SPI设备节点的区号只是SPI控制器用来寻址的不等于最终/dev节点编号。我见过产品代码里写死应用层去open /dev/press1但底层动态分配后press1被分配给了CS2的设备结果那个应用读到的全是CS2的数据排查了很久才发现是编号映射没做好。调试多设备驱动时建议先实现一个简单的ioctl把实例的chip_select和minor都回传上层打印出来确认应用层拿到的设备号跟实际物理通道是对应的。6. 关于扩展性的经验总结分离总线无关和总线相关代码如果只是做一两个设备前面的内容足够用了。但如果你的驱动未来要接多个不同总线的版本比如SPI版本、I2C版本甚至虚拟设备版本那么在设计驱动结构时就要做一次分层。这是我从一个用了很久的瑞芯微工业采集项目里提炼出的体会。举个例子假设我们未来既要做SPI接口的气压传感器版本又要做I2C接口版本同一个传感器厂商出了两种封装最忌讳的做法是probe函数里直接把SPI操作封装了事。正确的做法是把驱动拆成两层底层是总线抽象层定义一组统一的read_reg/write_reg函数指针结构体里挂上一个struct regmap或自定义的bus_ops上层是业务逻辑层只调用read_reg/write_reg不关心底下是SPI还是I2C。这样在SPI probe里用spi_write_then_read实现read_reg在I2C probe里用i2c_master_send/i2c_master_recv实现read_reg上层的传感器校准、滤波、数据解析逻辑完全复用不需要复制粘贴。在实际开发中可以借助内核的regmap API进一步简化。regmap提供了一套统一的接口抽象把SPI/I2C/MMIO等各种总线接口封装成regmap_config后续所有寄存器操作都走regmap_read/write切总线时只需要换个regmap_init_spi或regmap_init_i2c的参数。我在瑞芯微平台上做CODEC驱动和ADC驱动都习惯了这套写法省了不少重复功夫。还有一个容易被忽略的点多设备实例的调试效率。如果三台传感器同时probe而其中只有CS2的设备硬件贴装不良、数据不稳定你很难通过直接看dmesg来分清是哪个实例报的错误。所以驱动里每一条dev_info、dev_err、dev_dbg都要带上实例标识符最直接的做法就是打印chip_select或minordev_info(spi-dev, probe ok, cs%d, minor%d\n, chip-chip_select, chip-minor);这点投入成本和后续排查收益比起来实在太划算了。包括我前面写的probe里dev_info中带上CS编号目的之一就在这里。最后再分享一个我自己用着很顺的调试方法在驱动的字符设备操作里临时加一个debugfs节点直接把所有实例的状态当前private_data地址、chip_select、最近一次读取的时间戳、缓存值全部dump出来。调试时用cat就能看到每个通道的实时状态比反复加printk高效多了。static int press_debugfs_show(struct seq_file *m, void *v) { /* 遍历idr中所有实例打印chip_select、minor和压力原始值 */ } DEFINE_SHOW_ATTRIBUTE(press_debugfs);跟并发的多设备驱动打交道本质上就是一个从一口大锅各自分碗的问题。Linux设备模型本身给了你足够多的碗——struct device、drvdata、file-private_data就看你能不能熟练地把数据正确地盛进各自的碗里不串味、不打架。拿瑞芯微这种外设丰富、多设备场景多发的平台来练手恰好能把这块基本功打磨扎实。做了三四个多设备驱动以后再回头处理那些只服务单个设备的驱动你反而会觉得处处不习惯因为许多代码结构从一开始就能以更模块化的方式组织起来。