在瑞芯微平台上调驱动,我碰到最多的需求之一,就是一个驱动要同时照顾多个设备。注意,这里说的是“同一个驱动文件”,不是给每个设备各写一份驱动。比如RK3568板子上I2C2总线挂了两片同型号传感器,或者硬件改版后同一个外设换了寄存器地址和初始化流程,再或者双屏异显时两个触摸屏共用一套驱动逻辑。这些需求藏在项目里并不起眼,但真做起来坑很多:probe只执行一次、/dev节点只出现一个、两个设备的数据互相串扰。这篇文章就分享我在瑞芯微平台上总结出的两个小技巧:一是用of_device_id.data把设备差异做成一张配置表,二是用MISC_DYNAMIC_MINOR加per-device私有数据,让多个实例各自独立、互不干扰。代码基于RK3568、内核4.19/5.10,其它瑞芯微芯片同样适用。
1. 先搞清楚:内核怎么让“一个驱动”驱动“多个设备”
1.1 驱动的匹配机制:probe为什么会被调用多次
很多刚接触Linux驱动开发的同学容易搞混一个概念:驱动和设备的对应关系到底是一对一还是一对多。在Linux的设备模型里,驱动是一个“方法集合”,它本身不带状态;设备是内核里一个个独立的struct device对象,每个对象对应设备树里的一个节点,或者对应总线上扫描到的一个真实硬件。当驱动注册到内核后,总线会拿着驱动里的匹配表,去扫描所有挂在这条总线上的设备,每匹配上一个设备,就会为这个设备调用一次probe函数。
换句话说,一个I2C驱动注册一次,同一条I2C总线上挂两片地址不同的设备,probe就会被调用两次。如果这两个设备还分别注册成两个平台设备或者两个I2C client,那更是互不干涉。这个机制是Linux内核天生支持多设备的根基,理解它的关键点在于:probe里的参数(比如struct i2c_client *client)每次都是不同的,你必须把每次调用看成一次“新开张”,所有跟具体设备有关的数据都要在这一轮probe里分配并保存好。
1.2 我在瑞芯微平台上遇到的实际场景
我最近在RK3568上做双传感器数据采集,I2C2上挂了两片不同的传感器:型号A是老款,初始化需要往寄存器0x10写值;型号B是新款,寄存器布局变了,初始化流程也不同。同时,硬件上还留了一个扩展位,允许客户在另一个I2C地址上再挂一片同型号设备。这就意味着:一个驱动文件,要同时兼容两种型号,且每个型号可能有多片实例。
如果按最简单的思路,给A和B各写一个驱动,代码大量重复不说,后续型号C出来又要复制一遍。更麻烦的是,当两片设备同时工作时,如果驱动里用了全局变量保存状态,两个设备的数据必然串扰。所以我把设计目标定为:驱动只有一份probe和一份remove逻辑,型号差异通过配置数据区分,每个设备实例有自己独立的私有数据结构,并且能在/dev下生成不重名的节点。
2. 小技巧一:把设备差异放进匹配表的.data里
2.1 传统麻烦做法:在probe里堆if-else
在没有技巧的情况下,很多人会写这样的代码:
static int sensor_probe(struct i2c_client *client) { if (of_device_is_compatible(client->dev.of_node, "rockchip,sensor-a")) { /* 型号A初始化 */ i2c_smbus_write_byte_data(client, 0x10, 0x01); } else if (of_device_is_compatible(client->dev.of_node, "rockchip,sensor-b")) { /* 型号B初始化 */ i2c_smbus_write_byte_data(client, 0x20, 0x02); } ... }这种写法在只有两个型号时还能忍,一旦型号变多,probe会膨胀得非常难看。更致命的是,如果初始化函数里要用到不同寄存器地址、不同延时、不同频率参数,你得在probe里维护一大堆分支,新增一个型号就要改probe代码,还要担心改坏已有型号。而设备树的compatible字符串本质上是驱动与设备“握手”的凭证,它已经帮你做了第一层区分,你只需要在这个基础上把差异数据化。
2.2 技巧核心:of_device_id.data指向一个配置结构体
Linux提供的of_device_id结构体里有两个关键字段:compatible和data。compatible用于匹配设备树节点的compatible字符串,而data是一个void *指针,可以在匹配表里预先挂上任何你想要的数据。驱动probe时,用device_get_match_data()(配套的是of_device_get_match_data())就能把匹配表里对应的data指针取出来。
这意味着什么?意味着你可以把每个型号差的东西——名字、默认寄存器值、初始化函数指针、工作频率、校准参数——全部打包进一个结构体,然后把结构体地址塞进匹配表的.data字段。probe里的if-else分支被替换成“取出配置表,查表执行”。整个过程没有字符串比较,没有散落的magic number,所有型号差异收拢成一个只读配置数组。
struct sensor_model_info { const char *name; u8 id_reg; u8 id_value; int (*init)(struct i2c_client *client); }; static int sensor_a_init(struct i2c_client *client) { return i2c_smbus_write_byte_data(client, 0x10, 0x01); } static int sensor_b_init(struct i2c_client *client) { return i2c_smbus_write_byte_data(client, 0x20, 0x02); } static const struct sensor_model_info sensor_a_info = { .name = "sensor-a", .id_reg = 0x00, .id_value = 0xA0, .init = sensor_a_init, }; static const struct sensor_model_info sensor_b_info = { .name = "sensor-b", .id_reg = 0x00, .id_value = 0xB0, .init = sensor_b_init, };匹配表写成这样:
static const struct of_device_id sensor_dt_match[] = { { .compatible = "rockchip,sensor-a", .data = &sensor_a_info }, { .compatible = "rockchip,sensor-b", .data = &sensor_b_info }, { } }; MODULE_DEVICE_TABLE(of, sensor_dt_match); static const struct i2c_device_id sensor_i2c_id[] = { { "sensor-a", (kernel_ulong_t)&sensor_a_info }, { "sensor-b", (kernel_ulong_t)&sensor_b_info }, { } }; MODULE_DEVICE_TABLE(i2c, sensor_i2c_id);注意这里同时维护了of_match_table和i2c_device_id表。为什么两处都要?因为设备树匹配走of_match_table,而传统的板级描述(非设备树)模式走i2c_device_id。瑞芯微平台基本都是设备树,但有些老SDK或者ACPI环境会走到非设备树路径。两个表都维护,兼容性最好。
probe函数里取出配置表:
static int sensor_probe(struct i2c_client *client) { const struct sensor_model_info *info; struct device *dev = &client->dev; if (dev->of_node) info = device_get_match_data(dev); else info = (const struct sensor_model_info *)i2c_match_id(sensor_i2c_id, client)->driver_data; if (!info) return -ENODEV; dev_info(dev, "probe sensor %s\n", info->name); if (info->init) { int ret = info->init(client); if (ret) return ret; } ... }2.3 这个技巧为什么适合瑞芯微平台
瑞芯微的SDK里,芯片级dtsi和板级dts分层明确,板级dts里经常通过改compatible来切换同一外设的不同驱动物理实现。比如有些开发板有多个硬件版本,音频Codec、触摸屏、传感器都可能换了厂商。如果驱动里全是if-else,客户换硬件就要改驱动;用了.data配置表,客户只需要改设备树compatible,然后我这边加一行匹配表和一个配置结构体,就能兼容新硬件。
这个技巧还有一个隐藏好处:内核模块加载时,of_match_table里的compatible会被编译进模块的modinfo,你可以用modinfo直接查看这个驱动支持哪些设备。配上了.config数据和MODULE_DEVICE_TABLE,模块的自动加载、热插拔识别也会更可靠。对于产线要烧录多个硬件版本的场景,这个信息非常有用。
3. 小技巧二:每个设备一份私有数据,/dev节点不重名
3.1 全局变量是万恶之源
数据隔离是“一驱多设备”的核心难点之一。很多驱动为了省事,把设备状态、缓冲区、计数变量直接声明成全局变量。在只有一个设备的时候,这个问题不明显;但挂两片同型号设备后,第二次probe会把全局变量覆盖掉,第一个设备再读数据时,拿到的可能是第二个设备的寄存器值。我见过最典型的故障是:两个触摸屏共用一个驱动,全局变量里保存的event缓冲被后一个设备覆盖,导致第一个屏幕触控失灵。
正确的做法是:在每次probe里通过devm_kzalloc为当前设备分配一份独立的私有数据结构,把所有该设备相关的状态都装进去,然后用i2c_set_clientdata()(平台设备用platform_set_drvdata())把这份数据和当前client绑定。需要取数据的地方,用i2c_get_clientdata()或container_of把它取出来。这样每片设备都有一本自己的“账本”,谁都不会动谁。
3.2 动态设备号:miscdevice的妙用
驱动要给应用层暴露接口,常见三种方式:字符设备(cdev + alloc_chrdev_region)、miscdevice、input子系统等专有框架。对于多设备场景,我推荐miscdevice,因为它天生支持每个设备一个独立的/dev节点,而且次设备号可以动态分配。
miscdevice结构体里的minor字段填MISC_DYNAMIC_MINOR,内核会自动分配一个空闲的次设备号,不会和系统里已有的设备冲突。name字段就是/dev节点名,只要保证每个设备实例的name不重复就行。miscdevice还有一个fops字段,应用层open、read、write、ioctl都走这个文件操作集合。
光有miscdevice还不够,还得让应用层能区分“我打开的到底是哪个设备”。misc_open会把struct miscdevice指针放到file->private_data里,你只需要在open函数里用container_of把这个指针反解回你自己的私有数据结构,再存回file->private_data即可。后续read/write/ioctl直接从file->private_data里取值,就永远不会串设备。
3.3 完整驱动代码串联
下面是我在RK3568上调通的精简版驱动,去掉了业务逻辑,只保留多设备支持和数据隔离框架。
#include <linux/module.h> #include <linux/i2c.h> #include <linux/of_device.h> #include <linux/device.h> #include <linux/miscdevice.h> #include <linux/fs.h> #include <linux/uaccess.h> #include <linux/slab.h> #define DRIVER_NAME "rk_multi_sensor" struct sensor_model_info { const char *name; u8 id_reg; u8 id_value; int (*init)(struct i2c_client *client); }; struct sensor_dev_data { struct i2c_client *client; const struct sensor_model_info *info; struct miscdevice mdev; int counter; }; /* 型号A和B的model info,见上一节,这里省略重复定义 */ static int sensor_a_init(struct i2c_client *client) { return i2c_smbus_write_byte_data(client, 0x10, 0x01); } static int sensor_b_init(struct i2c_client *client) { return i2c_smbus_write_byte_data(client, 0x20, 0x02); } static const struct sensor_model_info sensor_a_info = { .name = "sensor-a", .init = sensor_a_init, }; static const struct sensor_model_info sensor_b_info = { .name = "sensor-b", .init = sensor_b_init, }; static const struct of_device_id sensor_dt_match[] = { { .compatible = "rockchip,sensor-a", .data = &sensor_a_info }, { .compatible = "rockchip,sensor-b", .data = &sensor_b_info }, { } }; MODULE_DEVICE_TABLE(of, sensor_dt_match); static const struct i2c_device_id sensor_i2c_id[] = { { "sensor-a", (kernel_ulong_t)&sensor_a_info }, { "sensor-b", (kernel_ulong_t)&sensor_b_info }, { } }; MODULE_DEVICE_TABLE(i2c, sensor_i2c_id); static int sensor_open(struct inode *inode, struct file *file) { struct sensor_dev_data *data = container_of(file->private_data, struct sensor_dev_data, mdev); file->private_data = data; return 0; } static long sensor_ioctl(struct file *file, unsigned int cmd, unsigned long arg) { struct sensor_dev_data *data = file->private_data; int val; switch (cmd) { case 0x01: /* 获取型号 */ val =>&i2c2 { status = "okay"; clock-frequency = <400000>; sensor_a: sensor-a@1a { compatible = "rockchip,sensor-a"; reg = <0x1a>; status = "okay"; }; sensor_b: sensor-b@1b { compatible = "rockchip,sensor-b"; reg = <0x1b>; status = "okay"; }; };在瑞芯微SDK里,dts文件路径一般是arch/arm64/boot/dts/rockchip/,不同内核版本可能略有差异。修改后,用SDK提供的编译命令重新生成boot.img或者单独编译dtb。如果用的是模块方式,只需要把dtb替换进boot分区,驱动模块单独insmod即可;如果把驱动编进内核,那就得连uImage/Image一起更新。
4.2 编译驱动为模块
在驱动源码目录下,用内核的Kbuild机制编译:
obj-m += rk_multi_sensor.o然后执行:
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- -C /path/to/kernel M=$(pwd) modules具体交叉编译工具链路径取决于SDK,瑞芯微v2/v3 SDK一般用buildroot目录下的工具链。编出来的rk_multi_sensor.ko拷到板子上,先确认I2C2总线上的设备地址能被扫到:
i2cdetect -y 2如果看到0x1a和0x1b都有编号出现,说明硬件和设备树没问题,可以加载模块:
insmod rk_multi_sensor.ko dmesg | tail -20正常会看到两条probe日志:
rk_multi_sensor: registered sensor-a-1a at addr 0x1a rk_multi_sensor: registered sensor-b-1b at addr 0x1b同时在/dev下看到两个节点:
ls -l /dev/sensor-*输出类似:
crw------- 1 root root 10, 55 Jan 1 00:00 /dev/sensor-a-1a crw------- 1 root root 10, 65 Jan 1 00:00 /dev/sensor-b-1b主设备号都是10(miscdevice共用主设备号10),次设备号不同,这正是MISC_DYNAMIC_MINOR动态分配的效果。
4.3 应用层验证数据隔离
光看/dev节点还不够,还要验证两个设备确实各管各的。写一个简单测试程序:
#include <stdio.h> #include <fcntl.h> #include <sys/ioctl.h> #include <unistd.h> int main(int argc, char **argv) { int fd, val; if (argc < 2) { fprintf(stderr, "usage: %s /dev/sensor-xxx\n", argv[0]); return 1; } fd = open(argv[1], O_RDONLY); if (fd < 0) { perror("open"); return 1; } if (ioctl(fd, 0x02, &val) < 0) { perror("ioctl"); return 1; } printf("%s counter: %d\n", argv[1], val); close(fd); return 0; }分别对两个节点执行三次,会看到两个计数器各自增加,互不影响。初始开两个终端各跑几遍,输出类似:
sensor-a-1a counter: 1 sensor-a-1a counter: 2 sensor-a-1a counter: 3 sensor-b-1b counter: 1 sensor-b-1b counter: 2 sensor-b-1b counter: 3这基本就能证明per-device私有数据生效了。如果两个节点的计数值互相跳,那就是全局变量导致的串扰,回到3.1去检查。
5. 常见问题与排查技巧实录
5.1 问题速查表
把我在瑞芯微平台调试多设备驱动时踩过和帮别人排过的坑整理一下:
| 现象 | 根因 | 排查命令/手段 | 解决方法 |
|---|---|---|---|
| probe只执行一次,只注册了一个节点 | 设备树第二个节点status不是okay,或reg地址和实际不符 | i2cdetect -y 总线号;检查dts | 修正设备树reg和status |
| /dev一个节点注册失败,dmesg报device register失败 | miscdevice的name重名 | ls /dev/sensor-* | name加地址或序号后缀 |
| device_get_match_data返回NULL | of_match_table没有填.data,或compatible大小写错 | 检查源码匹配表、dts | 补全.data配置 |
| 两个同型号设备数据串扰 | 驱动用了全局变量保存状态 | 看dmesg,应用层验证counter | 改用devm_kzalloc+set_clientdata |
| 模块加载后probe日志都没有 | 驱动没有编进内核或模块没加载成功 | lsmod、modprobe、dmesg | 确认insmod成功,compatible匹配 |
| 换内核版本后编译报错remove函数类型不匹配 | 4.19内核i2c_driver.remove返回int,5.10后返回void | 查看内核头文件 | 按对应内核版本修改remove返回类型 |
| 应用层open成功但ioctl报Bad address | copy_to_user传入了非法用户指针 | 检查应用层传参 | 用copy_to_user/copy_from_user安全拷贝,调用端检查arg |
5.2 详细排查思路
第一类问题是“根本不出节点”。先不要看驱动,首先确认设备树有没有生效:
ls /proc/device-tree/i2c@2/sensor-a@1a/ ls /proc/device-tree/i2c@2/sensor-b@1b/如果这两个目录不存在,说明设备树没编进去,或者节点被status = "disabled"挡住了。直接用i2cdetect确认I2C地址,排除硬件虚焊。确认设备和总线上都OK,再回头查驱动匹配表。
第二类问题是“出了节点但数据不对”。重点检查probe里的私有数据分配和保存环节。我见过有同事把per-device的data错误地挂到全局指针上,第二次probe就把第一次的覆盖了。正确逻辑是每个设备在probe时都独立分配,且通过i2c_set_clientdata绑定到自己的client上。任何时候想拿当前设备的私有数据,都从client反查,绝不要从全局变量取。
第三类是兼容性问题。瑞芯微不同内核版本的API差异很大,4.19和5.10之间改了一些函数签名。比如i2c_driver的remove,4.19是int类型,5.10改成了void。还有miscdevice注册的返回判断,老内核用IS_ERR,新内核统一用负数错误码。遇到编译错误先别急着改业务代码,去内核头文件里确认当前版本的API原型,这是经验之谈,能省很多时间。
我在实际调试中还发现一个特别实用的习惯:在probe里多打几条dev_info日志,把匹配到的型号名、I2C地址、动态名字都打出来。多设备驱动最容易出现“不知道自己当前在操作哪个设备”的迷失感,日志里带上设备地址后,配合wireshark抓I2C数据或者加dump_registers调试节点,问题定位效率能提升一倍。
另外顺便多说一句,如果后续要让驱动支持热插拔,或者支持运行时动态创建设备,可以在probe基础上再补一个sysfs接口。比如在私有数据结构里创建attr_group,这样应用层通过echo写/sys/class/xxx/yyy就能触发某个设备的重置或参数配置。多设备场景下,sysfs里每个设备一个目录,天然隔离,比纯ioctl更直观。但这是另一个话题了,核心还是先把数据隔离和节点命名做好。