ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

瑞芯微平台Linux驱动:从单设备到多实例的写法

瑞芯微平台Linux驱动:从单设备到多实例的写法 做瑞芯微平台Linux驱动开发的朋友十有八九都会遇到这个问题一个驱动要支持多个同型号设备到底该怎么写我最早是在RK3568的一个项目里被这个问题绊了一跤。板子上挂了两颗I2C温度传感器型号一样地址分别是0x48和0x49放在外壳不同位置。驱动刚写完的时候只写了“一颗传感器”的逻辑传感器0能正常工作等到把设备树加上第二颗节点问题就来了dmesg里只看到一次probe或者两次probe都执行了但中断处理函数里读到的寄存器全是第二颗的第一颗的数据怎么都刷新不了。排查到最后原因特别简单。驱动里用了一个全局结构体每次probe都往同一个变量里填client指针后面进来的设备把前面设备的指针覆盖了。中断回调、属性读取全都从那个全局变量拿数据自然只能操作最后一次probe的设备。这也是Linux驱动支持多个设备时新手最容易踩的坑。其实内核早就为“一个驱动对应多个设备实例”准备了一整套机制。设备模型本身就是按实例设计的每个struct device都应当拥有独立的私有数据。瑞芯微平台用的设备树和标准Linux内核完全一致所以这些方法在其他厂商SoC上同样通用只是树里compatible和资源长度不一样。1. 为什么需要关注多个设备1.1 场景两个设备一份驱动嵌入式Linux项目里多个同型号外设是常态。RK3588开发板上经常看到两路MIPI摄像头、两个HDMI又或者是两路I2C温度传感器。设备树里写两个节点非常容易难的是驱动只写一份却要让内核在两次probe之间不互相干扰。我这里说的“支持多个设备”不是指那种“用一个数组管理一堆设备”的简单模拟而是真正由内核设备模型驱动的每个设备树节点都会触发一次probe每次都拿到独立的struct device驱动需要为它创建独立的私有数据。这件事如果不做对轻则第二个设备识别不到重则中断风暴、内核崩溃。我之前帮同事调过一个RK3399的板子上面两个GT9271触摸屏共同挂在同一个I2C控制器下驱动是从原厂SDK拷出来的。原厂驱动只考虑了单屏里面定义了一个静态的struct gt9271_data *g_data。结果第二个触摸屏一注册前面的数据全乱了触摸坐标在屏幕上乱跳。后来我改成per-instance私有数据问题立刻消失。1.2 多个设备支持容易踩的三个坑先说三个我见过的典型反面写法大家可以对号入座。第一是全局变量满天飞。有人觉得驱动就一个设备把client、irq、buffer全部定义成全局或者用一个静态结构体。两个设备一加载probe被调用两次数据互相覆盖基本必炸。第二是probe里做“第一个设备”的特殊处理。比如if (first) { ... }、用static bool has_init控制只初始化一次的中断、只注册一次的sysfs。这种写法在硬件上如果只存在一颗设备时能跑但一旦接了两颗第二颗大概率不会正常工作。更麻烦的是这类问题不是必现的和硬件探测顺序有关。第三是资源申请后忘记释放或者remove里释放顺序不对。设备0被拔出或解绑时驱动把共享的中断或内存释放了设备1还在用系统直接Oops。用devm_*系列接口能很大程度缓解这个问题后面细说。2. 技巧一用匹配表传递硬件差异2.1 内核是怎么找到你的设备并调用probe的设备树节点和驱动是如何匹配的简单说设备树里每个节点都有一个compatible属性驱动里声明一个of_device_id数组。内核遍历设备树当节点的compatible与数组中某一个字符串相等就让这个驱动的probe处理这个设备。很多人把of_device_id当成“门禁卡”只要能进门就行。但内核还有一个容易被忽略的设计这张卡上其实可以写附带信息也就是of_device_id结构体里的.data字段。匹配成功后驱动可以直接用device_get_match_data(dev)把这个指针取出来用它来区分“这次probe的是哪种型号的硬件”。瑞芯微平台经常遇到“硬件版本不统一”的情况。同一个项目前期贴片用的是A芯片后期因为供货换成了引脚兼容的B芯片寄存器初始化序列略有不同。如果你在驱动里硬编码if (of_machine_is_compatible(...))或者靠设备树属性一个一个判断代码会变得很难维护。正确做法是把差异收敛到struct xxx_config里然后用compatible去选择对应的配置。2.2 用.data字段为每个compatible准备一套配置来看一个我常用的写法。假设有两颗芯片分别是my-temp-a和my-temp-b它们的寄存器地址不同、默认阈值不同部分版本没有ALERT引脚。enum temp_chip_type { TEMP_TYPE_A 0, TEMP_TYPE_B, }; struct temp_sensor_config { enum temp_chip_type type; int default_threshold; int has_alert_pin; u8 temp_reg; u8 config_reg; }; static const struct temp_sensor_config temp_a_config { .type TEMP_TYPE_A, .default_threshold 75, .has_alert_pin 1, .temp_reg 0x00, .config_reg 0x01, }; static const struct temp_sensor_config temp_b_config { .type TEMP_TYPE_B, .default_threshold 65, .has_alert_pin 0, .temp_reg 0x02, .config_reg 0x03, }; static const struct of_device_id temp_sensor_of_match[] { { .compatible rockchip,my-temp-a, .data temp_a_config }, { .compatible rockchip,my-temp-b, .data temp_b_config }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, temp_sensor_of_match);在probe里是这样取的const struct temp_sensor_config *cfg; cfg device_get_match_data(dev); if (!cfg) return -EINVAL; dev_info(dev, chip type %d, threshold %d, alert %s\n, cfg-type, cfg-default_threshold, cfg-has_alert_pin ? yes : no);这样写有一个很直接的好处将来硬件第三次改版你只需要增加一个temp_c_config结构体并在of_device_id数组里加一行{ .compatible rockchip,my-temp-c, .data temp_c_config }probe函数完全不用动。比在probe里堆一长串of_property_read_bool清爽得多。2.3 设备树里怎么配合设备树侧需要把两个节点分别写成对应的compatiblei2c0 { status okay; temp_a: temperature48 { compatible rockchip,my-temp-a; reg 0x48; interrupt-parent gpio1; interrupts RK_PA0 IRQ_TYPE_LEVEL_LOW; }; temp_b: temperature49 { compatible rockchip,my-temp-b; reg 0x49; interrupt-parent gpio1; interrupts RK_PA1 IRQ_TYPE_LEVEL_LOW; }; };注意compatible字符串要完全一致包括厂商前缀和大小写。瑞芯微平台在build内核时设备树一般是和内核分开编译的如果改了dts却没有重新编译dtb驱动自然匹配不到。我遇到过好几次这个低级错误驱动代码改了、模块重新加载了但设备树还是旧的。这里还有个细节。MODULE_DEVICE_TABLE(of, ...)不只是用来给modpost生成模块别名它也会让内核在模块自动加载时知道“这个模块支持哪些compatible”。如果忘了写这一行手动insmod还能加载但基于udev/modprobe的自动加载就找不到驱动。2.4 参数从哪来怎么管理有人会问这些配置数值是从芯片数据手册里抄吗对一个是数据手册另一个是硬件工程师给的BSP包。比如瑞芯微官方SDK里有些外设驱动会把不同物料型号的配置定义在同一个头文件里。但我的建议是如果配置项超过五六个就不要光靠.data指向一个结构体而是可以在结构体里再放一个struct reg_sequence *init_seq把不同芯片的初始化寄存器序列都整理成表probe里循环写一遍。这种做法的本质是把“驱动逻辑”和“硬件差异”解耦。probe函数永远只做几件事拿配置、分配私有数据、初始化硬件、注册子设备。至于硬件差异全部藏在配置表里。这样一来支持多个设备就不再是简单的代码复制而是数据驱动的设计。3. 技巧二per-instance私有数据 devm_*资源管理3.1 为什么不能用全局变量技巧一解决的是“不同硬件型号”怎么区分接下来要解决的是“同型号多个实例”怎么隔离。前面说过全局变量在多个设备同时probe时一定会出问题。内核设备模型的正确做法是为每个设备分配一份独立的私有数据通常是一个struct xxx_data里面保存这个设备自己的client指针、中断号、工作队列、regmap、锁等。打个比方一家公司每个员工都有自己的工位、电脑和工牌。如果你让所有员工公用一本通讯录上的同一行那今天A改了电话号码B的电话就跟着变了。私有数据就是给每个设备一张独立的工位牌同一个驱动模板但数据彼此隔离。在probe里直接用devm_kzalloc分配私有数据。这个函数分配的内存好处是设备被移除时自动释放不需要在remove里手动kfree而且即使probe在中途失败前面已经分配的资源也会自动释放。3.2 devm_*资源管理为什么适合多设备devm_前缀是“managed device resource”的意思。除了devm_kzalloc还有devm_request_irq、devm_regmap_init_i2c、devm_gpiod_get、devm_clk_get等。它们都有一个共同点把资源的生命周期绑定到struct device上设备销毁时内核自动按申请顺序释放。这对多个设备的场景来说非常香。设备0被解绑内核只释放设备0自己申请的那份资源设备1还在正常运行互不干扰。如果用传统request_irqkfreeremove里稍不留神就可能释放了别的设备还在用的资源尤其是中断共享和class_destroy这类全局资源。另外多个设备如果使用同一个中断号申请时要注意IRQF_SHARED。不过嵌入式里更多是每个设备有独立GPIO中断所以问题不大。重点是每个设备的中断处理数据都通过request_irq的最后一个参数dev_id传入回调函数拿到的就是本设备的私有数据天然不会混淆。3.3 私有数据结构的典型模板一个典型的多实例I2C传感器驱动probe大概是这样的struct my_sensor_data { struct i2c_client *client; const struct my_sensor_config *cfg; struct regmap *regmap; int irq; int temperature; }; static int my_sensor_probe(struct i2c_client *client, const struct i2c_device_id *id) { struct device *dev client-dev; struct my_sensor_data *data; const struct my_sensor_config *cfg; cfg device_get_match_data(dev); if (!cfg) cfg (const struct my_sensor_config *)id-driver_data; data devm_kzalloc(dev, sizeof(*data), GFP_KERNEL); if (!data) return -ENOMEM; >static irqreturn_t my_sensor_irq_handler(int irq, void *dev_id) { struct my_sensor_data *data dev_id; /* 这里读写data-client不会跑到另一个设备上 */ return IRQ_HANDLED; }sysfs属性文件回调里一般能通过struct device *dev拿到设备然后转成i2c_clientstatic ssize_t temp_show(struct device *dev, struct device_attribute *attr, char *buf) { struct i2c_client *client to_i2c_client(dev); struct my_sensor_data *data i2c_get_clientdata(client); return sysfs_emit(buf, %d\n,>static void my_sensor_remove(struct i2c_client *client) { struct my_sensor_data *data i2c_get_clientdata(client); /* 如果不需要关闭硬件直接留空即可 */ }注意新版内核更推荐void (*remove)(struct i2c_client *)这种签名。瑞芯微旧SDK可能会看到int remove但新代码里已经改掉了。4. 实操瑞芯微平台上同一驱动挂接两路I2C传感器4.1 硬件接线和设备树这一节我们做一个完整的例子RK3588的I2C2总线上挂两片相同的温度传感器地址分别是0x48和0x49各接一个GPIO中断。注意如果两片芯片的I2C地址完全相同硬件上就必须通过A0/A1引脚错开否则设备树里两个节点的reg一样I2C核心会直接报地址冲突。设备树片段如下i2c2 { status okay; pinctrl-names default; pinctrl-0 i2c2_xfer; temp0: temperature48 { compatible rockchip,my-temp-sensor; reg 0x48; interrupt-parent gpio1; interrupts RK_PA4 IRQ_TYPE_LEVEL_LOW; }; temp1: temperature49 { compatible rockchip,my-temp-sensor; reg 0x49; interrupt-parent gpio1; interrupts RK_PA5 IRQ_TYPE_LEVEL_LOW; }; };注意GPIO宏RK_PA4等在不同瑞芯微内核版本里可能定义不一致实际使用前先确认SDK里的宏。IRQ_TYPE_LEVEL_LOW表示低电平触发这个要和传感器ALERT引脚的开漏特性匹配通常会用低电平触发并在驱动里用线程化中断读取。4.2 驱动实现解析为了让例子完整我们把前面两个技巧都用上。先用of_device_id的.data指向一份默认配置但因为是同型号设备两个节点compatible相同probe会被调用两次每次都会生成独立的struct my_sensor_data。static const struct of_device_id my_temp_of_match[] { { .compatible rockchip,my-temp-sensor, .data my_temp_default_cfg }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, my_temp_of_match); static const struct i2c_device_id my_temp_id[] { { my-temp-sensor, 0 }, { } }; MODULE_DEVICE_TABLE(i2c, my_temp_id);完整probe函数我们上面已经写过这里再补充一个利用regmap读写寄存器并向上层注册hwmon的简化例子。注册hwmon后用户空间可以直接用sensors命令看到温度这对验证多个设备是否同时工作非常方便。static int my_temp_probe(struct i2c_client *client, const struct i2c_device_id *id) { struct device *dev client-dev; struct my_temp_data *data; int ret; data devm_kzalloc(dev, sizeof(*data), GFP_KERNEL); if (!data) return -ENOMEM; >export ARCHarm64 export CROSS_COMPILEaarch64-linux-gnu- make menuconfig # 打开对应驱动选项或者直接模块编译 make drivers/misc/my_temp.ko把my_temp.ko推到板子上后insmod my_temp.ko dmesg | grep my_temp如果两个设备都probe成功你会看到类似下面的输出my_temp 2-0048: hwmon device registered my_temp 2-0049: hwmon device registered2-0048表示i2c总线2上的地址0x48设备2-0049同理。看到两行说明驱动已经被调用两次且互不干扰。再通过sysfs验证cat /sys/class/hwmon/hwmon*/name cat /sys/class/hwmon/hwmon*/temp1_input或者直接用i2cdetect -y 2看总线上的设备地址再用i2cget -y 2 0x48 0x00读寄存器判断硬件是否正常。4.4 实际操作中我还会做的检查每次验证完我经常顺手做两个操作。一个是cat /proc/interrupts | grep my_temp确认两个中断号都有计数增长否则说明中断没有触发或者共享配置有问题。另一个是反复rmmod和insmod确认多次加载卸载后dmesg不会出现use-after-free、栈回溯之类的报错。多设备驱动最怕的就是释放顺序问题devm_*能兜底但驱动里如果有自己创建的内核线程或者class那还是要仔细处理。5. 常见问题排查与工程建议5.1 设备树匹配不到probe不执行这类问题占了瑞芯微平台驱动调试的一大半。先看系统起来后有没有生成设备节点在/sys/bus/i2c/devices/下能不能看到2-0048。如果根本没有说明设备树没生效。检查几个点父节点I2C控制器status okay不只是子节点。compatible是否与驱动of_device_id完全一致注意rockchip,前缀和逗号。重新编译dtb后是否真的烧录到板子上RK3588通常用resource.img或单独dtb分区。驱动里的MODULE_DEVICE_TABLE(of, ...)有没有写模块有没有正确安装到/lib/modules/...。如果设备树节点有但驱动没有probe可以在内核启动cmdline加dyndbgfile drivers/i2c/i2c-core-base.c p或者看dmesg里i2c核心输出的of_i2c_notify相关日志。我遇到过一种情况节点里compatible正确但reg地址和另一个已经占用的设备重复I2C核心会在注册子设备时直接报failed to add device。5.2 多个设备资源冲突两个设备共享同一份全局资源时必须加同步。最典型的是使用同一个外设控制器比如两个设备挂在同一个SPI总线上驱动里如果直接操作spi_message必须用mutex保护。I2C本身每次访问是原子的但如果你的驱动用了一个共享的gpio_chip或者共用的regmap也要考虑并发。GPIO中断冲突要提一下。瑞芯微的GPIO中断控制器按bank管理两个设备如果用了同一个GPIO引脚设备树里就会有两个节点引用同一个中断。这样的问题不是驱动能解决的必须改硬件或改pinmux。查看是否冲突可以用cat /sys/kernel/debug/gpio确认每个GPIO的占用状态。还有一处容易忽略多个设备如果都注册了同名sysfs属性或者都在/dev下创建同名设备节点会导致第二个设备注册失败。所以命名里一定要带上总线和地址比如my_temp_2_0048或者依赖内核自动分配的hwmon编号不要在代码里写死设备名。5.3 调试多个设备的小工具和方法调试多设备实例时dev_dbg比printk好用。因为dev_dbg会自动带上设备名和地址日志里能直接区分是哪个实例。比如dev_dbg(dev, read temp: %d\n, temp);输出会变成my_temp 2-0049: read temp: 28一眼就能看出是哪个设备。如果要用I2C工具定位硬件问题i2cdetect -y bus扫描地址i2cdump -y bus addr看寄存器i2ctransfer可以做更复杂的读写。瑞芯微平台GPIO调试用cat /sys/kernel/debug/gpio和cat /proc/interrupts。另外打开内核的CONFIG_DYNAMIC_DEBUG后可以这样单独打开某个文件的调试输出echo file drivers/misc/my_temp.c p /sys/kernel/debug/dynamic_debug/control这会比重新编译内核快很多。5.4 代码组织经验最后聊一点组织代码的体会。多设备驱动其实不需要写得多花哨只要记住几条原则每个实例一个私有结构体probe里只用devm_*申请资源硬件差异用of_device_id的data传递所有回调函数都从各自设备拿回私有数据。我在瑞芯微平台上review驱动时看到全局变量和静态结构体会立刻警觉大多数情况下都会让作者改成per-instance数据结构。这套思路不局限于I2Cplatform设备、SPI设备、MFD子设备都一样。另外驱动里如果用到工作队列建议每个实例都有自己的struct delayed_work和workqueue而不是共用一个全局work否则无法确定回调针对的是哪个设备。中断线程也要避免多个实例共享同一个struct completion。这里还有一个后续可以扩展的点当同一个硬件有多个功能时比如一个芯片既做触摸又做按键可以考虑用MFD框架把设备拆成多个子设备每个子设备对应一个platform_driver父设备的probe只负责初始化I2C和regmap再把子设备注册到总线上。这套流程在瑞芯微的PMIC和编解码器驱动里非常常见只是框架稍重但对于复杂芯片确实是更好的组织方式。我在实际项目里用这两个小技巧改了不下五六个驱动从单温度传感器到四路ADC从双触摸屏到多路PWM背光整体都很稳定。遇到新的多设备需求先把设备树和of_device_id的数据结构敲定再写probe后面基本就是复制一套初始化流程不用再做“每个设备一个驱动文件”的傻事了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进