拓冰建站拓冰建站
首页 / 资讯中心 / 正文

抛弃Makefile,用gn+Ninja构建系统加速C++增量编译

1. 我为什么抛弃Makefile转投gnNinjagn和Ninja这套组合我第一次正经接触是在跟Chromium编译文档搏斗的时候。当时心里满是疑惑谷歌放着CMake不用为什么非要自己搞一套构建系统直到后来在几个体量大到离谱的C项目里被传统递归Makefile的增量构建折磨到崩溃我才真正读懂这套组合背后的设计逻辑。这篇文章想把gnNinja这套东西讲透——包括它们各自在构建链路上的位置、一个最小可用工程的完整写法、以及那些文档里不会写但实际一定会踩的坑。先交代一下适用对象。如果你正在维护一个规模增长中的C/C项目受够了改一行头文件就要等全局重编或者你只是好奇Qt、Chromium、Fuchsia这些大型项目到底是怎么把构建速度压到极限的这篇文章对你就有参考价值。我尽量按先理解为什么、再动手怎么做的顺序来写因为gnNinja真正难的不是API而是心智模型。1.1 一个数十万行项目的增量构建噩梦先讲我自己的真实经历。之前接手的某个C服务端仓库用的还是经典的四层递归Makefile。每次make你要先在顶层Makefile里追踪哪些子目录变了再逐层往下传递依赖中间任何一个环节的时间戳判断出错后果就是两个极端要么改了一个头文件编译出来的东西几乎没变要么只动了一个.cc文件却因为头文件依赖没声明完整导致所有include了它的文件全部躺枪重编。最崩溃的是并行构建。Make的并行是基于目标的任务调度粒度很粗经常出现后半程只有两个核在干活的尴尬局面。十几分钟一次的迭代构建让改代码-编译-验证这个循环变得异常昂贵。后来我试过CMakeMake的方案构建速度确实有改善但生成Makefile本身也是一个渐进变慢的过程项目大了以后cmake跑到一半就够你喝杯茶了。当时我就在想为什么没有一种构建工具能把依赖图计算和依赖图执行这两个阶段彻底分开前一个阶段慢一点没关系后一个阶段一定要快到极致。gnNinja正是奔着这个目标去的。1.2 gn和Ninja的名字到底代表什么GN的全称是Generate Ninja名字已经把它和Ninja的关系写明白了。Ninja则是一个由Evan Martin谷歌工程师开发的小型构建系统它的设计目标只有一个让增量构建尽可能快。谷歌在迁移Chromium构建系统的过程中先是从GYP切换到GN同时在2012年前后让Ninja逐步取代Make作为Chromium的实际构建执行器。今天除Chromium外Fuchsia、部分嵌入式项目、以及Qt WebEngine底层就是Chromium内核的构建链路里都能看到gnNinja的身影。Ninja也被大量独立项目作为CMake的Make替代品使用——你不需要gn也能单独用Ninja关键看你希望谁来负责生成那些build.ninja文件。这套组合真正做到了各司其职gn负责思考把构建目标、依赖关系、编译参数全部整理成一张完整的图Ninja负责执行它不需要理解项目逻辑只需要照着图高效地把活干完。2. 弄清楚分工gn负责想Ninja负责跑很多人第一次接触gnNinja时最大的困惑是它们俩到底是不是同一个东西为什么有了CMake还要用gn为了把这层关系理顺我把这套构建体系拆成元构建和执行引擎两层来看。2.1 什么是元构建系统元构建系统不直接编译代码它的产物是给构建执行器看的文件。CMake是最典型的元构建系统它根据CMakeLists.txt生成Makefile、Ninja文件或者Visual Studio工程文件。gn也一样它读取BUILD.gn文件经过解析、依赖分析、配置合并之后输出一组build.ninja文件。这里有个关键点gn并不会像CMake那样兼容无数种后端它只生成Ninja格式。这种绝不三心二意的绑定关系让gn能在生成阶段做大量只有掌握全部信息才能完成的优化也让整个工具链的复杂度降了一个量级。对于构建系统这种基础工具来说少一个分支就意味着少一类bug、少一份维护成本。2.2 gn/gm与CMake/Make的对应关系层次谷歌系传统系职责元构建系统gnCMake解析工程描述计算依赖图生成构建文件执行引擎NinjaMake按依赖图执行编译、链接、复制等动作在实际命令行里你看到的是这样的工作流gn gen out/Default先生成构建文件然后ninja -C out/Default执行构建。如果把Ninja换成Make这就是把cmake换成gn、把Makefile换成build.ninja的等价流程。但体验差距是巨大的。Make采用递归调用的方式一个大型项目通常要起几百个Make进程每个子目录都要重新解析一遍自己的Makefile启动成本高且依赖信息分散在各处。Ninja则是一次性把所有目标读到内存里用边表和节点表的方式组织依赖单次启动耗时通常只有几毫秒到几十毫秒。Ninja的并行任务调度也远比Make精细它会识别图里哪些节点可以同时构建把核数用足。2.3 gn为什么把自己绑定在Ninja上这其实是个取舍问题。CMake为了兼容不同IDE和编译器需要维护N套生成器这是它体积庞大、生成速度上不去的原因之一。gn选择只面向Ninja就意味着生成器只有一条代码路径任何优化都能立刻让所有用户受益。另一个好处是gn的语法设计比CMake更克制。BUILD.gn看起来像Python但没有Python那么多动态特性没有网络访问、文件操作这样的副作用。它本质上是一种确定性的声明式语言这保证了同一份工程在任何机器上生成的构建图都是一致的。对于我这种吃过构建结果因环境而异亏的人来说这种确定性比某些花哨功能有价值得多。代价则是生态封闭gn目前几乎没有像CMake这样成体系的第三方模块仓库文档也偏工程化入门门槛比CMake高不少。如果你只是写一个几十个文件的小项目用gn属于杀鸡用牛刀但当你需要精细控制编译参数、依赖传递、多工具链交叉编译时gn的这套设计会让你觉得每一分学习成本都花在了刀刃上。3. 环境准备从源码编译gn和安装Ninja工欲善其事必先利其器。在动手写BUILD.gn之前先把gn和Ninja这两个工具准备好。在不同的Linux发行版上它们的安装难度差异很大我按最通用的路径来走一遍。3.1 从源码把gn编出来gn没有被广泛打包进主流Linux发行版的软件源最可靠的方式是从源码编译。好在gn的源码仓库本身很小编译也快git clone https://gn.googlesource.com/gn cd gn python build/gen.py ninja -C out sudo cp out/gn /usr/local/bin/这里有个比较自举的细节编译gn用的是它自己仓库里预先生成好的Ninja文件也就是说你需要先有一个可用的Ninja才能编译gn。这个过程有个专门的名字叫bootstrap和当时Linux内核编译阶段用make编make是一个思路。如果你实在没有Ninja也可以用包管理器先装一个下一节讲然后再回来编gn。gen.py脚本执行时间很短通常几秒钟就结束了。真正编译也就一两分钟取决于机器性能。编译产物只有一个gn二进制没有一堆库文件复制到系统PATH目录就算安装完成。3.2 Ninja的安装其实非常简单相比gnNinja的安装要省心太多。Debian/Ubuntu系列的包名是ninja-buildsudo apt install ninja-build注意这里的坑点包名叫ninja-build但装完后的可执行文件名仍然是ninja。macOS上更简单brew install ninja一步到位Windows用户可以直接用pip install ninja或者在Visual Studio的组件里勾选。Ninja是个单文件程序没有任何运行时依赖这也是它分发起来特别省事的原因。安装完建议顺手确认一下版本我写这篇文章时使用的版本是1.11.x大部分功能在1.9以上都稳定可用ninja --version3.3 验证工具链是否就绪在正式建工程前把所有工具都验证一遍。除了gn和Ninja还需要确认系统有gcc/g和ar因为后面的最小工具链配置会直接用它们gn --version ninja --version gcc --version ar --version这一步看着多余但实际排查问题时会省很多时间。我遇过不少gn报错几百行结果是因为gcc不存在的情况——构建系统本身没有问题问题出在它依赖的外部命令缺失。把环境一次性验证齐后面所有排错都更干净。4. 写第一个gn工程BUILD.gn语法入门环境就绪后我们来搭建第一个真正能跑的gn工程。这是理解整套体系的关键一步我会把每个文件的用途都交代清楚而不是丢给你一段能用就行的代码。4.1 项目的目录结构和.gn文件先看我使用的目录结构hello_gn/ ├── .gn ├── BUILD.gn ├── build/ │ ├── BUILDCONFIG.gn │ └── toolchain/ │ └── BUILD.gn ├── src/ │ ├── BUILD.gn │ └── main.cc └── lib/ └── math/ ├── BUILD.gn ├── add.cc └── add.h先说.gn文件。gn在构建时必须确定源码根目录在哪它的判断依据就是根目录下有没有.gn这个文件。.gn文件内容很简单# .gn buildconfig //build/BUILDCONFIG.gn这里//是gn里的根路径写法指向包含.gn文件的目录。buildconfig告诉gn去哪里找构建配置文件。如果.gn里不写这一行gn默认会在根目录找BUILDCONFIG.gn。Chromium这类巨型项目会把它放到独立的build/config目录里再通过.gn引过去所以你在Chromium源码里会看到一行指向//build/config/BUILDCONFIG.gn的配置。“根目录”这个概念在gn里极其重要所有依赖路径、输出路径都以它为准。理解//的语义后面看任何大型gn工程都不会迷路。4.2 target类型与BUILD.gn写法BUILD.gn的核心是定义各种target。gn内置的target类型覆盖了构建的大多数场景target类型作用典型场景executable生成可执行文件程序入口static_library生成静态库.a业务模块封装shared_library生成动态库.so/.dll插件、对外接口source_set只编译不归档对象直接并入依赖方中间层、避免重复链接group聚合其他target不产生任何文件对外暴露构建入口action执行自定义脚本代码生成、数据预处理copy复制文件到输出目录资源拷贝拿src目录的BUILD.gn举例executable(hello) { sources [ main.cc ] deps [ //lib/math:math ] }executable后面的hello是这个target的名字。deps里的//lib/math:math是gn的完整路径引用写法——冒号前是target所在目录冒号后是target名。这句话的意思是构建hello之前需要先构建lib/math目录里的math这个target。4.3 deps与public_deps的依赖方向依赖方向是gn新手最容易搞反的地方。A在deps里声明B表示A依赖B构建时会先构建B。但这里还有个细节deps声明的依赖默认是私有的。什么意思假设lib/math编译时需要某个include路径这个路径默认只对lib/math自己可见不会透传给依赖它的hello。如果你希望lib/math暴露给所有使用者的头文件路径、宏定义能被依赖方感知需要用到public_deps和public_configs这对组合。这在大型多模块项目里几乎是必备技能第8章我会用完整代码专门演示。现在你只需要记住一个判断标准如果依赖方需要include被依赖模块的头文件就应该用public_deps或public_configs如果依赖只是内部实现细节用deps就够了。根目录的BUILD.gn还承担一个特殊职责group(default) { deps [ //src:hello ] }当你在输出目录执行ninja而不指定目标时Ninja构建的就是这个default。把根目录下的入口target命名成default是gn约定俗成的做法相当于给整套构建一个默认入口。4.4 最小BUILDCONFIG与工具链配置这是gn最劝退新人的地方也是和CMake最大的差异gn不内置任何编译器知识。CMake会自动探测gcc/clang/MSVC然后生成编译命令gn则要求你显式定义一个toolchain——告诉它用什么命令编译、链接、归档。这个设计很谷歌因为Chromium这类项目需要极端精确地控制每一条编译命令任何自动探测都可能成为不确定性的来源。创建build/BUILDCONFIG.gnset_default_toolchain(//build/toolchain:gcc)这行代码把默认工具链指向build/toolchain/BUILD.gn里的gcctarget。再看build/toolchain/BUILD.gn这是一个最小可用的gcc工具链定义toolchain(gcc) { tool(cxx) { depfile {{output}}.d command g -MMD -MF $depfile {{defines}} {{include_dirs}} {{cflags}} -c {{source}} -o {{output}} deps gcc outputs [ {{source_out_dir}}/{{target_output_name}}.{{source_file_part}}.o ] } tool(alink) { rspfile {{output}}.rsp command rm -f {{output}} ar rcs {{output}} $rspfile rspfile_content {{inputs}} outputs [ {{output_dir}}/{{target_output_name}}{{output_extension}} ] default_output_extension .a } tool(link) { rspfile {{output}}.rsp command g {{ldflags}} $rspfile -o {{output}} rspfile_content {{inputs}} outputs [ {{output_dir}}/{{target_output_name}}{{output_extension}} ] } tool(stamp) { command touch {{output}} outputs [ {{output_dir}}/{{target_output_name}}.stamp ] } tool(copy) { command cp {{source}} {{output}} outputs [ {{output_dir}}/{{target_output_name}} ] } }逐个拆解里面的关键点{{...}}是gn的变量占位符。{{source}}是被编译的源文件路径{{output}}是产物路径{{defines}}、{{include_dirs}}、{{cflags}}则是在target或config里定义的编译参数。gn在生成构建文件时会把这些占位符统一替换成实际值。depfile和deps gcc是增量构建的灵魂。-MMD -MF $depfile让gcc在编译时额外生成一个.d依赖文件里面记录了本次编译实际include了哪些头文件。Ninja把这个文件解析后就能知道只要这些头文件里有任何一个发生变化对应的.o就需要重新编译。这正是Make最薄弱的环节——头文件依赖全凭项目作者手写写漏了就是改头文件不生效的灵异bug。alink和link用到了rspfile响应文件。当目标文件数量多到命令行长度超过系统上限时把所有输入文件路径写进一个.rsp文件再通过$rspfile传给编译器。这是大型项目里必须处理的问题gn在工具链层面就提供了机制。这里还有个小地方值得注意stamp工具用touch生成时间戳文件copy用cp复制。后面第9章会讲为什么这两个工具如果不做优化会引发连锁全量重编。配置好工具链后就可以生成并构建了gn gen out/Default ninja -C out/Default如果一切顺利第一条命令会静默生成构建文件第二条命令会编译出可执行文件。这条命令组合你后面会敲几百遍建议直接刻进肌肉记忆。5. gn gen到底干了什么从BUILD.gn到build.ninja很多教程都只教你gn gen out/Default但不解释这行命令背后的完整链路。我花了不少时间才把gn的思考过程搞明白这里尽量用最直白的方式讲清楚。5.1 gn gen的执行链路gn gen大致分四步第一步定位根目录。gn从当前目录向上逐级寻找.gn文件找到后以该目录为根把//绑定到这个根上。第二步加载构建配置。根据.gn里的buildconfig找到BUILDCONFIG.gn执行里面的set_default_toolchain等全局设置。这一步决定了整个构建使用哪套编译命令。第三步解析target图。gn从根目录BUILD.gn开始把里面引用的target逐个解析再递归解析它们的deps直到把所有可达的BUILD.gn全部读完。这期间它会校验target名是否重复、依赖路径是否存在、循环依赖是否出现。这个阶段计算出来的东西是一张以target为节点、以依赖关系为边的有向无环图。第四步输出构建文件。gn把这张图和工具链模板结合起来生成out/Default/build.ninja、out/Default/toolchain.ninja以及记录构建参数的out/Default/args.gn。整个过程的耗时在几十个模块的项目上是秒级的但在Chromium这种万级target的工程里也能控制在几秒到十几秒。能做到这么快一方面是因为gn本身不做编译另一方面是因为它的语言特性和解析器都被刻意设计得足够简单没有多余的开销。5.2 args.gn与gn args的参数体系gn的构建参数体系是一件用起来很舒服的东西。运行gn args out/Default会打开你的编辑器编辑当前输出目录的args.gn文件。这个文件的内容就是构建参数格式也是gn语法is_debug false target_cpu x64保存退出后gn会自动重新生成构建文件。想查看某个输出目录支持哪些参数gn args out/Default --list这会打印出所有通过declare_args声明过的参数及默认值。在写自己的工程时可以用同样套路定义私有参数declare_args() { enable_benchmark false }然后在BUILD.gn里根据参数的值走不同分支。这种机制让同一份源码、N种构建配置变得非常规整——不同产品线、不同编译模式之间的差异全部收敛在args.gn里而不是散落在各种环境变量和shell脚本中。5.3 build.ninja文件结构速读生成完的build.ninja可能很长但把它拆开看结构其实很规整。文件开头是一堆rule定义对应工具链里声明的各个工具rule CXX command g -MMD -MF $out.d ... -c $in -o $out deps gcc中间主体是无数条build语句每条表示一个编译单元build obj/src/main.o: CXX ../../src/main.ccbuild后面是输出文件冒号后面是rule名和输入文件。Ninja执行时就是根据这些build语句里的输入输出时间戳关系来判断哪些需要重编。文件末尾还会有default声明指定默认构建哪些目标。读build.ninja最大的价值在于排查为什么它要重新编译这个文件。当ninja -d explain输出的信息过于晦涩时直接翻构建文件里的对应build语句往往能找到最直接的答案。6. Ninja增量构建的原理与提速实操Ninja之所以能在大型项目里做到几秒级的增量构建核心秘密全部藏在它的执行模型里。理解这个模型你才能正确调优也才能在构建行为异常时快速定位。6.1 增量构建为什么快Ninja判断一个输出是否需要重新构建依据只有两条输出文件是否存在以及是否有任何输入文件比输出文件更新。如果有输入更新就执行对应命令。但光靠时间戳还不够。假设你第一次编译生成了hello.o第二次构建时你什么都没改Ninja发现所有输入都没变于是什么都不做。可如果你改了BUILD.gn里的编译参数比如加了个宏hello.o对应build语句里的command变了——Ninja怎么知道命令变了答案在.ninja_log文件。Ninja会把每次执行的完整命令和输出文件记录在日志里。下次构建时发现同一个输出但command和上次不一样就会认为输出已过期并重新执行。这个机制让构建系统对参数变更非常敏感**你不需要手动clean只要编译参数变了Ninja会自动重编所有受影响的文件。**这一点看着简单实际体验后才知道有多爽——再也不用担心改了个宏但忘了clean导致链接阶段各种诡异错误。还有一份关键文件是.ninja_deps它存储了每个.o文件对应的头文件依赖列表。正是因为有了它gcc -MMD生成的.d文件才不用保留到下一次构建Ninja会在每次编译后把新依赖合并进.ninja_deps。这也是Ninja增量构建比Make可靠的根本原因依赖信息由编译器提供、由构建系统统一管理不再依赖项目作者手工维护。6.2 并行度、内存和-j参数Ninja默认会利用所有CPU核心并行构建这是它比Makefill得更好的一大原因。但在实际项目中无脑拉满并行度经常会踩到内存的坑。每个C编译进程动辄占用几百MB甚至上GB内存如果机器是16核但只有16GB内存ninja -j16很可能把系统编译到swap地狱里去。我的经验是ninja -C out/Default -j 4根据项目大小和机器配置动态调整这个数字。对纯CPU密集的编译场景通常核数×2左右是性价比最高的但如果涉及链接链接阶段内存消耗极大可以适当降低。Ninja还支持负载限制参数-lninja -C out/Default -j 8 -l 6-l 6表示系统负载超过6时不再启动新任务。这对共享构建机特别有用。6.3 ninja -t调试命令Ninja内置了一组-t调试子命令是我排查构建问题时的主力工具命令作用ninja -t targets列出所有构建目标ninja -t query target查询某个目标的输入、输出和依赖ninja -t graph输出dot格式依赖图可配合graphviz可视化ninja -t commands target显示构建目标会执行的完整命令ninja -t compdb cxx cc生成compile_commands.json供clangd等工具使用ninja -t clean清空构建产物最常用的是-t query和-t graph。当你不确定某个文件为什么会被重新编译或者想确认依赖关系是否符合预期这两个命令能给出明确答案。compdb对日常C开发尤其重要——生成compile_commands.json后vim/emacs/VSCode里的智能补全、跳转、静态检查都能吃到精确的编译参数体验接近IDE但不锁编辑器。6.4 让gn在构建前自动重新生成gn在生成build.ninja时留了个后门它会在构建文件里写入一条规则让Ninja在检测到BUILD.gn、.gn或args.gn有改动时先自动重跑gn gen。所以你完全不需要养成改完BUILD.gn手动跑gn gen再ninja的习惯直接执行ninja -C out/Default它会自己判断是否需要重新生成。偶尔遇到我明明改了BUILD.gn但构建命令还是旧的这种诡异情况先确认你是不是改进了不同的输出目录或者是不是只改了args.gn但没触发自动重编。查看build.ninja开头的gnrule能帮你理解它到底什么时候会重跑。7. QT项目切换Ninja的具体做法搜索引擎的热搜词里出现了qt怎么切换ninja这个需求我太熟悉了。现实世界里的Qt用户问这个问题一般面临的是两种完全不同的场景一是Qt 6.0以后全面转向CMake的项目怎么用Ninja构建二是Qt 5时代遗留下来的qmake项目怎么提速。这两条路线的答案差别很大我分开讲。7.1 Qt 6 CMake走Ninja是官方主路Qt 6从构建系统层面彻底转向了CMake所以最顺滑的Ninja切换方式就是在CMake里指定生成器。假设你的Qt安装在$HOME/Qt/6.5.3/gcc_64CMake配置命令是cmake -S . -B build -G Ninja \ -DCMAKE_PREFIX_PATH$HOME/Qt/6.5.3/gcc_64 cmake --build build-G Ninja是关键参数。配置完成后CMake会生成build/build.ninja后续cmake --build build底层调用的就是Ninja。相比默认的Unix Makefiles生成器Ninja在增量构建和并行调度上有碾压性优势尤其是Qt项目动辄几百个源文件差别一开机就能感受到。我自己做过一个粗测同一个Qt Widgets项目Makefile增量构建在改一个头文件后需要30秒Ninja只需要6秒。差距不是来自编译器而是来自Make把大量时间浪费在递归解析和串行调度上。7.2 qmake项目用Ninja的可行方案qmake的情况比较尴尬。qmake的原生生成器始终是Makefile官方一直没有把Ninja支持提升为正式特性。我见过社区里有人尝试qmake -spec ninja之类的用法但它依赖的qmake版本、平台spec组合很不稳定不同Qt版本行为差异极大在生产项目里风险很高。我的建议很明确还在用qmake的老项目如果构建速度真的成了痛点最靠谱的路线是把构建系统迁到CMake而不是在qmake和Ninja之间想歪招。Qt官方其实也为qmake用户铺了一条CMake迁移的路径Qt 5.15以后Qt的CMake支持已经很完整find_package(Qt5 COMPONENTS Widgets)这样的写法可以直接用迁移成本主要是重写CMakeLists.txt业务代码基本不用动。为了那些已经在qmake上跑了好几年的存量项目把时间花在迁移到CMake上长远收益远大于薅一个实验性功能的羊毛。7.3 Qt Creator里如何把生成器换成Ninja如果你用Qt Creator开发CMake项目切换Ninja不需要动命令行。在较新版本的Qt Creator里路径是工具→选项→Kits→ 选择当前使用的套件 → 在右侧找到CMake Generator从下拉列表里把它从Unix Makefiles改成Ninja。旧的版本可能在工具→选项→Build Run→CMake里配置。切换完成后重新打开项目或重新配置构建目录Qt Creator会重新运行CMake并生成Ninja构建文件。后续的点编译、调试、运行全部走Ninja路径你几乎感觉不到区别但构建速度明显变快。有一点要注意如果项目已经用过Makefiles构建过建议删除旧的构建目录通常是build/避免两套构建文件混在一起产生时间戳错乱的问题。8. 多模块实战gnNinja构建一个带静态库的C项目前面把原理和环境讲完了这一章我们用gnNinja完整构建一个带静态库的多模块C项目。目标是把第4章留下的工具链配置完整跑通并演示模块间依赖、头文件传递和增量验证。8.1 项目结构与BUILD.gn的完整代码沿用第4章的hello_gn目录结构现在补齐所有文件。先补lib/math/add.h#ifndef LIB_MATH_ADD_H_ #define LIB_MATH_ADD_H_ int add(int a, int b); #endiflib/math/add.cc#include add.h int add(int a, int b) { return a b; }src/main.cc#include cstdio #include add.h int main() { std::printf(3 4 %d\n, add(3, 4)); return 0; }lib/math/BUILD.gn是这个项目的重点因为它要解决main.cc如何include到add.h的问题static_library(math) { sources [ add.cc ] public_configs [ :math_public ] } config(math_public) { include_dirs [ . ] }这里的思路是定义了一个名为math_public的config它把include_dirs设置为当前目录即lib/math。然后通过public_configs把这个config附加到math这个static_library上。效果是任何依赖math的target都会自动获得-I lib/math这个编译参数于是main.cc里的#include add.h就能找到头文件。为什么不能直接把include_dirs写在static_library里因为写在target级参数只对math自己生效不会传递给依赖它的hello。public_configs的本意就是解决打包发布配置这件事——把头文件路径、宏定义、编译选项这些需要外传的东西统一通过config暴露出去。这是gn工程里最标准的模块间通信方式比手工给每个依赖方加include路径要优雅得多也符合依赖关系只在BUILD.gn里声明一次的原则。src/BUILD.gnexecutable(hello) { sources [ main.cc ] deps [ //lib/math:math ] }整体依赖关系是根目录default依赖//src:hellohello依赖//lib/math:mathmath通过public_configs把自己需要的头文件路径传给hello。构建时Ninja会先编译add.cc生成add.o再归档成libmath.a然后编译main.cc最后把main.o和libmath.a链接成hello。8.2 include路径和头文件依赖的正确处理很多gn新手在这里栽跟头。你要记清楚头文件在源代码里怎么写include取决于依赖方通过config拿到了什么include路径而不是被include的头文件实际在哪。如果math_public里的include_dirs写成include_dirs [ . ]那么所有拿到这个config的人都能直接#include add.h。如果把include_dirs写成include_dirs [ //lib/math ]效果也一样。但如果你把include_dirs设在math这个target自己的属性里main.cc就找不到add.h编译时报fatal error: add.h: No such file or directory。这里忍不住多说一句gn的include路径解析规则和C编译器保持一致所有非//开头的路径都相对当前BUILD.gn所在目录解析。这个设计比CMake的路径取决于当前CMakeLists.txt目录要直观得多因为它和源码目录天然对应。8.3 链接第三方库与平台差异如果项目要链接系统库或第三方库gn的做法和CMake略有不同但思路一致。链接系统库时直接在target里声明executable(http_demo) { sources [ main.cc ] libs [ pthread, curl ] lib_dirs [ /opt/curl/lib ] }libs对应-l参数lib_dirs对应-L参数。跨平台时这些值通常需要根据当前工具链动态切换最正统的做法还是通过config隔离然后在BUILDCONFIG里用条件分支对不同平台赋不同的值。这就是gn把target_cpu、target_os这些系统参数内置的原因——你可以在BUILD.gn里写if (is_linux) { libs [ dl ] } else if (is_mac) { libs [ pthread ] }这些is_*变量同样由declare_args声明默认值会根据当前机器自动推断也可以在args.gn里手动覆盖。8.4 全量构建与增量验证现在把这套工程完整跑一遍。首次全量构建cd hello_gn gn gen out/Default ninja -C out/Default预期输出类似[1/4] CXX obj/lib/math/add.o [2/4] ALINK obj/lib/math/libmath.a [3/4] CXX obj/src/main.o [4/4] LINK ./hello[1/4]里的分母是Ninja本次要执行的总任务数分子是当前执行到的序号。全量构建后运行./out/Default/hello输出3 4 7。接着做增量验证。再执行一次ninja -C out/Default正常情况会输出ninja: no work to do表示所有产物都是新鲜的。然后修改add.h里加一行注释再执行ninja -C out/Default你会看到它只重新编译了main.o并重新链接add.o和归档过程完全跳过。这是因为.ninja_deps里记录了main.o对add.h的依赖而add.o只依赖add.cc和add.h这两个文件头文件内容变了但add.cc没变——等等这里其实有个细节add.cc也include了add.h所以add.o也应该重编。如果Ninja没有重编add.o说明add.cc的depfile机制有问题这是排查点。正常情况下改add.h会触发add.o和main.o两个文件重编。这个实验非常值得亲自做一遍它能帮你直观理解Ninja的依赖追踪精度——精度高到什么程度完全取决于depfile机制是否正常工作。9. 高频报错与排查经验最后分享几个我在实际使用gnNinja过程中踩过的高频坑。这些错误在官方文档里几乎找不到完整的排查思路但你在真实项目里大概率会撞上一两个。9.1 顽固报错missing and no known rule to make it这是Ninja报错里最经典的一条完整信息类似ninja: error: lib/math/add.h, needed by obj/src/main.o, missing and no known rule to make it翻译成人话就是Ninja知道main.o依赖add.h但这个add.h既不存在于源码目录也没有哪个构建规则能生成它。常见原因有两种要么是头文件路径写错了比如实际文件名是add.hh却include了add.h要么是头文件来自另一个模块但对方没有通过public_configs把include路径传过来。排查思路先用ninja -t query obj/src/main.o看看它的依赖列表里到底有没有这个头文件如果有说明depfile已经记录了错误路径问题出在代码里的include写法如果没有问题多半出在config传递上检查public_configs是否写对。9.2 改完BUILD.gn没生效前面说过Ninja会自动重跑gn gen但为什么有时候改了BUILD.gn完全不生效我最常遇到的原因是输出目录对不上。比如你习惯在out/Default构建却手滑改了out/debug目录里的BUILD.gnNinja感知不到那边的变化。另一种情况是手动编辑了args.gn但没触发重跑这时候直接执行gn gen out/Default强制重新生成一次就能解决。判断到底有没有重跑可以打开out/Default/build.ninja看开头的rule gn它记录了触发重跑的条件。如果怀疑当前构建文件已经过期最简单的处理方法就是删除整个输出目录重新gn gen——gn的生成速度很快完全没有必要心疼。9.3 并行构建OOM与内存控制第一次用默认并行度构建大型C项目时我差点以为机器死机了。C编译器的内存消耗远超直觉一个复杂模板元编程文件可以轻松吃掉2GB内存。当16个并行编译任务同时跑起来16GB内存的机器基本就顶不住了。解法分两层。第一层是控制并行度ninja -j 4或用自己的物理核数减几同时给系统留出余量。第二层是从源头降低单任务内存峰值比如在工具链配置里给clang/gcc加上-flto相关的内存优化参数或者换用内存占用更低的链接器。但在个人项目里最实用的还是第一层——调-j参数。我通常的做法是先看free -h确认可用内存然后估算可用内存/单编译任务平均占用得到的数字再打个八折作为-j的值。9.4 文件时间戳、restat与全量重编最后一个坑来自文件系统时间戳。Ninja依赖mtime判断过期但mtime在两种情况下会失灵一是git checkout、rsync同步这类操作会批量改变文件时间戳导致Ninja认为所有文件都变新了触发全量重编二是某些工具生成的文件保留了初始时间戳比如stamp文件它的值可能永远比依赖它的文件旧从而引发连锁重编。gn的工具链配置里可以通过restat 1缓解第二个问题。当工具声明了restatNinja会在命令执行完后重新检查输出文件时间戳如果发现输出没有实际变化就不把这个虚假的更新传播给下游。Chromium的stamp、copy工具都带了这个选项。实际项目中如果遇到莫名其妙的整树重编先怀疑是不是有工具在不该更新的时候更新了文件时间戳。还有个小技巧如果怀疑构建状态损坏与其花一小时分析不如直接ninja -C out/Default -t clean或者干脆rm -rf out/Default重来。gn的产物是高度可再生的重建成本很低。记住这一点能让你在构建系统发疯时保持理智。另外日常开发中我习惯把gn check加进提交流程——它能静态检查所有include是否在依赖图中合理声明相当于把第9.1节那种问题提前到写代码阶段就暴露。搭配gn format统一BUILD.gn格式整个项目的构建文件维护成本会低很多。这套工具链用熟了以后你会慢慢意识到构建系统不是项目的附属品它本身就是值得认真对待的基础设施。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门