
1. 为什么要在 GN 里配置 armcc 工具链最近在给一个 MCU 项目重写构建系统。原来的 Makefile 写了一千多行加一个源文件要改好几处增量编译偶尔还会漏掉头文件依赖。实在忍不了我决定换成 GN 加 Ninja 的组合。麻烦的是这个项目用的编译器是 armcc 工具链也就是 ARM Compiler 5 那一代的经典编译器不是 GCC也不是后来主推的 armclang。GN 本身不是一个编译器它只负责根据 BUILD.gn 生成 Ninja 文件。真正决定“用什么编译器、传什么参数、生成什么后缀文件”的是 GN 里的 toolchain 机制。armcc 工具链在 GN 里没有现成模板所以需要自己把编译器、汇编器、归档器、链接器的调用方式逐一描述清楚。这件事比想象中麻烦因为 GN 的 toolchain 语法有自己的规则armcc 的命令行参数又和 GCC 差得很远两边都不熟的时候报错能把人看花眼。这篇文章适合遇到类似场景的人手里有老嵌入式工程源代码依赖 ARMCC 5 的编译行为暂时没法切换到 GCC 或 armclang又希望构建系统更可靠、更好维护。读完你会知道 GN 怎么描述外部工具链armcc 的编译参数怎么接进 GN以及哪些坑我已经替你踩过了。1.1 为什么 MV 项目还在用 armcc很多人一听到 armcc 就会问都新时代了为什么不用 armclang我也问过自己这个问题。实际情况是这个项目里有大量历史代码它们深度依赖 ARMCC 5 的关键字和编译细节。比如__irq、__forceinline、__align这类语法在 GCC 和 armclang 下都要改写。老代码里还有一些第三方库厂商默认编译脚本就是为 armcc 写的改编译器意味着要把整个启动文件、库函数、链接脚本都重新验证一遍。换句话说换编译器不是改一个编译器名字那么简单它牵扯到整个代码生态。短期最务实的方案是把 armcc 工具链接进 GN让新构建系统能统一管理编译流程后续再慢慢评估迁移成本。1.2 GN 和 Makefile 的差别在哪里Makefile 最大的问题是规则太自由。每个变量的展开时机、隐式规则、模式规则都要自己维护文件一多很容易出现“改了头文件编译产物却没更新”的情况。GN 加 Ninja 的思路刚好反过来GN 负责生成一份完整的、没有歧义的 Ninja 文件Ninja 只做一件事就是根据依赖关系执行命令。GN 里没有“隐式规则”每个工具链的每个步骤都要显式声明输入是什么、输出是什么、命令是什么。这看起来啰嗦但正是这种啰嗦让构建变得可靠。配置 armcc 工具链时我们其实是把 Makefile 里零散的armcc -c ...、armlink ...命令翻译成 GN 能识别的tool(cc)、tool(link)等结构。翻译完之后增量编译、并发编译、依赖跟踪都由 Ninja 接管。2. GN 工具链机制先搞明白这 4 个概念在写任何 BUILD.gn 之前先别急着抄命令。GN 的工具链机制有几个核心概念不搞明白的话后面报错你会完全看不懂。2.1 toolchain 与 tool 的关系toolchain()在 GN 里定义了一套完整工具的集合它代表“用这套工具链构建一个目标时所有可能的操作”。tool()则是集合里的单个工具每个工具对应一个编译动作。对 armcc 工具链来说我们至少需要这些工具GN 工具名对应 armcc 生态工具职责ccarmcc编译 C 文件cxxarmcc --cpp编译 C 文件asmarmasm编译汇编文件alinkarmar打静态库linkarmlink链接可执行文件或镜像stamptouch一类命令GN 内部聚合目标用copycp一类命令GN 内部复制文件用每个tool()都必须写清楚三个东西command、outputs以及按需的rspfile、description等属性。GN 生成 Ninja 文件时会把这些模板展开成具体命令。2.2 toolchain_args切换工具链时的“参数快照”GN 允许同一个 BUILD.gn 被多个工具链复用。默认情况下一个 target 属于当前默认工具链但你可以用显式标签把它切到另一套工具链。比如deps [ //app:firmware(//build/toolchain/armcc:armcc) ]这里(//build/toolchain/armcc:armcc)就是指定用 armcc 工具链去构建//app:firmware。toolchain_args的作用是在切换工具链时给整个构建图重新传一组 build argument。比如 armcc 工具链希望target_cpu arm、target_os none就把这些写进toolchain_args。这样 GN 会认为“换了一套工具链 换了一套构建参数”从而生成不同的root_out_dir避免和默认工具链的产物混在一起。2.3 output_extension 与 rspfile输出命名和长命令行GCC 生成的最终产物通常是 ELF但 armcc 工具链里链接器armlink的默认输出是.axf格式本质上是一种带调试信息的 ELF 变体。如果想生成app.axf而不是默认的app就需要在tool(link)里设置default_output_extension axf。同理静态库用output_prefix lib加default_output_extension a就能得到libfoo.a。rspfile是另一个容易忽略的点。嵌入式工程的源文件多起来之后传给链接器的目标文件列表会非常长Windows 命令行上限又特别小。armcc 工具链支持--via参数可以把一堆参数写进文件再传给编译器或链接器。GN 里对应rspfile和rspfile_content生成 Ninja 文件时会把过长的部分写进.rsp文件避免命令行超限。2.4 为什么 armcc 的调用方式和 GCC 不一样很多人第一次配 armcc 会下意识按 GCC 的思维来结果就是到处碰壁。GCC 是一个“驱动式”编译器一个gcc命令可以完成预处理、编译、汇编、链接全过程。armcc 工具链则是明确分工的armcc只负责编译 C/C 到目标文件armasm负责汇编armar负责归档静态库armlink负责链接fromelf负责把链接产物转换成 bin、hex 等烧录格式。所以在 GN 里这些程序要各自对应各自的tool()不能在tool(link)里顺手让armcc去链接。理解了这一点后面配置才不会手忙脚乱。3. 一步一步配置 armcc 工具链下面给出一个能跑通的最小配置。假设工程目录结构是这样的project/ ├── BUILD.gn ├── args.gn ├── build/ │ └── toolchain/ │ └── armcc/ │ ├── BUILD.gn │ └── armcc.gni └── app/ ├── BUILD.gn └── src/ ├── main.c └── startup.s3.1 先写 toolchain 定义文件在build/toolchain/armcc/BUILD.gn里定义工具链。第一步不要追求完整先把 cc、link、stamp、copy 这几个必要的工具跑通再慢慢补其他工具。toolchain(armcc) { tool(cc) { command armcc -c --cpuCortex-M4 {{defines}} {{include_dirs}} {{cflags}} -o {{output}} {{source}} outputs [ {{output}} ] description armcc compile {{source}} } tool(cxx) { command armcc --cpp -c --cpuCortex-M4 {{defines}} {{include_dirs}} {{cflags}} -o {{output}} {{source}} outputs [ {{output}} ] description armcc compile {{source}} } tool(asm) { command armasm -o {{output}} {{source}} outputs [ {{output}} ] description armasm assemble {{source}} } tool(alink) { command armar --create {{output}} {{inputs}} outputs [ {{output}} ] output_prefix lib default_output_extension a description armar archive {{output}} } tool(link) { command armlink {{ldflags}} -o {{output}} --via {{rspfile}} outputs [ {{output}} ] default_output_extension axf rspfile {{output}}.rsp rspfile_content {{inputs}} description armlink link {{output}} } tool(stamp) { command touch {{output}} outputs [ {{output}} ] } tool(copy) { command cp -f {{source}} {{output}} outputs [ {{output}} ] } toolchain_args { target_os none target_cpu arm } }这里我把--cpuCortex-M4直接写进了命令实际项目里不建议这么做更好的是放到 config 里统一管理后面第 4 节会细说。注意tool(asm)里的{{source}}会被 GN 替换成汇编文件路径。如果汇编文件里还依赖预定义宏需要在 command 里加上对应的-D或--predefine否则可能编译出一堆莫名其妙的语法错误。3.2 指定默认工具链在工程根目录的args.gn里写上set_default_toolchain(//build/toolchain/armcc:armcc) target_os none target_cpu arm这样整个工程默认就使用 armcc 工具链。如果你不想全局替换默认工具链也可以在根 BUILD.gn 里用显式标签切到 armccgroup(default) { deps [ //app:firmware(//build/toolchain/armcc:armcc), ] }这两种方式都能达到目的。前者配置简单适合单工具链工程后者灵活适合同一份代码同时支持 GCC 和 armcc 的工程。3.3 定义一个可执行目标在app/BUILD.gn里写import(//build/toolchain/armcc/armcc.gni) executable(firmware) { sources [ src/main.c, src/startup.s, ] }executable()是 GN 内置目标它会使用当前工具链的link工具。因为工具链里设置了default_output_extension axf所以最终产物是firmware.axf。然后执行gn gen out/armcc ninja -C out/armcc如果一切顺利你应该能在out/armcc/app/下看到firmware.axf。如果这一步能跑通说明工具链骨架已经搭起来了。接下来要做的是把编译参数、链接脚本、格式转换这些细节补上。3.4 工具链里的隐藏依赖有个细节新手很容易漏tool(cc)等工具里可以写deps用来声明这个工具本身依赖的文件。比如编译器路径、库文件路径变了需要触发重新构建时可以用它。大多数情况下不需要写但如果你在args.gn里动态切换编译器版本建议把对应的armcc路径加进deps否则 Ninja 感知不到编译器更新。另外toolchain()定义里没有写default_output_dir时GN 会按 target 所在 BUILD.gn 的目录结构生成产物目录。像//app:firmware的产物会落到$root_out_dir/app/这是正常行为别到处找。4. 编译参数与链接脚本的联动设置工具链骨架跑通之后真正的嵌入式工程还是要面对编译参数和链接脚本。ARMCC 5 的参数有不少和 GCC 长得完全不一样我第一次接的时候也踩了不少坑。4.1 armcc 的 CPU、FPU、优化参数怎么接进 GNARMCC 5 用--cpu指定核心用--fpu指定浮点单元优化选项是-Ospace或-Otime而不是 GCC 的-Os、-O2。这些参数不适合散落在各个 target 里应该统一收进一个 config。在build/toolchain/armcc/armcc.gni里可以定义config(cortex_m4) { cflags [ --cpuCortex-M4, --apcsinterwork, --split_sections, -Ospace, --diag_warning4, ] defines [ __FPU_PRESENT1, ARM_MATH_CM4, ] include_dirs [ //third_party/cmsis/include, ] }然后在 target 里引用executable(firmware) { sources [ src/main.c, src/startup.s, ] configs [ //build/toolchain/armcc:compiler_config ] }config的好处是可以在不同芯片型号间做切换。如果你的工程同时支持多个型号可以把--cpu和--fpu提成 GN 变量比如declare_args() { arm_cpu Cortex-M4 arm_fpu FPv4-SP }然后在 config 里写成cflags [ --cpu arm_cpu, --fpu arm_fpu, ]这样换芯片时只需要改构建参数不用翻 BUILD.gn。4.2 链接脚本与 scatter 文件在 GN 里的传递方式ARMCC 的链接脚本通常叫 scatter 文件扩展名是.scat。它不像 GCC 的.ld文件那样放在命令行末尾而是通过--scatter参数传给armlink。scatter 文件本身是一个源码树里的文件链接时需要在输出目录里能访问到。推荐用 GN 的copy工具把它复制到$root_out_dir再通过ldflags引用copy(scatter_copy) { sources [ //app/scatter/app.scat ] outputs [ $root_out_dir/scatter/app.scat ] } executable(firmware) { deps [ :scatter_copy ] ldflags [ --entryReset_Handler, --scatter$root_out_dir/scatter/app.scat, --map, ] }为什么不用直接写//app/scatter/app.scat的绝对路径因为armlink的工作目录通常是$root_build_dir直接给一个源码树路径虽然有时候能用但路径里一旦有空格或特殊字符就很容易出问题。统一复制到$root_out_dir可以让 Ninja 清楚地把 copy 步骤作为链接的前置依赖scatter 文件变了链接就会重跑。4.3 生成 bin / hex 产物的几种实现方式链接出来的.axf一般不能直接烧录还需要fromelf转成.bin或.hex。在 GN 里有几种做法我最推荐的是写一个action来做转换。先准备一个脚本fromelf.py#!/usr/bin/env python3 import subprocess import sys src, dst sys.argv[1], sys.argv[2] subprocess.check_call([ fromelf, --bin, f--output{dst}, src, ])然后在 BUILD.gn 里加action(firmware_bin) { deps [ :firmware ] script //build/toolchain/armcc/fromelf.py inputs get_target_outputs(:firmware) outputs [ $root_out_dir/firmware.bin ] args [ inputs[0], $root_out_dir/firmware.bin ] }这里用get_target_outputs(:firmware)拿到firmware.axf的真实路径再作为 action 的输入。Ninja 会因此理解先链接firmware.axf再执行 fromelf最后生成firmware.bin。注意不要在tool(link)的 command 里直接追加fromelf。GN 对多输出工具的支持比较绕一旦链接和格式转换绑定到同一个 tool 里很容易出现“产物存在但 Ninja 认为没更新”的怪问题。拆成一个独立 action 是最省心的做法。4.4 全局 include 路径与宏定义的管理嵌入式工程里CMSIS、芯片头文件、驱动库的 include 路径通常是全工程共享的。把这些路径写进每个 target 会很难维护。GN 提供default_configs机制可以在BUILDCONFIG.gn或顶层 BUILD.gn 里给目标类型设置默认配置。不过默认配置的优先级和覆盖规则比较复杂新手容易踩坑。更简单的方法是统一建一个compiler_config然后显式合并到每个 target 的configs里。如果你的 target 很多还可以用模板封装一层比如自定义一个firmware_target()模板内部自动加上编译器配置这样调用方只需要写源文件和链接脚本即可。5. 踩坑实录与错误速查表配置过程中我记录的坑不算少这里挑几个最有代表性的整理成速查表。现象常见原因排查思路命令行执行armcc时提示 command not found环境变量 PATH 没有包含 armcc 所在目录确认工具链路径在args.gn或系统环境里加入 PATHGN 报错toolchain 找不到set_default_toolchain里的 label 写错检查 label 路径和 BUILD.gn 的实际位置armcc报无法打开头文件include 路径没有传进编译命令先用ninja -v看真实命令确认{{include_dirs}}是否展开armlink报 undefined symbol初始化和标准库相关符号缺失检查启动文件是否参与链接确认是否声明__use_no_semihostingNinja 报firmware.bin缺少生成规则action 的 deps 或 inputs 没有正确关联用get_target_outputs或显式 deps 挂到 executable 上Windows 下链接超长命令行报错链接参数太多超过系统限制确认rspfile配置已生效armlink使用--via编译产物路径不对文件“消失”不熟悉 GN 的$root_out_dir规则到对应工具链的 out 目录下看结构而不是全局搜索5.1 先用ninja -v看真实命令遇到任何编译错误第一步永远不是改 BUILD.gn而是看 Ninja 实际执行了什么命令。执行ninja -C out/armcc -v-v会让 Ninja 打印完整命令。这时候你能看到{{defines}}、{{include_dirs}}、{{cflags}}到底展开了没有参数顺序是什么。我见过很多人绕了半天最后发现只是include_dirs没进命令。看到真实命令后建议手动复制到终端里跑一遍。armcc 的报错比 Ninja 的报错直接得多单独跑一次能快速定位是参数问题还是源码问题。5.2 semihosting 问题的处理ARMCC 5 在裸机环境下经常遇到一个经典问题程序里用了标准库的某些函数链接时自动引入 semihosting 实现结果armlink报出一堆 undefined symbol或者明明编译过了运行时却卡在调试器的 semihosting 请求上。常见处理是在源码里加上#pragma import(__use_no_semihosting)并在需要时提供底层_sys_exit、_ttywrch之类的桩实现。这个和 GN 没有直接关系但它会在你的工具链配置完成之后突然冒出来所以提前知道比较好。5.3 rspfile 配置失效的排查如果你的链接命令还是直接展开成超长字符串而不是生成.rsp文件多半是rspfile_content没有包含真正超长的部分。GN 只有在rspfile和rspfile_content都设置时才会启用 response file。我建议把rspfile_content写成rspfile_content {{inputs}} {{ldflags}}这样链接时所有目标文件和链接参数都会收进 via 文件。注意--scatter这类带等号的参数最好放在command里放不进 via 文件的特殊字符反而容易让armlink解析出错。5.4 多工具链并存时的产物隔离如果工程里同时有 GCC 和 armcc 两套工具链一定要保证它们不能共用同一个$root_out_dir。GN 在切换工具链时会自动把不同工具链的产物放到不同子目录但这要求每个工具链的toolchain_args至少要有一个参数不同否则 GN 会认为它们等价。最简单的做法是在toolchain_args里加一个只有自己用的参数比如toolchain_args { target_os none target_cpu arm use_armcc true }并在另外一套工具链里设use_armcc false。这样两个工具链的产物目录天然隔离不会出现一个覆盖另一个的问题。6. 配置完之后的几条实操心得回过头看这次配置最有价值的一步不是写工具链定义本身而是先把最小目标跑通。armcc 工具链涉及的东西太多编译器、汇编器、链接器、格式转换、链接脚本、启动文件任何一个环节断了都会连锁报错。如果一开始就把所有参数堆进去出了问题根本不知道从哪查。我当时是先用一个只编译main.c的目标把 cc 和 link 跑通再加汇编文件再加 scatter最后再加 fromelf每一步都确认 Ninja 依赖正确后才继续。另外我强烈建议把工具链参数和业务 target 分开管理。CPU 型号、FPU、优化级别、diag 等级这些属于工具链策略散落在 target 里只会让后续维护变得痛苦。把它们收敛到config里以后换一颗芯片可能就是改一个args.gn参数的事而不是全局搜索替换编译选项。最后说一个长期建议armcc 5 毕竟是老一代编译器对新器件和新语言标准的支持有限。如果团队后续有新项目真的要尽早做 armclang 迁移评估。但在迁移完成之前把 armcc 工具链在 GN 里配置好至少能让老项目的构建不再是一本糊涂账也算为后续改造铺了一条能走的路。