1. 从“点灯驱动”到“多设备驱动”:瑞芯微平台上的分水岭
我最早在瑞芯微平台上写驱动的时候,其实是从最简单的GPIO点灯开始的。一个LED接在RK3568的某个引脚上,写一个platform_driver,probe里注册一下gpio,set_value翻转电平就完事了。代码写得飞快,觉得“Linux驱动不过如此”。
直到我接到一个正经需求:同一块板子上挂了4个同型号的I2C气压传感器,我写的驱动要同时管理这4个实例。紧接着又有个需求,一套驱动代码要兼容RK3568和RK3588两个平台上管脚定义不同的同款外设。那时候我才意识到,点灯的驱动和能上量的驱动,之间的分水岭就是对“多个设备”的支持能力。
这篇文章不聊那些教科书里翻来覆去讲的platform_driver框架,也不重复设备树基础语法,就聚焦在一件事上:当你要让一套Linux驱动代码支持多个设备实例、多个芯片型号时,真正决定成败的两个小技巧。全部基于瑞芯微RK3568/RK3588平台(其他平台的Linux内核原理通用,只有设备树写法稍有差异,思路完全一致)。
先交代一下背景,后面讲代码才有落点:
- 硬件平台:以瑞芯微RK3568为主,穿插RK3588示例
- 内核版本:Linux 5.10(瑞芯微官方BSP,SDK内附带)
- 场景1:同一I2C总线下挂4颗同型号传感器,一套驱动管4个实例
- 场景2:一套驱动代码同时适配RK3568和RK3588两个平台的同一芯片(引脚、寄存器地址不同)
如果你也在做瑞芯微相关的驱动开发,或者刚把设备树玩熟、想进阶正经的驱动架构,这篇文章适合你。
2. 核心思路:驱动要像“模板”而不是“单张图纸”
很多人写驱动时,脑子里默认只有“一张图纸”:驱动是图纸,设备树节点是材料清单,probe函数就是照着图纸造一个房子。这套思路在单设备场景完全没问题,但一旦接多设备,立马穿帮。
原因很简单:Linux驱动的probe函数是“每个匹配的设备节点都会执行一次”的。也就是说,挂4颗传感器,probe会跑4次,每次传入的struct device都不一样,对应的of_node也不一样。如果你在驱动里写死了全局变量存寄存器地址、存中断号、存设备名,第二次probe会把第一次的值覆盖掉。4个设备最终可能只有最后一个能正常工作,其他3个的资源全乱了。
正确的思路是:写驱动模板,不写设备实例。驱动只需要描述“这一类设备怎么操作”,具体是哪一个设备、它的寄存器地址在哪儿、中断引脚是哪个、型号差异的参数是什么,全部从设备树中获取,存入每个设备独立的私有数据区。
这套思路落实到具体代码上,有两个关键落点,也就是本文要说的两个技巧:
- 第一:让一套驱动支持多个“型号”,核心是用of_device_id里的driver_data做差异配置表。
- 第二:让一套驱动支持多个“实例”,核心是probe里动态分配私有数据、用ida管理次设备号,绝对不能依赖全局变量。
这两个技巧拆开看都不复杂,但合在一起,你的驱动就能从“demo级别”升级到“量产级别”。下面分别展开。
3. 技巧一:用driver_data做差异配置表,一套驱动兼容多型号
3.1 为什么需要差异配置表
场景是这样的:RK3568开发板上用的是芯片A,RK3588开发板上用的是芯片A的升级版A+。寄存器定义变了,reset引脚的GPIO号也变了,但操作逻辑总体一致:都是I2C写寄存器、读数据、处理中断。
最笨的办法是复制一份驱动,改改地址再编译。但这种做法过两周你自己都忘了哪个驱动对应哪块板子,维护成本直接爆炸。
稍微聪明一点的做法是在probe函数里用of_machine_is_compatible()判断平台:
static int foo_probe(struct i2c_client *client) { if (of_machine_is_compatible("rockchip,rk3568-ev-board")) chip_type = CHIP_A; else if (of_machine_is_compatible("rockchip,rk3588-ev-board")) chip_type = CHIP_A_PLUS; ... }说实话,这是能用,但很丑。每加一个平台就要改probe源码、加#if分支,而且驱动本身完全没有表述“自己支持什么”的能力,全靠运行时的环境判断。
推荐做法是让驱动自己通过设备树匹配表来表达支持范围,用driver_data字段指向一个差异配置结构体。
3.2 差异配置表的标准写法
设备树里,两个不同型号的芯片的匹配节点是这样的:
&i2c0 { status = "okay"; pressure_a: pressure@48 { compatible = "vendor,barometer-a"; reg = <0x48>; interrupt-parent = <&gpio3>; interrupts = <RK_PB2 IRQ_TYPE_LEVEL_LOW>; reset-gpios = <&gpio3 RK_PB3 GPIO_ACTIVE_LOW>; }; }; &i2c1 { status = "okay"; pressure_a_plus: pressure@49 { compatible = "vendor,barometer-a-plus"; reg = <0x49>; interrupt-parent = <&gpio4>; interrupts = <RK_PC2 IRQ_TYPE_LEVEL_LOW>; reset-gpios = <&gpio4 RK_PC3 GPIO_ACTIVE_LOW>; }; };注意compatible是不同的:一个是vendor,barometer-a,一个是vendor,barometer-a-plus。但驱动里,这两个compatible都指向同一个of_device_id数组。关键就在driver_data字段。
驱动里定义一个“芯片差异配置”结构体:
struct barometer_chip_config { const char *model_name; u32 status_reg; /* 状态寄存器地址 */ u32 data_reg; /* 数据寄存器地址 */ u32 reset_bits; /* reset寄存器位 */ int (*init)(struct i2c_client *client); /* 不同芯片的初始化函数 */ }; /* A型芯片的配置 */ static int barometer_a_init(struct i2c_client *client) { /* A型芯片特有的初始化流程 */ return 0; } static const struct barometer_chip_config barometer_a_cfg = { .model_name = "barometer-a", .status_reg = 0x00, .data_reg = 0x04, .reset_bits = 0x01, .init = barometer_a_init, }; /* A+型芯片的配置 */ static int barometer_a_plus_init(struct i2c_client *client) { /* A+型芯片初始化流程,可能多了个软复位步骤 */ return 0; } static const struct barometer_chip_config barometer_a_plus_cfg = { .model_name = "barometer-a-plus", .status_reg = 0x10, .data_reg = 0x14, .reset_bits = 0x80, .init = barometer_a_plus_init, };然后设备匹配表这样写:
static const struct of_device_id barometer_of_match[] = { { .compatible = "vendor,barometer-a", .data = &barometer_a_cfg, }, { .compatible = "vendor,barometer-a-plus", .data = &barometer_a_plus_cfg, }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, barometer_of_match);老资格的i2c驱动写法里,i2c_device_id也可以做类似的事。但在设备树大行其道的今天,我建议以of_match为主,i2c_device_id只留一个占位符防止模块加载时匹配不到(瑞芯微BSP里外设基本都是设备树节点,其实用不太上i2c_device_id了,但保留有好处)。
3.3 在probe里怎么取到配置
standard流程,probe函数里第一件事就是把driver_data拿出来:
static int barometer_probe(struct i2c_client *client) { const struct barometer_chip_config *cfg; struct barometer_dev *baro; int ret; /* 从of_match中拿到匹配项的driver_data */ cfg = device_get_match_data(&client->dev); if (!cfg) { dev_err(&client->dev, "no matching chip config\n"); return -ENODEV; } baro = devm_kzalloc(&client->dev, sizeof(*baro), GFP_KERNEL); if (!baro) return -ENOMEM; baro->client = client; baro->cfg = cfg; i2c_set_clientdata(client, baro); /* 调用配置里对应的初始化函数 */ if (cfg->init) { ret = cfg->init(client); if (ret) return ret; } dev_info(&client->dev, "%s probed, status_reg=0x%02x\n", cfg->model_name, cfg->status_reg); return 0; }device_get_match_data是一个通用API,它内部会根据设备是OF还是ACPI来分别从of_match_table或ACPI表中取data,具体到我们的场景就是of_device_id里的.data。这个API在内核4.x之后就很稳定,直接用。
经过这一通操作,你的驱动里再也不用出现“if (是某平台) ... else ...”。所有平台差异都沉淀成数据表,新增一个芯片型号只需要加一个配置结构体,加一行of_device_id,甚至可以做成单独的文件来维护。代码是“声明式”的,不是“过程式”的,方案就成功了。
3.4 这个技巧的价值在哪里
在瑞芯微平台上,这个套路尤其实用,原因有两个:
一是瑞芯微芯片型号非常多,RK3568、RK3566、RK3588、RK3528、RV1126等等,而且很多外设IP是沿用同一套的。比如同一个PHY芯片、同一颗PMIC、同一颗codec,往往RK3568板卡和RK3588板卡用的方案完全相同,但GPIO接法、复位脚、I2C地址可能有差异。用driver_data差异配置表,一套驱动吃遍所有平台。
二是BSP升级或平台迁移时,你不用再改动驱动逻辑代码。驱动逻辑只依赖“配置字段”和“函数指针”,只要新平台的差异能体现在配置里,驱动一行不改就能编译出来。你只需要重新确认设备树节点里的compatible是否正确指向了对应的配置项。
注意:
of_device_id中的.compatible必须和设备树节点里的保持一致,而且要注意大小写和厂家前缀。瑞芯微的BSP里,经常有compatible带了rockchip,前缀的设备,如果你写的驱动是独立的,前缀可以自己定义,只要两边对应即可。
4. 技巧二:一套probe管理多个实例,必须动态分配一切
4.1 多实例场景的“坑”到底在哪
回到最开头那个需求:RK3568板子上挂了4颗同型号的I2C传感器。设备树里需要创建四个节点,compatible都一样,只是reg地址不同:
&i2c3 { status = "okay"; clock-frequency = <400000>; sensor@48 { compatible = "vendor,pressure-sensor"; reg = <0x48>; }; sensor@4a { compatible = "vendor,pressure-sensor"; reg = <0x4a>; }; sensor@4c { compatible = "vendor,pressure-sensor"; reg = <0x4c>; }; sensor@4e { compatible = "vendor,pressure-sensor"; reg = <0x4e>; }; };这样写,内核在解析i2c3节点时,会对每个子节点调用一次driver的probe。4个设备,probe执行4次。
新手最常见的错误就是:
static int sensor_probe(struct i2c_client *client) { static struct sensor_dev *sdev; /* 静态变量保存设备实例 */ ... }你以为静态变量能“记住”第一个设备,实际上第二次probe时,静态变量被覆盖,第一个设备的内存地址丢了。更糟的是如果你在静态变量里存了i2c_client指针,第二次probe后,所有中断回调、sysfs操作都在操作同一个i2c_client,3个设备全乱套。
这个问题的根本原因在于:你把“一个设备”的私有状态,写成了“驱动全局”的状态。正确做法是每个probe都重新分配独立的私有数据,并且所有后续回调都要能通过传入的参数找到“我这一个设备”的私有数据,而不是去全局变量里找。
4.2 多实例驱动的标准骨架
一个规范的设备驱动,probe的回调里必须做这几件事:
static int sensor_probe(struct i2c_client *client) { struct sensor_dev *sdev; int ret; /* 1. 动态分配当前实例的私有数据 */ sdev = devm_kzalloc(&client->dev, sizeof(*sdev), GFP_KERNEL); if (!sdev) return -ENOMEM; sdev->client = client; i2c_set_clientdata(client, sdev); /* 2. 从设备树获取当前实例的差异信息 */ sdev->irq = client->irq; ret = device_property_read_u32(&client->dev, "offset", &sdev->offset); /* 没有也没关系,用默认值 */ if (ret) sdev->offset = 0; /* 3. 注册中断,注意传递的是sdev而非client */ if (sdev->irq) { ret = devm_request_threaded_irq(&client->dev, sdev->irq, NULL, sensor_irq_handler, IRQF_TRIGGER_LOW | IRQF_ONESHOT, "sensor-irq", sdev); if (ret) return ret; } /* 4. 创建字符设备/misc设备/sysfs接口 */ ret = sensor_create_dev_node(sdev); if (ret) return ret; dev_info(&client->dev, "sensor probed, irq=%d, offset=%d\n", sdev->irq, sdev->offset); return 0; }关键点:
devm_kzalloc是设备资源管理版本的内存分配,好处是只要设备unbind、probe失败返回错误,内存自动释放。你不需要在remove函数里手动kfree,也不用担心probe中途失败导致内存泄漏。瑞芯微BSP的内核版本较新,devm体系已经非常成熟,能用的都用上。i2c_set_clientdata把私有数据挂在i2c_client上,等中断回调或sysfs回调里头用i2c_get_clientdata取回来。注意,中断回调的参数里我们传的是sdev,不是client,这样直接在中断里就能访问私有数据。
中断回调里要注意,不要直接在中断上下文里读书写寄存器,耗时且危险:
static irqreturn_t sensor_irq_handler(int irq, void *dev_id) { struct sensor_dev *sdev = dev_id; /* 正确做法:只做标记,把数据读取放到线程化中断或工作队列 */ schedule_work(&sdev->work); return IRQ_HANDLED; }如果你用了devm_request_threaded_irq的threaded版本,第二个参数设为NULL时,内核会自动创建线程化中断,你在第6个参数(irq_handler_flags)里加IRQF_ONESHOT就行,线程里可以做I2C操作。
4.3 一个更复杂的场景:同名设备节点只能有一个
说到多实例,还有个瑞芯微平台上尤其常见的问题:有些硬件IP在SoC内部是“唯一”的,比如RK3588的Video Capture Unit(VPU)、RGA等,设备树里只有一个节点,但软件上要支持同时服务多个应用层实例(比如多路视频流)。这时候驱动层面怎么管理多实例?
方法就多样化了,核心是“内核对象用引用计数 + 文件open/release对应实例”:
// 用ida管理次设备号,保证多个实例不冲突 static int sensor_create_dev_node(struct sensor_dev *sdev) { int minor; /* 动态分配次设备号 */ minor = ida_alloc(&sensor_ida, GFP_KERNEL); if (minor < 0) return minor; sdev->minor = minor; sdev->dev = device_create(sensor_class, &sdev->client->dev, MKDEV(sensor_major, minor), sdev, "sensor%d", minor); if (IS_ERR(sdev->dev)) { ida_free(&sensor_ida, minor); return PTR_ERR(sdev->dev); } return 0; }注意,这里device_create的第4个参数(drvdata)传的是sdev,这样应用层open这个字符设备后,struct file里的private_data可以由你在open里设置:
static int sensor_open(struct inode *inode, struct file *filp) { struct sensor_dev *sdev = container_of(inode->i_cdev, struct sensor_dev, cdev); filp->private_data = sdev; return 0; }这样的好处是,即使应用层同时打开4个设备节点,每个fd都对应独立的sdev,互不干扰。
4.4 千万别用全局变量,如果真的要用,加锁
说句实话,有些驱动(包括我用过的一些瑞芯微SDK里的老驱动)还是会用全局变量,比如一个全局的struct xxx_dev *g_dev。这在单设备场景确实是捷径,probe里初始化一下,中断和ioctl里直接访问,代码短,看着清爽。
但我必须强烈建议你改掉这个习惯,原因不只有多实例问题,还有:
- 热插拔和rebind。I2C外设可能因为接触不良被内核卸载又重挂,如果新的probe覆盖了全局指针,老的指针就悬空了。
- 并发访问。两个进程同时调用read/write,一个全局的缓冲区会被两个进程互相踩踏。你用锁能防住,但每个实例的锁被全局共用,粒度太粗,性能和正确性都有隐患。
- 模块卸载再加载。如果全局变量里有残留状态,第二次加载模块时可能带着上次的错误状态启动。
真遇到无法避免的共享状态(比如同一颗寄存器的硬件锁),正确的姿势是:用devm_kzalloc分配在驱动私有结构体里,但不放进probe的私有数据,而是放入“共享的实例0”的私有数据,其他实例通过全局指针访问这个实例0,且整个访问全程用mutex保护区。全局指针本身只是一个“定位器”,不能存放任何真正需要随实例变化的数据。
提示:调试时可以用
/sys/bus/i2c/devices/下面的目录来确认设备是否被正确绑定。比如i2c-3/3-0048/目录下如果有driver符号链接指向你的驱动,说明设备树匹配成功。四个设备会有四个目录,各自绑定同一个驱动名,这就是多实例成功的标志。
5. 实操过程:RK3568上完整实现“一驱多”的步骤拆解
5.1 从零到一,完整流程清单
我按实际项目的推进顺序,把从设备树到应用层验证的全流程梳理一下。假设你已经有一套瑞芯微SDK,内核可以正常编译,设备树可以正常编译并使用(如果这第一步还没搞定,建议先从最简单的GPIO点灯开始,熟练再跳过来)。
第一步:定义设备树节点
确定外设挂在哪个I2C总线上。RK3568有I2C0-I2C5共6个I2C控制器,具体选哪个看你的原理图。设备树里给每个同型号设备各建一个子节点,compatible一致,reg地址不同:
&i2c2 { status = "okay"; pinctrl-names = "default"; pinctrl-0 = <&i2c2m0_xfer>; sensor0: pressure@48 { compatible = "vendor,pressure-sensor"; reg = <0x48>; }; sensor1: pressure@4a { compatible = "vendor,pressure-sensor"; reg = <0x4a>; }; };第二步:在内核menuconfig里把驱动编成模块
在驱动所在目录的Kconfig里确认一下:
config SENSOR_PRESSURE tristate "Vendor pressure sensor driver" depends on I2C help Driver for vendor pressure sensor.编译成模块的好处是调试时不用整机重启,insmod/rmmod就能热插拔重载,开发效率高很多。编成y虽然省事,但每次改驱动都要重新烧boot.img,太磨人了。瑞芯微平台的内核模块加载路径一般没问题,RK3568的rootfs里insmod和rmmod都好使。
第三步:驱动结构搭建
整个驱动的骨架文件建议拆成:
sensor_core.c:probe/remove、设备树匹配、模块入口sensor_chip_a.c:A型号芯片的寄存器操作、初始化逻辑sensor_chip_b.c:B型号芯片的寄存器操作、初始化逻辑sensor_ops.c:file_operations实现(open/read/ioctl)sensor.h:公共头文件
不过大多数驱动项目不会拆那么细,看团队习惯,我个人的建议是:如果代码总量在2000行以内,单文件即可,超过2000行拆文件会舒服一些。
第四步:实现file_operations
应用层通常需要从设备节点读取压力值,或者配置采样率。我一般用miscdevice来创建设备节点,因为它自动分配次设备号,不用手动申请:
static const struct file_operations sensor_fops = { .owner = THIS_MODULE, .open = sensor_open, .release = sensor_release, .read = sensor_read, .unlocked_ioctl = sensor_ioctl, #ifdef CONFIG_COMPAT .compat_ioctl = sensor_compat_ioctl, #endif }; static struct miscdevice sensor_miscdev = { .minor = MISC_DYNAMIC_MINOR, .name = "pressure_sensor", .fops = &sensor_fops, };注意多个实例共用一个miscdevice可以吗?可以,但每个实例open后要通过private_data区分。如果你在open里用container_of从inode里找到sdev,那这个通用的fops和miscdevice是可以在所有实例间共用的。miscdevice本身只是“类模板”,每个实例的设备节点名如果都是/dev/pressure_sensor,应用层就分不清谁是谁了。我习惯在probe里动态给设备节点加序号:
devname = devm_kasprintf(&client->dev, GFP_KERNEL, "pressure_sensor%d", sdev->id); sdev->miscdev.name = devname; sdev->miscdev.minor = MISC_DYNAMIC_MINOR; sdev->miscdev.fops = &sensor_fops;这样4个设备节点是/dev/pressure_sensor0到/dev/pressure_sensor3,一一对应。
5.2 ida动态分配实例编号
上面的id就是从ida里取的。用ida的好处是它能自动分配不重复的小整数,释放后还能复用,比你自己搞一个静态数组然后遍历找空位要可靠得多:
static DEFINE_IDA(sensor_ida); static int sensor_probe(struct i2c_client *client) { struct sensor_dev *sdev; int id; id = ida_alloc(&sensor_ida, GFP_KERNEL); if (id < 0) return id; sdev->id = id; ... } static void sensor_remove(struct i2c_client *client) { struct sensor_dev *sdev = i2c_get_clientdata(client); ida_free(&sensor_ida, sdev->id); }注意ida是全局的,用ida_alloc时如果某个probe失败了,要记得把id释放掉;不然系统里不断加载卸载模块,id会越用越大,虽然不影响功能但会看着很别扭。
5.3 验证多实例是否正常工作
驱动加载后,建议按这个顺序检查:
- 看dmesg有没有每个实例的probe日志。正常的dmesg应该能看到4行
sensor probed,而且id各不相同。 - 看
/sys/bus/i2c/devices/下有没有4个对应的目录。 - 看
/dev/pressure_sensor0到/dev/pressure_sensor3是否存在,权限是否正确。 - 应用层依次打开这4个设备,每个设备读取到的数据应彼此独立。比如A通道堵住、B通道通气,你读到的数值应该互不干扰。
- 中断触发也验证一下:四个设备同时触发中断,dmesg里的中断计数和回调操作是各自独立的。
5.4 瑞芯微BSP环境下的额外提醒
瑞芯微的官方BSP内核里,很多驱动写成模块后打包进rootfs是正常的,但如果你要编进kernel image,注意Makefile里是obj-$(CONFIG_SENSOR_PRESSURE) += sensor_pressure.o,别把模块的y和m搞混。另外SDK里的module编译环境通常依赖内核源码树配置,先make modules再make modules_install INSTALL_MOD_PATH=...就对了。
如果你内核对设备树的compatible校验不严格,经常会出现“节点存在但驱动不probe”的怪现象。排查方法很简单,/sys/firmware/devicetree/base/i2c@.../pressure@48/compatible这个文件里看一下值是否和你驱动里的of_match_table完全一致(包括结尾的\0,可以直接hexdump看)。瑞芯微的dtsi默认带了很多status = "disabled"的节点,如果你忘了把父节点(i2c2)的状态改成okay,子节点再匹配也没信号。
6. 常见问题与排查技巧实录
6.1 问题速查表
在瑞芯微平台上调试这套多设备驱动,我踩过不少坑。整理成一张速查表,做项目时遇到类似问题可以直接查:
| 现象 | 可能原因 | 排查/解决办法 |
|---|---|---|
| 四个设备只有两个probe成功 | 设备树节点reg地址与硬件实际地址不一致,或I2C地址冲突 | i2cdetect -y 2扫描总线确认设备地址;检查dts里reg是否重复 |
| probe成功后read读不到数据 | I2C通信失败,可能总线速率太高或上拉电阻问题 | 用i2cget直接读寄存器验证裸通信能力;降低clock-frequency到100kHz试 |
| 设备节点名重复 | miscdev.name设置成了静态字符串 | 改成devm_kasprintf动态拼接序号 |
| 打开设备节点后数据错乱 | 私有数据指针在open里取错 | 确认open里用的是container_of还是i2c_get_clientdata,保持统一 |
| remove后再次probe失败 | ida没有释放,或devm资源未清理干净 | remove里调用ida_free;检查devm_kmalloc是否配对 |
| 中断一直触发死循环 | 中断标志设置错误,或中断服务里没清中断源 | 检查IRQF_TRIGGER_*电平/边沿设置;线程化中断里读写状态寄存器屏蔽中断源 |
| dmesg报“fail to request irq” | GPIO/中断号被其他驱动占用,或中断引脚mux没配好 | 检查pinctrl配置,cat /proc/interrupts看中断号使用情况 |
| 模块加载失败:no such device | compatible不匹配,或父节点未enable | 检查/sys/firmware/devicetree/base/下节点的status |
6.2 分享一个我反复踩的坑:I2C地址冲突
RK3568的I2C控制器支持7位寻址,最多挂128个设备。听起来很多,但实际上I2C从设备地址是硬件定的,经常有同一个器件只有2-3个可选地址的情况。比如某传感器可选地址只有0x48、0x4a、0x4c、0x4e四个,如果板子上挂了4颗,恰好地址编满。这时候如果你在设备树里写错一个地址(比如把0x4e写成了0x48),两个节点共用同一个地址,内核probe只会调用一次,另一个永远无响应。
排查方法:先用i2cdetect看总线上实际能扫到哪些地址,再核对设备树reg。i2cdetect扫到的地址都应该是真实存在的,如果有一个地址既出现在i2cdetect里又出现在两个设备树节点里,那肯定是配置冲突了。
6.3 另一个经验:瑞芯微平台的中断不要用trigger flag,用GPIO立flag
RK3568和RK3588的GPIO中断极性问题。在设备树里写interrupts = <RK_PB2 IRQ_TYPE_LEVEL_LOW>,内核会传给GPIO controller,但RK平台的GPIO驱动对电平触发有个历史问题:电平触发在中断线程还没跑完时就可能再次触发,导致中断风暴。经验做法是设备树里写IRQ_TYPE_EDGE_FALLING(边沿触发),然后驱动里记录状态,读数据再校验“是不是真的发生了”。如果你的传感器硬件只支持电平触发,那就把中断改成轮询,定期读一下状态寄存器,代价小很多。
这算是我在瑞芯微平台上摸爬滚打出来的野路子,但确实能稳定跑。官方SDK里的一些驱动也是这么干的,你可以翻看drivers/iio/pressure/下的瑞芯微适配代码,很多都回避了电平触发。
6.4 多实例的sysfs怎么暴露才顺手
除了字符设备节点,我还习惯给每个实例建一个sysfs属性组,方便应用层或shell直接查看状态:
static ssize_t status_show(struct device *dev, struct device_attribute *attr, char *buf) { struct sensor_dev *sdev = dev_get_drvdata(dev); return sysfs_emit(buf, "%d\n", sdev->status); } static DEVICE_ATTR_RO(status); static struct attribute *sensor_attrs[] = { &dev_attr_status.attr, NULL, }; ATTRIBUTE_GROUPS(sensor_attrs);然后在probe里创建字符设备时带上这个属性组:
sdev->dev = device_create_with_groups(sensor_class, &client->dev, MKDEV(sensor_major, sdev->minor), sdev, sensor_groups, "sensor%d", sdev->id);这样shell里直接cat /sys/class/sensor_class/sensor0/status就能看状态,不用写个应用程序去读字符设备。这个思路对调试多实例特别方便,因为你可以快速确认每个实例当前的情况,不用同时开4个终端盯着/dev下的节点。
6.5 一个“玄学”排查法:设备树编译缓存
用瑞芯微SDK的设备树编译有个坑:如果你改了dts/dtsi文件,重新编译时有用缓存的情况(不是每次都全量编译),经常出现改了设备树但实际固件里还是旧配置的问题。建议在编译脚本里加make dtbs强制重新生成,或者干脆rm -rf kernel/kernel.dtb kernel/resource.img这种整一下。
如果出现“设备树里明明删了一个节点,启动后还是挂载了旧设备”的诡异问题,十有八九是这个。别问我怎么知道的,我花了整整一个下午排查一颗i2c设备为何始终probe不了,最后发现内核里跑的设备树还是四个小时前的旧版本。
7. 进阶思路与个人心得
两个技巧讲到这儿,基本能解决“一套驱动跑通多个同型号设备”的难题了。但在实际的瑞芯微平台项目中,多设备驱动还会遇到很多延伸问题,值得再啰嗦几句:
不同实例需要不同配置参数:比如4颗传感器安装位置不同,需要不同的校准系数。最简单的方式是设备树节点里加自定义property,如
offset = <10>、scale = <100>,probe里用device_property_read_u32逐个读取。不要把这些参数写死在驱动里,驱动只认设备树,这样硬件改版后只要改dts,不用重编驱动。多个实例共享同一个硬件资源:比如同一个GPIO中断引脚上挂了两个设备(共享中断),你用
devm_request_threaded_irq时注意标上IRQF_SHARED,并且在中断处理函数里判断是否是自己的设备产生了中断,不是就返回IRQ_NONE。这种场景在瑞芯微的多摄像头采集板上非常常见。驱动要支持“设备热插拔”或“运行时动态加载”:瑞芯微平台虽然大多数是固定外设,但也有人拿来做扩展板。如果外设可以在运行时拔出,你就得关注
driver_override、unbind/bind流程,以及remove函数里正确清理所有资源。devm体系可以让大部分资源自动清理,但ida、miscdevice注销这些还是要手动做。关于内核版本差异:瑞芯微BSP有Linux 4.19、5.10、6.1等多个内核版本。
device_get_match_data在4.19里存在但行为略有差异,建议probe里做一个NULL检查,兼容性更好。另外较新的内核里of_device_id的.data字段类型是kernel_ulong_t,在64位平台上用(unsigned long)&barometer_a_cfg转换不会截断,但保险起见还是强转一下,编译警告能避就避。
说回开头的两个场景。I2C多传感器那个项目,我用driver_data差异配置表同时支持了RK3568和RK3588的两种板卡,又用ida+私有数据管理了同板4个传感器实例,最终一个ko文件解决了两个平台、两组板卡的驱动需求。后面客户换板卡、换传感器型号,我基本都是只改设备树,驱动代码几乎没动过。
还有一点经验之谈:设备树里同一型号的设备多个节点,label属性建议加上。比如label = "sensor-0",这会给日志和sysfs的输出带来极大便利。虽然它不是Linux的标准属性,但很多驱动里会用dev_name加attribute来区分,你把它打印出来,排障时一眼就知道是板子上哪个物理位置的传感器出问题了。
最后再分享一个小技巧:在内核调试多实例驱动时,/sys/kernel/debug/dri这类debugfs节点配合dev_dbg打印特别有效。你在probe里用dev_info打日志时,输出的设备路径天然带了总线号和地址(比如i2c-3 3-0048),一眼就能认出来是哪个实例。日志里一定要带独有的设备标识,千万别图省事只打“probe ok”,不然4个实例的日志混在dmesg里面,你根本分不清谁是谁。
驱动开发这件事,写单设备是入门,写多设备才是真正的日常。这两个技巧,本质上就是让代码从“一个设备的专属驱动”升级成“一类设备的通用驱动模型”。你把这两个套路用熟了,不管芯片平台是瑞芯微还是别家,遇到多设备场景心态都会稳很多。