1. 设备树基础:什么是设备树、为什么需要设备树、设备树在Linux内核中的角色

各位同学,咱们今天聊聊设备树。说实话,我刚入行那会儿,Linux内核里还没有设备树这玩意儿。那时候板级文件满天飞,每次换个Flash型号都得改arch/arm/mach-xxx下的代码,改完还得重新编译内核。嗯,那日子,想想都头疼。

设备树(Device Tree)的出现,说白了就是把硬件描述从内核代码里剥离出来。它用一套独立的数据结构来描述硬件资源,内核启动时解析这个结构,就知道板子上接了哪些外设、内存多大、中断怎么连的。你想想看,这就像给硬件画了一张“配置清单”,内核照着清单干活就行。

1.1 什么是设备树

设备树是一个树形数据结构,每个节点代表一个硬件设备或总线。节点里包含属性(property),属性就是键值对,用来描述设备的寄存器地址、中断号、时钟频率等信息。

我习惯把设备树比作硬件的“身份证”。比如一个UART控制器,它的身份证上写着:我叫uart0,我住在0x10000000这个地址,我用中断号33,我跑115200波特率。内核拿到这张身份证,就知道怎么初始化它。

设备树的源文件是.dts(Device Tree Source),编译后生成.dtb(Device Tree Blob)。uboot或bootloader把.dtb传给内核,内核解析后构建出设备树的内存表示——我们叫它“展开的设备树”(Flattened Device Tree)。

核心概念速览

  • .dts:设备树源文件,文本格式,可读性强
  • .dtsi:设备树头文件,通常放SoC公共部分
  • .dtb:编译后的二进制文件,内核直接使用
  • DTB:Device Tree Blob,就是.dtb文件
  • FDT:Flattened Device Tree,内核内存中的展开表示

一个简单的设备树节点长这样:

/ {
    soc {
        uart0: serial@10000000 {
            compatible = "vendor,uart-ip-v1";
            reg = <0x10000000 0x1000>;
            interrupts = <0 33 4>;
            clock-frequency = <115200>;
            status = "okay";
        };
    };
};

你看,compatible属性告诉内核用哪个驱动,reg描述地址范围,interrupts描述中断信息。这些信息以前都硬编码在C代码里,现在全挪到设备树里了。

1.2 为什么需要设备树

这个问题我当年也问过。为什么非要搞这么一套东西?直接在内核代码里写死不行吗?

行,但代价太大。我给你列几个痛点:

痛点 说明
代码膨胀 每个板子都要在arch/arm/mach-xxx下加一堆板级文件,内核越来越臃肿
维护困难 换个Flash型号、改个GPIO复用,就得改内核代码,重新编译
碎片化严重 厂商各自维护自己的板级补丁,主线内核很难统一支持
启动流程复杂 uboot和内核之间传递硬件信息全靠ATAG,扩展性差
多平台支持差 同一份内核镜像很难同时支持多个硬件平台

我曾经在一个项目里维护过三块不同的板子,每块板子的Flash分区、网卡型号、LCD参数都不一样。每次改硬件,都得改arch/arm/mach-xxx下的代码,然后重新编译内核。更崩溃的是,有时候只是换了个Flash型号,结果启动时内核panic了——因为分区表写死了。

设备树解决了这些问题。硬件描述和内核代码解耦了。换硬件?改.dts文件,重新编译.dtb,内核不用动。同一份内核镜像,配不同的.dtb,就能跑在不同的板子上。这就是设备树最大的价值。

我的经验:如果你在做BSP开发,建议把设备树当成“硬件配置单”来管理。每个硬件变更,先改设备树,再改驱动。这个习惯能帮你省掉很多调试时间。

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

设备树在内核启动过程中扮演着“硬件信息传递者”的角色。它的工作流程是这样的:

  1. Bootloader加载DTB:uboot从Flash或网络加载.dtb文件到内存
  2. Bootloader传递DTB地址:通过寄存器r2(ARM架构)把DTB内存地址传给内核
  3. 内核早期启动解析DTB:内核在start_kernel之前,通过unflatten_device_tree()把DTB展开成设备树结构
  4. 驱动匹配:驱动通过of_match_table匹配设备树中的compatible属性
  5. 资源获取:驱动通过of_xxx系列API读取设备树中的reg、interrupts等属性

说白了,设备树就是内核和硬件之间的“翻译官”。内核不直接操作硬件寄存器,而是通过设备树知道硬件长什么样,然后让对应的驱动去干活。

注意:设备树不是万能的。它只描述硬件“有什么”,不描述硬件“怎么用”。怎么用是驱动的事。别把驱动逻辑写进设备树,那是典型的反模式。

我画了一张图,帮你理解设备树在内核中的位置:

设备树在Linux内核中的角色 硬件层(Hardware) CPU · 内存 · UART · I2C · SPI · GPIO · 时钟 · 中断控制器 设备树层(Device Tree) .dts → .dtb → Bootloader加载 → 内核解析 内核层(Kernel) 设备模型 · 驱动匹配 · 资源分配 · 中断映射 · 时钟管理 驱动层(Driver) probe() · of_match_table · of_iomap() · request_irq() 数据流向:硬件 → 设备树描述 → 内核解析 → 驱动适配

这张图展示了数据流向。硬件是客观存在的,设备树是它的“描述”,内核根据描述做资源管理,驱动根据描述做硬件操作。每一层各司其职,互不干扰。

1.4 设备树的核心机制

设备树有几个关键机制,我简单说一下:

  • 节点(Node):树形结构的基本单元,每个节点代表一个设备或总线
  • 属性(Property):节点的配置信息,键值对形式
  • 标签(Label):给节点起别名,方便引用,比如上面的uart0
  • phandle:节点引用机制,通过&标签实现节点间关联
  • 覆盖(Overlay):运行时动态修改设备树,常用于FPGA或可插拔模块

我记得有一次调试一个I2C设备,死活读不到设备ID。查了半天,发现设备树里i2c节点的时钟频率写错了,写成了400kHz,但实际硬件只支持100kHz。改回100kHz,问题解决。嗯,这种坑我踩过不止一次。

避坑指南:设备树里的reg属性,地址和长度都是32位或64位整数,注意大小端。ARM默认小端,PowerPC默认大端。我曾经因为大小端问题,把一个UART的地址配错了,结果读写全乱套。

1.5 小结

设备树不是什么高深的技术,它就是一套硬件描述规范。把硬件信息从内核代码里抽出来,放到一个独立的数据结构里。好处是:内核更干净、硬件变更更灵活、多平台支持更容易。

学设备树,你不需要背语法,也不需要记API。你只需要理解它的设计思想:描述硬件,而不是控制硬件。控制是驱动的事,描述是设备树的事。分清楚这个边界,后面学起来就顺了。