ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Linux驱动一驱多设备:从单实例到多实例的完整实践指南

Linux驱动一驱多设备:从单实例到多实例的完整实践指南

开发Linux驱动时,我接到过不止一次这样的需求:一块板子上同一个外设IP核被例化了两次,或者一条I2C总线上挂着四颗同型号传感器,然后要求只写一套驱动,让这一套驱动同时支撑起所有实例。说白了,就是Linux驱动支持多个设备。这几乎是设备驱动模型里最基础、也最容易用错的需求。

新手最容易踩的第一个坑,是把“一个驱动支持多个设备”理解成“一个驱动文件里拷贝多份代码”,或者用一堆全局变量分别存每路设备的寄存器地址、中断号、缓冲区。一开始确实能跑,但只要设备数量一变、probe顺序一调、两个实例同时打开,问题就全冒出来了。

这篇文章我以一套虚构的“某MXIO系列IO扩展控制器”为例,讲清楚一个驱动是如何同时匹配多个相同compatible的设备节点、如何让每个实例知道自己是哪一个、怎么安全处理中断和并发、以及我在实际项目中踩过的几个概率极高的坑。这套机制在platform总线、I2C/SPI总线上完全一致,搞懂了设备模型,换总线只是换probe的参数类型而已。

1. 从“一个设备一个驱动”到“一个驱动多份实例”,驱动的设计思路要变

1.1 应用场景先摆出来

我手上这块板子上的FPGA逻辑里,例化了两个完全相同的MXIO控制器IP核,分别挂在不同的寄存器基地址上,中断线也是分开的。设备树里长这样:

mxio0: mxio@40100000 { compatible = "vendor,mxio-v1"; reg = <0x0 0x40100000 0x0 0x1000>; interrupts = <0x0 0x2a 0x4>; }; mxio1: mxio@40102000 { 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数组怎么变成两次probe

2.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和irq;spi设备节点匹配后入参是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_dev:

static 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后缀,比如mxio@40100000和mxio@40102000,这样日志、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.h:

struct 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.c:

static 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_of,write/ioctl用filp->private_data拿到mx再操作。

在真正动手写“一驱动多设备”前,我强烈建议先把寄存器读写封装成带实例参数的小函数,不要直接裸操作一个全局ioremap地址。我早期接手的一个项目就是没有做这层抽象,驱动里到处是base地址的全局变量,后来扩展第二个实例时改得头皮发麻。封装好底层的read32/write32,probe函数也就二三十行,扩展第三、第四个实例只是改两行设备树的事。

说到底,Linux驱动支持多个设备这件事,本质不是“复制代码”,而是“把设备差异全部参数化、实例化”。设备树提供差异化描述,of_match_table提供差异化配置,probe创建差异化的实例数据,字符设备用次设备号暴露差异化入口。链路清楚了,多做几个实例只是重复劳动,不会变成重复踩坑。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进