4、设备树基础:DTS语法、Exynos设备树文件(exynos4412.dtsi)、如何添加自定义节点

好,咱们今天聊聊设备树。说实话,我刚入行那会儿,Linux内核里还到处都是板级文件,每个板子一个 board-xxx.c,那叫一个乱。后来设备树一统江湖,我一开始还挺抵触的——又得学新东西。但用顺手之后,我只能说:真香。

设备树,说白了就是一套描述硬件信息的“说明书”。你想想看,内核要支持那么多款芯片、那么多块板子,总不能每换一个板子就重新编译一次内核吧?设备树就是把这个“硬件描述”从内核代码里抽出来,变成一个独立的数据结构。内核启动时读它,就知道自己跑在什么硬件上了。

4.1 DTS语法:没那么玄乎

设备树的源文件后缀是 .dts,头文件后缀是 .dtsi。语法其实很简单,就是一棵树,根节点是 /,下面挂各种子节点。每个节点有属性,属性就是键值对。

我给大家看个最基础的例子:

/dts-v1/;

/ {
    compatible = "samsung,exynos4412";
    #address-cells = <1>;
    #size-cells = <1>;

    memory@40000000 {
        device_type = "memory";
        reg = <0x40000000 0x40000000>;
    };

    chosen {
        bootargs = "console=ttySAC2,115200 root=/dev/mmcblk0p2 rw";
    };
};

这里有几个关键点:

  • /dts-v1/;:版本声明,必须写在第一行。我见过有人漏掉这个,编译直接报错。
  • compatible:兼容性字符串,内核靠它匹配驱动。格式一般是"厂商,型号"。
  • #address-cells#size-cells:这两个决定了 reg 属性里地址和长度各占几个32位整数。比如 <1 1> 就是地址占1个cell,长度占1个cell。
  • reg:描述地址空间。上面那个 memory 节点,意思是从0x40000000开始,大小0x40000000(1GB)。
我的小习惯:reg 属性时,我习惯把地址和长度分开写,中间加个注释说明含义。比如 reg = <0x11400000 0x100>; /* UART基地址 0x11400000,长度 256字节 */。这样过几个月回来看,自己还能看懂。

4.2 Exynos4412的设备树文件:exynos4412.dtsi

咱们做Exynos4412开发,最核心的文件就是 exynos4412.dtsi。这个文件是三星官方提供的,描述了芯片内部所有的外设:UART、I2C、SPI、GPIO、定时器、中断控制器……基本上你能想到的都在里面。

我截取一段真实的 exynos4412.dtsi 内容给大家看看:

soc {
    compatible = "simple-bus";
    #address-cells = <1>;
    #size-cells = <1>;
    ranges;

    uart0: serial@13800000 {
        compatible = "samsung,exynos4412-uart";
        reg = <0x13800000 0x100>;
        interrupts = <0 52 0>;
        clocks = <&clock CLK_UART0>, <&clock CLK_SCLK_UART0>;
        clock-names = "uart", "clk_uart_baud0";
        status = "disabled";
    };

    uart1: serial@13810000 {
        compatible = "samsung,exynos4412-uart";
        reg = <0x13810000 0x100>;
        interrupts = <0 53 0>;
        clocks = <&clock CLK_UART1>, <&clock CLK_SCLK_UART1>;
        clock-names = "uart", "clk_uart_baud0";
        status = "disabled";
    };

    uart2: serial@13820000 {
        compatible = "samsung,exynos4412-uart";
        reg = <0x13820000 0x100>;
        interrupts = <0 54 0>;
        clocks = <&clock CLK_UART2>, <&clock CLK_SCLK_UART2>;
        clock-names = "uart", "clk_uart_baud0";
        status = "disabled";
    };

    uart3: serial@13830000 {
        compatible = "samsung,exynos4412-uart";
        reg = <0x13830000 0x100>;
        interrupts = <0 55 0>;
        clocks = <&clock CLK_UART3>, <&clock CLK_SCLK_UART3>;
        clock-names = "uart", "clk_uart_baud0";
        status = "disabled";
    };
};

注意看,每个UART节点都默认 status = "disabled"。为什么?因为芯片有4个UART,但你的板子可能只用其中一两个。具体用哪个,由板级 .dts 文件去 okay 它。

这里有个坑:我曾经接手一个项目,前任把UART2在 .dtsi 里直接写成了 status = "okay"。结果所有用这个 .dtsi 的板子都默认打开了UART2,导致GPIO冲突,排查了两天才找到原因。所以记住:.dtsi 里只描述硬件存在,不决定硬件启用。启用的事交给板级 .dts

4.3 如何添加自定义节点

做项目时,难免要加一些芯片手册上没有的外设。比如你外接了一个LED、一个自定义的FPGA、或者一个I2C传感器。这时候就需要添加自定义节点。

添加自定义节点有两种方式:

方式一:在板级 .dts 文件中添加

假设你的板子叫 myboard.dts,它 #includeexynos4412.dtsi。你想加一个自定义的LED设备:

#include "exynos4412.dtsi"

/ {
    model = "My Exynos4412 Board";
    compatible = "mycompany,myboard", "samsung,exynos4412";

    myled: my-led {
        compatible = "mycompany,my-led";
        gpios = <&gpx1 0 GPIO_ACTIVE_HIGH>;
        label = "user_led1";
        status = "okay";
    };
};

这里我创建了一个 my-led 节点,挂在根节点下。内核驱动可以通过 compatible 匹配到这个节点,然后读取 gpios 属性来控制LED。

方式二:在 .dtsi 中添加,然后板级文件引用

如果你这个设备会被多个板子用到,那就应该放在 .dtsi 里。比如你做了一个Exynos4412的扩展板,上面有个自定义的ADC芯片:

// 在 exynos4412-myadc.dtsi 中
&i2c_1 {
    myadc: adc@48 {
        compatible = "mycompany,my-adc";
        reg = <0x48>;
        vref-supply = <&ldo3_reg>;
        interrupt-parent = <&gpx0>;
        interrupts = <2 IRQ_TYPE_EDGE_FALLING>;
        status = "disabled";
    };
};

然后在板级文件里启用它:

// 在 myboard.dts 中
#include "exynos4412.dtsi"
#include "exynos4412-myadc.dtsi"

&myadc {
    status = "okay";
};
注意:自定义节点的 compatible 命名一定要规范。我见过有人随便写个 "my_device",结果跟内核里某个驱动冲突了。建议格式:"厂商,设备名",比如 "mycompany,my-adc"。这样既不会冲突,也方便别人理解。

4.4 编译设备树

写好了 .dts 文件,怎么用?得编译成 .dtb 二进制文件。命令很简单:

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

如果你在内核源码目录下,也可以用内核的编译系统:

make dtbs

或者只编译你那个:

make myboard.dtb

编译通过后,把 .dtb 文件放到启动分区,uboot 会把它传给内核。内核启动时,会解析这个二进制文件,构建出设备树结构,然后驱动们就可以通过 of_* 系列API去读取硬件信息了。

调试小技巧:如果设备树加载有问题,可以在内核启动参数里加 earlycondtoc 相关的调试选项。我个人习惯在 chosen 节点里加一个 bootargs 包含 earlyprintk,这样设备树解析阶段的错误就能在串口上看到了。

4.5 避坑指南

最后,分享几个我踩过的坑:

  • 地址冲突:自定义节点的 reg 地址不要跟芯片已有的外设地址重叠。我见过有人把自定义节点地址写成了0x13800000,跟UART0冲突了,结果UART0怎么都调不通。
  • 中断号错误:Exynos4412的中断控制器是GIC,中断号是SPI(共享外设中断)从32开始算。比如UART0的中断号是52,对应的是SPI 20(52-32=20)。自定义节点用中断时,一定要查清楚芯片手册。
  • 时钟依赖:有些外设需要时钟才能工作。如果你自定义的设备需要时钟,记得在节点里加上 clocks 属性,否则驱动可能无法正常工作。
  • 别忘了 status我刚开始写设备树时,经常忘记加 status = "okay",结果设备树解析成功了,但驱动就是 probe 不了。查了半天才发现是节点默认被禁用了。

嗯,设备树这块内容其实不少,但核心就是这些。你只要掌握了语法、理解了 .dtsi.dts 的关系、会添加自定义节点,基本上就能应付大部分项目了。下一章咱们聊聊具体的驱动开发流程,到时候会用到今天讲的设备树知识。