第四章 设备树基础:语法、Zynq平台结构与实战加载

设备树,英文叫 Device Tree,简称 DT。说实话,我刚接触 Linux 驱动那会儿,觉得这玩意儿挺玄乎的。不就是描述硬件嘛,干嘛搞这么复杂?后来在 Zynq 上做第一个项目时,因为设备树里一个中断号写错了,整整调了两天。嗯,从那以后,我再也不敢小看它了。

今天我们就来彻底搞懂设备树。我会从语法讲起,然后带你看看 Zynq 平台特有的设备树结构,最后手把手演示怎么编译和加载。你跟着走一遍,保证能上手。

4.1 设备树到底是个啥?

说白了,设备树就是一个描述硬件信息的文本文件。它告诉内核:你的板子上有哪些外设、它们挂在哪个地址上、用哪个中断号、时钟频率是多少等等。

在早期的 Linux 内核里,这些信息是硬编码在 C 代码里的。比如 arch/arm/mach-xxx 目录下,一堆 board 文件。每次换一块板子,就得重新编译内核。这显然不科学。

设备树解决了这个问题。它把硬件描述和内核代码分离开来。你换板子?改设备树就行,内核不用动。我个人觉得,这是 Linux 驱动开发史上最优雅的设计之一。

4.2 设备树语法速览

设备树的源文件后缀是 .dts(Device Tree Source)。它的语法很简洁,我总结几个核心点。

4.2.1 基本结构

一个设备树文件,本质上是一棵树。根节点是 /,下面挂各种子节点。每个节点代表一个设备或总线。

/dts-v1/;

/ {
    compatible = "xlnx,zynq-7000";
    
    cpus {
        #address-cells = <1>;
        #size-cells = <0>;
        
        cpu@0 {
            compatible = "arm,cortex-a9";
            reg = <0>;
        };
    };
    
    memory@0 {
        device_type = "memory";
        reg = <0x00000000 0x40000000>;
    };
};

看到没?根节点下挂了 cpusmemory。每个节点用 { } 包裹,属性用 key = value; 的形式。

关键属性解释:

  • compatible:最重要的属性。内核通过它来匹配驱动。格式是 "厂商,型号"。
  • reg:描述地址空间。前面两个数字是 #address-cells#size-cells 决定的。
  • device_type:早期遗留属性,现在基本只用 memory 节点。

4.2.2 地址编码

这块容易搞混。我刚开始也踩过坑。你看 #address-cells#size-cells,它们决定了 reg 属性里每个地址和长度占几个 32 位整数。

举个例子:

axi_gpio_0: gpio@41200000 {
    compatible = "xlnx,xps-gpio-1.00.a";
    reg = <0x41200000 0x10000>;
    interrupt-parent = <&intc>;
    interrupts = <0 29 4>;
};

这里 reg = <0x41200000 0x10000>,表示基地址 0x41200000,地址范围 0x10000(64KB)。interrupts 的三个数字分别表示:中断类型(0 是 SPI)、中断号(29)、触发类型(4 是高电平触发)。

我的小技巧: 在 Zynq 上,我习惯把 #address-cells 设为 1,#size-cells 设为 1。这样 reg 里就是一对一的地址和长度,看着清爽。但如果你用 64 位地址,就得设成 2。

4.2.3 引用与标签

设备树里经常要引用其他节点。比如中断控制器,所有外设都要指向它。这时候就用标签(label)。

&intc {
    interrupt-controller;
    #interrupt-cells = <3>;
};

&intc 就是引用。它指向根节点下定义的那个 intc 节点。这种写法在 Zynq 的设备树里随处可见。

4.3 Zynq 平台设备树文件结构

Zynq 的设备树,我建议你直接看 Xilinx 官方提供的 device_tree 仓库。它有一套完整的体系。我来拆解一下。

4.3.1 文件层次

一个典型的 Zynq 设备树项目包含这些文件:

文件名 作用
zynq-7000.dtsi SoC 级描述。包含 PS 部分所有外设:GIC、UART、I2C、SPI、DDR 控制器等。
zynq-7000-clk.dtsi 时钟树描述。Zynq 的时钟体系比较复杂,单独放一个文件。
pl.dtsi PL 侧 IP 核的描述。由 Vivado 自动生成。
system-top.dts 顶层文件。通过 #include 包含上面所有文件,再加上板级特有信息。

我记得第一次看到这么多文件时有点懵。后来发现,这种分层设计其实很科学。SoC 级的东西基本不动,PL 侧由工具生成,你只需要关注板级差异。

4.3.2 关键节点解析

我们挑几个 Zynq 上最重要的节点看看。

中断控制器(GIC):

intc: interrupt-controller@f8f01000 {
    compatible = "arm,cortex-a9-gic";
    #interrupt-cells = <3>;
    reg = <0xf8f01000 0x1000>,
          <0xf8f00100 0x100>;
    interrupt-controller;
};

这里有两个 reg 区域:第一个是 GIC 的 distributor 接口,第二个是 CPU 接口。Zynq 的 GIC 支持三种中断类型:SPI(共享外设中断)、PPI(私有外设中断)、SGI(软件生成中断)。

UART 控制器:

uart1: serial@e0001000 {
    compatible = "xlnx,xuartps", "cdns,uart-r1p8";
    reg = <0xe0001000 0x1000>;
    interrupts = <0 50 4>;
    clock-names = "uart_clk", "pclk";
    clocks = <&clkc 24>, <&clkc 25>;
};

注意 compatible 有两个值。内核会依次尝试匹配。先匹配 "xlnx,xuartps",不行再试 "cdns,uart-r1p8"。这是为了兼容不同版本的驱动。

我曾经踩过的坑: 在 Zynq 上做 UART 驱动时,我忘了加 clock-names 属性。结果内核报错说找不到时钟。查了半天才发现,驱动里是通过名字来获取时钟的。名字对不上,就获取失败。所以,设备树里的属性名一定要和驱动代码里的一致。

4.4 编译设备树

设备树源文件(.dts)不能直接被内核使用。需要编译成二进制格式(.dtb)。工具叫 dtc(Device Tree Compiler)。

4.4.1 手动编译

如果你在 Linux 主机上,直接敲命令:

dtc -I dts -O dtb -o system-top.dtb system-top.dts

参数说明:

  • -I dts:输入格式是 dts
  • -O dtb:输出格式是 dtb
  • -o:指定输出文件名

反过来,你也可以把 dtb 反编译成 dts:

dtc -I dtb -O dts -o system-top.dts system-top.dtb

这个操作我经常用。有时候拿到一个现成的 dtb,想看看里面到底配了啥,反编译一下一目了然。

4.4.2 在内核中编译

更常见的做法是,把设备树放到内核源码里,让内核编译系统帮你处理。在 Zynq 上,设备树文件放在 arch/arm/boot/dts/ 目录下。

你只需要在 arch/arm/boot/dts/Makefile 里加上你的 dtb 文件名:

dtb-$(CONFIG_ARCH_ZYNQ) += zynq-zed.dtb
dtb-$(CONFIG_ARCH_ZYNQ) += zynq-zc702.dtb
dtb-$(CONFIG_ARCH_ZYNQ) += my-custom-board.dtb

然后编译内核时,设备树会自动生成:

make ARCH=arm zynq_my_custom_board_defconfig
make ARCH=arm dtbs

我的建议: 项目初期,我习惯手动编译设备树,方便快速迭代。等硬件稳定了,再把它集成到内核编译流程里。这样调试效率高很多。

4.5 加载设备树

设备树编译好了,怎么让内核用上它?这取决于你的启动方式。

4.5.1 从文件系统加载

如果你用 U-Boot 启动,最灵活的方式是从文件系统加载 dtb。U-Boot 启动流程大致是:

# 加载内核镜像
tftp 0x30000000 uImage

# 加载设备树
tftp 0x2A000000 system-top.dtb

# 启动内核
bootm 0x30000000 - 0x2A000000

注意 bootm 命令的三个参数:内核地址、ramdisk 地址(没有就填 -)、dtb 地址。内核启动时会自动解析 dtb 里的信息。

4.5.2 内嵌到内核镜像

另一种方式是把 dtb 直接链接到内核镜像里。在 arch/arm/boot/dts/Makefile 中,如果你把 dtb 文件加到 targets 里,编译内核时它会自动被打包进 zImageuImage

这种方式适合产品发布。因为 dtb 和内核绑在一起,不会出现版本不匹配的问题。但调试时不太方便,每次改设备树都得重新编译内核。

注意: 如果你用 Xilinx 的 PetaLinux,它有一套自己的设备树管理方式。PetaLinux 会自动从 Vivado 导出的 .hdf 文件生成设备树。你可以在 project-spec/meta-user/recipes-bsp/device-tree/files/ 下添加自己的 .dtsi 文件来覆盖默认配置。这个我后面会专门讲。

4.6 实战:从零搭建一个 Zynq 设备树

光说不练假把式。我们动手做一个最简单的设备树,包含一个 GPIO 控制器和一个 UART。

/dts-v1/;

#include "zynq-7000.dtsi"

/ {
    model = "My Custom Zynq Board";
    compatible = "mycompany,zynq-custom", "xlnx,zynq-7000";

    chosen {
        bootargs = "console=ttyPS0,115200 root=/dev/mmcblk0p2 rw";
        stdout-path = &uart1;
    };

    memory@0 {
        device_type = "memory";
        reg = <0x00000000 0x40000000>;  /* 1GB DDR */
    };
};

&uart1 {
    status = "okay";
};

&gpio0 {
    status = "okay";
    gpio-line-names = "LED0", "LED1", "BTN0", "BTN1";
};

这个设备树做了三件事:

  1. 继承了 Zynq 7000 系列的所有 SoC 级配置
  2. 使能了 UART1 和 GPIO0
  3. 给 GPIO 引脚起了名字,方便应用层识别

编译它:

dtc -I dts -O dtb -o my-board.dtb my-board.dts

然后你就可以用 U-Boot 加载它了。内核启动后,在 /proc/device-tree 目录下能看到设备树的所有节点。你可以用 ls 命令查看:

# ls /proc/device-tree/
#address-cells  compatible     memory
#size-cells     cpus           model
chosen          gpio@41200000  uart@e0001000

看到没?设备树里的每个节点都变成了一个目录。你可以 cat 查看属性值。这个目录是调试设备树的利器。我每次改完设备树,都会先来这里确认一下属性对不对。

4.7 本章小结

设备树是 Zynq Linux 驱动开发的基石。你不需要背下所有语法,但一定要理解它的设计思想:把硬件描述和驱动代码分离。这样你的驱动才能跨平台复用。

我个人觉得,学设备树最好的方法就是动手改。拿一块现成的板子,试着改几个属性,看看内核行为有什么变化。比如把 UART 的波特率时钟改小一点,看看串口还能不能正常工作。这种实验做多了,你对设备树的理解就会越来越深。

下一章,我们会讲怎么在 Zynq 上编写一个完整的字符设备驱动。到时候你会看到,设备树里的属性是怎么被驱动代码读取和使用的。准备好了吗?我们继续。