
做C/C工程的人多多少少都听过GN这个名字。它是Google为了解决Chromium那种超大代码库而设计的元构建系统本身不直接调用编译器而是根据BUILD.gn和toolchain定义生成一套Ninja构建脚本再由Ninja真正调度编译器、链接器完成产出。GN这套体系里最劝退新手的就是toolchain这一层你必须自己告诉系统用什么编译器、以什么参数、把什么输入变成什么产物。这篇文章会从GN的架构说起结合一个可复现的C交叉编译例子把toolchain配置的语法细节、执行机制、常见翻车点讲透。适合想搞懂Chromium/Fuchsia构建逻辑的人也适合想把GN拿到自己项目里用的同学。1. GN是怎么工作的toolchain在其中扮演什么角色1.1 从一条编译命令说起先看最朴素的编译流程。我们写了一个main.cc想得到可执行文件脑子里马上会出现这样一条命令g -O2 -Iinclude -DMY_FLAG main.cc -o hello在普通Makefile或shell脚本里这条命令被手写出来编译器直接执行。GN做的事情本质上是把这个过程拆成了数据源码列表、头文件目录、宏定义、编译参数、链接参数都变成结构化字段然后根据toolchain里的规则把这些字段拼装成上面的命令行写进Ninja文件。这里的核心点在于拼装的模板是toolchain提供的。GN自身并不知道g长什么样也不知道-c和-o应该放在哪里。它只负责提供{{source}}、{{output}}、$defines这类占位符真正把这些占位符翻译成具体编译命令的是你写的toolchain。所以toolchain可以理解为GN和编译器之间的翻译层。GN是通用的toolchain是具体的。1.2 GN、Ninja、编译器三层关系很多第一次接触GN的人会把GN误当成一个构建工具其实它和Ninja是两套东西。GN负责做依赖分析、参数合并、工具链选择最后输出Ninja文件。Ninja负责执行任务它本身极其简单核心就是根据输入输出时间戳决定要不要跑跑什么命令。编译器、链接器、归档器是最底层的执行者Ninja只是调用它们。为什么要中间隔一层Ninja因为GN做的是复杂的图分析和配置展开这个过程慢而且计算重Ninja做的是极快的增量判断和执行单次执行开销极小。把两者拆开才能兼顾配置灵活性和构建速度。Chromium那种几万个target的工程如果每次构建都重新解析所有BUILD.gn谁也顶不住。等到生成Ninja文件之后GN基本就退场了。你后续跑ninja、改代码、增量编译都只在Ninja和编译器之间进行。这个设计对toolchain配置有什么影响呢意味着你写的toolchain规则最终只会变成Ninja文件里一条条具体的command一旦Ninja文件生成成功GN就不再参与——这也解释了为什么改toolchain之后必须重新运行gn gen因为已有的Ninja文件不会自动更新。1.3 每个GN工程都绕不开的四个文件toolchain配置不是孤立存在的它嵌在一整套GN工程结构里。一个最小可用的GN工程通常由这几个文件构成.gn工程根配置文件指定BUILDCONFIG.gn的位置和各种根属性。BUILDCONFIG.gn构建配置入口定义默认工具链、默认参数、全局declare_args。BUILD.gn真正的构建目标定义通过executable、static_library等函数描述要产什么。build/toolchain/BUILD.gntoolchain定义所在地通过toolchain(name) tool(cc)等块描述怎么产。这么说可能还不够直观我画个依赖关系用语言描述.gn指向BUILDCONFIG.gnBUILDCONFIG.gn通过set_default_toolchain指向build/toolchain/BUILD.gn里的某个toolchain根BUILD.gn里的所有target默认使用这个toolchain。如果你想给某个target单独换工具链就在该target上写toolchain //build/toolchain:另一个。理解了这个文件拓扑再去读工具链定义就不会迷路。很多报错找不到XXX.gni或者toolchain not defined本质上都是这个拓扑里某个环节指向了不存在的东西。2. 深入toolchain()语法、参数与执行机制2.1 toolchain就是一个装着toll定义的scopeGN语言本身是类似JSON 函数调用的混合体。toolchain(name) { ... }严格来说不是类或接口它只是一个特殊scope里面塞了若干个tool()块以及一些供外部读取的变量。我们看一个最简单的工具链定义骨架toolchain(mytoolchain) { cc gcc cxx g ar ar tool(cc) { command $cc -c {{source}} -o {{output}} outputs [ {{source_out_dir}}/{{target_output_name}}.{{source_file_part}} ] } tool(cxx) { command $cxx -c {{source}} -o {{output}} outputs [ {{source_out_dir}}/{{target_output_name}}.{{source_file_part}} ] } tool(link) { command $cxx {{inputs}} -o {{output}} $ldflags $libs outputs [ {{target_out_dir}}/{{target_output_name}}{{output_extension}} ] } }注意cc gcc和tool(cc)是两个不同的东西。前者是toolchain scope里的普通变量供command字符串引用后者是定义一个编译C文件的工具。GN内置了若干tool类型不同类型绑定不同的target产物类型tool(cc)编译C文件对应source_set、static_library等target中的.c源文件。tool(cxx)编译C文件。tool(alink)归档静态库对应static_library。tool(solink)链接共享库对应shared_library。tool(link)链接可执行文件对应executable。tool(stamp)生成时间戳文件用于聚合依赖。tool(copy)文件复制。这些tool类型不能乱起名。GN在解析target时会根据target类型自动去toolchain里找对应的tool。比如遇到executable(hello)它就找tool(link)如果找不到直接报错。2.2 tool(cc)里的command到底怎么拼出来的tool的定义里最关键的是command字段。这个字段看起来是一串字符串实际上包含了三种不同的东西普通文本原样保留比如-c、-o。{{xxx}}占位符GN在生成Ninja文件时根据当前target和源文件信息替换成具体路径或名字。$xxx变量引用引用toolchain scope里的变量、target的配置参数如$defines、$include_dirs以及某些全局变量。一个典型C编译command可能是这样的tool(cxx) { command $cxx -MMD -MF {{output}}.d $defines $include_dirs $cflags -c {{source}} -o {{output}} depfile {{output}}.d }其中$cxx从哪里来你在这个toolchain scope里定义过cxx g所以会被替换成g。$defines和$include_dirs则来自target上的configs、public_configs以及BUILDCONFIG.gn里全局设置的默认config。这里有个容易误解的点$defines不是你在target里写的defines [ A ]而是GN把target收集到的所有defines列表转换成-DA之后拼接出来的字符串。同理$include_dirs会变成-Ixxx。也就是说GN在命令生成阶段负责了列表变参数的转换toolchain的command只需要用$defines引用最终结果。2.3 占位符细节output、source、depfile占位符是GN toolchain配置的精髓也是最容易写错的地方。我列几个高频使用的{{source}}当前源文件的完整路径。{{source_dir}}当前源文件所在目录。{{source_file_part}}源文件名比如main.cc。{{source_name_part}}源文件名去掉扩展名比如main。{{output}}当前tool的outputs列表匹配到的输出文件路径。{{output_dir}}输出文件所在目录。{{output_name}}输出文件名去掉路径和扩展名。{{target_output_name}}target的名字。{{inputs}}当前动作的所有输入文件主要在链接、归档时使用。{{rspfile}}响应文件路径。{{rspfile_content}}写入响应文件的内容。有一个细节我当初折腾了很久{{output}}实际上是一个模式匹配的结果它由outputs字段决定。outputs是一个数组里面描述了这个tool生成文件的路径规则。比如outputs [ {{source_out_dir}}/{{target_output_name}}.{{source_file_part}} ]意思是编译src/foo.cc这个文件时输出到out/xxx/obj/src/foo.cc.o之类的位置。如果某个target有多个源文件{{target_output_name}}相同但{{source_file_part}}不同所以能保证每个源文件有独立的编译产物不会互相覆盖。如果outputs里只有[ {{target_out_dir}}/{{target_output_name}}.o ]编译多文件时就会撞车所有.cc都尝试生成同一个.o构建必然失败。这也是新手最常踩的坑。2.4 默认工具链、BUILDCONFIG.gn和toolchain_args的联动默认工具链不是GN自动选择的而是你在BUILDCONFIG.gn里显式指定的set_default_toolchain(//build/toolchain:host)这一行决定了根BUILD.gn里边所有没有显式toolchain 的target都使用名为host的这个toolchain。那toolchain(arm)又是怎么被用到的常见做法是在某个target上挂toolchain //build/toolchain:arm。GN发现这个target需要不同工具链会做一件非常聪明的事以arm toolchain里的toolchain_args作为新的参数环境重新运行一遍GN解析逻辑把该target依赖的整棵子树单独构建一份Ninja规则。toolchain(arm) { toolchain_args { target_os linux target_cpu arm is_clang false } }这个机制非常关键。它意味着同一个BUILD.gn文件在不同toolchain下可能解析出不同的结果。比如你在BUILD.gn里写了if (target_cpu arm) { sources [ arm_specific.cc ] }那么在host toolchain下这个文件不会被编译在arm toolchain下它会被加入。因为重新解析时target_cpu已经被toolchain_args里的值覆盖成了arm。注意toolchain_args里的变量必须提前用declare_args()声明否则GN会当作未定义变量处理。这也是为什么每个像样的GN工程都有几个.gni文件专门用来declare_args。还需要理解一点默认toolchain本身不受toolchain_args影响否则会无限递归。Chromium的做法是默认toolchain就是给你当前编译主机用的它的target_cpu、target_os直接从args.gn或检测结果来只有显式跨到其他toolchain时才用新的参数组重新解析。3. 实战手写一套C交叉编译工具链3.1 搭建目录骨架理论说再多不如完整跑一遍。下面我建一个模拟项目X目标是在一台x86 Linux主机上用arm交叉编译工具链编译出一个ARM平台的可执行文件。同时保留host工具链方便对照。目录结构如下mock_project/ .gn BUILDCONFIG.gn BUILD.gn args.gn build/ config/ BUILD.gn toolchain/ BUILD.gn gcc_toolchain.gni src/ BUILD.gn main.cc.gn文件内容buildconfig //BUILDCONFIG.gn这里//表示工程根目录。如果不指定buildconfigGN会默认找根目录下的BUILDCONFIG.gn所以一般显式写出来更稳妥。3.2 编写工具链模板gcc_toolchain.gni先写一个工具链模板避免host和arm两份配置重复堆代码。GN的template是预处理宏实例化时会把里面的内容展开成实际代码。template(gcc_toolchain) { toolchain(target_name) { cc invoker.cc cxx invoker.cxx ar invoker.ar tool(cc) { depfile {{output}}.d command $cc -MMD -MF {{output}}.d $defines $include_dirs $cflags -c {{source}} -o {{output}} outputs [ {{source_out_dir}}/{{target_output_name}}.{{source_file_part}} ] } tool(cxx) { depfile {{output}}.d command $cxx -MMD -MF {{output}}.d $defines $include_dirs $cflags -c {{source}} -o {{output}} outputs [ {{source_out_dir}}/{{target_output_name}}.{{source_file_part}} ] } tool(alink) { command rm -f {{output}} $ar rcs {{output}} {{rspfile}} rspfile {{output}}.rsp rspfile_content {{inputs}} outputs [ {{target_out_dir}}/{{target_output_name}}{{output_extension}} ] default_output_extension .a } tool(solink) { command $cxx -shared $ldflags -o {{output}} {{inputs}} $libs $lib_dirs outputs [ {{target_out_dir}}/{{target_output_name}}{{output_extension}} ] default_output_extension .so } tool(link) { command $cxx $ldflags -o {{output}} {{inputs}} $libs $lib_dirs outputs [ {{target_out_dir}}/{{target_output_name}}{{output_extension}} ] default_output_extension } tool(stamp) { command touch {{output}} outputs [ {{target_out_dir}}/{{target_output_name}}.stamp ] } tool(copy) { command cp -f {{source}} {{output}} outputs [ {{output_dir}}/{{target_output_name}}{{output_extension}} ] } } }几点说明$cflags里包含了从configs收集来的所有C/C编译flag。如果你想区分C和C可以分别用$cflags_c和$cflags_cc这里简化成统一的$cflags。alink用了{{rspfile}}因为静态库归档时输入的目标文件一多命令行长度会爆。GN会把{{rspfile_content}}写入临时文件再用文件的方式传给ar。link和solink都用了$ldflags、$libs、$lib_dirs。$libs会被GN转换成-lxxx形式$lib_dirs转换成-Lxxx。depfile设置为{{output}}.d这样Ninja可以追踪头文件依赖。-MMD让gcc在编译时顺带生成.d文件-MF指定输出位置。3.3 在build/toolchain/BUILD.gn里注册两个工具链import(//build/toolchain/gcc_toolchain.gni) gcc_toolchain(host) { cc gcc cxx g ar ar toolchain_args { target_os host_os target_cpu host_cpu is_clang false } } gcc_toolchain(arm) { cc arm-linux-gnueabihf-gcc cxx arm-linux-gnueabihf-g ar arm-linux-gnueabihf-ar toolchain_args { target_os linux target_cpu arm is_clang false } }这里host_os、host_cpu是GN内置变量分别表示当前编译主机的系统和架构所以host工具链不需要硬编码。arm工具链则是典型的交叉编译场景。我假设你的机器上装了arm-linux-gnueabihf-gcc这组工具如果你用的是aarch64换对应前缀就行。3.4 公共config与BUILDCONFIG.gn为了让所有target默认带上一些编译参数我在build/config/BUILD.gn里定义一个configconfig(project_default) { cflags [ -Wall, -O2 ] defines [ MY_PROJECT, PROJECT_VERSION1 ] include_dirs [ //src ] }然后在BUILDCONFIG.gn里引用它set_default_toolchain(//build/toolchain:host) set_defaults(executable) { configs [ //build/config:project_default ] } set_defaults(static_library) { configs [ //build/config:project_default ] } set_defaults(shared_library) { configs [ //build/config:project_default ] }set_defaults非常重要它给每个target类型提供默认configs。如果你不在BUILDCONFIG.gn里用set_defaults挂config那target上根本不会有默认编译参数甚至连基本的-Wall都不会出现。在大型工程里BUILDCONFIG.gn还会负责连接系统库、设置默认标准、链接参数等。这里我只挂基础的东西。3.5 写两个BUILD.gn根BUILD.gn只做一个调度executable(hello) { deps [ //src ] } executable(hello_arm) { deps [ //src:arm_main ] toolchain //build/toolchain:arm }初看可能觉得奇怪hello_arm里直接写toolchain //build/toolchain:arm那它依赖的arm_main是怎么被解析的这是一个重要机制当一个target显式指定了非当前默认的toolchainGN会以那个toolchain的toolchain_args重建解析环境所有被它依赖的target都自动进入arm工具链的上下文。所以src/arm_main不需要自己再写toolchain字段。# src/BUILD.gn static_library(mylib) { sources [ mylib.cc, mylib.h ] } executable(arm_main) { sources [ main.cc ] deps [ :mylib ] }main.cc内容随意比如打印一行字符串即可。mylib提供一个小函数比如返回一个整数main里调用它并打印这样能验证静态库链接是否正常。再放一个host版本的binary在根BUILD.gn里直接构建为了避免命名冲突我就不额外加host目标了。hello依赖//src而//src下没有直接叫src的target——这里要修正一下。让我把根BUILD.gn改得更干净# BUILD.gn executable(hello) { sources [ src/main_tool.cc ] deps [ //src:mylib ] } executable(hello_arm) { sources [ src/main_tool.cc ] deps [ //src:mylib ] toolchain //build/toolchain:arm }同一条源码同时构建host和arm两个版本区别只在toolchain。这在演示的时候格外直观同样的源码因为工具链不同产物的架构完全不同。3.6 生成Ninja文件和执行构建在项目根目录执行gn gen out/host --argstarget_cpux64 target_oslinux先构建默认host版本看看ninja -C out/host hello file out/host/hello如果一切正常file会告诉你这是一个x86-64可执行文件。然后生成arm版本gn gen out/arm --argstarget_cpuarm target_oslinux ninja -C out/arm hello_arm file out/arm/hello_arm这时发现一个问题我并没有指定默认toolchain走arm而hello_arm显式使用了arm工具链所以arm的Ninja文件会被生成在out/arm/obj/src/helloworld_arm之类的路径下。gn gen out/arm本身只是为默认toolchain生成规则但GN遇到toolchain //build/toolchain:arm时会顺手把arm子图也生成出来。如果只想让整个工程默认用arm最直接的方法是修改BUILDCONFIG.gnset_default_toolchain(//build/toolchain:arm)然后用gn gen out/arm时所有无显式toolchain的target都走arm。两种方式都合法实际项目里看需求选。跑完后会看到产物用readelf -h out/arm/hello_arm可以确认ELF机器类型是ARM。完整命令readelf -h out/arm/hello_arm | grep Machine看到ARM架构字样说明交叉编译链路打通了。4. 常见问题与排查技巧实录4.1 报错Toolchain not defined或找不到BUILDCONFIG.gn这种错误十有八九是文件拓扑问题。.gn里的buildconfig //BUILDCONFIG.gn路径写错或者BUILDCONFIG.gn里set_default_toolchain指向的toolchain不存在GN都会抛错。set_default_toolchain(//build/toolchain:host)里的//build/toolchain:host指向的是工程根目录下build/toolchain/BUILD.gn这个文件里名为host的toolchain。如果toolchain名字写成了gcc_toolchain(host)里的target_name那没问题如果你import错了文件或者BUILD.gn里没定义就会报错。排查思路很简单先gn gen out/debug看GN在哪一步报错。它会明确指出Unable to load //build/toolchain/BUILD.gn还是The toolchain //build/toolchain:host was not defined。4.2 命令拼写问题变量替换没生效最常见的是把{{source}}错写成$source或者把$defines错写成{{defines}}。这两套语法不能混用{{xxx}}主要用于文件路径和target元数据。$xxx用于引用toolchain scope里定义的变量、target配置参数等。一旦混用GN生成的Ninja命令里会出现字面量$defines然后Ninja执行时shell会把它展开成空编译参数丢失行为非常隐蔽。我踩过一次把$cflags漏了导致编译时没有任何优化选项和警告选项程序能编过但该有的宏定义、头文件路径全部消失。最后看ninja -v输出才发现命令里没有-DFOO。建议第一步永远是用ninja -v查看实际执行的命令行。GN生成的命令和手写的对不上说明占位符或变量引用有问题。4.3 响应文件与命令行过长在Linux上链接几百个目标文件时命令行很容易超过几万字符虽然Linux的ARG_MAX很大但某些工具或环境依然建议用响应文件规避。Windows更明显命令行长度限制大约32K所以toolchain里link、solink、alink最好都配置rspfile。我在上文alink里用了响应文件。如果你删掉rspfile相关配置改成直接$ar rcs {{output}} {{inputs}}在目标文件数量多的时候就会碰到Argument list too long或Windows下诡异的链接失败。响应文件具体用法就三行rspfile {{output}}.rsp rspfile_content {{inputs}} command ... {{rspfile}}注意rspfile_content里用{{inputs}}而不是$inputs这点我反复确认过写错会变成空文件。4.4 交叉编译产物架构不对交叉编译最大的迷惑点在于工具链换了但某些默认路径还是指向主机环境。比如arm工具链的gcc默认找的是arm的sysroot但如果你在toolchain里漏掉--sysroot/path/to/arm/sysroot链接时可能会找到主机上的libc.so最终产物虽然能链接通过运行环境却对不上。交叉编译时三个位置最容易出错编译器路径必须用arm前缀的gcc/g不是主机gcc。sysroot要指向ARM版根文件系统让链接器找到正确的libc。链接参数$libs和$lib_dirs里的库路径必须是arm版本。我习惯在toolchain里显式加一个变量sysroot /opt/arm-sysroot然后command里带上command $cxx --sysroot$sysroot $ldflags -o {{output}} {{inputs}} $libs $lib_dirs不要依赖工具链自带的默认sysroot尤其在嵌入式开发里SDK的sysroot路径五花八门显式指定最稳。4.5 增量构建不生效depfile配置缺失如果tool(cc)的command里没有-MMD -MF也没有depfile字段Ninja无法知道头文件变化。你改了某个.h文件重新跑ninja时它认为目标没变不会重新编译依赖它的.cc。这种改了头文件但构建结果没变的问题排查起来非常折磨人。depfile的配置就三处depfile {{output}}.d command $cc -MMD -MF {{output}}.d ... outputs [ {{source_out_dir}}/{{target_output_name}}.{{source_file_part}} ]注意-MF路径和depfile字段必须一致。如果depfile路径写成了固定名多文件编译时会互相覆盖导致依赖信息丢失。4.6 快速诊断表现象常见原因处理方法Toolchain not definedset_default_toolchain名字写错或文件缺失确认//build/toolchain/BUILD.gn中存在该toolchain编译命令明显缺参数$defines或$cflags写错/漏写用ninja -v查看实际命令比对占位符多源文件产出.o冲突outputs字段没有用source_file_part区分改成{{source_out_dir}}/{{target_output_name}}.{{source_file_part}}命令行过长link/alink未配置rspfile配置rspfile和rspfile_contentcommand里用{{rspfile}}头文件改动不触发重编depfile未配置或路径不对加-MMD -MF确保depfile字段一致ARM产物却是x86工具链前缀或sysroot配错检查cc/cxx路径加--sysroot用file/readelf验证gn gen报declare_args相关错误toolchain_args里用了未声明变量先在.gni里用declare_args声明一点实操心得GN的toolchain配置难难在它不像Makefile那样所见即所得所有规则都是隐式的、层层传递的。但反过来想一旦你把它跑通了好处也很明显同一套BUILD.gn可以在host、arm、各种编译参数组合下自由切换而且永不出现手写Makefile到后期维护不动的问题。我自己在某个模拟项目里用这套结构从零搭交叉编译到打通前后大概花了一天。最耗时间的不是toolchain语法本身而是理解{{source_out_dir}}和{{target_out_dir}}的产物路径规则。建议你搭好骨架后先跑一个只有一个源文件的executable生成Ninja后用ninja -v看实际命令把所有占位符的值都搞清楚再去加静态库、动态库和依赖树。这样踩坑半径小很多。如果后续要把GN接到更大的工程我建议多参考Chromium那套build/toolchain目录组织方式它的gcc_toolchain.gni里针对不同平台、不同编译器的处理非常完整但直接拿来用可能过度设计。按自己的编译器版本、平台需求裁剪出一份精简版是性价比最高的路径。