1. Linux内核构建系统的Makefile执行机制解析

Linux内核的构建系统是嵌入式开发中最具代表性的大型软件工程实践之一。其核心并非单一Makefile,而是一套分层、模块化、高度自动化的Kbuild框架。该框架通过顶层Makefile协调scripts/目录下的多个专用Makefile(如Makefile.build、Makefile.lib、Kbuild.include等),实现对数百万行C代码、汇编文件及配置项的精准编译与链接。理解其执行流程,是进行内核裁剪、驱动开发、交叉编译及故障排查的基础能力。本文基于Linux-5.2.5内核源码,从工程实践角度系统剖析Kbuild的执行逻辑,重点揭示变量展开时机、目标依赖推导、内置函数行为及关键编译阶段的内在机理。

1.1 Makefile基础语法在内核构建中的工程化体现

内核Makefile并非普通应用层脚本,其语法特性被深度用于构建过程的动态控制与条件化处理。工程师必须清晰区分哪些语法成分在解析阶段执行,哪些在执行阶段触发,否则将无法理解为何某些变量未按预期展开,或为何某条规则从未被调用。

1.1.1 Shell语句的执行上下文

在Kbuild中,Shell命令仅在两个明确上下文中执行:

  • 编译规则的指令部分 :当某个目标被选为执行目标且其依赖满足时,该目标下的所有 $(CC) 、 $(LD) 等命令行才由Shell解释器执行。例如,在 vmlinux 目标的规则中, $(LD) $(LDFLAGS) -o $@ $(vmlinux-init) $(vmlinux-main) $(vmlinux-mods) 这一行,仅在 vmlinux 被实际构建时运行。
  • $(shell ...) 与 != 赋值中的命令 :此类命令在Makefile解析阶段即刻执行,其输出结果作为字符串赋给变量。例如, KERNELVERSION := $(shell cat include/config/kernel.release 2>/dev/null) 在读取Makefile时就完成内核版本号的读取,确保后续所有规则都能引用一个确定的字符串值。同理, CC_VERSION := $(shell $(CC) --version | head -n1 | cut -d' ' -f3-) 在解析期即获取编译器版本,用于后续的编译器特性检测。
1.1.2 变量赋值的语义差异与工程意义

内核Makefile大量使用不同赋值操作符,其选择直接决定了构建的可预测性与性能:

操作符 语义 在Kbuild中的典型用途 工程意义
= 延迟赋值(递归展开) KBUILD_CFLAGS := $(KBUILD_AFLAGS) $(CC_FLAGS) 变量值在每次被引用时才展开,适合包含其他变量的复合定义,但需警惕循环引用风险。
:= 立即赋值(简单展开) KBUILD_EXTRA_SYMBOLS := $(KBUILD_EXTMOD)/Module.symvers 赋值时立即展开右侧所有变量,生成一个静态字符串。这是Kbuild中最安全、最常用的赋值方式,避免了因变量后续修改导致的不可预期行为。
!= Shell命令执行赋值 HOSTCC := $(shell which gcc) 将Shell命令的stdout作为变量值,常用于工具链路径探测。
?= 条件赋值 KBUILD_OUTPUT ?= $(CURDIR) 仅当变量此前未被定义时才赋值,为用户提供覆盖默认值的入口,是构建系统可配置性的基石。
+= 追加赋值 KBUILD_CFLAGS += -I$(srctree)/include 在已有值后追加新内容,是模块化添加编译选项的标准做法,保证各子模块的编译标志互不干扰。
1.1.3 include 与 -f/-C :构建系统的模块化骨架

Kbuild的模块化本质依赖于 include 和 -C 机制:

  • include 指令将指定文件的内容原样插入到当前Makefile的对应位置。内核顶层Makefile通过 include $(srctree)/arch/$(SRCARCH)/Makefile 引入架构特定的编译规则,通过 include $(srctree)/scripts/Makefile.build 引入通用的模块构建逻辑。这种“拼接”方式使得架构无关代码与架构相关代码完全解耦。
  • -C 参数用于 make 命令本身,指示Make进入指定目录并执行该目录下的Makefile。例如, $(Q)$(MAKE) $(build)=drivers/usb/core 这条命令,其本质是 make -C $(srctree) M=$(srctree) build=drivers/usb/core 。 -C 是Kbuild实现“一次配置、多处构建”的核心,它让外部模块( M= )能复用内核源码树中的全部构建基础设施,而无需复制任何Makefile。
1.1.4 目标执行与依赖推导:构建流程的驱动引擎

Make的执行起点并非文件开头,而是 目标(Target) 。 make 命令行若未指定目标,则执行第一个显式声明的目标(通常是 all 或 _all )。在Kbuild中,这个默认目标被精心设计为 all: vmlinux modules ,从而启动整个内核镜像与模块的构建。

依赖推导分为两个层面:

  • 显式依赖 :由 target: dependency1 dependency2 语法明确定义。例如, vmlinux: $(vmlinux-lds) $(vmlinux-init) $(vmlinux-main) $(kallsyms.o) 清晰地列出了 vmlinux 的所有直接输入。
  • 隐式依赖(自动推导) :Kbuild通过 $(CC) -M 命令为每个 .c 文件生成 .d 依赖文件(如 main.o.d ),其中包含了该C文件所 #include 的所有头文件的完整路径。这些 .d 文件被 include 进主Makefile,使得当任意一个头文件被修改时,Make能自动识别出哪些 .o 文件需要重新编译。这是保证增量编译正确性的关键技术。
1.1.5 时间戳与中间文件:增量编译的可靠性保障

Kbuild的高效性源于其对文件时间戳的严格管理:

  • .cmd 文件(如 main.o.cmd )记录了生成对应 .o 文件所使用的完整命令行。当 .c 文件或其依赖的头文件时间戳更新,或 .cmd 文件中记录的命令行与当前配置不一致时,Make会判定该目标过期,触发重新编译。
  • .d 文件(如 main.o.d )不仅用于依赖推导,其自身的时间戳也参与判断。如果 .d 文件比 .c 文件旧,说明依赖关系可能已失效,Kbuild会先重新生成 .d 文件,再据此决定是否重编 .o 。
1.1.6 关键内置函数:构建逻辑的原子操作单元

Kbuild大量封装了Make的内置函数,形成一套高阶构建原语:

# (1) $(wildcard pattern): 文件名通配,用于发现源文件
obj-y += $(wildcard drivers/usb/core/*.o)

# (2) $(patsubst pattern, replacement, text): 模式替换,用于生成目标列表
lib-y := $(patsubst %/, %/built-in.a, $(lib-y))

# (3) $(strip string): 去除首尾空格,用于清理路径
src := $(strip $(src))

# (4) $(filter pattern, text) / $(filter-out pattern, text): 条件筛选
# 从所有对象中筛选出需要编译进vmlinux的
vmlinux-init := $(filter $(init-y), $(init-y) $(init-m))

# (5) $(call func, args...): 函数调用,是Kbuild宏定义的核心
# 定义一个名为"cmd_cc_o_c"的函数,用于生成编译.c的命令行
cmd_cc_o_c = $(CC) $(c_flags) -c -o $@ $<

# 调用该函数,生成最终的命令行字符串
$(obj)/%.o: $(src)/%.c FORCE
	$(call if_changed_dep,cc_o_c)

这些函数的组合使用,使得Kbuild能够以声明式的方式描述复杂的构建逻辑,而无需编写冗长的Shell脚本。

1.2 Kbuild核心宏: $(build) 与 $(if_changed) 的深度剖析

Kbuild框架的精髓在于其定义的两个核心宏: $(build) 和 $(if_changed) 。它们是连接顶层Makefile与底层构建规则的桥梁,也是理解内核如何“自动化”编译的关键。

1.2.1 $(build) 宏:构建作用域的隔离与委托

$(build) 并非一个简单的变量,而是一个被 export 到子Make进程的环境变量,其值为一个目录路径。其标准调用形式为:

$(Q)$(MAKE) $(build)=drivers/usb/core

该命令的完整展开等价于:

make -C $(srctree) M=$(srctree) build=drivers/usb/core

其内部执行过程如下:

  1. 环境准备 :父Make进程将 build=drivers/usb/core 作为环境变量传递给子 make 进程。
  2. 目录切换 :子 make 进入内核源码根目录( $(srctree) )。
  3. Makefile加载 :子 make 加载 scripts/Makefile.build (而非顶层Makefile),因为 build= 参数的存在会覆盖默认的Makefile查找逻辑。
  4. 作用域限定 : scripts/Makefile.build 会将 $(build) 的值赋给 obj 变量,并据此扫描 $(obj) 目录下的 Kbuild 或 Makefile 文件,读取其中的 obj-y 、 obj-m 等定义,从而只构建该目录及其子目录下的目标。
  5. 递归构建 :若 $(obj) 目录下有子目录被列为 obj-y , Makefile.build 会再次调用 $(MAKE) $(build)=subdir/path ,形成深度优先的构建树。

$(build) 的作用概括为: 将一个物理目录路径映射为一个独立的、可复用的构建作用域,实现构建任务的精确委派与隔离。 这是Kbuild支持海量驱动模块并行、按需构建的根本机制。

1.2.2 $(if_changed) 宏:构建决策的智能引擎

$(if_changed) 是Kbuild中控制“是否需要重新执行某条命令”的核心判据。其调用形式为:

$(call if_changed,ld)
$(call if_changed,cc_o_c)

其内部逻辑高度工程化,结合了 .cmd 文件与时间戳检查:

  1. 命令行快照 :在首次成功执行 $(CC) -c -o main.o main.c 后,Kbuild会生成 main.o.cmd 文件,其内容为:

    cmd_main.o := $(CC) $(c_flags) -c -o main.o main.c
    

    这个 cmd_ 变量精确记录了生成该目标所用的完整命令行。

  2. 变更检测 :当 make 再次运行时, $(if_changed,cc_o_c) 会执行以下步骤:

    • 检查 main.o 是否存在且非空。
    • 检查 main.o.cmd 是否存在。
    • 如果 main.o.cmd 存在,读取其中的 cmd_main.o 变量值。
    • 将当前计算出的 cmd_main.o 值(基于当前 $(CC) 、 $(c_flags) 等变量)与 main.o.cmd 中存储的旧值进行字符串比较。
    • 同时,检查 main.c 及其所有依赖头文件(来自 main.o.d )的时间戳是否比 main.o 新。
    • 仅当以上任一条件为真时,才判定为“changed”,并执行 $(CC) ... 命令;否则,跳过该步骤。

$(if_changed) 的作用概括为: 提供一个幂等的、基于内容哈希(命令行字符串)与时间戳双重校验的构建决策机制,确保只有在输入或构建逻辑发生实质性变化时,才触发耗时的编译操作。 这是内核构建速度与可靠性的双重保障。

2. 外部模块编译: make M= 的完整执行链

外部模块(Out-of-Tree Module)的编译是驱动开发的日常操作。其命令 make -C /path/to/kernel M=$(pwd) modules 看似简单,背后却是一条跨越用户空间与内核源码树的复杂执行链。

2.1 关键Makefile角色

  • 用户当前目录的Makefile :通常仅包含一行 obj-m := mydriver.o ,定义了要构建的模块。
  • 内核源码树顶层Makefile :作为入口点,接收 -C 参数并初始化全局变量。
  • scripts/Makefile.build :被 $(build)=. 调用,负责解析用户目录的Makefile,生成构建规则。
  • scripts/Makefile.modpost :在模块编译完成后,负责生成 Module.symvers 符号版本文件,并处理模块间的符号依赖。

2.2 概要执行流程

  1. 用户执行 make -C /lib/modules/$(uname -r)/build M=$(pwd) modules 。
  2. 内核顶层Makefile被加载, M=$(pwd) 被设置为环境变量。
  3. 顶层Makefile识别到 modules 目标,其规则为 modules: $(modules) ,并调用 $(Q)$(MAKE) $(build)=$(M) 。
  4. $(build)=$(M) 触发 scripts/Makefile.build ,后者进入用户目录,读取其 Makefile 和 Kbuild 。
  5. Makefile.build 根据 obj-m 定义,生成 mydriver.o 和 mydriver.ko 的构建规则。
  6. 编译 mydriver.o ,并链接生成 mydriver.ko 。
  7. 最后, Makefile.modpost 被调用,扫描所有 .ko 文件,生成 Module.symvers 供后续模块依赖。

2.3 详细流程与关键变量

步骤 执行者 关键动作 工程要点
1. 初始化 顶层Makefile KBUILD_EXTMOD := $(M) ; KBUILD_SRC := $(srctree) KBUILD_EXTMOD 成为外部模块的根目录, KBUILD_SRC 指向内核源码,两者分离是外部构建的前提。
2. 解析模块Makefile Makefile.build include $(KBUILD_EXTMOD)/Makefile ; obj-m := $(KBUILD_EXTMOD)/mydriver.o Makefile.build 会将 $(KBUILD_EXTMOD) 加入 obj 搜索路径,确保能正确找到用户定义的 obj-m 。
3. 构建对象文件 Makefile.build $(CC) -I$(KBUILD_EXTMOD) -I$(KBUILD_SRC)/include ... -c -o mydriver.o mydriver.c 编译时会同时包含内核头文件( $(KBUILD_SRC)/include )和模块所在目录( $(KBUILD_EXTMOD) ),以支持模块自身的头文件。
4. 链接模块 Makefile.build $(LD) -r -o mydriver.o mydriver.o (relocation); $(LD) -r -o mydriver.ko mydriver.o 使用 -r (relocatable)链接,生成一个可重定位的ELF文件,即 .ko 模块。
5. 符号后处理 Makefile.modpost scripts/modpost -i $(KBUILD_EXTMOD)/Module.symvers -o $(KBUILD_EXTMOD)/modules.order $(KBUILD_EXTMOD)/*.o modpost 工具分析所有 .o 文件的 EXPORT_SYMBOL ,并与 Module.symvers 比对,确保符号可见性。

3. make menuconfig :内核配置系统的Makefile驱动

make menuconfig 是内核定制的起点,其背后同样由一套精巧的Makefile驱动。

3.1 涉及的Makefile

  • 顶层Makefile :定义 menuconfig 目标及其依赖。
  • scripts/kconfig/Makefile :定义了 conf , mconf , qconf 等配置工具的构建规则。
  • scripts/kconfig/ 下的源码 : mconf.c (基于ncurses的菜单配置器)。

3.2 概要流程

  1. make menuconfig 触发顶层Makefile中的 menuconfig 目标。
  2. 该目标依赖于 $(obj)/mconf ,即 scripts/kconfig/mconf 可执行文件。
  3. 若 mconf 不存在或过期,则 Makefile 会先编译 scripts/kconfig/mconf.c 。
  4. mconf 被调用,读取 Kconfig 文件(位于 $(srctree) 根目录及各子目录),生成交互式菜单。
  5. 用户配置后, mconf 将结果写入 .config 文件。

3.3 详细流程与配置传播

  • Kconfig文件解析 : mconf 递归扫描 $(srctree) 下的所有 Kconfig 文件,构建一个完整的配置项树。每个 Kconfig 文件通过 source "drivers/usb/Kconfig" 语句包含子目录的配置。
  • .config 生成 :用户的选择被写入 .config ,这是一个纯文本文件,格式为 CONFIG_USB=y 或 CONFIG_USB=n 。
  • include/generated/autoconf.h 生成 : make 在后续构建阶段,会调用 conf 工具(一个无界面的配置器)读取 .config ,并生成 include/generated/autoconf.h 。该头文件将 CONFIG_XXX 转换为C语言的宏定义( #define CONFIG_USB 1 ),供内核源码中的 #ifdef CONFIG_USB 条件编译使用。
  • include/config/auto.conf 生成 :同时生成一个Makefile格式的 auto.conf ,其中包含 CONFIG_USB=y 等行,供顶层Makefile在解析期直接 include ,从而在构建早期就能根据配置决定哪些目录需要被 $(build)= 。

4. make [all/_all/modules] :内核镜像构建的核心阶段

make 命令不带参数时,默认执行 all 目标,其核心任务是生成 vmlinux (未压缩的内核镜像)和 modules (可加载模块)。

4.1 关键目标与依赖关系

# 顶层Makefile片段
all: vmlinux modules

vmlinux: $(vmlinux-lds) $(vmlinux-init) $(vmlinux-main) $(kallsyms.o)
	$(call if_changed,ld)
	$(Q)$(MAKE) $(build)=. $(vmlinux-deps)

# prepare目标,是几乎所有编译工作的前置条件
prepare: $(required-targets) $(timestamp-header)
	$(Q)$(MAKE) $(build)=.

# required-targets 包含了 arch/$(SRCARCH)/include/generated/asm-offsets.h 等

4.2 概要流程

  1. prepare 阶段 :生成所有架构相关的头文件(如 asm-offsets.h )、链接脚本( vmlinux.lds )和编译所需的临时目录结构。
  2. vmlinux-init 阶段 :编译 init/ 目录下的所有代码( main.o , do_mounts.o 等),构成内核初始化段。
  3. vmlinux-main 阶段 :编译 drivers/ , fs/ , mm/ , net/ 等核心子系统,生成大量 .o 文件。
  4. 链接 vmlinux :将 vmlinux-init 和 vmlinux-main 中的所有 .o 文件,按照 vmlinux.lds 链接脚本的要求,链接成一个完整的、可执行的ELF文件 vmlinux 。
  5. 生成 vmlinuz :对 vmlinux 进行压缩( gzip / lzma / zstd ),并添加引导头(boot header),生成最终的 arch/x86/boot/bzImage (x86)或 arch/arm64/boot/Image (ARM64)。

4.3 详细流程与技术细节

vmlinux 目标
  • 链接脚本 vmlinux.lds :由 scripts/link-vmlinux.sh 脚本根据 arch/$(SRCARCH)/kernel/vmlinux.lds.S 模板生成。它精确指定了 .text , .data , .bss , .init.text 等段在内存中的布局,是内核能在物理地址上正确运行的蓝图。
  • kallsyms.o :一个特殊的对象文件,由 scripts/kallsyms 工具从 vmlinux 中提取所有符号(函数名、变量名)并生成一个C数组,再编译成 .o 。将其链接回 vmlinux ,使得内核在运行时能通过 kallsyms_lookup_name() 等函数动态查找符号,这对调试和 kprobe 等机制至关重要。
prepare 目标
  • asm-offsets.h 生成 :编译一个特殊的C文件 arch/$(SRCARCH)/kernel/asm-offsets.c ,该文件使用 offsetof() 宏计算内核数据结构中各字段的偏移量。编译后,通过 scripts/recordmcount.pl 等脚本,将 printf 输出的偏移量信息提取出来,写入 arch/$(SRCARCH)/include/generated/asm-offsets.h 。此头文件被汇编代码( .S 文件)广泛引用,是C与汇编协同工作的关键粘合剂。
  • generated/ 目录创建 : prepare 会确保 include/generated/ 、 arch/$(SRCARCH)/include/generated/ 等目录存在,为后续生成的头文件提供存放位置。

5. 总结:从Makefile到可执行内核的工程实践

Linux内核的构建过程,是一场精密的工程交响乐。 make 命令是指挥家,顶层Makefile是总谱,而 $(build) 与 $(if_changed) 则是乐手们遵循的核心演奏规则。每一个 vmlinux 的诞生,都始于 make menuconfig 对 .config 的雕琢,成于 prepare 阶段对架构头文件的生成,盛于 vmlinux-init 与 vmlinux-main 的并行编译,最终在 ld 的链接声中完成合奏。

对于嵌入式工程师而言,掌握这套机制的意义远超“能编译内核”。当遇到 undefined reference to 'xxx' 链接错误时,能通过检查 Module.symvers 和 $(if_changed,ld) 的日志定位符号缺失根源;当 make 耗时过长时,能通过分析 .cmd 和 .d 文件,判断是编译器缓存失效还是依赖关系错误;当需要为新硬件添加驱动时,能准确写出符合 Kbuild 规范的 Makefile 和 Kconfig ,无缝融入整个构建体系。这便是Kbuild所赋予工程师的,一种深入操作系统心脏的、可信赖的工程能力。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐