RK3588多设备驱动:container_of与ida实现多实例字符设备
2026/9/7 10:54:51 网站建设 项目流程

做瑞芯微平台驱动开发,尤其是 RK3588、RV1126 这类 SoC 上同时挂多个同型号外设的时候,很多朋友会栽在同一道坎上:写一个字符设备驱动,板子上却接了 2 颗甚至 4 颗同样的 ADC、同样的 sensor、同样的编解码芯片,驱动该怎么组织,才能让每个设备都有独立访问入口,又不会互相干扰?

这篇文章就用一个非常典型的场景——RK3588 的 SPI0 总线上挂两片 MCP3008 模数转换芯片——把 Linux 字符设备驱动支持多个设备实例的两个核心技巧讲透。这两个技巧分别是:用container_of把 cdev 绑进设备私有结构体,以及用ida动态分配次设备号并自动生成/dev节点。它们不挑平台、不挑内核版本,瑞芯微、树莓派、i.MX、全志上手都是同一套逻辑。不管你之前只写过杂项设备、写过全局数组版本,还是刚从应用层转内核,这一篇都能直接抄作业。

1. 多设备驱动场景:为什么全局数组不是好答案

1.1 典型需求:一个 SPI 控制器、两颗同型号 ADC

先说需求。RK3588 的 SPI0 控制器的 CS0 上接了 ADC0,CS1 上接了 ADC1,两颗芯片完全同型号,都叫 MCP3008,都是 8 通道 10-bit。应用层访问的时候,希望打开/dev/mcp3008_0就操作 ADC0,打开/dev/mcp3008_1就操作 ADC1,两边数据不能串。

这个需求拆开看就两个点:第一,两个设备在系统里要对应两个不同的设备节点;第二,内核驱动要能区分“当前用户打开的是哪一个芯片”,并且读写时只操作对应的那个 SPI 从设备。

很多第一次写多实例驱动的朋友,第一反应是全局数组:

static struct mcp3008_dev g_devs[2];

每次 open 一个设备节点,就根据次设备号iminor(inode)去找数组下标,然后返回&g_devs[minor]。乍一看没问题,实际工程里到处都是隐患。

1.2 全局数组方案的四个隐患

第一个隐患是设备数量写死。数组长度是编译期常量,板子从 2 颗改成 4 颗,要改代码重新编译;如果客户现场有不同配置,你得维护多个内核模块。

第二个隐患是次设备号和数组下标的对应关系太脆弱。如果中途有一个 probe 失败,或者用了动态次设备号分配,下标和次设备号就可能错位,等到用户空间打开设备时访问的却是另一个芯片的数据。

第三个隐患是并发访问的锁不好处理。多个进程同时 open 不同的实例时,如果锁放在全局数组上,那 ADC0 和 ADC1 明明是两套独立硬件,却被同一把锁串行化了;如果每个实例各自带锁,那这个“锁数组”的初始化、销毁、越界保护又是一堆麻烦事。

第四个隐患是热插拔或动态创建设备时完全没招。SPI 从设备一般不是热插拔,但你也可能用 GPIO 模拟的 SPI、或者用可配置的 mfd 子设备,这时候设备数量在运行时才知道,全局数组根本没法写。

所以正确方向不是“用一个数组管所有设备”,而是“每个设备实例自己带完整上下文,驱动只要拿到当前实例指针就能干活”。这就是下面第一个技巧要解决的问题。

2. 技巧一:把 cdev 嵌进设备结构体,用 container_of 找回实例

2.1 思路对比:全局索引 vs 上下文内聚

Linux 内核处理这类“一个驱动管多个实例”问题的标准手段,是私有结构体 + container_of。核心思想很简单:你要访问的任何数据——字符设备对象、次设备号、SPI 设备指针、锁、硬件状态——全部塞进一个结构体里。然后把这个结构体的地址,想办法在文件操作接口(open/read/ioctl)执行时拿到。

问题只剩一个:open 接口只给我们struct inode *struct file *,而struct inode里面有i_cdev指针,它指向注册进内核的struct cdev。如果我们把这个struct cdev作为成员嵌进私有结构体,那么从i_cdev的地址,用container_of就能反推出整个私有结构体的首地址。

一句话:全球数组是用下标找设备,私有结构体是用地址找设备。后者没有数量限制、没有下标错位问题,锁也是每个实例一把,天然隔离。

2.2 struct mcp3008_dev 与 container_of 的关键一行

先看私有结构体和文件操作的骨架:

struct mcp3008_dev { struct cdev cdev; /* 内核字符设备对象,必须嵌在结构体里 */ struct device *dev; /* device_create 返回的指针 */ struct spi_device *spi; /* 当前实例对应的 SPI 从设备 */ int minor; /* 当前实例的次设备号 */ struct mutex lock; /* 每实例一把锁,互不影响 */ unsigned int ch_value[8]; /* 8 通道采样缓存 */ };

注册 cdev 的时候,注意我们是给每个实例都执行一次cdev_initcdev_add,而不是共用一个 cdev:

cdev_init(&priv->cdev, &mcp3008_fops); priv->cdev.owner = THIS_MODULE; ret = cdev_add(&priv->cdev, MKDEV(mcp3008_major, priv->minor), 1); if (ret) { dev_err(&spi->dev, "cdev_add failed: %d\n", ret); goto err_ida; }

这里privstruct mcp3008_dev *priv->cdev就是内嵌的那个 cdev。cdev_add的第三个参数是“这个设备号下挂多少个次设备”。我们每个实例只占一个次设备号,所以传 1。

接着,open 接口里最重要的一行:

static int mcp3008_open(struct inode *inode, struct file *filp) { struct mcp3008_dev *priv; /* 从 inode 里的 cdev 指针,反推整个私有结构体 */ priv = container_of(inode->i_cdev, struct mcp3008_dev, cdev); filp->private_data = priv; return 0; }

container_of是内核里出现频率最高的宏之一。它的原理是:编译器知道struct mcp3008_devcdev成员相对于结构体首地址的偏移量offsetof(struct mcp3008_dev, cdev),现在拿到了cdev的真实地址,用它减去偏移量,就得到结构体首地址。就是一次指针算术。注意用这个宏时第二个参数是外层结构体类型,第三个参数是刚才那个成员的名字,这三个参数必须严格对应,写错一个就是内存踩踏。

2.3 这种写法为什么能省掉所有“找设备”的逻辑

一旦open里把priv塞进了filp->private_data,后面的readwriteioctlrelease全都省事了:

static ssize_t mcp3008_read(struct file *filp, char __user *buf, size_t count, loff_t *off) { struct mcp3008_dev *priv = filp->private_data; int chan; u16 val; if (count < sizeof(val)) return -EINVAL; chan = *off; /* 这里简单用 offset 表示通道号,实际项目可以走 ioctl */ if (chan < 0 || chan >= 8) return -EINVAL; mutex_lock(&priv->lock); val = mcp3008_read_channel(priv->spi, chan); mutex_unlock(&priv->lock); if (copy_to_user(buf, &val, sizeof(val))) return -EFAULT; return sizeof(val); }

整个 read 函数里没有出现“第几个设备”这种判断,因为private_data已经告诉内核函数当前操作的是哪颗芯片。这就是上下文内聚的好处:设备的所有状态都跟着实例走,而不是靠一个外部索引去猜

其实内核自身大量使用这种模式。struct spi_devicestruct i2c_clientstruct platform_device这些核心结构体,在具体驱动里都是作为更大结构体的成员存在,驱动用container_of(或者是基于它封装的to_spi_deviceto_platform_device)把抽象对象还原成自己的私有数据。理解了这一个技巧,回头看很多内核驱动的源码都会豁然开朗。

3. 技巧二:次设备号动态分配 + 自动生成节点

3.1 为什么手工分配次设备号会撞车

解决了“怎么找回实例”之后,下一个问题是“每个实例的次设备号从哪来”。

很多教材喜欢用register_chrdev,这个老接口会自动分配一个主设备号,但次设备号只能从 0 到 255 手工管理。假如你板子上有两个 MCP3008、四个串口扩展、一个 PWM 芯片,大家都用register_chrdev,你自己的程序里写着#define ADC0_MINOR 0#define ADC1_MINOR 1,看起来没问题,可一旦另一个驱动也用了次设备号 0、1,系统里/dev节点指向的 cdev 就可能张冠李戴。

即使你用alloc_chrdev_region动态拿到主设备号,次设备号依然需要自己管理。自己管理的意思是你得维护一个“哪些号用了、哪些号空闲”的数据结构,还要保证并发安全。这个活内核已经替你做好了,它的名字叫 IDA。

3.2 ida 的使用姿势

IDA(ID Allocation)是内核提供的整数 ID 分配器,内部用 radix tree 维护空闲 ID,支持动态分配、释放、并发安全。在驱动的视角里,它就是一个“自动发号器”:你向它要一个不重复的整数,用完再还回去。

驱动里一般这样定义一个全局 IDA:

static DEFINE_IDA(mcp3008_ida);

probe 里分配:

priv->minor = ida_alloc(&mcp3008_ida, GFP_KERNEL); if (priv->minor < 0) { ret = priv->minor; goto err_free; }

remove 里释放:

ida_free(&mcp3008_ida, priv->minor);

这里有两个细节值得注意。

第一,ida_alloc是相对较新内核(4.17 以后)的接口。瑞芯微平台的内核版本跨度很大,老的 BSP 内核(4.4、4.19)里可能只有ida_simple_getida_simple_remove。老接口用法几乎一样:ida_simple_get(&mcp3008_ida, 0, 0, GFP_KERNEL),中间的参数是范围。如果你在维护老平台,直接用老接口就行;新平台优先用ida_alloc,两个接口混用时一定要确认内核版本。

第二,ida_alloc分配出来的 ID 是最小的空闲整数,不保证连续。如果先注册 ADC0 拿 0,再注册 ADC1 拿 1,中途 ADC1 卸载了,下一次注册会优先复用 1,而不是继续累加。这个特性通常没问题,但如果你严格要求“片选 0 对应节点 0、片选 1 对应节点 1”,那就不要依赖 IDA 的顺序,而是在device_create命名时显式用spi->chip_select之类的硬件标识。设备树里写的reg = <0>会在 SPI 子系统中映射到片选号,probe 里可以直接读。

3.3 class + device_create 让 /dev 节点自动出现

有了次设备号,还得让系统在/dev下生成节点。这一步靠 class 和device_create

注册字符设备时,驱动一般还要创建 class,这样/sys/class下会有对应的目录,udev(或者 busybox 的mdev)监听 uevent 后会自动在/dev下创建设备节点。

static struct class *mcp3008_class; mcp3008_class = class_create(THIS_MODULE, "mcp3008"); if (IS_ERR(mcp3008_class)) { ret = PTR_ERR(mcp3008_class); goto err_unregister; }

注意class_create在旧内核里确实是两个参数,但比较新的内核(6.4 之后)改成了单参数。瑞芯微主线内核或较新 BSP 如果编译报参数数量不对,去掉THIS_MODULE即可。

probe 里注册设备节点:

priv->dev = device_create(mcp3008_class, &spi->dev, MKDEV(mcp3008_major, priv->minor), priv, "mcp3008_%d", priv->minor); if (IS_ERR(priv->dev)) { ret = PTR_ERR(priv->dev); goto err_cdev; }

这里第五个参数是设备节点名称的格式串,最终/dev下会出现mcp3008_0mcp3008_1。如果device_create失败,它会返回一个ERR_PTR编码的错误码,所以必须用IS_ERR判断,千万不要拿它和NULL比。很多朋友在这里栽过跟头,device_create返回错误指针不是空指针,直接用if (!priv->dev)判断,错误码被当成 0 处理,后面访问priv->dev直接崩。

device_create的第四个参数是drvdata,内核会把priv存入priv->dev->driver_data。这个数据不强制使用,但保留下来有个好处:以后你在别的函数里如果只拿到struct device *,可以用dev_get_drvdatapriv取回来。

4. 组合实战:RK3588 上从 0 到 1 的完整链路

4.1 设备树里同时声明两个节点

瑞芯微平台设备树中,SPI 控制器下挂多个同型号从设备,写法很直接。在设备树里给 SPI0 添加两个子节点:

&spi0 { status = "okay"; pinctrl-names = "default"; pinctrl-0 = <&spi0_cs0 &spi0_cs1>; adc0: mcp3008@0 { compatible = "mcp3008"; reg = <0>; spi-max-frequency = <1000000>; }; adc1: mcp3008@1 { compatible = "mcp3008"; reg = <1>; spi-max-frequency = <1000000>; }; };

两个节点的compatible完全相同,所以匹配的是同一个驱动,probe 会被调用两次。reg对应片选号,在这个例子里分别是 CS0 和 CS1。spi-max-frequency是 SPI 控制器配置从设备最高频率的依据,MCP3008 只有 1MHz 左右,别写太高。

驱动侧匹配表:

static const struct of_device_id mcp3008_of_match[] = { { .compatible = "mcp3008" }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, mcp3008_of_match);

4.2 probe 里怎么把两个技巧串起来

驱动入口用 SPI 子系统注册,probe 函数签名的参数是struct spi_device *

static int mcp3008_probe(struct spi_device *spi) { struct mcp3008_dev *priv; dev_t devt; int ret; priv = kzalloc(sizeof(*priv), GFP_KERNEL); if (!priv) return -ENOMEM; priv->spi = spi; mutex_init(&priv->lock); /* 技巧二:分配次设备号 */ priv->minor = ida_alloc(&mcp3008_ida, GFP_KERNEL); if (priv->minor < 0) { ret = priv->minor; goto err_free; } /* 技巧一:初始化并添加 cdev,嵌入私有结构体 */ cdev_init(&priv->cdev, &mcp3008_fops); priv->cdev.owner = THIS_MODULE; devt = MKDEV(mcp3008_major, priv->minor); ret = cdev_add(&priv->cdev, devt, 1); if (ret) { dev_err(&spi->dev, "cdev_add failed: %d\n", ret); goto err_ida; } /* 自动生成 /dev 节点 */ priv->dev = device_create(mcp3008_class, &spi->dev, devt, priv, "mcp3008_%d", priv->minor); if (IS_ERR(priv->dev)) { ret = PTR_ERR(priv->dev); goto err_cdev; } spi_set_drvdata(spi, priv); dev_info(&spi->dev, "mcp3008 probed, minor=%d\n", priv->minor); return 0; err_cdev: cdev_del(&priv->cdev); err_ida: ida_free(&mcp3008_ida, priv->minor); err_free: kfree(priv); return ret; }

注意这里cdev_add如果失败,必须把cdev_del的路径跟ida_free的路径分开,顺序不能乱。很多驱动写成一锅粥的 error handling,释放顺序错了,minor 被释放了两次或者 cdev 残留,下一次 probe 就会失败。

对应的 remove:

static void mcp3008_remove(struct spi_device *spi) { struct mcp3008_dev *priv = spi_get_drvdata(spi); device_destroy(mcp3008_class, MKDEV(mcp3008_major, priv->minor)); cdev_del(&priv->cdev); ida_free(&mcp3008_ida, priv->minor); kfree(priv); }

4.3 open/read 时怎么知道自己操作的是哪个设备

当用户态执行open("/dev/mcp3008_1", O_RDONLY)时,VFS 会根据设备节点记录的dev_t找到主设备号对应的模块、次设备号对应的 cdev,然后调用mcp3008_open。open 里我们已经用container_of拿到了priv,并写进了filp->private_data

后续每次read都从private_data取指针,实际访问的priv->spi就是内核在 probe 时绑定到第二颗芯片的 SPI 设备。这个绑定关系在 probe 时就固定下来了,不会因为用户打开节点的顺序而改变。哪怕用户先打开mcp3008_1再打开mcp3008_0,各自拿到的spi依然是 CS1 和 CS0,不会串。

4.4 加载后的实际效果

驱动编译成模块后,在 RK3588 板子上按顺序执行:

insmod mcp3008.ko

dmesg 里会出现两条类似这样的 log:

mcp3008 spi0.0: mcp3008 probed, minor=0 mcp3008 spi0.1: mcp3008 probed, minor=1

/dev下出现两个节点:

ls -l /dev/mcp3008_*

如果之前创建过 class,/sys/class/mcp3008/下也会有两个目录。访问cat /sys/class/mcp3008/mcp3008_0/dev能看到主次设备号,uEvent 机制就是靠这个信息通知 udev 生成节点的。

再配合一个简单的应用层程序,分别打开两个节点读数据,就能发现 ADC0 的电压和 ADC1 的电压互不干扰。到这里,两个技巧就算真正落地了。

5. 实测中踩过的坑:从 dmesg 到行为异常

5.1 cdev_add 返回 -EEXIST 的排查

我在 RK3588 上第一次跑这套代码时,probe 第一次成功,第二次直接报cdev_add failed: -17(-EEXIST)。我当时的直觉是次设备号冲突,但 IDA 明明分配了两个不同的号。

后来发现根因在 remove 函数:早期版本我在 remove 里忘了调用ida_free,模块卸载后重新 insmod,IDA 继续从 0 开始分配,0 被占用后第二次 probe 分配到了 0 和一个新 ID,而cdev_add用的MKDEV设备号跟上一个生命周期里的设备号相同,内核认为这个设备号已经被注册了。

这个问题在“卸载重载模块”时非常隐蔽,因为 dmesg 不一定会直接提示是哪个设备号冲突。排查步骤是:

  1. cat /proc/devices确认主设备号是否被重复注册;
  2. ida_alloc之后打印priv->minor,确认分配到的 ID;
  3. 重点检查 remove 路径,看看ida_free是否执行了。

其实这类问题最有效的预防手段就是插拔测试加卸载重载测试同时做,很多驱动只跑一遍 insmod 看不出问题。

5.2 device_create 参数错位引发的 /dev 节点消失

另一个让我折腾半天的现象是:cdev_add成功了,device_create也没报错,但/dev下就是没有节点。

检查之后发现是device_create的参数顺序错了。它的完整签名是:

struct device *device_create(struct class *cls, struct device *parent, dev_t devt, void *drvdata, const char *fmt, ...);

我一开始把第二个参数&spi->dev写成了priv->dev,而priv->dev在创建之前还是NULL,传进去之后device_create内部会尝试访问 parent 成员,导致节点没生成,但又不一定立刻 panic,只是 uevent 没有正确触发。

检查方法很简单:/sys/class/mcp3008/下如果有目录但/dev下没有节点,大概率是 uevent 没发出来或者 udev 规则没匹配上。这时候手动跑一条mknod /dev/mcp3008_0 c 240 0能确认驱动本身是否正常,再回头查device_create的 parent 和 class 参数。

5.3 container_of 用错成员名的现场

container_of(inode->i_cdev, struct mcp3008_dev, cdev)这行代码,如果第三个成员名写成了别的字段,比如container_of(inode->i_cdev, struct mcp3008_dev, dev),编译器不会报错,因为它只看类型和偏移,但运行时会用一个完全错误的偏移量算结构体首地址,然后 read 时通过priv->spi访问内存,大概率直接触发 kernel NULL pointer dereference 或 oops。

这类问题定位时,dmesg 里的栈回溯会指向 read 或 ioctl 函数,而不是 open 本身。我的建议是:container_of时,第二个参数和第三个参数必须对照着结构体定义复查一遍。更稳妥的做法是尽量使用内核提供的封装宏,比如to_mcp3008_dev(ptr)这种内联函数:

static inline struct mcp3008_dev *to_mcp3008_dev(struct cdev *cdev) { return container_of(cdev, struct mcp3008_dev, cdev); }

封装一层后,调用点只需要写to_mcp3008_dev(inode->i_cdev),就算以后结构体改名或字段调整,也只改一处。

5.4 udev/mdev 规则与设备节点自动创建的隐性条件

最后一个坑很现实:在瑞芯微的 buildroot 根文件系统里,很多项目用的不是 systemd-udevd,而是 busybox mdev。mdev 默认只在热插拔事件时根据/etc/mdev.conf规则创建设备节点,如果你的/etc/mdev.conf没有给mcp3008配置规则,/dev/mcp3008_*就不会自动出现。

解决办法有两个。一个是在/etc/mdev.conf加一行:

mcp3008_[0-9] 0:0 600

另一个是干脆在驱动加载后手动mknod。区别在于,前者对用户透明,后者适合调试阶段快速验证。系统里如果用的是 systemd-udevd,一般不需要额外配置,默认会按MAJOR:MINOR自动生成。所以遇到“设备节点不出现”的问题,先确认根文件系统用的是 mdev 还是 udev,再决定往哪个方向排查。

我在实际项目里还碰到过一个更冷门的情况:device_create的 name 格式串用了%d,但传入的是priv->minor,此时minorint,没问题;可如果某些内核配置下dev_t的 minor 部分被宏定义成unsigned long,打印和格式化时类型不匹配也会导致节点名异常。这类问题虽然少见,但在多平台移植时值得留个心眼。

如果让我给刚接触多设备驱动的人一个综合建议,那就是:一开始就按“私有结构体 + container_of + ida + class 自动节点”这套方式来写,不要用全局数组过渡,因为过渡版本最终还是要推翻重写。刚开始可能觉得多绕了一圈,但写过一次之后,你会发现加第三颗芯片真的只需要在设备树里多写一个节点,驱动代码一行都不用动。这种“数据跟着实例走”的思路,才是 Linux 驱动开发的真正底色。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询