Linux驱动一驱多设备:从单实例到多实例的完整实践指南
开发Linux驱动时我接到过不止一次这样的需求一块板子上同一个外设IP核被例化了两次或者一条I2C总线上挂着四颗同型号传感器然后要求只写一套驱动让这一套驱动同时支撑起所有实例。说白了就是Linux驱动支持多个设备。这几乎是设备驱动模型里最基础、也最容易用错的需求。新手最容易踩的第一个坑是把“一个驱动支持多个设备”理解成“一个驱动文件里拷贝多份代码”或者用一堆全局变量分别存每路设备的寄存器地址、中断号、缓冲区。一开始确实能跑但只要设备数量一变、probe顺序一调、两个实例同时打开问题就全冒出来了。这篇文章我以一套虚构的“某MXIO系列IO扩展控制器”为例讲清楚一个驱动是如何同时匹配多个相同compatible的设备节点、如何让每个实例知道自己是哪一个、怎么安全处理中断和并发、以及我在实际项目中踩过的几个概率极高的坑。这套机制在platform总线、I2C/SPI总线上完全一致搞懂了设备模型换总线只是换probe的参数类型而已。1. 从“一个设备一个驱动”到“一个驱动多份实例”驱动的设计思路要变1.1 应用场景先摆出来我手上这块板子上的FPGA逻辑里例化了两个完全相同的MXIO控制器IP核分别挂在不同的寄存器基地址上中断线也是分开的。设备树里长这样mxio0: mxio40100000 { compatible vendor,mxio-v1; reg 0x0 0x40100000 0x0 0x1000; interrupts 0x0 0x2a 0x4; }; mxio1: mxio40102000 { compatible vendor,mxio-v1; reg 0x0 0x40102000 0x0 0x1000; interrupts 0x0 0x2b 0x4; };两个节点的compatible完全一样。如果按某些旧式写法的思路就得分出两个驱动或者两个“子驱动”这显然不是Linux设备模型提倡的做法。正确的做法是只写一个platform_driver内核会在匹配时分别和这两个platform_device绑定probe函数会被调用两次每一次的入参pdev都指向不同的物理设备。1.2 “支持多个设备”其实包含两种需求很多同学没有分清一驱动多设备至少有两种形式。第一种是同一个驱动支持多个不同型号的设备比如MXIO芯片有v1和v2两个版本寄存器布局基本兼容但某些功能位有差异。这种情况下一个驱动里要挂多张of_device_id表项用data字段区分配置。第二种是同一个型号的芯片被安装/例化了多个实例比如板子上焊了两颗MXIO或者一个SoC内部把相同IP核例化了四次。驱动要能对每个实例分别维护寄存器映射、中断、缓冲区互不干扰。我实测下来大多数人栽在第二种。因为第一种无非是多写几个匹配表项而第二种要真正把一个“设备驱动”的思维转成“设备实例驱动”的思维所有会变的数据都不能再放进全局变量必须挂到每个实例自己的上下文结构体里。1.3 为什么Linux天然适合“一驱动多设备”Linux的设备驱动模型从设计第一天起就是面向“多实例”的。device和device_driver是两个独立对象匹配成功后一个driver对象可以同时绑定多个device对象。内核维护的driver核心链表里一个driver节点下面会挂一串device。正是因为这个架构我们写驱动时根本不需要为第二个实例复制代码只需要保证函数可重入、数据按实例隔离。反过来说如果哪个驱动的probe里用了“裸全局变量存状态”那它基本没有资格叫“多设备驱动”只能叫“碰巧跑一次的驱动”。2. 匹配链路拆解一个of_device_id数组怎么变成两次probe2.1 of_match_table与MODULE_DEVICE_TABLE驱动要匹配设备树节点靠的是struct of_device_id数组。以MXIO v1/v2的兼容性需求为例声明如下static const struct mxio_config { u32 fifo_depth; u32 feature_flags; } mxio_v1_config { .fifo_depth 16, .feature_flags BIT(0), }; static const struct mxio_config mxio_v2_config { .fifo_depth 32, .feature_flags BIT(0) | BIT(1), }; static const struct of_device_id mxio_of_match[] { { .compatible vendor,mxio-v1, .data mxio_v1_config }, { .compatible vendor,mxio-v2, .data mxio_v2_config }, { /* sentinel */ }, }; MODULE_DEVICE_TABLE(of, mxio_of_match);每个表项的compatible字符串必须和设备树节点里compatible属性的第一项完全一致才能优先匹配上。MODULE_DEVICE_TABLE的作用是把这张表编译进模块的别名信息里让热插拔和modprobe能根据设备树节点自动加载模块。没有它模块在板子上经常不被自动加载只能手动insmod。2.2 device节点匹配成功之后发生了什么设备树在启动阶段会被解析每个带reg和compatible的节点最终会变成一个platform_device对象挂在某个总线上。内核的总线匹配逻辑会遍历设备树节点的compatible列表和当前已注册驱动的of_match_table只要找到一个相同的compatible字符串就完成匹配随即调用驱动的probe。两个节点虽然compatible一样但它们是两个独立的platform_device所以匹配会发生两次probe的调用次数也是两次。每次probe拿到的pdev不同platform_get_resource和platform_get_irq取到的寄存器和中断号也不同。这里的关键是probe函数每个实例执行一次但驱动内部的静态局部变量、全局变量在所有实例之间是共享的这正是绝大多数毛病的源头。在probe里拿匹配表项的data字段用下面这个接口static int mxio_probe(struct platform_device *pdev) { const struct mxio_config *conf of_device_get_match_data(pdev-dev); if (!conf) return -EINVAL; /* conf-fifo_depth 就是该设备对应的配置 */ ... }of_device_get_match_data会从当前设备的匹配表项里取出data指针。这个设计非常实用v1和v2的寄存器操作差别不大只是参数不同一套probe代码通过conf指针就能同时驱动两种版本。如果哪天又多出一个v3只需要在of_device_id数组里加一项不用动probe逻辑。2.3 不是只有platform总线才这样我上面举的是platform设备因为嵌入式里最常见。实际上i2c_driver、spi_driver的匹配机制一脉相承i2c设备节点匹配上后的probe入参是struct i2c_client里面带addr和irqspi设备节点匹配后入参是struct spi_device里面有chip select和mode等信息。一个i2c驱动支持总线上四颗同型号传感器本质也是同一套driver绑定四个i2c_client。所以只要理解platform这条链路换个总线只是结构体名称不同思路完全通用。3. 让每个实例“知道自己是哪一个”资源获取与编号管理3.1 别再用全局变量存寄存器基地址我见过太多驱动这样写static void __iomem *g_mxio_base; static int g_mxio_irq;两个实例probe时后probe的那个会覆盖先probe的那个。看起来好像没报错但第一个设备的read/write操作全部错乱在某些平台直接访问到非法地址导致系统挂死。正确姿势是让每个实例维护自己的完整上下文struct mxio_dev { void __iomem *base; unsigned long irq; struct mutex lock; u32 fifo_depth; struct cdev cdev; struct device *dev; };在probe里用devm_platform_ioremap_resource获得专属寄存器映射struct mxio_dev *mx; mx devm_kzalloc(pdev-dev, sizeof(*mx), GFP_KERNEL); if (!mx) return -ENOMEM; mx-base devm_platform_ioremap_resource(pdev, 0); if (IS_ERR(mx-base)) return PTR_ERR(mx-base); mx-irq platform_get_irq(pdev, 0); if (mx-irq 0) return mx-irq; platform_set_drvdata(pdev, mx);devm_platform_ioremap_resource内部就是platform_get_resource devm_ioremap_resource只要设备树reg写对它就能自动拿到当前实例的地址。代码里完全没有“第几个实例”的全局概念probe被调用几次就有几个独立的mxio_dev对象。3.2 次设备号分配从/dev/mxio0到/dev/mxioN如果这个驱动还要暴露给用户空间操作就不能只在内核里区分实例还要让用户空间能打开不同设备的节点。最传统、也最稳定的办法是给每个实例分配一个独立的次设备号批量注册主设备号。以模块最多支持8个实例为例#define MXIO_MAX_DEVICES 8 static int mxio_major; static struct class *mxio_class; static atomic_t mxio_alloc_idx ATOMIC_INIT(0); static int __init mxio_init(void) { dev_t devno; int ret; ret alloc_chrdev_region(devno, 0, MXIO_MAX_DEVICES, mxio); if (ret 0) return ret; mxio_major MAJOR(devno); mxio_class class_create(THIS_MODULE, mxio); if (IS_ERR(mxio_class)) { unregister_chrdev_region(devno, MXIO_MAX_DEVICES); return PTR_ERR(mxio_class); } return platform_driver_register(mxio_driver); }probe中每次分配一个次设备号索引然后创建cdev和类设备static int mxio_setup_chardev(struct mxio_dev *mx, int idx) { dev_t devno MKDEV(mxio_major, idx); cdev_init(mx-cdev, mxio_fops); mx-cdev.owner THIS_MODULE; cdev_add(mx-cdev, devno, 1); mx-dev device_create(mxio_class, mx-pdev-dev, devno, mx, mxio%d, idx); return PTR_ERR_OR_ZERO(mx-dev); }probe主体里调用int idx atomic_fetch_inc(mxio_alloc_idx); if (idx MXIO_MAX_DEVICES) { atomic_dec(mxio_alloc_idx); dev_err(pdev-dev, too many instances\n); return -EBUSY; } ret mxio_setup_chardev(mx, idx); if (ret) return ret;用户空间看到的节点就是/dev/mxio0、/dev/mxio1。两个进程分别打开这两个节点实际操作的是两个完全不同的硬件实例。3.3 open之后怎么拿回实例指针字符设备的open回调里我们通过inode里的cdev指针反向找到所属的mxio_devstatic int mxio_open(struct inode *inode, struct file *filp) { struct mxio_dev *mx container_of(inode-i_cdev, struct mxio_dev, cdev); filp-private_data mx; return 0; }这里用到了container_of原理是通过结构体成员地址反推结构体首地址。只要cdev是mxio_dev结构体的成员且在probe里被cdev_init初始化过open时就能根据inode-i_cdev安全拿回实例指针。之后read/write/ioctl里只需要从filp-private_data取出mx再操作mx-base就能做到“每个实例各干各的”。另外有个细节如果驱动支持同时被多个进程打开同一个实例每个file的private_data可能会是同一个mxio_dev指针这时必须在read/write里用mutex保护寄存器操作。如果只允许一个进程打开可以在open里加一个atomic变量做计数超过1就返回-EBUSY。更多并发细节我放在下一章。4. 多实例同时跑起来并发、中断与资源隔离4.1 寄存器访问锁一实例一锁而不是全局一锁当两个实例同时被用户空间访问物理上只是两个不同地址的寄存器区域本来不需要互相串行。如果图省事用一个全局mutex保护所有实例的寄存器操作两个实例的并发能力直接退化成了串行这在IO密集型场景里性能损失很大。正确做法是把mutex放进mxio_dev每个实例一把锁mutex_init(mx-lock); static ssize_t mxio_write(struct file *filp, const char __user *buf, size_t count, loff_t *pos) { struct mxio_dev *mx filp-private_data; mutex_lock(mx-lock); /* 写MXIO寄存器 */ mutex_unlock(mx-lock); return count; }如果两个实例共享一个底层外设总线比如同一条I2C总线、同一个SPI控制器那还得分两层锁底层总线访问时加“总线级锁”上层各实例自己的寄存器缓冲操作加“实例级锁”。这一层混在一起很容易死锁我建议把锁的最小粒度控制在寄存器读写的临界区里不要在read/write函数的外层套一把大锁。4.2 中断共享IRQF_SHARED与状态寄存器排查MXIO v1在硬件上允许两个控制器共享同一个中断线。共享中断的规则是申请中断时必须带IRQF_SHARED标志而且每个申请者都要提供自己的设备指针。中断触发时内核依次调用所有注册在该中断号上的handler每个handler去读自己实例的状态寄存器判断本次中断是不是自己的。ret devm_request_threaded_irq(pdev-dev, mx-irq, mxio_handler, mxio_thread_fn, IRQF_SHARED, mxio, mx);在mxio_handler里如果读状态寄存器发现没有pending事件必须返回IRQ_NONE确认是自己实例的事件才返回IRQ_WAKE_THREAD或IRQ_HANDLED。这里有一个非常典型的坑如果硬件状态寄存器的中断标志需要写1清0而且多个实例共享中断线绝对不能在一个实例的handler里把“所有实例的中断状态寄存器”全部清掉。否则另一个实例的中断丢失等它的线程函数去读数据时数据已经出错了。正确的清中断操作永远只清当前实例自己的状态位。4.3 工作队列与缓冲区只要共享就有风险如果中断下半部用工作队列处理最好每个实例一个delayed_work或者work_struct放到自己的结构体里。不要让两个实例共用同一个全局work_struct不然实例1的work还在队列里排队实例2又去初始化这个work轻则触发内核告警重则损坏链表结构直接panic。DMA缓冲区更是如此。多设备支持的场景下每个实例都要有独立的DMA缓冲区或至少独立的descriptor。两个实例共用一块DMA内存同时搬运数据时数据互相覆盖属于家常便饭。我在实际项目里遇到过一次两个控制器的收发缓冲区分了4KB结果都写到同一块物理内存上抓包发现A控制器的数据包被B控制器的数据覆盖排查了很久才意识到DMA内存是全局分配的。整理一下多实例开发需要隔离的关键资源资源类型正确做法典型错误寄存器映射每个probe独立ioremap全局指针被后probe覆盖中断线devm_request_threaded_irq IRQF_SHARED不共享导致注册失败自旋锁/mutex放入per-instance结构体全驱动一把锁造成串行工作队列每个实例独立work共用一个work导致重复初始化DMA/缓冲区dma_alloc_coherent按实例分配多个实例共用一个缓冲区次设备号批量分配按索引定位实例每次都动态申请主设备号5. 实际项目里最容易翻车的五个细节5.1 两个相同compatible的节点到底会不会冲突有人担心设备树里写两个相同compatible的节点会不会导致内核认为这是同一个设备。实际上不会每个节点都会创建独立的platform_device只要reg不重叠就没问题。不过有一个细节值得注意如果节点写了label但没有给reg或者reg是0两个设备节点因为缺少地址区分在sysfs里可能显示类似的路径IDE工具查看时容易混淆。我给这类多实例节点取名时习惯带上path或reg后缀比如mxio40100000和mxio40102000这样日志、sysfs、crash时定位都更快。5.2 probe中途失败时devm能帮你清什么、不能帮你清什么devm_系列资源管理函数解决的是probe失败后的自动释放问题。devm_kzalloc、devm_platform_ioremap_resource、devm_request_threaded_irq都在失败路径上自动释放。但cdev_add和device_create不属于devm管理范围一旦probe在chardev创建之后失败必须手动cdev_del和device_destroy否则下一次probe再执行时相同次设备号可能已经存在cdev_add会返回失败。我的习惯是把“非devm的资源”尽量集中在probe最后面做并在出错路径写清楚清理函数。哪怕最终没走出错路径这个习惯也救过我一次当时后续实例probe失败前面成功实例释放掉了但失败实例的cdev没清模块卸载时系统一直报“chrdev already exists”。5.3 remove函数里的顺序问题一驱动多设备时模块卸载会依次调用每个设备的remove。可能设备0先被移除设备1还在运行。如果设备1的代码里还引用着设备0的数据那就是典型的悬空指针。统一做法是remove里只清理当前pdev对应的实例不碰任何全局实例数组。跨实例的资源引用要么在模块加载时初始化后就只读要么在内核里用引用计数或者锁保护。需要留意的是用户空间如果正打开着/dev/mxio0执行rmmod时设备0的remove如果直接释放内存用户空间下次调用read/write会直接访问已释放的内存。所以字符设备驱动在open时要增加模块引用计数if (!try_module_get(THIS_MODULE)) return -ENODEV;在release里module_put。这是很多“一驱动多设备”驱动在卸载模块时崩溃的原因——之前我一直只盯着probe/remove后来才发现是open/release的引用计数没做。5.4 次设备号耗尽与实例上限静态分配次设备号时MXIO_MAX_DEVICES是8但如果产品改版后一个系统里要挂10个实例probe到第9个就会返回-EBUSY。日志需要写清楚是“实例数量超过上限”不然硬件工程师会以为板子坏了。更松的做法是使用动态次设备号分配机制如ida在probe成功后才分配remove时释放不必一次性把主设备号下所有次设备号都占住。缺点是不再能简单用“次设备号等于实例编号”来映射但多数应用场景里我们本来就不依赖这个映射open里拿到的是cdev指针并不需要关心次设备号具体是多少。5.5 匹配表一定要以空表项结尾of_device_id数组最后一个元素必须是空初始化器否则内核遍历表时会越过数组边界。这个错误平时不会暴露但当你同时增加第二个compatible项时数组越界可能让匹配逻辑读到随机内存导致probe被莫名其妙地调用。写MODULE_DEVICE_TABLE时建议习惯性写 { /* sentinel */ }哪怕表里当前只有一项。我还遇到过一种情况同一个驱动的of_match_table里既有v1又有v2设备树节点中compatible写成“vendor,mxio-v1”。系统启动后加载了另一个旧驱动旧驱动的匹配表里恰好也有“vendor,mxio-v1”。这时匹配谁不匹配谁取决于注册顺序两个驱动都可能被绑定但同一个设备只会被一个驱动probe。如果发现设备节点没有被自己的驱动接管先用ls /sys/bus/platform/devices/看一下设备当前的driver指向哪里再去查是不是匹配表重复导致驱动抢设备。6. 手把手梳理一套“支持多实例的驱动”最小骨架从代码组织角度我建议把整个驱动拆成三个文件核心逻辑、设备注册、字符设备接口。下面给一套我目前常用的最小骨架方便照着抄。mxio_core.hstruct mxio_dev { void __iomem *base; unsigned long irq; struct mutex lock; u32 fifo_depth; struct cdev cdev; struct device *dev; struct platform_device *pdev; };mxio_core.cstatic const struct mxio_config mxio_v1_config { .fifo_depth 16 }; static const struct mxio_config mxio_v2_config { .fifo_depth 32 }; static const struct of_device_id mxio_of_match[] { { .compatible vendor,mxio-v1, .data mxio_v1_config }, { .compatible vendor,mxio-v2, .data mxio_v2_config }, { /* sentinel */ }, }; MODULE_DEVICE_TABLE(of, mxio_of_match); static int mxio_probe(struct platform_device *pdev) { struct mxio_dev *mx; const struct mxio_config *conf; int idx, ret; mx devm_kzalloc(pdev-dev, sizeof(*mx), GFP_KERNEL); if (!mx) return -ENOMEM; conf of_device_get_match_data(pdev-dev); if (!conf) return -EINVAL; mx-base devm_platform_ioremap_resource(pdev, 0); if (IS_ERR(mx-base)) return PTR_ERR(mx-base); mx-irq platform_get_irq(pdev, 0); if (mx-irq 0) return mx-irq; mx-fifo_depth conf-fifo_depth; init_rwsem(mx-lock); mx-pdev pdev; platform_set_drvdata(pdev, mx); idx atomic_fetch_inc(mxio_alloc_idx); if (idx MXIO_MAX_DEVICES) { atomic_dec(mxio_alloc_idx); return -EBUSY; } ret mxio_setup_chardev(mx, idx); if (ret) return ret; return 0; } static void mxio_remove(struct platform_device *pdev) { struct mxio_dev *mx platform_get_drvdata(pdev); device_destroy(mxio_class, mx-dev-devt); cdev_del(mx-cdev); } static struct platform_driver mxio_driver { .probe mxio_probe, .remove mxio_remove, .driver { .name mxio, .of_match_table mxio_of_match, }, }; module_platform_driver(mxio_driver);mxio_fops里open用container_ofwrite/ioctl用filp-private_data拿到mx再操作。在真正动手写“一驱动多设备”前我强烈建议先把寄存器读写封装成带实例参数的小函数不要直接裸操作一个全局ioremap地址。我早期接手的一个项目就是没有做这层抽象驱动里到处是base地址的全局变量后来扩展第二个实例时改得头皮发麻。封装好底层的read32/write32probe函数也就二三十行扩展第三、第四个实例只是改两行设备树的事。说到底Linux驱动支持多个设备这件事本质不是“复制代码”而是“把设备差异全部参数化、实例化”。设备树提供差异化描述of_match_table提供差异化配置probe创建差异化的实例数据字符设备用次设备号暴露差异化入口。链路清楚了多做几个实例只是重复劳动不会变成重复踩坑。