在C++工程领域,提到构建系统,大多数人首先想到的是CMake或者Make,但真正执行编译动作的幕后角色往往是Ninja。如果你打开一个由CMake生成的构建目录,会发现里面有一个build.ninja文件,里面密密麻麻全是Ninja语法。理解这个文件的语法和工作机制,不仅能帮你在编译出错时快速定位问题,还能让你在需要定制编译流程时游刃有余。本文将从Ninja的设计哲学讲起,逐步拆解build.ninja的核心语法。

Ninja是什么,它和CMake、Make是什么关系
Ninja诞生于Google Chrome项目的构建需求。Chrome的代码规模极其庞大,使用Make构建一次需要数小时,而Ninja的设计目标只有一个:构建速度尽可能快。为了达到这个目标,Ninja牺牲了可读性和便利性——它没有条件判断、没有循环、没有函数,一切都是声明式的描述。正因如此,Ninja的语法异常简单,解析速度极快,配合增量编译策略,大型项目 rebuild 的速度可以达到Make的数倍甚至数十倍。
三者之间的关系可以这样理解:CMake是构建系统的生成器,它负责读取你写的CMakeLists.txt,然后根据平台和工具链生成对应的构建文件。当选择Ninja作为生成器时,输出的就是build.ninja。Ninja读取这个文件后,构建出一张依赖图,找出哪些目标过期需要重新编译,然后以最大并行度调度编译任务。换句话说,CMake负责描述“要构建什么”,Ninja负责“怎么高效地构建”。日常开发中很少手写build.ninja,但读懂它是调试编译问题、理解构建过程的必备技能。
执行构建的命令通常是ninja -C build,其中-C参数指定构建目录,Ninja会自动查找该目录下的build.ninja文件并开始执行。也可以用ninja -C build 某目标名来只构建特定目标。
build.ninja的核心语法元素
build.ninja文件的语法只有少数几个顶层声明:rule、build、variable、pool、default、include和subninja。其中最核心的是rule和build两种语句,理解了它们就理解了Ninja的灵魂。
rule语句定义一条规则,描述“如何从输入生成输出”,本质上就是一个命令模板。rule内部最重要的字段是command,也就是实际执行的shell命令。此外还有description用于在构建日志中显示友好信息,depfile用于声明依赖文件,deps用于启用依赖信息的紧凑存储格式。rule中可以使用$in和$out两个特殊变量,分别展开为build语句中声明的输入和输出列表。
build语句则声明具体的构建边,它指定输出文件、规则名和输入文件,把抽象的规则落地成一次实际的构建动作。Ninja把每一个build语句称为一个edge(边),把每一个文件称为一个node(节点),所有的边和节点共同组成一张有向无环图。当输入文件比输出文件新时,对应的边就会被执行。
# 定义一个编译C++文件的规则 rule cxx command = g++ -MMD -MF $out.d -c $in -o $out description = 编译 $out depfile = $out.d deps = gcc # 声明一条构建边:由main.cpp生成main.o build main.o: cxx main.cpp # 声明链接规则 rule link command = g++ $in -o $out description = 链接 $out build app: link main.o util.o
上面这个最小示例中,Ninja会先编译main.cpp和util.cpp得到目标文件,再把它们链接成app可执行程序。两条build边之间存在隐式的顺序约束:链接必须在编译完成之后执行,因为链接的输入正是编译的输出。
变量与作用域
Ninja支持用变量名 = 值的方式定义变量,使用$变量名或${变量名}展开。全局变量定义在顶层,对整个文件可见;而build语句内部定义的变量只作用于该条边,并且会覆盖全局同名变量。这个特性常用于给单个文件附加特殊的编译选项,比如给某个文件单独加调试宏或者关闭优化。
cflags = -Wall -O2 rule cxx command = g++ $cflags -c $in -o $out build main.o: cxx main.cpp build special.o: cxx special.cpp cflags = -Wall -O0 -DDEBUG_MODE
此外还有一类特殊的rule级别变量,通过rule_name_var = value的语法绑定到规则上,例如cxx_flags = -std=c++17会作为$cxx_flags在名为cxx的规则内生效。这种机制让CMake这类生成器可以为不同规则注入不同参数而互不干扰。
隐式依赖与顺序依赖
build语句支持三种依赖写法。显式依赖直接写在规则名后面,会传给命令的$in变量;隐式依赖写在|符号之后,参与依赖图的构建但不作为命令输入;顺序依赖写在||之后,只保证先后顺序,不参与脏检查。隐式依赖最常见的用途是头文件生成的场景,比如代码依赖一个由脚本生成的头文件,编译前必须先保证该头文件存在。
rule gen_header command = python gen_header.py $out build config.h: gen_header config.template rule cxx command = g++ -c $in -o $out # config.h是隐式依赖,不会出现在$in中,但会先被生成 build main.o: cxx main.cpp || config.h
依赖发现机制:为什么改动头文件会触发重编译
一个常见的疑问是:build语句里明明只写了main.cpp,为什么改了某个头文件也会导致重新编译?答案藏在depfile和deps这两个配置里。编译器在编译时可以用-MMD -MF参数生成一个描述真实依赖关系的文件,Ninja通过depfile声明读取这个文件,从而把头文件依赖补进依赖图。而deps = gcc则更进一步,Ninja会把解析出的依赖信息压缩存储到.ninja_deps数据库中,后续构建直接读数据库,省去重复解析depfile的开销,这也是Ninja在超大项目上依然飞快的重要原因之一。
如果项目里缺少这套配置,就会出现改了头文件但增量编译不生效的诡异问题,此时只能全量重编。所以排查Ninja构建问题时,第一步就应该检查规则里是否正确配置了依赖收集。
phony规则、默认目标与文件组织
phony是Ninja内置的一条特殊规则,它声明某个名字不是真实文件,只是一组构建目标的别名。典型用法是把all、clean这类命令行常用的名字映射到真实的构建边。当你在命令行输入ninja all时,Ninja构建的就是phony目标背后关联的所有输出。
build all: phony app test_bin build app: link main.o util.o build test_bin: link test_main.o util.o default all
default语句指定不带参数执行ninja时默认构建的目标,通常配合phony一起使用。include和subninja则用于拆分文件:前者把另一个文件的内容原样嵌入当前作用域,后者引入的文件拥有独立的变量作用域,只能看到被显式传递的变量。CMake生成的构建目录里大量使用subninja,把每个目标的规则分散到CMakeFiles目录下的各个子文件中,顶层build.ninja只保留总入口,这样修改单个目标时Ninja无需重新解析全部规则。
动手实战:手写一个完整的build.ninja
最后通过一个完整例子把前面的知识串起来。假设项目包含三个源文件和一个自动生成的头文件,下面是完整的构建脚本,包含编译、代码生成、链接和phony别名,可以直接保存为build.ninja后执行ninja验证。
ninja_required_version = 1.7 cxx = g++ cflags = -std=c++17 -Wall -O2 -Iinclude rule cxx command = $cxx $cflags -MMD -MF $out.d -c $in -o $out depfile = $out.d deps = gcc description = CXX $out rule gen command = python tools/gen_version.py $out description = GEN $out rule link command = $cxx $in -o $out description = LINK $out build include/version.h: gen tools/gen_version.py build src/main.o: cxx src/main.cpp build src/util.o: cxx src/util.cpp build src/net.o: cxx src/net.cpp || include/version.h build myapp: link src/main.o src/util.o src/net.o build all: phony myapp default all
执行ninja -v可以看到每条边的完整命令,ninja -t targets可以列出所有可构建目标,ninja -t clean清理构建产物。这些-t开头的工具子命令是调试build.ninja的利器,值得花时间熟悉。
总的来说,Ninja的语法设计体现了“简单即快速”的哲学:没有花哨的特性,只有一张纯粹的依赖图和高效的调度器。读懂build.ninja之后,你会发现CMake生成的构建过程不再神秘,遇到链接顺序错误、依赖缺失、增量编译失效等问题时,直接查看生成的build.ninja往往比猜测CMake脚本更快找到答案。对于中小型项目,甚至可以考虑直接手写build.ninja,省去CMake这一层抽象,获得更直接的构建控制力。
Ninja构建工具build.ninja语法C++构建系统修改时间:2026-09-12 18:02:49