设备树从dts到驱动probe的完整链路与避坑指南

发布时间:2026/11/3 11:04:02
设备树从dts到驱动probe的完整链路与避坑指南

简介:这份PDF面向嵌入式Linux驱动开发初学者与内核移植人员,系统讲解设备树的由来、dts语法与内核加载流程,帮助读者理解驱动代码与板级硬件信息如何解耦。内容从设备树替代arch目录板级代码的背景讲起,说明dtb由dts编译生成、dts可引用dtsi的分层组织方式,并给出空设备树示例,逐项解释键值对属性、尖括号32位整型、方括号二进制数据及逗号连接多值等写法;节点命名规则 [@ ]与compatible、reg、#address-cells、#size-cells等关键属性也有专门说明。加载部分按解析dtb、构建设备树、绑定驱动三阶段展开,并补充节点转为platform device的条件与of_match_table匹配机制。资源包仅1个PDF文件,约293KB,篇幅紧凑便于通读。已有662人学习,适合作为设备树入门与查阅的轻量资料。

1. 设备树:为什么一块板子的硬件信息,不该写进内核源码里

同一颗 SoC 被十几家厂商做成不同板子,内存大小、外设地址、中断线全不一样。2.x 内核时代的做法是给每块板子写一个板级文件塞进arch/目录,结果内核源码树里堆满了几百个board-xxx.c,改一个引脚定义就要重编整个内核。设备树(Device Tree)就是对这个问题的正面回答:把板级硬件描述从内核代码里抽出来,变成一份独立的数据文件,内核只留驱动,硬件细节在启动时以数据的形式喂进去。搞懂它,你才能明白为什么现在写驱动不再需要#include一堆板级头文件,也才能看懂compatible匹配失败时内核到底在干什么。这份资料把 dts 语法、dtb 加载、platform device 转换、驱动取资源四段串成了一条完整链路,正好适合拿来把「数据与代码分离」这件事从结论读到实现。

2. dts 语法:键值对背后是怎么表达一棵硬件树的

设备树的源文件是 dts,本质是一棵以/为根的树,每个节点挂若干属性,属性是 key-value 对。它长得像 JSON,但值的类型系统比 JSON 严格得多——因为这份文本最终要被编译成二进制给内核解析,每个值的编码方式必须无歧义。理解语法不能停在「怎么写」,要看到「写成这样,编译出来是什么字节」。

2.1 四种值类型与它们的二进制形态

dts 里属性的值只有几类写法,每类对应 dtb 里不同的编码:

/ { node1 { a-string-property = "A string"; /* 字符串,含结尾 \0 */ a-string-list-property = "first", "second"; /* 字符串列表,多个 \0 结尾串拼接 */ a-byte-data-property = [0x01 0x23 0x34 0x56];/* 方括号:原始字节流 */ a-cell-property = <1 2 3 4>; /* 尖括号:每个数一个 32 位大端 cell */ mixed-property = "a string", [0x01 0x23], <0x12345678>; }; };

尖括号< >里的每个数字被当作一个 32 位无符号整数,术语叫 cell;方括号[ ]里是裸字节,不做任何解释;双引号是字符串;逗号把多个值拼进同一个属性。关键在于:dtb 里每个属性只记录「名字、长度、一段字节」,类型信息在编译后就丢了。内核读属性时靠的是读取 API 的约定——你用of_property_read_u32去读一个字符串属性,它不会报「类型错误」,只会因为长度对不上返回失败。这就是为什么 dts 写错类型往往不报编译错,而是在驱动 probe 时才炸,属于典型的血泪经验区。

mixed-property这种混合类型在真实板级文件里很少见,但它说明了一件事:属性值在底层就是一串字节,所谓「类型」只是上层约定。dtc 编译器会把字符串的\0、cell 的大端序、字节流的原样全部按规范铺进 dtb,内核解析时再按属性名和调用方的期望去还原。

2.2 节点命名与 unit-address:同名节点靠什么区分

节点名格式是<name>[@<unit-address>]。name必填,ASCII,最长 31 字符,通常是设备类型;@后面是可选的 unit-address,描述设备在主总线上的地址。比如serial@101F0000、spi@10115000。

这个设计解决了一个具体问题:一条总线上可以挂多个同类设备,节点名如果只能叫serial,第二个就重名了。加上 unit-address 后,serial@101F0000和serial@101F1000是两个合法且不同的节点。注意 unit-address 只是名字的一部分,它和reg属性里的地址是两回事——前者用于节点唯一标识,后者才是驱动真正拿去映射的地址。我见过有人改了reg忘了改@后的地址,编译能过,运行时节点名和实际地址对不上,排查起来很费劲。

2.3 compatible 与 reg:驱动匹配和地址描述的两根支柱

compatible是设备树和驱动之间的唯一契约。格式一般是"<manufacturer>,<model>",可以写多个字符串,从最具体到最通用排列,表示「我这个设备兼容这些型号」。内核匹配驱动时,拿节点的 compatible 列表去逐个比对驱动of_match_table里的字符串,命中即绑定。

reg描述设备地址,形式是<address length [address2 length2] ...>。这里有个容易翻车的点:address 和 length 各占几个 32 位 cell,不是固定的,由父节点的#address-cells和#size-cells决定。64 位系统里这两个值常设为 2,于是reg = <0x0 0x101F0000 0x0 0x1000>表示基地址 0x101F0000、长度 0x1000,高低 32 位分开写。如果父节点写的是#address-cells = <1>,你却按两个 cell 去写,解析出来的地址就整体错位——这类错误不会在编译期暴露,只会在驱动拿到一个荒谬的地址时显现。

soc { #address-cells = <1>; #size-cells = <1>; serial@101F0000 { compatible = "vendor,uart-16550", "ns16550a"; reg = <0x101F0000 0x1000>; /* 1 个 address cell + 1 个 size cell */ }; };

上面这段里,父节点声明了 1+1,子节点就必须按 1+1 写。compatible写了两个值,意味着先尝试匹配vendor,uart-16550的专用驱动,匹配不上再退到通用的ns16550a驱动——这种「从具体到通用」的排序是设备树匹配的惯例,不是随意写的。

2.4 dtsi 与 dts 的分层:SoC 公共部分怎么复用

单板文件不会从零写起。SoC 厂商把芯片内部的硬件信息(中断控制器、时钟、总线)写成 dtsi,多块板子共用的部分也抽成 dtsi,具体板子的差异写成 dts,通过#include引入。编译时 dtc 先把 include 展开,再整体编译成一个 dtb。

这个分层直接对应现实中的职责划分:SoC 厂商维护 dtsi,板厂只写自己板子上的差异(内存大小、外设使能、引脚复用)。好处是 SoC 换了新板子,dts 可能只有几十行。代价是调试时你看到的报错节点可能来自某个被 include 进来的 dtsi,得顺着 include 链去找源头。常见的实现思路是:先dtc -I dtb -O dts把最终 dtb 反编译回文本,看展开后的完整树,比在多个文件间跳转快得多。

3. 从 dts 到 dtb:编译、加载与内核解析的完整链路

dts 是给人看的,内核吃的是 dtb。中间隔着 dtc 编译、bootloader 搬运、内核解析三道关。这条链路上任何一环出问题,现象都是「驱动没 probe」或者「启动卡住」,但根因可能完全不同。把链路拆开看,才能定位到具体是哪一环。

3.1 dtc 编译:文本树怎么变成扁平二进制

dtc(device tree compiler)把 dts 编译成 dtb。dtb 的结构是扁平的:一个头部(magic、总大小、结构块偏移、字符串块偏移等),后面跟着结构块(一串 token,描述节点开始、属性、节点结束)和字符串块(所有属性名集中存放,结构块里只存偏移)。

# 编译:dts -> dtb dtc -I dts -O dtb -o board.dtb board.dts # 反编译:dtb -> dts,排查最终生效内容时最有用 dtc -I dtb -O dts -o board_decompiled.dts board.dtb

-I指定输入格式,-O指定输出格式。反编译这一步在排查时价值极高:你写的 dts 经过 include 展开、覆盖、删除之后,最终进 dtb 的内容可能和你以为的不一样,反编译出来看到的就是内核真正拿到的树。头部里的 magic 是0xd00dfeed,内核解析前先校验它,对不上直接判定「不是合法 dtb」。

3.2 bootloader 传参:设备树地址是怎么交到内核手里的

编译好的 dtb 由 bootloader 在启动时加载到内存,启动内核时把 dtb 的物理地址作为参数传给内核。不同架构的传参约定不同,但本质都是「内核入口处多了一个寄存器或参数,指向内存中的 dtb」。

内核启动早期会先根据设备树里的平台信息确认自己是否支持这块板子——如果 dtb 描述的 SoC 和编译进内核的配置对不上,启动会在这里失败。接着解析运行时配置:bootargs(内核命令行)、根节点的#address-cells和#size-cells、memory节点的reg(物理内存布局)。这几项是内核在建立内存管理之前就必须知道的,所以解析顺序在最前面。

3.3 解析成 allnodes 全局链表:节点在内存里长什么样

运行时配置解析完,内核把设备树里的所有节点解析出来,挂到一个全局链表allnodes上。每个节点在内存里对应一个struct device_node,核心字段大致是:节点名、属性链表、父节点指针、子节点链表、以及指向原始 dtb 中该节点位置的指针。

属性则对应struct property,记录名字、长度、值指针。这里有个设计细节值得注意:属性值并不复制,而是直接指向 dtb 在内存中的原始位置。这意味着 dtb 所占的那块内存在内核整个生命周期内都不能被覆盖——bootloader 通常会把 dtb 放在一块保留内存里,或者内核在解析后自己保留它。如果某段代码在启动后复用了那块内存,读属性就会读到垃圾数据,现象是驱动偶尔 probe 失败、偶尔正常,属于玄学级 bug。

allnodes链表的意义在于:它把扁平的 dtb 还原成了可遍历的树结构,后续所有of_*API 都是在这棵树上做查找。of_find_node_by_path从根开始按路径逐级找子节点,of_find_compatible_node遍历整棵树比对 compatible,底层都是走这个链表。

3.4 节点转 platform device:哪些节点有资格变成设备

设备树节点本身只是数据,要变成驱动能绑定的对象,还得转成platform_device。但并不是所有节点都转——内存节点显然不需要驱动,chosen节点是启动参数容器,也不转。转换条件很明确:

  • 节点必须含compatible属性;
  • 是根节点的子节点,且含compatible;
  • 或者父节点含特殊 compatible(simple-bus、simple-mfd、isa、arm,amba-bus),且自身含compatible。

第三条是关键:simple-bus这类 compatible 表示「我这个节点是个总线,我的子节点都是可以独立成设备的」。于是 SoC 节点常被标成simple-bus,它下面挂的 uart、spi、i2c 控制器就都能转成 platform device。反过来,如果一个外设节点挂在没有这类标记的父节点下,即使它有 compatible,也不会被转成 platform device,驱动永远等不到 probe——这是「驱动写了但不生效」最常见的原因之一。

转换时,节点的reg被翻译成platform_device里的resource数组,compatible和节点名进入匹配用的字段。这一步是设备树「数据」和驱动模型「对象」之间的桥。

4. 驱动侧:compatible 匹配与资源获取的源码级细节

驱动怎么从设备树里拿到自己需要的资源,是整条链路的终点。这里的设计取舍很集中:匹配靠字符串,取资源靠标准 API,地址翻译靠父节点的 cells 声明。每一环都有明确的失败模式。

4.1 of_match_table:字符串匹配是怎么发生的

驱动的匹配表长这样:

static const struct of_device_id beep_table[] = { { .compatible = "vendor,beep" }, /* 与 dts 节点 compatible 逐项比对 */ { /* sentinel */ } }; static struct platform_driver beep_driver = { .probe = beep_probe, .remove = beep_remove, .driver = { .name = "beep", .of_match_table = beep_table, /* 设备树匹配入口 */ }, };

of_match_table是一个以空项结尾的数组。内核在把节点转成 platform device 后,拿节点的 compatible 列表去和每个驱动的表比对:节点的第一个 compatible 先和所有驱动的表项比,没命中再拿第二个,依此类推。所以 dts 里 compatible 的排列顺序直接决定匹配优先级。

这里有个容易忽略的点:driver.name和of_match_table是两条独立的匹配路径。老式的 platform 设备靠name匹配,设备树设备靠of_match_table匹配。如果只写了name没写of_match_table,设备树节点转出来的 device 匹配不上,probe 不会调用。反过来只写of_match_table是现在的主流做法。两者都写时,匹配逻辑会先走设备树路径。

4.2 platform_get_resource:reg 属性怎么变成 resource

节点转 platform device 时,reg被拆成resource数组,每个resource记录起始地址、结束地址、名字和类型标志。驱动侧用标准 API 取:

struct resource *res_mem; void __iomem *base; /* 取第 0 个 IO 内存资源,类型必须是 IORESOURCE_MEM */ res_mem = platform_get_resource(pdev, IORESOURCE_MEM, 0); if (!res_mem) return -ENODEV; /* 申请并映射,之后才能读写寄存器 */ base = devm_ioremap_resource(&pdev->dev, res_mem); if (IS_ERR(base)) return PTR_ERR(base);

platform_get_resource的第二个参数是资源类型,第三个是同类资源中的序号。reg里写了多段地址时,序号 0、1、2 依次对应。devm_前缀表示资源由设备管理框架托管,驱动卸载时自动释放,省掉手写释放路径——这是现代驱动的主流写法,早期驱动手动release_mem_region加iounmap,漏一个就泄漏。

struct resource的核心字段是start、end、flags,end是闭区间,所以长度是end - start + 1。地址翻译在转换阶段就完成了:内核根据父节点的#address-cells/#size-cells把reg里的 cell 序列还原成 64 位地址和长度,驱动拿到的已经是翻译后的值,不需要自己再算 cells。

4.3 属性读取 API:为什么推荐 device_property_* 而不是 of_*

读自定义属性有两套 API:of_property_read_*直接操作device_node,device_property_read_*走统一的设备属性抽象。后者更好,因为它同时支持设备树和 ACPI 两种固件描述方式,驱动不用为两种平台写两套代码。

u32 freq; const char *label; /* 读单个 u32,属性不存在或长度不符时返回负错误码 */ if (device_property_read_u32(&pdev->dev, "clock-frequency", &freq)) freq = 24000000; /* 缺省值兜底 */ /* 读字符串 */ device_property_read_string(&pdev->dev, "label", &label);

device_property_read_u32的第三个参数是输出指针,返回值 0 表示成功,负值表示属性缺失或类型不匹配。注意它不会帮你做单位换算,dts 里写的是裸数值,驱动自己解释含义。数组类 API(_read_u32_array、_read_u8_array)需要先知道元素个数,通常配合device_property_count_u32先查长度再分配缓冲。

of_find_node_by_path和of_find_compatible_node这类查找 API 在驱动里用得越来越少,因为 platform device 已经把节点信息带过来了,驱动直接拿pdev->dev.of_node即可。只有在需要跨节点引用(比如 phandle 指向的另一个节点)时才手动查找。

5. 避坑与排查:设备树不生效时到底该看哪里

设备树的问题有个共同特征:编译不报错,运行时静默失败。下面几条是按「现象 → 原因 → 解决」整理的常见翻车记录,覆盖了从语法到匹配的主要坑位。

5.1 驱动 probe 不调用,日志里什么都没有

现象:驱动编译进内核,模块也加载了,但 probe 函数一次都没进,dmesg里没有任何相关报错。

原因:节点没被转成 platform device,或者 compatible 没对上。前者常见于节点挂在没有simple-bus标记的父节点下;后者常见于 dts 里 compatible 拼写和驱动表不一致,或者驱动只写了driver.name没写of_match_table。

解决:先dtc -I dtb -O dts反编译,确认目标节点在最终 dtb 里存在且 compatible 正确;再确认父节点链上有simple-bus或同类标记;最后核对驱动of_match_table的字符串,一个字符都不能差。可以在启动后看/sys/firmware/devicetree/base下的目录结构,确认内核看到的树。

5.2 reg 解析出的地址明显不对

现象:驱动拿到resource->start是个荒谬的值,映射后读写寄存器直接挂死或读到全 0xFF。

原因:父节点的#address-cells/#size-cells和子节点reg的写法不匹配。比如父节点声明 1+1,子节点却按 2+2 写了四个 cell,解析时地址和长度整体错位。

解决:从目标节点往上逐级检查#address-cells和#size-cells,确保reg的 cell 数量和父节点声明一致。64 位地址必须由父节点声明 2 个 address cell,不能靠子节点自己「多写几个数」蒙混。

5.3 属性读出来是垃圾值或长度错误

现象:device_property_read_u32返回错误,或者读出来的值每次启动都不一样。

原因:属性类型写错(比如该用尖括号写成了方括号),或者 dtb 所在内存被后续代码覆盖。前者是语法层问题,后者是内存保留问题。

解决:先反编译确认属性编码正确;如果编码没问题但值不稳定,检查 bootloader 放置 dtb 的内存区域是否被内核或其他组件复用,确认该区域被正确保留。

5.4 改了 dts 但行为没变

现象:修改 dts 重新编译,烧录后驱动行为还是旧的。

原因:bootloader 加载的是旧 dtb,或者编译产物没被正确打包进启动镜像。多级 include 的情况下,也可能改的 dtsi 没被目标 dts 引用到。

解决:反编译实际加载的 dtb(不是编译目录里的那个),对比修改是否生效;检查构建脚本里 dtb 的打包路径;顺着 include 链确认改动的文件确实在编译输入里。

5.5 同名节点冲突导致覆盖

现象:某个节点在最终 dtb 里消失了,或者属性被意外改写。

原因:dts 和 dtsi 里存在同路径节点,dtc 的合并规则是后出现的覆盖先出现的,属性级别合并而非整体替换。改错层级就会把不该覆盖的属性冲掉。

解决:反编译看最终树,确认每个节点的属性来源;合并时用&label引用方式追加属性,而不是重新写一遍完整节点,减少覆盖风险。

6. 设计取舍:为什么是「数据驱动匹配」而不是「代码直接描述硬件」

设备树这套机制最核心的取舍,是把「硬件是什么」和「怎么操作硬件」彻底切开,用一份可独立编译、独立替换的数据文件做中间层。这个选择带来的收益很直接:同一份内核镜像配不同 dtb 就能跑不同板子,驱动代码里不再有板级宏和条件编译,SoC 厂商和板厂可以各管各的文件。代价也同样真实:多了一层间接,匹配失败时没有编译期报错,全靠运行时排查;dtb 的内存生命周期成了隐性约束;cells 的声明让地址描述多了一层需要人工对齐的规则。

对比 2.x 时代的板级文件方案,设备树的本质是把「编译期绑定」换成了「启动期绑定」。板级文件方案里,硬件信息和内核代码一起编译,错了编译不过,但换板子就得重编内核;设备树方案里,硬件信息是外部数据,换板子只换 dtb,但错误被推迟到运行时。这是一次典型的「灵活性换确定性」的交易,而工程上所有的排查手段——反编译、看/sys/firmware/devicetree、检查 allnodes——都是在把推迟的确定性补回来。

我一般会这么理解这套机制的价值边界:它适合硬件差异大、板子型号多的场景,因为复用收益高;对于单一固定硬件的产品,设备树带来的间接层反而是负担,但生态已经统一到这条路上,绕不开。真正要建立的习惯是:任何设备树相关的异常,第一步永远是反编译实际加载的 dtb,看内核真正拿到的是什么,而不是盯着自己写的源文件猜。从那以后我每次改完 dts,都会强制走一遍「编译 → 反编译 → 对比目标节点」的流程,再上板验证,省掉了大量「改了没生效」的来回。希望帮到你。

本文还有配套的精品资源,点击获取