瑞芯微Linux驱动多实例管理:设备树aliases与次设备号实战
2026/9/7 10:45:49 网站建设 项目流程

做瑞芯微平台驱动开发的朋友,应该都撞到过这个问题:板子上明明只写了一份驱动,硬件上却焊了两路、三路,甚至八路一模一样的设备。我最早在RK3568上做多路环境监测扩展板就是这样——四颗同型号温湿度传感器挂在同一条I2C总线上,后面还拖了一路从UART引出的智能电源模块。第一版驱动写得很“天真”,全局数组存实例数据,设备号写死,结果加第三路设备的时候,光是改设备树和驱动间的对应关系就折腾掉一晚上。后来把两个关键技巧吃透了,才算真正告别“每加一路设备就改一次代码”的日子。

这篇内容不绕弯子,直接把我在瑞芯微平台Linux驱动开发里最常用的两个手段讲透:一是如何从设备树里拿到“我是第几个设备”,二是如何用一个主设备号管住同一驱动下的多个实例。适合刚接触驱动架构、或者正在做多路外设扩展的朋友参考。

1. 什么场景下才需要“一份驱动,多个设备”:先想清楚再动手

不是所有驱动都需要考虑多实例支持。如果产品里某个外设芯片只焊一粒,那你大可以写得很“个人化”,设备号写死都行。但只要是验收要过了、批量要出货的板子,多路同型号器件几乎是必然的设计选择:四路ADC采集、八路串口转485、多路GPIO控制的继电器模组,或者我在RK3568上做的多路I2C传感器阵列,统统是同一个外设重复出现。

这种场景下,驱动代码最忌讳的就是“按数量写死”。我见过有人把每路设备都复制一份驱动文件,然后改compatible字符串来匹配,最后设备树里塞了一堆几乎一样的节点。这种写法的问题很直接:改一处逻辑要同步改N个文件,编译完还得检查哪份没改全,线上出问题定位也慢。Linux内核设计本身就支持“一份driver匹配多个device”,关键是驱动代码要在probe阶段拿到“我是哪一路”这个信息,之后的所有数据、寄存器地址、设备号管理,全部围绕这个实例ID展开。

在设计驱动框架之前,先想清楚四个问题,能省掉后面大量返工:

  • 你的设备是挂在什么总线上?platform总线、I2C、SPI还是USB?这决定了匹配方式是设备树节点还是id_table。
  • 多路设备之间是完全独立的,还是要共享某些硬件资源?比如共用同一组电源控制、共用一个中断线。
  • 用户空间要怎样区分这N个设备?最自然的方式是/dev/下的节点名带编号,比如temp_sensor0、temp_sensor1。
  • 设备数量是固定的还是会动态变化?板级器件一般固定,USB/PCIe类设备则要考虑热插拔。

我把这些想清楚之后,驱动架构就非常清晰了:probe函数每被调用一次,就代表内核发现了一个硬件实例;我在probe里完成三件事——从设备树解析出这个实例的ID、映射属于自己的那一段寄存器地址、把实例私有数据挂到dev的drvdata上。后面所有文件操作,都从私有数据里取上下文。

这套思路在瑞芯微平台上尤其顺手,因为RK3568、RK3588这些芯片的外设控制器非常丰富,设备树里有大把现成的控制器节点可以挂子节点。下面进入正题,第一个技巧解决“识别我是谁”的问题。

2. 技巧一:设备树里认“我是谁”——别硬编码设备编号

2.1 为什么不能在probe里自己数数

新手最容易犯的错误,是在probe函数里搞一个static计数器:

static int count; priv->id = count++;

表面上看,每次probe调用,id都会顺延,第一次probe是0,第二次是1,逻辑上没毛病。但这个写法有个致命问题:probe的顺序和设备树里节点的排列顺序、以及内核扫描总线的顺序并不保证一致。我在RK3568上调过多路I2C设备,明明设备树里temp0写在temp1前面,实际probe时temp1先触发,结果是/dev/temp_sensor0对应的物理上却是第二颗芯片。如果硬件设计和生产装配时,0号节点固定对应板子上的0号位置,那这个编号错乱就属于硬伤。

另一个问题是热插拔场景。如果设备可以动态插拔,计算器数值更不可控,拔掉第0路再插回来,新实例可能拿到id=3,和已有的其他实例编号撞车。

所以,设备实例的编号必须从稳定、可预期的地方来。对平台设备和I2C/SPI从设备来说,最可靠的来源就是设备树本身。

2.2 用aliases节点给设备编号,一行代码搞定

设备树里有个全局的aliases节点,专门用来给某些设备起“短名字”并编号。Linux内核里的of_alias_get_id()函数就是干这个的。我不需要自己在驱动里解析任何字符串,内核会帮我查aliases表,返回编号。

设备树里这样写:

/ { aliases { temp0 = &temp0; temp1 = &temp1; }; /* 注意:&temp0 引用的是下面i2c1中的label */ }; &i2c1 { status = "okay"; pinctrl-names = "default"; pinctrl-0 = <&i2c1_xfer>; temp0: tmp117@48 { compatible = "vendor,tmp117"; reg = <0x48>; }; temp1: tmp117@49 { compatible = "vendor,tmp117"; reg = <0x49>; }; };

驱动probe里获取编号:

#include <linux/of.h> #include <linux/platform_device.h> struct temp_sensor_priv { void __iomem *base; int id; /* 其他私有数据 */ }; static int temp_sensor_probe(struct i2c_client *client, const struct i2c_device_id *id) { struct device *dev = &client->dev; struct temp_sensor_priv *priv; int dev_id, ret; dev_id = of_alias_get_id(dev->of_node, "temp"); if (dev_id < 0) dev_id = 0; /* 设备树里没配alias时退到0,生产环境建议直接返回错误 */ priv = devm_kzalloc(dev, sizeof(*priv), GFP_KERNEL); if (!priv) return -ENOMEM; ret = i2c_smbus_read_byte_data(client, REG_ID); if (ret < 0) return ret; priv->id = dev_id; i2c_set_clientdata(client, priv); dev_info(dev, "probe ok, instance id = %d\n", priv->id); return 0; }

关键就一行:of_alias_get_id(dev->of_node, "temp")。内核会去设备树aliases节点里查找名为temp0、temp1的引用,匹配到当前节点的label后,返回后面的数字。

这个方案有几个实打实的好处:

  • 编号和设备树里的声明完全对应,不依赖probe顺序。
  • 驱动代码里不需要硬编码任何设备地址或序号。
  • 新增一路设备时,只需要在设备树里增加节点和alias,驱动和用户空间代码都不动。

2.3 平台设备也可以这样玩

刚刚演示的是I2C从设备。对于挂在片上总线上的自定义逻辑,比如FPGA通过Localbus映射出来的多路寄存器块,通常会写成platform设备。这时可以用platform_get_resource()配合aliases,逻辑完全一样:

#include <linux/of_address.h> static int temp_sensor_platform_probe(struct platform_device *pdev) { struct device *dev = &pdev->dev; struct temp_sensor_priv *priv; struct resource *res; int dev_id; dev_id = of_alias_get_id(dev->of_node, "tempsens"); if (dev_id < 0) return -EINVAL; res = platform_get_resource(pdev, IORESOURCE_MEM, 0); if (!res) return -EINVAL; priv = devm_kzalloc(dev, sizeof(*priv), GFP_KERNEL); if (!priv) return -ENOMEM; priv->base = devm_ioremap_resource(dev, res); if (IS_ERR(priv->base)) return PTR_ERR(priv->base); priv->id = dev_id; platform_set_drvdata(pdev, priv); return 0; }

设备树里这样配:

/ { aliases { tempsens0 = &tempsens0; tempsens1 = &tempsens1; }; tempsens0: tempsens@20000000 { compatible = "vendor,tempsens"; reg = <0x0 0x20000000 0x0 0x1000>; interrupts = <0 45 4>; }; tempsens1: tempsens@20001000 { compatible = "vendor,tempsens"; reg = <0x0 0x20001000 0x0 0x1000>; }; };

瑞芯微的SDK里,设备树中aliases节点已经预设了大量控制器别名,比如uart0、i2c0、spi0这些。自己加别名时,前缀一定要避开这些已经被内核或driver使用的名字,否则of_alias_get_id检索时可能撞上意外的节点。

2.4 不依赖aliases的备选方案

如果你不方便在aliases节点里加引用,还有一个土办法:在设备节点里自定义一个属性,比如reg-id:

tempsens0: tempsens@20000000 { compatible = "vendor,tempsens"; reg = <0x0 0x20000000 0x0 0x1000>; reg-id = <0>; }; tempsens1: tempsens@20001000 { compatible = "vendor,tempsens"; reg = <0x0 0x20001000 0x0 0x1000>; reg-id = <1>; };

驱动里用of_property_read_u32_index(dev->of_node, "reg-id", 0, &dev_id)读取。这个方法的好处是自定义性更强,缺点是不是内核标准机制,别人看设备树时不一定能立刻明白这个属性的含义。我个人的习惯是优先用aliases,自定义属性只在内核源码规范不允许动aliases的模块里使用。

3. 技巧二:次设备号与container_of,管好同一主设备号下的多个实例

3.1 一个主设备号,N个次设备号

识别出设备实例之后,下一个问题是怎么把每个实例和用户空间的设备节点对应起来。

Linux字符设备的主设备号标识驱动,次设备号标识该驱动管理下的具体设备。多实例驱动的标准套路,就是整个驱动只申请一个主设备号,然后用不同的次设备号区分每一个硬件实例。每个实例一个cdev,cdev注册时用“主设备号+实例ID”组成的设备号。

我习惯在驱动初始化阶段申请一整段次设备号空间:

#include <linux/fs.h> #include <linux/cdev.h> #include <linux/device.h> #define TEMP_SENSOR_MAX_DEVS 8 #define TEMP_SENSOR_NAME "temp_sensor" static int temp_major; static int __init temp_sensor_init(void) { dev_t dev; int ret; /* 动态申请主设备号,次设备号从0到TEMP_SENSOR_MAX_DEVS-1 */ ret = alloc_chrdev_region(&dev, 0, TEMP_SENSOR_MAX_DEVS, TEMP_SENSOR_NAME); if (ret < 0) return ret; temp_major = MAJOR(dev); return 0; } module_init(temp_sensor_init);

alloc_chrdev_region而不是自己指定主设备号,是为了避免和内核里已有驱动的主设备号冲突。调试阶段如果频繁加载卸载,自己指定的号一旦被别的模块占用,insmod就会失败。

回看初始化部分,还需要创建class,后面每个probe实例创建各自的设备节点:

static struct class *temp_sensor_class; static int __init temp_sensor_init(void) { dev_t dev; int ret; ret = alloc_chrdev_region(&dev, 0, TEMP_SENSOR_MAX_DEVS, TEMP_SENSOR_NAME); if (ret < 0) return ret; temp_major = MAJOR(dev); temp_sensor_class = class_create(TEMP_SENSOR_NAME); if (IS_ERR(temp_sensor_class)) { unregister_chrdev_region(dev, TEMP_SENSOR_MAX_DEVS); return PTR_ERR(temp_sensor_class); } return 0; }

3.2 每个实例一个cdev,用container_of找回私有数据

probe函数里,拿到实例ID之后,第二步就是把该实例的cdev注册进内核。这里的实现细节决定了后面open函数能不能准确找到“我打开的到底是哪个设备”。

我的做法是让每个设备实例都拥有自己独立的cdev对象,cdev_add时传入的次设备号等于实例ID:

static const struct file_operations temp_sensor_fops = { .owner = THIS_MODULE, .open = temp_sensor_open, .read = temp_sensor_read, .write = temp_sensor_write, .release = temp_sensor_release, }; static int temp_sensor_register_cdev(struct temp_sensor_priv *priv, int idx) { dev_t dev = MKDEV(temp_major, idx); int ret; cdev_init(&priv->cdev, &temp_sensor_fops); priv->cdev.owner = THIS_MODULE; ret = cdev_add(&priv->cdev, dev, 1); if (ret < 0) return ret; /* device_create会在/dev下自动生成temp_sensorN节点 */ priv->dev = device_create(temp_sensor_class, NULL, dev, priv, "temp_sensor%d", idx); if (IS_ERR(priv->dev)) { cdev_del(&priv->cdev); return PTR_ERR(priv->dev); } return 0; }

注意cdev_add(&priv->cdev, dev, 1)的第三个参数是count,这里填1,表示这个cdev只管理一个次设备号。这样VFS层在打开/dev/temp_sensor3时,内核会自动定位到次设备号为3的那个cdev,而cdev又是priv结构体的成员,于是就能用container_of反推整个priv。

open函数就变得非常干净:

static int temp_sensor_open(struct inode *inode, struct file *filp) { struct temp_sensor_priv *priv; /* 从inode里关联的cdev反推出包含它的priv结构体 */ priv = container_of(inode->i_cdev, struct temp_sensor_priv, cdev); filp->private_data = priv; return 0; }

container_of是内核里最常用的“从成员找容器”的宏。它的原理是计算cdev成员在priv结构体内的偏移量,再用cdev的地址减去这个偏移,就得到了priv的起始地址。不需要维护任何全局数组,也不需要遍历,打开任意一个设备节点,O(1)拿到对应实例数据。

后面的read、write等所有文件操作函数,都可以直接从filp->private_data里取priv:

static ssize_t temp_sensor_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos) { struct temp_sensor_priv *priv = filp->private_data; u32 raw; char tmp[32]; int len; /* 从实际硬件读温度,失败返回错误码 */ raw = readl(priv->base + REG_TEMP); len = snprintf(tmp, sizeof(tmp), "%u.%02u\n", raw / 100, raw % 100); return simple_read_from_buffer(buf, count, f_pos, tmp, len); } static ssize_t temp_sensor_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos) { struct temp_sensor_priv *priv = filp->private_data; /* 用户写入校准值,只写到当前实例的寄存器 */ ... return count; }

3.3 为什么不推荐“一个cdev配多个次设备号”

有些旧代码会把N个次设备号挂在一个cdev下面,open时自己用iminor(inode)去索引一个全局数组。比如:

static int temp_sensor_open(struct inode *inode, struct file *filp) { int minor = iminor(inode); if (minor >= MAX_DEVS) return -ENODEV; filp->private_data = &g_devs[minor]; return 0; }

这种写法在内核2.x时代很流行,但放到现在有很明显的毛病:

  • 需要维护一个全局数组g_devs,数组大小写死,一旦实例数超过上限就得改代码重新编译。
  • 多实例之间的边界模糊,一个越界访问就可能踩到别的实例数据。
  • 逻辑上“一个cdev管多个设备”意味着多个设备节点共享同一个inode的i_cdev,在部分文件系统场景下open到不同节点时,内核无法直接给你区分是哪一路,必须靠次设备号再查一层。

每实例一个cdev的做法,本质上把“设备实例”和“内核对象”做了一一映射,不管是remove一个设备还是用户空间访问,都能直接通过该实例自己的cdev来回溯,代码路径短很多。

到这一步,两个技巧已经完整覆盖了从“设备树识别实例”到“用户空间设备节点访问实例”的整条链路。

4. 瑞芯微平台实战:容易踩的四个坑

4.1 aliases命名别和SDK自带的控制器编号冲突

瑞芯微SDK的设备树里,RK3568的DTSI文件已经定义了大量的alias,比如uart0到uart9、i2c0到i2c5、spi0到spi3,还有以太网、GPU、NPU等一堆节点。如果你给自己的外设起别名时用了已经存在的名字,比如temp = &uart0,那of_alias_get_id时检索到的就不是你的本意了。

我在RK3568上第一次就栽在这里。当时想给一路RS485串口起别名“rs485”,结果SDK里已经有个别名占了类似的编号段,驱动拿到的ID完全不是预期值。排查了很久,最后把设备树反编译出来对照aliases才找到原因。

建议是:自定义外设别名使用项目缩写作为前缀,避免和官方DTSI中大量通用外设别名重叠。比如项目名是“env_monitor”,别名就写成envmon0 = &envmon0。并且别名前缀要在同一个驱动里全局唯一,不同驱动之间也不能共用。

4.2 probe顺序和aliases编号不是一回事

即使有了aliases,也不要觉得“编号小的设备一定先probe”。I2C设备驱动的probe顺序,取决于I2C总线扫描、设备树节点顺序和驱动注册时机的综合结果。我在调试时见过temp1先probe、temp0后probe的情况。所以设备节点名称虽然稳定,但驱动的加载日志顺序可能和编号不一致。

这不影响功能,因为每个实例的编号来自设备树,而不是来自probe顺序。但如果你在驱动里写了“数组第一个元素是0号设备”之类的假设,就很危险。全部数据访问都以priv->id为准,不要依赖probe执行顺序。

4.3 设备节点的自动创建和删除

使用device_create(temp_sensor_class, NULL, dev, priv, "temp_sensor%d", idx)之后,只要内核开启了devtmpfs(瑞芯微SDK默认开启),/dev下的节点就会自动出现,不需要手动mknod。但这里有个细节:device_create的第四参数是私有数据指针,会挂到设备结构体的driver_data里。我建议每次都传入priv,这样驱动在remove时,可以通过dev_get_drvdata或直接从priv->dev的driver_data反查,避免再维护一个从设备号到priv的映射。

配套的remove函数这样写:

static int temp_sensor_remove(struct platform_device *pdev) { struct temp_sensor_priv *priv = platform_get_drvdata(pdev); if (priv) { device_destroy(temp_sensor_class, MKDEV(temp_major, priv->id)); cdev_del(&priv->cdev); } return 0; }

顺序很重要:先device_destroy销毁用户空间可见节点,再cdev_del注销内核字符设备。反过来的话,设备节点虽然没了,但内核对象还被引用,可能在并发open的瞬间出现访问越界。

4.4 多实例数据不是“多份全局变量”

有些驱动写着写着,会把每个实例的数据又汇总到一个全局数组里,方便“统一管理”。这个习惯能理解,但很危险。多实例驱动的高并发访问下,全局数组一旦没加锁,就会产生数据竞争;加了锁又会在某个实例被remove后留下空洞索引。我见过一个驱动,8路设备支持,某路设备掉线后,用户空间写第4路,结果写到了内存里的隔壁单元。

正确做法是:每个实例的开销数据全放在priv结构体里,通过devm_kzalloc分配,生命周期跟随device。跨实例共享的资源另行设计,使用引用计数或mutex保护,不要和实例私有数据混在一起。

此外还有个细节:多实例的file_operations是共享的静态结构体,不需要每个实例复制一份。open函数每个实例被打开时,通过的inode不同,container_of取回的priv自然不同。

5. 验证与调试:怎么确定两个技巧真的生效了

5.1 用系统节点确认主次设备号

驱动加载成功后,先查/proc/devices

cat /proc/devices | grep temp_sensor

能看到temp_sensor和它对应的主设备号,说明alloc_chrdev_region成功。

然后查看设备节点:

ls -l /dev/temp_sensor*

正常情况下能看到:

crw------- 1 root root 240, 0 Jan 1 00:00 temp_sensor0 crw------- 1 root root 240, 1 Jan 1 00:00 temp_sensor1

后面逗号分隔的两个数字,前一个是主设备号,后一个是次设备号。看到次设备号和设备树里的alias编号一一对应,就说明设备号分配正确。

5.2 写入读取验证实例隔离

用一组简单命令验证每个实例是否真的只操作自己的寄存器:

echo 25 > /dev/temp_sensor0 cat /dev/temp_sensor0

再操作1号设备:

echo 30 > /dev/temp_sensor1 cat /dev/temp_sensor1

如果两个设备读出来的校准值互不影响,说明open函数中container_of取回的priv确实是各自独立的。如果想看得更直观,可以在read/write函数里加入:

dev_info(priv->dev, "read temp from instance %d\n", priv->id);

这样每次访问,dmesg里会打印出实例编号,能直接确认访问路径。

5.3 模拟设备树缺失alias的场景

有条件的话,可以故意删掉设备树aliases中的一个条目,测试驱动的容错路径。我在代码里写的是if (dev_id < 0) return -EINVAL;,这样设备树配错时会直接probe失败,而不是静默地退到编号0,掩盖问题。生产环境强烈建议这样处理,宁可启动时报错,也不要让多个设备抢同一个编号。

6. 从两个技巧到一套驱动骨架

这两个技巧单独看都不难,但组合起来就是一套完整的多设备驱动骨架:

  • 设备树通过compatible匹配驱动,通过aliases给每个设备实例定义稳定编号。
  • 驱动probe函数里,用of_alias_get_id拿到实例ID,用platform_get_resource或i2c的读取函数初始化硬件。
  • 每个probe实例创建自己的cdev和/dev节点,节点名带上实例ID。
  • open函数用container_of从inode拿到priv,用户空间的每一次访问都精确落到唯一硬件实例上。

在瑞芯微RK3568上,这套骨架可以套用到绝大多数外设驱动里。我后来在同一个项目里又扩展了四路继电器控制、一路风扇转速检测,全部沿用这个模式,唯一的改动就是新增设备树节点和alias,驱动代码一行没动。

最后说一个亲测有用的习惯:给设备节点命名时,尽量把设备类型和实例ID写清楚,比如temp_sensor0、rs485_1、relay_3,而不是笼统的temp0、ser1。否则板子多了,用户空间脚本按设备名操作时很容易对错号。Linux驱动开发的很多问题,不是原理多深奥,而是命名和编号这些细节没理顺。设备树alias、次设备号、container_of这套组合,就是帮你把“细节”从代码逻辑里抽离出来,交给内核机制去管理。

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

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

立即咨询