7. Make 工具的文件调用溯源
在前面章节的我们介绍了 U-Boot 的许多文件,如 .config , autoconf.h , config.h , uboot-cfg 等
但是我们并不知道 Makefile 是如何在执行过程中调用这些文件的,故本文通过 make 执行的输出内容按图索骥寻找这些文件是如何被调用的。
7.1 remake 介绍
在此之前我们需要先介绍 make 工具中的 remake机制,对后续分析文件调用有帮助。

shell
-include A_value
是指包含 A_value 这个文件,如果不存在这个文件也不会报错
但是我们发现后面有一个同名的 A_value 目标,那么 makefile 会自动执行这个选项
shell
@echo "A=5" > A_value
是指创建一个 A_value 文件,并把A=5写入进去。
在创建完后,makefile 会重新执行
shell
-include A_value
此时存在 A_value 文件,所以 A 的值可以被正常打印

从打印顺序我们也可以看出,先打印 "A_value file is created",再打印后面两句。由此我们可以知道 makefile 是执行了A_value 目标,再回头执行 all 目标
7.2 makefile 基本语法说明
1. -include <文件路径>
语法规则:
如果文件存在,就地展开;如果不存在,不报错,而是去下方寻找有没有"生产这个文件"的规则。
U-Boot 实战举例:
shell-include include/config/auto.conf情景:这是存放 .config 转化后的变量文件。
逻辑:
Make 读到这一行,发现 auto.conf 还没生成。
因为有 -,它不报错,继续往下走。
它在后面找到了生成 auto.conf 的规则,于是去执行那个规则。
生成文件后,Make 原地复活,重新包含这个文件。
2. (Q) (MAKE) -f <Makefile路径> <目标>语法规则:
主 Makefile 不处理具体的操作,让另一个 Makefile 带着特定的任务去执行。
U-Boot 实战举例:
shell$(Q)$(MAKE) -f $(srctree)/scripts/Makefile.build obj=drivers/gpio解释:
$(Q):如果定义了 V=1,就显示完整命令,否则保持安静(quiet)。
-f scripts/Makefile.build:这是关键,它告诉 Make:"不要用当前的规则了,去读 scripts/Makefile.build 这个文件"。
obj=drivers/gpio:传个参数过去,告诉它:"去把 GPIO 驱动目录给我编了"。
3. $(obj)/%.o: %.c语法规则:
这是一种通配符规则。% 相当于通配符 *。它定义了"原材料"到"产物"的通用转换公式。
U-Boot 实战举例
shell$(obj)/%.o: %.c $(CC) $(c_flags) -c -o $@ $<通俗翻译:
产物 ( $@ ):冒号左边的 %.o(比如 main.o)。
原料 ( $< ):冒号右边的第一个 %.c(比如 main.c)。
逻辑:只要你想造一个 .o 文件,只要目录下有个同名的 .c,就按下面的 $(CC)(编译器)命令去加工。
寻找线索:如果你发现某个 .h 也是这么生成的,你就能在冒号右边看到它是通过哪个脚本生成出来的。
4.ifeq / else / endif语法规则:
根据变量的值,决定哪部分代码块生效。
U-Boot 实战举例:
shellifeq ($(dot-config),1) # 只要你配置过(即 .config 存在),就执行这里的逻辑 -include include/config/auto.conf else # 如果你还没配置,就默认执行这个 _all: config-error endif逻辑:ifeq 即 if equal,也就是相等,同理 ifneq 就是不相等
shellifneq (参数1, 参数2) # 如果 参数1 和 参数2 不一样,执行这里的代码 else # 如果 参数1 和 参数2 一样,执行这里的代码(可选) endif检查变量是否为空
ifneq 后面跟着一个变量和一个"空值"。如果变量里有内容,条件就成立。
shellifneq ($(KBUILD_EXTMOD),) # 如果 KBUILD_EXTMOD 不是空的,说明我们在编译外部模块 ... endif
7.3 默认目标 _all 跟踪
不需要逐句阅读代码,前面 ifeq 类似的命令基本都是设置环境变量和处理分支逻辑。
直接看下面的代码:

在 Makefile 语法中,如果在终端只输入 make 而不带任何参数,它会自动执行文件中出现的第一个目标。在 U-Boot 的 Makefile 里,这个第一个目标就是 _all。
_all 是入口 ,分析 Makefile 的核心逻辑就不再是向下读,而是 追踪依赖。
在 Makefile 中,一个目标的格式是 目标: 依赖。
分析思路:看 _all 后面是什么依赖,然后一样一级一级查下去。

即目标 _all 依赖于 all

即目标 all 依赖于 $(ALL-y)
在 U-Boot 这种大型工程中,ALL-y 并不是一次性定义完的,它采用了追加赋值(+=)
在 Linux 内核和 U-Boot 的 Makefile 体系中,变量命名遵循一个固定模式:变量名-$(配置状态)。
ALL-:表示这是一个汇总所有产物的清单。
y:代表 Yes。
$(ALL-y):最终展开后,它只包含那些在配置阶段(.config)被设置为 y(编译进镜像)的文件。
在 Makefile 的各个角落发现 ALL-y += ... 这样的语句。
Makefile 会根据 .config 中的配置,动态地往这个清单里加东西。

随着 Makefile 往下运行,会看到大量的 ALL-$(CONFIG_XXX) += ...。
如果某个功能被勾选(变为 ALL-y),它就会被追加到这个清单里。
不只有ALL-y,还会存在大量的变量值,举例:
$(CONFIG_OF_SEPARATE) 是一个变量。它的值通常有两种情况:
如果在 .config 中勾选了该功能,它的值就是 y
那么
shellALL-$(CONFIG_OF_SEPARATE) += u-boot-dtb-tegra.bin也可以展开成
shellALL-y += u-boot-dtb-tegra.bin
现在从第一个出现的 ALL-XXX 入手分析
shell
ALL-y += u-boot.srec u-boot.bin u-boot.sym System.map binary_size_check
ALL-y 是一个变量名
它是 Makefile 中的一个集合
Makefile 的执行逻辑:
第一阶段:初始化
shellALL-y := u-boot.cfg # 假设初始只有这一个第二阶段:你看到的这一行(追加)
shellALL-y += u-boot.srec u-boot.bin u-boot.sym System.map binary_size_check此时 ALL-y 的内容变为:
u-boot.cfg u-boot.srec u-boot.bin u-boot.sym System.map binary_size_check第三阶段:根据你的 i.MX6ULL 配置继续追加
shell# 如果你在 .config 里开启了设备树 ALL-$(CONFIG_OF_SEPARATE) += u-boot-dtb.bin如果 CONFIG_OF_SEPARATE=y,那么变量再次变长,最终汇聚到 all: $(ALL-y)。
这一行里的每一个成员,都代表了一个独立的目标(Target),Make 会为它们分别寻找"生产规则":
①u-boot.srec (S-Record 格式)这是一种 Motorola 开发的 ASCII 文本格式,包含了地址信息和数据。
用途:主要用于通过串口(如使用 loady 命令)下载代码到开发板。虽然现在用得少了,但依然是 U-Boot 的标准产物。
②u-boot.bin (核心产物)这是你最关心的二进制文件。它是经过裁剪、链接后,可以直接烧录到 i.MX6ULL 的 SD 卡或 NAND Flash 中的镜像。
后续追踪:在 Makefile 中搜索 u-boot.bin:,你会发现它通常是由 u-boot-nodtb.bin 和设备树 dt.dtb 合并而来的。
③ u-boot.sym (符号表)它记录了 U-Boot 中所有函数和全局变量的内存地址。
用途:它是给开发者看的。当你遇到崩溃(Panic)并打印出一串 16 进制地址时,你需要查阅这个文件来确定到底是哪个函数出错了。
④ System.map (地址映射表)与 .sym 类似,它是对整个编译出的二进制文件的"地图"描述。
区别:.sym 通常由 nm 工具生成,而 System.map 是更直观的地址与符号对照表。
⑤ binary_size_check (动作目标)注意,这不是一个文件,而是一个校验动作。
逻辑:它会触发一个脚本,检查生成的 u-boot.bin 是否超过了你定义的 Flash 分区大小(比如 i.MX6ULL 的 SPL 阶段对大小限制非常严格)。如果超了,编译会直接报错。
这里我们选取其中一个产物 u-boot.bin 进行分析说明:

shell
u-boot.bin: u-boot-dtb.bin FORCE
$(call if_changed,copy)
代码遵循标准的 Makefile 规则格式:
**目标(Target): 依赖项(Prerequisites)
Tab\] 命令(Recipe)** 1. 目标 (u-boot.bin):最终生成的、准备烧录到存储介质(如 SD 卡)的镜像文件。 2. 依赖项 (u-boot-dtb.bin): 这个文件通常已经在之前的步骤中生成了。 从名字可以看出,它是 u-boot (代码) + dtb (设备树) 的结合体。 依赖项 (FORCE):一个伪目标,确保每次执行 make 时都会检查此规则的命令是否需要运行。 3. 命令 ($(call if_changed,copy)): if_changed:U-Boot 的智能包装函数,只有当 u-boot-dtb.bin 更新了,或者编译命令改变了,才会执行。 copy:对应 Makefile 中的 cp(拷贝)指令。
接下来分析:u-boot-dtb.bin

shell
u-boot-dtb.bin: u-boot-nodtb.bin dts/dt.dtb FORCE
$(call if_changed,cat)
在现代嵌入式系统中,为了让一份代码(U-Boot)能跑在不同的板子上,采用了代码与硬件描述分离的设计:
代码:负责逻辑控制(怎么读磁盘、怎么初始化网络)。
设备树 (DTB):负责描述硬件(串口寄存器地址是多少、网卡接到哪个引脚)。
这一行 Makefile 规则的作用,就是把编译好的"逻辑代码"和"硬件地图"缝合在一起。
依赖项:原材料拆解
- u-boot-nodtb.bin (代码主体):
nodtb 的意思是 "no Device Tree Blob"。
它是从 u-boot ELF 文件中提取出来的纯机器码,它包含了所有的函数逻辑,但它不知道具体的硬件寄存器基地址。- dts/dt.dtb (硬件地图):
( "/" 代表 dt.dtb 这个文件位于一个名为 dts 的文件夹内)
这是由 .dts 源文件经过 DTC (Device Tree Compiler) 编译生成的二进制文件。
dts: Device Tree Source(设备树源文件目录) 。这是存放 .dts(文本格式,人能读懂)文件的地方。
dt: Device Tree(设备树) 。
dtb: Device Tree Blob(设备树二进制大对象) 。它是 .dts 经过编译器(DTC)处理后生成的二进制文件,机器才能读懂。
它记录了 i.MX6ULL 的外设信息。- FORCE:
这是一个伪目标,确保 Makefile 每次运行到这里时都会重新检查依赖关系和命令。
命令:$(call if_changed,cat)这一行看起来是调用了一个复杂的函数,其实核心动作非常"暴力":
if_changed:这是 U-Boot 构建系统的一个智能宏。
它会对比当前生成的命令和上次编译时的命令,或者检查依赖文件是否有更新,只有在必要时才会执行。
cat:这就是 Linux 系统中的 cat 命令。
实际发生的动作:
shellcat u-boot-nodtb.bin dts/dt.dtb > u-boot-dtb.bin它直接将设备树二进制文件接在了代码镜像的末尾。
接下来是分析 u-boot-nodtb.bin 和 dts/dt.dtb 这两个依赖
先分析 dts/dt.dtb
shell
dts/dt.dtb: checkdtc u-boot
$(Q)$(MAKE) $(build)=dts dtbs
目标与依赖
目标 (dts/dt.dtb):主 Makefile 最终需要的硬件地图文件。
依赖项 (checkdtc):这是一个检查动作。在编译设备树之前,系统必须先检查你的电脑上是否安装了 dtc(设备树编译器)以及版本是否符合要求。
依赖项 (u-boot):依赖于 ELF 格式的 u-boot。这确保了在处理设备树之前,主要的链接工作已经完成(或者至少同步进行)。
命令部分
shell$(Q)$(MAKE) $(build)=dts dtbs$(Q):控制是否回显命令。
如果你 make V=1,你能看到完整的命令;默认情况下它是安静模式。
$(MAKE):启动一个新的 make 进程。
$(build)=dts:这是 Kbuild 系统的精髓。
它告诉 Make:"去子目录 dts/ 寻找 Makefile,并以那里的规则为准"。
dtbs:这是传给子 Makefile 的目标参数,意思是"生成所有的设备树二进制文件"。
再分析 u-boot-nodtb.bin
shell
u-boot-nodtb.bin: u-boot FORCE
$(call if_changed,objcopy)
$(call DO_STATIC_RELA,$<,$@,$(CONFIG_SYS_TEXT_BASE))
$(BOARD_SIZE_CHECK)
shell$(call if_changed,objcopy)这是最核心的一步。
背景:u-boot (ELF) 文件虽然包含机器码,但它还带着大量的调试信息、符号表、段描述符等。如果你直接把它烧进 SD 卡,CPU 是读不懂这些元数据的。
动作:objcopy 工具只把 .text(代码段)、.data(数据段)等真正运行需要的二进制机器码提取出来。
结果:生成一个纯净的机器码文件。
shell$(call DO_STATIC_RELA,$<,$@,$(CONFIG_SYS_TEXT_BASE))参数解释:
$<:代表依赖项 u-boot。
$@:代表目标项 u-boot-nodtb.bin。
$(CONFIG_SYS_TEXT_BASE):这就是你在 autoconf.h 或 Kconfig 里定义的 U-Boot 在内存(DDR)中的起始地址(通常是 0x87800000)。
为什么需要它?:有些指令使用了绝对地址。如果你编译时指定代码在 0x87800000 运行,但实际由于某些原因需要挪动位置,这个宏(如果开启了 CONFIG_STATIC_RELA)会处理静态重定位表,确保代码在指定的内存基地址上能正确跑起来。
DO_STATIC_RELA 的具体工作:它运行一个脚本(通常是 tools/relocate-rela),读取 u-boot (ELF) 里的重定位表,然后直接修改 u-boot-nodtb.bin 里的机器码,把里面所有相对于基地址的偏移量,根据你设定的 $(CONFIG_SYS_TEXT_BASE) 算好并填进去。
shell$(BOARD_SIZE_CHECK)作用:检查生成的 u-boot-nodtb.bin 大小是否超过了硬件限制。
场景:i.MX6ULL 的内部寄存器和缓存大小是有限的。如果你的 U-Boot 编得太臃肿,超过了设定的最大尺寸,这一行会直接报错中止编译,防止你烧录一个注定无法启动的镜像。
u-boot (ELF):包含所有函数名和行号,文件可能很大(几十MB)。u-boot.bin:是精简版,只有机器指令,文件很小(几百KB)。
联系:正式 objcopy 进行了删减
7.4 u-boot 跟踪
shell
u-boot: $(u-boot-init) $(u-boot-main) u-boot.lds FORCE
$(call if_changed,u-boot__)
ifeq ($(CONFIG_KALLSYMS),y)
$(call cmd,smap)
$(call cmd,u-boot__) common/system_map.o
endif
这一行规则的目的是让所有源代码(.c 和 .S)从零散的状态,最终组合成成一个可执行整体(ELF 格式)。
依赖项:
shellu-boot: $(u-boot-init) $(u-boot-main) u-boot.lds FORCE$(u-boot-init)
通常包含的是板子启动时最先运行的那一小段代码(比如 arch/arm/cpu/armv7/start.S 编译出的目标)。
它必须放在内存的最前面,否则 CPU 上电后找不到第一条指令。
$(u-boot-main)这是 U-Boot 的核心,包含了配置的所有驱动(USB、网络、串口)、文件系统和命令处理逻辑。
它实际上是把各个子目录下的 built-in.o 文件打包在了一起。
u-boot.lds这是链接脚本。
它不提供代码,但它告诉链接器(LD):把 u-boot-init 放哪,把 u-boot-main 放哪,内存起始地址设为多少。
命令部分:
shell$(call if_changed,u-boot__)本质:这行宏调用最终会转换成一条长长的链接命令。
实际动作:arm-linux-ld [参数] -T u-boot.lds (u-boot-init) --start-group (u-boot-main) --end-group -o u-boot。
意义:它把所有的二进制碎片按照链接脚本的指示,严丝合缝地缝合在一起,生成带调试信息的 ELF 文件。
shellifeq ($(CONFIG_KALLSYMS),y) $(call cmd,smap) $(call cmd,u-boot__) common/system_map.o endif如果配置里开启了 CONFIG_KALLSYMS:
cmd,smap:系统会先扫描一遍刚才生成的 u-boot,提取出所有函数的地址和名字,存入 common/system_map.o。
二次链接:再次执行 u-boot__,把这个带有"地址簿"的 .o 文件也链接进去。
好处:U-Boot 在 i.MX6ULL 上崩溃时,它能直接打印出出错的函数名字(如 do_bootm),而不是给你一串冰冷的 16 进制地址。这对于研究生阶段做科研调试非常有帮助。
我们已经看完了文件是如何组装的,下一步应该去看零件是从哪来的,所以接下来要分析 u-boot-main :
shell
u-boot-main := $(libs-y)
(注意:n 和 : 之间有一个空格,不加空格无法搜索出来)
在这一行之前,Makefile 已经执行了成百上千行 libs-y += ...。
里面装满了 drivers/、common/、fs/、net/ 等目录名。
u-boot-main:这一行把购物车里的内容一次性倒进"总装清单"里。
:= (立即赋值):它告诉 Make,"现在立刻就把 libs-y 里的目录列表确定下来,不要等后面变了再改"。
为什么不直接在链接命令里用 $(libs-y),非要倒手给 u-boot-main?这体现了 U-Boot 构建系统的分层设计思维:
1. 解耦:libs-y 负责收集(不同架构、不同配置下的文件夹)。
2. 管理:u-boot-main 负责定义结构。在复杂的版本中,u-boot-main 可能还要包含 (libs-m) 或其他特殊模块,通过 u-boot-main 统一管理,可以让最后的链接命令 (call if_changed,u-boot__) 保持简洁。
shell
libs-y += drivers/spi/
libs-$(CONFIG_FMAN_ENET) += drivers/net/fm/
libs-$(CONFIG_SYS_FSL_DDR) += drivers/ddr/fsl/
libs-$(CONFIG_SYS_FSL_MMDC) += drivers/ddr/fsl/
libs-$(CONFIG_ALTERA_SDRAM) += drivers/ddr/altera/
libs-y += drivers/serial/
libs-y += drivers/usb/cdns3/
libs-y += drivers/usb/dwc3/
libs-y += drivers/usb/common/
libs-y += drivers/usb/emul/
libs-y += drivers/usb/eth/
libs-y += drivers/usb/gadget/
libs-y += drivers/usb/gadget/udc/
libs-y += drivers/usb/host/
libs-y += drivers/usb/musb/
libs-y += drivers/usb/musb-new/
libs-y += drivers/usb/phy/
libs-y += drivers/usb/ulpi/
libs-y += cmd/
libs-y += common/
libs-$(CONFIG_API) += api/
libs-$(CONFIG_HAS_POST) += post/
libs-y += test/
libs-y += test/dm/
libs-$(CONFIG_UT_ENV) += test/env/
libs-$(CONFIG_UT_OVERLAY) += test/overlay/
libs-y += $(if $(BOARDDIR),board/$(BOARDDIR)/)
libs-XXX 和 ALL-XXX 一个原理
它们都遵循 Kbuild 系统的核心公式:变量-(宏定义) += 目标。 **libs-(CONFIG_XXX) += ...**
角色:零件收集器。
原理:如果 CONFIG_XXX 是 y,就把对应的文件夹(车间)加入到 libs-y 托盘里。
最终归宿:这些文件夹里的代码会被编成 .o 文件,最后放入 u-boot 里。
ALL-$(CONFIG_XXX) += ...角色:产物决定者。
原理:如果 CONFIG_XXX 是 y,就把对应的最终文件(如 u-boot.img、u-boot.kwb)加入到 ALL-y 清单里。
最终归宿:决定了运行 make 结束后,目录下会多出哪些可以直接拿去烧录的文件。