ARM官方的LLVM Embedded Toolchain for Arm(下面就叫它LLVM-ET)源码仓库,我前后翻了三遍。第一遍扫目录结构,第二遍看CMake脚本和组件依赖,第三遍直接把工具链拉到本地完整构建了一轮,跑完了一组针对Cortex-M和Cortex-A的交叉编译验证。这篇就把我从模块划分、构建流程到测试证据的静态评测结论整理出来,包括我在构建过程中踩到的几个坑,以及最终建议直接使用它时的适用边界。
如果你正在做ARM裸机或RTOS开发,目前还犹豫要不要从传统工具链切到LLVM/Clang体系,或者你已经在用armclang但想了解官方开源的这条线到底成不成熟——这篇文章给出的结论和操作路径,可以直接拿来当参考依据。
1. 为什么单独评测LLVM Embedded Toolchain for Arm——它和标准LLVM/Clang有何不同
1.1 ARM工具链的演化背景
传统的ARM编译器CC(也就是老工程师常说的armcc)在ARM Compiler 5.x版本之后基本进入了维护模式。ARM自家主推的新工具链一直是Arm Compiler for Embedded,也就是armclang。但armclang作为商业产品,虽然基于LLVM,却不是一个完全开源的分发——你需要许可证、需要注册ARM账号、需要依赖ARM Development Studio那套生态。
LLVM-ET就是ARM官方在开源方向上给出的对应答案。它把LLVM、Clang、LLD这些上游组件和Bare-metal运行时组件打包成一个完整工具链,目标是让你不碰商业授权也能拿到一条相对正式的、ARM自己维护验证过的LLVM工具链路径。
但这里有一个关键认知差:很多人以为LLVM-ET就是“上游LLVM换了个安装包”,实际源码评测下来并不是这样。它在LLVM之上做了不少嵌入式场景的针对性处理,比如C库的选型、链接器脚本的生成方式、编译器运行库的构建策略,这些都直接影响你最终编译链接能不能一条龙走通。
1.2 我评测时重点看的四个维度
我给自己定的评测范围很明确,不评价benchmark跑分,重点看工程质量和可落地性:
- 模块划分:源码树里各个组件的边界是否清晰,是否便于维护和二次开发。
- 构建设计:从源码到工具链的构建脚本是否自洽,能否在干净环境里复现。
- 测试证据:项目自带的测试用例覆盖度,以及我自己动手编译验证时能否产出可信结果。
- 使用成本:替代现有工具链时,需要改多少东西。
这四个维度也是我给团队做工具链选型时固定走的一套评估框架。所以这篇不是简单地“教你装一个工具链”,而是一个源码层面的静态质量审计加上实测验证的综合报告。
2. 源码树解剖:从顶层Component到模块职责划分
2.1 仓库结构:这是一个“组装厂”,不是一个完整的编译器仓库
第一次 clone 到ARM-software/LLVM-embedded-toolchain-for-Arm仓库后,你会有一个非常直观的感受:这个仓库里并没有大部分LLVM编译器的源码。
这不是bug,恰恰是LLVM-ET最核心的模块划分思路。它本质上是一个组装层或者说BSP层,通过 CMake 的 FetchContent 机制把各类上游组件拉到构建系统里,再通过一套顶层配置把它们组合成统一的工具链。
从模块职责角度看,它的源码树可以分成这么几层:
| 层级 | 典型目录/组件 | 职责 |
|---|---|---|
| 组装层 | 顶层CMakeLists.txt、cmake/ | 定义组件拉取与构建顺序,统一toolchain配置入口 |
| 编译器核心 | LLVM、Clang(通过FetchContent拉取) | C/C++前端、中端优化、后端代码生成 |
| 链接器 | LLD | ELF链接、linker script支持 |
| 运行库 | compiler-rt、libcxx、libcxxabi、libunwind | 提供内建函数、C++标准库、异常与栈展开支持 |
| C库 | picolibc(或newlib) | 裸机环境下最基本的C标准库实现 |
| 测试与集成 | 各个组件的test目录、CI脚本 | 单元测试、集成测试、回归验证 |
这种“组装厂”思路最大的好处是:它避免了在ARM官方仓库里维护一整份大幅修改过的LLVM源码。如果你看过其他芯片厂商自带的GCC工具链,你会发现它们经常维护着一个巨大的、和上游已脱节的GCC fork,更新一个GCC版本要承受很大的diff冲突。LLVM-ET则把定制点集中到配置和构建编排层,向上游代码的侵入极小,这样长期跟随LLVM新版本的成本就低很多。
2.2 clang与lld在其中的角色
在这个工具链里,Clang不是简单的“拿上游版本直接编”,而是以嵌入式为默认场景做了配置调优。
以我评测时拉取的分支为例,它默认针对的目标主要覆盖了Cortex-M系列、Cortex-R系列以及部分Cortex-A系列Bare-metal目标。这意味着你编译时不需要像使用上游原生Clang那样频繁地追加--target=arm-none-eabi之外的目标描述参数——当然你用的时候显式指定目标仍然是最稳的做法。
LLD在这个工具链里的价值被很多初看源码的人低估。LLVM体系下,LLD是链接速度和脚本兼容性的关键。实测下来,LLD对GNU ld的兼容性已经足够好,标准的.ld链接脚本在LLD下基本可以直接使用,少数不支持的语法也能通过调整脚本规避。这意味着你把项目从GCC工具链迁移过来时,不需要重写链接脚本,这一点对存量项目非常友好。
2.3 compiler-rt与C库的编排逻辑
在Bare-metal场景下,一个常被新手忽略的事实是:工具链光有编译器是不够的,你还需要一套目标平台上能跑的运行库。
LLVM-ET把compiler-rt作为默认的编译器运行库,用它提供__aeabi_*这样的ARM EABI辅助函数,以及__divdi3这类整数运算辅助函数。这一点和上游Clang保持一致,但ARM在集成时对ARM架构特定汇编函数的选取做了定制,避免你链接时出现“找不到某个特定符号”的尴尬。
C库上,它默认集成了picolibc,这实际上是一个为嵌入式场景裁剪过的C库,对裸机开发非常友好。picolibc相比newlib的优点在于它对构建系统做得很干净,可以和LLVM体系无缝衔接。你在源码评测时能看到这一层的依赖关系完全通过CMake的接口传递,没有硬编码路径,所以在不同的构建环境下不容易出现库找不到的问题。
3. 从源码到可用的交叉编译器:构建流程与参数解释
3.1 构建环境准备
先说明,LLVM-ET对Linux和Windows都有支持,但我评测主要在Linux环境下进行,所以下面的路径以Linux为准。
我用的环境是Ubuntu 22.04,一套16核的机器,内存32GB。构建LLVM这种体量的项目,内存建议至少16GB,否则很容易在链接阶段出现OOM。依赖方面,以下这些是必须的:
sudo apt-get update sudo apt-get install -y build-essential cmake ninja-build python3 git如果你在Ubuntu 18.04这类旧系统上构建,注意CMake版本不要低于3.20,否则LLVM的CMake脚本很可能跑不过。另外我建议在干净环境里构建,不要和其他版本LLVM混装,避免CMake缓存串味。
3.2 关键CMake变量与配置命令
仓库clone下来后,进入根目录,我是这样配置的:
git clone https://github.com/ARM-software/LLVM-embedded-toolchain-for-Arm.git cd LLVM-embedded-toolchain-for-Arm cmake -S . -B build -GNinja \ -DCMAKE_BUILD_TYPE=Release \ -DFETCHCONTENT_QUIET=OFF \ -DLLVM_ENABLE_PROJECTS="clang;lld" \ -DLLVM_ENABLE_RUNTIMES="compiler-rt;libcxx;libcxxabi;libunwind" \ -DLLVM_INSTall_UTILS=ON逐项解释一下这些参数为什么这么设:
CMAKE_BUILD_TYPE=Release:编译器本体必须用Release构建,这一点不用多解释,Debug版Clang的性能会让你怀疑人生。
LLVM_ENABLE_PROJECTS="clang;lld":在LLVM体系中,工程里各子项目分为PROJECTS和RUNTIMES两个阵营。clang、lld这些属于PROJECTS,它们和LLVM核心一起构建;而compiler-rt、libcxx这类我们放在RUNTIMES,因为它们是要编译给目标架构使用的。
LLVM_ENABLE_RUNTIMES="compiler-rt;libcxx;libcxxabi;libunwind":指定要交叉编译的运行时库。注意LLVM-ET对这一层做了自动化处理,它会根据工具链的目标自动推断交叉编译参数,不需要你自己写一堆--target的复杂flag。
配置完成后,直接构建:
cmake --build build -j$(nproc)这个构建过程在16核机器上大概需要20到40分钟,具体取决于网络拉取依赖的速度和机器性能。构建产物会集中在build/toolchain/bin目录下,你会看到clang、clang++、llvm-ar、lld等可执行文件。
3.3 构建期最容易出问题的地方
我连续在三个不同环境构建过LLVM-ET,踩过的坑主要集中在以下两点:
第一个是网络问题。因为LLVM-ET通过FetchContent拉取上游组件,如果网络不稳定,很容易在中途Fetch失败。解决办法有两个推荐方式:一是提前用git clone把上游仓库拷到本地,然后用-DFETCHCONTENT_SOURCE_DIR_LLVM=/path/to/llvm-project这类变量指向本地目录;二是对无法直接访问外网的环境,做一个本地镜像。当时我直接用了本地路径方式,构建时间明显更可控。
第二个坑用的是llvm-project的版本对齐。因为 Fetch 的是特定commit,如果你的本地缓存里有旧的llvm-project目录,CMake不会主动重新拉取,导致用了不匹配的版本。最稳妥的做法是让FetchContent管理版本,除非你有充分的离线构建理由,否则不要在环境变量里擅自定向到旧目录。
另一个容易出问题的点是C库的Fetch。LLVM-ET构建时会拉取picolibc源码并编译,如果编译机器上缺少某些文本处理工具或awk版本过旧,picolibc的配置阶段可能静默失败。我当时排查了很久,最后发现是awk版本太老,换用mawk之后就好了。这类问题在官方文档里不会有,只有实际跑一遍才看得到。
3.4 构建产物验证
构建完成后,别急着直接用,先做一个基线检查:
build/toolchain/bin/clang --version build/toolchain/bin/llvm-ar --version然后看它对target的支持:
build/toolchain/bin/clang --print-targets | grep -i arm正常情况下应该能看到arm、armeb、thumb等目标。这能确认编译器本体没编坏。之后再对运行库产物做检查,确认compiler-rt和libc++的库文件是否存在:
find build/toolchain -name "*compiler-rt*" find build/toolchain -name "libc++.a"如果这几个库都齐了,说明工具链主链路是通的。其余的问题要靠编译实际工程去验证。
4. 测试证据:从lit到最小冒烟用例
4.1 LLVM-ET里的测试链路
源码静态评测里很重要的一环,就是看这个项目拿什么东西证明自己是好的。LLVM-ET沿用了LLVM体系内的lit测试框架,同时在各组件里保留了大量针对ARM目标的用例。
在构建目录里,可以直接运行:
cmake --build build --target check-clang这会在已构建的Clang上跑完整个Clang测试套件,包括大量代码生成的回归测试。ARM指令选择、内联汇编、内建函数这些都有覆盖。
再跑一下:
cmake --build build --target check-lldLLD的测试套件里同样包含了对ARM目标的支持。这里我会特别关注链接脚本解析和重定位的用例,因为这是裸机开发最容易出问题的区域。
不过,让我说实话:跑完check-clang和check-lld只能证明“编译器的通用功能没坏”,不能证明“它适合我的嵌入式项目”。所以真正的测试证据,还是要落到实际编译验证上。
4.2 我实际跑过的一组验证用例
这里给出一套我用来验证LLVM-ET可用的最小冒烟方案。先写一个简单的裸机C文件:
// smoke.c #include <stdint.h> volatile uint32_t counter; void delay(volatile uint32_t n) { while (n--) { __asm volatile("nop"); } } int main(void) { counter = 0; while (1) { counter++; delay(10000); } return 0; }还要一个最小启动文件,这里用汇编实现基本的向量表和复位入口:
// startup.s .syntax unified .cpu cortex-m4 .thumb .section .isr_vector,"a",%progbits .word _estack .word Reset_Handler .section .text .thumb_func .global Reset_Handler Reset_Handler: ldr r0, =_estack mov sp, r0 bl main b .然后是链接脚本,这里给一个极简版:
/* link.ld */ ENTRY(Reset_Handler) MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K } SECTIONS { .isr_vector : { . = ALIGN(4); KEEP(*(.isr_vector)) . = ALIGN(4); } > FLASH .text : { . = ALIGN(4); *(.text*) *(.rodata*) . = ALIGN(4); } > FLASH .data : { . = ALIGN(4); _sdata = .; *(.data*) _edata = .; . = ALIGN(4); } > RAM AT > FLASH .bss : { . = ALIGN(4); _sbss = .; *(.bss*) *(COMMON) _ebss = .; . = ALIGN(4); } > RAM }这个链接脚本里_estack需要在别处定义,初学者可以简单地在链接脚本末尾补一行:
_estack = ORIGIN(RAM) + LENGTH(RAM);编译命令:
TOOLCHAIN=build/toolchain/bin $TOOLCHAIN/clang --target=arm-none-eabi -mcpu=cortex-m4 \ -mfloat-abi=soft -nostdlib -ffreestanding \ -T link.ld \ startup.s smoke.c \ -o smoke.elf注意这里我直接传了链接脚本,Clang会自动调用LLD完成链接。编译完成后验证:
build/toolchain/bin/llvm-objdump -d smoke.elf这个反汇编输出会直接给你证据:可以看到Reset_Handler里mov sp, r0、bl main这些指令是否生成了正确的Thumb指令,向量表是否落在了0x08000000起始位置,各个段的装载地址和运行地址是否符合链接脚本预期。
4.3 再进一步:QEMU运行验证
如果你身边没有实体开发板,QEMU可以帮你完成最后一步运行验证。安装qemu-system-arm后,可以跑一个Cortex-M4的模拟:
qemu-system-arm -machine mps2-an385 -cpu cortex-m4 -nographic \ -kernel smoke.elf这个用例正好匹配我们前面设置的Cortex-M4目标。虽然MPS2开发板的flash和RAM地址和STM32的映射不完全一致,但如果你的链接脚本地址是按0x08000000排的,QEMU跑不起来也正常,不是工具链的问题。想快速验证工具链生成的指令能被CPU执行,可以在链接脚本里把FLASH的ORIGIN改成0x00000000,这样与MPS2的镜像加载地址匹配,能看到程序计数器持续跳动。
这类验证跑通之后,我对LLVM-ET的“能从源码构建出可用工具链”的判断就算坐实了。
5. 静态评测结论与选型建议
5.1 从源码看到的几个质量信号
整个评测下来,有几个信号强烈表明这个项目工程质量在同类开源工具链里处于上游水平。
第一,模块边界非常干净。所有个性化定制集中在构建编排层,对上游LLVM源码的侵入性改动极少。这意味着你追新版本时不必担心ARM官方的私有补丁冲突,也意味着这个项目的长期可维护性很强。
第二,构建脚本做得比大多数开源项目细致。FetchContent的版本管理、跨平台路径处理、缓存变量提示都做得很规范,我在三个不同环境里构建,没有遇到过一个“配置成功但编译莫名其妙失败”的问题。
第三,测试体系完整继承了LLVM的优势。check-clang、check-lld这些测试套件在ARM目标上的用例量很足,对编译器后端任何可能引入的ARM回归都有比较强的约束。
5.2 什么项目适合直接用LLVM-ET
基于这次评测,我给三类项目开了可以直接用它的“绿灯”:
第一类是从零起步的Cortex-M系列裸机或RTOS项目。新项目没有太多历史包袱,直接使用LLVM-ET不存在迁移成本,而且LLVM的编译诊断信息更友好,对现代C标准的支持也走在GCC前面,开发体验更顺。
第二类是已经有CMake构建体系的存量项目。如果你现成的构建脚本里用的是clang或gcc,改到LLVM-ET通常只需要调整CMAKE_C_COMPILER指向,链接器脚本基本无需改动。
第三类是需要稳定复现的持续集成流水线。LLVM-ET的源码构建全流程可复制,配合本地缓存目录可以做到完全离线构建,这在很多公司的内网CI环境里是刚需。
相对地,如果你依赖某个没跟上LLVM演进的第三方编译器扩展、或者你的项目必须用厂商只支持GCC的SDK,那还是先别强行切到LLVM-ET。工具链切换不是目的,让项目持续稳定交付才是目的。
5.3 我个人的使用体会
最后分享一个实际体会:LLVM-ET现在对我来说已经是评估一个嵌入式工具链质量的“基准线”。我以前给团队做工具链选型时,总是拿GCC ARM Embedded作为默认答案。但这次把LLVM-ET从源码一路构建到跑通裸机程序之后,我自己的倾向已经变了——新项目如果条件允许,我会优先考虑LLVM-ET,理由不是它开放源码,而是它把“工具链是怎么被构建出来、怎么被验证过”这件事完整地摊开给你看了。
这种透明度在传统商业工具链里几乎不可能获得。后续我准备在这个基础上再写一篇关于LLVM-ET里链接脚本和启动文件的高级用法,如果你也在研究这套工具链,不妨一起交流。