
简介这份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描述设备在主总线上的地址。比如serial101F0000、spi10115000。这个设计解决了一个具体问题一条总线上可以挂多个同类设备节点名如果只能叫serial第二个就重名了。加上 unit-address 后serial101F0000和serial101F1000是两个合法且不同的节点。注意 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; serial101F0000 { compatible vendor,uart-16550, ns16550a; reg 0x101F0000 0x1000; /* 1 个 address cell 1 个 size cell */ }; };上面这段里父节点声明了 11子节点就必须按 11 写。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 编译文本树怎么变成扁平二进制dtcdevice 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或者父节点含特殊 compatiblesimple-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_resourcereg 属性怎么变成 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、flagsend是闭区间所以长度是end - start 1。地址翻译在转换阶段就完成了内核根据父节点的#address-cells/#size-cells把reg里的 cell 序列还原成 64 位地址和长度驱动拿到的已经是翻译后的值不需要自己再算 cells。4.3 属性读取 API为什么推荐 device_property_* 而不是 of_*读自定义属性有两套 APIof_property_read_*直接操作device_nodedevice_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的写法不匹配。比如父节点声明 11子节点却按 22 写了四个 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都会强制走一遍「编译 → 反编译 → 对比目标节点」的流程再上板验证省掉了大量「改了没生效」的来回。希望帮到你。本文还有配套的精品资源点击获取