设备树(Device Tree)基础

设备树这东西,说白了就是一块「硬件描述板」。你想想看,操作系统要跑起来,总得知道CPU有几个核、内存多大、外接了什么设备吧?传统做法是在内核里写死一堆板级代码,换一块板子就得重新编译。RISC-V这边更灵活——我们用设备树来描述硬件,内核启动时解析它,动态适配。

我在做RISC-V移植时遇到过这么个坑:设备树里一个中断号写错了,系统启动到一半就挂掉。查了两天才发现是设备树的问题。从那以后,我对设备树的每个字段都格外小心。

设备树的概念与作用

设备树(Device Tree)是一种描述硬件资源的数据结构。它把CPU、内存、总线、外设、中断控制器等信息,组织成一棵树形结构。内核在启动阶段读取这棵树,就知道该怎么初始化硬件了。

它的核心作用有三个:

  • 硬件描述标准化:同一份内核镜像,换不同的设备树就能适配不同板卡
  • 减少板级代码:不用在内核源码里塞一堆arch/arm/mach-xxx的文件夹
  • 动态加载支持:U-Boot或OpenSBI可以在启动时修改设备树,比如调整内存大小

关键理解:设备树不是驱动,它是驱动的「配置文件」。驱动代码根据设备树里的信息,决定怎么操作硬件寄存器。

DTS与DTB文件格式

设备树有两种形态:源码和二进制。

格式 全称 特点 使用场景
DTS Device Tree Source 文本文件,人类可读写 开发阶段编辑、维护
DTB Device Tree Blob 二进制文件,机器可解析 启动时传递给内核
DTSI Device Tree Source Include 公共片段,被DTS引用 SoC级通用描述

DTS到DTB的转换,靠的是dtc工具。我个人习惯在Makefile里加一条规则:

# 编译设备树
dtc -I dts -O dtb -o board.dtb board.dts

# 反编译(调试时很有用)
dtc -I dtb -O dts -o board.dts board.dtb

小技巧:反编译DTB时加-s参数可以显示排序后的节点,方便对比差异。我曾经用这个功能抓到一个内存地址写错的bug。

设备树节点与属性

设备树的每个节点代表一个硬件设备或总线。节点里用属性来描述具体参数。

一个典型的节点长这样:

/ {
    #address-cells = <2>;
    #size-cells = <2>;

    memory@80000000 {
        device_type = "memory";
        reg = <0x0 0x80000000 0x0 0x40000000>;
    };

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

        uart@10000000 {
            compatible = "ns16550a";
            reg = <0x0 0x10000000 0x0 0x1000>;
            interrupts = <10>;
            interrupt-parent = <&plic>;
            clock-frequency = <3686400>;
        };
    };
};

这里有几个关键属性:

  • compatible:驱动匹配的关键字,内核靠它找到对应的驱动
  • reg:地址和长度,格式由#address-cells和#size-cells决定
  • interrupts:中断号,具体含义依赖中断控制器
  • interrupt-parent:指向中断控制器的phandle

注意:reg里的地址是总线地址,不是物理地址。如果节点挂在simple-bus上,地址直接映射到物理地址空间。但如果有IOMMU或地址转换,情况就复杂了。

RISC-V设备树特有属性

RISC-V的设备树有一些自己的规矩。我刚开始做RISC-V移植时,就被这些特有属性坑过几次。

CPU节点描述

RISC-V的CPU节点必须包含riscv,isa属性,用来描述指令集扩展:

cpu@0 {
    device_type = "cpu";
    reg = <0>;
    status = "okay";
    compatible = "riscv";
    riscv,isa = "rv64imafdc";
    mmu-type = "riscv,sv48";
    clock-frequency = <1000000000>;

    interrupt-controller {
        #interrupt-cells = <1>;
        interrupt-controller;
        compatible = "riscv,cpu-intc";
    };
};

riscv,isa里的字符串含义:

字符 含义 说明
I 整数指令 基础指令集,必须包含
M 乘除法 硬件乘除法支持
A 原子操作 LR/SC指令
F 单精度浮点 32位浮点运算
D 双精度浮点 64位浮点运算
C 压缩指令 16位指令编码

中断控制器(PLIC与CLINT)

RISC-V平台通常有两个中断控制器:

  • CLINT:处理定时器和软件中断,挂在CPU的核间中断线上
  • PLIC:处理外设中断,支持多级优先级

设备树里这样描述:

clint@2000000 {
    compatible = "riscv,clint0";
    reg = <0x0 0x2000000 0x0 0x10000>;
    interrupts-extended = <&cpu0 3 &cpu1 3>;
};

plic@c000000 {
    compatible = "riscv,plic0";
    reg = <0x0 0xc000000 0x0 0x4000000>;
    interrupts-extended = <&cpu0 11 &cpu1 11>;
    interrupt-controller;
    #interrupt-cells = <2>;
    riscv,ndev = <127>;
};

riscv,ndev这个属性很关键,它告诉PLIC最大支持多少个外设中断源。我见过有人把这个值设小了,结果高编号的中断根本收不到。

内存与地址映射

RISC-V设备树里,内存节点用device_type = "memory"标识。但要注意,如果有多段不连续的内存,需要多个memory节点:

memory@80000000 {
    device_type = "memory";
    reg = <0x0 0x80000000 0x0 0x40000000>;
};

memory@1000000000 {
    device_type = "memory";
    reg = <0x0 0x1000000000 0x0 0x40000000>;
};

经验之谈:QEMU的virt平台默认把内存放在0x80000000开始的位置。如果你自己写设备树,记得跟QEMU的启动参数-m保持一致,否则内核可能认不全内存。

知识结构总览

下面这张图把设备树的核心脉络串起来了:

设备树知识结构图 设备树根节点 / CPU 节点 内存节点 SoC 节点 UART 节点 PLIC 节点 CLINT 节点 各节点关键属性 • CPU: compatible="riscv", riscv,isa="rv64imafdc", mmu-type • 内存: device_type="memory", reg=<地址 长度> • UART: compatible="ns16550a", reg, interrupts, clock-frequency • PLIC: compatible="riscv,plic0", riscv,ndev, interrupts-extended • CLINT: compatible="riscv,clint0", reg, interrupts-extended

从这张图能看出来,设备树就是一棵从根节点向下展开的树。每个节点挂载不同的硬件模块,节点里用属性描述具体参数。RISC-V特有的就是CPU的riscv,isa、PLIC的riscv,ndev这些属性,写的时候一个都不能少。

避坑提醒:我曾经在设备树里漏写了interrupt-controller这个空属性,结果PLIC节点没有被识别为中断控制器,所有外设中断都失效了。这个属性虽然不携带数据,但它的存在本身就是一种声明。