资讯详情

资讯详情

Linux多设备驱动设计:私有数据隔离与动态设备号实践

最近在折腾瑞芯微 RK3568 的板子写驱动时被问得最多的一个问题就是我的驱动要同时支持两个同类型外设怎么搞比如两个 CAN 控制器、两路串口、两个同型号 ADC甚至板子上挂了两个一样的触摸屏。很多朋友的第一反应是用一个全局结构体把寄存器地址、中断号、缓冲区统统存起来设备一多就乱套操作 A 设备的时候 B 设备跟着“响应”。这个问题在瑞芯微平台特别典型因为 SoC 内部集成了大量重复的 IP设备树里好几个节点 compatible 完全相同同一个驱动会被 probe 多次状态却必须互不干扰。这篇文章就分享两个我在瑞芯微方案上踩过不少坑之后沉淀下来的小技巧一是用设备私有数据把每个实例彻底隔离二是用动态设备号给每个实例独立创建设备节点。所有代码我都在 RK3568 上编译运行过内核版本用的 5.10不过思路完全通用内核 4.19 一样能用。1. 多设备支持的本质一个驱动如何面对多个“自己”1.1 瑞芯微平台为什么常见“一个驱动管多个设备”瑞芯微 RK3568/RK3588 这类 SoC 里同一个 IP 经常会有多份。举个具体的例子RK3568 原生带两路 CAN-FD、三路 I2S、四路 UART、多路 SPI/I2C这还只是裸芯片上的硬核。到了板级厂商为了灵活性往往还会把同一型号的片子多挂几颗比如两个来自同一厂商的音频 codec、两片触摸屏、四个 USB-UART 桥接芯片。你去看 dts很可能发现好几个节点的 compatible 字符串完全一样只是 reg、interrupts 不同。Linux 的设备模型决定了这种情况会怎么工作。设备树里的每个节点对应一个 struct device驱动模块本体只加载一份但 driver 核心会对每个匹配到的节点调用一次 probe这就形成“一个驱动多个实例”的局面。关键问题不在于 probe 能不能被调用多次而在于每次 probe 之间的状态怎么隔离开。比较容易踩坑的地方在于刚入门的时候写驱动我们习惯用模块级全局变量什么 g_base、g_irq、g_buffer看起来简单一到多设备就原形毕露。两个设备一先一后 probe后面 probe 的设备会把前面设备的寄存器映射地址覆盖掉之后中断进来处理函数拿着全局地址去访问寄存器访问的可能完全是另一个设备的地址空间轻则功能错乱重则内核直接 oops。1.2 多设备场景下的核心矛盾状态从“全局”走向“每设备”把视角拉高一点驱动支持多个设备的本质是把“全局唯一”的状态模型切换成“每设备独立”的状态模型。单设备驱动里你可以心安理得地写这样的代码static void __iomem *g_base; static int g_irq; static ssize_t my_read(struct file *file, char __user *buf, size_t count, loff_t *pos) { int val ioread32(g_base REG_STATUS); /* ... */ return count; }在只有一个设备时这是最快最省事的写法。但出现第二个设备后代码里所有 g_base 的读写点都需要变成“从当前文件或当前中断上下文里拿到属于我自己的 base”。这不是改一两个宏的问题而是代码组织方式的问题你需要一个能贯穿 probe、open、read、write、ioctl、release、中断处理这些上下文的“纽带”带着属于当前实例的那份状态到处走。这个“纽带”内核里早就准备好了就是 struct device 的 driver_data 指针和它的一整套辅助接口。搞懂这个多设备驱动就成功了一半。2. 小技巧一用设备私有数据把每个实例彻底隔离2.1 内核现有的基础设施driver_data 指针与相关 APILinux 内核中每个 struct device 里都有一个 void *driver_data 字段专门用来存驱动为这个设备准备的私有数据。围绕它内核提供了一组封装叫 dev_set_drvdata/dev_get_drvdata针对 platform 设备还有 platform_set_drvdata/platform_get_drvdata底层是同一个机制static inline void platform_set_drvdata(struct platform_device *pdev, void *data) { dev_set_drvdata(pdev-dev, data); } static inline void *platform_get_drvdata(struct platform_device *pdev) { return dev_get_drvdata(pdev-dev); }它的作用就像一个文件柜每个设备都有自己独立的小格子probe 的时候你把对应的上下文指针放进去以后无论哪个阶段只要手头有 pdev 或 dev就能拿出自己设备专属的那份数据。我用“每个实例一套独立的账本”来类比一家公司在多个城市开办事处各办事处的账本必须在各办事处手里而不是统一堆在总部一张表上。2.2 设计一个 per-device 上下文结构体多设备驱动第一步就是定义好每个设备实例的上下文。以下是我在瑞芯微平台上的常用写法。以一个虚拟设备为例它有一块寄存器内存区和一个中断驱动最终要给它创建 /dev/mydev0、/dev/mydev1 这样的节点struct my_device { struct cdev cdev; /* 嵌套字符设备对象 */ struct device *dev; /* class device用于删除节点 */ void __iomem *base; /* 本实例的寄存器基地址 */ int irq; /* 本实例的中断号 */ int id; /* 实例编号通常来自 alias */ int minor; /* 次设备号 */ struct mutex lock; /* 设备锁防止并发访问 */ /* 其他业务相关的状态都可以放这里 */ };注意我特意把 struct cdev 嵌进结构体而不是用指针单独 kzalloc。这样做的原因后面会看到在 file_operations 的 open 回调里我们手头只有 inode而 inode-i_cdev 正好指向设备对应的 cdev用 container_of 就能从这个 cdev 反推出整个 my_device 结构体非常自然也不会多一次寻址。2.3 probe 里完成“建档立卡”probe 函数的任务就是分配结构体、填充硬件资源、把结构体绑定到 pdev-dev.driver_data最后创建字符设备。核心片段如下static int my_probe(struct platform_device *pdev) { struct my_device *my_dev; struct resource *res; int ret; my_dev devm_kzalloc(pdev-dev, sizeof(*my_dev), GFP_KERNEL); if (!my_dev) return -ENOMEM; mutex_init(my_dev-lock); res platform_get_resource(pdev, IORESOURCE_MEM, 0); my_dev-base devm_ioremap_resource(pdev-dev, res); if (IS_ERR(my_dev-base)) return PTR_ERR(my_dev-base); my_dev-irq platform_get_irq(pdev, 0); if (my_dev-irq 0) return my_dev-irq; /* 用 alias 或自增序列给实例编号 */ my_dev-id of_alias_get_id(pdev-dev.of_node, mydev); if (my_dev-id 0) my_dev-id atomic_inc_return(my_dev_count) - 1; platform_set_drvdata(pdev, my_dev); ret devm_request_irq(pdev-dev, my_dev-irq, my_irq_handler, IRQF_TRIGGER_RISING, dev_name(pdev-dev), my_dev); if (ret) return ret; ret my_create_cdev(my_dev); if (ret) return ret; dev_info(pdev-dev, mydev%d at 0x%llx irq %d\n, my_dev-id, (unsigned long long)res-start, my_dev-irq); return 0; }几个细节解释一下。第一devm_kzalloc 是设备资源管理 API 的典型代表意思是“内存的生命周期跟着设备走”设备从系统里移除时由内核自动释放不需要在 remove 里手动 kfree更不用为每个错误分支写一堆 goto err_free。第二platform_get_resource devm_ioremap_resource 组合依然走 devm 路线内存映射也自动释放漏掉 iounmap 这种经典内存泄漏问题被框架兜底了。第三devm_request_irq 同理中断在设备拆除时自动释放只要你在 remove 里不再手动 free_irq 就不会有双放问题。2.4 open/read/ioctl 与中断如何拿到正确实例probe 只是第一步真正让多设备正确的是 file_operations 的各个回调都能拿到“当前是哪台设备”。open 里做法如下static int my_open(struct inode *inode, struct file *file) { struct my_device *my_dev container_of(inode-i_cdev, struct my_device, cdev); file-private_data my_dev; return 0; }之后 read/write/ioctl/release 都从 file-private_data 取值就行static ssize_t my_read(struct file *file, char __user *buf, size_t count, loff_t *pos) { struct my_device *my_dev file-private_data; u32 val; mutex_lock(my_dev-lock); val ioread32(my_dev-base MY_STATUS_REG); mutex_unlock(my_dev-lock); /* 实际读取逻辑 */ return count; }这里为什么不用全局查找一句话每个文件打开时内核通过 inode 反查 cdev而我们提前把 cdev 嵌在 my_device 里container_of 一步就能拿到实例后续所有系统调用都通过 file-private_data 携带这个指针整个生命周期里都不会存在“我在处理哪个设备”的歧义。中断处理稍微有一点不同因为中断处理函数里没有 file 也没有 inode它拿到的 data 参数来自 request_irq 的第五个参数。我们刚才传的是 my_dev所以处理函数这样写就能拿到本实例static irqreturn_t my_irq_handler(int irq, void *data) { struct my_device *my_dev data; u32 status; status ioread32(my_dev-base MY_IRQ_STATUS); if (!(status MY_IRQ_PENDING)) return IRQ_NONE; /* 共享中断时必须判断 */ iowrite32(status, my_dev-base MY_IRQ_STATUS); /* 清中断 */ /* 业务处理 */ return IRQ_HANDLED; }注意共享中断里的回读判断。瑞芯微 SoC 内部很多中断控制器会把多个外设中断源合并到同一个中断线两个同类型设备完全可能在一条线上。如果中断处理函数不检查硬件状态寄存器就一律返回 IRQ_HANDLED不仅会吞掉另一个设备的中断还会让系统日志刷屏“nobody cared”。我已经在共享中断场景里吃过这种亏现在的习惯是进入处理函数先读状态不是我的 pending 就老老实实返回 IRQ_NONE。2.5 devm_ 资源管理的时序优势这里单独提一下 devm_ 的释放顺序。内核里 devres 是一份按申请顺序排列的资源链表设备移除时按逆序释放也就是说你最后申请的资源最先释放。在我上面那段 probe 代码里资源申请顺序是先 devm_kzalloc 分配结构体再 devm_ioremap_resource 做寄存器映射最后 devm_request_irq 注册中断。所以设备卸载时内核会先 free_irq再 iounmap最后释放结构体内存。这个顺序非常关键因为中断处理函数里会访问 my_dev-base如果先释放结构体再关中断中断回调就可能踩到已经释放的内核内存崩溃概率极高。用 devm_ 之后顺序由框架保证remove 回调解耦得非常干净static int my_remove(struct platform_device *pdev) { struct my_device *my_dev platform_get_drvdata(pdev); my_delete_cdev(my_dev); /* 设备节点、cdev 仍然需要手动清理 */ return 0; }这里唯一不能省的是 cdev 和设备节点的删除因为 cdev、class device 不是标准 devm 资源。后面技巧二里我会给出配套的完整写法。3. 小技巧二动态设备号与每个实例独立节点3.1 固定主设备号在多设备场景的问题很多字符设备驱动教学代码喜欢这样定义一个静态的宏 MY_MAJOR 192然后在模块加载时 register_chrdev_region 去抢这个主设备号。单设备实验没问题多设备场景马上暴露问题。首先192 这种魔数主设备号很容易和其他模块冲突而且内核官方文档里已经明确鼓励使用动态分配主设备号。其次如果硬编码了固定的起始设备号两个相同驱动实例同时出现时需要的设备号段规格就非常不灵活。更重要的一个点在多设备驱动里我们希望每个实例是系统的“一等公民”。什么叫一等公民就是有自己的 /dev 节点有自己的次设备号用户态可以独立 open、独立 close互不干扰。比如两个同型号 USB-UART 桥接芯片在 Linux 下会变成 /dev/ttyUSB0 和 /dev/ttyUSB1背后就是驱动为两个物理设备分别分配了不同的次设备号。瑞芯微平台上的多个 UART、多个 codec 也都是这个套路。3.2 alloc_chrdev_region ida 分配次设备号技巧二的核心是用 alloc_chrdev_region 动态申请一段设备号区间再配合 ida 机制为每个设备实例分配一个独立的次设备号。ida 是内核提供的 ID 分配器专门用来高效地分配和释放不重复的小整数天然适合“给每个设备一个编号”的场景。模块初始化函数里这样写#define MY_MAX_MINORS 16 /* 实例数量上限 */ static dev_t my_devno_base; static struct class *my_class; static DEFINE_IDA(my_minor_ida); static int __init my_module_init(void) { int ret; ret alloc_chrdev_region(my_devno_base, 0, MY_MAX_MINORS, mydev); if (ret) return ret; my_class class_create(THIS_MODULE, mydev); if (IS_ERR(my_class)) { unregister_chrdev_region(my_devno_base, MY_MAX_MINORS); return PTR_ERR(my_class); } return 0; }这里 alloc_chrdev_region 的第一个参数是出参内核自动挑一个还没有被占用的主设备号从次设备号 0 开始给我们 MY_MAX_MINORS 个设备号第四个参数是驱动名它会出现在 /proc/devices 里。比起写死主设备号这种方式在目标板上基本不会有冲突。3.3 cdev 与 device_create 挂接/dev 节点自动生成每个实例的次设备号在 probe 阶段使用 ida 申请。现代内核推荐 ida_alloc/ida_free 而不是老式的 ida_simple_get/ida_simple_remove语义更清晰。接着把次设备号、cdev、class device 三者绑起来static int my_create_cdev(struct my_device *my_dev) { int ret, minor; minor ida_alloc(my_minor_ida, GFP_KERNEL); if (minor 0) return minor; my_dev-minor minor; cdev_init(my_dev-cdev, my_fops); my_dev-cdev.owner THIS_MODULE; ret cdev_add(my_dev-cdev, MKDEV(MAJOR(my_devno_base), minor), 1); if (ret) { ida_free(my_minor_ida, minor); return ret; } my_dev-dev device_create(my_class, my_platform_dev-dev, MKDEV(MAJOR(my_devno_base), minor), my_dev, mydev%d, my_dev-id); if (IS_ERR(my_dev-dev)) { ret PTR_ERR(my_dev-dev); cdev_del(my_dev-cdev); ida_free(my_minor_ida, minor); return ret; } return 0; }device_create 那一行是关键它会在 /sys/class/mydev/ 下面创建 mydevX 入口同时把设备号信息告诉 udevudev 在收到内核 uevent 之后自动在 /dev 下生成对应节点。你的用户态程序从此再也不用手动 mknod。我在瑞芯微的实际项目里曾经碰到过一种情况板子没有 udev或者用的是精简版 buildroot/dev 节点要由 mdev 或者 init 脚本扫描创建。这时候就算驱动注册成功/dev 下也看不到节点。解决办法是先查 /sys/class/mydev/mydev0/dev 这个文件里的“主设备号:次设备号”然后再补 mknod。如果你发现 /sys/class 下有自己的设备目录但 /dev 没有节点第一反应就应该是“用户态自动创建设备节点的机制没起来”而不是驱动写错了。remove 里配套的清理动作是static void my_delete_cdev(struct my_device *my_dev) { device_destroy(my_class, MKDEV(MAJOR(my_devno_base), my_dev-minor)); cdev_del(my_dev-cdev); ida_free(my_minor_ida, my_dev-minor); }模块退出时再把设备号区间和 class 释放static void __exit my_module_exit(void) { class_destroy(my_class); unregister_chrdev_region(my_devno_base, MY_MAX_MINORS); }3.4 在设备树上用 alias 信息固定实例编号前面 ida 分配的次设备号是动态的它只保证“不重复”但同一个硬件插槽每次上电拿到的编号可能变化。对某些用户态脚本来说更希望固定下来slot0 永远是 mydev0slot1 永远是 mydev1。这时可以用设备树 alias。在 dts 的 aliases 节点里定义aliases { mydev0 mydev_0; mydev1 mydev_1; };然后在 probe 里利用 of_alias_get_id 拿到固定编号my_dev-id of_alias_get_id(pdev-dev.of_node, mydev); if (my_dev-id 0) my_dev-id atomic_inc_return(my_dev_count) - 1;这样即使两个设备注册顺序有变化节点的名字也会按 alias 固定下来。瑞芯微 SDK 里给串口、网口等设备分配 ttyS0/eth0 这类名字底层思路就是这套。4. RK3568 实测两个同类型外设完整跑通4.1 设备树怎么写我在 RK3568 公板上做过一个验证两个完全相同的 GPIO 控制外设通过片选接入同一个 SPI 控制器dts 里两个子节点 compatible 相同。简单化的设备树是这样/ { compatible rockchip,rk3568; aliases { mydev0 mydev_0; mydev1 mydev_1; }; mydev_0: mydevff2a0000 { compatible vendor,my-device; reg 0x0 0xff2a0000 0x0 0x1000; interrupts GIC_SPI 64 IRQ_TYPE_LEVEL_HIGH; status okay; }; mydev_1: mydevff2a1000 { compatible vendor,my-device; reg 0x0 0xff2a1000 0x0 0x1000; interrupts GIC_SPI 65 IRQ_TYPE_LEVEL_HIGH; status okay; }; };of_match_table 在驱动里写成static const struct of_device_id my_of_match[] { { .compatible vendor,my-device }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, my_of_match);需要注意两个节点的 reg 地址不能重叠中断号必须真实对应到 SoC 内部不同的中断源否则第二个实例的 ioremap 或 request_irq 会失败。4.2 模块加载与节点验证驱动编译成 .ko 后从串口终端登到板子上insmod 之后会看到两段独立的 dev_info 打印说明两个实例都 probe 成功。接着验证# 查看设备号分配情况 cat /proc/devices | grep mydev mydev 240 # 查看次设备号与节点 ls -l /dev/mydev* crw------- 1 root root 240, 0 Jan 1 00:00 /dev/mydev0 crw------- 1 root root 240, 1 Jan 1 00:00 /dev/mydev1 # 查看 sysfs 下的设备属性 cat /sys/class/mydev/mydev0/dev 240:0两个节点、两个次设备号互不干扰。我再写一个简单的用户态程序分别 open 两个节点一个读 status 寄存器一个写控制寄存器程序跑起来后从逻辑分析仪看波形两个设备的行为完全独立。有时候两个外设中断共享同一条中断线。验证方法是在 dts 里给两个节点配同一个中断号驱动代码里给 request_irq 加上 IRQF_SHARED 标志然后分别触发两个设备的中断日志里应该出现两条独立的 dev_info处理函数也通过 data 参数正确区分了来源。4.3 用户态测试程序视角用户态不用关心驱动内部如何区分关键在于 open 两个 fd 时内核传给驱动的是一个又一个 inode/file 实例。驱动侧通过 cdev 反查和 private_data 把两个 fd 引向两个不同的 my_device于是两个应用程序线程可以安全并发地操作各自的外设。这里有个经典坑如果我在 read/write 里用了全局缓冲区用户态两个 fd 就会打架。解决办法是任何临时缓冲都放在 stack 或 kmalloc 的动态内存里真正要跨系统调用保存业务状态时才放到 my_device 结构体中并用 mutex 保护。实测在并发读写下这种设计不会丢数据也不会错位。5. 常见问题与排查技巧别让多设备变成多麻烦5.1 问题速查表我把多设备驱动调试时最常遇到的坑整理成一张表这些问题我在瑞芯微平台上都碰到过至少一次现象常见原因排查方向设备树有多个节点但 probe 只跑一次第二个节点 status 为 disabled或 compatible 写错查看 /sys/firmware/devicetree/base 下的节点状态/dev 下没有节点udev/mdev 没工作看 /sys/class/ / /dev 是否存在两个设备数据互相串用了全局变量/全局缓冲区全文件搜索模块级 static 变量换成 per-device 结构体中断处理函数频繁报 IRQ 混乱共享中断没判断 pending 位进入 handler 先读状态寄存器不属于自己的返回 IRQ_NONErmmod 时提示 busy用户态 fd 仍打开或 cdev 未删除lsof /dev/mydev0 找出占用进程检查 remove 是否调了 cdev_del设备节点名字重复导致 device_create 失败次设备号分配冲突检查 ida_alloc/ida_free 是否成对ida 在模块初始化是否正确复位probe 第二次返回 -EBUSY寄存器映射或中断号被第一个实例独占确认两个节点的 reg/irq 在硬件上确实不同5.2 排错时我习惯先做的三件事第一打开内核动态调试聚焦 probe 和 remove 的返回值。在驱动代码里多用 dev_info(pdev-dev, ...) 而不是 printk因为 dev_xxx 会自动带上设备名多实例日志里一眼就能看出是哪个设备在报错。比如dev_info(pdev-dev, probe mydev%d at irq %d\n, my_dev-id, my_dev-irq);第二利用 debugfs 把每个实例的关键状态导出来。我在 my_device 里加一个 debugfs dentry文件内容就打印 base 地址、irq、当前配置寄存器值定位“为什么两个设备表现不一致”会快很多。多实例场景下你不可能每次都靠猜来判断是哪个设备的状态不对debugfs 是最直接的证据来源。第三检查 dmesg 时按设备名过滤而不是直接 grep 驱动名。两个实例的日志交错在一起容易看花眼用dmesg | grep mydev0单独看第一个设备的行为比通读全部日志有效得多。5.3 关于“两个技巧”背后的一点想法这两个技巧其实可以浓缩成一句话多设备驱动写得好不好取决于你愿不愿意为每个实例维护独立上下文并在每个可能进入的上下文里都能定位到这个实例。第一个技巧解决“在内存里怎么组织状态”第二个技巧解决“在用户态怎么区分实例”。两者配合才是一个完整的多设备驱动。这套方法不只是给瑞芯微用的任何嵌入式 Linux 平台、任何类型的多实例外设都适用。我后来在 RISC-V 板子和 x86 工控机上迁移同样的代码除了设备树写法略有差异驱动的核心骨架完全没动。所以与其到处找所谓“平台专用写法”不如先把这两个通用技巧吃透后面换平台只是换皮不换里。5.4 一段实测中的小插曲最后说说我这次在 RK3568 上验证时遇到的一个小问题。设备树里两个节点都配了同一个 GPIO 中断号驱动里也加了 IRQF_SHARED但测试时发现触发第一个设备的中断后第二个设备的中断处理也被频繁调用日志被刷屏。排查之后发现原因不在驱动而在 GPIO 子系统的中断映射。两个节点虽然用的是同一个中断号但内部中断触发方式不同硬件上共享中断要求所有共享方在触发模式上保持一致。把 dts 里两个节点的 interrupts 属性改成同样的触发模式后问题消失。芯片手册里写着支持共享中断不代表所有中断源都适合做共享遇到异常先检查设备和控制器两端的中断配置。个人习惯上我会先把 IRQF_SHARED 去掉测试一遍如果两个设备都能独立工作再开启共享验证回调是否能正确区分。这样分两步走能避免把“硬件不支持共享”和“软件没写好”混在一起判断。调试多设备驱动最忌讳的就是一把抓现象、日志、代码、硬件配置一起猜很容易把自己绕晕。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →