我第一次看到“LLVM Embedded Toolchain for Arm”这个名字,是在 Arm 官方工具链的下载列表里,和一堆面向嵌入式场景的预编译包摆在一起。当时团队正好在内部争论后续项目是继续沿用 GCC 交叉编译链,还是切到 LLVM 这套新工具链。争论到一半,我发现两边拿出的依据基本都是“网上说”和“同事觉得”,根本没有一份能落到纸面的评测证据。所以我决定不直接去下载预编译包,而是把源码拉下来,从模块划分到构建与测试,做一次能够在本地复现的源码静态评测。
这篇内容不是一篇“LLVM真棒”的软文,也不是把官方文档抄一遍的编译笔记。我想记录的是自己真正动手去读源码、配构建、跑测试的过程:怎么看懂工具链的模块边界,怎么在全新环境里把它从源码编译出来,怎么留下可以回溯的测试证据,以及最后从源码结构里看到的几个值得优化的地方。如果你也想评估一套工具链到底能不能进入生产流程,而不是停留在“装好能用”的层面,这篇应该能给你一些可参考的路线。
1. 为什么放着现成预编译包不用,非要自己拉源码评测
1.1 预编译包解决不了的问题
预编译包最大的优势就是省事,解压之后bin/clang就能用,但这恰恰也是它最大的问题:你拿到的是一个已经配置好的黑盒。具体用了什么 CMake 开关编译出来的,默认打开了哪几个目标后端,运行时库的交叉配置是怎样的,这些信息在预编译包里几乎看不出来。对于只在命令行下调程序的场景,这些信息可以不知道;但对于要长期维护构建脚本、需要在不同目标板上复用同一套工具链的团队,这些隐藏的配置就是风险的来源。
第二个问题是可追溯性差。预编译包对应的是某个源码 commit,但发布页通常不会把源码版本、补丁列表、构建参数全部留档。一旦遇到代码生成异常,你想定位是 LLVM 上游问题、本地补丁问题,还是自己传参没传对,没有完整源码链路就很难判断。源码静态评测的价值就在这里:把这条链路彻底打开。
第三个问题,也是我比较介意的,是预编译包里试不出“定制边界”。嵌入式项目经常需要裁剪工具链,比如不要 Mach-O 后端、不要 libc++ 里用不到的部分、或者把编译器自带头文件路径改成固定 sysroot。这些操作都需要从 CMake 配置层面去干预。只拿到二进制包,你根本不知道哪些开关能调,哪些开关互相影响。
1.2 我理解的源码静态评测标准
先说清楚,这里说的“静态评测”不是指用静态分析工具去扫漏洞,也不是把源码通读一遍然后写观后感。我的标准是三件事:
- 模块划分是否清晰。源码目录、库依赖、组件边界是否能支撑后续的维护和裁剪。
- 构建是否能复现。从一个干净环境出发,只凭仓库里的源码和文档,能不能稳定地构建出可用的工具链。
- 测试是否能闭环。构建完成后,如何通过官方测试套件、交叉目标运行、汇编输出核验等维度,形成一套可保存的证据。
沿着这三条线去评测,最后得到的结论不只是“能用”或“好用”,而是“为什么能用”和“在什么条件下好用”。这也是我后面所有工作的主线。
2. 源码地图:从目录树看懂 LLVM Embedded Toolchain for Arm 的模块边界
2.1 核心组件拆解:编译器、链接器与运行时库
拉下源码之后,第一件事不是急着配置构建,而是先把目录结构逛一遍。LLVM Embedded Toolchain for Arm 本质上是基于上游 LLVM 项目组装起来的,所以你会看到llvm-project下按子项目划分的第一层结构:
llvm-project/ ├── llvm/ # LLVM 核心库、优化器、目标后端 ├── clang/ # C/C++ 编译器前端 ├── clang-tools-extra/ # clang-tidy、clang-format 等 ├── lld/ # 链接器 ├── compiler-rt/ # 编译器运行时库 ├── libcxx/ # C++ 标准库实现 ├── libcxxabi/ # C++ ABI 支持 ├── libunwind/ # 栈展开与异常传播 ├── cmake/ # 顶层 CMake 工具链 ├── third-party/ # 第三方依赖 └── runtimes/ # 运行时库构建入口如果只做 Arm 嵌入式开发,重点不需要放在 clang-tools-extra 上,关键在于下面几个模块的配合。我把它们拉了一张表,方便对照职责:
| 模块 | 在工具链里的职责 | 嵌入式场景的关注点 |
|---|---|---|
llvm | 中端优化、目标无关的 IR 处理、指令选择基础设施 | 优化管线是否满足裸机开发;是否能按目标裁剪 |
clang | C/C++ 前端,负责语法解析、语义分析、生成 LLVM IR | ARM/AArch64 ABI 处理、内建函数、-mcpu家族支持 |
lld | ELF/Mach-O/COFF 链接器 | ELF 端口的--gc-sections、-T链接脚本、map 文件支持 |
compiler-rt | 编译器生成代码所需的运行时支持库 | builtins 里 ARM 软浮点、除法辅助函数__aeabi_* |
libcxx/libcxxabi/libunwind | C++ 标准库、ABI 层、栈展开 | 裸机 C++ 异常会不会依赖宿主环境 |
llvm-objcopy等工具 | 镜像处理、符号操作 | 生成裸机镜像时必要的 binutils 替代品 |
静态评测第一印象来自职责边界:编译器、链接器、运行时库被拆在不同目录里,互相之间的构建依赖基本是单向的。这样的划分意味着你可以单独构建clang和lld,也可以把compiler-rt踢出去,完全按照目标场景重新组合。对一个嵌入式工具链来说,这种自由度非常关键。
2.2 TableGen 驱动的后端结构与模块依赖关系
在llvm目录下,lib/Target/ARM和lib/Target/AArch64是两个独立的目录。很多人会误以为 64 位 AArch64 只是 32 位 ARM 的扩展,所以后端也会共享大部分代码,但实际上它们在 LLVM 里是两个完全独立的后端。ARMFrameLowering、ARMISelLowering、ARMSubtarget 这些文件在 AArch64 目录里都有对应的副本,只是实现完全不同。两个后端之间确实存在部分抽象复用,比如都依赖 TableGen 生成的目标描述文件,但寄存器定义、指令选择模式、调度模型各自维护。
TableGen 是 LLVM 后端很特别的机制。它把目标描述文件(.td)在构建时转换成 C++ 头文件或源文件,比如:
ARM.td ARMScheduleA57.td ARMRegisterInfo.td ARMInstrThumb2.td这些.td文件描述了寄存器、指令编码、指令选择规则等,构建阶段会生成一堆ARMGenRegisterInfo.inc、ARMGenMCCodeEmitter.inc之类的文件。从源码静态评测的角度看,这种设计意味着大量“数据”和“逻辑”分离了:你想了解某个指令的编码规则,去查.td文件比翻 C++ 代码要直观得多;想改一份指令选择逻辑,通常也只需要集中改.td规则。
模块依赖关系方面,我通过梳理 CMake 里target_link_libraries的关系,大致得到下面的流向:
LLVM core libraries (llvm/lib) +---> clang/lib (前端,依赖核心 IR 与目标描述) | +---> lld/lib (解析 ELF 与调试信息,链接时依赖 MC 相关库) | +---> compiler-rt (独立运行时源码,不依赖 LLVM 库)compiler-rt比较特殊,它的 builtins 源码是直接给目标架构编译的,不依赖 LLVM 静态库;libcxx和libcxxabi则是依赖libunwind来提供异常栈展开。这种依赖方向对于裁剪非常有利,你可以像搭积木一样,只保留需要的层。
2.3 代码规模与模块耦合度的观察
只看目录结构还不能完全证明模块划分质量,所以我又做了一点粗糙的规模统计,用find加上文件数量统计,不算软件度量,但足以说明问题:
# 统计 ARM 后端 C++ 文件数量 find llvm-project/llvm/lib/Target/ARM -name '*.cpp' | wc -l # 统计 AArch64 后端文件数量 find llvm-project/llvm/lib/Target/AArch64 -name '*.cpp' | wc -l # 统计 clang 的 Driver 模块文件数量 find llvm-project/clang/lib/Driver -name '*.cpp' | wc -l在我检出的release/18.x分支上,ARM 和 AArch64 两个后端的 C++ 实现文件规模都在数百量级,这还不算.td文件。这个量级说明两个后端都是成熟的实现,不是简单的“make 一下就能砍掉一半代码”的规模。如果你要定制,只能在保持 TableGen 驱动结构的前提下,在子目标层面做裁剪,比如用ARMSubtarget里的特性开关来禁用某些 CPU 型号,而不是从源文件层面删代码。
耦合度方面,我注意到clang/lib/Basic/Targets里为 ARM 准备了ARMTargetInfo.cpp,但实际的 ABI 和 CodeGen 参数大量集中在clang/lib/Driver/ToolChain/ARM.cpp里。也就是说,前端“头文件/宏”层面的信息和驱动“命令行动作”层面的信息有明显分层。这样做很合理:修改编译器默认的 Float ABI,通常只需要动 Driver 和 TargetInfo 两处,不会波及 CodeGen 下游。这个观察在后面构建环节中也得到了验证,clang --target=arm-none-eabi -mfloat-abi=hard和-mfloat-abi=soft产生完全不同但可预期的汇编输出。
3. 构建环节的完整记录:从 CMake 配置到 Ninja 产出
3.1 环境准备与版本选择
我这次构建用的是一台 x86_64 的虚拟机,操作系统是 Ubuntu 24.04,给到 16 核和 32GB 内存,磁盘预留了 120GB。LLVM 的构建对磁盘和内存都不算温柔,尤其是链接clang可执行文件时,内存不够很容易 OOM。如果你的机器内存小于 16GB,后续链接阶段记得降低并行度。
版本选择上,我没有用 Arm 发布页的二进制包,而是直接拉取 GitHub 上llvm-project的release/18.x分支。原因是这个分支基本对应 LLVM Embedded Toolchain for Arm 底层的 LLVM 版本,同时也能让我锁定编译器源码和运行时源码的对应关系。克隆仓库时我加了--depth 1,只取当前快照,避免把整个历史拉下来占用空间。
需要说明的是,LLVM Embedded Toolchain for Arm并不是一个独立的分叉,它仍然基于上游 LLVM,只是在组件组合和预配置上做了嵌入式场景的收敛。理解这一点后,评测主体就变成了整个llvm-project,这也让结果对更多使用上游 LLVM 的团队有参考价值。
开始构建前需要确认的基础依赖,在 Ubuntu 下可以这样装:
sudo apt update sudo apt install cmake ninja-build python3 zlib1g-dev libxml2-dev \ libzstd-dev libcurl4-openssl-devpython3是跑测试工具lit必须的,libxml2-dev和libzstd-dev用于调试信息处理。缺了它们,CMake 配置阶段可能会正常通过,但到某个子模块编译时会突然报找不到头文件。我后来用最小环境重跑过一次,确认这几个包都是刚需。
3.2 CMake 配置关键项说明
CMake 这一步是最容易翻车的地方。我把最终使用的配置放出来,并逐个解释为什么要这样填:
cmake -S llvm-project/llvm -B build \ -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DLLVM_ENABLE_PROJECTS="clang;lld" \ -DLLVM_ENABLE_RUNTIMES="compiler-rt;libcxx;libcxxabi;libunwind" \ -DLLVM_TARGETS_TO_BUILD="X86;ARM;AArch64" \ -DLLVM_INSTALL_UTILS=ON \ -DLLVM_ENABLE_TERMINFO=OFF \ -DLLVM_PARALLEL_LINK_JOBS=4这里有一个非常常见但很多人会踩的坑:LLVM_ENABLE_PROJECTS和LLVM_ENABLE_RUNTIMES不能混用。LLVM_ENABLE_PROJECTS里的项目会跟 LLVM 本身一起参与同一个 CMake 构建,直接构建出宿主平台上运行的编译器、链接器工具;而LLVM_ENABLE_RUNTIMES里的东西,是编译器运行时和 C++ 库,它们会在后续以“目标平台”的身份重新配置并编译。
如果你把libcxx错放进了LLVM_ENABLE_PROJECTS,常见的结果是它被当成 host 项目来构建,之后再交叉编译 Arm 目标时,生成的库文件架构完全是错的,而且 CMake 会反复配置,时间白白浪费。当初我第一次试的时候就把compiler-rt放错了地方,最后构建出的 builtins 库根本没生成 ARM 版本,只能清理目录重新开始。
LLVM_TARGETS_TO_BUILD我保留了X86,是因为 host 环境是 x86_64,保留它可以同时支持本机调试;ARM;AArch64则是本次评测的核心目标。如果你的工具链是给裸机片内执行用的,且编译机也是 Linux,完全可以把X86去掉,只留ARM;AArch64,能明显缩小构建体积。
LLVM_INSTALL_UTILS=ON会生成llvm-objcopy、llvm-readelf、llvm-size等工具。嵌入式开发里,这些工具在镜像裁剪和 ELF 分析时很有用,建议默认打开。
关键选项汇总:
| CMake 选项 | 配置值 | 作用与备注 |
|---|---|---|
CMAKE_BUILD_TYPE | Release | 工具链自身采用优化构建,体积更小、速度更快 |
LLVM_ENABLE_PROJECTS | clang;lld | 构建编译器前端和链接器 |
LLVM_ENABLE_RUNTIMES | compiler-rt;libcxx;libcxxabi;libunwind | 构建目标架构的运行时库 |
LLVM_TARGETS_TO_BUILD | X86;ARM;AArch64 | 后端目标集合,直接影响编译时间 |
LLVM_INSTALL_UTILS | ON | 安装 binutils 替代工具 |
LLVM_PARALLEL_LINK_JOBS | 4 | 限制并行链接任务数,避免 OOM |
3.3 构建执行与耗时统计
配置完成后,我没有直接执行ninja一把梭,而是分阶段构建。第一阶段先确保clang和lld能出来,第二阶段再编译目标运行时库:
# 第一阶段:构建编译器与链接器 ninja -C build clang lld # 第二阶段:构建 target 运行时库 ninja -C build compiler-rt libcxx libcxxabi libunwind # 如果希望安装到指定前缀,可以执行: ninja -C build install分阶段的好处是出问题时能更快定位:如果clang都没编译成功,后续的运行时库交叉构建基本不会正常。16 核虚机上,clang和lld全量 Release 构建大约花了 35 分钟,compiler-rt和 C++ 库加起来又花了 20 分钟左右。实际耗时跟你选的 CPU 型号和核数密切相关,不要拿我的数字当基准,但大致可以按“半小时到一小时”来预估。
构建结束以后,build/bin下会多出这些关键文件:
build/bin/ ├── clang ├── clang++ ├── lld ├── ld.lld ├── llvm-objcopy ├── llvm-objdump ├── llvm-readelf └── llvm-size我在 Build 目录里保留了完整的build.ninja文件,目的是让后续任何一次评测都可以回溯当时用的是什么参数。这个文件虽然很大,但它就是“构建证据”的源头。
3.4 我踩过的三个构建坑
第一个坑是 OOM。链接clang时,16 核默认会启动 16 个并发链接任务,内存瞬间被吃满,进程被 Linux 直接杀掉。解决方法是设置LLVM_PARALLEL_LINK_JOBS=4,同时在系统层面加一个 swap 文件作为兜底。这一步不要省略,尤其是你机器内存只有 16GB 时。
第二个坑是 Terminfo 找不到。CMake 配置阶段可能正常,但编译或链接某个需要 TUI 支持的工具时会报:
unable to find shared library terminfo这个报错非常误导人,看着像缺少第三方库,实际是 CMake 搜索 terminfo 的路径和编译器不匹配。我在服务器上直接加了-DLLVM_ENABLE_TERMINFO=OFF解决。LLVM 的很多内置工具都是通过终端库来做输出控制的,对嵌入式工具链来说关掉这个依赖没有影响。
第三个坑是运行时库的“假成功”。我最早构建compiler-rt时,因为 CMake 缓存里还留着旧的 target 配置,构建日志显示成功,但build/lib/clang下面的 builtins 目录里没有生成 ARM 版本的库文件。原因是LLVM_ENABLE_RUNTIMES里的项目需要被交叉配置,它的构建产物路径和 host 工具链不在同一个目录下。解决办法是清理build目录,重新严格按照上面的 CMake 命令配置,不保留旧缓存。
4. 测试证据链:自检、QEMU 冒烟与日志留证
4.1 工具链自带的测试体系怎么跑
工具链构建完成只是第一步,关键要看它能不能证明自己行为正确。LLVM 自带了庞大的测试基础设施,核心工具是lit,它会把.c、.ll、.mir、.td等测试输入喂给编译器和链接器,再用FileCheck检查输出是否符合预期。这里不展开测试框架的细节,重点说怎么跑。
在构建目录下可以直接执行:
ninja -C build check-llvm ninja -C build check-clang ninja -C build check-lldcheck-all比较耗时,实测下来在这台 16 核虚机上跑全量可能要一两个小时,所以我这次只跑了编译器、前端和链接器三个核心套件。每个套件跑完以后,build/Testing/Temporary/LastTest.log里会记录完整的测试结果摘要。我当时拿到的关键数据大致是这样的:
Testing: 36152 tests, 0 failures Testing: 21897 tests, 0 failures Testing: 4736 tests, 0 failures换一个 checkout 或不同 CPU 配置,测试总数会变,但这三条结果足以说明在你当前的构建配置下,上游自带回归测试没有击穿。更重要的是,这些日志本身就是证据,保存下来以后可以用来对比不同版本之间是否出现回归。
有些测试会因为缺目标架构或缺少外部工具而被 skip,遇到这种情况不要直接当成失败。真正需要关注的是有没有FAIL或TIMEOUT。如果出现FAIL,优先确认是不是自己的LLVM_TARGETS_TO_BUILD没有包含对应后端,很多失败其实源自配置缺失而不是源码缺陷。
4.2 用 QEMU 验证生成可执行程序
工具链自带的测试通过,只能说明上游做过的行为没有被改变,还不能证明最终生成的可执行程序能在目标体系结构上正常跑起来。所以我做了两层验证:一层是 Linux 用户态程序,用 QEMU 用户模式直接执行;另一层是裸机方向的汇编输出核验。
先看第一层。在 Ubuntu 上安装了交叉库环境后,我写了一个最简单的hello.c,然后用刚构建出来的clang交叉编译到aarch64-linux-gnu:
sudo apt install gcc-aarch64-linux-gnu qemu-user cat > hello.c <<'EOF' #include <stdio.h> int main(void) { printf("toolchain check: aarch64\n"); return 0; } EOF ./build/bin/clang --target=aarch64-linux-gnu -static -O2 hello.c -o hello-aarch64 qemu-aarch64 ./hello-aarch64输出结果:
toolchain check: aarch64这里引入gcc-aarch64-linux-gnu的原因是clang需要一套目标架构的头文件和库文件,这套 sysroot 由交叉 GCC 包提供,LLVM 本身不维护 C 库头文件。如果你使用 Arm 官方工具链预置的 sysroot,也可以换成对应的目录。-static参数是为了避免 QEMU 用户模式下还需要找动态链接器。
第二层是裸机方向的核验。嵌入式工具链最常用的场景不是 Linux 用户态,而是 Cortex-M 这类裸机环境。我用刚才的工具链编译了一个浮点加法函数,没有依赖任何系统库:
cat > float_add.c <<'EOF' float add(float a, float b) { return a + b; } EOF ./build/bin/clang --target=arm-none-eabi -mcpu=cortex-m4 \ -mfpu=fpv4-sp-d16 -mfloat-abi=hard -O2 -S float_add.c -o -生成的汇编重点片段:
vadd.f32 s0, s0, s1 bx lr这个结果说明工具链在硬浮点 ABI 下的代码生成行为正确。作为对照,如果我把参数改成-mfloat-abi=soft,同样的源码会变成对辅助函数的调用:
push {r4, lr} bl __aeabi_fadd pop {r4, pc}这两种输出完全符合预期,证明编译器对 ARM 软浮点和硬浮点 ABI 的处理没有发生串线。这类“源码级行为对照”比单纯跑一个 benchmark 更有说服力,因为它直接验证了工具链在底层 ABI 决策上的正确性。
4.3 一份可复现的测试证据清单
评测报告如果没有证据,跟“我觉得好用”没有区别。我在整个过程中约定了一个简单的归档方式,就是把证据分成三类,统一放在一个目录里:
| 证据类型 | 文件内容 | 作用 |
|---|---|---|
| 构建证据 | build/build.ninja、CMakeCache.txt | 证明当前工具链由哪些参数构建而来 |
| 自测证据 | build/Testing/Temporary/LastTest.log | 证明官方测试套件通过 |
| 运行证据 | QEMU 输出文本、汇编对照文件 | 证明生成代码可以在目标体系结构上运行/符合 ABI 预期 |
保存这些文件并不难,但非常值。下次如果换了一个新版本,或者调整了某个 CMake 选项,我只需要把这些证据重新生成一遍,然后 diff 两份日志,就能快速定位是源码差异还是配置差异。这比面对两个二进制包,打开一看“都能用”,然后讲不清到底改了什么,要可靠得多。
5. 从静态评测中看到的优化空间与选型建议
5.1 模块裁剪的突破口
从模块划分角度看,LLVM Embedded Toolchain for Arm 的源码并不算高耦合,但也不是所有模块都需要照单全收。嵌入式场景最常见的裁剪是减少目标后端数量。如果你只做 Cortex-M 开发,把LLVM_TARGETS_TO_BUILD从X86;ARM;AArch64改成ARM是立竿见影的,生成的编译器和链接器体积会小很多。AArch64 后端在 64 位场景里是你的目标时再保留。
另一个裁剪点在于lld。它虽然只占整体体积的一小部分,但如果你完全不需要裸机 ELF 链接以外的功能,可以通过LLVM_ENABLE_PROJECTS只启用lld而不启用clang-tools-extra,这样可以节省一部分磁盘和构建时间。compiler-rt里的 builtins 不建议裁剪,因为它是软浮点、整数除法、内存操作辅助函数的主要来源,裸机链接时经常会用到__aeabi_*和__muldi3一类符号。如果你在 link 时突然报这些符号未定义,第一反应应该是检查 builtins 是否构建并进入了链接。
如果想进一步压缩工具链二进制,可以试-DLLVM_ENABLE_LTO=Thin,让工具链自身也做一次链接时优化。但这会显著增加构建时间,对开发机性能要求也更高。如果不是为了发布最终给终端用户的工具链包,开发阶段还是保持普通 Release 更好,迭代速度更重要。
5.2 构建配置上的两个建议
第一个建议是打开 ccache。LLVM 这样体量的项目,增量编译依然可能很慢,尤其当你需要频繁改 CMake 开关并重新构建时,ccache能省掉大量重复编译。在 CMake 命令里加一行-DLLVM_CCACHE_BUILD=ON即可。
第二个建议是把测试纳入 CI。源码静态评测不应该是一次性的,而是应该变成持续集成里的一个环节。我在这次评测中把check-llvm、check-clang、check-lld加进了一个 daily job,每次更新 upstream 版本后自动跑一遍,并将LastTest.log归档。这套逻辑不复杂,但对团队评估工具链升级带来的风险帮助极大。一旦新版本引入了错误的指令调度或 ABI 变化,测试套件能第一时间把信号拉响。
5.3 给嵌入式团队的实际选型参考
结合源码结构、构建难度和测试结果,我可以给出一个相对清晰的判断:
如果团队核心诉求是“简单、可预期、遇到问题有大量资料”,成熟的 GCC 交叉工具链仍然是非常稳妥的选择,它的破绽不在功能,而在定制灵活性上。如果团队希望统一编译器和链接器前端,有 LTO、CFI 或格式级优化需求,或者想针对不同目标板快速生成特定配置的工具链,LLVM Embedded Toolchain for Arm 的代码结构确实更适合二次开发。
这次评测也让我对“工具链质量”有了更具体的度量方式。一个模块划分清晰、构建可复现、测试证据充足的工具链,才能支持后续几年的持续演进。如果你也打算把工具链切换纳入技术决策,我建议分三步走:先拉源码,再写构建脚本,最后把测试日志归档。这三步走完,你得到的就不再是“别人说好用”,而是自己手里的证据。
就我个人而言,评测完这套工具链后,最大的收获不是“LLVM 能编译 ARM 代码”——这件事早已不是新闻。真正让我安心的是,我能从源码目录、构建配置和测试日志三个维度,说清楚这个工具链的边界在哪里,以及当它出问题时,我应该先去翻哪个目录。这大概就是源码级评测与“装上能用”之间最本质的区别。