Overview
Compiler as Build System: 从 Makefile 到现代语言工具链

Compiler as Build System: 从 Makefile 到现代语言工具链

May 30, 2026
5 min read

引子

Tsoding 在一期视频中说过一段话,这段话在程序员社区里广为流传,甚至成了一种“现代语言设计哲学“的口号:

“If the language has a separate build system, you know, it’s a red flag, right? So your compiler has to be the build system, right? That’s how modern languages do shit. This separation of, you know, main application and then build you know main compiler and then builders that is on top of the compiler. It’s so fucking ancient. It’s so fucking 2010. Modern languages do not do shit like that. In modern languages, compiler is the build system. If you dont do that, you’re not going to make it. I’m sorry, right? So, it’s just like that’s the new standard.”

翻译过来大意是:如果一门语言还需要独立的构建系统,那它就已经落后了。编译器本身就是构建系统——这才是现代语言的做派。“编译器和构建系统分离“这种模式,太 2010 年了。

这段话之所以有冲击力,是因为它挑战了一个根深蒂固的观念:编译器只负责把源代码翻译成目标文件,构建系统负责管理依赖、调用编译器、组装产物。从 C + Makefile 开始,这个分工就一直是主流。

但在 2020 年代之后,Go、Rust、Nim 等语言的崛起让人们看到:这套分工未必是必然的。编译器可以自带依赖解析、单元测试、性能 benchmark、二进制发布——把所有构建相关的事情都整合进去。

为什么传统观念里编译器 ≠ 构建系统?这个问题值得追溯一下。

C + Makefile:分离的起点

说起“编译器 + 独立构建系统“的组合,C 语言是这一切的起点。

早期的 C 项目构建简单粗暴——手动敲命令:

Terminal window
gcc main.c -o main

如果项目有多个 .c 文件,就逐一编译:

Terminal window
gcc -c foo.c -o foo.o
gcc -c bar.c -o bar.o
gcc main.c foo.o bar.o -o main

这种模式在小项目中没什么问题。但当项目规模扩大,文件数量从几个变成几十个,源文件之间的依赖关系开始复杂化——手动维护编译顺序变成了一场噩梦。

Makefile 的登场

1977 年,Stuart Feldman 创造了 Make,专门来解决这个问题。Makefile 让你用声明式语法描述构建规则:

CC=gcc
TARGET=main
SRC=main.c
$(TARGET): $(SRC)
$(CC) $(SRC) -o $(TARGET)

相比手动逐条执行命令,Makefile 带来了两个关键改进:

增量构建(Incremental Build):Make 会追踪文件的时间戳,只有当源文件比目标文件新时才重新编译。这在大型项目中可以节省数十倍的编译时间。

依赖声明:你可以描述文件之间的依赖关系,Make 会自动计算需要重新构建的顺序:

CC=gcc
TARGET=main
OBJ=main.o foo.o bar.o
$(TARGET): $(OBJ)
$(CC) $(OBJ) -o $(TARGET)
main.o: main.c foo.h
$(CC) -c main.c -o main.o
foo.o: foo.c foo.h bar.h
$(CC) -c foo.c -o foo.o
bar.o: bar.c bar.h
$(CC) -c bar.c -o bar.o

Makefile 的局限

Makefile 统治了 C/C++ 项目数十年,但它有几个根本性的缺陷:

跨平台噩梦:Unix 上的 gcc 在 Windows 上可能要找 cl。即使后来有了 MSYS、MinGW、Cygwin 等解决方案,编写真正可移植的 Makefile 依然是一个专业技能。

手工维护依赖规则:每个 .o 文件依赖哪些 .c.h 文件,都需要手动声明。如果漏写了一条依赖,修改头文件时就不会触发正确的重新编译。

隐藏依赖无法追踪:这是最关键的问题。Makefile 只能看到文件级别的时间戳,但理解不了 #include 语义:

main.c
#include "foo.h"
int main() {
return foo();
}

如果 foo.h 被修改了,但 main.c 的时间戳没变,Make 不会重新编译 main.c——除非你手动在规则里写明 main.o: foo.h。这意味着头文件改变了,你可能需要手动 touch 源文件,或者写一堆冗长的依赖规则。

正是这些痛点,推动了后来 CMake、autotools 等工具的出现——它们试图用自动化的方式生成 Makefile,或者用更高级的抽象来描述构建流程。但本质上,它们还是在解决“如何用外部工具驱动编译器“这个问题——编译器本身依然不负责构建流程。

CMake:Autotools 的正经替代者

如果说 Autotools 是 C/C++ 项目跨平台化的第一次尝试,那 CMake 就是真正让它变得可用的那次进化。

Autotools 的三层架构之痛

GNU Autotools 包括 aclocal、autoconf、automake、libtool 等一系列工具,构成了一个复杂的三层架构:

Terminal window
configure.ac --(autoconf)--> configure --(configure执行)--> Makefile.in --(make)--> Makefile

这种架构设计的初衷是好的——让同一套源码能在 Unix、Linux、BSD 等不同系统上构建。但实际使用时,问题接踵而至:

configure.ac 的语法晦涩难懂:一套用 M4 宏包装过的 Shell 脚本,学习曲线陡峭。

构建流程不透明:生成的 configure 脚本可能有数万行,一旦出错,调试起来如同雾里看花。

维护成本高:每次项目结构变化,都要手动更新 Makefile.am 和 configure.ac。

社区里流传着一句话:“GNU Autotools 是一种只写语言(write-only language)”——意思是你能勉强写出来,但基本不可能维护和读懂。

CMake 的两层解法

CMake 采用了更简洁的两层架构:

Terminal window
CMakeLists.txt --(cmake)--> Makefile / Ninja / VS Project / Xcode

只需要写一个 CMakeLists.txt,就能生成 Makefile、Ninja、Visual Studio 或 Xcode 项目。项目结构描述清晰,语法也远比 Autotools 直白:

cmake_minimum_required(VERSION 3.10)
project(MyApp)
add_executable(myapp main.cpp utils.cpp)
target_link_libraries(myapp pthread)
if(WIN32)
target_link_libraries(myapp ws2_32)
endif()

这段 CMakeLists.txt 做的事情很直接:定义项目名称、声明可执行文件及其源文件、处理平台特定的库链接。跨平台问题从代码层面转移到了构建配置层面。

理念依旧分离

不过,即使 CMake 大幅简化了构建描述,它依然没有突破“编译器与构建系统分离“这个框架。

CMake 负责的是:描述构建目标、管理依赖关系、选择生成器(Make/Ninja/VS)。而真正调用编译器完成编译的,是 Makefile 中的 $(CC) 或 Ninja 文件中的 clang++ 命令。

换句话说:CMake 抽象了构建描述,但没有整合编译器本身。gcc、clang、msvc 这些编译器工具,在 CMake 眼中依然是外部程序。

这并不是 CMake 的缺陷——它的设计目标本就不是整合编译器,而是提供一个更好的构建描述层。但对于追求“编译器即构建系统“的现代语言设计者来说,这个分离本身就是问题。

Go:渐进式的殊途同归

Go 在 2009 年诞生时,设计者面临的课题和 Nim 不同。Nim 从零开始,可以把一切都塞进编译器团队的工具链里。而 Go 要在已经存在的 C/C++ 项目生态里生存,过于激进的统一会吓跑潜在用户。

1.5 之前的 GOPATH 时代

Go 1.0 到 1.4 之间,用的是 GOPATH 模式:

Terminal window
export GOPATH=$HOME/go
export PATH=$PATH:$GOPATH/bin

这个模式下,代码必须放在 $GOPATH/src/ 下的特定目录结构里:

$GOPATH/src/
github.com/
username/
project/
main.go

没有版本锁定,没有可重现构建。依赖就是源代码克隆下来的某个时刻的快照。如果上游更新了,你的项目可能就编译不通过了。

1.11:Go Modules 的登场

2018 年,Go 1.11 引入了 Go Modules(go.mod),这是 Go 工具链第一次真正接管依赖管理:

module example.com/myapp
go 1.21
require (
github.com/gin-gonic/gin v1.9.1
)

go.mod 文件明确声明了模块名称、Go 版本和直接依赖及其精确版本。这解决了三个问题:

版本锁定:每次 go build 基于相同的 go.modgo.sum 产生相同的构建结果。

可重现构建go.sum 记录了所有传递依赖的加密哈希,确保任何人、任何时间构建都得到相同的产物。

独立构建:不需要预先把依赖克隆到 GOPATH,工具链会自动管理。

现在:go build 就是一切

今天的 Go 开发者,面对一个全新项目,只需要三步:

Terminal window
go mod init example.com/myapp # 初始化 go.mod
go get github.com/gin-gonic/gin # 添加依赖
go build ./... # 构建

go build 做了什么?它自动:

  1. 解析 go.mod,理解模块依赖图
  2. 下载缺失的依赖(通过 go.sum 验证完整性)
  3. 并行编译每个包
  4. 链接成最终二进制

没有 Maven,没有 Gradle,没有 SBT——甚至没有单独的“包管理器“命令。go build 就是构建本身。

证据:GOCACHE

Go 工具链对构建流程的掌控力,有一个很直接的证明:GOCACHE

/Users/username/Library/Caches/go-build
echo $GOCACHE

这个目录由 go 工具链全权管理,存储着所有编译中间产物的缓存。如果你删掉它,下次构建会重新编译所有内容——因为缓存完全由编译器拥有,不存在任何外部构建系统能介入的空间。

这和 Make 的 .o 文件不同:Makefile 控制着 main.o 何时生成、何时复用。而 Go 的构建缓存是编译器的内部实现细节,外部工具根本看不到。

同样的模式也出现在 GOMODCACHE(依赖下载位置)、GOTOOLCHAIN(工具链版本管理)等环境变量上。Go 工具链 owning the entire pipeline from source to binary——这不只是宣传语,而是技术现实。

Go 的故事最有意思的地方在于:它从传统模式出发,一步步把本来属于外部工具链的职责吸纳进来,最终走到了和 Nim 相似的结果。

接下来轮到 Rust 了。Rust 的 Cargo 可能是“编译器即构建系统“这个理念最广为人知的代言者——但它的实现远比表面看起来更有层次。

Rust/Cargo:整合的成熟形态

Cargo 的出现,让 Rust 从第一天起就站在了一个完全不同的起点上。

Cargo.toml:项目声明即构建配置

Rust 项目的核心只有一个文件——Cargo.toml

[package]
name = "myapp"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = "1.0"
tokio = { version = "1.0", features = ["full"] }

这就是你需要写的全部。没有 Makefile,没有 CMakeLists.txt,没有 separate 的包管理器配置。依赖声明和项目元数据放在同一个文件里,而这个文件本身就是构建流程的核心。

cargo build:一步到位的构建

当你执行 cargo build 时,整个流程是自动发生的:

Terminal window
$ cargo build
Updating crates.io index
Downloaded 0 crates in 0s ( happiest when it compiles )
Downloaded 143 crates in 3.2s
Compiling libc v0.2.158
Compiling log v0.4.22
Compiling serde v1.0.217
...
Compiling myapp v0.1.0 (path/to/myapp)
Finished dev [unoptimized + debuginfo] target(s) in 12.5s

cargo build 一次完成了:依赖解析(从 crates.io 下载)、并行编译(143 个 crate)、链接——全部在你敲下的一条命令里。你不需要知道 serde 从哪来、不需要知道它依赖哪些库、不需要手动链接任何 .rlib 文件。Cargo 完全抽象了这些细节。

rustc 的原始形态

如果没有 Cargo,用纯 rustc 构建会是什么样子?

Terminal window
rustc -o myapp src/main.rs \
--extern serde=target/deps/libserde-${hash}.rlib \
--extern serde_derive=target/deps/libserde_derive-${hash}.rlib \
--extern tokio=target/deps/libtokio-${hash}.rlib \
--extern mio=target/deps/libmio-${hash}.rlib \
--extern ... \
# 几十条依赖路径,手动维护版本

你必须:

  • 知道每个依赖的准确路径和文件名(包含 hash)
  • 手动维护依赖的拓扑顺序
  • 处理条件编译特性(features)
  • 处理平台特定的依赖

这完全不可行。而 Cargo 就是为了消除这些复杂性而生的。

整合的边界

Cargo 整合的范围,远不止“编译 + 链接“这两件事:

Terminal window
cargo build # 编译
cargo test # 单元测试
cargo doc # 生成文档(并自动在浏览器中打开)
cargo bench # 性能基准测试
cargo run # 运行
cargo publish # 发布到 crates.io
cargo install # 安装为全局工具

依赖解析、编译、链接、单元测试、文档生成、性能 benchmark、二进制发布——全部由一个工具完成。没有独立的测试框架、没有独立的文档生成器、没有独立的发布工具。它们都是 Cargo 的一部分,都是“编译器即构建系统“这个理念的具体实现。

这也解释了为什么 Rust 社区说“Cargo 就是 Rust 的一部分“——不只是语言特性,更是工具链哲学。编译器(rustc)不完全只是把源代码翻译成 IR;完整的构建体验需要 Cargo,而 Cargo 和 rustc 共同构成了 Rust 的编译器形象。

Nim + JS 生态:殊途同归

Nim 和 JavaScript 看起来毫无相似之处——一个是静态编译的系统语言,一个是动态解释的脚本语言。但如果我们把视角拉到“构建工具链整合“这个维度,会发现一个有趣的巧合:它们从相反的方向出发,却走向了同一个终点。

Nim:从编译器出发的整合

说到“编译器即构建系统“,Nim 是最彻底的例子之一。Nim 语言的包管理器叫 Nimble,它不是一个独立于编译器的东西——它本质上是 Nim 编译器团队维护的标准工具。安装 Nim 之后,nimble 命令就随之可用了,不需要单独安装任何东西。

一个典型的 Nim 项目只需要一个 .nimble 文件:

helloworld.nimble
version = "0.1.0"
author = "Author"
description = "A hello world project"
license = "MIT"
srcDir = "src"
bin = @["hello"]
requires "nim >= 1.6.0"

然后:

Terminal window
nimble build # 编译
nimble test # 运行测试
nimble install # 安装为全局工具
nimble publish # 发布到 nimble/pkgdoe

没有 Makefile,没有 CMakeLists.txt,没有任何外部构建系统的参与。依赖版本解析、构建流程、测试、发布——全部由这套“编译器 + 包管理器“的组合完成。

Nim 的特点是从编译器向下整合。语言设计者从第一天就把构建、测试、文档、打包这些事情纳入了考虑,编译器和包管理器是同一套团队、同一套设计哲学的产物。

JavaScript:从构建工具出发的整合

JavaScript 的故事则是另一个方向。

2009 年 npm 诞生时,JavaScript 根本没有“编译器“的概念——浏览器只执行源代码。但随着 Node.js 生态的成熟,项目规模越来越大,人们开始需要:

  • npm scripts 作为构建编排:最早的构建自动化
  • Browserify/Webpack:吞噬编译环节:把 TypeScript 编译成 JS、把 SASS/SCSS 编译成 CSS、把 ES6+ 编译成 ES5……这些本该由“编译器“做的事情,被构建工具接管了
  • Vite:新一代的整合思路:Vite 不只是 bundler,它把开发服务器的职责(按需编译、HMR)和生产构建的职责(rollup 打包)整合在一起。开发体验和生产构建终于使用同一套工具

JavaScript 生态的核心特点是:没有传统意义上的“编译器“。JS 代码在浏览器里直接运行(或者说,应该直接运行)。所以构建工具承担了所有“让代码在不同环境兼容“的职责——这实际上是编译器该做的事。

换句话说,JS 生态的整合方向是:从构建工具向上吞噬。Webpack 吸收了 TypeScript 编译器(ts-loader)、CSS 编译器(sass-loader)、代码分割、热更新……一个纯粹的资源打包工具,最终变成了半个编译器。

殊途同归

Nim 从编译器出发向下整合,JavaScript 从构建工具出发向上吞噬——方向相反,但终点相同。

Nim 的开发者:安装 Nim → 使用 Nimble → 构建、测试、发布,全套流程在语言官方工具链内完成。

JavaScript 的开发者:选择 Vite/Webpack → 配置 ts-loader/sass-loader/postcss → 构建、测试、热更新,全套流程在构建工具链内完成(虽然这个工具链比 Nim 的更复杂)。

两种路径的共同点是:开发者不再需要在“编译“和“构建“之间来回切换。Nim 开发者不需要想“我是该用 nimble build 还是 nim 编译“;JS 开发者也不需要想“我是该用 tsc 编译还是用 webpack 打包“。工具链的边界消失了,只剩下一个统一的操作界面。

这正是 Tsoding 那句话的真正含义:现代语言(或语言生态)把编译器(编译行为)和构建系统(项目管理行为)融合成了同一个工具。方向可以不同——Nim 从上往下,JS 从下往上——但最终呈现给开发者的,是无缝的体验。

结论:趋势背后的驱动力

回顾这条演进路径,“编译器即构建系统“之所以成为趋势,有三个核心驱动力。

开发者体验:一个工具学习成本最低。Go 开发者只需要知道 go build,Rust 开发者只需要知道 cargo build。不需要在 makecmakeninja 之间切换,不需要学习多套语法和概念。这是加法背后的减法。

一致性:编译器版本和构建行为版本绑定。Rust 1.0 时期的 Cargo 和 Rust 1.70 时期的 Cargo 行为一致,依赖解析算法、并行编译策略、缓存机制都在语言版本内统一。不会出现“用新版编译器但旧版构建系统“这种错位。

工具链整合:包管理器、构建工具、编译器共享知识。Go 的 go.mod 不只是依赖声明文件——它是语言运行时的一部分,编译器能理解模块图的语义,IDE 能提供精确的跳转和补全。这种深度整合在分离模式下是无法实现的。

但 Bazel 提醒我们:这个趋势不是绝对的。

Google 的 Bazel 是一个反例。在 Google 的规模下——数百万行代码、数千个仓库、数十种编程语言——“编译器即构建系统“模式已经失效了。原因很简单:没有任何一种编译器能理解 Google 的全部代码。构建系统必须独立于编译器,成为一个可以驱动任何工具的通用层。

Bazel 的存在说明:“编译器即构建系统“是一个局部最优解,而不是终极答案。它适合单语言、单生态的项目——Rust 项目用 Cargo,Go 项目用 go build,Nim 项目用 Nimble。但当规模大到需要多语言协作、需要跨仓库的依赖管理、需要远程缓存和分布式构建时,独立构建系统的价值就显现出来了。

所以这个趋势的真实含义是:对于大多数项目来说,编译器整合构建系统是更好的选择;但在极端规模下,分离是必要的。这不是非此即彼的二元对立,而是不同场景下的权衡。

就在我们以为趋势已经明朗时,另一个变量出现了——AI。

如果大语言模型可以自动生成 Makefile、CMakeLists.txt,甚至 Cargo.toml,那“编译器即构建系统“这个命题是否还有意义?或者说,当 AI 可以弥合分离模式的学习成本时,整合是否反而变得无关紧要?

这个问题没有答案。但有一点是确定的:AI 改变的是工具生成的效率,而不是工具本身的逻辑。无论 Makefile 是人类手写还是 AI 生成,它描述的构建关系是不变的。“编译器即构建系统“的价值不在于减少打字,而在于让构建行为成为编译器的第一等公民。

趋势是否会因为 AI 而逆转?现在回答这个问题还太早。但值得思考的是:如果构建文件可以由 AI 生成,那人类为什么要手动维护它们?如果这个问题的答案是“不需要“——那分离模式和整合模式的边界,或许会比我们想象的更快地模糊。

这个博客系列的目的,不是给出一个确定的结论,而是记录一场正在发生的演变。 从 C + Makefile 的分离,到 Go/Rust 的整合,再到 JS 生态的工具链吞噬,这条路还没有走到终点。什么是“好的开发者工具“?答案在变,需求在变,工具也在变。

唯一不变的是:开发者的时间是有限的。任何能减少认知负荷、减少上下文切换、减少学习成本的工具,都是在这个方向上迈出的一步。“编译器即构建系统“是当前这个方向上最清晰的脚印——但它不会是最后一个。

(全文完)