
1. 从一个真实需求说起为什么要手写设备树很多刚接触嵌入式Linux的朋友拿到一块i.MX6ULL或者RK3568的开发板第一反应是找厂商提供的SDK把现成的镜像烧进去能跑起来就万事大吉。但一旦板子上换了一颗不同型号的PHY芯片或者要把一块SPI接口的OLED屏接到另一组引脚上立刻就懵了——内核启动日志里一堆probe failed驱动死活加载不上串口打印出来的错误信息看得云里雾里。这个问题的根源十有八九出在设备树上。设备树Device Tree本质上就是一份用特定语法写成的“硬件说明书”。内核启动的时候会读这份说明书知道这块板子上有什么设备、这些设备挂在哪个总线上、用哪些引脚、中断线接在哪里、时钟从哪来。没有这份说明书内核就不知道该怎么初始化硬件驱动也就无从匹配。我见过太多人在这上面栽跟头有人直接把厂商的设备树文件复制过来改个名字结果引脚复用冲突导致系统起不来有人改了设备树但忘了重新编译dtb烧进去的还是旧文件还有人压根不知道dtc这个工具怎么用面对一堆.dts、.dtsi、.dtb、.dtbo文件完全分不清谁是谁。这篇内容就是要把“从零构建一个嵌入式板级设备树描述”这件事讲透。不管你是刚入行的嵌入式新人还是从单片机转过来的老手只要跟着走一遍就能搞清楚设备树的来龙去脉能自己动手写出一份可用的板级设备树并且知道出问题的时候该往哪个方向排查。涉及的核心工具链包括dtc编译器、Linux内核的DTS目录结构以及i.MX6ULL和RK3568这两个典型平台的设备树组织方式。2. 设备树的核心概念与文件体系拆解2.1 设备树到底解决了什么问题在设备树出现之前ARM架构的Linux内核里充斥着大量的板级支持代码也就是所谓的board-xxx.c文件。每支持一块新板子就要往内核里加一堆C代码来描述硬件。这些代码散落在arch/arm/mach-xxx/目录下维护起来极其痛苦。Linus当时就发过火说ARM的代码乱得像一锅粥。设备树的核心思路是把“硬件描述”和“驱动代码”彻底分开。驱动只负责怎么操作这一类设备至于这块板子上到底有没有这个设备、挂在哪个地址、用哪个中断全部交给设备树来描述。这样一来同一份驱动代码可以适配无数块不同的板子只需要换一份设备树文件就行。打个比方驱动就像是通用的打印机驱动程序设备树就像是告诉系统“这台电脑上装了一台HP LaserJet接在USB口上”的那份配置信息。驱动不用关心你接的是哪台打印机设备树告诉它就行了。2.2 DTS、DTSI、DTB、DTBO到底谁是谁刚接触设备树的人最容易被这一堆缩写搞晕。我用一张表把它们的关系理清楚文件类型全称作用类比.dtsDevice Tree Source板级设备树源文件描述具体一块板子一份完整的配置文档.dtsiDevice Tree Source Include被包含的公共描述文件类似头文件文档模板/公共片段.dtbDevice Tree Blobdtc编译后生成的二进制文件内核实际读取的编译后的可执行文件.dtboDevice Tree Blob Overlay设备树叠加层用于动态修改已有设备树补丁文件实际项目中SoC厂商会把芯片内部的公共部分比如CPU、内存控制器、GPIO控制器、I2C控制器等写在一个.dtsi文件里比如imx6ull.dtsi。然后每个具体的板子写一个.dts文件通过#include把SoC的.dtsi包含进来再补充板子特有的外设描述。这种分层设计的逻辑很清晰SoC级别的描述是共用的板子级别的描述是差异化的。你换一块板子只需要改.dts文件.dtsi基本不用动。2.3 设备树的语法骨架设备树的语法其实不复杂核心就是节点node和属性property。一个最简单的设备树结构长这样/dts-v1/; #include imx6ull.dtsi / { model My Custom Board; compatible fsl,imx6ull-14x14-evk, fsl,imx6ull; chosen { stdout-path uart1; }; memory80000000 { device_type memory; reg 0x80000000 0x20000000; }; }; uart1 { pinctrl-names default; pinctrl-0 pinctrl_uart1; status okay; };几个关键点需要理解/是根节点所有设备都挂在它下面compatible属性是驱动匹配的关键内核用这个字符串去找到对应的驱动reg属性描述寄存器地址范围格式是起始地址 长度status属性控制设备是否启用okay表示启用disabled表示禁用uart1这种写法是引用一个已经定义好的节点往里面追加内容注意compatible属性的值必须和驱动代码里of_device_id表中定义的字符串完全一致差一个字符都匹配不上。这是新手最常踩的坑之一。3. 从零开始构建i.MX6ULL板级设备树3.1 准备工作源码目录结构与工具链确认在动手写设备树之前先把环境理清楚。假设你已经拿到了Linux内核源码以i.MX6ULL为例设备树文件通常放在arch/arm/boot/dts/ ├── imx6ull.dtsi # SoC级公共描述 ├── imx6ull-14x14-evk.dts # 官方EVK板设备树 ├── imx6ull-myboard.dts # 我们要新建的板级设备树 └── Makefile # 编译规则确认dtc工具已经安装。一般内核源码的scripts/dtc/目录下就有也可以单独安装# 检查dtc版本 dtc --version # 如果没有在Ubuntu上安装 sudo apt-get install device-tree-compilerdtc的版本很重要太老的版本可能不支持某些新语法。建议用4.0以上的版本。内核自带的dtc通常和内核版本匹配优先用内核自带的。3.2 创建板级DTS文件从复制到改造最稳妥的做法不是从空白文件开始写而是拿一个最接近的官方板级文件作为基础来改。i.MX6ULL的EVK板设备树imx6ull-14x14-evk.dts就是一个很好的起点。cd arch/arm/boot/dts/ cp imx6ull-14x14-evk.dts imx6ull-myboard.dts然后修改Makefile加上我们自己的dtb编译目标dtb-$(CONFIG_SOC_IMX6ULL) \ imx6ull-14x14-evk.dtb \ imx6ull-myboard.dtb接下来就是逐项改造。第一步改model和compatible/ { model My Company i.MX6ULL Board; compatible mycompany,imx6ull-myboard, fsl,imx6ull; };这里compatible里保留fsl,imx6ull是有意为之的。因为很多SoC级别的驱动匹配的是这个通用字符串去掉它可能导致某些控制器驱动加载不上。前面加上自己板子的专属字符串是为了让板级特有的驱动能精确匹配。3.3 内存节点与chosen节点配置内存节点描述了系统可用的DDR范围。i.MX6ULL常见配置是512MB DDR3起始地址0x80000000memory80000000 { device_type memory; reg 0x80000000 0x20000000; };0x20000000换算成十进制是512MB。如果你板子上焊的是256MB的DDR这里就要改成0x10000000。这个值写错了内核启动时会出现内存越界或者只识别到一部分内存的情况。chosen节点用来指定内核启动参数和标准输出设备chosen { stdout-path uart1; bootargs consolettymxc0,115200 root/dev/mmcblk1p2 rootwait rw; };stdout-path指定内核日志从哪个串口输出。i.MX6ULL的UART1对应的是ttymxc0。bootargs里的root指定根文件系统所在的分区这个要根据实际存储设备来填。实操心得bootargs其实可以在U-Boot里通过bootargs环境变量覆盖设备树里的这个配置更多是作为默认值。实际调试阶段建议在U-Boot里设置改起来更方便不用重新编译dtb。3.4 引脚复用配置pinctrl子系统的设备树写法这是设备树里最容易出错的部分。i.MX6ULL的每个引脚都有多种功能比如一个引脚可以当GPIO、可以当UART的TX、也可以当I2C的SCL。具体当什么用要通过IOMUXC控制器的寄存器来配置。在设备树里引脚配置通过pinctrl子系统来描述。先定义一个引脚配置节点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_i2c1: i2c1grp { fsl,pins MX6UL_PAD_UART4_TX_DATA__I2C1_SCL 0x4001b8b0 MX6UL_PAD_UART4_RX_DATA__I2C1_SDA 0x4001b8b0 ; }; };MX6UL_PAD_UART1_TX_DATA__UART1_DCE_TX这个宏定义在imx6ul-pinfunc.h里它编码了引脚寄存器偏移、复用模式、输入输出配置等信息。后面那个0x1b0b1是引脚的电气特性配置包括上下拉、驱动能力、转换速率等。这个十六进制值的每一位都有含义以0x1b0b1为例bit 0-2转换速率和驱动能力bit 3-5输出驱动强度bit 6-10保留bit 11开漏使能bit 12上下拉/保持器选择bit 13上下拉使能bit 14-15上下拉强度bit 16滞回比较器使能不同SoC的编码方式不一样i.MX6ULL的可以参考芯片参考手册的IOMUXC章节。实际项目中最省事的办法是直接参考官方EVK板设备树里相同功能的配置值因为官方值都是经过验证的。然后在UART节点里引用这个配置uart1 { pinctrl-names default; pinctrl-0 pinctrl_uart1; status okay; };pinctrl-names定义状态名称pinctrl-0引用对应的引脚配置节点。驱动在probe的时候会自动应用这些配置。3.5 I2C设备挂载以OLED屏为例假设我们要在I2C1总线上挂一块SSD1306驱动的OLED屏地址是0x3C。在设备树里这样写i2c1 { clock-frequency 100000; pinctrl-names default; pinctrl-0 pinctrl_i2c1; status okay; oled3c { compatible solomon,ssd1306; reg 0x3c; status okay; }; };clock-frequency设置I2C总线速率标准模式100kHz快速模式400kHz。SSD1306一般100kHz就够了速率太高可能导致通信不稳定。reg 0x3c是I2C从设备地址。这个地址要跟硬件实际连接一致SSD1306的地址由SA0引脚决定接地是0x3C接VCC是0x3D。compatible字符串要和驱动里的匹配。如果内核里没有现成的SSD1306驱动你可能需要自己写一个或者用通用的I2C字符设备驱动来操作。常见坑I2C设备挂载后驱动probe失败先检查三件事——设备地址对不对、引脚配置有没有冲突、上拉电阻有没有焊。i.MX6ULL的I2C引脚内部有可配置的上拉但外部通常还需要4.7kΩ的上拉电阻。4. 编译、烧录与验证的完整流程4.1 用dtc编译设备树设备树的编译有两种方式单独用dtc编译或者通过内核的Makefile编译。推荐用内核的Makefile因为会自动处理头文件依赖。# 在内核源码根目录执行 make ARCHarm CROSS_COMPILEarm-linux-gnueabihf- imx6ull-myboard.dtb编译成功后会在arch/arm/boot/dts/目录下生成imx6ull-myboard.dtb。如果想单独用dtc编译命令是这样的# 先通过C预处理器处理#include cpp -nostdinc -I include -I arch/arm/boot/dts \ -undef -x assembler-with-cpp \ arch/arm/boot/dts/imx6ull-myboard.dts /tmp/myboard.pre.dts # 再用dtc编译 dtc -I dts -O dtb -o imx6ull-myboard.dtb /tmp/myboard.pre.dts单独编译的好处是快改一行编译一次也就一两秒。但要注意手动处理#include路径不然会报找不到头文件的错误。4.2 反编译dtb检查内容编译出来的dtb是二进制文件不能直接看。可以用dtc反编译回dts来检查dtc -I dtb -O dts -o /tmp/check.dts imx6ull-myboard.dtb这个操作在排查问题的时候特别有用。比如你怀疑某个节点的属性没写对反编译出来一看便知。有时候内核实际读取的dtb和你以为的不是同一个反编译确认一下能省很多调试时间。4.3 烧录与启动验证dtb文件的烧录方式取决于你的启动方式。如果是SD卡启动dtb通常放在FAT分区里和zImage放在一起# 挂载SD卡的boot分区 sudo mount /dev/sdb1 /mnt/boot # 复制dtb sudo cp imx6ull-myboard.dtb /mnt/boot/ # 卸载 sudo umount /mnt/boot然后在U-Boot里设置启动参数setenv fdt_file imx6ull-myboard.dtb setenv bootargs consolettymxc0,115200 root/dev/mmcblk1p2 rootwait rw boot系统启动后通过以下几个地方验证设备树是否生效# 查看内核识别的设备树模型 cat /proc/device-tree/model # 查看某个节点的属性 ls /proc/device-tree/soc/aips-bus02100000/i2c021a0000/ # 查看platform设备列表 ls /sys/bus/platform/devices/ # 查看i2c设备是否注册成功 ls /sys/bus/i2c/devices/如果/proc/device-tree/下能看到你添加的节点说明设备树已经被内核正确解析了。如果驱动还是没加载那问题就出在驱动匹配或者硬件初始化上而不是设备树本身。4.4 设备树调试的常用手段内核启动日志是排查设备树问题的第一手资料。把串口输出的完整日志保存下来搜索关键词of_开头的函数名设备树解析相关的日志probe failed驱动探测失败not found找不到某个节点或属性invalid属性值格式错误如果日志级别不够可以在bootargs里加loglevel8或者ignore_loglevel来打印更多信息。另外/sys/firmware/devicetree/base/目录下是设备树的完整映射和/proc/device-tree/是同一个东西的不同挂载点。可以直接用hexdump或者od命令查看二进制属性值# 查看reg属性的原始值 hexdump -C /proc/device-tree/soc/aips-bus02100000/i2c021a0000/oled3c/reg5. 常见问题排查与避坑指南5.1 驱动probe失败的五种典型原因设备树写完了驱动却加载不上这是最常见的情况。我整理了一个排查表现象可能原因排查方法驱动完全不加载compatible不匹配检查驱动of_device_id表和dts里的字符串驱动加载但probe失败引脚配置冲突检查pinctrl是否有其他节点占用了同组引脚probe返回-EPROBE_DEFER依赖的时钟/ regulator未就绪检查clocks、vcc-supply等属性设备节点不存在status为disabled确认status okay地址冲突reg属性与其他设备重叠检查reg地址范围是否重复compatible不匹配是最常见的问题。内核匹配驱动的逻辑是拿设备树节点的compatible属性值去遍历所有驱动的of_device_id表找到完全一致的字符串才调用驱动的probe函数。注意是“完全一致”大小写、连字符、逗号都不能差。5.2 引脚复用冲突的排查思路i.MX6ULL的引脚复用有个特点同一个物理引脚可以被多个设备树节点引用但实际生效的只有最后配置的那个。如果两个节点都配置了同一个引脚后配置的会覆盖先配置的导致先配置的设备工作异常。排查方法是在内核启动日志里搜索pinctrl相关的警告信息。另外可以查看/sys/kernel/debug/pinctrl/目录下的引脚状态# 查看所有引脚的当前复用状态 cat /sys/kernel/debug/pinctrl/*/pinmux-pins这个输出会列出每个引脚被哪个设备树节点占用。如果发现某个引脚被两个节点同时引用那就是冲突了。避坑技巧在设备树里给每个pinctrl节点起一个有意义的名字比如pinctrl_uart1、pinctrl_i2c1不要用pinctrl_1、pinctrl_2这种。项目大了之后名字清晰能省很多事。5.3 dtb版本与内核版本不匹配有时候你改了设备树编译也通过了烧进去却发现改动没生效。大概率是dtb文件没有真正更新到启动分区。确认方法在U-Boot里用fdt addr命令查看当前加载的dtb地址然后用fdt print查看具体节点的属性值和你的dts文件对比。另一个可能的原因是内核编译时用了旧的dtb。make dtbs和make zImage是分开的有时候只编译了内核没编译设备树。养成习惯改完dts后执行make dtbs确认dtb文件的时间戳更新了。5.4 RK3568平台设备树的差异点虽然这篇内容以i.MX6ULL为主线但RK3568也是很多人关心的平台。两者的设备树思路一致但有几个差异需要注意RK3568的设备树文件在arch/arm64/boot/dts/rockchip/目录下是64位ARM架构。引脚配置用的是Rockchip自己的pinctrl框架语法和i.MX6ULL不同pinctrl { uart2 { uart2_xfer: uart2-xfer { rockchip,pins 1 RK_PA0 2 pcfg_pull_up, 1 RK_PA1 2 pcfg_pull_up; }; }; };rockchip,pins的格式是bank pin func configbank是GPIO组号pin是组内引脚号func是复用功能编号config是电气配置。另外RK3568的显示子系统设备树比较复杂涉及VOP、MIPI DSI、HDMI等多个节点。如果要改触摸屏方向通常不是改设备树而是在应用层通过libinput或者xinput来旋转。设备树里主要配置的是触摸控制器的中断引脚和I2C地址。5.5 设备树overlay的使用场景.dtbo文件用于在不重新编译整个设备树的情况下动态修改设备树内容。典型场景是同一块核心板配不同的底板或者同一个底板插不同的扩展模块。使用overlay需要在U-Boot里加载基础dtb和overlay dtbo然后应用# 在U-Boot中 load mmc 0:1 ${fdt_addr_r} base.dtb load mmc 0:1 ${fdt_addr_r_overlay} overlay.dtbo fdt addr ${fdt_addr_r} fdt resize 8192 fdt apply ${fdt_addr_r_overlay} bootz ${kernel_addr_r} - ${fdt_addr_r}overlay的dts写法有固定格式/dts-v1/; /plugin/; i2c1 { status okay; newdevice20 { compatible vendor,newdevice; reg 0x20; }; };/plugin/标记表示这是一个overlay文件。编译时用dtc -选项来保留符号信息否则overlay无法正确解析引用。6. 设备树编写的经验沉淀与进阶建议6.1 命名规范与代码组织设备树文件写多了之后命名规范的重要性就体现出来了。我的习惯是板级dts文件名用soc-boardname.dts格式比如imx6ull-myboard.dts节点标签用功能名比如pinctrl_uart1、pinctrl_i2c1不要用pinctrl_1自定义节点放在文件末尾用注释分隔开公共配置抽到.dtsi文件里板级差异留在.dts里如果项目有多个板子共用同一份SoC配置可以建一个myboard-common.dtsi把公共部分放进去各个板子的dts文件include它。这样改一处就能影响所有板子维护效率高很多。6.2 版本管理策略设备树文件一定要纳入版本管理。我见过有人直接在SDK里改设备树改完也不备份出了问题想回退都回不去。建议的做法是内核源码用git管理设备树文件跟着内核走。如果是独立维护的设备树单独建一个git仓库用submodule或者脚本的方式和内核源码关联。每次修改设备树commit message写清楚改了什么、为什么改。比如“增加I2C1上的SSD1306 OLED支持”就比“修改设备树”有用得多。6.3 从设备树反推硬件设计设备树不仅是给软件看的也是硬件设计的文档。一份写得好的设备树能让人清楚地知道这块板子上有什么外设、怎么连接的。我有个习惯拿到一块新板子先看它的设备树文件比看原理图还快。因为设备树里把关键信息都提炼出来了——I2C地址、SPI片选、中断引脚、时钟频率一目了然。反过来说写设备树的时候也要有“文档意识”。属性值不要写魔法数字能用宏定义的用宏定义。引脚配置后面加注释说明这个引脚接的是什么。这样过几个月再回来看或者交接给别人的时候能省很多沟通成本。6.4 持续学习的方向设备树涉及的知识面其实很广SoC的时钟树、电源管理、引脚控制器、总线协议每一个展开都是一大块内容。我的建议是先把手头的板子吃透把设备树写对、写稳然后再逐步深入。具体的学习路径可以这样走先能照着官方设备树改出自己的板级文件然后能独立添加新外设再然后能排查驱动probe问题最后能根据原理图从零写一份完整的设备树。每一步都需要实际动手光看文档是学不会的。工具方面dtc的man page值得通读一遍里面有很多编译选项在调试时很有用。内核源码里的Documentation/devicetree/bindings/目录是设备树属性的权威参考每个驱动的设备树绑定文档都在那里。遇到不确定的属性先去这个目录里搜一下比在网上到处找答案靠谱得多。我在实际项目中最大的体会是设备树这东西看十遍不如写一遍。找一个现成的开发板把它的设备树从头到尾改一遍加上自己的外设编译烧录验证走完整个流程比看任何教程都管用。踩过的坑越多记得越牢。