设备树与平台设备驱动匹配机制:从compatible到probe的完整解析
最近有朋友在调一块新的板卡驱动写完、模块也加载进去了probe 却始终不被调用。他把 dts 反复看了半天也没看出问题最后我让他把驱动里的compatible字符串和设备树里的字符串逐个字符对比才发现差了一截——设备树里写的是acme,board-ctrl驱动表里写的是acme,board-ctrl-v2。就这么个字符级误差整个内核直接当设备不存在。这个场景在平台设备驱动和设备树DTS相关的开发里实在太常见了。平台设备驱动负责管理那些不挂在 USB、PCI、I2C 这类标准总线上、但又真实存在的内建硬件而设备树则是描述这些硬件长什么样、资源在哪、怎么连接的现代方式。这篇文章把两者之间的关系彻底理一遍从设备树节点如何变成 platform_device到 compatible 如何匹配驱动再到 reg、interrupts、clocks 这些资源到底怎么被解析成内核 API最后再聊几个实际项目中容易踩的坑。1. 从板级代码到设备树硬件资源描述方式的转变1.1 老派做法platform_device 是写在代码里的很多人第一次接触设备树时都有一种割裂感以前写驱动就是直接初始化寄存器设备树一出现怎么冒出来一堆.dts、.dtsi、.dtb文件还得搞清楚驱动为什么突然不 probe 了。要理解这种不适感得先看看早期 Linux 平台的硬件描述方式。早期 ARM 平台的典型做法是硬件资源描述直接用 C 代码写死在板级文件里。比如要为某块板子上的一颗外设构造 platform_device你得手动填写 resource 数组static struct resource board_ctrl_resources[] { { .start 0x20000, .end 0x207ff, .flags IORESOURCE_MEM, }, { .start 45, .end 45, .flags IORESOURCE_IRQ, }, }; static struct platform_device board_ctrl_device { .name board-ctrl, .id 0, .resource board_ctrl_resources, .num_resources ARRAY_SIZE(board_ctrl_resources), }; static int __init board_init(void) { platform_device_register(board_ctrl_device); return 0; }代码本身不复杂但问题在于每换一块板子就要改一段板级代码重新编译内核。换一个寄存器地址、改一个中断号都得找到对应板级文件去改再重新生成内核镜像。ARM 生态里芯片型号和板卡型号又多这种做法的维护成本很快失控。这种方式还有一个逻辑上的问题硬件描述和驱动逻辑搅在一起。同一个驱动可能服务多块板卡而每块板卡的资源定义散落在各自不同的板级文件中驱动想要对接靠的是平台设备注册时传入的platform_data指针。数据结构一旦不统一驱动就变得很难复用。1.2 设备树的本质一种可携带的自描述硬件清单设备树Device Tree改变的不是描述硬件这个行为而是把描述从源代码变成了独立数据。完整的链路是这样的DTS 是设备树源文件Device Tree Source由开发者编写通过设备树编译器编译成 DTB 二进制文件Device Tree Blobbootloader 启动内核时把 DTB 放入内存并将地址传给内核内核启动早期解析 DTB把原本零散的硬件信息解析成统一的设备模型。对你来说最直接的变化是硬件资源描述不再参与内核编译而是作为一个独立输入被传递。同一份内核镜像只要 bootloader 传入的 DTB 不同就能适配不同的开发板。改寄存器地址、改中断号通常只需要修改 DTS 再重新生成 DTB没必要重新编译整个内核。这里要澄清一个常见误解设备树不是给驱动提供一个配置文件它是真真实实参与系统启动的二进制结构。内核里有一个完整的设备树解析器会从根节点开始递归遍历所有节点把每个节点转换成对应的设备对象。对于没有总线归属的节点转换结果就是 platform_device。这个过程发生在驱动注册之前。1.3 DTS 到底在描述哪些信息一个设备树节点通常承载四类信息这也是你在阅读 .dtsi 文件时最需要关注的部分层级与归属节点挂在哪个父节点下父节点代表总线和控制器编址规则地址和长度在子节点中如何解释由#address-cells和#size-cells决定资源清单寄存器区域、中断编号、时钟、GPIO、供电等兼容性compatible 厂商,型号这是驱动匹配硬件的关键字段。设备树本质上解决的是硬件资源的描述、传递和管理这三个问题。描述是为了让人和工具看懂传递是为了让 bootloader 和内核解耦管理则是让驱动可以在运行时动态获取资源而不是把一切写死在代码里。2. 平台设备与平台驱动的匹配链路2.1 设备树节点是如何变成 platform_device 的一旦内核拿到 DTB第一件事就是创建设备模型。这个动作通常发生在启动早期的init_machine或者of_platform_populate路径中内核遍历设备树的顶层节点遇到没有特殊总线归属的节点就将其构造为platform_device并挂到 Linux 设备模型的虚拟平台总线上。这个过程不是随便挑节点而是有一套规则。常见的转化对象包括位于 SoC 内部、直接挂在根总线下的控制器比如串口、SPI 控制器、以太网 MAC、SRAM 区域带有compatible属性且没有挂在具体总线控制器下的设备通过simple-bus等兼容标记标识的中间节点其下子节点也会被递归展开。如果你在/sys/bus/platform/devices/下看到一串以base或者虚拟地址命名的设备目录比如20000.board-ctrl那就是设备树节点被转换成平台设备后的产物。这一步说明设备树节点已经成功被内核识别接下来就看驱动能不能匹配上。/sys/bus/platform/devices/ ├── 20000.board-ctrl ├── 18000000.serial └── spi0x180000002.2 compatible 与 of_match_table驱动识别硬件的关键设备已经被识别驱动要做的就是向平台总线注册自己然后等待匹配。匹配的核心依据是compatible字符串驱动侧通过of_device_id表声明自己支持哪些硬件static const struct of_device_id board_ctrl_of_match[] { { .compatible acme,board-ctrl, }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, board_ctrl_of_match); static struct platform_driver board_ctrl_driver { .probe board_ctrl_probe, .remove board_ctrl_remove, .driver { .name board_ctrl, .of_match_table board_ctrl_match, }, }; module_platform_driver(board_ctrl_driver);匹配规则并不复杂设备树节点里的compatible会与of_match_table的条目逐一比对只要完全一致就算匹配成功随后平台总线调用驱动的probe函数。但要注意两个容易出问题的细节如果节点写有多个 compatible比如compatible acme,board-ctrl-v2, acme,board-ctrl内核会按顺序匹配先匹配最具体的 v2匹配不上再用通用项作为 fallback驱动侧的compatible不能写在driver.name里必须放在of_match_table否则平台总线无法完成设备树匹配。MODULE_DEVICE_TABLE这一行也很重要。它生成的模块别名会让modprobe在设备出现时自动加载驱动。很多朋友开发时喜欢手动insmod一进制成品系统就发现驱动模块起不来八成就是少了这一行。2.3 probe 之后资源从哪里拿当probe被调用说明匹配已经完成设备模型已经给驱动准备好了一个struct platform_device *指针。这个指针后面挂着从设备树解析出来的所有资源。在内核里设备树节点的reg寄存器区域、interrupts中断信息会被统一转换成一组struct resource然后挂到platform_device的resource数组中。驱动要做的是通过platform_get_resource()和platform_get_irq()按顺序取出。示例static int board_ctrl_probe(struct platform_device *pdev) { struct resource *res; void __iomem *base; res platform_get_resource(pdev, IORESOURCE_MEM, 0); if (!res) return -EINVAL; base devm_ioremap_resource(pdev-dev, res); if (IS_ERR(base)) return PTR_ERR(base); irq platform_get_irq(pdev, 0); if (irq 0) return irq; ... }每次拿资源时都做空指针检查这是我在实际项目里被教训出来的习惯。设备树和驱动异步解耦节点没写完、属性拼错都可能造成资源缺失直接使用空指针的后果远比一次报错严重。3. 设备树节点翻译成内核资源逐项拆解3.1 地址资源reg 与 platform_get_resource要正确理解设备树里的地址必须先理解#address-cells和#size-cells。这两个属性定义在父节点上告诉解析器地址字段占几个 cell长度字段占几个 cell。cell 在设备树里就是一个 32 位无符号整数的基本单位。通常#address-cells 1表示地址是一个 32 位值#size-cells 1表示长度也是一个 32 位值64 位地址的 SoC 上常见#address-cells 2。所以一个节点写reg 0x20000 0x800在父节点配置为#address-cells 1和#size-cells 1的前提下含义是从 0x20000 开始长度 0x800 字节。这段信息解析之后会变成一个IORESOURCE_MEM资源。驱动用platform_get_resource(pdev, IORESOURCE_MEM, 0)拿到起始地址和结束地址再用devm_ioremap_resource()建立内核虚拟地址映射。我曾经见过有人把地址写错后readl读回来全是0xffffffff还怀疑硬件坏了。实际上多半是reg地址越界、映射失败或者访问的地址根本不在节点描述的范围里。遇到这种问题先检查设备树的 reg 和 SoC 手册中的地址区间是否一致别急着怀疑硬件。3.2 中断资源interrupts 与 platform_get_irq设备树中断是初学者最容易懵的地方因为中断编号的语义完全取决于中断控制器。比如常见的中断控制器配置中interrupts 0 23 4这样的写法通常表示这是全局中断 SPI中断号为 23触发类型为高电平有效。但具体含义不能只看三个数字要看中断控制器的#interrupt-cells属性。还经常遇到interrupt-parent。设备树里每个节点默认使用父节点的中断控制器但如果中断控制器不在直接父节点上就要显式声明interrupt-parent intc。漏写这一条中断可能会被路由到错误的中断域导致request_irq成功但中断永远不触发。中断获取在驱动里通常只有一行irq platform_get_irq(pdev, 0);然后配合devm_request_irq()注册中断处理函数。如果返回-ENXIO说明设备树节点没有解析到中断如果中断注册成功但触发不了重点排查interrupt-parent和触发类型。3.3 时钟、GPIO、供电这些资源怎么描述寄存器只是平台设备资源的一部分真正的嵌入式外设往往会用到时钟、GPIO、供电轨。这些资源不会出现在resource数组中而是被解析成内核的通用框架对象。时钟的例子clk devm_clk_get(pdev-dev, core); clk_prepare_enable(clk);设备树里对应clocks clk_controller 5; clock-names core;clocks属性的第一个 cell 是时钟控制器和时钟索引第二个 cell 的含义由时钟控制器的 binding 决定。clock-names则给这个时钟起了个名字让驱动可以用名字获取而不是依赖索引。用名字比用索引可读性强得多也能承受设备树节点的顺序调整。GPIO 同样通过属性描述。比如reset-gpios gpio0 8 0;驱动用devm_gpiod_get(pdev-dev, reset, GPIOD_OUT_LOW)获取对应的 GPIO 描述符。注意这里 API 会自动去掉属性名里的-gpios后缀所以reset-gpios对应的是reset。供电轨用vcc-supply这类属性描述驱动通过devm_regulator_get()获取供电调节器。这一类资源的特点是驱动代码不再关心具体硬件如何连接只需要声明我要一个叫 reset 的 GPIO、一个叫 core 的时钟内核会从设备树里自动完成关联。4. 真实项目中排查设备树问题的思路4.1 probe 没有被调用先怀疑匹配如果我只能给出一条排查设备树相关问题的经验那就是当 probe 没有被调用时不要第一时间怀疑内核没解析设备先查匹配。把排查链路固定下来确认设备树节点已经生成平台设备查看/sys/bus/platform/devices/下是否有对应目录确认识别到 compatible在设备目录下看of_node/compatible文件内容确认驱动已被加载lsmod或查看内核日志中模块是否加载成功对比 compatible 字符串设备树里的字符串必须和of_match_table完全一致包括前缀和大小写。顺序不能反过来。很多人一上来就用printk在 probe 里打点结果 probe 压根没进打印自然没有。先花十秒钟看看系统里有没有这个设备往往能省掉一小时的排查。4.2 地址看着没问题为什么访问还是异常地址访问异常是另一个高频问题。常见原因有两类。第一类是设备树里的 reg 区域和实际硬件资源不匹配。比如 SoC 手册里寄存器区间是 0x20000-0x20100设备树只写了reg 0x20000 0x80。内核通过devm_ioremap_resource()做映射时会检查资源范围与父区域是否有重叠如果没重叠映射可能裁剪或失败。驱动访问超出映射范围的地址读回来就是0xffffffff写操作则可能引发总线错误。第二类是访问地址确实在 CPU 地址空间中但硬件模块被某个总线桥或时钟门控挡住了。设备树里的reg只是描述了地址并不保证设备被解复位、时钟被打开。如果你看到readl返回全F先看设备的电源域和时钟状态特别是在新加节点的时候设备树里漏掉clocks属性导致时钟没开启就会表现出这种地址对但读不通的诡异现象。4.3 中断不触发先查中断父节点和触发类型平台驱动的中断问题很大比例不是驱动代码的问题而是设备树中断描述问题。排查步骤platform_get_irq如果返回负数说明节点没有正确解析到中断检查interrupt-parent和interrupts属性中断能获取但驱动不触发先用工具或cat /proc/interrupts看中断是否真的注册成功检查触发类型是否符合硬件实际。设备树里写的是边沿触发但硬件实际上发出的是电平信号中断只触发一次后就不再产生中断这是最常见的假象。设备树里的interrupts中的 flags 由中断控制器定义必须查对应控制器的 binding。不要凭经验猜测比如一些中断控制器用1表示上升沿、4表示高电平但不同控制器的编码可能完全不同。4.4 把 dmesg 和 sysfs 当作第一现场设备树调试有一个好处大部分信息可以从内核日志和 sysfs 中看到。在问题现场我的习惯是dmesg | grep -E platform|acme|board-ctrl ls -l /sys/bus/platform/devices/ cat /sys/bus/platform/devices/20000.board-ctrl/of_node/compatibledmesg里如果能看到OF: fdt: ...或者驱动 probe 相关日志就能在早期判断设备树解析是否成功。sysfs 下的of_node目录是设备树节点的实时视图里面能看到compatible、reg这些原始属性值拿它和.dts源文件对比能快速定位是编译问题还是运行时问题。还有一个技巧用dtc工具反向编译运行时设备树。启动进入系统后把/sys/firmware/devicetree/base的内容导出来或者直接看 dmesg 构建的调试信息能看到 bootloader 最终传给内核的设备树内容。有些问题不是你的.dts写错而是 bootloader 在启动时覆盖了部分节点这种问题光看源码是看不出来的。5. 进阶DMA、电源域和设备树协同的现代管理方式5.1 dma-coherent 与 dma-ranges简化 DMA 映射的关键属性平台设备如果要做 DMA 传输设备树里的缓存一致性和地址映射描述至关重要。dma-coherent属性表示设备与 CPU 缓存保持一致驱动使用 DMA API 时内核会少做很多缓存同步操作。虽然对于某些硬件来说不映射 DMA 区域直接读写也能跑但在实际项目里这往往导致不稳定、偶发数据错乱。dma-ranges则描述设备侧 DMA 地址到 CPU 地址的映射关系。另一个常见场景是SoC 的某个总线控制器在设备树中作为父节点子设备节点通过dma-ranges指定 DMA 地址偏移。如果 DMA 地址与 CPU 地址一致这项可以不写一旦不一致漏写dma-ranges驱动用dma_alloc_coherent分配内存后硬件访问的内存可能完全错位。这里建议凡是涉及 DMA 的平台设备驱动里不要手动做旧式总线地址转换统一使用dma_alloc_coherent、dma_map_single这套 DMA API。设备树把一致性属性和映射关系描述清楚内核会自动处理大多数底层差异。5.2 power-domains、regulator 和 OPP 表的联动现代 SoC 上一个外设往往要依赖一组电源状态。设备树通过power-domains属性把设备和电源域绑定驱动在 probe 时用dev_pm_domain_attach()或设备电源管理接口内核会确保在驱动访问硬件之前对应电源域已经上电。与电源配套的还有 regulator。一个设备可能有多个供电轨比如vcc-core、vcc-io。设备树里用厂商自定的*-supply属性描述驱动通过devm_regulator_get(pdev-dev, vcc-core)获取。这里的核心思想和时钟、GPIO 一样驱动只按名字消费资源不关心资源背后的硬件实现。opp表则是描述设备的频率和电压裕量组合。CPU 或者 GPU 这类需要动态调频调压的设备会通过operating-points-v2节点把不同频率对应的电压列出来。驱动配合调频框架使用而不是自己在初始化代码里硬编码一堆电压值。5.3 用 pinctrl 和默认状态少写控制引脚代码很多朋友写平台驱动时花大量精力在 init 函数里配置引脚复用。这在设备树成熟的平台上是浪费的pinctrl子系统和设备树自带默认配置机制。设备树里一个常见写法是在设备节点内部声明pinctrl-names和pinctrl-0uart { pinctrl-names default; pinctrl-0 uart_default_pins; status okay; };内核在设备驱动 probe 时会自动应用default状态对应的引脚配置驱动代码里完全不需要再去调用 pinmux 相关接口。需要进入低功耗状态时可以再定义sleep状态驱动通过pinctrl_select_state()切换。我遇到的很多驱动代码中手工配置引脚的逻辑往往比真正驱动逻辑还长。迁移到设备树平台后这部分完全可以交给 pinctrl。如果还有人在驱动里看到gpio_direction_output用于初始化引脚多功能复用那基本是从旧平台移植代码留下的后遗症。5.4 一个重要的经验设备树只描述不实现设备树开发里最容易偏离方向的心态是把设备树当万能配置中心什么逻辑都往里写。实际上设备树的基本哲学是描述硬件不描述策略、不实现行为。判断一条数据该不该放设备树可以问自己三个问题这个信息是否属于硬件物理属性驱动程序在其他平台上是否需要不同值如果换一块实现相同功能的板卡这个字段是否需要改变如果三个问题都是是那么它适合进设备树。如果只是代码实现细节比如某个超时时间、缓冲区大小、算法开关那更合适放在驱动代码或模块参数中。设备树是给系统做硬件描述的骨架平台设备驱动则是承载行为的实体。两者各司其职才能发挥这套机制真正的优势。我见过不少人试图在设备树里加自定义属性来控制软件行为短期看确实少改代码但长期维护时别人根本分不清哪些属性是标准绑定、哪些是私有扩展这才是最麻烦的。就我个人实际项目中的体会来说调试设备树问题最能提升效率的习惯就是少猜、多查、先看内核怎么看。把 compatible 作为第一道关卡把 sysfs 和 dmesg 当作现场证据再结合设备树反向编译工具确认最终运行时数据绝大多数问题都能在五分钟内定位。设备树和平台设备驱动这套机制刚接触时确实绕但只要理解了描述与实现分离这个核心思路后面的工作就会顺畅很多。