做瑞芯微平台的 Linux 驱动,我遇到最多的需求不是从零写驱动,而是“让一个驱动支持好几个设备”。这个“好几个设备”通常有两层含义:一是同型号设备在系统里出现多份,比如一块板子上挂了两个同型号的 ADC、两个同型号的传感器;二是同一个驱动要兼容不同型号的芯片,比如做显示的时候,一个屏驱动要能同时适配三五块不同厂商的面板。两种场景都能把人逼疯,尤其是当你在瑞芯微的 SDK 里找到一份参考驱动,发现前辈们已经用“复制粘贴大法”写了三个几乎一模一样的文件的时候。
这篇文章我想跟你聊两个我实战里最常用来解决这类问题的技巧。一个是利用驱动模型本身的多实例机制,让一套platform_driver干净地服务多个同型号设备;另一个是用设备树匹配表里的data字段加私有配置表,让一个驱动兼容多个差异很大的芯片型号。两种技巧在 rk3568、rk3588、rv1126 这些常用平台上都能直接用,而且代码量能少一半。适合正在写外设驱动、被多设备兼容问题折磨的嵌入式 Linux 工程师,也适合刚入门想搞懂驱动模型匹配机制的朋友。
1. 先把思路理清楚:一个驱动对应多个设备,本质是什么
很多人在“一个驱动支持多个设备”这件事上卡住,不是因为不会写代码,而是没想明白 Linux 驱动模型的匹配逻辑。我见过有同事把一份驱动复制成三份,然后在三个文件里改不同的寄存器地址,问他为什么这么干,他说“probe 不是每个驱动只执行一次吗?”这个误解不解决,后面怎么写都是别扭的。
1.1 痛点场景:复制粘贴式驱动为什么不能长久
先说个我实际见过的例子。某项目在 rk3568 上同时接了三个同型号的 I2C 触摸屏控制器,分布在不同的 I2C 总线上,中断脚也不一样。最初的驱动代码里,作者用一堆全局变量保存三个设备的寄存器基地址、中断号、上报状态,然后在同一个中断处理函数里写if (index == 0) ... else if (index == 1) ...。刚开始能用,后来改版把屏换成了另一款,结构完全变了,改起来痛不欲生。这种写法最核心的问题,是它把“设备”和“驱动”绑死成一对一,完全无视了 Linux 设备模型天生就支持一对多的事实。
另一个高频场景是产品系列化。同一个主控硬件平台,可能衍生三五个不同配置的产品,有的用 720p 屏幕,有的用 1080p 屏幕,有的屏初始化序列完全不同。如果每一块屏都要写一个独立驱动,维护起来就是灾难:哪天发现一个初始化时序 bug,你得同时改四个文件,还容易漏。这类问题靠“技巧一”解决不了,得靠“技巧二”从架构上规避。
1.2 理解 platform 设备模型:probe 是按设备执行的,不是按驱动执行的
要解决上面两个问题,必须先搞清楚一个核心机制:probe 函数什么时候被调用?答案是:当总线上有设备与驱动匹配时,每匹配上一个设备,就调用一次 probe。也就是说,如果你的系统里有三个同 compatible 的设备树节点,你的 driver 只需要注册一次,probe 会被调用三次。每一次传入的struct platform_device *pdev都指向不同的设备,资源、属性、寄存器地址都不一样。很多人的误区就是把 probe 当成“程序入口”,觉得一个驱动只能对应一个设备,这完全是没吃透模型。
platform 总线的匹配过程,简单说分这么几步。首先看 driver 的id_table(非设备树平台的产物),里面每一项有name和driver_data,匹配的是设备名字字符串。其次看of_match_table,这一项专为设备树设计,匹配的是设备树节点的compatible属性。在瑞芯微平台,绝大多数情况都走第二条路线,因为 SDK 里设备树非常完善,节点上写着compatible = "rockchip,xxx",驱动里声明同名的of_device_id就行了。匹配成功之后,内核就会调用 probe。到这里你应该明白了,一个驱动对应多个同型号设备,靠的不是魔法,就是这套本来就存在的多实例机制,只是你之前可能没有意识到,于是自己用全局变量硬造了一套“多实例”,反而把问题搞复杂了。
1.3 瑞芯微平台驱动的常见组织方式与设备树地位
瑞芯微平台的驱动开发,和纯 x86 或者单片机裸机开发有一个明显差异:设备树(DTS)的地位非常高,几乎所有硬件资源描述都由设备树提供。你在驱动里拿寄存器地址,用platform_get_resource或devm_platform_ioremap_resource,而不是硬编码;拿中断号,用platform_get_irq;拿自定义参数,用device_property_read_u32。这套接口的好处是驱动代码里几乎不会出现“某个硬件具体接在哪儿”的信息,硬件连接变了,改 DTS 就行,驱动不用动。
这也是后面两个技巧能够成立的基础。如果不理解设备树是“描述板级信息的输入源”,你会觉得驱动里到处都是if (board_type == XXX)这种代码,其实都可以通过设备树属性或者匹配表的扩展字段优雅处理。瑞芯微的 SDK 里,Linux 内核版本通常从 4.19 到 5.10 不等,但好消息是device_property_*系列接口和of_device_get_match_data这些 API 在这些版本里都很稳定,代码写出来在不同内核之间迁移成本很低。
2. 技巧一:多个同型号设备,用一套 platform_driver 服务多实例
第一个技巧解决“同型号多个设备”的问题。核心思想很简单:设备树里写多个 compatible 相同的节点,驱动里只写一份 platform_driver,通过每个设备独立的资源信息来区分它们。听起来容易,实际操作里有几个细节必须处理好,否则很容易翻车。
2.1 设备树里如何规划多个节点
假设底板上有两个 I2C ADC 芯片,型号一样,挂在同一条 I2C 总线上,地址不同,中断脚不同。设备树节点怎么排?两个节点都用同一个compatible值,这是关键,因为这样才能让它们匹配到同一个驱动。
&i2c3 { status = "okay"; adc0: adc@48 { compatible = "rockchip,adc-multi-demo"; reg = <0x48>; interrupt-parent = <&gpio1>; interrupts = <RK_PA0 IRQ_TYPE_LEVEL_LOW>; vref-supply = <&vcc_3v3>; label = "adc0"; }; adc1: adc@49 { compatible = "rockchip,adc-multi-demo"; reg = <0x49>; interrupt-parent = <&gpio1>; interrupts = <RK_PA1 IRQ_TYPE_LEVEL_LOW>; vref-supply = <&vcc_3v3>; label = "adc1"; }; };这里有几个设计安排值得解释。reg是 I2C 从设备地址,内核会根据它生成不同的 device name 后缀,系统里会存在类似2-0048和2-0049两个设备目录,互不干扰。interrupts不同,驱动里就能用platform_get_irq分别拿到各自的 irq 号。label是我自定义的属性,用来在应用层区分这俩设备,不是内核必需,但实战中非常有用,比自动生成的设备名直观得多。还有一点:两个节点即使 compatible 相同,地址必须唯一,否则 I2C 子系统在注册时会报地址冲突,这是设备树层面最容易犯的错。
2.2 驱动代码写法:彻底告别全局变量
驱动的骨架只需要一份,probe 会执行两次,必须保证 probe 的代码是无状态的。也就是说,局部变量、动态分配的数据结构、devm 系列函数都安全;全局变量只要不保存“当前设备”特有的信息,也安全。但如果你习惯用static int major; static void __iomem *base;这种方式,就会出大问题:第二次 probe 时,第一次的指针被覆盖,第一个设备就彻底废了。
正确做法是,把每个设备独有的资源全部放进一个私有数据结构,用dev_set_drvdata挂在设备上,之后中断、文件操作函数里都通过container_of拿到这个结构体。看下面的代码示例:
struct adc_demo_dev { struct i2c_client *client; int irq; int gpio_int; struct mutex lock; u32 raw_value; struct device *dev; const char *label; }; static int adc_demo_probe(struct i2c_client *client, const struct i2c_device_id *id) { struct device *dev = &client->dev; struct adc_demo_dev *priv; int ret; priv = devm_kzalloc(dev, sizeof(*priv), GFP_KERNEL); if (!priv) return -ENOMEM; priv->client = client; priv->dev = dev; mutex_init(&priv->lock); priv->irq = platform_get_irq(to_platform_device(dev), 0); if (priv->irq < 0) return priv->irq; ret = device_property_read_string(dev, "label", &priv->label); if (ret) priv->label = "unknown"; dev_info(dev, "probed irq=%d label=%s\n", priv->irq, priv->label); dev_set_drvdata(dev, priv); return 0; }注意platform_get_irq的入参,I2C client 对应的 device 在设备模型里同样挂在 platform 总线上,所以能直接转成 platform_device。不过这里有个更通用的写法,用irq_of_parse_and_map或者直接device_get_irq也行,但platform_get_irq在单 irq 场景下最省事。关键是每个设备有自己的priv,中断来了,比如使用线程化中断:
static irqreturn_t adc_demo_threaded_irq(int irq, void *data) { struct adc_demo_dev *priv = data; mutex_lock(&priv->lock); priv->raw_value = i2c_smbus_read_word_data(priv->client, 0x00); mutex_unlock(&priv->lock); dev_info(priv->dev, "irq handled, raw=%u\n", priv->raw_value); return IRQ_HANDLED; }request_threaded_irq(priv->irq, NULL, adc_demo_threaded_irq, IRQF_TRIGGER_LOW | IRQF_ONESHOT, priv->label, priv);这样注册,data 参数把对应的设备实例带过去了,两个中断怎么触发都不会混。这个套路最大的优势,就是当你系统里挂 5 个同型号设备时,这段代码一行都不用改,probe 自动执行 5 次,每个设备独立工作。
2.3 为什么这样能区分设备:资源与设备实例一一对应
这套方案能成立,本质上是因为内核为每个设备树节点都创建了一个独立的struct device,而probe是每个设备调用一次,platform_get_resource、device_property_*读到的内容都属于当前这个设备。你可以把设备树理解成一份“硬件连接清单”,驱动 probe 的执行,就是对清单里每一项执行一遍“装配流程”。每个设备实例在装配过程中拿到自己的资源,装进自己的私有数据,之后所有操作都只跟这个私有数据交互。两个设备之间的唯一关系,可能只剩下共用同一个probe函数和同一个中断回调函数,这就已经做到职责分离了。
2.4 实战心得:硬件设计上尽量让同类设备资源“可独立描述”
用这个技巧时,有几个硬件设计层面的建议。同类设备的差异尽量体现在设备树可描述的维度上,比如 I2C 地址、中断脚、GPIO 编号、供电脚,都做成可配置。千万不要出现“第二个设备默认接着第一个设备的 INT 脚”这种搞法,也不要把两个设备的复位脚共用同一个 GPIO,否则驱动里第二设备 probe 时gpio_request会失败。我在一个项目里就因为偷懒省了一个 GPIO,导致第二颗芯片无法独立复位,最后只能返工改板。
3. 技巧二:一套驱动兼容多型号芯片,用匹配表 data 字段驱动配置
如果说技巧一是“数量”上的扩展,那技巧二就是“种类”上的扩展。真实产品里,同一个外设接口,今天用这颗芯片,明天因为供货换成另一颗,寄存器定义还完全不一样。你当然可以为每颗芯片写一个驱动,然后用设备树去选择。但更优雅、更好维护的做法,是写一个驱动,通过一张配置表把不同芯片的差异收敛掉。
3.1 核心原理:of_device_id 的 data 字段到底怎么用
设备树匹配表of_device_id有一个很容易被忽略的字段:data。它是个const void *类型,可以指向任意结构体。驱动匹配成功后,通过of_device_get_match_data(dev)就能拿到这个指针。这意味着什么?意味着你可以把“不同型号芯片之间的配置差异”定义成一张静态的表,每个表项对应一种芯片型号,probe 时根据匹配到的型号自动取对应表项,然后初始化。代码里再也不需要一大串if (chip == A) ... else if (chip == B) ...。
看一个典型写法。假设你要兼容两款 I2C 环境传感器,一款温湿度寄存器在 0x00,另一款在 0x10,测量精度也不一样:
struct env_sensor_cfg { const char *chip_name; u32 temp_reg; u32 humi_reg; int resolution_mv; int (*init)(struct device *dev); }; static int init_chip_a(struct device *dev) { regmap_write(dev_get_regmap(dev, NULL), 0x00, 0x80); return 0; } static int init_chip_b(struct device *dev) { regmap_write(dev_get_regmap(dev, NULL), 0x10, 0x7f); return 0; } static const struct env_sensor_cfg chip_a_cfg = { .chip_name = "aht30", .temp_reg = 0x00, .humi_reg = 0x01, .resolution_mv = 10, .init = init_chip_a, }; static const struct env_sensor_cfg chip_b_cfg = { .chip_name = "sht40", .temp_reg = 0x10, .humi_reg = 0x11, .resolution_mv = 5, .init = init_chip_b, }; static const struct of_device_id env_sensor_of_match[] = { { .compatible = "vendor,env-sensor-a", .data = &chip_a_cfg }, { .compatible = "vendor,env-sensor-b", .data = &chip_b_cfg }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, env_sensor_of_match);probe 里这样取配置:
static int env_sensor_probe(struct i2c_client *client, const struct i2c_device_id *id) { const struct env_sensor_cfg *cfg; struct device *dev = &client->dev; cfg = of_device_get_match_data(dev); if (!cfg) return -ENODEV; if (cfg->init) cfg->init(dev); dev_info(dev, "probed as %s, temp_reg=0x%02x\n", cfg->chip_name, cfg->temp_reg); return 0; }这套东西的关键在于:配置表是静态的、只读的,驱动本身不关心你实际接的是哪颗芯片,它只管“你给我哪个配置,我就按哪个配置工作”。新增芯片时,你只需要在设备树写一个 compatible 匹配项,然后往表里塞一个结构体,编译一次,完事。
3.2 不是所有差异都要消灭,找准“配置维度”才是重点
很多人第一次用配置表时容易走入另一个极端:把所有寄存器都放到表里,结构体里塞了 30 个字段,最后配置表比驱动逻辑还难读。我自己的经验是,配置表适合放“型号之间的静态差异”,比如寄存器地址、初始化序列指针、采样精度、默认阈值、超时时间。至于那些跟硬件连接直接相关的参数(中断号、GPIO、供电电压、时钟频率),应该尽量归入设备树,让板级工程师去填,驱动里用device_property_read_u32读取。这样一条很实用的分工原则:跟“这颗芯片本身是什么”相关的放配置表,跟“在这块板上怎么接”相关的放设备树。
举例来说,一块板子上接了某款触摸屏,它的 I2C 地址是 0x38,另一块板子因为地址冲突改成了 0x39,这个 0x39 就不该写进配置表,而是直接写进各自板子的 DTS 的reg属性里。驱动里用client->addr读到的自然是对的。把这两类信息混在一起,是配置表架构最常见的烂尾原因。
3.3 瑞芯微平台上的典型应用:兼容多版本屏幕初始化序列
在瑞芯微平台,配置表思路最典型的使用场景是 DRM 驱动里的屏幕兼容。面板厂商给的初始化序列经常因为玻璃版本、IC 版本不同而有差异,如果你在设备树里针对不同屏幕写了不同的compatible,驱动里用of_device_get_match_data取到一个struct panel_desc,这个结构体里携带初始化命令数组、时序参数、供电时序,就能优雅地管理。瑞芯微自己的panel-simple驱动就是这样设计的,可以说它就是这套技巧的一个官方级范本。
不过这种框架下有个特别容易犯的错:设备树的compatible必须和of_match_table里的完全一致,包括厂商前缀。你写"vendor,panel-720p",驱动里写"vendor,panel-720p",一个字符都不能差。很多坑都出在复制粘贴的时候把逗号后面的空格也带进去了,或者大小写不一致,导致匹配失败,probe 根本不执行。排查这一类问题,/sys/bus/platform/devices/.../uevent里会暴露模块的OF_COMPATIBLE,一看便知。
3.4 如果平台进程不使用设备树:platform_device_id 的替代方案
有些驱动可能还需要兼容老平台,内核没开启设备树,或者设备树的匹配被降级。这时of_device_id就不生效了,需要用platform_device_id表,它的driver_data字段同样可以携带一个枚举值或者指针。写法上大同小异:
static const struct platform_device_id env_sensor_id_table[] = { { "env-sensor-a", (kernel_ulong_t)&chip_a_cfg }, { "env-sensor-b", (kernel_ulong_t)&chip_b_cfg }, { }, }; MODULE_DEVICE_TABLE(platform, env_sensor_id_table);在 probe 里,判断传入的id是否为 NULL,然后id->driver_data取配置。但现在的瑞芯微平台基本都是设备树路线,这套老 API 主要用于兼容旧的板级文件,我不建议新代码把精力花在这上面,了解有这个东西就行。
4. 实操演练:rk3568 平台上让一个 I2C 驱动同时管理两个不同型号设备
技巧归技巧,把两个技巧真正组合起来用一个实际工程,才能体会到威力。我以一个非常贴近日常需求的项目为例子:rk3568 的 I2C2 总线上挂了两颗温度传感器,一颗是 A 型号(寄存器地址表不同),一颗是 B 型号(探测逻辑不同),它们同时工作,应用层希望分别读到各自的温度值。整包代码把多实例和配置表两个技巧都用上。
4.1 工程架构建模:一个数据结构拆成“配置表 + 运行时数据”两部分
设计思路:配置表(const struct temp_sensor_cfg)描述型号差异,运行时数据(struct temp_sensor_dev)描述具体设备状态。这样既支持同型号多颗,也支持多种型号混挂。整体目录结构可以保持简洁:
drivers/iio/temperature/my_temp_sensor/ ├── my_temp_sensor.c ├── my_temp_sensor_cfg.c └── my_temp_sensor.h头文件里定义公共数据结构。这里我故意把cfg放在结构体首部,方便后续用container_of从 cfg 强制转运行时结构体,不过正常的工程不建议依赖这种布局,我只是为了展示灵活性。
#ifndef _MY_TEMP_SENSOR_H_ #define _MY_TEMP_SENSOR_H_ struct temp_sensor_cfg { u8 temp_reg; u8 sign_bit; int default_offset; const char *compat_name; }; struct temp_sensor_dev { struct i2c_client *client; struct device *dev; const struct temp_sensor_cfg *cfg; struct mutex lock; int temperature; }; #endif两个型号的配置表写到my_temp_sensor_cfg.c:
#include "my_temp_sensor.h" static const struct temp_sensor_cfg cfg_model_a = { .temp_reg = 0x00, .sign_bit = 12, .default_offset = -500, .compat_name = "vendor,temp-sensor-a", }; static const struct temp_sensor_cfg cfg_model_b = { .temp_reg = 0x20, .sign_bit = 8, .default_offset = 0, .compat_name = "vendor,temp-sensor-b", }; const struct temp_sensor_cfg *temp_sensor_cfg_lookup(const char *compat) { if (!strcmp(compat, "vendor,temp-sensor-a")) return &cfg_model_a; if (!strcmp(compat, "vendor,temp-sensor-b")) return &cfg_model_b; return NULL; }直接用字符串查表是最低门槛的做法,简单、直观,也便于新工程师理解。实践里我会直接把这个查表逻辑改成of_match_table + of_device_get_match_data,更符合内核习惯。下面两个版本的驱动都给你参考。
4.2 驱动主体代码:把多实例和配置表串起来
主驱动文件my_temp_sensor.c的关键代码我拆成几块说明。先是匹配表:
static const struct of_device_id my_temp_sensor_of_match[] = { { .compatible = "vendor,temp-sensor-a", .data = &cfg_model_a }, { .compatible = "vendor,temp-sensor-b", .data = &cfg_model_b }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, my_temp_sensor_of_match);然后是 probe 函数。这个函数既会被型号 A 的节点触发,也会被型号 B 的节点触发,还会被两颗同型号的节点各触发一次。每一次执行都独立分配私有数据,独立读取自己的硬件信息。
static int my_temp_sensor_probe(struct i2c_client *client) { struct device *dev = &client->dev; const struct temp_sensor_cfg *cfg; struct temp_sensor_dev *sensor; int ret; cfg = of_device_get_match_data(dev); if (!cfg) return -ENODEV; sensor = devm_kzalloc(dev, sizeof(*sensor), GFP_KERNEL); if (!sensor) return -ENOMEM; sensor->client = client; sensor->dev = dev; sensor->cfg = cfg; mutex_init(&sensor->lock); // 验证芯片 ID,避免驱动绑定到不支持的设备 ret = i2c_smbus_read_byte_data(client, cfg->temp_reg); if (ret < 0) { dev_err(dev, "failed to read temp reg 0x%02x, ret=%d\n", cfg->temp_reg, ret); return -ENODEV; } dev_info(dev, "temp sensor probed, model=%s, temp_reg=0x%02x\n", cfg->compat_name, cfg->temp_reg); i2c_set_clientdata(client, sensor); return 0; }注意这里我加了一个“读寄存器验证”的步骤。为什么要这么做?因为设备树里的 compatible 是软件约定的,万一板级工程师写错了节点,驱动却照样 probe 成功,最后运行时读数据全是错的,排查成本很高。在 probe 里做一次最小读取验证,能尽早暴露硬件连接错误或者 compatible 指向错误。另一个好处是,如果总线上同时挂了 A、B 两种型号,但设备树里给 B 写成了 A 的 compatible,probe 时读取 B 的 0x00 寄存器,很可能返回异常值,驱动直接返回-ENODEV,不会进入后续的死循环重试。
读取温度的接口:
static int my_temp_sensor_read_temp(struct temp_sensor_dev *sensor, int *temp) { int raw; int val; mutex_lock(&sensor->lock); raw = i2c_smbus_read_word_data(sensor->client, sensor->cfg->temp_reg); mutex_unlock(&sensor->lock); if (raw < 0) return raw; // 根据型号的符号位宽度和偏移量计算温度值 val = raw & BIT_MASK(sensor->cfg->sign_bit); if (val & BIT(sensor->cfg->sign_bit - 1)) val -= BIT(sensor->cfg->sign_bit); *temp = val * 10 + sensor->cfg->default_offset; return 0; }为了让用户空间能读数据,我把它做成一个极简的 IIO 驱动或者 misc 设备。假如用 misc 设备,需要注意设备号分配:每个实例用misc_register时指定不同的 name,内核会自动分配次设备号。多个实例共用一套 file_operations,在open里从file->private_data拿到sensor结构体,之后读写都靠它,完全不需要全局状态。这样两个设备都会有独立的/dev/my_temp_sensor0、/dev/my_temp_sensor1节点,应用层根据节点名区分就行。
4.3 设备树写法:让两个型号共存
在 rk3568 的设备树里,I2C2 节点下面挂两颗不同类型的传感器,写法是这样的:
&i2c2 { status = "okay"; clock-frequency = <400000>; temp_sensor_a: temp-sensor@18 { compatible = "vendor,temp-sensor-a"; reg = <0x18>; status = "okay"; }; temp_sensor_b: temp-sensor@28 { compatible = "vendor,temp-sensor-b"; reg = <0x28>; status = "okay"; }; };两个节点 compatible 不同,在驱动里匹配到的配置自然不同。这里的reg是芯片的 I2C 地址,必须跟硬件实际跳线一致。有个细节:瑞芯微的 I2C 设备树节点里通常继承pinctrl-0等属性,不需要你手动加,只要在&i2c2下面新增子节点即可。编译内核时,确保CONFIG_I2C_CHARDEV或者你的驱动编译成模块,然后用insmod my_temp_sensor.ko加载。
加载后看内核日志,你会看到类似这样的输出:
my_temp_sensor: temp sensor probed, model=vendor,temp-sensor-a, temp_reg=0x00 my_temp_sensor: temp sensor probed, model=vendor,temp-sensor-b, temp_reg=0x20两次 probe 证明多实例机制正常工作。如果只打印了一次,说明有一个节点没有匹配上,检查两个途径:设备树编译是否正确(/proc/device-tree/i2c@fe820000/temp-sensor@18/compatible文件内容对不对)以及驱动有没有被正确加载到内核。
4.4 上层验证:应用里同时读两个设备
写一个简单的 C 程序读取两个设备节点的温度,流程就是标准的 open + read。但有两个实践细节值得提一下:一类是“到底用哪个节点”,我建议把设备名固定成带方向的名字,比如/dev/temp-sensor-a、/dev/temp-sensor-b,而不是依赖系统自动按注册顺序编号的my_temp_sensor0/my_temp_sensor1,否则插拔顺序一变,设备名就错位。二类是把温度读取延后到用户主动访问时再执行,不要用内核线程周期轮询,除非你有实时性要求。原因很简单,内核线程轮询大多数时候读到的数据没人在用,白耗 CPU 和 I2C 总线带宽;用户态应用需要时才读,反而更省资源。
5. 常见问题与排查技巧实录
两个技巧在实际项目中用久了,翻车点其实有比较强的规律性。我整理了几个高频故障,每个都按“现象、原因、排查、解决”四步讲清楚。这张速查表建议直接收藏,遇到问题先过一遍。
| 故障现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 两个设备只 probe 了一次 | 设备树里有个节点status = "disabled" | 检查/proc/device-tree/.../status的值 | 改为"okay" |
| probe 一直不执行 | compatible 不匹配 | 查看/sys/bus/i2c/devices/*/uevent里的OF_COMPATIBLE | 统一 compatible 字符串 |
| probe 执行两次但第二个设备无法访问 | I2C 地址冲突 | i2cdetect -y <bus>看地址占用情况 | 修改硬件地址或设备树 reg |
| 中断线程读到的数据总是第一个设备的 | 中断回调里没有正确使用传入的 priv | 打印 dev_info(priv->dev, ...) 确认 | 统一用 data 参数,别用全局变量 |
| 两个设备互相干扰 | 用了全局变量保存状态 | 审查全局变量 | 移入私有数据结构 |
| 加载模块后系统崩溃 | driver_data 类型转换错误 | 查看 dmesg 的 oops 日志 | 确保 of_match_table 的 data 类型一致 |
5.1 probe 没执行或者只执行一次,怎么快速定位
这个是最常见的问题,也是最容易自我排查的。先看dmesg里有没有驱动注册的打印。驱动注册成功说明 module 加载没问题,接下来怀疑匹配。用ls /sys/bus/i2c/devices/看看 i2c 总线上有哪些设备,比如2-0018、2-0028这种,代表总线上确实有设备挂上去了。进到对应目录,比如/sys/bus/i2c/devices/2-0018/,cat uevent,看OF_COMPATIBLE=...的值。这个值必须和驱动of_match_table里的 compatible 一模一样。万一这里什么都不显示,说明这个设备不是由设备树创建的,很可能是另一个驱动在同一地址上先抢注了,这时i2cdetect -l和i2cdetect -y 2马上能看出端倪。
如果只有一个设备节点挂上来了,另一个没出现,先检查设备树语法和编译结果。瑞芯微的 SDK 里通常有编译 DTB 的脚本,比如./make.sh dtb,编出来的 dtb 路径一般在kernel/arch/arm64/boot/dts/rockchip/下。用dtc反编译看一下节点是否完整,status是不是"okay"。很多时候是板级工程师在别的 dtsi 里覆盖了status = "disabled",导致你加的子节点被屏蔽。
5.2 两个设备都申请同一个中断号,怎么避免冲突
如果两块芯片的中断都接到了同一个 GPIO 上,且硬件上没有做或逻辑合并,驱动里第二个设备的request_irq会失败。这种事我碰过好几次,处理方式分两种情况。第一种,硬件上两个设备的中断确实必须分开,那就改板,把第二个设备的中断换到另一个 GPIO,这类问题必须在硬件设计阶段规避;第二种,两个设备共用一根中断线,驱动里可以只让第一个设备注册中断,第二个设备不注册,但中断服务函数需要遍历两个设备,通过读取设备寄存器判断是哪个设备触发的。这种共享中断写法在驱动模型里属于“一对多共用 handler”模式,注意要用IRQF_SHARED,并且中断处理函数必须正确判断“不是我的中断”时返回IRQ_NONE。瑞芯微平台上的 GPIO 作为中断源时,interrupts属性最好写清楚触发类型,比如IRQ_TYPE_LEVEL_LOW,否则很容易进中断风暴。
5.3 配置表类型不匹配导致内核崩溃,这类 bug 怎么防
of_device_get_match_data返回的是const void *,如果你在某个 compatible 表项里填了一个结构体指针,另一个表项里填了一个整型值,probe 函数却统一按结构体指针解引用,内核会立刻报Unable to handle kernel paging request,页面变成 oops。这种问题靠读代码基本很难一眼看出,最好的防御手段是写一个编译期的类型安全检查辅助宏。内核里有个现成做法:#define of_match_ptr(_ptr) (_ptr)这个宏不能保证类型,但手写一个可以。更朴素的办法是,保证of_match_table的表项清一色都填“指向配置结构体的指针”,不要混用。我见过有同事偷懒,某个表项直接写(kernel_ulong_t)0x1234表示版本号,结果 probe 里直接cfg->xxx崩了。记住一条:data字段永远填指针,其他值都用device_property_read_*走设备树。类型统一,靠约定保证长期可维护。
5.4 宝贵教训:瑞芯微平台上的 pinctrl 冲突
这个坑比较有平台特色。瑞芯微的芯片,GPIO 复用关系非常严格,两个设备如果引用了同一个 pin,设备树里描述 pinctrl 的时候也很容易踩坑。比如某颗传感器用了 I2C2 的 SCL/SDA,另一个传感器想复用 SCL/SDA 做普通 GPIO 读取,这个在硬件上就不成立,dts 里写了也会在 pinctrl 子系统注册时报警告。你会看到类似pin ... already requested by ...的日志,然后第二个设备 probe 失败。这类问题,设备树里检查pinctrl-0属性,把所有引用了同一个 pin 的地方列出来,看有没有冲突。更重要的是,这种问题要在画原理图的时候想清楚,软件排查只能发现问题,不能解决硬件错误,最终还得改板。
6. 写在最后的经验体会:这两个技巧是你驱动架构的地基
两个技巧单独看都不复杂,但组合使用以后,驱动架构会变得非常干净。设备树描述“这块板上有什么”,配置表描述“这颗芯片是什么样的”,probe 根据匹配到的节点拿到配置,再根据具体资源实例化一个设备状态。新增硬件,要么在设备树加节点,要么往配置表加一项,驱动代码本身几乎不动。这套模式我用了好几年,从瑞芯微的 3288 到现在的 3588,一直没变过。
还是想提醒一句,驱动开发里最怕的不是写功能,而是写“看似能用、一换板就死”的代码。全局变量、复制粘贴驱动、hardcode 寄存器地址,这些做法在新人阶段看起来很快,但产品一旦多了,维护成本是几何级上升的。设备树、配置表、私有数据结构、container_of,这些工具内核已经提供了,就看你自己用不用。每次动手写驱动之前,先问一句自己:如果这个驱动要支持两份硬件,我现在的代码还能不能躺赢?如果答案是不能,那多半是设计有问题,趁早重写。