嵌入式Linux学习指南之设备树——Linux内核设备树编译机制深度解析

0 阅读6分钟

嵌入式Linux学习指南之设备树——Linux内核设备树编译机制深度解析

仓库已经开源!所有教程,主线内核移植,跑新版本imx-linux/uboot都在这里!欢迎各位大佬观摩!喜欢的话点个⭐!

仓库地址:github.com/Awesome-Emb…

静态网页:awesome-embedded-learning-studio.github.io/imx-forge/

这是笔者在维护 imx-forge 项目时记录的一篇技术笔记,希望能帮助大家理解 Linux 内核中设备树的编译机制。

引言

如果你在开发嵌入式 Linux 系统,一定接触过设备树(Device Tree)。那些 .dts 文件最终是如何变成内核可识别的 .dtb 二进制文件的?这个过程中 GCC 和 DTC 又是如何协作的?

今天我们就来深入剖析 Linux 内核中设备树的两阶段编译流程,看看这背后巧妙的设计。

两阶段编译流程概览

Linux 内核处理设备树源文件(.dts)时,采用了一个精妙的两阶段编译策略:

源文件 (.dts)
    ↓
【阶段1】GCC 预处理 → 处理 #include 和宏定义
    ↓
临时文件 (.dts.tmp)
    ↓
【阶段2】DTC 编译 → 生成二进制格式
    ↓
目标文件 (.dtb)

为什么要分两步?

直接用 DTC 编译不就行了吗?为什么要先用 GCC 处理一遍?这个设计带来了几个显著优势:

完整的 C 预处理器支持:可以使用 #include#define#ifdef 等熟悉的语法 ✅ 强大的依赖管理:自动追踪头文件变化,实现增量编译 ✅ 灵活的条件编译:根据不同配置生成不同版本的设备树 ✅ 与内核构建系统无缝集成:统一管理编译流程

深入源码:编译命令分析

让我们从内核源码入手,看看这个编译过程是如何实现的。核心逻辑位于 scripts/Makefile.dtbs 文件的第 132-137 行:

quiet_cmd_dtc = DTC $(quiet_dtb_check_tag) $@
      cmd_dtc = \
        $(HOSTCC) -E $(dtc_cpp_flags) -x assembler-with-cpp -o $(dtc-tmp) $< ; \
        $(DTC) -o $@ -b 0 $(addprefix -i,$(dir $<) $(DTC_INCLUDE)) \
               $(DTC_FLAGS) -d $(depfile).dtc.tmp $(dtc-tmp) ; \
        cat $(depfile).pre.tmp $(depfile).dtc.tmp > $(depfile) \
        $(cmd_dtb_check)

这个命令看似复杂,实际上就是三个步骤的串联。我们逐个拆解。

阶段 1:GCC 预处理

$(HOSTCC) -E $(dtc_cpp_flags) -x assembler-with-cpp -o $(dtc-tmp) $<

这里的参数很有讲究:

  • $(HOSTCC):使用主机系统的 GCC 编译器
  • -E只进行预处理,不编译(关键!)
  • $(dtc_cpp_flags):预处理标志(稍后详解)
  • -x assembler-with-cpp:将输入视为汇编语言,但启用 C 预处理器
  • -o $(dtc-tmp):输出到临时文件 .dts.tmp
  • $<:输入的 .dts 源文件

预处理标志的定义(第 127 行):

dtc_cpp_flags = -Wp,-MMD,$(depfile).pre.tmp -nostdinc -I $(DTC_INCLUDE) -undef -D__DTS__

每个参数都有其深意:

参数作用设计意图
-Wp,-MMD,file生成依赖文件追踪头文件变化,支持增量编译
-nostdinc禁用标准 C 头文件路径隔离设备树编译环境,避免污染
-I $(DTC_INCLUDE)只添加设备树特定的包含路径精确控制可访问的头文件
-undef取消所有预定义宏避免编译器内置宏干扰
-D__DTS__定义设备树编译宏允许条件编译

为什么使用 -x assembler-with-cpp

这是个巧妙的技巧。它告诉 GCC:"把输入文件当汇编语言处理,但启用 C 预处理器"。这样做的好处是:

✅ 支持完整的 C 预处理语法(#include#define#ifdef) ✅ 不要求符合 C 语法(设备树毕竟不是 C 代码) ✅ 允许设备树特有的语法结构

阶段 2:DTC 编译

$(DTC) -o $@ -b 0 $(addprefix -i,$(dir $<) $(DTC_INCLUDE)) \
       $(DTC_FLAGS) -d $(depfile).dtc.tmp $(dtc-tmp)

参数解析:

  • $(DTC):设备树编译器
  • -o $@:输出文件(.dtb
  • -b 0:设备树版本为 0(自动检测)
  • -i ...:添加 include 搜索路径
  • $(DTC_FLAGS):DTC 编译标志(如警告控制)
  • -d $(depfile).dtc.tmp:生成 DTC 依赖文件
  • $(dtc-tmp):输入文件(预处理后的临时文件)

include 路径展开

$(addprefix -i,$(dir $<) $(DTC_INCLUDE))

假设 $<arch/arm/boot/dts/board.dts,这行会展开为:

-i arch/arm/boot/dts/ -i scripts/dtc/include-prefixes

阶段 3:依赖合并

cat $(depfile).pre.tmp $(depfile).dtc.tmp > $(depfile)

将预处理依赖和 DTC 依赖合并,形成完整的依赖关系链。

include-prefixes 机制:架构无关的巧妙设计

这是内核设备树编译系统中最优雅的设计之一。

DTC_INCLUDE 定义

DTC_INCLUDE := $(srctree)/scripts/dtc/include-prefixes

目录结构

scripts/dtc/include-prefixes/
├── arc -> ../../../arch/arc/boot/dts
├── arm -> ../../../arch/arm/boot/dts
├── arm64 -> ../../../arch/arm64/boot/dts
├── dt-bindings -> ../../../include/dt-bindings
├── microblaze -> ../../../arch/microblaze/boot/dts
├── mips -> ../../../arch/mips/boot/dts
├── nios2 -> ../../../arch/nios2/boot/dts
├── openrisc -> ../../../arch/openrisc/boot/dts
├── powerpc -> ../../../arch/powerpc/boot/dts
├── riscv -> ../../../arch/riscv/boot/dts
├── sh -> ../../../arch/sh/boot/dts
└── xtensa -> ../../../arch/xtensa/boot/dts

工作原理

使用符号链接将架构特定的 DTS 目录映射到统一的 include-prefixes 目录。这样做的好处:

架构无关的 include 路径:可以用 <dt-bindings/...> 这样的统一写法 ✅ 自动适配当前编译的架构:编译 ARM 时自动指向 ARM 目录 ✅ 简化跨平台设备树的编写:同一份设备树可以在不同架构间复用

实际示例

在设备树中可以这样写:

#include <dt-bindings/interrupt-controller/irq.h>
#include "imx6ull.dtsi"  // 自动查找当前架构的目录

编译时:

  • dt-bindings 会被解析为 include/dt-bindings
  • imx6ull.dtsi 会在 arch/arm/boot/dts/ 中查找

依赖关系管理:双重保险

内核构建系统非常重视依赖追踪,对于设备树编译,它生成了两个阶段的依赖文件

1. 预处理依赖(.pre.tmp)

gcc -E-MMD 选项生成,记录:

  • .dts 文件包含的所有头文件
  • #include 指令引用的文件

2. DTC 依赖(.dtc.tmp)

dtc-d 选项生成,记录:

  • DTC 工具内部的依赖
  • 引用的其他设备树文件

3. 合并依赖

cat $(depfile).pre.tmp $(depfile).dtc.tmp > $(depfile)

将两个依赖文件合并,形成完整的依赖关系链。

增量编译的威力

完整的依赖信息使得内核构建系统能够:

只重新编译修改过的文件:大大加快编译速度 ✅ 精确追踪头文件变化:一个头文件的修改会触发所有依赖它的文件重新编译 ✅ 支持并行编译:依赖关系明确,可以安全并行

实战案例:完整的编译过程

让我们通过一个具体的例子,看看整个编译流程是如何运作的。

示例设备树文件

文件arch/arm/boot/dts/board.dts

// SPDX-License-Identifier: (GPL-2.0 OR MIT)
/dts-v1/;
#include "imx6ull.dtsi"
#include "board-common.dtsi"
#include <dt-bindings/interrupt-controller/irq.h>

/ {
    model = "Test Board";
    compatible = "test,test-board";

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

实际编译过程

阶段 1:预处理
gcc -E \
    -Wp,-MMD,board.dts.pre.tmp \
    -nostdinc \
    -I scripts/dtc/include-prefixes \
    -undef -D__DTS__ \
    -x assembler-with-cpp \
    -o board.dts.tmp \
    arch/arm/boot/dts/board.dts

生成的 board.dts.tmp(预处理后):

// ... imx6ull.dtsi 的内容展开 ...
// ... board-common.dtsi 的内容展开 ...
// ... irq.h 的内容展开 ...

/dts-v1/;

/ {
    model = "Test Board";
    compatible = "test,test-board";

    memory@80000000 {
        device_type = "memory";
        reg = <0x80000000 0x10000000>;
    };
};
阶段 2:DTC 编译
dtc -o board.dtb \
    -b 0 \
    -i arch/arm/boot/dts/ \
    -i scripts/dtc/include-prefixes \
    -Wno-unique_unit_address \
    -d board.dtc.tmp \
    board.dts.tmp

生成文件

  • board.dtb - 二进制设备树文件
  • board.dtc.tmp - DTC 依赖文件
  • board.dts.tmp - 预处理后的临时文件
阶段 3:依赖合并
cat board.dts.pre.tmp board.dtc.tmp > .board.dtb.d

关键参数详解

DTC_FLAGS 常见设置

DTC_FLAGS += -Wno-unique_unit_address \
             -Wno-unit_address_vs_reg \
             -Wno-avoid_unnecessary_addr_size \
             -Wno-alias_paths \
             -Wno-interrupt_map \
             -Wno-simple_bus_reg

这些标志禁用了一些设备树编译器的警告,原因包括:

  • 设备树可能包含多个相似的节点(如多个串口)
  • 某些验证规则在不同架构下不适用
  • 历史兼容性考虑

符号输出选项(-@)

DTC_FLAGS += $(if $(filter $(patsubst $(obj)/%,%,$@), $(base-dtb-y)), -@)

如果设备树是基础 DTB(支持 overlay),则添加 -@ 选项:

  • 作用:生成符号信息,允许设备树 overlay 动态添加节点
  • 应用场景:可插拔设备、BeagleBone Cape 等

编译产物链:从源码到内核

完整的编译链路:

源文件:
  board.dts
    ↓ [gcc -E 预处理]
临时文件:
  board.dts.tmp (预处理后的 DTS)
    ↓ [dtc 编译]
  board.dtb (二进制设备树)
    ↓ [包装成汇编]
  board.dtb.S
    ↓ [汇编器]
  board.dtb.o (目标文件)
    ↓ [链接器]
  内核镜像或模块

为什么要包装成目标文件?

  • ✅ 将设备树链接到内核镜像,统一管理
  • ✅ 支持模块化设备树(可以动态加载)
  • ✅ 统一的构建流程,与其他内核代码保持一致

总结与思考

Linux 内核的设备树编译机制体现了几个重要的设计原则:

1. 分离关注点

预处理和编译分离,每个工具做它最擅长的事:

  • GCC 擅长处理 C 预处理器语法
  • DTC 擅长编译设备树

2. 依赖管理

完整的依赖追踪确保:

  • 增量编译的正确性
  • 构建系统的效率
  • 变更影响的可预测性

3. 跨平台支持

通过符号链接实现架构无关:

  • 简化设备树编写
  • 提高代码复用性
  • 降低维护成本

4. 构建系统集成

与 Make 构建系统无缝集成:

  • 统一的编译接口
  • 一致的依赖管理
  • 标准化的输出格式

关键要点回顾

使用 gcc -E 进行预处理:支持完整的 C 预处理器语法,增强表达能力 ⭐ 使用 -nostdinc 隔离环境:避免系统头文件污染,确保可重复构建 ⭐ 通过符号链接实现架构无关:优雅的跨平台解决方案 ⭐ 双重依赖文件确保正确性:预处理和编译两阶段都生成依赖信息

扩展阅读

如果你想深入了解设备树的更多细节,可以参考以下资源:


相关阅读

  1. 入门 · 环境搭建 · 00 · Qt6 安装踩坑指南 - 相似度 80%
  2. 深入理解Linux模块——模块参数与内核调试:让模块"活"起来的魔法 - 相似度 80%
  3. 深入理解Linux模块——内核模块编译与加载详解:从 Makefile 到 insmod 的完整旅程 - 相似度 80%