最近在把一个裸机嵌入式项目从Makefile迁移到GN构建系统,整个迁移过程比预想中顺利,唯独卡在armcc工具链的接入上折腾了不少时间。GN这套元构建系统本身设计得很精巧,但它的工具链抽象几乎默认你是拿GCC或者Clang干活,突然换成armcc这种“自成一派”的商用编译器,默认模板基本没法直接用。这篇东西就专门聊清楚:怎么在GN里把armcc工具链完整地配置起来,从工具链抽象原理、命令行参数映射,到完整的BUILD.gn和BUILDCONFIG.gn写法,再到实际编译、链接、生成bin文件的过程中会遇到的问题。
如果你也正在用GN管理嵌入式工程,又因为项目历史原因必须保留armcc作为实际编译器,那这篇文章能把大多数坑先帮你踩平。哪怕你暂时不用armcc,看完自定义toolchain的完整写法,以后想接任何非GCC系编译器到GN里,思路也是一模一样的。
1. 为什么说GN默认工具链玩不转armcc
1.1 GN的工具链抽象和它的“默认假设”
GN和Ninja的组合里,GN负责生成Ninja文件,Ninja只负责执行具体的构建动作。GN里构建动作的最小单位叫tool,一个toolchain就是一组tool的集合。默认情况下,GN会给你预设一套基于GCC/Clang习惯的toolchain,里面包含cc、cxx、alink、solink、stamp、copy这些工具。
问题就出在这套默认实现里。GN调用cc工具时,会自动帮你在命令行里加上依赖生成参数,比如-MMD -MF这类GCC风格的选项,还会假设编译器能理解-O2、-g、-fno-omit-frame-pointer这类参数。你把armcc直接塞进去,理论上gn gen这一步不会报错,因为GN只是在生成脚本,真正暴露问题的是Ninja启动构建那一刻。你会看到一串unknown option、unsupported command line option,然后整条编译命令失败。
更麻烦的是,armcc的参数风格和GCC差异很大。GCC用-mcpu=cortex-m4,armcc用--cpu=Cortex-M4;GCC用-MMD,armcc用--depend;GCC用-Wl,传参数给链接器,armcc的链接器armlink用--scatter=路径来指定分散加载文件。所以把armcc接进GN,本质上不是“填几个参数”,而是要重写整套工具链的command模板。这活儿绕不开,只能自己动手。
1.2 armcc命令行的“非主流”习惯
armcc是ARM编译器套件里的C编译器命令,和它配套的还有armcpp、armlink、armar、fromelf这些工具。armcc命令行选项的典型风格是双横线加长单词选项,和GCC的单横线短选项完全是两套体系。这里列一组对照关系,方便你理解两者差异有多大:
| 功能 | GCC风格 | armcc风格 |
|---|---|---|
| 指定CPU | -mcpu=cortex-m4 | --cpu=Cortex-M4 |
| 浮点ABI | -mfloat-abi=hard | --fpu=FPv4-SP |
| 生成依赖文件 | -MMD -MF xxx.d | --depend=xxx.d |
| C语言标准 | -std=c99 | --c99 |
| 编译+链接命令 | gcc foo.c -o foo.elf | armcc --arm foo.c --scatter=xxx.sct -o foo.axf |
最要命的是链接环节。armcc配套的链接器armlink根本不认GCC那种-Wl,--gc-sections的写法,它要求你用--scatter指定分散加载文件,用--entry指定入口函数,用--first指定第一个加载段。这些选项在GN的默认solink工具描述里完全没有对应字段,只能全部写进command字符串里。
所以在动手配置之前,先在心里建立这个认知:GN的toolchain机制只是一个命令生成框架,它本身并不绑定GCC,你需要做的是把armcc世界的命令格式完整翻译成GN能理解的tool描述。
2. 环境准备:先确认四件事
2.1 工具链组件清单
接armcc之前,先确认你手里的工具链是完整的。一个能正常工作的armcc工具链环境,至少要有这四个可执行文件:
armcc:C编译器,负责把.c编译成.oarmlink:链接器,负责把.o和.a链接成axf或elfarmar:归档工具,负责生成.a静态库fromelf:镜像转换工具,负责把axf/elf转成bin或hex
其中fromelf虽然不参与编译链接,但嵌入式项目最终要烧录到芯片里基本都要转一道bin,所以我也把它纳入工具链范畴。拿到工具链后,先分别在终端里验证一下版本和授权状态:
armcc --vsn armlink --vsn armar --vsn fromelf --vsn这一步不能省,尤其是armcc这种商用工具链,经常出现“命令能找到但授权无效”的情况。armcc遇到授权问题时会直接输出一段提示然后拒绝编译,如果你在GN配置阶段没验证,等到Ninja真正跑起来才暴露,排查起来会多绕一个弯。
2.2 确定CPU型号与apcs参数
armcc命令行里有两个参数是必须设对的,否则编译出的代码大概率跑不起来。第一个是--cpu,用来告诉编译器目标内核的具体型号,比如Cortex-M0、Cortex-M3、Cortex-M4、Cortex-M7或者Cortex-A53。注意大小写,armcc对这里的大小写是敏感的,cortex-m4会直接报错,必须写成Cortex-M4。
第二个是--apcs,全称是ARM Procedure Call Standard,也就是ARM过程调用标准。嵌入式裸机项目里最常用的是--apcs=interwork,这个选项让编译器生成的代码同时支持ARM和Thumb指令集之间的安全切换。如果你的内核只支持Thumb模式(比如Cortex-M0/M3/M4都是这样),那interwork基本是标配。这个参数直接写进toolchain的command里,不留给每个子目标单独传,防止有人粗心漏掉导致链接时出现奇怪的跳转错误。
3. 核心配置:在GN里把armcc工具链“翻译”过来
3.1 目录结构与顶层BUILDCONFIG.gn
我建议的项目结构是这样:
project_root/ ├── BUILD.gn # 顶层目标定义 ├── BUILDCONFIG.gn # 全局配置,定义默认toolchain和公共变量 ├── toolchain/ │ ├── BUILD.gn # 自定义armcc toolchain实现 │ └── armcc_config.gn # 公共config,集中管理编译选项 ├── src/ │ ├── BUILD.gn │ ├── main.c │ └── ... ... ├── scatter/ │ └── project.sct # 分散加载文件 └── out/ # 输出目录,由gn gen自动生成顶层BUILDCONFIG.gn的作用很关键,GN在每次执行构建配置时都会先读这个文件。我习惯把target_cpu相关的armcc参数全部放在这里,统一管理。比如你有一个基于Cortex-M4的项目,可以这样写:
# BUILDCONFIG.gn set_default_toolchain("//toolchain:armcc") armcc_cpu = "Cortex-M4" armcc_fpu = "FPv4-SP" armcc_apcs = "interwork"set_default_toolchain这一行最重要,它告诉GN:除非某个目标显式指定其它toolchain,否则全工程都用//toolchain:armcc这套工具链。变量命名不要用GN内置的target_cpu那种,因为GN基于x86 host的默认值会干扰你的判断,直接用自定义变量更干净。
3.2 toolchain/BUILD.gn完整实现
接下来是重头戏,在toolchain/BUILD.gn里定义armcc工具链。GN的工具链定义语法是通过toolchain()函数配合tool()子块来写的。每个tool()块里需要描述:命令模板、输出文件名规则、依赖文件规则、响应文件规则等。
这里给出一个能直接跑起来的完整写法,以ARM Compiler 5风格的armcc为例:
# toolchain/BUILD.gn import("//BUILDCONFIG.gn") toolchain("armcc") { tool("cc") { depfile = "{{output}}.d" command = "armcc --c99 --cpu=$armcc_cpu --fpu=$armcc_fpu --apcs=$armcc_apcs --depend={{output}}.d --depend_format=unix --depend_system=all {{defines}} {{include_dirs}} {{cflags}} -c {{source}} -o {{output}}" deps = "gcc" outputs = [ "{{source_out_dir}}/{{source_file_part}}.{{target_output_name}}.o" ] output_prefix = "" } tool("cxx") { depfile = "{{output}}.d" command = "armcc --cpp --c99 --cpu=$armcc_cpu --fpu=$armcc_fpu --apcs=$armcc_apcs --depend={{output}}.d --depend_format=unix --depend_system=all {{defines}} {{include_dirs}} {{cflags}} -c {{source}} -o {{output}}" deps = "gcc" outputs = [ "{{source_out_dir}}/{{source_file_part}}.{{target_output_name}}.o" ] output_prefix = "" } tool("alink") { command = "armar --create {{output}} {{inputs}}" outputs = [ "{{target_out_dir}}/{{target_output_name}}{{output_extension}}" ] output_prefix = "" default_output_extension = ".a" } tool("solink") { command = "armlink --cpu=$armcc_cpu --apcs=$armcc_apcs --scatter=//scatter/project.sct --entry=Reset_Handler --first=Reset_Handler {{inputs}} {{solibs}} --userlibpath {{target_out_dir}} -o {{output}}" outputs = [ "{{target_out_dir}}/{{target_output_name}}{{output_extension}}" ] output_prefix = "" default_output_extension = ".axf" } tool("stamp") { command = "touch {{output}}" outputs = [ "{{output}}" ] } tool("copy") { command = "cp {{source}} {{output}}" outputs = [ "{{output}}" ] } }有几个细节需要你特别注意。
第一,depfile和--depend是配套的。armcc生成依赖文件用的是--depend选项,和GCC的-MMD参数不同,但生成的.d文件内容格式是相似的。GN的depfile字段告诉Ninja去读这个文件里的头文件依赖关系,这样才能实现“改个头文件就重编相关.c文件”的增量构建。--depend_format=unix是故意加的,armcc在所有平台下默认可能生成带盘符或反斜杠的路径,GN对Windows路径的解析有问题,强制转成unix格式能省掉很多麻烦。
第二,deps = "gcc"这个字段不是指真用了GCC,而是告诉GN:这个tool生成的是GCC风格的依赖文件格式。GN目前支持gcc和msvc两种依赖格式解析,armcc生成的.d文件和GCC基本一致,所以选gcc就行。
第三,链接命令里我把--entry=Reset_Handler --first=Reset_Handler写死了。这是嵌入式项目里很常见的做法,让Reset_Handler成为整个镜像的入口,并且放到第一个段。如果你的工程入口函数名字不同,记得改掉。
第四,output_extension设成了.axf,这是armlink默认的可执行文件后缀。你也可以用--output等选项改成.elf,但既然工具链默认是axf,就直接沿用。
3.3 依赖文件与输出后缀的适配
实际配置过程中最容易被忽略的就是输出文件后缀的适配。GN内部对目标类型有“预期”——编译C源文件时,它期望工具生成一个.o文件;收集静态库时,它期望工具生成一个.a文件;链接可执行文件时,它期望生成一个无后缀名或者自定义后缀的文件。你在tool("alink")里设置了default_output_extension = ".a",GN才能把static_library目标正确绑定到这个工具上。
armcc编译生成的对象文件默认后缀是.o,这点和GCC一致,所以cc工具的输出模板不需要额外改后缀。但armlink生成的默认文件是.axf,这是最大的差异点。如果你在solink里不写default_output_extension = ".axf",GN会用默认的空后缀,到时候生成的文件名就会变成app而不是app.axf,本身不算大问题,但后续用fromelf处理时会容易混淆。
依赖文件适配方面,armcc的--depend生成的.d文件里,目标文件名那行是用tab缩进的,比如这样:
main.o: main.c header.hNinja对依赖文件的解析还算宽容,只要源文件路径和目标文件路径能对上就行。但armcc在Windows环境下如果不加--depend_format=unix,生成的路径里会出现反斜杠,Ninja会把反斜杠当成转义符,轻则依赖解析失败,重则直接报错中断构建。所以那个--depend_format=unix务必保留。
4. 实操过程:把配置跑起来
4.1 最小工程:编译一个C文件生成可执行文件
理论说完,直接来一个最小可复现的工程。假设源码只有一个main.c,内容大致是:
// main.c #include "platform.h" int main(void) { init_clock(); init_uart(); while (1) { send_char('A'); } }对应根目录的BUILD.gn:
# BUILD.gn executable("app") { sources = [ "src/main.c" ] include_dirs = [ "src", "include" ] }执行构建:
gn gen out ninja -C out第一次构建时,GN会读取BUILDCONFIG.gn,加载//toolchain:armcc,然后生成对应的Ninja文件。Ninja执行时会依次调用:
armcc --c99 --cpu=Cortex-M4 --fpu=FPv4-SP --apcs=interwork --depend=... --depend_format=unix --depend_system=all -Isrc -Iinclude -c src/main.c -o out/src/main.o armlink --cpu=Cortex-M4 --apcs=interwork --scatter=//scatter/project.sct --entry=Reset_Handler --first=Reset_Handler out/src/main.o --userlibpath out -o out/app.axf构建产物里会多出一个out/app.axf。用fromelf把它转成bin:
fromelf --bin --output=out/app.bin out/app.axf拿到app.bin就可以直接烧录了。
4.2 多文件与静态库工程
真实项目里不太可能只有一个C文件,必然要拆模块、建静态库。GN里静态库目标用static_library表示,注意这个关键字对应到toolchain里的alink工具,而不是cc。比如工程里有驱动模块和主应用模块:
# src/driver/BUILD.gn static_library("driver") { sources = [ "uart.c", "clock.c", "gpio.c", ] include_dirs = [ "." ] }# src/app/BUILD.gn executable("app") { sources = [ "main.c" ] deps = [ "//src/driver:driver" ] include_dirs = [ "//src/driver" ] }这种情况下,GN会先生成out/src/driver/libdriver.a,然后再链接app时把libdriver.a传给armlink。armlink收到.a文件后,只会抽取其中被引用到的模块,保证最终固件体积不会因为链接了整个库而膨胀。
这里有一个GN的隐晦规则:static_library目标生成的文件名默认会带上lib前缀,也就是说driver会变成libdriver.a。如果你后续在链接命令里手动指定了库路径,要记得用libdriver.a这个名字,不要习惯性地写driver.a。armlink对这个倒是宽容,用--userlibpath指定路径后,它会自己去找libdriver.a。
不过--userlibpath只负责告诉链接器“去哪个目录找库”,具体的库文件名还是要出现在输入列表里。GN在链接时会自动把依赖的静态库加到{{inputs}}里,所以上面的示例里虽然命令没直接写libdriver.a,实际执行时armlink会收到完整的输入列表。
5. 常见问题与排查技巧实录
5.1 高频报错速查表
我总结了这一套配置过程中最常见的几个坑,按出现频率从高到低排了个表。看到报错先对照一下,大概率能省下半小时:
| 报错关键词 | 常见原因 | 解决思路 |
|---|---|---|
unknown option/unsupported option | GN默认的cflags或defines传进了armcc | 在目标的configs里清掉默认config,改用你自定义的armcc config |
could not open scatter file | 分散加载文件路径不对,或者被GN当作文件而非路径处理 | 检查--scatter=后面的路径,确保用的是GN能找到的真实路径 |
Missing entry point | 链接命令没有指定--entry或--first | 确认入口函数符号名字,并显式加--entry=xxx --first=xxx |
depend file ... not found | --depend参数拼写有误,或者路径里的反斜杠造成解析失败 | 加--depend_format=unix,并检查depfile路径是否与{{output}}.d一致 |
unknown option: --cpp | armcc的C++编译命令不是armcc --cpp,或者工具链版本太老 | 确认工具链是否支持C++;不支持就改用只含C代码的工程验证 |
failed to add library ... | 静态库后缀不是.a,或armlink找不到libxxx.a | 检查alink的default_output_extension,确认输出目录里有.a文件 |
5.2 我在这个配置里反复踩的四个坑
第一个坑和GN的默认config有关。我在最小工程里犯的第一个错误是忘记清掉默认config。GN对每个目标默认注入一套config,里面带着一堆适用于GCC/Clang的编译选项,比如-fno-omit-frame-pointer、-fno-ident、-Wextra之类的。armcc看到这些直接报警,编译出来的东西也不对劲。解决方法是给每个目标或每个目录统一设置:
config("armcc_default_config") { cflags = [ "--c99", "-Ospace", ] defines = [ "USE_ARMCC" ] } set_defaults("static_library") { configs = [ "//toolchain:armcc_default_config" ] } set_defaults("executable") { configs = [ "//toolchain:armcc_default_config" ] }第二个坑是路径分隔符。我在Windows主机上配置时,armcc生成的.d文件里目标名带了盘符和反斜杠,Ninja解析依赖关系时直接报“missing and no known rule to make it”。这个问题的根子不在GN,而在armcc的--depend_format设置。加入--depend_format=unix之后,问题立刻消失。如果是在Linux主机上开发,这步可能遇不到,但建议无论什么平台都写上,统一行为。
第三个坑是静态库后缀。我曾经有一版配置里把alink的default_output_extension写成了.o,结果链接阶段armlink收到的输入列表里出现了一个怪异的libdriver.o,它完全懵了,报了一堆failed to add library错误。排查的时候我一度以为是链接命令少了什么参数,最后发现是后缀名不匹配,GN按.o去命名静态库文件,armlink按.a去寻找库。把后缀改回.a后一切正常。
第四个坑是入口函数匹配。armcc和armlink默认生成的镜像入口并不是Reset_Handler,它其实是工具链运行时库里的一个符号。如果你的工程没有显式指定--entry,链接器会采用运行库默认入口,这可能导致最后烧进芯片的程序不从复位向量开始执行,看起来就像“固件没跑起来”。我后来在solink命令里固定加--entry=Reset_Handler --first=Reset_Handler,并且要求工程里的启动文件必须导出这个符号,问题才算彻底解决。
补充一个针对新一代工具链的提醒。如果你拿到的是新版工具链,实际编译器命令可能不叫armcc而叫armclang,参数风格也从双横线长选项变成了类似GCC的单横线选项。armclang的--cpu要换成-mcpu,--apcs要换成-mthumb -mfloat-abi=hard这类。这属于Arm Compiler 6的世界,和本文的armcc不完全是一套写法,但自定义toolchain的思路完全一致,你只需要把命令模板里的选项替换成armclang对应的写法就行。
最后再分享一个我自己的体会。折腾这套配置最有效率的路径不是一上来就搞全工程,而是先用一个最简单的单文件工程把cc和solink两个工具打通,确认能生成axf和bin,再逐步加入静态库、多目录依赖、分散加载这些环节。每一步都验证通过了再往下走,出问题时的排查范围会小很多。GN这个系统的好处是配置逻辑非常清晰,一旦你把toolchain翻译正确,后续所有目标的构建都自动走在同一条路径上,稳定性比手写Makefile高太多。现在这个项目里,我要换芯片型号只需要改一下BUILDCONFIG.gn里的armcc_cpu变量,然后重新gn gen,所有目标都会自动用新的CPU参数重新编译,这才是把构建配置做成“基础设施”该有的样子。