第一章:设备树的前世今生

1.1 为什么要有设备树?

先问大家一个问题:你写嵌入式Linux驱动时,最烦什么?

我个人最烦的是——换一块板子,代码就得大改。尤其是那些描述硬件资源的部分,比如GPIO几号、中断挂在哪、时钟频率多少。这些信息以前都硬编码在C文件里,我们管它叫“板级文件”。

说白了,板级文件就是一堆结构体,里面塞满了硬件地址、中断号、寄存器偏移。每次换平台,就得改这些结构体。改完还得重新编译内核。你想想看,如果只是换了个LED引脚,就得重新编译整个内核,这效率能高吗?

我在项目中遇到过最夸张的一次:客户换了颗Flash芯片,只是引脚重映射了一下,结果我们花了三天改板级文件,还引入了两个新bug。从那以后我就意识到——硬件描述和驱动代码必须解耦。

这就是设备树的由来。

1.2 从板级文件到设备树的演进史

早期Linux内核(2.6时代)是怎么处理硬件信息的?

  • 每个板子都有一个 board-xxx.c 文件
  • 里面定义 platform_deviceresourceplatform_data
  • 驱动通过 platform_get_resource() 获取硬件信息

举个例子,以前我们写一个LED驱动,板级文件里大概长这样:

static struct resource led_resources[] = {
    {
        .start = GPIO_BASE + 0x10,
        .end   = GPIO_BASE + 0x10 + 3,
        .flags = IORESOURCE_MEM,
    },
};

static struct platform_device led_device = {
    .name = "my_led",
    .id   = 0,
    .num_resources = ARRAY_SIZE(led_resources),
    .resource = led_resources,
};

然后把这个设备注册到内核里。驱动那边再通过名字匹配,拿到资源。这套机制本身没问题,但问题是——每个板子都得写一份。ARM架构下,光是arch/arm/mach-xxx目录里,就有成百上千个这样的文件。

2011年左右,Linus Torvalds发飙了。他公开吐槽ARM社区的内核代码就是一坨“shit”,因为每次合并窗口,ARM那边提交的板级文件比新功能还多。他要求ARM必须像PowerPC那样,用设备树来描述硬件。

嗯,这里要注意:设备树并不是Linux发明的。它最早来自Open Firmware,PowerPC和SPARC平台一直在用。Linux只是把它移植到了ARM上。

从Linux 3.x开始,ARM架构逐步淘汰板级文件。到了4.x时代,新板子基本都强制使用设备树。现在你去看arch/arm/mach-xxx目录,里面干净多了。

1.3 设备树在Linux内核中的角色

设备树到底是什么?我习惯把它理解成“硬件的JSON文件”。

它用树形结构描述硬件:

  • 根节点是“/”,代表整个板子
  • 子节点是各种总线(如I2C、SPI、GPIO)
  • 叶子节点是具体设备(如温度传感器、LED、按键)

每个节点里,用属性(property)来描述硬件参数。比如:

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

    leds {
        compatible = "gpio-leds";
        led0 {
            gpios = &gpio1 15 GPIO_ACTIVE_HIGH;
            label = "heartbeat";
        };
    };
};

这段描述告诉内核:板子上有一个LED,挂在GPIO1的第15号引脚上,高电平有效。驱动不需要知道具体引脚号,它只需要读设备树就行。

设备树在内核启动流程中的角色是这样的:

  1. Bootloader(如U-Boot)加载设备树二进制文件(.dtb)到内存
  2. 内核启动时,解析dtb,构建设备树结构
  3. 驱动通过OF(Open Firmware)API读取设备树节点
  4. 内核根据设备树信息,自动创建设备、分配资源

我曾经踩过一个坑:设备树里中断号写错了,结果驱动注册时一直拿不到中断。查了两天才发现,是设备树里 interrupts = <0 31 4> 的第二个参数写成了31,实际应该是29。从那以后,我每次改设备树都会用 dtc -I dtb -O dts 反编译确认一遍。

核心要点:设备树把硬件描述从驱动代码中剥离出来。驱动只关心“怎么操作硬件”,不关心“硬件接在哪”。换板子时,只需要改设备树,不需要改驱动代码。

1.4 设备树的核心数据结构

设备树在内核里是怎么存的?其实就是一个大结构体——struct device_node

字段 说明
name 节点名称,如"led0"
type 节点类型,如"device"
compatible 兼容性字符串,驱动匹配用
phandle 节点句柄,用于引用
properties 属性链表,存所有键值对
parent 父节点指针
child 子节点指针
sibling 兄弟节点指针

内核启动时,会把dtb解析成一颗由 device_node 组成的树。驱动通过 of_find_node_by_name()of_get_property() 等API来遍历和读取。

避坑指南:我曾经在调试时发现设备树节点读不到,后来发现是设备树里节点名写错了大小写。设备树是大小写敏感的,LED0led0 是两个不同的节点。写设备树时,建议统一用小写加数字。

1.5 设备树与驱动匹配机制

驱动怎么找到对应的设备树节点?靠的是 compatible 属性。

驱动里会定义一个 of_device_id 表:

static const struct of_device_id led_of_match[] = {
    { .compatible = "vendor,my-led" },
    { /* sentinel */ }
};
MODULE_DEVICE_TABLE(of, led_of_match);

设备树里写:

led0 {
    compatible = "vendor,my-led";
    ...
};

内核在注册设备时,会遍历所有驱动的 of_device_id 表,找到匹配的 compatible 字符串,然后调用驱动的probe函数。

这里有个细节:compatible 字符串的命名规范是 "厂商,设备名"。比如 "ti,omap-gpio""fsl,imx-uart"。这样能避免不同厂商的同名设备冲突。

注意:设备树里的 compatible 字符串必须和驱动里的完全一致,包括大小写和标点。我见过有人把逗号写成了句号,结果驱动死活匹配不上。这种bug很难查,因为内核不会报错,只会静默跳过。

1.6 设备树的编译与加载

设备树源文件(.dts)不能直接被内核使用,需要编译成二进制(.dtb)。

编译命令:

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

反编译命令:

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

在U-Boot里,加载设备树的典型流程:

tftp 0x80000000 zImage
tftp 0x83000000 myboard.dtb
bootz 0x80000000 - 0x83000000

内核启动时,会从 0x83000000 地址读取dtb,解析后构建设备树。

我个人习惯在调试阶段,把设备树放在内核的 arch/arm/boot/dts/ 目录下,和内核一起编译。这样方便修改和测试。但产品阶段,建议把设备树放在bootloader里,这样更换板子时不需要重新编译内核。

1.7 本章小结

设备树解决了嵌入式Linux开发中的一个核心痛点:硬件描述与驱动代码的耦合。它让驱动变得通用,让板级适配变得简单。

从板级文件到设备树,本质上是“把硬件信息从代码里抽出来,放到数据里”。这个思路在很多地方都适用——你想想看,配置文件和硬编码的区别,不就是这个道理吗?

下一章,我们会深入设备树的语法细节,教你如何手写一个完整的设备树文件。到时候我会分享一些我在项目中总结的“设备树避坑指南”,保证实用。