一、从 make 开始
make 首次出现于 1976 年的贝尔实验室,它是一个典型的 Unix 项目,距今约有 50 年历史。在此之前,人们主要通过编写「make」「install」「clean」等 shell 脚本解决项目的构建与清理任务。在小型项目的场景下,这些脚本的编写与管理的确简单方便。但随着项目复杂度越来越高,工程构建的需求越来越复杂,致使项目构建越来越困难。
每个人的工作经验和编码习惯是不一样的,所以对于项目构建的方式与理解也不尽相同。在这块没有标准的「无法地带」土壤上,总是伴随着无休无止的争论。这极大地降低了程序员们的生产力。
make 工具链正是人们意识到这个问题后产生的第一个普遍意义上的解决方案。通过编写一个 Makefile 文本文件,即可将项目构建活动所需的脚本或命令都集中到一个入口上,只需描述构建规则(由构建目标、先决关系和构建命令组成)以及规则之间的依赖关系即可。至今它依旧活跃在传统 C 语言/Go 语言项目的构建流水线上。
1.1 make 是怎样解决项目的构建问题的?
程序员们往往会靠抽象和封装解决复杂的逻辑问题,靠建立标准或协议解决有争议的问题。
make 也不例外。为了让项目的构建更简单,Makefile 中隐藏了大量规则使得它的编写变得更为简单(后面详细介绍)。并且它将通用的构建逻辑封装成了 Makefile 世界中的底层逻辑,比如:
- 构建规则的抽象,只有先决条件发生变化或发生修改才会去真正执行构建动作;
- make 中的变量规则,使得你能通过环境变量、命令行变量、文件内声明的变量去设定构建需要的变量;
- 根据目标进行先决条件和构建命令的自动推导,进一步简化常见语言 Makefile 的编写;
从这里可以看到,初代编译系统的特性点为:
- 比对目标和先决条件的更新时间,仅编译必要的部分;
- 用隐藏规则、自动推导等手段简化构建文件的编写,让开发者专注于功能开发而不是项目构建;
- 利用变量加载规则实现多模式/跨环境的项目构建;
1.2 make 的后继者们
make 成为项目构建界的第一块基石之后,起到了抛砖引玉的作用。很多人基于 make 工具或思想对其进行封装、扩展或重构。下面介绍几个比较常见的工具。
衍生项目
常说的 make 全名其实是 GNU make,所以它是属于 GNU Project 的,并且是开源的,仅适用于类 Unix 操作系统。在其它操作系统或环境中衍生出了大量类 make 项目,比如:
- 适用于 BSD OS 的 bmake
- 适用于 MS OS 的 nmake
- 适用于 Java 的 Apache Ant,它使用 XML 描述代码的构建过程和依赖关系。它和 Java 语言一样,为跨平台而存在,所以它基本不使用 shell 命令或脚本进行构建。所有必要的操作都封装成了 XML 标签和打成 Jar 包的插件
生成器项目
生成器能站在更高的层次上看待和抽象问题,make 也有很多生成器,能简化构建文件的编写,生成兼容性更好的 Makefile 文件。
autotools
Auto Tools 是 GNU 为了解决 Makefile 跨平台配置问题的一套工具链。首先通过 autoscan 扫描工作目录,生成 configure.scan 文件。然后将它改名为 configure.ac,并修改配置内容。再执行 aclocal 命令,它会根据 configure.ac 生成 Autoconf宏文件 aclocal.m4。接着再执行 autoconf 命令为 configure.ac 进行宏展开生成 configure 脚本。此时工作就完成一半了。然后再编写 Makefile.am 配置文件,执行 automake --add-missing 命令生成 Makefile.in 文件。此时便可以根据 ./configure 命令生成最终的 Makefile 了。
它虽然是 GNU 工程的官方解决方案,并且足够灵活,但它的工作原理和使用过程都十分复杂,暂不推荐学习 autotools 工具链。
cmake
cmake 也是针对 make 跨平台构建支持的不足而出现的项目,通过编写 CMakeLists.txt 描述项目模块之间的依赖关系,再执行 cmake,它即可根据现有环境自动生成一份复杂的 Makefile。它与 make 的区别在于:make 是命令式的,cmake 是声明式的,你无需关注编译细节,只需要描述编译所需的必要信息即可。
二、现代环境下的构建工具
2.1 meson
Meson 是一个现代化的构建系统,设计目标是提供极致的构建速度和出色的用户体验。它于 2013 年由 Jussi Pakkanen 发起,其核心理念是「快」——不仅构建速度快,配置文件的编写和维护也应该快速便捷。
与 CMake 类似,Meson 也采用声明式语法描述项目结构,但它的语法更加简洁直观。Meson 的配置文件称为 meson.build,使用一种类似 Python 的领域特定语言(DSL),而非 CMake 那样繁琐的命令式语法。这种设计使得 Meson 配置文件更易读、更易维护。
# meson.build 示例project('myapp', 'c', version: '1.0.0')
# 查找依赖dependency('glib-2.0')dependency('gtk+-3.0')
# 定义构建目标executable('myapp', 'src/main.c', 'src/utils.c', dependencies: [glib_dep, gtk_dep], install: true)
# 添加测试test('basic', executable('test_basic', 'tests/basic.c'))Meson 的一个显著特点是它原生支持多种现代开发工作流:
- 跨平台支持:原生支持 Linux、Windows、macOS,无需额外的配置文件
- 依赖管理:内置 Wrap 依赖系统,可直接从 Git 仓库或 tarball 获取依赖
- 多语言支持:支持 C、C++、D、Fortran、Java、Rust、C# 等多种语言
- 内置功能:原生支持单元测试、代码覆盖率、静态分析、文档生成等
Meson 的另一个重要特性是其可扩展的模块系统。通过内置模块,Meson 可以处理国际化和本地化(i18n)、RPM 包生成、Qt 资源编译等常见任务,无需额外编写脚本。
在性能方面,Meson 对增量构建进行了深度优化。它会精确追踪文件变化,仅重新编译受影响的目标。同时,Meson 与 Ninja 构建系统紧密集成,后者专为高速构建而设计,两者配合能够实现极快的构建速度。
2.2 ninja
Ninja 是一个专注于速度的小型构建系统。它由 Evan Martin 于 2010 年在 Google 开发,最初是为了解决 Chromium 项目的编译速度问题。Ninja 的设计哲学非常明确:不做任何决策,只负责快速执行。
与 make 不同,Ninja 没有「隐式规则」或「自动推导」。它要求构建文件显式列出每一个构建规则和依赖关系。这意味着 Ninja 本身不负责「如何」构建项目,而只负责「尽可能快地」执行构建命令。
# build.ninja 示例rule cc command = gcc -c $in -o $out
rule link command = gcc $in -o $out
build main.o: cc main.cbuild utils.o: cc utils.cbuild myapp: link main.o utils.oNinja 的核心优势在于其构建图的优化能力。它会分析整个构建依赖图,识别并行构建机会,并尽可能多地并行执行不相关的构建任务。这使得 Ninja 在多核 CPU 上能够充分利用硬件性能。
Ninja 的另一个关键设计是「最小重构建」。它通过精确的时间戳比对和依赖追踪,确保只重新编译真正需要更新的文件。这种精确性使得增量构建非常高效——即使项目有数万个文件,修改一个小文件后的重编译通常只需几秒钟。
正是因为 Ninja 的高效,它成为了许多现代构建系统的后端选择。CMake、Meson 等都可以生成 Ninja 构建文件,然后由 Ninja 执行实际的编译工作。这种「前端生成器 + 后端执行器」的架构,结合了声明式配置的易用性和命令式执行的高效性。
Ninja 几乎从不被直接编写。开发者通常使用 CMake、Meson 等高级工具生成 build.ninja 文件,然后让 Ninja 执行构建。这种分工让各工具专注于自己擅长的领域:
- 高层构建系统(CMake、Meson):负责解析项目结构、管理依赖、生成构建规则
- 低层构建系统(Ninja):负责高效调度和执行编译任务
2.3 monorepo、bazel 与分布式构建
随着软件项目规模的增长,传统的单仓库构建方式逐渐暴露出局限性。现代大型项目往往需要管理数十甚至上百个相互依赖的组件,这就催生了 monorepo(单一仓库)管理方式和配套的构建工具。
Monorepo 是一种将多个项目或组件放在同一个代码仓库中进行管理的策略。与多仓库(multirepo)方式相比,monorepo 的优势在于:
- 原子性提交:跨多个组件的修改可以在一次提交中完成
- 代码共享:不同项目可以直接引用共享代码,无需发布和版本管理
- 统一工具链:所有项目使用相同的构建工具和配置
- 简化依赖管理:不需要维护多个仓库之间的依赖关系
然而,monorepo 也带来了构建挑战。当仓库中包含数千个目标时,传统构建工具的增量构建能力往往不够精确,可能导致不必要的重新构建。这正是 Bazel 等 monorepo 专用构建工具大显身手的领域。
Bazel 是 Google 开源的构建和测试工具,它继承了 Google 内部 Blaze 构建系统的设计理念。Bazel 的核心特性包括:
# BUILD 文件示例cc_binary( name = "myapp", srcs = ["main.cc", "util.cc"], deps = [ "//lib:common", "//lib:network", ],)
cc_library( name = "common", srcs = ["common.cc"], hdrs = ["common.h"], visibility = ["//visibility:public"],)Bazel 的设计有几个关键特点:
Hermetic Build(封闭构建):Bazel 构建过程是完全确定的。它使用沙箱机制隔离每次构建,确保构建结果只受输入文件和构建规则的影响,而不受系统环境的影响。这意味着在不同机器上执行相同的构建会得到完全相同的结果。
精确的依赖分析:Bazel 要求显式声明所有依赖关系。这种显式声明使得 Bazel 能够精确判断哪些目标需要重新构建,从而实现真正高效的增量构建。即使项目有数万个目标,修改一个文件后 Bazel 也能快速确定受影响的目标。
分布式构建:Bazel 支持分布式构建执行,可以将编译任务分发到多台机器上并行执行。这对于大型项目尤为重要——Google 内部的构建集群可以在几分钟内完成数百万行代码的重新编译。
远程缓存:Bazel 可以使用远程缓存存储构建产物。如果其他开发者已经构建过相同的目标,你可以直接从缓存获取构建结果,无需本地重新编译。这大大加速了 CI/CD 流水线和团队协作效率。
除了 Bazel,monorepo 生态中还有其他重要的构建工具:
- Pants:由 Twitter 开发,支持 Python、Java、Scala 等语言,专注于细粒度的依赖管理
- Buck:Facebook 开发的构建系统,主要服务于 Java 和 Android 项目
- Nx:面向 JavaScript/TypeScript monorepo 的构建系统,提供智能的增量构建和缓存机制
- Turborepo:专为 JavaScript/TypeScript 设计的高性能构建系统,强调远程缓存和分布式任务执行
这些工具虽然各有侧重,但都遵循相似的设计原则:显式依赖声明、精确的增量构建、支持分布式执行。它们共同代表了构建系统在规模化场景下的演进方向。
在选择构建工具时,需要根据项目规模和团队情况做出权衡:
| 项目规模 | 推荐工具 | 理由 |
|---|---|---|
| 小型项目 | Make/Meson | 配置简单,学习成本低 |
| 中型项目 | CMake/Meson | 跨平台支持好,社区成熟 |
| 大型 monorepo | Bazel/Pants | 支持分布式构建和远程缓存 |
| JavaScript monorepo | Nx/Turborepo | 针对 JS 生态优化 |
三、现代项目/流水线构建最佳实践
从构建系统的发展历程中,见证了构建系统从一个简单的工具演变为复杂的工程实践体系:
- 学会拆解目标:即使是单体项目,在编译、测试和部署时也可以拆分为多个目标,拆分有利于增量构建,永远只做必要的工作;
- 遵循木桶原理:优化流水线时永远优先考虑最慢的部分;
- 项目设计阶段就需要考虑可测试性:所有外层依赖都选用标准的、可 Mock 的类型;
四、工具笔记
4.1 Makefile 简要笔记
已知,对于解决一个问题,确定执行方案后,无论你切换何类工具都不能降低方案的复杂度,你最多只能将某些复杂度隐藏到某个工具下面。make 也是如此,它将项目构建时的常见规则隐藏了起来,使得 Makefile 的编写更加容易与简洁。为了更清晰地理解 make 到底做了什么,为了能编写更加正确的 Makefile,需要了解 make 的内部世界。
从入口开始 - 文件和目标的加载规则
make 默认会加载 Makefile/makefile 文件,但同时也能通过 -f/--file 指定具体文件。加载入口 Makefile 后,它会继续解释和读入被 include 的 Makefile,include 的路径会从当前的相对路径或 -I/--include-dir 指定的路径中寻找。并且它还会把 MAKEFILES 环境变量进行读入,但不会加载目标,并且还会忽视其中的错误。
一般来说,make 的最终目标是 makefile 中的第一个目标,而其它目标一般是由这个目标连带出来的。这是 make 的默认行为。当然,一般来说,你的 makefile 中的第一个目标是由许多个目标组成,你可以指示 make,让其完成你所指定的目标。要达到这一目的很简单,需在 make 命令后直接跟目标的名字就可以完成(如前面提到的「make clean」形式)。
make 命令执行后有三个退出码:
- 0:表示执行成功。
- 1:如果 make 运行时出现任何错误,其返回 1。
- 2:如果你使用了 make 的
-q选项,并且 make 使得一些目标不需要更新,那么返回 2。
变量规则
变量定义
执行 Makefile 文件时,能从多处加载变量,按优先级从低到高可以分为:
- 环境变量
- Makefile 中定义的变量
- make 命令行中定义的变量,比如
make VAR1=VAL1 - -e 选项指定的变量,比如
make -e VAR1=VAL1
注:通过 make 命令行定义的变量和环境变量会传给 make 子进程,在递归调用时需要注意这一点。
# 默认的命令变量AR = arAS = asCC = ccCXX = g++CO = coCPP = $(CC) –EFC = f77GET = getLEX = lexPC = pcYACC = yaccYACCR = –rMAKEINFO = makeinfoTEX = texTEXI2DVI = texi2dviWEAVE = weaveCWEAVE = cweaveTANGLE = tangleCTANGLE = ctangleRM = rm –f
# 默认的命令行参数变量ARFLAGS : 函数库打包程序AR命令的参数。默认值是 rvASFLAGS : 汇编语言编译器参数。(当明显地调用 .s 或 .S 文件时)CFLAGS : C语言编译器参数。CXXFLAGS : C++语言编译器参数。COFLAGS : RCS命令参数。CPPFLAGS : C预处理器参数。( C 和 Fortran 编译器也会用到)。FFLAGS : Fortran语言编译器参数。GFLAGS : SCCS "get"程序参数。LDFLAGS : 链接器参数。(如: ld )LFLAGS : Lex文法分析器参数。PFLAGS : Pascal语言编译器参数。RFLAGS : Ratfor 程序的Fortran 编译器参数。YFLAGS : Yacc文法分析器参数。
%.o : %.c $(CC) -c $(CFLAGS) $(CPPFLAGS) $< -o $@变量操作
自动变量(特殊变量)
-
$@:表示规则中的目标文件集; -
$%:仅当目标是函数库文件中,表示规则中的目标成员名,否则其值为空; -
$<:依赖目标中的第一个目标名字; -
$?:所有比目标新的依赖目标的集合。以空格分隔。 -
$^:所有的依赖目标的集合。以空格分隔。 -
$+:这个变量很像$^,也是所有依赖目标的集合。只是它不去除重复的依赖目标。 -
$*:这个变量表示目标模式中%及其之前的部分。 -
$(@D)|$(*D):表示$@的目录部分(不以斜杠作为结尾)。 -
$(@F)|$(*F):表示$@的文件部分。 -
$(%D), $(%F):表示函数库(.a)文件成员的目录和文件部分。 -
$(<D), $(<F):表示依赖文件的目录部分和文件部分。 -
$(^D), $(^F):表示所有依赖文件的目录部分和文件部分(去重)。 -
$(+D), $(+F):表示所有依赖文件的目录部分和文件部分(允许重复)。 -
$(?D), $(?F):表示被更新的依赖文件的目录部分和文件部分。
自动推导规则
C 语言自动推导
filename.o 文件作为目标时,make 会自动把 filename.c 作为依赖,通过 cc -c filename.c 命令进行编译。
C++ 语言自动推导
filename.o 文件作为目标时,make 会自动把 filename.cc 或是 filename.C 作为依赖,通过 cc -c filename.c 命令进行编译。
链接 Object 文件的隐含规则
filename 目标依赖于 filename.o,通过 $(CC) $(LDFLAGS) filename.o $(LOADLIBES) $(LDLIBS) 命令进行链接。
汇编语言自动推导
filename.o 的目标的依赖目标会自动推导为 filename.s,通过 $(AS) $(ASFLAGS) 进行编译。filename.s 的目标的依赖目标会自动推导为 filename.S 通过 $(AS) $(ASFLAGS) 命令进行编译。
注:
- 这里只列出了常见的自动推导规则,一些过时的语言或工具的规则我省略掉了
- 内建的自动推导规则是允许被重载的
通配符规则
make 支持三个通配符:*、? 和 ~。它们的含义与 bash 中的定义是相同的。
*表示匹配任意个字符;?表示匹配单个字符;~表示用户目录;
函数规则
| 函数名 | 功能描述 |
|---|---|
$(origin <variable>) | 告诉变量的「出生情况」,有如下返回值:
|
$(addsuffix <suffix>,<names...>) | 把后缀 <suffix> 加到 <names> 中的每个单词后面,并返回加过后缀的文件名序列。 |
$(addprefix <prefix>,<names...>) | 把前缀 <prefix> 加到 <names> 中的每个单词前面,并返回加过前缀的文件名序列。 |
$(wildcard <pattern>) | 扩展通配符,例如:$(wildcard ${ROOT_DIR}/build/docker/\*) |
$(word <n>,<text>) | 取字符串 <text> 中第 <n> 个单词(从一开始),并返回字符串 <text> 中第 <n> 个单词。如 <n> 比 <text> 中的单词数要大,那么返回空字符串 |
$(subst <from>,<to>,<text>) | 把字串 <text> 中的 <from> 字符串替换成 <to>,并返回被替换后的字符串 |
$(eval <text>) | 将 <text> 的内容将作为 makefile 的一部分而被 make 解析和执行。 |
$(firstword <text>) | 取字符串 <text> 中的第一个单词,并返回字符串 <text> 的第一个单词 |
$(lastword <text>) | 取字符串 <text> 中的最后一个单词,并返回字符串 <text> 的最后一个单词 |
$(abspath <text>) | 将 <text> 中的各路径转换成绝对路径,并将转换后的结果返回 |
$(shell cat foo) | 执行操作系统命令,并返回操作结果 |
$(info <text ...>) | 输出一段信息 |
$(warning <text ...>) | 输出一段警告信息,而 make 继续执行 |
$(error <text ...>) | 产生一个致命的错误,<text ...> 是错误信息 |
$(filter <pattern...>,<text>) | 以 <pattern> 模式过滤 <text> 字符串中的单词,保留符合模式 <pattern> 的单词。可以有多个模式。返回符合模式 <pattern> 的字串 |
$(filter-out <pattern...>,<text>) | 以 <pattern> 模式过滤 <text> 字符串中的单词,去除符合模式 <pattern> 的单词。可以有多个模式,并返回不符合模式 <pattern> 的字串 |
$(dir <names...>) | 从文件名序列 <names> 中取出目录部分。目录部分是指最后一个反斜杠(/)之前的部分。返回文件名序列 <names> 的目录部分。 |
$(notdir <names...>) | 从文件名序列 <names> 中取出非目录部分。非目录部分是指最后一个反斜杠(/)之后的部分。返回文件名序列 <names> 的非目录部分。 |
$(strip <string>) | 去掉 <string> 字串中开头和结尾的空字符,并返回去掉空格后的字符串 |
$(suffix <names...>) | 从文件名序列 <names> 中取出各个文件名的后缀。返回文件名序列 <names> 的后缀序列,如果文件没有后缀,则返回空字串。 |
$(foreach <variable>,<list>,<text>) | 把参数 <list> 中的单词逐一取出放到参数 <variable> 所指定的变量中,然后再执行 <text> 所包含的表达式。每一次 <text> 会返回一个字符串,循环过程中 <text> 的所返回的每个字符串会以空格分隔,最后当整个循环结束时,<text> 所返回的每个字符串所组成的整个字符串(以空格分隔)将会是 foreach 函数的返回值。 |
4.2 有用的 Makefile 片段
构建项目时,可能需要提前安装一些工具或者库。这个过程可以提前执行写好的脚本实现,也可以写在 makefile 中每次都重新安装一遍。但更优雅的还是先对其进行检查,若不存在再进行安装。详见如下代码片段:
## 通过定义形如 tools.verify.<toolName> 依赖来达到检查 <toolName> 是否存在,若不存在则调用 tools.install.<toolName> 的结果.PHONY: tools.verify.%tools.verify.%: @if ! which $* &>/dev/null; then $(MAKE) tools.install.$*; fi
# 工具安装的包装.PHONY: tools.install.%tools.install.%: @echo "===========> Installing $*" @$(MAKE) install.$*
# 示例 go toolsinstall.git-chglog: @$(GO) install github.com/git-chglog/git-chglog/cmd/git-chglog@latest
# 示例 golanginstall.golang: # 若觉得 yum 源不稳定,则可以写一个复杂但更可靠的脚本来实现工具的安装 yum install go -y
# 示例,先进行代码生成,再构建 go 项目build: tools.verify.golang tools.verify.git-chglog @codegen -type=int ${ROOT_DIR}/internal/pkg/code @$(GO) build ${ROOT_DIR}/cmd/demo.goGolang 程序能轻松支持交叉编译,并且大多数 gopher 都约定将可执行文件入口定义到 cmd 目录中,所以参考下面的 makefile 片段能轻松实现 golang 的多平台编译:
# golang 专用,获取 cmd 目录下的所有编译目标,进行多平台构建# 定义变量GO_SUPPORTED_VERSIONS ?= 1.13|1.14|1.15|1.16|1.17|1.18|1.19|1.20# 定义路径变量ROOT_PACKAGE=/root/OUTPUT_DIR=/root/# 定义构建目标COMMANDS ?= $(filter-out %.md, $(wildcard ${ROOT_DIR}/cmd/*))BINS ?= $(foreach cmd,${COMMANDS},$(notdir ${cmd}))
.PHONY: go.build.verifygo.build.verify:ifneq ($(shell $(GO) version | grep -q -E '\bgo($(GO_SUPPORTED_VERSIONS))\b' && echo 0 || echo 1), 0) $(error unsupported go version. Please make install one of the following supported version: '$(GO_SUPPORTED_VERSIONS)')endif
.PHONY: go.buildgo.build: go.build.verify $(addprefix go.build., $(addprefix $(PLATFORM)., $(BINS)))
.PHONY: go.build.multiarchgo.build.multiarch: go.build.verify $(foreach p,$(PLATFORMS),$(addprefix go.build., $(addprefix $(p)., $(BINS))))
.PHONY: go.build.%go.build.%: $(eval COMMAND := $(word 2,$(subst ., ,$*))) $(eval PLATFORM := $(word 1,$(subst ., ,$*))) $(eval OS := $(word 1,$(subst _, ,$(PLATFORM)))) $(eval ARCH := $(word 2,$(subst _, ,$(PLATFORM)))) @echo "===========> Building binary $(COMMAND) $(VERSION) for $(OS) $(ARCH)" @mkdir -p $(OUTPUT_DIR)/platforms/$(OS)/$(ARCH) @CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) $(GO) build $(GO_BUILD_FLAGS) -o $(OUTPUT_DIR)/platforms/$(OS)/$(ARCH)/$(COMMAND)$(GO_OUT_EXT) $(ROOT_PACKAGE)/cmd/$(COMMAND)参考现代 IDE 对项目的生命周期管理功能的支持,也可以使用 Makefile 实现如下目标的管理:
- 静态代码检查(lint)
- 单元测试(test)
- 编译(build)
- 镜像打包和发布(image/image.push)
- 清理(clean)
- 代码生成(gen)
- 部署(deploy)
- 发布(release)
- 帮助(help)
- 为文件头追加版权声明(add-copyright)
- 生成 API 文档(swagger)
- 生成项目静态站点(site)
Make 的学习资料非常丰富,所以这里就不对 make 的基础知识做过多介绍了,想系统学习一遍的读者可以参考下面的网站:
五、总结
构建任务的顺序(调度算法) 任务是否重新构建(重建策略)
make 提升了项目管理的下限,通过简单的 Makefile,能轻易学习到别人是如何管理复杂项目的。
(完)
六、参考
参考
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






