设备树从入门到调试:dts/dtsi/dtb与驱动绑定全解析

发布时间:2026/10/22 11:37:50
设备树从入门到调试:dts/dtsi/dtb与驱动绑定全解析
设备树Device Tree这名字干过嵌入式 Linux 驱动的人基本都绕不开。尤其是搞 ARM 平台、SoC 移植、板级 bring-up 的同行日常打交道最多的文件往往不是 C而是一个个后缀为.dts、.dtsi的文本文件。我第一次被它“逼着”系统学习是在某块开发板上反复出现启动阶段崩溃log 停在驱动 probe 环节查了半天 driver 逻辑都查不出问题最后才发现根因根本不是 C 代码而是设备树里reg属性和interrupts的组成方式写错了。从那时起我就意识到设备树这东西不是“认识几个节点、抄几个属性”就行的它背后的设计逻辑、资源描述方式、编译工具链和调试手段缺一环都会让人摔跟头。这篇文章不会讲太多玄乎的概念就按我自己从“看 dts 都头疼”到“能独立从零写一颗虚拟 SoC 的设备树并跑通驱动”的路径来拆。适合刚接触设备树的同学也适合已经写了一阵 dts、但对某些标准字段和 debug 手段还没完全吃透的工程师。你会搞清楚设备树到底解决什么问题、语法骨架怎么搭、dts/dtsi/dtb/dtc 这些术语怎么分、驱动和设备树怎么建立绑定以及最常见的“启动就崩、属性总对不上、编译老报错”该怎么排。1. 设备树的“为什么”从板级文件到统一资源描述1.1 内核在设备树出现之前是怎么描述硬件的在设备树大规模进入内核之前很多架构尤其是早期的 ARM 社区处理硬件描述的方式是“写 C 代码”。什么意思呢就是每个板子都要在 arch 目录下放一个 board 文件里面用 C 结构体把板载资源一个个列出来比如内存起始地址和大小、串口地址、中断号、GPIO 编号等等然后在内核启动时直接注册成 platform device。这种方式在板卡数量少、SoC 型号单一的时候其实没有什么毛病缺点要到“一个内核要支持几十个板子、几百种外设组合”的时候才暴露出来。首先代码维护成本极高。每来一块新板子就算只是换了一个网卡芯片也得新增或者修改一个 board 文件然后重新编译内核。其次内核镜像里塞进了一堆和特定板子强绑定的代码换硬件就得换内核这跟 Linux 追求可移植性的方向是背道而驰的。后面 ARM 社区也做过一些 bootloader 参数传递硬件信息的尝试但都不够通用和结构化。设备树就是在这种背景下被引入的把硬件资源描述从“编译期用 C 硬编码”搬到“运行期用二进制数据传递”。1.2 设备树解决的三个核心问题设备树本质上是一棵描述硬件拓扑的树形数据结构它解决了三个让我觉得非常关键的问题。第一硬件描述与内核代码解耦。设备树是数据不是代码。理论上同一份内核镜像可以跑在多个硬件平台上只要 bootloader 在启动时把对应的设备树二进制传给内核即可。这意味着新板子来了不用动 C 代码只要新增或者修改一个 dts 文件重新编译生成 dtb 就行。第二避免平台设备“硬编码注册”。以前要注册一个串口或者网卡必须在初始化代码里调用一堆注册接口。现在设备树节点天然会被内核解析成 platform device驱动只要按照compatible属性去匹配就完事谁发现硬件、谁注册设备这些底层逻辑内核已经替你干了。第三把中断、GPIO、时钟这类跨设备引用规范化。设备树用phandle指针句柄实现了节点之间的引用关系一个外设节点想用哪个中断控制器、哪个 GPIO 控制器、哪路时钟直接在属性里认证关系不需要全局搜索代码里有没有定义宏。提示不要被“树”这个字吓住。把设备树理解成“硬件的目录树”就简单了。普通目录树里文件夹里套子文件夹文件是最后的叶子节点设备树里总线上挂控制器控制器上挂外设节点叶子节点存放寄存器和中断信息。2. 从 dts 到 dtb 再到内核文件形态与完整编译链路2.1 五分钟分清 dts、dtsi、dtb、dtc 这几个术语这几个后缀名放在一起的时候很多新手会觉得杂乱但它们的逻辑非常简单。.dts设备树源文件Device Tree Source纯文本格式是人直接编写和阅读的对象。.dtsi设备树包含文件Device Tree Source Include也是文本格式通常存放 SoC 内部的公共定义比如 CPU 节点、中断控制器、时钟控制器、通用外设控制器等。板级特有的外设、内存大小、引脚复用一般放在板级 dts 里。两者用#include语法组合。.dtb设备树二进制文件Device Tree Blob是 dts 编译后的产物也是 bootloader 最终加载并传递给内核的格式。.dtc设备树编译器Device Tree Compiler负责把 dts 转成 dtb 的工具。你在 Linux 内核源码目录里敲make dtbs实际上就是调用它来批量编译。这些术语之间的关系就像汇编源码.s经由编译器变成目标文件.o最后链接成可执行文件一样。我建议你把 dts/dtsi 当成源代码、dtb 当成可执行文件去理解两者的角色差异就一目了然了。2.2 编译、反编译与格式校验的实用操作拿内核源码里某个目录的设备树来演示通常会先设置 ARCH 和 CROSS_COMPILE 环境变量然后执行编译目标。实际工作时我经常只用一小段命令族比如针对单一 dts 文件手动编译。假如现在手上有一份最简单的demo_board.dts命令行操作类似下面这样# 直接使用 dtc 手动编译 dtc -I dts -O dtb -o demo_board.dtb demo_board.dts # 加上 - 选项生成可用于 overlay 的 dtb后面会讲 dtc -I dts -O dtb - -o demo_board.dtb demo_board.dts # 反编译把 dtb 还原回可读的 dts dtc -I dtb -O dts -o demo_board_reverse.dts demo_board.dtb编译时最常遇到的问题有两大类一类是语法错误比如括号不匹配、忘写分号、#include路径找不到另一类是警告比较典型的是unit address和reg首地址不一致、RTC节点缺少interrupt-parent、graph相关节点缺少端口描述等等。把警告当成错误处理是个好习惯至少我踩过不少次“反编译一切正常但启动就挂”的坑追根溯源往往是警告背后暗藏着模型语义错误。你还可以用fdtdump直接查看 dtb 的二进制结构或用fsck之类的工具检查 dtb 格式完整性。以fdtdump为例fdtdump demo_board.dtb | head -60它会把你二进制里的节点和属性以类似 dts 的格式打印出来方便快速确认编译产物里包含什么内容。对做 bootloader 和内核联动调试的人来说这是经常用到的工具务必装好。2.3 内核拿设备树的完整启动链路很多搞应用层开发的朋友可能会有疑惑dtb 文件是怎么到内核手里的这个链路其实已经形成了标准流程。一般分为三步bootloader 把 dtb 从存储介质eMMC、SD 卡、网络读入内存bootloader 根据自己的配置对 dtb 做少量修改比如修改内存大小、填充 bootargs、加载 overlay最后把 dtb 起始地址通过寄存器传递或者协议约定的方式交给内核。内核启动早期通过固定区域的指针拿到 dtb 地址在setup_arch流程中解析它建立内存节点映射、初始化中断控制器并把外设节点转换为 platform device。这里有个值得注意的底层机制dtb 在内存中必须满足一定对齐要求如果 bootloader 传给内核的地址没有对齐内核可能直接就拒绝识别。早期 bring-up 阶段我遇到过 bootloader 把 dtb 拷贝到奇地址结果内核打印很诡异的物理地址错误。这不是内核代码问题而是 bootloader 侧的传参位置问题。建议在 bootloader 控制台打印 dtb 加载地址并做一次md命令查看内存内容确认d00dfeed魔数是否存在。“d00dfeed”和利于 memory 的 dtb 格式头开头的 magic 数值一样看到它就说明 dtb 基本没被传错。3. 节点、属性和标准字段设备树的语法骨架与语义细节3.1 认识树结构节点就是硬件对象从根节点/开始每个硬件对象对应一个节点节点的名字一般写成nameunit-address的形式比如serial90000000。后面的部分是可选的“单元地址”一般写外设寄存器首地址。光看节点名就能猜到这颗外设的基地址在哪里这在调试时非常直观。节点内部是成对的属性每个属性有名字和值。值可以是空、单个值、字符串、字符串列表、数字、数字数组等等。一个简单的外设节点大概长这样/dts-v1/; / { compatible demo,board; #address-cells 1; #size-cells 1; uart0: serial90000000 { compatible demo,ns16550a; reg 0x90000000 0x1000; interrupts 5; interrupt-parent intc; clock-frequency 1843200; status okay; }; intc: interrupt-controllera0000000 { compatible demo,vic; reg 0xa0000000 0x1000; interrupt-controller; #interrupt-cells 1; }; };看到uart0:这种写法了吗这是节点的标签。标签的作用是给其他节点提供引用入口后续写uart0 { ... };就能对这个节点做追加或修改操作在设备树里这叫“覆盖”也是组织公共设备树和板级差异的手段。在底层标签会被编译成phandle属性其他节点用uart0引用它时最终就是引用那个phandle。3.2 地址映射最关键的 reg 属性#address-cells 与 #size-cellsreg是每个外设节点的灵魂属性它描述了设备在父总线地址空间中的起始地址和占用的地址长度。但它的“单元格长度”是由父节点的#address-cells和#size-cells决定的。这是很多初学者最容易搞混的地方。举个直观例子。根节点/里写了#address-cells 1、#size-cells 1意思是在这个总线上地址用 1 个 32 位整数表示长度也用 1 个 32 位整数表示。所以子节点的reg 0x90000000 0x1000就是“基地址 0x90000000长度 0x1000”。如果 SoC 支持 64 位地址根节点就会写#address-cells 2此时子节点 reg 前面的地址部分要写两个 32 位整数比如reg 0x0 0x90000000 0x0 0x1000前两个数拼出高 64 位地址后两个数拼出长度。长度要不要写两段也取决于#size-cells的值。这个映射规则是递归的。总线下挂一个 i2c 控制器i2c 控制器节点内部可能有#address-cells 1和#size-cells 0那它底下的子设备地址就不是内存地址而是 7 位或者 10 位的 i2c 从机地址长度自然为 0。这种父节点定义子节点地址格式的设计让同一棵设备树能够适配完全不同的总线模型非常巧妙。i2c91000000 { compatible demo,i2c; reg 0x91000000 0x1000; #address-cells 1; #size-cells 0; sensor48 { compatible demo,temperature; reg 0x48; /* 从机地址没有长度 */ }; };3.3 中断描述interrupt-parent、interrupts 与 #interrupt-cells中断和地址是设备树里最常出问题的两个领域。中断描述也遵循“控制器定义格式”的套路。中断控制器节点需要声明interrupt-controller;以及#interrupt-cells表示每个中断请求由几个数字组成。比如一个简单的中断控制器用 1 个数字表示中断号那就写#interrupt-cells 1有些 GIC 需要 3 个数字类型、中断号、触发方式那就写#interrupt-cells 3。外设节点里用interrupt-parent指明自己挂在哪个中断控制器上再用interrupts描述具体的中断。如果整个系统只有一个中断控制器根节点里声明interrupt-parent intc子节点可以省略不写采用继承方式。但我的建议是在复杂平台里尽量让每个使用中断的外设节点显式声明自己的interrupt-parent避免层级继承导致误判。踩过一个印象很深的坑某个子节点挂在了一个没有注册interrupt-controller属性的节点上内核解析中断时直接回退到默认控制器中断号对不上驱动运行到一半触发一个莫名奇妙的共享中断。3.4 GPIO、pinctrl 与时钟属性实战感知GPIO 属性的写法和中断类似也是依赖控制器节点提供#gpio-cells来确定组成格式。一个典型写法是 GPIO 控制器定义#gpio-cells 2第一个数字是引脚号第二个数字是标志位比如高有效还是低有效、是否上拉。外设节点里则用类似reset-gpios gpio0 17 GPIO_ACTIVE_LOW的形式引用引用了哪路控制器、哪个引脚、什么有效电平一目了然。pinctrl 是管脚复用配置的核心。设备树里通常用pinctrl-names和pinctrl-0来绑定pinctrl-names default;表示这个节点存在一组默认状态的引脚配置pinctrl-0 pinctrl_uart0_default指向 pinctrl 节点里定义好的一个配置子节点。这种做法可以在驱动probe之前把引脚切到目标复用功能避免“外设寄存器写对了但引脚没接通”的问题。时钟引用的模式和 GPIO 也很像时钟控制器节点声明#clock-cells ...外设节点用clocks和clock-names属性引用。比如clocks clk 42; clock-names baud;表示外设使用 clk 这个控制器输出的第 42 路时钟在驱动里可以用clock-names直接查找对应时钟。3.5 必须认识的几个特殊节点在设备树里除了外设节点还有几个承载系统级信息的特殊节点写 dts 时绕不开它们。根节点/的model和compatible属性。model是板子型号的通俗名称compatible是内核做板级匹配用的字符串一般格式为厂商,型号。cpus节点。用来描述 CPU 核心数量、架构、频率、缓存信息device_type cpu和reg即 CPU 编号是必备属性。memory节点。描述物理内存的起始地址和大小device_type memory且reg是必备。很多 bootloader 会根据实际内存大小动态修改这个节点。chosen节点。用来存放运行时参数和 bootloader 填充的数据最典型的就是bootargs内核启动参数在这里。aliases节点。用来给节点起“短名”比如serial0 uart0这样后续不管是命令行还是驱动代码都可以用serial0这种直观的别名来占位而不是满世界找完整节点路径。reserved-memory节点。给某些需要预留物理内存的设备比如图形处理器、DSP 固件划专用区域防止内核把它当作普通内存分配掉。4. 从零写一份最小可用设备树并跑通一个驱动4.1 搭建平台骨架与内存节点现在假设我们在一套仿真平台上工作平台只有一个 CPU、一块内存区、一个中断控制器、一路串口。直接按设备树规范写出一份完整且能通过 dtc 编译的 dts。这个流程我是强烈建议你亲手走一遍的哪怕手头没有真实硬件也可以在仿真环境里验证。/dts-v1/; / { model DemoVirtualBoard; compatible demo,virtual-board; #address-cells 1; #size-cells 1; aliases { serial0 uart0; }; chosen { bootargs consolettyS0,115200n8 root/dev/ram0; }; cpus { #address-cells 1; #size-cells 0; cpu0 { device_type cpu; compatible demo,cpu; reg 0; }; }; memory80000000 { device_type memory; reg 0x80000000 0x20000000; /* 512MB 内存 */ }; intc: interrupt-controllera0000000 { compatible demo,vic; reg 0xa0000000 0x1000; interrupt-controller; #interrupt-cells 1; interrupts 0; }; uart0: serial90000000 { compatible demo,ns16550a; reg 0x90000000 0x1000; interrupts 3; interrupt-parent intc; clock-frequency 1843200; status okay; }; };不需要刻意堆砌属性。核心是明确 CPU、内存、中断控制器、串口这几大块。chosen里bootargs的 console 参数要和你串口节点匹配不然内核起来之后控制台没有输出调试难度会直线上涨。4.2 在设备树里加入自定义外设节点现在给这块虚拟板子增加一个自定义传感器设备假设它挂在某个内存映射区域需要两个寄存器片段、一个中断、一路复位 GPIO。设备树部分我们会这样扩展/ { gpio0: gpiob0000000 { compatible demo,gpio; reg 0xb0000000 0x1000; gpio-controller; #gpio-cells 2; }; sensorc0000000 { compatible demo,temperature-sensor; reg 0xc0000000 0x100, 0xc0001000 0x100; reg-names config, data; interrupts 7; interrupt-parent intc; reset-gpios gpio0 8 GPIO_ACTIVE_LOW; status okay; }; };这里有两个 reg 片段reg-names给每个片段起了名字驱动通过名字拿到对应的resource就不用记住数字序号了代码可读性会好很多。GPIO 的第二个数字让它表示有效电平GPIO_ACTIVE_LOW这类常量通常来自内核头文件dts 源文件通过#include dt-bindings/gpio/gpio.h引入编译时会被展开成数字 1非常直观。也就是说设备树源码层面是“友好名称 宏定义”二进制层面全是数字中间由预处理器和编译器完成转换。4.3 驱动侧如何与设备树节点绑定设备树写得再好驱动不按套路出牌也是白搭。如果驱动的匹配方式还是老一套platform_driver_register然后手动判断设备名那扩展性会差很多。现代写法是在驱动代码里引入of_match_table并填上兼容列表。#include linux/module.h #include linux/platform_device.h #include linux/of.h #include linux/of_platform.h static const struct of_device_id demo_sensor_dt_ids[] { { .compatible demo,temperature-sensor, }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, demo_sensor_dt_ids); static int demo_sensor_probe(struct platform_device *pdev) { struct resource *res; void __iomem *base; int irq; res platform_get_resource_byname(pdev, IORESOURCE_MEM, config); base devm_ioremap_resource(pdev-dev, res); irq platform_get_irq(pdev, 0); dev_info(pdev-dev, sensor probed, irq%d, base%px\n, irq, base); return 0; } static struct platform_driver demo_sensor_driver { .probe demo_sensor_probe, .driver { .name demo-sensor, .of_match_table demo_sensor_dt_ids, }, }; module_platform_driver(demo_sensor_driver);关键是of_match_table。内核在启动时读取设备树节点里的compatible字符串用这个列表去逐一比较一旦匹配成功就会创建 platform device 并调用 probe。所以在写 dts 时使用什么 compatible驱动里就要保持一致差一个字符都匹配不上。很多“设备树明明写了节点但驱动没反应”的问题最后查出来都是 compatible 字符串拼错了或者驱动没注册of_match_table。4.4 从源文件到内核看到 log 的完整验证流程完成 dts 和驱动代码之后不要急着烧到板子上盲试先做低成本验证。第一关是编译。用 dtc 编译 dts 时务必把警告看仔细尤其注意有没有unit address和reg不匹配的提示。第二关是看 proc 或 sysfs。内核起来之后进入/sys/firmware/devicetree/base你会发现设备树节点以目录形式原样呈现。比如进入 sensor 节点目录cat compatible能看到字符串cat reg能看到原始的数字序列。第三关是看驱动是否 probe用ls /sys/bus/platform/devices或者直接看内核 log 里有没有demo_sensor_probed这样的输出。提示/sys/firmware/devicetree/base是内核导入的设备树运行时视图在这里你能看到内存节点、chosen 节点、每一个外设节点。如果某个节点在源码里写了但运行系统里找不到往往是编译流程没把最新的 dtb 烧进去。5. 真实踩坑记录与排查技巧5.1 启动阶段就崩先查是不是 dtb 本身出了问题设备树写错导致的启动崩溃表现和普通内核代码 bug 很像有时候甚至会更隐蔽因为 log 会停在非常早的汇编阶段或者解压缩阶段。我的经验是如果内核 log 一上来就是乱七八糟的地址异常先不要怀疑驱动第一件事查 dtb 是否真的能被内核识别。可以在 bootloader 端打印 dtb 的 magic 数值也可以用dtc -I dtb -O dts反编译一版尽可能和源码做 diff。有一次我在某个平台上改了一处根节点属性编译正常但启动直接 hang 住。排查了很久才发现dtc编译出来的 dtb 被 bootloader 加载到内存后bootloader 又根据chosen节点里的bootargs做了二次拼接结果把 dtb 尾部内存改坏了。这个问题的根源是内存分配重叠而不是内核解析问题。建议明确 bootloader 使用的 dtb 加载地址和 initrd、kernel 加载地址之间要留足安全垫。5.2 reg 长度和 #address-cells 不匹配万年经典大坑“为什么我明明写了 reg但驱动拿到的地址是错的”哪怕有三年经验的工程师也免不了在某天被这个问题坑一次。根本原因是#address-cells和#size-cells决定了解析reg的方式你单独看reg 0xc0000000 0x100没有意义必须看父节点是怎么定义的。如果父节点是#address-cells 2那地址部分会解析成0x00000000 0xc0000000驱动拿到的地址可能是 64 位的但你的 SoC 只有 32 位地址总线最终就得到错误结果。排查方法其实也不难。设备树被内核导入之后去/sys/firmware/devicetree/base下用od -tx4 reg查看原始字节序。比如od -An -tx4 /sys/firmware/devicetree/base/sensorc0000000/reg你会看到一组 32 位整数自己数一数这几组数字的个数和顺序就能知道编译器实际生成的数据和你想象中是否一致。再对照父节点的#address-cells、#size-cells通常马上就能定位问题。5.3 中断相关问题的调试思路中断不对时最常见的现象是驱动request_irq成功但中断永远不来或者一来就是疯狂风暴。建议先确认中断控制器节点有没有声明interrupt-controller再确认外设节点的interrupt-parent有没有指对地方。有时候一颗 SoC 里有多个中断控制器外设节点如果没显式声明interrupt-parent内核会沿着父节点向根节点搜索一旦路径上有另一个interrupt-controller节点中断号解释方式就完全不同。如果你想在设备树层面临时验证某路中断是否走得通可以把某个外设的中断直接绑定到另一颗控制器的一个未使用中断号上看看是否触发但更靠谱的是先靠内核启动日志。启动时如果设备树里中断属性解析有问题内核通常会打印of_irq_parse_one或者failed to parse interrupt之类的关键字。搜索这些关键字基本能定位到具体节点。5.4 设备树覆盖的常见误区设备树覆盖overlay是在主 dtb 之上动态添加或修改节点的一套机制很适合“保留一个基础设备树板级差异临时附加”的场景。它要求主 dtb 在编译时带上-选项把节点的__symbols__信息保留下来然后 overlay 里用uart0 { ... }或者/ { ... };片段去追加。但有一个很常见的误区overlay 不是简单的文本叠加它是基于符号表在二进制层面做的修改。如果你在主 dtb 编译时没有加-随后在用户态用configfs加载 overlay内核会找不到目标符号直接报failed to apply。我早期非常困扰后来才意识到问题出在“底层 dtb 是否带符号信息”上。想要快速验证 dtb 有没有符号信息直接执行dtc -I dtb -O dts -o out.dts main.dtb看看输出里有没有__symbols__节点没有就说明编译时没收纳符号信息。5.5 高频问题速查现象可能原因排查建议内核启动 log 打印 dtb magic 不对bootloader 加载地址不对检查 bootloader 内存布局确认 dtb 起始地址compatible 匹配不上dts 与驱动字符串不一致cat /proc/device-tree或 sysfs 查看实际节点字符串驱动 probe 没调起来节点 status 不为 okay检查 status 属性必要时临时改成 okayreg 拿到地址与预期不同父节点 address/size cells 定义不当用od -tx4查看 raw reg 数据中断触发混乱interrupt-parent 指向错误增加显式 interrupt-parent核对#interrupt-cellsdtc 编译报 unit address 不匹配节点名与 reg 首地址不一致修正节点名或 reg保持两者一致overlay 加载失败主 dtb 缺少symbols重新用-参数编译主 dtbsysfs 里找不到节点烧录的 dtb 是旧版在编译目录检查 dtb 时间戳和内容这几种问题是设备树使用频率最高的坑也是我在实际调试中反复遇到的。设备树本身并不难理解难的是所有细节都要求严格一致compatible 字符串、寄存器长度、中断格式、父子单元格约束、编译选项任何一环出了偏差现象都可能千奇百怪。但我个人最大的体会是一旦你把设备树当成“硬件数据的序列化协议”而不是“配置文件”很多谜团会自然解开。它只是用树形结构把硬件信息从代码里剥离出来传给内核进行运行时解析而已所有属性都服务于一个目标让内核正确、高效地认识硬件。之后再有“某节点注册不上”的诡异问题记住一条排查主线就够用先看 dtb 对不对再看节点属性对不对最后才回头查驱动代码。