设备树(DTS)入门:从零理解这个“硬件说明书”

说实话,我刚接触嵌入式Linux那会儿,最头疼的就是设备树。那时候还没有设备树,内核里全是板级文件,一个平台一个文件,改个GPIO都得重新编译内核。后来设备树来了,很多人觉得是“多此一举”,但用久了你会发现——这玩意儿真香。

设备树到底是什么?说白了,它就是一份描述硬件信息的“说明书”。告诉内核:你有哪些外设、它们挂在哪条总线上、中断号是多少、寄存器地址在哪。没有它,内核就得靠一堆C代码硬编码这些信息,换个板子就得改代码。

核心观点:设备树让内核与硬件描述解耦。同一份内核镜像,换不同的设备树文件,就能跑在不同硬件上。这就是它的价值所在。

设备树的起源:为什么要有它?

在设备树出现之前,Linux内核维护着大量板级文件(arch/arm/mach-xxx)。每个开发板都有自己的初始化代码,内核社区被这些代码淹没了。我记得有一次,为了支持一个新板子,我不得不复制整个mach目录,改几十个文件——那感觉,就像在泥潭里走路。

2005年左右,PowerPC架构率先引入了设备树(Device Tree),后来ARM社区也跟进。到2011年,ARM Linux主线开始强制要求使用设备树。嗯,这个转变过程其实挺痛苦的,很多老工程师都不习惯。但回头看,这是必须走的一步。

设备树的本质,就是把硬件描述从C代码中剥离出来,变成一种独立的数据结构。你想想看,硬件信息是相对稳定的,而内核代码是不断演进的。把它们分开,维护起来就轻松多了。

DTS / DTC / DTB:三个文件,一条流水线

这三个缩写,我刚开始也搞混过。其实很简单,它们就是设备树的“原料”、“工具”和“成品”。

缩写 全称 说明
DTS Device Tree Source 文本格式的源文件,人类可读写
DTC Device Tree Compiler 编译器,把DTS转成DTB
DTB Device Tree Blob 二进制格式,内核直接使用

工作流程是这样的:你写一个.dts文件,然后用DTC编译它,生成.dtb文件。Bootloader在启动时,把DTB加载到内存,然后把地址传给内核。内核解析DTB,就知道硬件长什么样了。

我在项目中遇到过一个问题:DTS里写错了中断号,结果网卡驱动死活不工作。查了两天才发现,是GPIO中断号偏移了一位。从那以后,我每次改DTS都会用dtc -I dts -O dtb编译一下,再用fdtdump反查确认。

小技巧:用dtc -@选项可以生成带符号的DTB,方便后续动态覆盖(overlay)。这在产品迭代中特别有用。

DTS文件结构:树形结构,节点和属性

DTS的语法其实很简单。它是一棵树,根节点是/,下面挂各种子节点。每个节点描述一个设备或总线,节点里用属性描述具体信息。

来看一个最简单的例子:

/dts-v1/;

/ {
    model = "MyBoard v1.0";
    compatible = "vendor,myboard", "vendor,common";

    #address-cells = <1>;
    #size-cells = <1>;

    memory@80000000 {
        device_type = "memory";
        reg = <0x80000000 0x10000000>;
    };

    uart0: serial@10000000 {
        compatible = "ns16550";
        reg = <0x10000000 0x1000>;
        interrupts = <0 5 4>;
        clock-frequency = <1843200>;
    };
};

这里有几个关键点:

  • compatible:最重要的属性。内核用它来匹配驱动。格式是"厂商,设备",比如"ti,omap3-uart"。
  • reg:描述地址和大小。前面#address-cells和#size-cells决定了它的格式。
  • interrupts:中断描述。具体格式取决于中断控制器。
  • 节点标签:比如uart0:,方便在其他地方引用。

我曾经犯过一个低级错误:忘了在根节点里设置#address-cells和#size-cells,结果reg属性解析出来全是错的。内核启动时直接panic,连日志都来不及看。嗯,这种坑踩过一次就记住了。

设备树在Linux启动中的作用

内核启动时,设备树扮演着“硬件地图”的角色。整个过程大致是这样的:

  1. Bootloader加载DTB:U-Boot从存储介质(eMMC、SD卡、网络)读取DTB,放到内存中。
  2. Bootloader修改DTB:有时Bootloader会根据实际硬件情况,修改DTB里的某些属性(比如内存大小、MAC地址)。
  3. 内核接收DTB地址:Bootloader通过寄存器(通常是r2)把DTB的物理地址传给内核。
  4. 内核解析DTB:内核启动早期,会调用unflatten_device_tree(),把DTB解析成内存中的树形结构。
  5. 驱动匹配:内核遍历设备树,为每个节点寻找匹配的驱动。匹配依据就是compatible属性。
  6. 设备初始化:驱动读取节点里的属性(reg、interrupts等),完成硬件初始化。

这里有个细节:内核在解析DTB时,会先建立device_node结构体,然后再转换成platform_device。这个过程在drivers/of/目录下实现,有兴趣可以看看。

注意:如果Bootloader传的DTB地址不对,或者DTB格式损坏,内核会在启动早期卡住,连控制台都看不到。我建议在Bootloader阶段用fdt addr和fdt list命令检查一下DTB是否正常。

设备树的核心逻辑:一张图看懂

下面我用一张SVG图,把设备树的整个工作流程串起来。这张图我画了好几次,力求简洁明了。

设备树工作流程 编写DTS源文件 .dts / .dtsi DTC编译 生成DTB二进制 .dtb Bootloader加载 内核解析DTB 匹配驱动 DTS内部结构(树形) / ├── model = "MyBoard" ├── compatible = "vendor,myboard" ├── memory@80000000 reg = <0x80000000 0x10000000> ├── uart0: serial@10000000 compatible = "ns16550" reg = <0x10000000 0x1000> ├── i2c@20000000 ├── eeprom@50 compatible = "atmel,24c02" 关键属性说明 compatible:驱动匹配的关键,格式"厂商,设备" reg:地址和大小,由#address-cells和#size-cells决定 interrupts:中断号、触发方式,依赖中断控制器 status:设备状态,"okay"启用,"disabled"禁用

这张图把整个流程串起来了。从编写DTS开始,到编译成DTB,再到Bootloader加载,最后内核解析匹配驱动。每一步都很关键,任何一个环节出问题,硬件就可能无法正常工作。

避坑指南:我踩过的那些坑

做设备树开发,有几个地方特别容易出问题。我把自己踩过的坑列出来,希望能帮你少走弯路。

  • compatible写错:驱动匹配全靠它,写错一个字内核就认不出来。我曾经把"ti,omap3-uart"写成了"ti,omap3_uart",下划线代替了横线,结果驱动没加载。查了半天才发现。
  • reg地址不对齐:有些外设对地址对齐有要求,比如必须4字节对齐。如果reg里写的地址不对齐,内核访问时会出异常。
  • 中断号重复:多个设备用了同一个中断号,会导致中断冲突。我建议用cat /proc/interrupts检查一下中断分配情况。
  • 忘记包含头文件:DTS里经常引用.dtsi文件(比如#include "soc.dtsi"),如果路径不对或者文件缺失,编译会报错。

调试建议:内核启动时加earlycon和dtb=xxx参数,可以在早期看到设备树解析的日志。另外,ls /sys/firmware/devicetree/base/可以查看当前系统使用的设备树内容。

设备树这东西,说难不难,说简单也不简单。关键是要理解它的设计思想——把硬件描述和内核代码分开。一旦你掌握了它,移植内核到新板子就会变得非常轻松。我个人觉得,设备树是嵌入式Linux开发中必须跨过的一道坎,跨过去之后,你会发现世界豁然开朗。

可可精品资料库