
1. 从一块裸板到能被内核识别的板子设备树到底在解决什么问题手里拿到一块 i.MX6ULL 或者 RK3568 的核心板焊好串口、接上电源U-Boot 能跑起来内核也编译出来了但你会发现一个很尴尬的事内核根本不知道这块板子上有什么。它不知道内存多大、串口挂在哪个地址、I2C 上接了哪颗 PMIC、网口的 PHY 用的是 YT8521 还是别的型号。早期 ARM Linux 的做法是把这些信息硬编码在arch/arm/mach-xxx下面板子一多内核源码里全是#ifdef改一个引脚要重新编译整个内核维护成本高得离谱。设备树Device Tree就是来解决这个问题的。它把板子上有什么硬件、这些硬件怎么连、地址是多少、中断走哪根线这些描述性信息从内核代码里剥离出来变成一份独立的、可以被 bootloader 传给内核的数据结构。内核启动时解析这份数据动态生成对应的platform_device再和驱动里的platform_driver做匹配。同一份内核镜像配不同的设备树就能跑在不同的板子上——这才是嵌入式 Linux 真正意义上的一套内核多板复用。关键词里的DTCDevice Tree Compiler是把人类可读的.dts源文件编译成内核能识别的.dtb二进制 blob 的工具DTS是源文件DTSI是可以被 include 的公共片段DTB是编译产物。搞嵌入式 Linux 的人尤其是做 i.MX6ULL、RK3568 这类国产 SoC 的设备树是绕不过去的基本功。这篇内容适合已经能编译内核、能跑通串口但对设备树还停留在照着教程改改阶段的开发者。我会从零开始把一块假想的板子描述出来把每一步为什么这么做讲清楚。2. 动手之前先把设备树的语法骨架吃透2.1 节点、属性、phandle三个必须建立直觉的概念设备树的本质是一棵树每个节点node代表一个设备或一个总线节点里挂属性property属性用键值对描述这个设备的特征。最直观的类比是 JSON但比 JSON 多了几个嵌入式特有的东西。一个最简节点长这样uart1: serial02020000 { compatible fsl,imx6ul-uart, fsl,imx6q-uart; reg 0x02020000 0x4000; interrupts GIC_SPI 26 IRQ_TYPE_LEVEL_HIGH; clocks clks IMX6UL_CLK_UART1_IPG, clks IMX6UL_CLK_UART1_SERIAL; status okay; };uart1:前面这个叫标签label它本身不参与编译进 DTB 的最终结构但可以被其他节点用uart1引用。serial02020000是节点名后面是单元地址unit-address通常就是寄存器基地址用来区分同类型的不同节点。compatible是设备树里最重要的属性没有之一。它是一个字符串列表内核拿它去和驱动的of_device_id表做匹配。匹配顺序是从左到右第一个最具体最后一个最通用。上面这个例子内核会先找fsl,imx6ul-uart的驱动找不到再退到fsl,imx6q-uart。这个设计很巧妙——新 SoC 可以复用老 SoC 的驱动只要硬件寄存器兼容。reg描述地址范围格式是基地址 长度可以有多个。interrupts描述中断具体几个 cell 由中断控制器的#interrupt-cells决定。clocks引用时钟clks就是一个 phandle 引用。phandle这个词新手最容易懵。简单说当你写clks时DTC 会在编译时把clks这个节点的 phandle 值一个唯一整数填进去。运行时内核通过这个整数找到对应节点。你可以把它理解成设备树内部的指针。2.2 DTSI 与 DTS 的分工为什么不要把所有东西写在一个文件里实际项目里你几乎不会从零写一个完整的.dts。SoC 厂商会提供soc.dtsi里面描述了 SoC 内部所有外设的寄存器地址、中断号、时钟板级厂商再写board.dtsinclude 这个 dtsi然后只描述这块板子上哪些外设被引出来了、接了什么、引脚怎么复用。// imx6ull-myboard.dts #include imx6ull.dtsi #include imx6ull-pinfunc.h / { model My Custom i.MX6ULL Board; compatible fsl,imx6ull-myboard, fsl,imx6ull; chosen { stdout-path uart1; }; memory80000000 { device_type memory; reg 0x80000000 0x20000000; // 512MB }; }; uart1 { pinctrl-names default; pinctrl-0 pinctrl_uart1; status okay; };这种分层的好处是SoC 层面的东西厂商维护你只关心板级差异。i.MX6ULL 的 dtsi 里uart1默认status disabled因为不是每块板子都用它。你在板级 dts 里把它改成okay再补上引脚配置它就活了。这个默认关闭、按需开启的约定是设备树能保持整洁的关键。2.3 编译链路从 .dts 到 .dtb 再到内核里的展开编译设备树用 DTC命令很直接dtc -I dts -O dtb -o myboard.dtb myboard.dts但在内核源码树里你一般不用手动调 dtc而是make ARCHarm CROSS_COMPILEarm-linux-gnueabihf- dtbs内核的 Makefile 会自动处理 include 路径、预处理宏是的dts 支持 C 预处理器可以用#define。生成的 dtb 放在arch/arm/boot/dts/下。U-Boot 启动时把 dtb 加载到内存某个地址然后bootz时把 dtb 地址作为第三个参数传给内核。内核在setup_arch阶段调用unflatten_device_tree把扁平的 dtb 展开成内核里的device_node树然后of_platform_populate遍历这棵树为每个有compatible的节点创建platform_device。这里有个细节值得注意dtb 里的节点顺序不重要但 phandle 引用必须在编译时能解析。如果你写uart1但 uart1 这个 label 不存在dtc 会直接报错不会生成 dtb。这个检查在编译期就拦住了大部分低级错误。3. 给一块假想板子写设备树从内存到串口的完整描述3.1 内存节点内核启动的第一份地图内存节点是设备树里最基础也最不能出错的部分。内核需要知道物理内存的起始地址和大小才能建立页表、初始化伙伴系统。memory80000000 { device_type memory; reg 0x80000000 0x20000000; };i.MX6ULL 的 DDR 通常映射在0x80000000大小看你的颗粒。0x20000000是 512MB。如果你焊的是 256MB这里就得改成0x10000000。写大了不会立刻崩但系统跑到某个内存压力大的场景会随机 oops写小了则浪费一半内存。我见过有人抄了参考板的 dts结果自己的板子只有 256MB内核启动到一半就挂了查了半天才发现是这里。device_type memory这个属性是历史遗留现代内核其实靠节点名memory和reg就能识别但保留它没坏处很多工具链还认这个。3.2 串口节点调试的第一根救命稻草串口是嵌入式开发的生命线设备树里必须把它配通。以 i.MX6ULL 的 UART1 为例uart1 { pinctrl-names default; pinctrl-0 pinctrl_uart1; status okay; }; iomuxc { pinctrl_uart1: uart1grp { fsl,pins MX6UL_PAD_UART1_TX_DATA__UART1_DCE_TX 0x1b0b1 MX6UL_PAD_UART1_RX_DATA__UART1_DCE_RX 0x1b0b1 ; }; };这里有两个关键点。第一pinctrl-0引用的pinctrl_uart1节点必须在iomuxc下面定义因为 i.MX 的引脚复用控制器本身也是一个设备树节点。第二fsl,pins里的每个条目是引脚宏 配置值引脚宏来自imx6ull-pinfunc.h配置值是一个 32 位整数编码了上下拉、驱动强度、速度等电气参数。0x1b0b1这个值不是随便写的。拆开看低 5 位是驱动强度中间几位是上下拉和开漏配置高位是转换速率。i.MX6ULL 的参考手册里有详细位定义。新手最容易犯的错是直接抄参考板的配置值但自己的板子串口走线长度不同、电平转换芯片不同电气参数需要调整。如果串口能出字符但偶尔乱码八成是这里的驱动强度或上下拉不对。status okay是开关。dtsi 里默认是disabled你不改这一行串口永远不会注册。3.3 I2C 与挂载设备以一颗 PMIC 为例I2C 总线本身是个节点挂在它下面的设备是子节点。假设我们用 I2C1 接了一颗 PFUZE100 电源管理芯片i2c1 { clock-frequency 100000; pinctrl-names default; pinctrl-0 pinctrl_i2c1; status okay; pmic: pfuze10008 { compatible fsl,pfuze100; reg 0x08; regulators { sw1a_reg: sw1ab { regulator-min-microvolt 300000; regulator-max-microvolt 1875000; regulator-always-on; }; }; }; };reg 0x08是 I2C 从机地址。compatible匹配到 PFUZE100 的驱动后驱动会去读芯片 ID 确认然后注册 regulator。regulator-always-on表示这个电源轨不能被关闭通常给 CPU 核心或 DDR 供电的轨都要加这个。这里有个坑I2C 设备的reg地址是 7 位地址但有些 datasheet 给的是 8 位含读写位。比如 datasheet 写 0x10实际 7 位地址是 0x08。写错了驱动 probe 会失败报no such device。我一般会先用i2cdetect -y 1在能跑的系统上扫一遍确认地址再写进设备树。3.4 网口与 PHYYT8521 这类国产 PHY 的设备树写法网口是设备树里相对复杂的部分因为它涉及 MAC、PHY、MDIO 总线三者的关系。以 YT8521 为例fec1 { pinctrl-names default; pinctrl-0 pinctrl_enet1; phy-mode rmii; phy-handle ethphy0; status okay; mdio { #address-cells 1; #size-cells 0; ethphy0: ethernet-phy0 { compatible ethernet-phy-ieee802.3-c22; reg 0; reset-gpios gpio5 7 GPIO_ACTIVE_LOW; reset-assert-us 10000; reset-deassert-us 100000; }; }; };phy-mode决定 MAC 和 PHY 之间的接口类型RMII 和 RGMII 的引脚配置完全不同。phy-handle指向具体的 PHY 节点。reset-gpios描述 PHY 的复位引脚reset-assert-us和reset-deassert-us是复位时序单位微秒。YT8521 这类 PHY 有时候需要额外的寄存器配置比如驱动强度、时钟延迟。这些通常通过phy-supply或者厂商自定义属性传递。如果网口能识别 PHY 但 ping 不通先查phy-mode和时钟再查 PHY 的 strap 引脚配置。很多国产 PHY 的默认 strap 和参考设计不一致硬件上拉下拉电阻没焊对设备树怎么写都没用。4. 编译、烧录、验证把设备树真正跑起来4.1 编译时的常见报错与定位方法编译设备树最常见的错误有三类。第一类是语法错误比如少了个分号、括号不匹配dtc 会直接告诉你行号。第二类是 phandle 引用错误比如uart1找不到报Label or path uart1 not found。第三类是reg格式错误比如#address-cells和#size-cells不匹配导致 cell 数量对不上。# 单独编译一个 dts方便快速验证 dtc -I dts -O dtb -o /tmp/test.dtb arch/arm/boot/dts/imx6ull-myboard.dts # 反编译 dtb 回 dts检查最终生成的内容 dtc -I dtb -O dts -o /tmp/test.dts /tmp/test.dtb反编译这一步非常有用。你写的 dts 经过预处理、include 展开、phandle 填充后最终长什么样反编译一看便知。有时候你改了 dts 但忘了重新编译 dtb烧进去的还是旧的反编译对比一下就能确认。4.2 内核启动日志里怎么确认设备树被正确解析内核启动时串口会打印大量信息。关键看这几行OF: fdt: Machine model: My Custom i.MX6ULL Board ... imx6ul-pinctrl 20e0000.iomuxc: initialized IMX pinctrl driver 20a0000.serial: ttymxc0 at MMIO 0x20a0000 (irq 26, base_baud 5000000) is a IMX ... fec 2188000.ethernet eth0: registered PHC device 0Machine model来自设备树的model属性确认你烧的是对的 dtb。串口那行会打印出 MMIO 地址和中断号和你在 dts 里写的一致就说明解析成功。网口那行如果出现registered PHC device说明 MAC 和 PHY 都通了。如果某个设备没出现先看/proc/device-tree/下有没有对应节点ls /proc/device-tree/ cat /proc/device-tree/model cat /proc/device-tree/soc/serial02020000/status/proc/device-tree/是内核把解析后的设备树以文件系统形式暴露出来的接口每个节点是一个目录每个属性是一个文件。这是验证设备树最直接的手段。4.3 用 of 系列接口在驱动里读取设备树如果你在写驱动设备树里的信息通过of_系列函数读取struct device_node *np pdev-dev.of_node; u32 reg_base; of_property_read_u32(np, reg, reg_base); const char *model; of_property_read_string(np, model, model); int gpio of_get_named_gpio(np, reset-gpios, 0);of_property_read_u32读单个整数of_property_read_u32_array读数组of_get_named_gpio读 GPIO。这些函数在linux/of.h里声明。注意of_property_read_*系列在属性不存在时返回负值不会崩溃所以驱动里要判断返回值。我见过有人不判断返回值直接用属性没配时读到的是栈上的随机值行为完全不可预测。5. 那些教程不会告诉你的设备树实战经验5.1 引脚复用冲突两个节点抢同一个 pin 怎么办i.MX6ULL 的引脚复用是设备树里最容易出问题的地方。假设你把 UART1 的 TX 引脚同时配给了 GPIO 和 UARTpinctrl 子系统在注册时会检测冲突但检测不是万能的。如果两个节点的pinctrl-0引用了同一个 pin 的不同功能后注册的会覆盖先注册的表现为某个外设时好时坏。排查方法是看/sys/kernel/debug/pinctrl/下的状态cat /sys/kernel/debug/pinctrl/20e0000.iomuxc/pinmux-pins这个文件会列出每个 pin 当前被哪个设备占用。如果发现某个 pin 被两个设备引用就得回去改设备树把不用的那个节点的status改成disabled或者把引脚配置拆开。经验做法在板级 dts 里把所有用不到的 SoC 外设节点显式status disabled。虽然 dtsi 里默认就是 disabled但有些厂商的 dtsi 会默认开启一些外设你不关掉它就会去抢引脚。5.2 时钟与 regulator 的依赖顺序为什么 probe 会 deferred设备树里节点顺序不代表 probe 顺序。内核的驱动模型是异步的如果一个驱动依赖的时钟或 regulator 还没准备好它会返回-EPROBE_DEFER内核把它放到 deferred 列表里稍后重试。uart1 { clocks clks IMX6UL_CLK_UART1_IPG, clks IMX6UL_CLK_UART1_SERIAL; clock-names ipg, per; };clock-names的顺序必须和clocks一一对应。驱动里用clk_get(dev, ipg)拿第一个clk_get(dev, per)拿第二个。名字写错了clk_get返回错误指针驱动 probe 失败。如果启动日志里看到大量deferred probe pending说明有依赖没满足。等所有驱动都加载完/sys/kernel/debug/devices_deferred会列出还在等待的设备。常见原因是 regulator 节点没配好或者时钟控制器驱动没加载。5.3 从 RK3568 的触摸屏旋转看设备树的灵活性RK3568 平台上有个很典型的场景触摸屏默认竖屏要改成横屏。这不需要改驱动代码改设备树就行。触摸屏的compatible通常是goodix,gt9xx或类似驱动里会读一个touchscreen-swapped-x-y或者touchscreen-inverted-x的属性i2c3 { gt9xx: touchscreen5d { compatible goodix,gt9xx; reg 0x5d; touchscreen-swapped-x-y; touchscreen-inverted-x; }; };这两个布尔属性告诉驱动交换 X/Y 轴、翻转 X 轴组合起来就是旋转 90 度。这种硬件描述驱动行为的设计是设备树的精髓——同一个驱动配不同的属性适配不同的硬件形态。你不需要为每个屏幕方向编译不同的驱动。5.4 设备树 overlay不重新编译内核就能改硬件描述产品量产之后如果只是改一个 GPIO 或者加一个 I2C 设备重新编译整个 dtb 再烧录很麻烦。设备树 overlaydtbo允许你在运行时动态修改设备树。# 编译 overlay dtc -I dts -O dtb -o myoverlay.dtbo myoverlay.dts # 在 U-Boot 里应用 fdt addr ${fdt_addr} fdt resize fdt apply ${overlay_addr}Overlay 的语法和普通 dts 类似但用fragment包裹/dts-v1/; /plugin/; {/soc/i2c021a0000} { status okay; newdevice20 { compatible my,newdevice; reg 0x20; }; };/plugin/标记这是 overlay{/path}用绝对路径引用目标节点。Overlay 在 RK3568 这类支持动态配置的平台上用得很多尤其是做多屏、多传感器配置的产品。6. 设备树调试的完整排查链路一个真实案例的复盘6.1 现象网口 PHY 识别不到但 MDIO 总线是通的之前调过一块板子i.MX6ULL YT8521内核启动后eth0不出现但mdio总线上能看到地址 0 有设备响应。这说明 MDIO 通信正常问题出在 PHY 驱动匹配或者 PHY 初始化。第一步确认设备树里 PHY 节点的compatible。YT8521 的驱动在drivers/net/phy/下compatible应该是ethernet-phy-ieee802.3-c22这是通用 PHY 的 compatible驱动会去读 PHY ID 寄存器来进一步匹配。如果写成了ethernet-phy-ieee802.3-c45那就匹配错了。第二步看内核日志里有没有PHY ID相关的打印。正常应该看到类似YT8521 Gigabit Ethernet的字样。如果没有说明 PHY 驱动没匹配上可能是 PHY ID 读出来是 0 或者 0xffffffff。第三步查复位时序。YT8521 的复位引脚如果没配好PHY 一直处于复位状态MDIO 能通但 PHY 不工作。设备树里的reset-gpios和reset-deassert-us必须和硬件一致。我们那块板子的复位引脚是 GPIO5_7低电平复位复位后需要至少 100ms 才能访问。设备树里写的是reset-deassert-us 100000对应 100ms。第四步用mii-tool或ethtool在能跑的系统上直接读 PHY 寄存器ethtool --phy-statistics eth0 mii-tool -v eth0如果 PHY 寄存器读出来全是 0基本可以确定是复位或者时钟问题。最后发现是phy-mode写成了rgmii但硬件实际是rmii导致 MAC 和 PHY 的时钟方向不对PHY 收不到正确的时钟。6.2 排查设备树问题的通用思路从上面的案例可以总结出一套通用方法。先确认设备树节点有没有被内核解析看/proc/device-tree/下有没有对应目录。再确认驱动有没有匹配看dmesg | grep 驱动名。然后确认依赖资源时钟、GPIO、regulator有没有拿到看/sys/kernel/debug/clk/和/sys/kernel/debug/gpio。最后确认硬件本身有没有问题用示波器量时钟、量复位引脚。设备树的问题80% 是写错了但没报错——属性名拼错、cell 数量不对、phandle 引用错。所以反编译 dtb 和对比参考 dts 是最有效的两个手段。我习惯在改完设备树后先反编译看一眼最终生成的节点确认属性都在再烧录。6.3 几个容易忽略的细节#address-cells和#size-cells是父节点定义的子节点的reg格式必须遵守。I2C 控制器通常#address-cells 1、#size-cells 0因为 I2C 设备只有地址没有长度。SPI 控制器类似。但 memory 节点的父节点根节点通常是#address-cells 1、#size-cells 1所以reg是地址 长度。interrupt-parent可以写在根节点也可以写在每个设备节点。如果写在根节点所有子节点默认继承。i.MX6ULL 的根节点里通常有interrupt-parent gpc但具体外设的中断又通过intc转发这个层级关系要看清楚。aliases节点用来给设备起别名比如serial0 uart1;这样内核打印时用ttymxc0而不是20a0000.serial。别名不影响功能但影响可读性。7. 从设备树延伸到嵌入式 Linux 的硬件描述思维设备树不只是一份配置文件它代表了一种硬件描述与驱动分离的架构思想。你写设备树的过程本质上是在用内核能理解的语言把一块板子的硬件拓扑翻译出来。这个能力在换平台时尤其值钱——从 i.MX6ULL 换到 RK3568SoC 相关的 dtsi 完全不同但板级 dts 的结构、pinctrl 的写法、regulator 的描述方式思路是相通的。我个人的习惯是拿到一块新板子先不急着改驱动而是把设备树里所有外设节点过一遍确认status、compatible、reg、pinctrl、clocks这五项。这五项对了大部分外设就能工作。剩下的问题要么是硬件本身要么是驱动里的特殊配置都可以通过设备树属性来传递。最后分享一个实用技巧把/proc/device-tree/整个目录打包备份出问题时和正常版本的做 diff能快速定位是哪次修改引入的。这个目录是只读的但可以tar出来tar -czf dt-backup-$(date %Y%m%d).tar.gz /proc/device-tree/设备树这东西看十遍文档不如自己从头写一遍。找一块能跑的板子把它的 dts 删到只剩内存和串口然后一个一个把外设加回来每加一个验证一次。这个过程走完你对设备树的理解会比看任何教程都扎实。