ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

LLVM嵌入式工具链源码评测:从构建到测试的完整实践

LLVM嵌入式工具链源码评测:从构建到测试的完整实践 我第一次看到“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 处理、指令选择基础设施优化管线是否满足裸机开发是否能按目标裁剪clangC/C 前端负责语法解析、语义分析、生成 LLVM IRARM/AArch64 ABI 处理、内建函数、-mcpu家族支持lldELF/Mach-O/COFF 链接器ELF 端口的--gc-sections、-T链接脚本、map 文件支持compiler-rt编译器生成代码所需的运行时支持库builtins 里 ARM 软浮点、除法辅助函数__aeabi_*libcxx/libcxxabi/libunwindC 标准库、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 --targetarm-none-eabi -mfloat-abihard和-mfloat-abisoft产生完全不同但可预期的汇编输出。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_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_ENABLE_RUNTIMEScompiler-rt;libcxx;libcxxabi;libunwind \ -DLLVM_TARGETS_TO_BUILDX86;ARM;AArch64 \ -DLLVM_INSTALL_UTILSON \ -DLLVM_ENABLE_TERMINFOOFF \ -DLLVM_PARALLEL_LINK_JOBS4这里有一个非常常见但很多人会踩的坑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_UTILSON会生成llvm-objcopy、llvm-readelf、llvm-size等工具。嵌入式开发里这些工具在镜像裁剪和 ELF 分析时很有用建议默认打开。关键选项汇总CMake 选项配置值作用与备注CMAKE_BUILD_TYPERelease工具链自身采用优化构建体积更小、速度更快LLVM_ENABLE_PROJECTSclang;lld构建编译器前端和链接器LLVM_ENABLE_RUNTIMEScompiler-rt;libcxx;libcxxabi;libunwind构建目标架构的运行时库LLVM_TARGETS_TO_BUILDX86;ARM;AArch64后端目标集合直接影响编译时间LLVM_INSTALL_UTILSON安装 binutils 替代工具LLVM_PARALLEL_LINK_JOBS4限制并行链接任务数避免 OOM3.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_JOBS4同时在系统层面加一个 swap 文件作为兜底。这一步不要省略尤其是你机器内存只有 16GB 时。第二个坑是 Terminfo 找不到。CMake 配置阶段可能正常但编译或链接某个需要 TUI 支持的工具时会报unable to find shared library terminfo这个报错非常误导人看着像缺少第三方库实际是 CMake 搜索 terminfo 的路径和编译器不匹配。我在服务器上直接加了-DLLVM_ENABLE_TERMINFOOFF解决。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-gnusudo 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 --targetaarch64-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 --targetarm-none-eabi -mcpucortex-m4 \ -mfpufpv4-sp-d16 -mfloat-abihard -O2 -S float_add.c -o -生成的汇编重点片段vadd.f32 s0, s0, s1 bx lr这个结果说明工具链在硬浮点 ABI 下的代码生成行为正确。作为对照如果我把参数改成-mfloat-abisoft同样的源码会变成对辅助函数的调用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_LTOThin让工具链自身也做一次链接时优化。但这会显著增加构建时间对开发机性能要求也更高。如果不是为了发布最终给终端用户的工具链包开发阶段还是保持普通 Release 更好迭代速度更重要。5.2 构建配置上的两个建议第一个建议是打开 ccache。LLVM 这样体量的项目增量编译依然可能很慢尤其当你需要频繁改 CMake 开关并重新构建时ccache能省掉大量重复编译。在 CMake 命令里加一行-DLLVM_CCACHE_BUILDON即可。第二个建议是把测试纳入 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 代码”——这件事早已不是新闻。真正让我安心的是我能从源码目录、构建配置和测试日志三个维度说清楚这个工具链的边界在哪里以及当它出问题时我应该先去翻哪个目录。这大概就是源码级评测与“装上能用”之间最本质的区别。
返回列表