4. 设备树(Device Tree)详解:从语法到实战

设备树,说白了就是一块“硬件描述板”。它告诉内核:你的板子上有哪些外设、它们挂在哪条总线上、中断怎么连、GPIO怎么用。没有它,Linux内核就得靠一堆硬编码的板级文件来认硬件——那维护起来简直是噩梦。

我最早接触设备树是在做一款i.MX6的板子。当时从厂商拿到的BSP里,设备树文件乱成一团,一个dts包含七八个dtsi,层层嵌套。我花了整整两天才理清楚哪个节点是哪个外设。从那以后,我养成了一个习惯:拿到一块新板子,第一件事就是打开它的设备树,从头到尾读一遍。

4.1 设备树文件体系:DTS、DTSI、DTB

设备树源文件有三种形态,它们的关系其实很简单:

后缀 全称 作用 谁生成
.dts Device Tree Source 板级设备树源文件,描述整块板子 开发者编写
.dtsi Device Tree Source Include 公共设备树头文件,被dts包含 芯片厂商提供
.dtb Device Tree Blob 编译后的二进制文件,内核直接使用 dtc编译生成

你可以把 .dtsi 想象成C语言里的头文件。芯片厂商会把SoC的公共部分——比如CPU核心、中断控制器、时钟、DMA——都放在dtsi里。板级开发者只需要写一个很小的dts,用 #include 把dtsi拉进来,再补充板子特有的外设就行。

核心原则:dtsi管芯片,dts管板子。别把板级特有的东西塞进dtsi,也别在dts里重复定义芯片已有的内容。

编译命令也很简单:

dtc -I dts -O dtb -o myboard.dtb myboard.dts

反编译回来查问题也常用:

dtc -I dtb -O dts -o myboard.dts myboard.dtb

4.2 节点与属性:设备树的“骨架”和“血肉”

设备树的每个硬件单元都是一个节点(node)。节点里用属性(property)来描述它的特征。我习惯把节点看作“硬件模块的身份证”,属性就是身份证上的各项信息。

来看一个典型的节点:

uart1: serial@02020000 {
    compatible = "fsl,imx6q-uart", "fsl,imx21-uart";
    reg = <0x02020000 0x4000>;
    interrupts = <0 26 IRQ_TYPE_LEVEL_HIGH>;
    clocks = <&clks IMX6QDL_CLK_UART_IPG>,
             <&clks IMX6QDL_CLK_UART_SERIAL>;
    clock-names = "ipg", "per";
    status = "okay";
};

这里有几个关键属性,我一个个说:

  • compatible:驱动匹配的关键字。内核通过它找到对应的驱动程序。我建议把兼容列表写得全一点,从具体型号到通用型号,这样内核更容易匹配上。
  • reg:寄存器地址和长度。格式是 <起始地址 长度>。如果有多段地址空间,可以写多组。
  • interrupts:中断号、触发类型。不同芯片的编码方式不一样,得看芯片手册。
  • clocks 和 clock-names:时钟源。names是给驱动用的标签,方便它通过名字找时钟。
  • status:设备状态。常用 "okay" 或 "disabled"。我在调试时经常把某个外设先 disabled 掉,等驱动调通了再打开。

我的习惯:每个节点都加上 status 属性,即使默认就是 "okay"。这样后续做设备树覆盖时,可以很方便地通过修改 status 来控制设备开关。

4.3 中断与GPIO控制器描述

中断和GPIO是设备树里最容易出坑的地方。我踩过的坑,说出来都是泪。

4.3.1 中断控制器

中断控制器本身也是一个节点,它需要标注 interrupt-controller 属性:

intc: interrupt-controller@00a01000 {
    compatible = "arm,cortex-a9-gic";
    reg = <0x00a01000 0x1000>,
          <0x00a00100 0x100>;
    interrupt-controller;
    #interrupt-cells = <3>;
};

#interrupt-cells 告诉内核:这个中断控制器发出的中断描述需要几个cell。GIC(通用中断控制器)通常是3个cell:中断类型(0=SPI,1=PPI)、中断号、触发类型。

外设节点引用中断时,写法如下:

ethernet@2188000 {
    interrupts = <0 57 IRQ_TYPE_LEVEL_HIGH>;
    interrupt-parent = <&intc>;
};

这里 interrupt-parent 指定了中断路由到哪个控制器。如果整个系统只有一个中断控制器,可以在根节点统一指定,子节点就不用重复写了。

我曾经踩过的坑:有一次我忘了给某个外设节点加 interrupt-parent,而系统里有两个中断控制器。结果内核把中断路由到了错误的控制器上,网卡死活不触发中断。查了两天才发现是这个问题。

4.3.2 GPIO控制器

GPIO控制器的描述套路和中断类似:

gpio1: gpio@0209c000 {
    compatible = "fsl,imx6q-gpio", "fsl,imx35-gpio";
    reg = <0x0209c000 0x4000>;
    interrupts = <0 66 IRQ_TYPE_LEVEL_HIGH>;
    gpio-controller;
    #gpio-cells = <2>;
    interrupt-controller;
    #interrupt-cells = <2>;
};

外设引用GPIO时,用 gpios 属性:

leds {
    compatible = "gpio-leds";
    led-red {
        gpios = <&gpio1 15 GPIO_ACTIVE_LOW>;
        label = "red-led";
    };
};

#gpio-cells = <2> 表示每个GPIO描述需要2个cell:第一个是引脚号,第二个是标志位(比如高电平有效还是低电平有效)。

4.4 设备树覆盖(Overlay)

设备树覆盖,说白了就是“运行时打补丁”。你不需要重新编译整个设备树,只需要写一个很小的overlay文件,动态地添加或修改节点。

这个功能在以下场景特别有用:

  • 外设模块热插拔:比如你插了一个摄像头模块,overlay可以动态添加I2C设备和GPIO配置。
  • FPGA动态重配置:FPGA加载了新逻辑,overlay同步更新设备树。
  • 调试阶段快速迭代:不用每次改设备树都重启系统。

一个典型的overlay文件长这样:

/dts-v1/;
/plugin/;

/ {
    fragment@0 {
        target = <&i2c1>;
        __overlay__ {
            status = "okay";
            temp-sensor@48 {
                compatible = "ti,tmp102";
                reg = <0x48>;
            };
        };
    };
};

编译overlay和普通设备树略有不同:

dtc -I dts -O dtb -@ -o myoverlay.dtbo myoverlay.dts

注意 -@ 选项,它告诉dtc保留符号信息,这样overlay才能找到要覆盖的目标节点。

我的建议:在开发阶段,尽量把板子特有的外设都做成overlay。这样主设备树保持干净,只包含芯片公共部分。调试某个外设时,只需要加载对应的overlay,不影响其他功能。

4.5 设备树核心知识体系

下面这张图是我自己总结的设备树知识框架,帮你快速建立整体认知:

设备树核心知识体系 设备树核心知识体系 设备树(Device Tree) 文件体系 节点与属性 中断与GPIO .dts .dtsi .dtb 节点结构 属性定义 标签引用 中断控制器 GPIO控制器 中断映射 设备树覆盖(Overlay) 动态加载 fragment语法 模块热插拔 设备树是BSP开发的核心,掌握它你就能精准控制硬件与内核的交互

设备树这东西,说难不难,说简单也不简单。关键是多动手、多踩坑。我建议你找一块开发板,把它的设备树从头到尾读一遍,然后试着改几个节点,编译、加载、验证。等你亲手把一块板子的设备树从零搭起来,你就真正掌握了。