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: pressure@0 { compatible = "vendor,press-sensor"; reg = <0>; spi-max-frequency = <2000000>; }; pressure1: pressure@1 { compatible = "vendor,press-sensor"; reg = <1>; spi-max-frequency = <2000000>; }; pressure2: pressure@2 { 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_drvdata:
static 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_operations:open时如何正确路由到实例
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设备配了reg=0、reg=1、reg=2,那么次设备号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里都有一把mutex,read/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/下写规则:
KERNEL=="press[0-9]*", MODE="0666"别觉得这是小事,我在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或minor:
dev_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,就看你能不能熟练地把数据正确地盛进各自的碗里,不串味、不打架。拿瑞芯微这种外设丰富、多设备场景多发的平台来练手,恰好能把这块基本功打磨扎实。做了三四个多设备驱动以后,再回头处理那些只服务单个设备的驱动,你反而会觉得处处不习惯,因为许多代码结构从一开始就能以更模块化的方式组织起来。