1. 设备树基础

大家好,我是你们的嵌入式系统讲师。今天咱们来聊聊设备树——这个在Linux内核开发中绕不开的话题。

说实话,我刚开始接触嵌入式Linux那会儿,设备树还没普及。那时候大家还在用板级文件,每个板子一个board-xxx.c,代码里写满了GPIO号、中断号、时钟频率。后来我参与一个项目,要同时支持三款硬件略有差异的板子,那叫一个痛苦——改一个外设,得同步修改三份文件,稍不留神就漏了。

嗯,设备树就是来解决这个问题的。

一句话总结:设备树是一种描述硬件资源的数据结构,它把硬件配置信息从内核源码中剥离出来,变成一份独立的、可读性强的描述文件。

1.1 什么是设备树

设备树,全称Device Tree,简称DT。它本质上是一个树形结构,用来描述硬件平台上的各种设备信息。

你想想看,一个嵌入式板子上有哪些东西?CPU、内存、I2C控制器、SPI控制器、GPIO、中断控制器、时钟源……这些硬件资源怎么连接、地址范围是多少、中断号是多少,以前都硬编码在C代码里。设备树把这些信息抽出来,写成一种类似JSON的文本格式。

举个例子,一个简单的设备树节点长这样:

/ {
    model = "TI AM335x BeagleBone Black";
    compatible = "ti,am335x-bone-black", "ti,am335x-bone", "ti,am335x";

    memory@80000000 {
        device_type = "memory";
        reg = <0x80000000 0x10000000>;  /* 256MB RAM */
    };

    i2c0: i2c@44e0b000 {
        compatible = "ti,omap4-i2c";
        reg = <0x44e0b000 0x1000>;
        interrupts = <70>;
        status = "okay";
    };
};

看到没?每个设备就是一个节点,节点里用属性来描述它的特征。compatible告诉内核用哪个驱动,reg描述地址和大小,interrupts指定中断号。清晰明了。

我个人习惯:写设备树时,先把硬件手册翻一遍,把外设的基地址、中断号、时钟关系列个表格,再动手写dts文件。这样不容易漏东西。

1.2 为什么需要设备树

这个问题我问过不少刚入行的朋友。他们的第一反应是:「以前没有设备树不也跑得好好的吗?」

对,但那是过去。咱们来看看没有设备树的日子是什么样的。

在Linux 2.6到3.x早期,ARM架构的板级支持代码是这样的:

static struct resource i2c_resources[] = {
    {
        .start = 0x44e0b000,
        .end   = 0x44e0bfff,
        .flags = IORESOURCE_MEM,
    },
    {
        .start = 70,
        .end   = 70,
        .flags = IORESOURCE_IRQ,
    },
};

static struct platform_device i2c_device = {
    .name = "omap4-i2c",
    .id   = 0,
    .num_resources = ARRAY_SIZE(i2c_resources),
    .resource = i2c_resources,
};

每个板子都要写这么一堆。我记得有一次,客户换了一颗内存颗粒,容量从256MB变成512MB。就改一个内存大小,我得去翻board-xxx.c,找到meminfo结构体,改完重新编译整个内核。你说烦不烦?

设备树带来的好处,我总结了几点:

  • 硬件描述与内核代码解耦——换硬件改dts文件就行,不用动C代码
  • 同一内核镜像支持多款板子——启动时根据设备树动态适配
  • 减少内核源码中的冗余代码——以前每个板子一个文件,现在一个dts搞定
  • 社区统一标准——PowerPC、ARM、RISC-V都用同一套机制

我曾经踩过一个坑:设备树里reg属性的地址写错了,导致外设驱动申请内存资源失败。查了两天才发现是dts里少写了一个0。从那以后,我每次写完dts都会用dtc -I dts -O dtb反编译检查一遍地址值。

1.3 设备树在Linux启动流程中的角色

设备树不是只在编译时有用。它在系统启动过程中扮演着关键角色。咱们来走一遍启动流程,看看设备树在哪里发挥作用。

下面这张图是我画的设备树在启动流程中的位置:

设备树在Linux启动流程中的角色 ① Bootloader 加载 U-Boot 从存储介质读取 .dtb 文件到内存 ② 传递 DTB 地址 Bootloader 通过 r2 寄存器传递 DTB 物理地址 ③ 内核早期解析 unflatten_device_tree() 解析 DTB 为 device_node 树 ④ 驱动匹配与初始化 of_match_table 匹配 → probe() 读取资源 → 设备初始化 ✅ 系统启动完成 存储介质 (eMMC/SD/NAND) r2 寄存器 内存中的 device_node 树 platform_device

流程其实不复杂。我拆开来讲:

  1. Bootloader加载DTB——U-Boot从存储介质(eMMC、SD卡、NAND Flash)读取.dtb文件,放到内存某个地址。这一步我建议用fdt addr命令确认地址对不对。
  2. 传递DTB地址——U-Boot跳转到内核时,通过ARM的r2寄存器(或其他架构的对应机制)把DTB的物理地址传给内核。内核启动代码第一时间读取这个地址。
  3. 内核早期解析——内核调用unflatten_device_tree(),把扁平的DTB二进制数据解析成一颗device_node树。这棵树会一直保留在内核内存中,供后续使用。
  4. 驱动匹配与初始化——内核遍历设备树,为每个compatible属性匹配对应的驱动。匹配成功后调用驱动的probe()函数,驱动从设备树中读取reg、interrupts等资源,完成初始化。

我建议:调试启动问题时,在U-Boot里用fdt list命令查看设备树内容,确认它是否被正确加载。很多启动失败的原因就是DTB文件损坏或地址传错了。

这里有个关键点——设备树是「一次解析,多次使用」。内核在启动早期就把设备树解析好了,后面所有驱动都通过of_系列API(比如of_property_read_u32、of_iomap)来读取设备树信息。这样做的好处是:驱动代码里不需要硬编码任何硬件地址,全部从设备树动态获取。

举个例子,一个I2C驱动读取设备树资源的代码:

static int my_i2c_probe(struct platform_device *pdev)
{
    struct device *dev = &pdev->dev;
    struct resource *res;
    void __iomem *base;
    int irq;

    /* 从设备树读取内存资源 */
    res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
    base = devm_ioremap_resource(dev, res);

    /* 从设备树读取中断号 */
    irq = platform_get_irq(pdev, 0);

    /* 从设备树读取自定义属性 */
    u32 clock_freq;
    of_property_read_u32(dev->of_node, "clock-frequency", &clock_freq);

    /* ... 初始化硬件 ... */
    return 0;
}

看到没?驱动里没有写死任何地址。换板子?换dts文件就行,驱动代码一行都不用改。这就是设备树的精髓。

我曾经遇到一个诡异的问题:某款板子在启动时内核崩溃,卡在of_platform_populate函数里。查了半天,发现是设备树里一个节点的reg属性长度写错了,导致内核访问了非法地址。所以,写设备树时一定要对照硬件手册,地址和长度一个字节都不能错。

好了,设备树的基础概念就讲到这里。总结一下:设备树就是硬件的「身份证」,它告诉内核「我有哪些设备、它们在哪里、怎么用」。没有它,内核就得靠一堆板级文件来猜,那效率太低了。

下一节咱们会深入设备树的语法和编译方法,到时候我会带大家手写一个完整的dts文件。嗯,敬请期待。


可可精品资料库