4. 硬件适配基础:设备树(Device Tree)概念、语法与U-Boot关系

做嵌入式Linux移植,绕不开设备树。说白点,设备树就是一套描述硬件信息的“数据表格”。它告诉内核:CPU是什么型号、内存多大、外设挂在哪条总线上、中断怎么接。没有它,内核就是个瞎子。

我刚开始接触Linux移植那会儿,还是用老式的板级文件(board-xxx.c)。每个板子都要写一堆C代码来注册设备。后来设备树一普及,整个人都清爽了。嗯,咱们今天就把设备树这层窗户纸捅破。

4.1 设备树是什么?为什么需要它?

设备树(Device Tree,简称DT)是一种描述硬件资源的数据结构。它用树形节点表示硬件拓扑,每个节点包含若干属性(键值对)。

举个例子,一颗SoC内部有CPU、内存控制器、GPIO、UART、I2C等模块。设备树就把这些模块组织成一颗树:根节点是“/”,下面挂“cpus”、“memory”、“soc”等子节点,再往下是具体的外设。

核心思想:硬件描述与内核代码分离。同一份内核镜像,换一块板子只需要换一个.dtb文件,不用重新编译内核。

为什么非要用设备树?我总结三个理由:

  • 解耦:内核不再硬编码硬件信息,板级差异全部放到.dts文件中
  • 标准化:ARM、RISC-V、x86都在用,社区统一规范
  • 可维护:加个新外设,改几行.dts就行,不用动内核源码

我在项目中遇到过一块板子,原厂给的BSP里设备树写错了GPIO中断号。排查了三天,最后发现是.dts里interrupts属性少写了一个cell。从那以后,我拿到新板子第一件事就是对着原理图逐行核对设备树。

4.2 设备树语法基础

设备树源文件(.dts)是给人看的文本文件,经过dtc编译器生成二进制.dtb文件。语法其实不复杂,咱们直接看例子。

4.2.1 基本结构

/dts-v1/;

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

    cpus {
        #address-cells = <1>;
        #size-cells = <0>;
        cpu@0 {
            compatible = "arm,cortex-a8";
            device_type = "cpu";
            reg = <0x0>;
        };
    };

    memory@80000000 {
        device_type = "memory";
        reg = <0x80000000 0x20000000>;  /* 512 MB */
    };

    &uart0 {
        status = "okay";
        pinctrl-0 = <&uart0_pins>;
        pinctrl-names = "default";
    };
};

看到没?根节点“/”下面挂cpus、memory、uart0。每个节点用花括号包裹,属性用“key = value;”表示。字符串用双引号,32位整数用尖括号,64位用方括号。

4.2.2 常用属性

属性名 含义 示例
compatible 设备兼容性字符串,驱动匹配依据 "ti,am335x-uart"
reg 地址/长度对,描述寄存器或内存范围 <0x44e09000 0x2000>
interrupts 中断号、触发类型 <0x1c 0x4>
status 设备状态:"okay"、"disabled" "okay"
pinctrl-* 引脚复用配置 <&uart0_pins>
clocks 时钟源引用 <&clk_uart0>

避坑指南:我曾经在reg属性里写反了地址和长度,导致内核访问外设寄存器时直接挂死。记住:reg = <基址 长度>,长度单位是字节。

4.2.3 引用与覆盖

设备树支持通过标签(label)引用节点,然后在其他地方覆盖属性。这个特性非常实用。

// 在.dtsi中定义
uart0: serial@44e09000 {
    compatible = "ti,am335x-uart";
    reg = <0x44e09000 0x2000>;
    interrupts = <72>;
    status = "disabled";
};

// 在.dts中覆盖
&uart0 {
    status = "okay";
    pinctrl-0 = <&uart0_pins>;
};

这样设计的好处是:SoC厂商提供.dtsi文件描述芯片内部资源,板级厂商在.dts里只覆盖需要修改的部分。代码复用率极高。

4.3 设备树与U-Boot的关系

U-Boot和设备树的关系,说白了就是“搬运工”和“说明书”。U-Boot负责把.dtb文件从存储介质加载到内存,然后传递给内核。

4.3.1 传递流程

  1. 加载:U-Boot从eMMC、SD卡、网络等位置读取.dtb文件到内存
  2. 修改:U-Boot可以根据环境变量或fdt命令修改设备树(比如调整内存大小、使能/禁用外设)
  3. 传递:启动内核时,U-Boot把.dtb地址通过寄存器r2(ARM)或a1(RISC-V)传给内核

注意:U-Boot和内核使用的设备树必须匹配。版本不一致可能导致外设无法识别,甚至启动失败。我见过有人拿Linux 4.19的设备树去启动Linux 5.10的内核,结果网卡驱动直接罢工。

4.3.2 U-Boot中的fdt命令

U-Boot提供了一套fdt命令,可以在启动前动态调整设备树。这个功能在调试时特别有用。

# 加载设备树到内存
tftp 0x82000000 myboard.dtb

# 设置fdt地址
fdt addr 0x82000000

# 查看节点
fdt list /soc

# 修改属性
fdt set /soc/mmc@480b4000 status "disabled"

# 添加新节点
fdt mknode /soc mydevice
fdt set /soc/mydevice compatible "my,device"
fdt set /soc/mydevice reg <0x48000000 0x1000>

# 保存修改
fdt boardsetup

我个人习惯在调试新板子时,先用fdt命令临时禁用有问题的外设,等驱动调通了再改.dts源文件。这样不用反复烧写镜像,效率高很多。

4.3.3 设备树在U-Boot中的位置

U-Boot本身也使用设备树。从2016年开始,U-Boot逐步迁移到设备树驱动模型(DM)。现在U-Boot的板级配置、外设驱动都依赖设备树。

U-Boot的设备树和内核的设备树通常是同一份,或者内核设备树的子集。U-Boot只关心启动阶段需要的外设(串口、MMC、网络等),其他外设可以裁剪掉。

关键点:U-Boot编译时会把设备树二进制(.dtb)嵌入到u-boot.bin中,或者单独存放。启动时U-Boot从存储介质加载.dtb,然后传递给内核。内核启动后,U-Boot就完成了它的使命。

4.4 设备树知识体系

下面这张图展示了设备树在嵌入式系统中的核心位置和流转关系。

设备树知识体系与数据流 设备树知识体系与数据流 .dts 源文件 文本格式,人类可读 dtc编译 dtc 编译器 Device Tree Compiler 生成 .dtb 二进制 扁平化设备树 加载到内存 U-Boot 引导程序 fdt命令修改 / 传递 通过r2寄存器传递地址 启动内核 Linux 内核 解析设备树,匹配驱动 初始化外设 硬件外设 UART / I2C / SPI / GPIO 驱动控制 .dtsi 公共头文件 SoC级描述,可复用 #include 关键要点 • .dts → dtc → .dtb • U-Boot加载并传递.dtb • 内核解析设备树匹配驱动 • 硬件描述与代码分离 • 同一内核支持多块板子

4.5 实战建议

学设备树,光看语法没用。我建议你拿一块开发板,对着原理图和芯片手册,自己写一份.dts文件。从最简单的串口开始,逐步添加GPIO、I2C、SPI、网卡。每加一个外设,就编译、烧写、验证一次。

遇到问题怎么办?

  • 用dtc -I dtb -O dts反编译.dtb,看看实际生效的设备树长什么样
  • 在内核启动参数加earlycon和console=ttyS0,看设备树解析日志
  • 在U-Boot里用fdt list逐级检查节点属性

踩坑提醒:我曾经在.dts里写错了#address-cells和#size-cells的值,导致reg属性解析出来的地址完全不对。这两个属性决定了reg里每个地址/长度占几个32位单元。ARM64通常用<2>表示64位地址,ARM32用<1>。搞混了,内核读到的寄存器地址就是错的。

设备树说难不难,说简单也不简单。核心就三点:树形结构、键值对属性、引用覆盖。把这三点吃透了,剩下的就是对着手册填参数。嗯,动手写几遍就熟了。