做 Arm 交叉编译,很多人的第一反应是去下arm-none-eabi-gcc,要么就是翻出还在用的 Arm Compiler 5。这两年我在给团队搭嵌入式 CI 的时候,越来越觉得应该认真看一遍 LLVM Embedded Toolchain for Arm 的源码。这个项目不只是一个“clang 换 gcc”的壳,它在模块划分、构建编排、运行库组合上做了很多值得拆解的决策。这篇文章就把我从源码静态评测视角看到的模块划分、构建流程、测试证据和踩坑点完整写出来。
如果你正在做 Cortex-M 固件开发,或者想用 LLVM 系列工具统一服务器和嵌入式两套 toolchain,这篇文章应该正对你的胃口。整个评测不依赖开发板,拉源码、读源码、跑构建、跑测试,全部可以在 x86 主机上完成;好处是快,而且每个结论都能用文件、日志和二进制产物作为证据。
1. 这套工具链到底解决什么问题
1.1 它不是“另一个编译器下载页”
一听到“LLVM Embedded Toolchain for Arm”,很多人以为是 Arm 官方打包的又一个 clang 发行版。打开源码才发现,它的重心其实不在“编译器的编译”,而在“编译器之外的运行库怎么组装”。
我理解的定位是这样的:它是一套给 Arm 嵌入式场景设计的“完整工具链生成器”。这里的完整,意味的不只是 clang 能编.c文件,而是包括:
- 头文件:C 库头文件、C++ 标准库头文件;
- 启动文件:
crt0、crti、crtn这类裸机启动和收尾代码; - 运行库:C 库、C++ ABI、异常栈展开、编译器内置函数;
- 链接器:
ld.lld,以及配套链接脚本模板; - 测试:对应的编译测试、运行测试和 LIT 用例。
这正好是嵌入式工具链最容易让人踩坑的部分。用 clang 直接编一个裸机程序不难,难的是当你需要printf、需要 C++ 的std::vector、需要异常处理、需要不同的浮点 ABI 时,所有库都能在同一个-mcpu/-mfpu组合下对上号。LLVM-ET 本质上是在把这件事自动化。
1.2 为什么值得做一次源码静态评测
我这次做的是“静态评测”,没有接开发板,也没有跑 QEMU。核心理由是:
- 嵌入式目标板子太多,Cortex-M0、M4、M7、M33、A57 的配置差异很大,逐一套硬件不可能;
- 工具链的很多问题,在源码和构建日志里就已经有明确痕迹,比如某个 multilib 目录缺失、某个头文件被条件编译排除了、某个链接脚本没有暴露选项;
- 静态评测可以把“能不能构建”“有没有测试证据”先钉死,再决定要不要花时间做板级验证。
这种做法对团队选型特别有用。源码拉下来两小时,构建一晚上,测试证据全在build目录里。评估结论不是“听说这个工具链好用”,而是“哪个版本、哪个参数、编出了什么库、跑了哪些测试、有没有失败”。
1.3 我的评测基线
我先交代一下环境,方便你对结论做版本映射:
| 项目 | 我的配置 |
|---|---|
| 宿主系统 | Ubuntu 22.04 x86_64 |
| CMake | 3.24+ |
| Ninja | 1.11+ |
| Python | 3.10+ |
| 源码版本 | LLVM-ET 17.x 对应的一版 |
| 目标架构 | armv7em-none-eabi、armv8m.main-none-eabi |
| 验证工具链 | clang、ld.lld、llvm-ar、llvm-nm、llvm-readobj |
后面提到的路径、变量名在不同版本里可能略有变化,但模块边界和构建逻辑基本稳定。
2. 源码模块划分:目录结构里写着的设计意图
2.1 根目录第一眼看到的东西
把仓库git clone --recursive下来之后,我第一件事是看目录布局。它并不像普通 LLVM 仓库那样只有llvm-project一个大目录,而是把“上游编译器源码”和“外围配置脚本”分开管理。
我看到的典型结构可以简化成下面这样:
LLVM-embedded-toolchain-for-Arm/ ├── CMakeLists.txt ├── cmake/ │ ├── EmbeddedToolchain.cmake │ └── ... ├── scripts/ │ ├── build.py │ ├── test.py │ └── ... ├── llvm-project/ │ ├── clang/ │ ├── lld/ │ ├── compiler-rt/ │ ├── libcxx/ │ ├── libcxxabi/ │ ├── libunwind/ │ └── ... └── picolibc/ ├── newlib/ ├── picocrt/ ├── ...llvm-project和picolibc是被编排进来的上游源码,真正的“胶水层”在根目录的CMakeLists.txt、cmake/和scripts/里。这个拆分让我在评估时能快速区分两件事:
- 哪些代码是 Arm 团队自己写的;
- 哪些代码是上游 LLVM / picolibc 原样带进来的。
对源码静态评测来说,这个区分非常重要。自己写的配置代码才是工具链真正的心智模型,上游代码更多是黑盒被调用的部分。
2.2 每个模块在整条链路里的角色
我习惯把模块按“编译时”“链接时”“运行时”三个时间段来看。
| 模块 | 角色 | 负责什么 |
|---|---|---|
| clang | 编译前端 | C/C++ 编译,生成目标文件 |
| lld | 链接器 | 把对象文件和运行库链接成可执行文件 |
| compiler-rt | 编译期运行库 | 提供__aeabi_*、__udivsi3、软浮点等内置函数 |
| picolibc | C 库 | printf、malloc、字符串函数、启动文件 |
| libunwind | 运行时栈展开 | 异常处理和 C++ 析构需要_Unwind_* |
| libc++abi | C++ ABI 层 | 类型识别、异常对象、__cxa_*等 |
| libc++ | C++ 标准库 | std::vector、std::string、STL 容器与算法 |
这个分层跟 GNU Arm Embedded Toolchain 的设计思路类似,但实现细节差异很大。GCC 通常把 C 库、启动文件和链接脚本打包成一个整体,而 LLVM-ET 更倾向于“组件可替换”。
2.3 模块边界里藏着的几个判断
读源码时我特别留意了这三处边界:
第一,clang 和运行库的边界。clang 只负责把源码翻译成可执行代码,不负责提供memcpy或__aeabi_idiv。这样做的好处是:如果你已经有自己的 C 库,完全可以不引入 picolibc,只把 clang 和 compiler-rt 接进来。
第二,C 库和 C++ 标准库的边界。picolibc 提供 C 接口,libc++ 依赖这些 C 接口构建 C++ 层。边界清楚保证了“只用 C”的项目可以不携带 C++ 库,减小固件体积。
第三,目标相关和宿主相关的边界。构建工具链时有一堆步骤是跑在 x86 主机上的,比如配置、生成头文件、运行 LIT 测试;真正 target 相关的代码全部通过交叉编译和 multilib 目录隔离。这条边界如果不清楚,很容易出现“拿了宿主机的.a库链接到 Arm 固件里”这种隐患。
3. 构建系统与关键配置:散件是怎么拼成工具链的
3.1 为什么不直接分发一个“编好的 clang”
一个最直觉的方案是:Arm 官方把 clang、libc++、picolibc 全编好,打包成.tar.xz给用户下载。LLVM-ET 没有走这条路,而是提供一个源码级构建流程。原因我读下来有两个:
第一,embedded target 的配置矩阵太大。Cortex-M0 没有硬件除法,Cortex-M4F 有单精度 FPU,Cortex-M7 可能还有双精度。同一个 clang 二进制可以同时支持这些配置,但运行库必须按armv6-m、armv7em、armv7em/fp等组合分别预编译。这个矩阵在分发时很难全量打进去,源码构建则可以按需生成。
第二,可追溯性。工具链发行版的版本号只能告诉你“从哪个快照编出来的”,源码仓库却能告诉你“哪些补丁、哪些配置、哪些编译选项参与了这次构建”。对做质量体系和长期维护的团队来说,源码构建的证据链更完整。
3.2 我实际执行的构建命令
官方仓库提供了脚本化入口。我的做法是先跑一遍脚本,再看脚本内部干了什么。大致命令如下:
git clone --recursive https://github.com/ARM-software/LLVM-embedded-toolchain-for-Arm.git cd LLVM-embedded-toolchain-for-Arm python3 scripts/build.py \ --target armv7em-none-eabi \ --build-type Release \ --jobs 8 \ --run-tests--target指的不是“生成的编译器跑在哪个主板上”,而是“生成的交叉编译器默认面向哪个目标”。我重点看的是--run-tests这个开关,它会在构建完成后执行测试套件,并把结果写到构建目录里。这也正好对应标题里的“测试证据”。
如果你不想用脚本,也可以在根目录直接走 CMake:
cmake -S . -B build -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DLLVM_TARGETS_TO_BUILD=ARM \ -DLLVM_DEFAULT_TARGET_TRIPLE=armv7em-none-eabi \ -DLLVM_ENABLE_PROJECTS="clang;lld" \ -DLLVM_ENABLE_RUNTIMES="compiler-rt;libcxx;libcxxabi;libunwind" cmake --build build变量名在不同版本会有微调,但逻辑是固定的:先编 clang/lld,再利用这组工具链去编运行库,最后把 picolibc 和 C++ 库打包进 sysroot。
3.3 静态代码里最有信息量的几个点
我读构建配置时,重点关注了三个文件:
一是CMakeLists.txt里的LLVM_ENABLE_RUNTIMES。它决定了哪些运行时组件会跟着主工具链一起被交叉编译。compiler-rt、libcxx、libcxxabi、libunwind 都属于这一类,而不是LLVM_ENABLE_PROJECTS。这两者的区别非常关键:PROJECTS是宿主上的构建工具,RUNTIMES是目标机上的运行库。
二是 cmake 目录下的 multilib 配置。它围绕 Arm 架构的“architecture + profile + fpu + float-abi”生成不同子目录,比如:
lib/clang/17/lib/armv7em-none-eabi/thumb/v7e-m/fp-h lib/clang/17/lib/armv7em-none-eabi/thumb/v7e-m/nofp这种设计保证了同一个 clang 在遇到不同-mcpu/-mfpu参数时,可以去正确的目录里找运行库。
三是脚本里对--specs=picolibc.specs的传递。picolibc 使用 specs 文件来描述启动文件、链接脚本和系统调用桩。工具链在这个环节做没做对,直接决定用户拿到手后能不能一句命令链出可执行文件。
4. 核心实现机制拆解:从 clang 参数到运行库匹配
4.1 clang 侧怎么识别 Arm 嵌入式目标
clang 对 Arm embedded 的识别从 target triple 开始。armv7em-none-eabi里的none表示没有操作系统,eabi表示遵循 Arm EABI。但 triple 本身只能框定一个范围,真正决定指令集的是以下选项:
-march=armv7e-m:指定架构版本;-mthumb:使用 Thumb 指令集;-mfpu=fpv4-sp-d16:指定 FPU;-mfloat-abi=hard/softfp/soft:指定浮点参数传递方式。
这里最容易出问题的是浮点 ABI 不一致。编译.c文件时用-mfloat-abi=hard,运行库却用softfp编,链接阶段就会出现某些符号找不到,或者隐式调用约定错乱。LLVM-ET 的 multilib 机制就是为了让“编译参数”和“运行库路径”自动对齐。
我在静态评测里做的一个验证是:
clang --target=armv7em-none-eabi \ -march=armv7e-m -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard \ -print-libgcc-file-name也就是让 clang 告诉我:如果按照这组参数链接,它应该去找哪个libclang_rt.builtins-*.a。输出路径里的目录正好对应源码 cmake 配置里生成的 multilib 目录之一,这个闭环就通了。
4.2 compiler-rt 的内置函数从哪里来
embedded 场景里,处理器不一定支持 64 位除法、浮点算术甚至 32 位除法。编译器在遇到a / b时,如果目标硬件没有idiv指令,就会调用__aeabi_uidiv、__aeabi_idiv这类函数。这些函数的实现集中在compiler-rt/lib/builtins。
源码里能看到清晰的架构拆分:builtins/arm/目录下专门放 Arm 相关文件,builtins/顶层放通用逻辑。静态评测时我比较关注的是:
fp_add_impl.inc这类软浮点实现,是否根据__ARM_PCS_VFP宏切换 ABI;- 是否有
__aeabi_d2f、__aeabi_f2l这些 EABI 要求的辅助函数; - 编译选项里有没有正确传入
-mfpu/-mfloat-abi,因为这会影响函数内部是否使用硬件浮点指令。
用llvm-nm查看编译出来的库,可以很快确认关键符号存在:
llvm-nm libclang_rt.builtins-armv7em.a | grep aeabi如果看到__aeabi_uidiv、__aeabi_idiv、__aeabi_memcpy等符号,说明编译器运行库的基本盘是完整的。
4.3 libunwind、libc++abi、libc++ 的三角关系
C++ 异常处理在裸机上是个复杂话题。try / catch要生效,至少需要三层协作:
- libunwind 负责栈展开,遍历调用栈并恢复现场;
- libc++abi 负责
__cxa_throw、__cxa_begin_catch、__cxa_end_catch; - libc++ 负责标准异常类型和语言运行时设施。
LLVM-ET 把这几个库都编进 sysroot,意味着你可以在不带 Linux 的 Cortex-M 上使用 C++ 异常。不过裸机异常处理通常要求链接时加入 unwind 表头,也就是--eh-frame-hdr,并且链接脚本需要合理安排.eh_frame段的位置。
从源码评测的角度,我重点看的是它们是否按同一套 target triple 和 ABI 编译。因为这三个库互相依赖,任何一个用错了-fexceptions/-fno-exceptions,都会在运行时出现“异常抛出后崩掉”这种极难排查的问题。
4.4 为什么 C 库选了 picolibc 而不是 newlib
这是我在源码里注意到的另一个关键决策。picolibc 具备这么几个对嵌入式工具链友好的特点:
- 无操作系统依赖,系统调用桩可以自己接管;
- 默认
printf支持完整格式化,也可以裁剪成 mini 版本; - 头文件和启动流程贴合裸机场景;
- 许可证和服务对象更适合做源码级集成。
newlib 也不是不行,但体型更大、POSIX 倾向更强。对 Cortex-M 常见的内存限制来说,picolibc 显然更克制。它保留了 malloc 等动态内存接口,同时允许通过链接脚本把堆区控制在一个明确范围内。
5. 构建与测试的证据链:可复现,才有说服力
5.1 构建日志就是第一手证据
我不太相信“我本地编过没问题”这种结论,所以我做评测时会保留所有原始日志。构建完成后,build目录里至少会有几类关键证据:
build/ ├── CMakeCache.txt ├── CMakeFiles/CMakeOutput.log ├── build.ninja ├── bin/ │ ├── clang │ ├── ld.lld │ └── llvm-ar ├── lib/clang/17/lib/ │ ├── armv7em-none-eabi/... │ └── libclang_rt.builtins-armv7em.a ├── picolibc/ └── Testing/Temporary/LastTest.logCMakeCache.txt记录了我传入的每一个配置项;build.ninja记录了 Ninja 将要执行的全部构建规则;LastTest.log记录了测试执行细节。这些文件比口头结论硬得多。
5.2 构建产物要对照源码看
构建完成后,我会把“源码里声明的输出”和“实际生成的文件”做一次核对。具体做法:
find build/lib/clang -name "*.a" | sort正常情况下,会看到按 multilib 排列的多组静态库。每组目录名里都携带架构/FPU 信息。此时我回头翻源码里的 multilib 配置,看是不是一一对应。如果源码配了三套,目录里却只有两套,说明有一组没有成功构建,这是非常典型的证据缺口。
还有一种核对方法是用llvm-readobj查看目标文件的架构属性:
llvm-readobj --file-headers build/lib/clang/17/lib/.../libclang_rt.builtins-armv7em.a如果文件头里显示的 Machine 值是 ARM,而不是 X86_64,至少说明这不是“拿宿主机库充数”。
5.3 测试怎么跑、结果怎么读
LLVM-ET 的测试主要是 LIT / FileCheck 体系。跑测试时我通常分两层:
第一层是构建系统自带的 CTest:
ctest --test-dir build --output-on-failure它会统一汇总所有已注册的测试用例,包含 libc++、libc++abi、libunwind 和 compiler-rt 的测试。失败时--output-on-failure会把具体命令和 diff 直接打出来。
第二层是更细粒度的 LIT 测试,比如针对某个运行库单独跑:
ninja check-compiler-rt ninja check-libcxx ninja check-libunwind读测试结果时要特别留意图里的x86_64和armv7em字样。有些测试可能因为工具链版本原因标记为XFAIL,这没关系;但如果一个号称针对 Arm 的测试实际上在宿主上跑了一圈,那它的证据价值就大打折扣。所以我会用llvm-nm或测试日志里的 RUN 行确认测试对象确实是目标架构产物。
5.4 把证据固化到 CI 里
源码静态评测的另一个用途是可以直接变成 CI 流水线。我在团队里会把下面的步骤固化成 nightly job:
- 定时拉取上游代码;
- 用固定版本 CMake/Ninja 构建;
- 跑
ctest --output-on-failure; - 把
LastTest.log和构建产物存档; - 记录
git rev-parse HEAD作为证据锚点。
这样一旦未来出现回归,可以直接反查“哪一次源码变更导致哪一组测试失败”,而不用靠猜。
6. 踩过的坑和值得记录的经验
6.1 宿主 clang 版本不一致会非常痛苦
这套工具链构建过程中,可能需要用宿主 clang 来编译一部分 LLVM/运行库。如果宿主 clang 版本比源码期望的新或旧太多,会出现奇怪的 ABI 问题。我的建议从来不是“拿系统自带 clang 一把梭”,而是先看仓库 README 和构建脚本里声明的依赖版本,必要时用源码期望的版本。
经验是:在容器或 CI 环境里固定 clang 版本,比在个人笔记本上碰运气稳定得多。
6.2 链接脚本和启动文件最容易配错
很多用户拿到 clang 后,自己写的链接脚本还是 GCC 时代的,启动文件用的是旧startup.s。这会导致链接时出现__crt0_start找不到、__bss_start__符号对不上等问题。用 LLVM-ET 时,应该明确使用 picolibc 提供的 specs 文件,比如:
clang --target=armv7em-none-eabi \ -mthumb -mcpu=cortex-m4 -mfpu=fpv4-sp-d16 -mfloat-abi=hard \ --specs=picolibc.specs \ -Wl,-Tmemory.ld \ main.c -o firmware.elf--specs=picolibc.specs会帮你把 picolibc 的启动文件、初始化流程和默认链接脚本接上,但内存布局文件-Tmemory.ld仍然需要你自己写,告诉链接器 ROM/RAM 的基地址和大小。
6.3 multilib 目录不是越多越好
从源码上看,LLVM-ET 支持很多 Arm 变体。但实际项目里,运行库每多一组,构建时间和产物体积都会增加。我在评测时习惯先明确目标列表,只保留团队实际用到的 Cortex-M 型号和浮点组合。宁可后期加,也不要一开始把 multilib 配置拉满。
否则你会在 CI 上看到大量“正在重新构建 compiler-rt for armv6-m... armv7em/fp... armv8m.main...”的无意义耗时。
6.4 静态评测有边界,别把话说满
这是最想提醒的一点。源码评测能确认“模块划分合理”“构建可复现”“测试通过”,但不能确认“在特定硬件上不会触发 errata”“中断现场保存没问题”“功耗行为正常”。板上验证仍然不可替代。
我见过的项目翻车,大多不是在正常路径上,而是在-O2+ 硬件浮点 + 中断嵌套 + 动态内存分配叠加起来的角角落落。所以源码静态评测适合做“一票否决”和“快速筛选”,不适合做“最终放行”。
7. 这次静态评测给我留下的最大印象
我整体看完 LLVM Embedded Toolchain for Arm 的源码后,最大的感受是:这个项目把“LLVM 是个编译器”升级成了“LLVM 是一整套可以自己再生产的嵌入式工具链”。它没有把复杂全推给用户,而是用模块划分、multilib 和测试体系,把裸机工具链最难的部分标准化了。
如果让我选一个最值得学的设计,我会选“源码级 sysroot 生成”这套思路。它让工具链的每一个运行库都能追溯、可重编、可替换,而不是一个黑色压缩包。对要长期维护固件代码库的团队来说,这种透明性比“开箱即用”更值钱。
我现在已经在团队内部用这套流程验证新的 Arm 项目。下一步大概率会把它接到 nightly CI 里,按版本固话构建和测试证据。如果你正在评估要不要换掉旧工具链,我的建议很直接:别急着下载二进制,先把源码拉下来,亲手跑一遍构建。那个过程里踩到的每个报错,都是比任何文档都真实的技术答案。