做C/C++项目久了,几乎每个人都会被构建系统磨掉一层耐心。Makefile写起来不复杂,但项目一上规模,看依赖关系、管编译顺序、做增量编译就慢慢变得不可控。如果你关注过Chromium、V8、Flutter这类大型仓库,应该对GN和Ninja这对组合不陌生——GN负责把项目描述翻译成构建规则,Ninja负责用极快的速度把这些规则真正跑起来。这套组合在Google内部和大量开源项目里已经被验证了很多年,对于想摆脱Make/CMake维护噩梦,又希望构建速度、增量编译能力都能上一个台阶的团队来说,是一个非常值得认真评估的方向。
这篇文章我会从设计思路讲起,把GN的语法、构建文件的组织、完整项目实操、常见坑、以及和CI/Docker/Qt等场景的配合都梳理一遍。无论你是第一次听说GN,还是已经在项目里踩过几个坑,读完之后应该都能对这套构建体系有一个完整的判断,也能直接照着示例把项目跑起来。
1. 为什么现代大型项目需要GN+Ninja这套组合
1.1 传统的Makefile和CMake到底卡在哪里
在聊GN之前,先回头看看传统方案的痛点。手写Makefile最大的问题是:规则越写越像程序,但Make本质上只是带依赖关系的命令执行器。项目一复杂,你要自己维护头文件依赖、跨目录依赖、编译选项传递、多平台分支、Debug/Release切换,很快Makefile就会变成一团只有原作者能看懂的“咒语”。
CMake比手写Makefile前进了一大步,它把“描述项目”和“生成构建脚本”分离,解决了跨平台问题。但CMake真正跑起来的时候,底层往往还是生成Makefile,再由make执行。这一层间接带来的问题是:configure阶段要反复解析大量CMakeLists,生成过程偏慢;make在增量编译时会做大量不必要的文件状态检查,几十万文件的大型工程增量构建可能要几十秒甚至几分钟。真正的痛点是——描述逻辑和构建执行混在一起,导致“该重编的没重编、不该重编的反复重编”。
1.2 GN和Ninja各自扮演什么角色
GN全称Generate Ninja,是一个元构建系统,输入是.gn和BUILD.gn文件,输出是给Ninja用的build.ninja文件。它本身不直接编译任何代码。Ninja则是一个专注于速度的轻量构建执行引擎,读取build.ninja后调度编译、链接命令。
你可以把这套组合理解为:GN是画图纸的建筑师,Ninja是拿着图纸施工的工程队。建筑师只管把图纸画对、画清楚,施工队只关心怎么在最短时间内把楼盖完。这跟Makefile那种“建筑师兼包工头”的方式有本质区别。职责分离带来的直接好处是:
- GN可以专心优化构建描述的清晰度和可维护性,支持条件分支、变量、模板、跨目录依赖。
- Ninja可以专注于文件时间戳判断、依赖图调度、并行执行,把增量构建做到“毫秒级判断、秒级编译”。
- 因为GN生成Ninja文件的速度远快于CMake的configure,即使反复修改构建描述,重新生成的开销也非常低。
从数据上看,Chromium这样规模的项目,用Ninja全量构建虽然也要一段时间,但增量构建非常快。改一个源文件重新编译,通常就是这个文件及其直接依赖的重新编译,构建系统本身的调度开销几乎可以忽略。
1.3 什么项目适合切换过来
不是所有项目都需要GN+Ninja。如果你只是编译一个单独的main.c,直接一行gcc就够了,引入GN反而多此一举。但如果你遇到下面这些情况,就值得认真考虑:
- 项目有多个目录、多个可执行文件/库,目标之间有复杂的依赖关系。
- 需要在多个平台(Windows/Linux/macOS)上维护一致的构建方式。
- 需要灵活切换不同的编译配置,比如Debug/Release、静态库/动态库、不同指令集。
- 多人协作、CI流水线频繁触发构建,构建速度直接影响开发效率。
- 项目规模大到Makefile或CMake的增量构建已经明显拖慢节奏。
顺带说一下,现在网上搜“构建”这个词,会看到很多不同含义的场景,比如知识库构建、数据特征构建、Docker镜像构建。这里讨论的C/C++项目构建,是整个软件工程领域最传统的那个方向,跟AI知识库、数据管线完全是两码事,别被热词混淆了方向。
2. GN核心概念与构建文件写法
2.1 构成项目的三个关键文件
任何一个GN项目,根目录下至少要有三样东西:.gn文件、BUILDCONFIG.gn、还有真正定义目标的BUILD.gn文件。
.gn文件虽然看起来不起眼,但它决定了一件事:GN从哪个目录开始寻找项目根。内容通常只有一行:
buildconfig = "//BUILDCONFIG.gn"这里//开头的路径表示项目根目录下的文件。如果没有特别说明,GN默认也会在根目录找BUILDCONFIG.gn,所以这一行其实可以是隐式的。但既然写项目了,还是建议显式写出来,方便后续阅读和调试。
BUILDCONFIG.gn是全局配置入口,所有子目录的BUILD.gn在被解析之前,都会先加载它。通常在这里面设置默认工具链、全局编译参数、目标平台等。最简单的情况下,它可能只有一行:
set_default_toolchain("//build/toolchain:gcc")BUILD.gn则是真正定义构建目标的地方。可以分布在不同目录里,每个目录一个,通过//路径相互引用。GN不推荐把所有target堆在一个根BUILD.gn里,那样就跟把所有C++代码写进一个文件没什么区别。
2.2 target的常见类型
在BUILD.gn里,最常用的target类型有几种,初次接触时掌握这几个就够了:
| target类型 | 作用 | 对应产物 |
|---|---|---|
| executable | 生成可执行文件 | 可执行程序 |
| static_library | 生成静态库 | .a / .lib |
| shared_library | 生成动态库 | .so / .dll / .dylib |
| group | 逻辑分组,只组织依赖不产生文件 | 无直接产物 |
| action | 执行一条自定义命令 | 任意产物 |
举个例子,下面的代码定义了一个可执行文件和一个静态库:
static_library("hello") { sources = [ "hello.cc", "hello.h", ] include_dirs = [ "." ] } executable("demo") { sources = [ "main.cc" ] deps = [ ":hello" ] }deps是GN里最核心的依赖表达方式。它告诉GN,编译demo之前先编译hello,链接demo时要把hello库加进去。GN会根据所有target的deps自动构建依赖图,生成访问顺序正确的build.ninja文件。
2.3 变量、条件判断与args参数
GN的语法风格和Python有点像,但它不是完整编程语言。它刻意做得很小,目的是保证所有构建文件都能被快速解析、静态分析、很容易做缓存和增量。你可以在BUILD.gn里声明变量、写条件判断、用循环生成重复结构,但不要去写复杂的逻辑。
下面是一个带调试/发布条件的目标:
declare_args() { is_debug = true } executable("demo") { sources = [ "main.cc" ] if (is_debug) { cflags = [ "-g", "-O0" ] } else { cflags = [ "-O2" ] } }declare_args()的作用是声明一个可以从命令行覆盖的构建参数。比如你想切到Release模式,不需要改任何文件,直接执行:
gn gen out/release --args="is_debug=false"这样生成的out/release/build.ninja就会按is_debug=false来构建。每一个out目录独立保存自己的参数配置,所以你可以同时维护Debug、Release、交叉编译等多种构建目录,互不干扰。
2.4 依赖传递与include路径的坑
使用CMake的习惯是,如果你链接了某个库,这个库的include目录通常也自动可用,因为CMake会通过target_link_libraries传递接口属性。但GN默认不做这种传递。
假设static_library("hello")的头文件在include/目录,executable("demo")里引用这个头文件,你需要让demo也知道include路径。GN的推荐做法是使用config:
config("hello_include") { include_dirs = [ "//include" ] } static_library("hello") { sources = [ "src/hello.cc" ] public_configs = [ ":hello_include" ] } executable("demo") { sources = [ "main.cc" ] deps = [ "//src/hello:hello" ] }凡是依赖hello的target,都会自动继承public_configs里的include路径。如果你刚开始写GN,这个机制务必要搞懂,因为它和CMake的心智模型完全不同,是新手最容易掉进去的坑之一。
3. 完整实操:从空目录到一个可运行的C++项目
3.1 项目目录结构设计
为了让后面的命令行操作能直接复现,我构建一个最简单的示例项目。功能非常朴素:static_library提供一个PrintHello函数,executable链接它并调用函数输出一句话。
目录结构设计成下面这样:
demo/ ├── .gn ├── BUILDCONFIG.gn ├── BUILD.gn ├── src/ │ ├── BUILD.gn │ ├── hello.cc │ ├── hello.h │ └── main.cc └── build/ └── toolchain/ └── BUILD.gn这里src目录里同时放库和可执行文件,属于演示性写法。真实项目里如果模块多,可以按模块拆目录,每个模块一个BUILD.gn。toolchain放在build/toolchain下是约定俗成的路径,方便其他项目参考。
3.2 逐个文件看关键配置
首先是根目录的.gn文件:
buildconfig = "//BUILDCONFIG.gn"然后是BUILDCONFIG.gn,指定默认工具链为gcc:
set_default_toolchain("//build/toolchain:gcc")接下来是build/toolchain/BUILD.gn。这是整个配置里最“底层”的部分,定义了编译器和各种编译规则。我在这里给一个极简但可运行的版本,主要为了演示GN的机制,实际项目中建议参考Chromium或BoringSSL等开源项目里的完整toolchain配置。
toolchain("gcc") { cc = "gcc" cxx = "g++" ar = "ar" ld = cxx tool("cc") { depfile = "{{output}}.d" command = "$cc -MMD -MF $depfile -c {{defines}} {{include_dirs}} {{cflags}} {{cflags_c}} {{source}} -o {{output}}" deps = "gcc" outputs = [ "{{output}}" ] } tool("cxx") { depfile = "{{output}}.d" command = "$cxx -MMD -MF $depfile -c {{defines}} {{include_dirs}} {{cflags}} {{cflags_cc}} {{source}} -o {{output}}" deps = "gcc" outputs = [ "{{output}}" ] } tool("alink") { command = "rm -f {{output}} && $ar rcs {{output}} {{inputs}}" outputs = [ "{{output}}" ] } tool("solink") { command = "$ld -shared -o {{output}} {{inputs}} {{ldflags}} {{libs}}" outputs = [ "{{output}}" ] } tool("link") { command = "$ld -o {{output}} {{inputs}} {{ldflags}} {{libs}}" outputs = [ "{{output}}" ] } tool("stamp") { command = "touch {{output}}" outputs = [ "{{output}}" ] } tool("copy") { command = "cp -f {{source}} {{output}}" outputs = [ "{{output}}" ] } }这个文件看着复杂,其实逻辑很简单。toolchain里定义了六种工具:编译C、编译C++、创建静态库、创建动态库、链接可执行文件、打时间戳、复制文件。每个tool里的command是真正要执行的命令行,{{source}}、{{output}}这些是GN在生成ninja文件时替换的占位符。
再说一遍,这里为了篇幅做了大量简化。真实项目的toolchain通常要处理编译器的预编译头、响应文件、Windows下的不同命令格式、硬链接优化、调试信息生成等。入门阶段先跑通一个最简单的,后面再逐步加细节。
继续看根目录的BUILD.gn:
group("default") { deps = [ "//src:demo", ] }这个group的作用是给ninja命令一个默认入口。如果没有它,执行ninja时不知道默认该构建什么,或者只能去构建第一个碰到的target。命名为default是Ninja的约定,默认会优先构建它。
然后是src/BUILD.gn:
static_library("hello") { sources = [ "hello.cc", "hello.h", ] include_dirs = [ "." ] } executable("demo") { sources = [ "main.cc" ] deps = [ ":hello" ] }hello.h的内容:
#ifndef DEMO_HELLO_H_ #define DEMO_HELLO_H_ void PrintHello(); #endifhello.cc:
#include <iostream> #include "hello.h" void PrintHello() { std::cout << "Hello from GN + Ninja" << std::endl; }main.cc:
#include "hello.h" int main() { PrintHello(); return 0; }3.3 生成与编译的完整命令
先在项目根目录准备一个构建输出目录,然后用gn生成:
gn gen out/default如果你之前没接触过GN,命令行里会输出生成成功的提示。此时out/default目录下会出现build.ninja和args.gn等文件。build.ninja就是Ninja直接执行的构建脚本,它内容非常扁平、可读,打开看一眼就能理解Ninja的设计哲学。
接着执行编译:
ninja -C out/default-C参数让ninja进入out/default目录找build.ninja。执行完你应该能在out/default目录下看到可执行文件demo。运行它:
./out/default/demo输出:
Hello from GN + Ninja如果你第一次跑,可能会好奇为什么整个构建好像“唰”一下就好了。这正是Ninja厉害的地方——它不做多余的检查,依赖关系由GN提前算好,Ninja只需要按图执行。
3.4 增量构建到底快在哪里
现在做一个简单实验。修改src/hello.cc,把输出文字改一下,然后再次执行:
ninja -C out/default注意看输出——Ninja只会重新编译hello.cc,然后重新链接demo。main.cc完全没有被重新编译,因为它的依赖图里不包含hello.cc。如果你在Makefile时代遇到过“改一个头文件导致全项目重编”的噩梦,Ninja这种精确的依赖追踪会给你极大的幸福感。
再看另一个细节:如果你修改了src/BUILD.gn或BUILDCONFIG.gn,无需手动重新执行gn gen。GN会自动把这些gn文件作为build.ninja的依赖,ninja会先调用GN重新生成再继续编译。这个自动再生的机制,等于把“配置”和“构建”无缝衔接起来了,日常开发时基本感觉不到中间过程。
3.5 多配置切换:Debug、Release与交叉编译
前面提到用gn args切换配置,这里实际演示一下。先生成一个Release构建目录:
gn gen out/release --args="is_debug=false"如果你在src/BUILD.gn里写了if (is_debug)分支,那两个目录编译出的cflags就会不一样。验证方式很简单:
ninja -C out/default -v ninja -C out/release -v-v参数会显示完整编译命令,你能直接看到默认目录带着-g,release目录带着-O2。
交叉编译也是同一套思路,核心是替换toolchain。实际项目中,嵌入式场景常常要为主机平台和目标板卡各建一个out目录,每个目录指定各自的target_cpu和toolchain。比如给目标板卡编译应用层组件时,可能有一个out/arm、一个out/host,命令行就是:
gn gen out/arm --args="target_cpu=\"arm\""具体参数怎么传,取决于你写的toolchain怎么接收target_cpu。这套机制保证了同一套源码和BUILD.gn可以支持多个目标平台,不用像Makefile那样到处写if platform分支。
4. 常见问题与排查技巧实录
4.1 高频报错速查表
我在实际使用过程中,遇到过的错误绝大多数都能归到下面几类:
| 错误现象 | 常见原因 | 解决办法 |
|---|---|---|
| gn: Unable to load //BUILDCONFIG.gn | 根目录.gn中buildconfig路径写错,或该文件不存在 | 检查.gn文件路径和文件名大小写 |
| ERROR: Could not find source file //src/hello.cc | BUILD.gn里sources路径写错 | 确认路径相对于项目根目录是否正确 |
| ninja: error: missing and no known rule to make it | target没有声明对应source文件或依赖缺失 | 检查deps是否完整,sources是否把所有编译输入都列进去 |
| ld: cannot find -lxxx | 链接时找不到系统库或第三方库 | 检查libs声明,或是否在toolchain里配了正确的库搜索路径 |
| args.gn:1:1: Unknown name is_debug | 参数声明缺失 | 在BUILDCONFIG.gn或BUILD.gn中用declare_args()先声明 |
| File not within output directory | out目录和源文件目录混了 | 确认gn gen指定的输出目录是独立目录,别把源文件目录当输出目录 |
4.2 增量构建失效的几个坑
Ninja快归快,但增量构建失效的情况我也踩过几次。最典型的是“改了头文件,所有依赖它的文件都重新编译”——这其实是正确行为,因为Ninja的depfile里记录了每个源文件实际包含的头文件依赖。但如果头文件路径写得有问题,比如用了绝对路径、或没有通过公共include_dirs传播,就会出现两种情况:要么漏跟头文件导致改头文件后受影响文件不重编,要么重复编译一堆本不该动的文件。
第二个常见坑是文件时间戳问题。Ninja判断是否重建依赖源文件和输出文件的时间戳,如果你在CI里把out目录从一个机器直接拷贝到另一个机器,文件mtime可能不一致,Ninja会误判到底该不该重编。所以我建议CI里不要直接缓存整个out目录,要想清楚哪些可以缓存、哪些必须重新生成。
遇到“莫名其妙的全部重编”时,可以使用:
ninja -C out/default -d explain这个参数会逐条解释为什么某个目标需要重建,是诊断增量构建问题最犀利的工具,没有之一。
4.3 本地依赖和第三方库怎么组织
GN没有自己的包管理器,这一点跟vcpkg、conan这种方案不一样。处理第三方依赖,我见过几种常见姿势:
- 用git submodule或git subtree把第三方库源码直接放到third_party目录。
- 在third_party域名下为每个库写一个BUILD.gn,把库的源码组织成GN target。
- 如果第三方库本来就有CMakeLists,可以先把它编译成静态库,再用GN的action或预先编译好的二进制接入。
这种方式看起来原始,但对构建过程的控制力反而更强。所有依赖都通过deps显式声明,构建图一目了然,不会出现CMakeFetchContent那种隐式拉取、隐式编译的“黑箱”问题。
4.4 Qt项目怎么切到Ninja
很多人搜索“Qt怎么切换ninja”,其实是希望Qt项目也能享受Ninja的增量构建速度。这里要分情况说清楚。
如果你用的是Qt 6配合CMake管理工程,那切换非常容易。CMake自带Ninja generator,构建时只要指定一下:
cmake -B build -G Ninja cmake --build build如果是在Qt Creator的图形界面里,打开项目后在“构建目录”设置里把CMake generator改成Ninja即可。
但如果你用了qmake,那要说明一下:qmake默认生成的是Makefile,它本身并不直接支持Ninja generator。Ninja更适合和CMake组合。所以Qt项目的正确姿势是:用CMake,指定Ninja为generator,让CMake做项目管理,让Ninja负责高性能构建执行。
5. 在CI与容器化环境里落地
5.1 GitLab CI中的流水线设计
GN+Ninja在CI里的表现,是我推荐它的另一个重要原因。因为它生成文件的行为非常确定:给定相同的gn文件和args,生成的build.ninja基本是确定的。这给流水线排查和缓存策略都带来了方便。
一个很基础的GitLab CI阶段可能长这样:
build: stage: build script: - gn gen out/ci --args="is_debug=false" - ninja -C out/ci cache: paths: - out/ci/obj注意这里我缓存的是out/ci/obj这种中间产物目录,而不是整个out目录。为什么?因为build.ninja、args.gn这些构建描述文件应该每次从git仓库的状态重新生成,如果连带缓存了,反而可能因为和源码不一致导致诡异的构建错误。缓存中间obj文件,可以让增量编译在runner上生效,大幅缩短重复构建时间。
还有一个小建议:在多个runner并行时,最好对每个runner单独分配out目录,避免两个runner同时写同一个out目录产生竞争。可以根据CI运行环境的变量动态生成out路径,比如out/ci-${CI_COMMIT_SHORT_SHA},或者每个runner固定一个目录。
5.2 Docker多阶段构建与传统镜像构建
GN+Ninja在Docker里有一个非常舒服的用法:多阶段构建。你可以在一个builder镜像里放好GN、Ninja和编译工具链,把项目构建成二进制,然后把这些二进制拷进一个极简的运行时镜像。这样最终镜像里完全没有编译工具链,镜像体积能大幅缩小,攻击面也随之减小。
一个典型的Dockerfile骨架:
FROM ubuntu:22.04 AS builder RUN apt-get update && apt-get install -y \ ninja-build g++ python3 \ && git clone ... WORKDIR /src RUN gn gen out --args="is_debug=false" \ && ninja -C out FROM ubuntu:22.04 COPY --from=builder /src/out/demo /usr/local/bin/demo ENTRYPOINT ["demo"]如果你在CI里遇到“docker: error response from daemon: get ... 失败”这类报错,多半是runner本身拉取镜像的网络问题、或者Docker daemon的registry配置有问题。这种问题不属于GN/Ninja本身,但经常出现在Jenkins、GitLab Runner的Docker executor环境里。排查时先确认runner能正常拉取基础镜像,再检查docker镜像源配置,最后再往回看构建脚本。
5.3 交叉编译和嵌入式场景
嵌入式场景,比如常见的板级Linux系统构建,本质上就是把宿主机上的交叉编译工具链、目标系统的库文件、应用源码组织到一起,产出一个能在目标硬件上运行的系统镜像。这里面会涉及大量组件的编译顺序、依赖关系、配置选项,正好是GN擅长处理的领域。
用GN做嵌入式交叉编译时,最重要的还是toolchain的设计。你需要定义一个指向交叉编译器的toolchain,比如:
toolchain("arm_gcc") { cc = "/opt/arm-gnu-toolchain/bin/arm-linux-gnueabihf-gcc" cxx = "/opt/arm-gnu-toolchain/bin/arm-linux-gnueabihf-g++" ld = cxx ... }然后把构建参数指向这个toolchain。这样所有target的编译命令都会自动使用交叉编译器,不需要在业务代码里写任何平台分支。一套BUILD.gn可以同时支持主机构建和交叉构建,这比传统上维护两套Makefile的方案要清爽得多。
6. 一些个人经验和最后的小建议
从传统构建系统换到GN+Ninja,最直观的感受是“构建脚本终于能看懂”了。BUILD.gn里的每个target、每条deps都表达得很清晰,新同学上手看构建文件比看CMake容易很多。构建速度上的提升更是体感明显,尤其是增量编译,普通规模的项目几乎感受不到等待。
但我也要实话实说,这套体系的学习曲线不算平的。最大的门槛不是GN的语法,而是toolchain的配置。如果你之前没写过自定义toolchain,第一次看build/toolchain/BUILD.gn多半会有点懵。我的建议是不要自己从零写全套配置,而是找一个大点的开源项目,比如BoringSSL或Skia,借鉴它们的toolchain定义,然后按自己的需求裁剪。
另外,如果你只是写一个很小的工具或者课程作业,完全没必要引入GN。它适合的是结构复杂、长期演进、需要多平台支持的项目。判断标准很简单:当你开始为项目写第二个CMakeLists或者第三个Makefile的时候,就可以认真考虑GN了。我个人的体会是,花一天时间把基础搭建跑通,之后省下的是无数个下午。