1. 为什么我建议你认真了解Ninja这个构建系统
如果你编译过一个上千源文件的C++项目,很可能经历过这样的场景:在执行make -j8之后,终端里飞驰过无数条编译命令,但只要改动一个头文件,整个工程就像失忆一样,把一半的文件重新编译一遍。最开始我会以为这是Make的"正常表现",后来才发现真正的问题出在依赖跟踪和构建调度的粒度上。后来我把项目切到Ninja,同样的增量改动,重建时间从几十秒降到几秒。Ninja不是一个"更好用的Make",而是一个完全从"构建速度"这个目标倒推出来的低层构建系统。
Ninja的定位,简单说就是"执行速度快到几乎感知不到"的构建工具。它不提供复杂的宏语言,没有Make那么丰富的函数和隐式规则,也不打算让你直接拿它编写大型项目的完整构建逻辑。它的构建文件通常由CMake、Meson、GN这样的元构建系统生成。你可以把它理解为构建界的"汇编语言":上层的高级语言负责生成最优化、最直白的指令,Ninja负责把这些指令以极快的速度执行完。Google的Chromium项目是这个系统最早的深度用户,也正是因为需要在几十个模块、数万个源文件的规模下保持可接受的构建速度,Ninja才被设计和开源出来。
什么人适合马上接触Ninja?第一类是使用CMake或Meson的开发者,你只需要加一个-G Ninja参数,就能在大多数场景下体验到构建速度的提升。第二类是受够了Makefile隐式规则"玄学"的人,Ninja的构建文件非常直白,每个命令、每条依赖都摆在明面上。第三类是对构建原理感兴趣的人,Ninja的增量判断逻辑和调度方式,可以当成一个非常好的学习范本。即使你不是专业做构建基础设施的,花半天时间把Ninja跑通,后面能省下的时间绝对值得。
需要说明的是,我在这篇文章里会把Ninja当作一个独立的构建系统来讲,不假设你已经用过它。我会从设计思路、构建文件语法、与CMake等工具的配合、增量构建原理、性能调优与踩坑经验这几个角度,把我在实际项目里用出来的心得都写出来。Ninja的官方文档很精炼,但很多"为什么"和"实际项目中怎么办"不是文档里能查到的,这些才是本文的重点。
2. 设计哲学:Ninja为何刻意做得比Make "笨"
2.1 从Makefile的复杂说起
想理解Ninja,先理解它讨厌什么。GNU Make发展了几十年,语法已经非常丰富:变量、函数、模式规则、隐式规则、VPATH、include、条件判断,甚至还能定义自己的函数。丰富的语法让Makefile几乎成为一种编程语言,但这是有代价的。Make在执行前必须先读取整个Makefile,展开所有变量和规则,分析隐式规则链,才能确定哪些目标需要重建。在小型项目里这个解析时间忽略不计,但在几万条规则的项目里,解析和评估本身就可能花上好几秒,而且增量构建时还得重复这个过程。更麻烦的是,Make的隐式规则经常让开发者意外:你觉得某个文件应该被重建,但它没有;或者反过来,一个无关的变量赋值让所有规则都重新触发。
Ninja从根上就不打算做这些事。它的作者Evan Martin在开发Chromium构建系统时发现,底层构建工具最需要的不是功能强大,而是"调度快速、执行准确"。于是Ninja被设计成只包含最小的功能集:rule定义命令,build声明输出和输入,再加上变量和几个特殊字段,仅此而已。没有函数,没有模式规则,没有自动推导。所有的复杂计算都放到上层生成器里完成,Ninja只负责把生成器告诉它的依赖关系和时间戳信息高效地组织起来。
2.2 Ninja构建文件的最小骨架
Ninja的构建文件默认叫build.ninja,也支持通过-f指定其他文件。它的基础结构如下:
rule cc command = gcc -c $in -o $out description = CC $out build hello.o: cc hello.c build app: cc hello.o default app这个例子里,rule cc定义了一条编译规则,command是实际执行的命令行,$in和$out分别代表输入文件和输出文件。build hello.o: cc hello.c声明了一个构建目标:hello.o由规则cc作用于输入hello.c生成。build app: cc hello.o同理,虽然规则名也叫cc,但这里我们是用gcc直接链接,只是为了演示规则可以复用。default app指定了如果不带参数执行ninja时默认构建的目标。
看起来跟Makefile很像,对吗?但注意没有clean、install这样的内建目标,没有变量自动展开的隐式规则。你可以定义自己的变量,比如cflags = -Wall,然后在command里用$cflags引用。你必须把每一条依赖关系都显式写出来,Ninja不会因为你有一个hello.c就自动推导出hello.o。这看起来是"退化",却是Ninja能够快速判断"哪些目标需要重建"的关键:依赖图是静态的、扁平的,加载后直接变成内存里的哈希表和邻接表,不需要任何动态计算。
2.3 为什么这种"笨"反而更快
官方有个很形象的描述:Ninja的目标是让"增量构建的速度接近于只运行必要编译命令的时间"。它做了三件非常务实的事情:
- 构建文件解析极快。文件结构简单,没有需要解释执行的函数,读进来就能构建图。
- 依赖判断极简。对每个输出,记录其输入的时间戳;当输入比输出新,就重建。由于不依赖隐式规则,它能精确知道哪些文件需要重编。
- 并行调度高效。Ninja内部用拓扑排序和就绪队列调度任务,在
-j指定的并发数内,能最快开始运行可并行的任务。
所以,在同等条件下,使用同样的编译命令,Make可能需要几百毫秒来做"判断要构建什么",Ninja可能只需要几毫秒。全量构建时两者的编译时间本身差不多,但在一次次小改动触发增量构建时,Ninja的优势会积累得非常可观。另外,因为规则都是显式的,Ninja不会出现Make那种"多执行了某个模式规则"或者"漏掉某个隐式依赖"的问题——前提是上层生成器生成的依赖足够准确。
3. 手写一个真实可用的Ninja构建文件
3.1 带头文件依赖的完整示例
只看最小骨架是跑不起来实际项目的,因为C/C++项目最大的麻烦在于头文件依赖。Ninja本身不会去扫描#include,它必须依赖外部机制来了解头文件的变化。最常见的做法是让编译器输出依赖文件,再通过depfile字段把这些依赖纳入构建图。
假设有这样一个项目结构:
. ├── include/ │ └── util.h ├── src/ │ ├── main.c │ └── util.c └── build.ninjabuild.ninja可以这么写:
ninja_required_version = 1.10 cflags = -Wall -Iinclude rule cc command = gcc $cflags -MMD -MF $out.d -c $in -o $out depfile = $out.d deps = gcc description = CC $out rule link command = gcc $in -o $out description = LINK $out build src/main.o: cc src/main.c build src/util.o: cc src/util.c build app: link src/main.o src/util.o default app这里的关键是:gcc -MMD -MF $out.d让编译器在编译时额外生成一个$out.d文件,里面记录了这个.o文件所包含的头文件依赖。depfile = $out.d告诉Ninja去读取这个文件,把其中的头文件也加入依赖图。deps = gcc是让Ninja用专用的解析器读取GCC格式的依赖文件,这样读取速度更快,而且不需要保留.d文件(其实默认会把依赖信息存到.ninja_deps文件里)。如果你用的是Clang,同样可以这样处理。
执行ninja,第一次会编译两个源文件并链接成app。接着修改include/util.h,再执行ninja,你会发现只有包含了这个头文件的util.o和app会被重新构建,main.o如果确实没有包含util.h就不会动。这比Makefile里常见的"把所有头文件一股脑全列进变量"要精确得多。
3.2 构建文件里的变量作用域与常见写法
Ninja的变量作用域和Make有类似之处,但更严格。变量可以在文件顶部定义,也可以在rule或build块内定义。rule里的变量相当于作用于该规则的所有实例,而build块里定义的变量只对这条构建语句有效。变量的求值时机也很重要:不同变量类型有不同的展开规则,初学者最容易踩的坑是在rule里使用$in、$out、$depfile这几个特殊变量时,必须放在command中才能正确展开。
比如下面这种写法是合法的:
rule compile command = $compiler -I$incdir -c $in -o $out description = COMPILE $out compiler = gcc incdir = include build foo.o: compile foo.c如果$compiler在rule定义之后才赋值,会影响command吗?会。因为rule块内的变量都是延迟求值的,只有到真正执行那条build时才展开。这个特性可以让你在文件末尾改变变量,从而影响前面定义的规则。但要小心,别为了炫技写出难以阅读的构建文件。我个人的习惯是:所有基础变量放在文件顶部,规则定义放在中间,具体的build语句放在后面,保持从上到下的阅读顺序。
3.3 使用phony和default管理目标
大型项目会有很多中间产物,需要给它们起一些"别名"方便记忆,phony就是这个用途。例如:
build tests: phony unit_tests integration_tests build all: phony app tests这里tests和all本身不产生文件,只是依赖其他目标。执行ninja all就相当于说"把所有东西都构建出来"。不过phony虽然好用,过度使用会让依赖图变得混乱。如果某个phony目标既被用作真实文件的输入,又出现在依赖链里,Ninja会按别名处理,容易导致时间戳判断失效。建议只把phony用在明确的聚合目标上,不要让它参与真正编译命令的输入。
另外,default语句如果存在,那么不带参数执行ninja时会构建它指定的目标;如果没有default,默认就是构建文件里出现的所有目标。大多数由CMake生成的build.ninja会把all或实际目标设为默认,这样ninja命令最省心。
4. 与CMake/Meson配合:一句话让整个项目提速
4.1 CMake生成Ninja构建系统的用法
对绝大多数项目来说,手动维护build.ninja并不是好主意,尤其是当项目规模变大,你还需要处理平台差异、编译选项、条件编译、安装规则时。正确姿势是让CMake这类元构建系统去生成build.ninja。你只需要在配置阶段指定生成器:
cmake -S . -B build -G Ninja cmake --build build-G Ninja告诉CMake使用Ninja作为底层构建工具。CMake会自动生成内容庞杂的build.ninja,里面包含所有编译规则、链接规则、依赖信息、自定义命令、生成文件规则等。你基本不需要去看它,直接用cmake --build build即可,也可以用cmake --build build -- -j16给Ninja传递并行参数。
一个常见的误区是认为"CMake + Ninja"只能用于C/C++。实际上CMake在较新版本里也支持Fortran、CUDA、Swift等语言,只要底层有对应编译器,Ninja作为执行后端都是适用的。如果你的项目已经在用CMake,从默认生成的Makefile切到Ninja,几乎不需要改动CMakeLists.txt,只需要在配置时多传一个-G参数,风险非常低。
4.2 Meson、GN等工具为何默认选择Ninja
Meson这个构建系统从设计之初就把Ninja作为默认后端,它的理念是让开发者用更高级、更可读的DSL描述构建,而把底层执行细节交给Ninja。Meson的目标之一就是"让构建系统用起来现代、快速且友好",它选择的底层就是Ninja,因为它不需要重新发明一套执行调度机制,只要专注做好依赖分析和规则生成。
GN(Generate Ninja)则是Chromium团队专门设计的元构建系统,主要用来生成Ninja文件。Chromium级别的项目有非常复杂的依赖关系、大量的平台配置和组件化需求,GN生成的build.ninja精确到每个源文件的依赖,配合Ninja的执行速度,才能在合理的硬件上完成大规模开发循环。如果你不参与Chromium开发,大概率不会直接接触到GN,但了解这个背景能帮助你理解Ninja在Google内部乃至整个C/C++生态中的位置。
4.3 用好ninja -t compdb导出compile_commands.json
对接编辑器是实际开发中的高频需求,尤其是clangd、ccls这类基于编译数据库的C/C++语言服务器。使用Makefile时,要么手动配置CMake导出compile_commands.json,要么靠bear等工具生成。切换到Ninja后,一切都更简单:执行
ninja -C build -t compdb cxx cc > compile_commands.json-t compdb命令会读取build.ninja里所有编译规则,输出一个标准的JSON数组,每条记录包含当前目录、编译命令和源文件路径。你可以把它保存为compile_commands.json,然后让clangd打开项目时自动索引。这个文件对于代码跳转、自动补全、静态检查都至关重要。
我遇到过不少同事,项目还在用Makefile时,为了生成compile_commands.json折腾半天,切到Ninja后这个问题就消失了。因为CMake只要指定Ninja生成器,在配置阶段就能直接输出compile_commands.json,前提是开启CMAKE_EXPORT_COMPILE_COMMANDS。
5. 增量构建原理与五种最实用的诊断命令
5.1 时间戳逻辑与restat机制
Ninja判断一个目标是否需要重建的逻辑非常直白:如果存在输出文件且其时间戳比所有输入文件都新,就认为目标是最新的;否则就执行规则重建它。这个逻辑和Make基本一致,但有个额外机制容易忽略:restat。当一条命令执行后,如果它的输出时间戳其实没有变化,Ninja可以通过restat属性得知,并向上更新状态,避免传播无谓的重建。典型场景是脚本生成文件:某个脚本每次运行都会更新生成文件的访问时间,但文件内容可能没变。如果不用restat,所有依赖这个生成文件的编译任务都会被触发;用了restat,Ninja会在脚本执行完后再检查时间戳,发现没有变化,就认为该目标"实际未更新",从而让下游任务保持最新状态。
实际项目中,CMake生成的某些自定义命令会自动加上restat,但手写build.ninja时,如果碰到"文件没变但每次都要重编下游"的现象,可以检查一下是不是缺少restat = 1。这个字段虽小,却能省下大量不必要的编译。
5.2 用-d explain找出"为什么又重编"
Ninja提供了一些调试选项,其中-d explain是我最依赖的。当你不确定某个目标为什么总是被重建时,运行
ninja -d explainNinja会在重建前输出原因,例如:
ninja explain: output build/foo.o older than most recent input build/foo.c ninja explain: output build/foo.o missing这种输出能立刻暴露问题所在:可能是某个生成文件的时间戳总在变,也可能是你的build.ninja里依赖写得不全。遇到"全量重编"的诡异现象,先跑一遍-d explain,大多数情况能定位到是哪个输入文件一直在"变新"。
5.3ninja -t子命令实战
-t是Ninja自带的诊断工具入口,下面这些命令我几乎每天都会用:
ninja -t query target:查看某个目标的具体依赖关系,包括输入、输出、使用的规则,适合快速确认依赖图是否符合预期。ninja -t targets:列出构建文件里的所有目标,后面加规则名可以过滤出使用该规则的目标。ninja -t commands target:打印构建目标时会执行哪些命令,不实际执行。切换构建系统后,可以用它来确认最终编译命令是否包含了正确的宏定义和头文件路径。ninja -t graph target:输出Graphviz格式的依赖图,可以结合dot渲染成图片。当依赖关系复杂到肉眼无法追踪时,这是最直观的排查方式。ninja -t deps target:查看目标已记录的依赖文件列表,确认depfile是否正确加载了头文件依赖。ninja -t clean target:清理目标相关的输出。比手动删除文件靠谱。
其中compdb在上一节已经提过。这些子命令都没有写进Ninja的默认帮助里,但对排查构建问题价值极大。我之前遇到过一个问题:某个头文件被修改后,只有一半的源文件被重编,另一半没有。看代码觉得所有文件都#include了它,但实际重编了一半,找不出原因。后来用ninja -t deps一查,才发现没有重编的那些.o文件的依赖列表里根本没有那个头文件——因为它们在代码里用的是相对路径,而编译器的-MMD输出和实际文件名大小写不一致,导致依赖匹配失败。这种问题如果靠肉眼从构建日志里找,会浪费很多时间。
6. 性能调优与真实踩坑:把Ninja用到极致
6.1 并行度、状态输出和任务池
Ninja默认会检测CPU核心数并设置一个适合并行编译的-j值,但在实际项目中,盲目拉满并行度并不总是最优。-j太大时,大量编译进程同时跑起来,可能耗尽内存或I/O带宽,反而让总时间变长。如果发现编译时卡顿明显,可以先用-j减半试一下。对于链接这类内存大户,Ninja提供了pool机制来限制并发:
pool link_pool depth = 1 rule link command = g++ -o $out $in pool = link_pool这样即使全局-j16,同一时刻也只允许一条链接任务运行,避免内存被多个链接进程打爆。在大型C++项目里,这个设置经常能避免链接阶段的内存抖动。
另一个提升体验的小技巧是设置NINJA_STATUS环境变量,让Ninja在执行过程中显示当前进度。例如:
export NINJA_STATUS="[%f/%t %p] " ninja%f表示已完成任务数,%t表示总任务数,%p是已完成百分比。相比默认输出,这种进度显示从"让人焦虑"变成"让人安心"。它在CI日志里也很有用,可以直观看到构建是否卡住。
6.2 搭配ccache/distcc,混合构建
Ninja只负责调度,不关心你实际运行的是什么编译器,所以它可以轻松地和各种编译加速工具组合。最常见的组合是ccache:
# 在rule里把编译器包装成ccache rule cc command = ccache gcc $cflags -MMD -MF $out.d -c $in -o $out也可以利用CCACHE_PREFIX、CCACHE_BASEDIR等环境变量控制缓存行为。使用CMake时,可以通过CMAKE_CXX_COMPILER_LAUNCHER=ccache来设置,让生成的构建命令自动带上ccache前缀,无需手改规则。Ninja增量判断和ccache命中判断是两层:Ninja发现输入时间戳变了,会重新运行命令,但命令落到ccache时可能直接命中缓存而秒回。这样即使某种原因导致依赖重编,只要源文件内容没变,编译过程仍然很快。如果你的团队有共享缓存服务,也可以把ccache配置成分布式缓存,效果更明显。
distcc之类的分布式编译相对复杂,但原理上一样:只要命令能被包装,Ninja就只是傻傻地执行它。需要注意的是,分布式编译对增量依赖判断要求更高,因为每台机器的头文件路径可能不同,-MMD生成的依赖文件也会包含路径信息。在使用前一定要确认所有机器环境一致,否则容易出现"这边编译成功、那边编译失败"的诡异问题。
6.3 迁移到Ninja时最容易踩的五个坑
从Makefile迁移或初次使用Ninja时,有几个坑几乎是人人都会遇到的,在这里集中说一下。
第一个坑是忘记写depfile。手写build.ninja时,如果只写了简单的command = gcc -c $in -o $out,没有-MMD -MF,那么Ninja只追踪由build语句显式列出的输入文件,头文件变化完全不会触发重建。这会导致"改了头文件,程序还是旧的"的诡异现象。解决办法是接好depfile机制,并且把依赖文件纳入版本控制之外,因为它们是构建的临时产物。
第二个坑是把输出文件放在源目录里。Ninja的增量判断完全基于文件路径和时间戳,如果输出和输入混在一起,清理起来会很麻烦,还容易误删源码。建议所有输出都放到独立的构建目录,CMake里的CMAKE_BINARY_DIR就是这个作用。也不要让某个build语句的输出路径和另一个build语句的输入路径存在大小写不一致的情况,在Linux上没问题,但在macOS的大小写不敏感文件系统上,时间戳对比可能被干扰。
第三个坑是盲目迁移所有自定义命令。Makefile里可以用$(shell ...)在解析阶段执行命令,Ninja不允许。如果你原本的Makefile逻辑严重依赖shell函数、条件判断和宏,直接手写等价build.ninja会非常痛苦。这个时候更好的做法是先迁移到CMake或Meson,再让它们生成Ninja文件,而不是尝试在Ninja里复刻Make的复杂逻辑。
第四个坑是并行编译遇到命令名冲突。比如你在rule里写了cd subdir && make,这是把Make当作子进程调用,如果subdir里的构建依赖没有正确记录在父级build.ninja中,Ninja无法调度子Make内部的任务,并行可能造成竞争。正确的做法是尽量不用嵌套构建工具,所有编译动作都应该由Ninja直接调度。如果一定要调用外部脚本,务必让脚本自身保证幂等和线程安全。
第五个坑是忽视ninja -d explain的作用。遇到任何"不该重编却重编"的问题,先别乱改代码,先加-d explain看原因。很多时候是因为某个生成文件每次都被命令touch了,或者依赖路径里包含了不稳定的时间戳文件。搞清楚原因再动手,能省下大量排错时间。
6.4 一个真实的迁移收益记录
我用一个实际的例子来说明收益。一个中等规模的C++项目,约800个源文件,使用CMake构建。原来用make -j8做增量构建,改动一个核心头文件后,通常需要重新编译200多个源文件并链接,整个过程约1分20秒。后来只把生成器换成-G Ninja,同样的改动场景,编译耗时降到50秒左右。看起来差别不算巨大,但改动一个.cpp文件时,原Makefile可能因为某条隐式规则触发,导致几十个文件重编;Ninja在信息完备时,只重编这一个文件和相应的链接步骤,耗时不到20秒。为什么同样用CMake,一个用Makefile后端,一个用Ninja后端,会有这个差别?因为CMake为Makefile后端生成的规则和依赖追踪方式,不如为Ninja后端生成的那么精确。CMake在Makefile后端里使用了make的$(MAKE)递归或某些批量规则,而Ninja后端从最开始就为Ninja设计了一套干净、细粒度的构建图。
当然,首次全量构建时,Ninja并不会比Make快很多,因为瓶颈在编译器本身。但开发过程中迭代最频繁的其实是增量构建。一天下来,几十次增量构建节省的时间累积起来非常可观。这也是我建议每个使用CMake/Meson的团队都尝试Ninja的原因。
7. 什么时候不要用Ninja,以及我的最后几条建议
Ninja不是万金油。对于非常小的项目,比如一个只有两三个源文件的课程作业或临时脚本,直接一条gcc命令或者一个简单的Makefile反而更直观。Ninja的最小构建文件虽然简单,但为了头文件依赖还得配depfile,这个复杂度对小项目来说是多余的。另外,如果你的项目严重依赖Make的隐式规则、多级条件分支、各种shell函数展开,而且你暂时没有计划迁移到CMake/Meson,那么不要硬改Ninja。强行用Ninja复刻Make的高级功能,只会得到一份难维护的build.ninja。
如果你决定上手,我的建议路径是:先用CMake的-G Ninja把它跑起来,不要一开始就手写build.ninja。等到你确实需要定制一些CMake不好表达的规则时,再去研究Ninja的rule、pool和restat。手写build.ninja的场景通常是嵌入式、特殊代码生成器或者自定义脚本链,这时保持简单最重要:规则尽量少,变量尽量少,每个build的输入输出都显式写出,别让Ninja做任何“猜测”。
最后分享一个我自己的小习惯:在项目根目录放一个Makefile,里面只写一行:
all: cmake -S . -B build -G Ninja && cmake --build build这样既保留了大家熟悉的make入口,又让真正的构建落到Ninja上。等团队成员慢慢体会到增量构建的速度差异后,很多人会自动把ninja命令变成肌肉记忆。构建系统这件事,工具本身重要,但真正决定效率的是你愿不愿意花点时间理解背后的调度逻辑。Ninja的设计者把“快”做成了唯一目标,而我们要做的,就是把这个目标用在自己的项目里。