在瑞芯微平台上做Linux驱动开发,板子做到一半,需求经常就会变成“同样的外设,再给我加一颗”。比如RK3568的SPI总线上本来挂了一颗ADC芯片,驱动都验证完了,客户又提了一句:同一型号的ADC,用另一个片选,再加一路。或者遇到调试工位,一次接了好几块同型号传感器,想在同一个系统里全部识别出来。这种需求非常普遍,统一说就是一个驱动实例要同时管多个同型号设备。
很多人的第一反应是复制一份probe代码,拆出adc0_init()和adc1_init(),再用宏或者if语句把寄存器地址分开。这个做法短期能跑,但后续只要改一个采样时序的bug,就得改两遍;改到第三路、第四路的时候,代码里全是分支,基本没法维护。其实Linux驱动框架本身就支持一个driver匹配多个device,关键是要在设备树、probe函数、设备节点管理这三个层面,把“每个设备自己的数据”和“驱动共用的逻辑”彻底分开。
这篇文章我基于瑞芯微平台,把平时用的两个小技巧完整拆开讲一遍。第一个技巧是从设备树和probe入手,按实例隔离私有数据;第二个技巧是用动态设备号和class_create自动管理多个/dev节点。两个技巧配合起来,以后再加同型号芯片就是往设备树里加一个节点的事。适合正在调多路外设、看platform/i2c/spi驱动、或者刚接手瑞芯微SDK里某个多实例驱动的工程师。
1. 多实例驱动难的真正原因:驱动本身是写给“结构”的,不是写给“数量”的
1.1 典型场景:同一颗主控上的多颗同型号外设
瑞芯微平台上最常见的多实例场景有这么几类:
- SPI总线上挂多片同型号ADC,例如MCP3008、ADS1256,用不同CS片选区分。
- I2C总线上挂多颗同型号传感器,例如温度传感器、陀螺仪,用不同地址区分。
- 主控内部多路同类型控制器,例如RK3568自带两路CAN、多路UART,驱动要同时管理。
- USB转串口芯片一次接入多块,例如CP2102、CH340,核心驱动其实也是多实例。
不管是哪种总线,内核的匹配逻辑都是同一个:一个struct driver可以匹配多个struct device。SPI总线上每写一个adc@0、adc@1子节点,内核就生成一个spi_device;I2C总线每个地址对应一个i2c_client;platform平台设备则是直接根据compatible字段创建平台设备。每个device实例都会触发一次probe,所以“支持多个设备”本质上不是要不要做的问题,而是你想不想让框架帮你做。
1.2 为什么很多人一写多实例就翻车
翻车最典型的写法就是“全局变量收集所有实例状态”。比如:
static void __iomem *g_base_reg; static int g_irq; static struct mutex g_lock;假设第一颗芯片probe成功,把这些填好了。第二颗芯片probe进来,同样把g_base_reg覆盖掉。表面上看起来没什么,因为两次probe都成功了,但当你打开第二颗芯片的节点读数据时,中断处理函数里拿到的g_base_reg已经被覆盖成第一颗芯片的寄存器地址。两个设备互相踩,调试起来非常崩溃。
在瑞芯微的SDK里,这个问题的隐蔽性还要更高,因为很多底层驱动从老芯片SDK拷贝过来,当年单芯片版本没有多实例需求,代码里到处是全局变量。到了RK3568这种多核多外设的平台,一个节点不够用了,全局变量冲突就会以非常奇怪的形式表现出来:第二路数据采样值不对、中断总是触发到第一个driver、甚至系统一开机就crash在驱动初始化。
1.3 内核本来就支持一对多,只是需要按“实例”理解代码
正确思路不是去对抗“一个driver管多个device”的机制,而是顺着它走。你需要做到三点:
- 设备树节点里把每颗芯片的独立资源说清楚,比如reg、中断GPIO、复位引脚。
- probe函数每次被调用,都利用入参拿到“当前这个设备”的资源,并存入一个独立分配的结构体。
- 所有打开、读取、写寄存器、中断处理的逻辑,都从这个结构体出发,而不是从全局变量出发。
这就是我后面要讲的两个技巧的地基。先讲设备树怎么拆,再讲probe里怎么用。
2. 设备树节点拆分:让每颗芯片都自带一张“资源名片”
2.1 RK3568上两片SPI ADC的是设备树写法
以瑞芯微RK3568为例,假设SPI1总线上要挂两片同型号ADC芯片,驱动名我叫它myadc,兼容字符串用vendor,myadc。设备树子节点应该是这样的:
&spi1 { status = "okay"; pinctrl-names = "default"; pinctrl-0 = <&spi1m0_cs0 &spi1m0_cs1>; adc0: adc@0 { compatible = "vendor,myadc"; reg = <0x0>; spi-max-frequency = <1000000>; reset-gpios = <&gpio3 RK_PA4 GPIO_ACTIVE_LOW>; interrupt-parent = <&gpio3>; interrupts = <RK_PA5 IRQ_TYPE_LEVEL_LOW>; vref-supply = <&vcc_3v3>; }; adc1: adc@1 { compatible = "vendor,myadc"; reg = <0x1>; spi-max-frequency = <1000000>; reset-gpios = <&gpio3 RK_PA6 GPIO_ACTIVE_LOW>; interrupt-parent = <&gpio3>; interrupts = <RK_PA7 IRQ_TYPE_LEVEL_LOW>; }; };这里面有几个信息对多实例特别重要。
reg = <0x0>和reg = <0x1>,在SPI总线下表示两个不同的片选通道。RK3568的SPI控制器支持多CS时,每个CS对应一个从设备。I2C总线则不同,reg填的是从机地址,比如reg = <0x48>和reg = <0x4a>,只要地址不冲突,同一总线上可以挂多颗芯片。
reset-gpios和interrupts是两颗芯片各自的独立引脚。这两个属性在probe阶段会解析出来,分别保存到对应的struct spi_device里。只要设备树节点分开,驱动就能通过API拿到属于当前节点的GPIO和中断号,不需要自己硬编码。
vref-supply这类电源属性,常见于ADC、传感器节点。它在瑞芯微平台很关键,因为驱动probe时会通过devm_regulator_get()拿到电源,输出使能后芯片才真正上电。如果多路设备共用一个regulator,内核的引用计数会正确处理,不会出现一路关闭导致另一路掉电的问题。
2.2 dtsi和板级dts的分工,决定多实例扩展是否顺手
在瑞芯微SDK里,rk3568.dtsi定义的是芯片内部控制器节点和默认属性,板级文件比如rk3568-evb.dts则负责填充具体外设和引脚。加外设时,尽量在板级文件里追加子节点,不要改dtsi里已经写好的控制器默认状态。
例如在板级dts里,对一个已有SPI控制器追加子节点:
&spi1 { status = "okay"; adc2: adc@2 { compatible = "vendor,myadc"; reg = <0x2>; }; };这样做的优势是,后加的芯片就像一张“设备名片”,带上自己独有的资源去匹配驱动。驱动不需要关心板子上到底挂了几颗,只需要每次probe都按同样的流程去解析当前节点即可。将来第三颗、第四颗芯片,只需要继续添加对子节点,驱动完全不用改动。
2.3 设备树解析阶段的坑位提醒
瑞芯微平台设备树调试经验看下来,有两个高频坑:
一个是pinctrl没写全,导致第二个片选引脚没有被复用成SPI功能,总线通信失败。检查方式是在板子上执行:
cat /sys/kernel/debug/pinctrl/pinctrl-rockchip-pinctrl/pinmux-pins | grep spi1如果看到第二路CS引脚没有出现在SPI1对应的mux组里,说明pinctrl配置被漏掉或冲突了。
另一个是interrupts属性中的电平触发类型写错,比如说芯片实际是低电平有效,写成了IRQ_TYPE_EDGE_FALLING,导致中断频繁触发或丢失。建议先按芯片手册把触发方式写准,再考虑软件过滤。
设备树这部分准备好的话,probe函数的写法就可以进入正题了。
3. 技巧一:probe阶段把每个实例的私有数据“绑定”到设备上
3.1 定义per-instance结构体
第一个小技巧的核心,就是每个设备实例一个独立的数据结构,所有状态都放到这个结构体里,绝不让实例之间共享可变状态。以myadc驱动为例,先定义实例结构体:
struct myadc_dev { struct device *dev; struct spi_device *spi; struct mutex lock; struct gpio_desc *reset_gpio; struct regulator *vref; unsigned int irq; unsigned int cs; struct cdev cdev; dev_t devno; /* 其他硬件状态字段 */ bool adc_enabled; u32 sample_buf[8]; };每个字段都对应“这颗芯片自己的一部分”。lock是该实例自己的锁,reset_gpio是该芯片自己的复位脚,sample_buf是该芯片自己的采样缓冲。这样两个实例分别采样、分别打开、分别关闭,不会互相干扰。
3.2 probe的完整套路:解析、分配、初始化、绑定
probe每次被内核调用的时机,都是一次“发现了一颗新芯片”的过程。要做的四件事很固定。
static int myadc_probe(struct spi_device *spi) { struct myadc_dev *adc; int ret; adc = devm_kzalloc(&spi->dev, sizeof(*adc), GFP_KERNEL); if (!adc) return -ENOMEM; adc->dev = &spi->dev; adc->spi = spi; adc->cs = spi->chip_select; mutex_init(&adc->lock); adc->reset_gpio = devm_gpiod_get_optional(&spi->dev, "reset", GPIOD_OUT_LOW); if (IS_ERR(adc->reset_gpio)) return PTR_ERR(adc->reset_gpio); adc->vref = devm_regulator_get_optional(&spi->dev, "vref"); if (IS_ERR(adc->vref)) { if (PTR_ERR(adc->vref) == -EPROBE_DEFER) return -EPROBE_DEFER; adc->vref = NULL; } adc->irq = gpiod_to_irq( devm_gpiod_get_optional(&spi->dev, "irq", GPIOD_IN)); if (adc->irq < 0) adc->irq = 0; spi_set_drvdata(spi, adc); ret = myadc_request_irq(adc); if (ret) return ret; ret = myadc_create_device_node(adc); if (ret) return ret; dev_info(&spi->dev, "myadc probed: CS%d, irq=%d\n", adc->cs, adc->irq); return 0; }有几个细节值得展开。
devm_kzalloc分配的结构体生命周期跟随设备。设备从总线删除时,内核自动释放,不用在remove里手动调用kfree。瑞芯微编译内核时如果开了CONFIG_DEBUG_DEVRES,还能在/sys下查到资源分配情况,排查泄漏很好用。
spi_set_drvdata是绑定动作。以后中断回调、read/write回调里只要拿到struct spi_device *,用spi_get_drvdata就能找回当前实例。这个接口在i2c驱动里对应i2c_set_clientdata,platform驱动里对应platform_set_drvdata。
devm_gpiod_get_optional和devm_regulator_get_optional都是资源管理接口,拿到和内核设备生命周期绑定的资源。这里必须解释一下optional的用途:因为设备树里不同子节点可能配置不同,比如第二路没有独立复位引脚,但第一路有,驱动必须允许这种差异。用optional拿到NULL后,后面代码里加一个if (adc->reset_gpio)判断就行。
-EPROBE_DEFER处理在瑞芯微平台尤其重要。第二颗芯片的中断GPIO可能依赖一个还没准备好电源域或pinctrl驱动,如果probe过早执行,相关设备还没注册,此时必须返回-EPROBE_DEFER,让内核在其他设备注册成功后再调用一次probe。很多人多实例失败就是在这里少了一个返回分支,导致第二颗芯片一直无法完成初始化。
3.3 从open、read到中断,如何按实例取数据
设备打开时,典型写法是把实例挂到file的私有数据里。
static int myadc_open(struct inode *inode, struct file *filp) { struct myadc_dev *adc = container_of(inode->i_cdev, struct myadc_dev, cdev); filp->private_data = adc; return 0; }container_of根据内嵌的cdev字段,反推出实例结构体。这一步能搞清楚,两个实例就永远不会混淆。用户打开/dev/myadc0时,inode->i_cdev是实例0的cdev,自然拿回实例0;打开/dev/myadc1时,拿回实例1。
read接口里再从filp->private_data取回实例:
static ssize_t myadc_read(struct file *filp, char __user *buf, size_t count, loff_t *pos) { struct myadc_dev *adc = filp->private_data; u32 value; int ret; mutex_lock(&adc->lock); /* 根据当前实例的CS和regmap完成读取 */ ret = myadc_read_sample(adc, &value); mutex_unlock(&adc->lock); if (ret) return ret; if (copy_to_user(buf, &value, sizeof(value))) return -EFAULT; return sizeof(value); }中断处理函数也一样,注册时传人的data就是实例:
static irqreturn_t myadc_irq_handler(int irq, void *data) { struct myadc_dev *adc = data; /* 使用adc->sample_buf等实例字段,不需要关心是哪颗芯片 */ return IRQ_WAKE_THREAD; }这样的代码看起来比“拷贝两份probe”多一点结构体,但逻辑完全单一了。任何一处修改采样时序的代码,天然对所有实例生效,而且只存在于一个地方。
3.4 技巧一为什么能“自然”支持多实例
我后来总结过一句话:内核框架本身就是多实例的,真正的多实例工作发生在你把“全局可变量”降级成“实例字段”的那一刻。
设备树每多一个匹配节点,内核会再多调用一次probe;如果你在probe里用的全是局部变量和per-instance结构体,第二次probe就是完全独立的过程。不需要为第二颗芯片再写一个入口函数,也不需要维护驱动数组的下标。
我补充一个常见误解:有人以为多用几个全局数组也算实例化,比如static struct myadc_dev g_adc[2]。这确实能跑,但问题遇到底层硬件资源被拔插,或者总线扫描顺序变化时,数组下标和设备实体的对应关系会非常脆弱。对比之下,spi_set_drvdata天然把数据和设备对象绑在一起,顺序再乱也不怕。这就是技巧一真正的用意。
4. 技巧二:动态设备号和class_create,让/dev节点跟着实例走
4.1 固定设备号在双实例下有多麻烦
刚学驱动时,很多人习惯在模块初始化里自己指定主设备号,比如register_chrdev(240, "myadc", &fops),再在板子上手动mknod /dev/myadc c 240 0。
这种写法在单一设备场景下问题不大。一旦有两个实例,你马上会遇到三个麻烦:
- 两个实例要访问相同主设备号、不同次设备号,但
register_chrdev函数默认绑定整个主设备号,次设备号的管理非常原始。 - 板子上每次重新烧录系统,如果脚本没有自动创建节点,
/dev/myadc1有可能不存在,应用直接打开失败。 - 如果240这个主设备号被系统里其他模块占了,就得手动改数值,多实例还要考虑次设备号的分配策略。
所以技巧二的核心是:不再人工管理设备号,而是交给内核动态分配,再用class自动生成设备节点。
4.2 用alloc_chrdev_region动态分配设备号
模块初始化时只做两件事:动态分配一段区段号,并把这个区段号交给cdev子系统。
static int major; static struct class *myadc_class; static int __init myadc_init(void) { dev_t devno; int ret; ret = alloc_chrdev_region(&devno, 0, MYADC_MAX_DEVS, "myadc"); if (ret) return ret; major = MAJOR(devno); myadc_class = class_create("myadc"); if (IS_ERR(myadc_class)) { unregister_chrdev_region(devno, MYADC_MAX_DEVS); return PTR_ERR(myadc_class); } return 0; }这里MYADC_MAX_DEVS可以定义成一个支持的最大实例数量。因为每次probe都会分配一个次设备号,必须在启动时就把段区长度定下来。RK平台上外设通常是固定的,一般给8、16或32个就够了。
用alloc_chrdev_region而不是register_chrdev的另一个好处是,主设备号由内核根据占用情况自动分配,不会再出现“板上另一个驱动已经占用了240”这种冲突。每个实例分配独立的dev_t,为下一步创建节点做准备。
4.3 probe里用device_create创建属于每个实例的节点
probe代码中调用驱动自己的函数创建设备节点,我在前面的probe里已经留了入口myadc_create_device_node(adc),它内部是做这些事:
static int myadc_create_device_node(struct myadc_dev *adc) { int minor = atomic_inc_return(&myadc_next_minor); dev_t devno; if (minor >= MYADC_MAX_DEVS) return -ENODEV; adc->devno = MKDEV(major, minor); cdev_init(&adc->cdev, &myadc_fops); adc->cdev.owner = THIS_MODULE; cdev_add(&adc->cdev, adc->devno, 1); device_create(myadc_class, adc->dev, adc->devno, adc, "myadc%d", minor); return 0; }这段代码的关键点在最后一行。device_create的前三个参数告诉内核:这个设备属于myadc这个class,设备号为adc->devno,在/sys/class/myadc/目录下自动生成一个名为myadc0或myadc1的条目。系统的udev/mdev机制检测到class下出现新设备时,自动在/dev/下创建同名节点。
所以整个过程变成了:设备树多一个节点 -> probe多次被调用 -> device_create多次被调用 -> /dev下自动多出myadc1、myadc2。应用层完全不用手工mknod,也不会出现手滑把设备号写错的问题。
还需要注意,每实例一个struct cdev。因为cdev_init把操作函数指针绑定到实例上,但如果两个实例共用同一个cdev对象,第二个cdev_add会把第一个覆盖,造成打开节点后定位到错误实例。多实例驱动必须让每个myadc_dev拥有自己的cdev字段。
4.4 在板子上验证设备节点自动生成
模块加载后,可以这样验证:
cat /proc/devices | grep myadc ls -l /sys/class/myadc/ ls -l /dev/myadc*如果看到三个/dev/myadc0、/dev/myadc1节点,而且每次设备树新增节点后自动多一个,说明动态设备号的链路已经完全打通。
有一种情况值得提醒:某些瑞芯微板子使用buildroot或精简文件系统,可能没有完整启动udev。这时/dev/myadc1不会自动出现。解决办法是在/etc/mdev.conf里加一行规则,或者在应用启动脚本中主动读取/sys/class/myadc/目录下的设备名并创建对应节点。这个环节跟驱动本身无关,但多实例开发时经常被卡一下。
5. 共享中断、并发锁和调试手法:两个实例同时跑起来才算结束
5.1 每实例一把锁,而不是全局一把锁
多实例驱动最容易被忽略的并发问题,是实例之间的锁隔离。很多驱动一开始写着:“反正用户也不会同时读两个设备,一把全局锁就够了。”这句话在单芯片时代成立,在多实例场景下会变成一个隐藏性能瓶颈。
假设实例0正在读取采样值,过程中访问了SPI总线并占用了全局锁。实例1此时也想读一个值,它唯一能做的就是在全局锁前面等待。如果实例0的采样时间较长,实例1的用户线程就会长时间卡死。应用层看起来就像第二路设备“没响应”。
改成每实例一把锁之后,adc0和adc1可以并行跑采样流程。只要底层SPI控制器本身支持多CS并发访问(瑞芯微大部分SPI控制器是顺序执行的,但等待时间不会互相阻塞到用户态),用户体验就会好很多。
5.2 中断共享:同一根中断线上挂两颗芯片
多实例中断最微妙的问题是共享中断线。如果硬件设计中两个ADC的中断脚都接到RK3568的同一个GPIO上,中断处理如果仍然按“每次触发都认为只有一颗芯片发送中断”来写,很容易把另一路的数据看成同一路。
注册中断时使用IRQF_SHARED标志:
ret = devm_request_threaded_irq(&spi->dev, adc->irq, NULL, myadc_threaded_irq, IRQF_TRIGGER_LOW | IRQF_SHARED, "myadc", adc);同时,中断处理函数里必须加入“这是否是本实例的中断”的判断。判断依据可以是读取寄存器查询状态位,或者通过一个GPIO电平确认。如果发现不是当前硬件产生的中断,立刻返回IRQ_NONE。内核会继续把该中断传递给下一个注册在共享线路上驱动实例。
我在瑞芯微板子上遇到过一次比较典型的故障:两个ADC芯片的中断脚通过线或方式接到同一个GPIO,因为共享标志没写,第二个驱动注册时直接返回-EINVAL,导致第二路永远无法工作。排查方法是在/proc/interrupts里看中断号对应的驱动名,如果发现只有一处注册,基本就是共享中断没配对。
5.3 多实例调试三板斧
多实例驱动出来后,不要只看“能跑起来”就以为完工。用下面这三个方法快速确认两路实例都正常:
cat /proc/interrupts cat /sys/kernel/debug/devices_debug dmesg | grep myadc/proc/interrupts里能看到每个中断号被触发的次数。读取设备0的数据后,如果设备0对应中断计数增加,设备1对应中断计数保持不变,说明中断隔离是正确的。
/sys/kernel/debug/devices_debug能列出所有设备及其probe状态,如果有实例一直在-EPROBE_DEFER,这里能看到原因。
dmesg里应该看到两条不同的probe日志。比如:
myadc probed: CS0, irq=157 myadc probed: CS1, irq=158只出现一条,说明第二颗芯片的设备树节点根本没有被匹配到,优先检查节点状态是否为disabled、reg是否在控制器支持的片选范围内、以及pinctrl是否被其他设备占用。
5.4 实测教训:两路一起读写时才暴露的寄存器映射问题
我调RK平台两路外部ADC时,单路跑都正常,两路同时读写就发现设备0输出的采样值会跳到设备1的量程。最终定位发现是驱动read函数里用了struct spi_transfer的tx/rx buffer,buffer本身是per-instance的,但发送命令字却引用了一个模块级常量表,而每次读写的命令字会根据片选地址动态修正。两个实例同时运行时,命令字在某个短暂的窗口被改写,导致控制器片选选错。
这个问题印证了前面说的:多实例驱动的纯度要求很高,不仅数据结构要实例化,所有会被修改的中间变量,只要生命周期跨越等待或中断,都必须放在per-instance结构体里。像命令字这种临时量,直接在read函数栈上生成就够了,千万别放成模块全局变量。
6. 我在瑞芯微平台上的一点实操体会
这两个技巧看下来,其实不是多高深的东西,本质就两句话:设备树负责把每颗芯片的资源切开,probe负责把每颗芯片的状态打包。第二句话交给动态设备号和class_create去完成,不需要手动管理设备号。
我自己在实际项目中,最痛的不是写第一版多实例驱动,而是之后每加一颗同型号芯片都要改设备树和驱动两处。改成这套写法后,驱动文件写一次,后面纯粹变成了设备树维护工作。上层应用按/dev/myadc0、/dev/myadc1直接访问,不用关心物理关系。
最后分享一个操作细节,瑞芯微编译内核时如果改过设备树,最好重新生成dtb再烧写,不要图省事在板子上用fdtput直接改设备树。因为多个外设节点之间的pinctrl和中断资源在编译时会被dtc重新排序,手改容易改出资源重叠的问题。多实例驱动本来就是用来“省事”的,把设备树弄清爽了,后续维护就是真正的低成本。