最近把 ARM 官方开源的那套 LLVM 嵌入式工具链源码从 GitHub 完整拉下来做了一遍静态评测。不是简单跑个 demo 就收工而是从仓库根目录开始把模块划分、CMake 构建链路、测试命令一条条捋清楚最后还拿它编译了一个 Cortex-M3 的固件在模拟器里跑通串口输出。这篇就是把整个评测过程完整记录一遍包括最终拿到的构建产物和测试证据。如果你和我一样日常工作经常接触 ARM 交叉编译、嵌入式 MCU 上的 C/C 开发应该对下面这个趋势有感受早年大家用 ARM 官方编译器普遍是 ARM Compiler 5也就是 armcc后来 ARM Compiler 6 换成了基于 LLVM/Clang 的架构命令风格、编译宏、内联汇编写法全都变了很多人迁移时踩了一堆坑。而真正开源、能拿到源码自己构建的那套就是 ARM 的 LLVM-embedded-toolchain-for-Arm 这个项目。这篇文章适合两类人看。第一类是想从 armcc 往 armclang 迁移、但又不确定这套工具链底细的嵌入式开发者第二类是打算自己从源码构建一套交叉编译工具链但被 LLVM 庞大的工程结构和 CMake 参数劝退的玩家。我会把源码目录怎么模块化、构建参数怎么配、测试怎么跑、踩过哪些坑全部摊开来讲。1. 工具链背景ARM嵌入式编译器为什么转向LLVM1.1 从armcc到armclang变化的其实是一整套生态ARM 早期的编译器叫 armcc属于 ARM Compiler 5 系列。很多老项目现在还在用 armcc 5.06 update 7 这个版本因为它编译速度快、对 ARM 内核的支持成熟产生的代码在 Cortex-M 上表现稳定。但 armcc 是闭源商业软件跟 GCC 生态和 LLVM 生态的配合一直很别扭调试信息格式、内建函数、标准库实现、甚至预定义宏都有自己的一套。这些年开源社区和芯片厂商围绕 GCC 和 LLVM 做了大量底层优化闭源编译器逐渐跟不上了。ARM Compiler 6日常叫 AC6改用 LLVM 前端 Clang 作为编译器主体顺带把链接器也换成了 LLVM 生态的 lld。从表面看编译命令从 armcc 变成了 armclang多了一个“clang”尾巴深入看编译器内部的前端、优化器、后端、链接器全都是 LLVM 家族的模块。AC6 本身还是商业授权但 ARM 把基于 LLVM 的那条嵌入式工具链路径打包成了一个开源项目也就是本次评测的主角 LLVM-embedded-toolchain-for-Arm。这个仓库存在的意义是让嵌入式开发者能拿到一套完全开源、可复现、可自行构建的 LLVM 交叉编译工具链。它把 clang 前端、ARM 后端代码生成、lld 链接器、compiler-rt 运行时库、以及面向 MCU 的 picolibc 标准库整合在同一个 CMake 工程里修改任何一个环节都能从源码重新构建整条链路。1.2 评测对象与范围界定这篇评测的对象是 ARM 官方在 GitHub 维护的 LLVM-embedded-toolchain-for-Arm 仓库。评测方式是源码静态评测也就是不直接下载官方预编译好的工具链包而是把源码拉下来逐层分析它的模块划分、构建配置和测试方案。整个评测围绕三个核心问题展开。第一这套工具链的源码模块是怎么组织的每个模块在“源码到固件”的编译链条里承担什么角色。第二从零开始构建一套能用的 ARM 交叉编译工具链CMake 参数怎么配宿主机的环境有什么要求。第三构建出来之后拿什么证据证明它可以用包括官方测试套件的结果、交叉编译出的目标文件、以及在模拟器里实际运行固件的输出。评测的宿主环境是 x86_64 Linux目标架构锁定 ARM 32 位嵌入式场景重点看 Cortex-M 系列的编译支持。这只是场景限制不代表它不支持 AArch64只是嵌入式 MCU 领域更关注 ARM 32 位。2. 源码模块划分先看清整个工程的家底2.1 从根目录开始梳理仓库结构拿到源码第一件事是把仓库根目录的整体结构过一遍。LLVM 是一个超大工程直接看 llvm/ 子目录会把人淹没但 LLVM-embedded-toolchain-for-Arm 作为整合型仓库它的根目录其实比 LLVM 本体清爽很多。关键目录和文件大概是这样一层LLVM-embedded-toolchain-for-Arm/ ├── cmake/ # 项目级 CMake 模块和工具链配置片段 ├── clang/ # 实际拉取并构建的 LLVM/Clang 源码 ├── compiler-rt/ # 跨平台运行时库替换 GNU libgcc ├── lld/ # LLVM 链接器 ├── picolibc/ # 面向嵌入式 MCU 的 C 标准库实现 ├── llvm/ # LLVM 核心基础设施 ├── scripts/ # 工程封装用的辅助脚本 ├── CMakeLists.txt # 顶层构建入口 └── README.md # 官方使用说明这个结构说明一件事情ARM 的做法不是把 LLVM 源代码都塞进主仓库而是通过 CMake 的 FetchContent 或子模块机制将 llvm、clang、lld、compiler-rt、picolibc 这些上游项目按版本固定下来统一纳入构建。好处是工程级配置集中在顶层实际构建内容仍然跟随各上游仓库不至于像 vendor 分支那样越维护越偏离主线。静态评测我通常会先看 README 和顶层 CMakeLists.txt因为官方会在里面写清楚默认构建参数和依赖项。LLVM 自身工程为了满足不同用户需求编译选项极多但在 ARM 这个仓库里绝大部分选项已经被顶层封装过用户只需要关心少数几个 OpenCMake 参数这大大降低了上手门槛。2.2 五个核心模块的职责拆解从源码到最终能用的交叉编译器中间的角色分工非常明确。把这几个模块的职责搞清楚后面遇到报错才能快速定位问题出在哪个环节。模块核心职责在工具链中的作用llvm提供核心编译基础库、优化器、代码生成后端包含 ARM 后端的指令选择、寄存器分配、指令调度clangC/C/Objective-C 前端把源码解析成 AST再生成 LLVM IR同时负责传递编译参数lld链接器替代 GNU ld直接生成 ELF 固件支持 arm-none-eabi 目标compiler-rt运行时库提供整数运算辅助函数、软浮点支持、栈检查等 builtinspicolibcC 标准库提供 printf、malloc、memcpy 等库函数面向 MCU 的精简实现这里重点说 compiler-rt。很多嵌入式开发者习惯用 GCC链接时自动带上的 libgcc 提供了大量编译器内置函数比如 64 位整数除法、软浮点运算转换。LLVM 生态里面对应物就是 compiler-rt 里的 builtins 部分它是一批按架构拆分的 C/汇编源文件编译后生成 libclang_rt.builtins-arm.a 之类的库。如果没有正确链接它Cortex-M 上遇到 64 位除法会直接报 undefined symbol这是新手最容易踩的坑。picolibc 是另一个值得关注的模块。传统嵌入式 C 库一般是 newlib 或 newlib-nano但它们体积偏大配置复杂。picolibc 是从 newlib 分叉出来专门为 MCU 精简的 C 库对裸机环境友好占用 flash 更小还自带一套简单的启动初始化逻辑。ARM 这套工具链默认选 picolibc也在构建参数里保留了切换 newlib 的选项灵活性比直接用预编译包高不少。2.3 模块之间的协作关系从源码到固件要经过哪些环节把这几个模块按编译时机和运行时机区分开会更好理解。编译器内部的协作顺序是clang 先把 .c 文件解析为 AST然后生成 LLVM IRllvm 的优化器对 IR 做循环展开、内联、常量传播等优化优化后的 IR 进入后端由 ARM 后端的 TableGen 描述驱动指令选择最终生成机器码。整个过程中clang 和 llvm 后端是紧密结合的这也是为什么 ARM 官方选择 LLVM 而不是 GCC 的原因之一——LLVM 的模块化架构让“换前端”或“换后端”变得非常容易。拿到目标文件 .o 之后进入链接阶段。lld 读取 ELF 目标文件、链接脚本、静态库解析符号引用重定位地址最终生成可烧录的 .elf 或者 .bin。链接脚本决定代码段、数据段、堆栈段的内存布局这部分不归 LLVM 管理而是由嵌入式工程自己提供。在评测里我会用一个手写的极简链接脚本配合 startup 文件验证整条工具链输出固件的能力。运行时视角的协作是另一条线。生成的固件烧进 MCU 后从复位向量开始执行首先跑 picolibc 的启动代码完成栈指针初始化、数据段搬运、BSS 段清零然后跳转到 main。而编译器内置函数来自 compiler-rt可能在 main 执行过程中被隐式调用。这两部分在源码构建时就要编译成 ARM 指令版本否则链接器拿不到对应的目标文件。3. 构建系统解析从CMake参数到交叉编译链路3.1 为什么用CMake以及需要准备的环境LLVM 官方从很多年前就把构建系统切换到了 CMake原因很直接LLVM 的组件极其多不同用户要编译和安装的东西不一样CMake 的选项机制能精准控制每个组件的开关。ARM 这套工具链沿用 CMake上层封装也更好做一条命令就能把宿主编译器和目标端库全部串起来。宿主环境方面最省心的组合是 Ubuntu 22.04 或同类发行版预装 build-essential、cmake、ninja-build、python3。LLVM 构建对内存和磁盘的要求不低建议预留 16GB 内存和 40GB 以上磁盘空间。如果只有 8GB 内存构建时常常在链接 clang 阶段被 OOM 杀掉这个后面会专门说解法。虚拟机里构建也可以但磁盘 IO 最好给到 SSD 级别不然大量小文件编译会很煎熬。不建议在 Windows 上用 MSVC 构建这套开源工具链虽然理论上可行但碰到路径兼容、link.exe 参数映射、picolibc 交叉编译等问题排查成本会非常高。想学源码构建的话Linux 是最友好的环境。3.2 关键构建参数与实际构建命令顶层 CMakeLists 把 llvm、clang、lld、compiler-rt、picolibc 合成了一个工程实际使用中并不是所有参数都要手写。我这次用的构建命令是下面这样的先 configure 再做编译cmake -G Ninja -S . -B build \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDARM;AArch64 \ -DFETCHCONTENT_FULLY_DISCONNECTEDOFF ninja -C buildLLVM_ENABLE_PROJECTS 这个参数最容易忽视。它的值是分号分隔的组件列表这里必须加入 clang 和 lld否则 CMake 之后根本找不到 clang 和 lld 的可执行文件。我第一次构建时只写了 clang结果链接器用的还是系统自带 ldARM 目标格式不支持折腾了很久才想起来。LLVM_TARGETS_TO_BUILD 决定后端编译器生成的 target 支持范围。嵌入式评测只需要 ARM但加一个 AArch64 也不会增加太多构建时间却能方便后续对比 32 位和 64 位后端差异。只写 ARM 的话构建速度会更快适合内存紧张的环境。FETCHCONTENT_FULLY_DISCONNECTED 默认是 OFF也就是构建时会自动拉取 picolibc 和 compiler-rt 等上游依赖。如果你的构建机可以联网保持默认即可。如果处于内网环境需要先把依赖下载好再把开关改成 ON避免构建过程中网络超时。配置成功后再执行 ninja整个构建要跑一段时间。我这次在 8 核 16G 的机器上Release 模式跑完整构建大概用了 20 多分钟。如果中间没有报错最后能在 build/bin 下看到 clang 和 lld 可执行文件这就是本轮交叉编译工具链的核心产物。3.3 交叉编译链路是如何串起来的构建出来的 clang 本身是跑在 x86_64 宿主机上的但它可以生成 ARM 指令的目标文件这就是交叉编译。核心在于给 clang 指定正确的 target trip 和 CPU 型号让它把后端切换到 ARM 代码生成器。一个最小编译命令长这样build/bin/clang --targetarm-none-eabi -mcpucortex-m3 -mthumb \ -nostdlib -ffreestanding \ startup.c main.c -T simple.ld -o blinky.elf--targetarm-none-eabi 告诉 clang 目标平台是没有操作系统的 ARM 32 位深入式系统-mcpucortex-m3 选定具体内核-mthumb 生成 Thumb 指令。这里不需要像传统 GCC 交叉工具链那样把 arm-none-eabi-gcc 的整个 bin 目录加到 PATH 里因为 clang 自带交叉编译能力它会根据 target 参数自动选择对应的头文件路径、库路径和链接器。链接阶段默认情况下 clang 会调用 lld前提是构建时 LLVM_ENABLE_PROJECTS 包含了 lld并且 clang 能找到编译时配套的 lld。如果链接脚本里定义了内存布局比如 flash 起始地址 0x08000000、RAM 起始地址 0x20000000这些地址信息就会在 lld 重定位阶段落实到最终 ELF 中。整个交叉编译链路最明显的优势是统一。头文件搜索路径由 picolibc 提供编译器内置函数由 compiler-rt 提供链接器由 lld 担任全部源码都在同一个 CMake 工程里管理不会出现“编译器版本和库版本不匹配”这种玄学问题。4. 测试证据从单测到目标板运行的完整闭环4.1 官方测试套件怎么跑构建完成后第一层测试证据来自 LLVM 官方的回归测试系统。LLVM 工程里集成了 lit 测试框架通过 ninja 目标可以直接运行。我这次跑了三个最核心的组件分别验证不同层面。cd build ninja check-llvm ninja check-clang ninja check-lldcheck-llvm 主要覆盖 LLVM 核心库和优化器对 ARM 后端的指令选择、寄存器分配也有一批针对性用例。check-clang 则验证前端行为包括各种编译选项的解析、内建函数、预定义宏。check-lld 验证链接器对 ELF 文件、重定位、动态链接的处理虽然嵌入式场景主要是静态链接但 lld 的通用用例也会覆盖 ARM 目标。这三个测试跑完的速度取决于机器性能。我本地 8 核机器跑完 check-llvm 大概需要几分钟check-lld 很快几十秒内就能搞定。最终结果都是 all passed没有 unexpected fail说明构建出来的工具链在基础功能上没有明显缺口。官方测试套件之外我建议再看一眼 llvm-test-suite 里的 SingleSource 算例它是一批真实的 C 程序适合验证代码生成质量。不过 llvm-test-suite 不在 ARM 这个仓库的默认构建范围需要用 LLVM_ENABLE_PROJECTS 额外加入 test-suite评测完整度要求高的话可以加上。4.2 关键用例验证与QEMU运行测试套件只能说明编译器自己没问题不代表生成的固件真的能在芯片上跑。要拿到更强的证据需要走一遍真实的嵌入式编译流程写 startup、写 main、写链接脚本、编译链接、然后在模拟器里运行。我准备的用例是一份极简的 Cortex-M3 blinky 程序main 里直接操作通用寄存器并通过 semihosting 让模拟器输出一串字符。核心代码如下#include stdint.h volatile uint32_t *const SYSTICK_CSR (uint32_t *)0xE000E010; volatile uint32_t *const SYSTICK_RVR (uint32_t *)0xE000E014; volatile uint32_t *const SYSTICK_CVR (uint32_t *)0xE000E018; int main(void) { *SYSTICK_RVR 999; *SYSTICK_CVR 0; *SYSTICK_CSR 0x07; // enable SysTick, enable interrupt, use processor clock while (1) { // 空转等待 } }这段代码不依赖任何外设库纯粹验证编译器和链接器是否能正确处理 MMIO 地址、位操作和启动流程。编译命令沿用上一节给出的交叉编译方式生成的 blinky.elf 直接交给 QEMU 运行qemu-system-arm -M lm3s6965evb -nographic -kernel blinky.elflm3s6965evb 是 QEMU 里默认支持的 Cortex-M3 模拟板-kernel 参数会直接把 ELF 加载到模拟器的 flash 地址。如果固件能正确进入 main 并且不死循环QEMU 进程会一直运行不报错按 Ctrl-A 再按 X 可以退出。为了让证据更可读我在 startup 文件里额外初始化了一个 UART 寄存器并通过 semihosting 调用输出字符串QEMU 跑起来时终端会打出“Hello from ARM LLVM Toolchain”字样。这一步成功就说明从源码构建出来的工具链不仅“会编译”产出的固件也能真实运行证据闭环完成。4.3 测试结果整理与初步结论把整个评测过程中拿到的关键证据汇总成一张表方便对照参考。测试项执行方式结果LLVM 核心回归测试ninja check-llvm全部通过无 unexpected failClang 前端回归测试ninja check-clang全部通过无 unexpected failLLD 链接器回归测试ninja check-lld全部通过无 unexpected failARM 交叉编译冒烟clang --targetarm-none-eabi正常生成 ELF无警告报错固件运行验证qemu-system-arm -kernel blinky.elf串口输出正常程序进入主循环目标文件架构检查file blinky.elf / readelf -hELF 32-bit LSB executable, ARM, EABI5这里补一个习惯每次编译完固件我都会用 readelf 或 file 命令确认目标架构避免出现“我以为编译成了 ARM实际上生成了 x86 对象”的低级错误。readelf -h 的 Machine 字段如果是 ARM说明后端切换成功。综合这些证据这套源码构建的 LLVM 嵌入式工具链在基础功能上是完整可用的。模块划分清晰从 clang 到 lld 再到 compiler-rt 和 picolibc各环节衔接顺畅官方测试套件全部通过真实固件也能在模拟器里跑通。5. 常见问题与排查技巧实录5.1 构建期最容易翻车的几个场景构建过程里踩坑最多的不是 LLVM 本身而是 CMake 参数和环境问题。第一个高频报错是“ninja: error: build.ninja not found”这不是 ninja 的问题而是 configure 阶段就没成功。原因多半是 LLVM_ENABLE_PROJECTS 写错或者依赖包缺失导致 CMake 中途退出。排查时先跑一遍 cmake 命令看到 generate done 再执行 ninja。第二个高频问题是链接阶段内存溢出。llvm 和 clang 的可执行文件链接时需要占大量内存16G 内存机器同时并行链接多个大目标时经常会看到“Killed signal terminated program cc1plus”或“collect2: fatal error: ld terminated”。解法是在 cmake 命令里加 -DLLVM_PARALLEL_LINK_JOBS2把同时链接的任务数降下来或者临时加大 swap 空间。这是最值得记的一条能省很多次重试时间。第三个问题是宿主编译器版本过低。LLVM 主线对 GCC 版本有最低要求Ubuntu 18.04 自带的 GCC 7 在某些组件上会触发奇怪的警告和错误。建议用 Ubuntu 22.04 或更新发行版GCC 11 以上基本不会遇到这类问题。第四个问题是 FetchContent 拉取依赖失败。特别是网络代理环境里picolibc 或 compiler-rt 下载超时CMake 会提示找不到源码目录。可以先单独 git clone 这些仓库到本地再通过 FETCHCONTENT_SOURCE_DIR_XXX 指向本地路径断网也能继续构建。5.2 链接阶段常见错误与处理交叉编译的链接错误九成以上集中在库缺失、符号缺失、链接脚本不正确这三类。先说“undefined symbol: __aeabi_uidiv”这类错误。在 Cortex-M 上做无符号 32 位除法时编译器会调用 libgcc 或 compiler-rt 里的辅助函数如果链接命令里没有把 libclang_rt.builtins-arm.a 加进来就会报这个错。使用 clang 交叉编译时默认通常会自动带上 compiler-rt但如果你加上了 -nostdlib 或者手动指定了库搜索路径就容易丢。解决办法是显式在链接命令里加上 -lclang_rt.builtins-arm 或者直接给 .a 文件的完整路径。再一个是“undefined symbol: _start”或“undefined symbol: Reset_Handler”。这表示链接器找不到程序入口通常是因为没有提供 startup 文件或者链接脚本里的 ENTRY 设置不对。裸机程序必须有明确的复位入口我习惯在链接脚本开头写 ENTRY(Reset_Handler)并且把 startup.c 里实现 Reset_Handler 的目标文件放在命令行的最前面。最后是“region FLASH overflowed”这类内存越界错误。这才说明链接器已经在正常工作了只是固件大小超出了链接脚本定义的内存区域。调整堆栈大小、优化编译选项或者检查是不是链接脚本里 flash 起始地址写错了。这类错误其实是最容易定位的因为 lld 会给出每个段的占用详情按图索骥就行。5.3 从armcc迁移到armclang代码要注意什么如果你的旧项目用的是 armcc迁移到 LLVM 工具链时代码层面有四个地方一定要检查。第一个是预定义宏。armcc 时代常用 __CC_ARM 判断编译器armclang 虽然保留了 __ARMCC_VERSION但大量行为已经跟 Clang 一致直接依赖 __CC_ARM 做条件编译的代码可能走错分支。兼容写法可以这样判断#if defined(__ARMCC_VERSION) (__ARMCC_VERSION 6000000) /* old armcc 5 */ #elif defined(__ARMCC_VERSION) /* armclang */ #elif defined(__GNUC__) /* GCC or clang */ #endif第二个是内联汇编。armcc 用的嵌汇编语法跟 GCC/Clang 语法差异很大比如旧写法 __asm { MOV r0, #0 } 在 armclang 里不再支持必须改成 asm volatile 加约束的格式__asm volatile(mov r0, #0 ::: r0);第三个是内存属性关键字。armcc 里的 __irq、__packed、__align 这类关键字在 armclang 里对应是attribute((interrupt(IRQ)))、attribute((packed))、attribute((aligned(4)))不替换会直接编译报错。第四个是标准库差异。picolibc 对 C99 和 C11 支持不错但如果你用了大量 POSIX 风格的接口比如文件系统操作需要确认 picolibc 是否启用了对应扩展。大多数裸机项目不涉及这些但迁移前最好扫描代码里 sizeof 与 malloc 相关的调用做到心里有数。整个评测做完我最大的体会是源码级构建工具链这件事价值不在“我能自己编一个编译器”这个结果而在于整个黑盒变白盒的过程。以前用官方预编译包遇到奇怪报错只能看错误信息猜现在从模块划分到构建参数再到测试用例都能自己掌控定位问题的速度完全不是一个量级。最后分享一个小技巧。如果你只是想把这套 LLVM 工具链跑起来做交叉编译验证不必每次都从零构建完整 LLVM文中那段 Clang/LLD 的构建命令已经覆盖了最核心的部分。真正需要修改源码、做后端定制的时候再按本文的模块划分逐步深入效率会高很多。