1. 从零开始DTS文件到底是什么如果你刚开始接触嵌入式Linux或者内核驱动开发第一次看到.dts文件时大概率会有点懵。满屏幕的/dts-v1/、/ { ... };、 、[ ]感觉像在看天书。别慌我第一次看的时候也这样。简单来说DTSDevice Tree Source就是一份用特定格式写的“硬件配置清单”它用一种结构化的文本语言把板子上有什么CPU、多少内存、哪些外设比如UART、I2C、GPIO、以及这些外设挂在哪个总线、地址是什么、中断号是多少都清清楚楚地列了出来。你可以把它想象成你去电脑城装机给老板的那张配置单“CPU要i7-12700主板要Z690内存32G DDR5显卡RTX 4080”。DTS文件就是给Linux内核的“装机单”。内核启动时会读取这个清单实际上是编译后的二进制DTB文件然后就知道“哦我跑的这个板子内存是从0x30000000开始的有64MB有一个串口在地址0x101F1000中断号是44还有一个LED灯接在GPIO的第5个引脚上。” 这样一来同一份内核镜像配上不同的DTS文件就能跑在不同的硬件板子上实现了内核与硬件描述的分离这是设备树技术带来的最大好处。那为什么需要这么个东西呢在设备树普及之前内核里充斥着大量的“板级文件”Board File里面全是针对某一块具体板子的硬编码。每换一块板子甚至同一块板子改个LED引脚都得去重新配置内核、编译内核麻烦不说还容易出错。设备树把这种硬件描述抽离出来变成了一个独立的、可编译的文件内核只需要通用的解析代码大大提升了可移植性和可维护性。所以学习DTS格式就是学习怎么写这份给内核的“硬件清单”。这份清单有它自己的语法和规矩掌握了它你就能让内核正确地认识和管理你的硬件。接下来我们就从最基础的“清单”结构开始一步步拆解。2. 庖丁解牛DTS文件的标准布局一份完整的DTS文件它的骨架是固定的就像一篇文章有开头、正文和结尾。我们来看一个最精简的例子我把每一部分都加上注释/dts-v1/; // 第一部分版本声明 /memreserve/ 0x30000000 0x10000; // 第二部分内存保留区可选 / { // 第三部分根节点一切从这里开始 model My Awesome Board; compatible my-company,my-board; #address-cells 1; #size-cells 1; memory30000000 { // 一个子节点描述内存 device_type memory; reg 0x30000000 0x4000000; // 64MB内存 }; leds { // 另一个子节点描述LED compatible gpio-leds; led0 { label heartbeat; gpios gpio0 5 0; // 使用GPIO0的第5号引脚 }; }; };2.1 开篇明义版本声明/dts-v1/;文件的第一行必须是这个。这就像C语言文件里的#include stdio.h或者脚本开头的#!/bin/bash告诉解析器dtc编译器“喂我这份清单是按照DTS版本1的语法写的请按这个规则来解析我。” 目前主流就是/dts-v1/;你基本不会碰到别的。少了这一行编译器会直接报错所以这是必选项。2.2 划出禁区内存保留区/memreserve/这一行是可选的。它用来告诉内核“这块内存区域你别用我另有安排。” 什么情况会用到呢我举两个实际踩过的坑。场景一Bootloader和内核的“交接棒”区域。有时候Bootloader比如U-Boot会把一些关键数据比如内核启动参数ATAGS或者更现代的UEFI系统表放在内存的某个固定位置然后告诉内核“数据我给你放这儿了你别覆盖它。” 这时候就需要用/memreserve/把这块区域保护起来。场景二给特殊功能预留内存。比如有些安全协处理器、或者特定的DMA引擎需要一块物理上连续且不被系统动用的内存。你也可以在这里预留。它的格式是/memreserve/ 起始地址 长度;。地址和长度通常用十六进制表示。例如/memreserve/ 0x30000000 0x10000;意思就是从0x30000000地址开始保留64KB0x10000字节的内存。内核的内存管理系统看到这个标记就不会把这部分内存纳入可分配的内存池。2.3 万物之源根节点/ { ... };斜杠/代表根节点这是整个设备树的起点和总容器。所有其他的硬件设备节点都是它的子孙。根节点里必须定义一些标准属性这些属性描述了整块板子的最基本信息内核依赖这些信息来做初步的识别和设置。上面例子里的几个就是最核心的model一个字符串简单描述这块板子叫什么。比如Raspberry Pi 4 Model B。在/proc/device-tree里能看到它主要用于人类阅读和日志记录。compatible这是最重要的属性之一是一个字符串列表。它定义了这块板子或设备与哪个哪些驱动程序兼容。内核启动时会拿着这个字符串去驱动程序列表里“配对”。比如ti,am335x-bone-black内核会寻找声明支持ti,am335x-bone-black或更通用兼容字符串如ti,am33xx的驱动程序。这个属性的值通常遵循“制造商,型号”的格式。#address-cells和#size-cells这两个属性决定了在当前节点及其子节点中如何解析“地址”和“大小”这两个数值。它们不是描述具体的地址而是描述地址和大小这两个“数据”的格式。1表示用1个32位数即1个cell来表示一个地址或大小。比如在根节点设置#address-cells 1; #size-cells 1;那么它的一个子节点比如内存节点的reg属性用来描述地址范围就可能写成reg 0x30000000 0x4000000意思是起始地址是0x300000001个cell大小是0x40000001个cell。如果根节点设置#address-cells 2; #size-cells 1;那reg属性可能就需要写成reg 0x0 0x30000000 0x0 0x4000000用两个cell来表示一个64位的起始地址。理解这两个属性是理解设备树中地址映射的关键。3. 血肉填充属性与节点的定义规则骨架搭好了接下来就是往里面填充具体的设备和它们的参数这就是通过节点和属性来完成的。节点代表一个设备或一个总线属性则是这个设备的参数。3.1 属性的“七十二变”格式与取值属性是附着在节点上的键值对用来描述节点的特征。它的基本格式很简单属性名 值;或者对于没有值的标志性属性属性名;关键在于这个“值”它有三种基本类型但组合起来变化很多Cells32位整数数组用尖括号 括起来里面是一个或多个32位整数cell。这是最常用的格式用于表示地址、中断号、引脚编号、时钟频率等数值型数据。reg 0x30000000 0x4000000; // 起始地址和长度 interrupts 0 45 4; // 中断控制器、中断号、触发方式 #size-cells 1; // 单个cell对于64位数据就用两个cell来表示例如clock-frequency 0x00000001 0x00000000;表示0x1000000004.29GHz这个64位的频率值。字符串用双引号 括起来。用于名称、兼容性标识等文本信息。compatible simple-bus; status okay; device_type memory;字符串可以是一个列表用逗号分隔例如compatible ns16550, ns8250;表示这个设备首先兼容ns16550驱动如果不匹配再尝试ns8250驱动。字节序列用方括号[ ]括起来里面是用两个十六进制数字表示的一个字节。常用于表示MAC地址、加密密钥等二进制数据。local-mac-address [00 0a 35 12 34 56]; // 每个字节必须用两位十六进制 // 也可以写成连续的形式 local-mac-address [000a35123456]; // 效果同上这里有个新手极易踩的坑[00 0a]是正确的[0 a]或[0 0a]是错误的因为一个字节必须用两位十六进制数完整表示。[0]会被认为是0x00吗不一定编译器可能会报错或产生歧义所以务必写两位。这三种类型还可以混合使用用逗号分隔构成复合值。这在reg属性中很常见比如一个设备有多个内存映射区域reg 0x1000 0x100 0x2000 0x200; // 区域1地址0x1000长0x100区域2地址0x2000长0x2003.2 节点的“家族树”结构与寻址节点是设备树的核心构件代表一个总线、一个设备或一个功能模块。它的基本结构如下[label:] node-name[unit-address] { [properties] [child-nodes] };label标签可选。相当于给这个节点起个别名方便在其他地方引用。比如uart0: serial101F1000 { ... }之后你就可以用uart0来指代这个节点非常方便尤其是在覆盖override节点属性时。node-name节点名必需。一个描述设备类型的名字比如cpumemoryi2cgpio-leds等。unit-address单元地址可选但强烈建议加上。用于在同一个父节点下区分多个同类型设备。地址通常与该设备在父总线上的地址有关。比如i2c40000000和i2c40001000表示两个不同基地址的I2C控制器memory30000000和memory0表示两块不同起始地址的内存。节点内部可以包含任意数量的属性和子节点形成一棵树。例如一个I2C控制器节点下可以挂载多个I2C设备子节点i2c40000000 { compatible vendor,i2c-controller; reg 0x40000000 0x1000; #address-cells 1; #size-cells 0; clock-frequency 100000; eeprom50 { // I2C从设备7位地址0x50 compatible atmel,24c02; reg 0x50; pagesize 8; }; temperature-sensor48 { // I2C从设备地址0x48 compatible ti,tmp102; reg 0x48; }; };这里可以看到I2C控制器节点父节点定义了#address-cells 1和#size-cells 0。这意味着它的子节点I2C设备的reg属性只需要一个cell来表示设备地址即I2C的7位或10位从地址而不需要大小信息所以#size-cells为0。这种父子节点间#address-cells/#size-cells的传递和继承是设备树描述复杂总线层级关系的精髓。4. 进阶技巧复用、覆盖与编译实战当你为一个芯片家族比如某款ARM SoC的多块不同载板写DTS时你会发现大部分内容是重复的CPU架构、内部外设如中断控制器、时钟控制器的描述都是一样的。只有外部电路如LED、按键、扩展接口不同。这时候DTS的包含和覆盖机制就派上大用场了。4.1 模块化设计.dtsi头文件和C语言用.h头文件定义公共部分一样DTS可以用.dtsiDevice Tree Source Include文件。通常我们把SoC芯片级的硬件描述放在.dtsi里比如my-soc.dtsi把板级特定的描述放在.dts文件里比如my-board.dts。.dts文件通过#include来包含.dtsi。一个典型的my-soc.dtsi文件可能长这样// 定义一些GPIO引脚的宏方便引用和C语言的#define一样 #define GPIO_ACTIVE_HIGH 0 #define GPIO_ACTIVE_LOW 1 /dts-v1/; / { compatible my-company,my-soc; #address-cells 1; #size-cells 1; cpus { #address-cells 1; #size-cells 0; cpu0 { compatible arm,cortex-a7; device_type cpu; reg 0; }; }; soc { compatible simple-bus; #address-cells 1; #size-cells 1; ranges; // 表示子节点的地址空间直接映射到父节点地址空间 uart0: serial101F1000 { compatible ns16550; reg 0x101F1000 0x1000; interrupts 0 44 4; clocks osc24mhz; status disabled; // 默认不启用由板级文件决定 }; gpio0: gpio101F4000 { compatible my-company,gpio-bank; reg 0x101F4000 0x1000; gpio-controller; #gpio-cells 2; ngpios 32; }; }; };对应的my-board.dts文件则很简单/dts-v1/; #include my-soc.dtsi // 包含SoC公共部分 / { model My Company Development Board; compatible my-company,my-board, my-company,my-soc; // 板级兼容性优先 memory30000000 { device_type memory; reg 0x30000000 0x40000000; // 板子有1GB内存 }; chosen { bootargs consolettyS0,115200 earlyprintk root/dev/mmcblk0p2 rootwait; }; leds { compatible gpio-leds; led0 { label sys-led; gpios gpio0 5 GPIO_ACTIVE_LOW; // 使用dtsi中定义的gpio0节点 linux,default-trigger heartbeat; }; }; // 启用在dtsi中定义的uart0并指定时钟 uart0 { status okay; clocks osc24mhz; }; };这种分层的写法让代码复用率极高维护起来也方便。芯片升级了改.dtsi板子设计变了只改.dts。4.2 精准修改属性覆盖与节点引用有时候你不想动公共的.dtsi文件只想在板级文件里微调某个参数。比如.dtsi里定义LED接在gpio0的第5脚但你的板子实际接在第6脚。你有两种方法方法一完整路径覆盖。在.dts文件里重新定义该节点的属性。编译器后处理的属性会覆盖先定义的。// 在 my-board.dts 中 / { leds { led0 { gpios gpio0 6 GPIO_ACTIVE_LOW; // 将pin从5改为6 }; }; };方法二使用标签Label引用。这是更优雅、更推荐的方式。首先在.dtsi中定义节点时加上标签// my-soc.dtsi my_led: leds { compatible gpio-leds; led0 { gpios gpio0 5 GPIO_ACTIVE_LOW; }; };然后在.dts中通过标签引用并覆盖// my-board.dts my_led { led0 { gpios gpio0 6 GPIO_ACTIVE_LOW; }; };这种方式语义更清晰直接指明了要修改的是哪个节点避免了在复杂的树状结构中写冗长路径可能带来的错误。4.3 从源码到二进制编译与反编译实战写好的.dts和.dtsi文件是文本内核需要的是二进制格式的.dtbDevice Tree Blob。这个转换工作由设备树编译器dtc完成。编译通常在Linux内核源码树里操作。假设你的板级DTS文件是arch/arm/boot/dts/my-board.dts执行make dtbs内核的Makefile会自动调用dtc编译所有dts文件为dtb。生成的my-board.dtb就在arch/arm/boot/dts/目录下。反编译调试神器这是排查DTS问题最常用的手段。如果你不确定你写的DTS最终生成的效果或者想查看现成dtb文件的内容可以用dtc反编译dtc -I dtb -O dts -o my-decompiled.dts my-board.dtb打开my-decompiled.dts你就能看到所有宏被展开、所有包含被合并、所有覆盖生效后的最终结果。我无数次靠这个命令找到了属性写错、节点路径不对的问题。比如你可以验证上面通过my_led覆盖gpios属性的操作是否真的生效了。5. 避坑指南那些年我踩过的DTS坑理论讲完了最后分享几个实战中容易出错的地方希望能帮你少走弯路。坑一地址与大端小端Endianness。reg属性里的地址和长度都是指从“父总线”视角看到的地址。对于内存映射设备这通常是物理地址。但要特别注意这个地址的字节序大端/小端是由父总线决定的。在常见的ARM小端系统上一般没问题。但在一些包含非ARM核心如DSP的复杂SoC上或者使用某些总线标准时可能需要特别处理。最稳妥的方法是参考芯片原厂的参考DTS是怎么写的。坑二status属性的误用。status属性控制一个节点是否“启用”。常见值有okay启用、disabled禁用、fail、fail-sss等。很多驱动会检查这个属性。一个常见的错误是在.dtsi里为了方便把所有外设status都设为okay但在板级.dts里有些外设硬件上并没连接这可能导致驱动探测失败甚至引发系统异常。好的习惯是在.dtsi里将外设默认设为disabled只在板级.dts里明确启用那些实际存在的。坑三compatible字符串的匹配。这是驱动和设备绑定的关键。字符串必须完全匹配驱动中定义的of_device_id表里的内容。一个空格、一个逗号、甚至大小写不匹配都会导致绑定失败。经常有同学写了ti,am335x-bone-black但驱动里是ti,am335x-boneblack少个连字符结果设备永远无法初始化。多利用内核日志搜索compatible匹配失败的信息。坑四#address-cells和#size-cells的继承混乱。这是理解设备树层次结构的难点。记住这两个属性定义的是子节点的reg属性中“地址”和“大小”字段各占用多少个cell。父节点定义子节点遵守。对于像I2C、SPI这种设备地址本身不需要“大小”概念的父节点就设置#size-cells 0;。对于内存映射设备通常需要地址和大小所以是1 1或2 164位地址。画个树状图从根节点开始逐级标出这两个属性的值会清晰很多。坑五过度依赖反编译结果调试。反编译dtb得到的dts是最终结果但它可能和你的源文件差异很大因为编译器会做优化比如删除未引用的节点、重组和格式化。调试时应该以你写的源文件为主结合内核启动时的of_系列API的打印信息通常打开CONFIG_OF_DEBUG可以获得更详细解析日志来定位问题。反编译结果更多是用来做最终验证。设备树的语法本身并不复杂它的难点在于对硬件系统的准确描述和对内核驱动模型的深入理解。最好的学习方法就是多读、多写、多调试。从一块成熟开发板如树莓派、BeagleBone的DTS文件开始读起尝试修改一个LED的引脚编译、刷入、验证。当你成功让一个设备按照你的描述工作时你对设备树的理解就真正上了一个台阶。