RK3588多设备驱动:container_of与ida实现多实例字符设备

发布时间:2026/9/7 10:55:09
RK3588多设备驱动:container_of与ida实现多实例字符设备 做瑞芯微平台驱动开发尤其是 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 上接了 ADC0CS1 上接了 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_init和cdev_add而不是共用一个 cdevcdev_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; }这里priv是struct 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_dev中cdev成员相对于结构体首地址的偏移量offsetof(struct mcp3008_dev, cdev)现在拿到了cdev的真实地址用它减去偏移量就得到结构体首地址。就是一次指针算术。注意用这个宏时第二个参数是外层结构体类型第三个参数是刚才那个成员的名字这三个参数必须严格对应写错一个就是内存踩踏。2.3 这种写法为什么能省掉所有“找设备”的逻辑一旦open里把priv塞进了filp-private_data后面的read、write、ioctl、release全都省事了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_device、struct i2c_client、struct platform_device这些核心结构体在具体驱动里都是作为更大结构体的成员存在驱动用container_of或者是基于它封装的to_spi_device、to_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 的使用姿势IDAID Allocation是内核提供的整数 ID 分配器内部用 radix tree 维护空闲 ID支持动态分配、释放、并发安全。在驱动的视角里它就是一个“自动发号器”你向它要一个不重复的整数用完再还回去。驱动里一般这样定义一个全局 IDAstatic 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_get和ida_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_0、mcp3008_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_drvdata把priv取回来。4. 组合实战RK3588 上从 0 到 1 的完整链路4.1 设备树里同时声明两个节点瑞芯微平台设备树中SPI 控制器下挂多个同型号从设备写法很直接。在设备树里给 SPI0 添加两个子节点spi0 { status okay; pinctrl-names default; pinctrl-0 spi0_cs0 spi0_cs1; adc0: mcp30080 { compatible mcp3008; reg 0; spi-max-frequency 1000000; }; adc1: mcp30081 { 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 就会失败。对应的 removestatic 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.kodmesg 里会出现两条类似这样的 logmcp3008 spi0.0: mcp3008 probed, minor0 mcp3008 spi0.1: mcp3008 probed, minor1/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模块卸载后重新 insmodIDA 继续从 0 开始分配0 被占用后第二次 probe 分配到了 0 和一个新 ID而cdev_add用的MKDEV设备号跟上一个生命周期里的设备号相同内核认为这个设备号已经被注册了。这个问题在“卸载重载模块”时非常隐蔽因为 dmesg 不一定会直接提示是哪个设备号冲突。排查步骤是cat /proc/devices确认主设备号是否被重复注册在ida_alloc之后打印priv-minor确认分配到的 ID重点检查 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此时minor是int没问题可如果某些内核配置下dev_t的 minor 部分被宏定义成unsigned long打印和格式化时类型不匹配也会导致节点名异常。这类问题虽然少见但在多平台移植时值得留个心眼。如果让我给刚接触多设备驱动的人一个综合建议那就是一开始就按“私有结构体 container_of ida class 自动节点”这套方式来写不要用全局数组过渡因为过渡版本最终还是要推翻重写。刚开始可能觉得多绕了一圈但写过一次之后你会发现加第三颗芯片真的只需要在设备树里多写一个节点驱动代码一行都不用动。这种“数据跟着实例走”的思路才是 Linux 驱动开发的真正底色。

相关新闻