LLVM项目结构解析:从Clang到自定义Pass的编译器基础设施实战
做编译器工具链、语言运行时或者底层性能优化相关工作的人这几年绕不开一个名字llvm-project。无论你是在给某个新语言写后端还是在排查一份诡异的内存越界又或者只是想搞懂 Clang 到底怎么把 C 模板展开成机器码最终都会落到这个仓库上。这个项目不是一个简单的编译器而是一整套围绕“中间表示 多阶段优化 可复用后端”设计的编译器基础设施。本文我会从实际使用的角度把 llvm-project 的项目结构、构建方式、核心工作流和一些常见坑梳理一遍。适合刚接触 LLVM 的开发者也适合已经用过 Clang 但对整体架构还比较模糊的人。如果你急着先跑通一个环境可以直接跳到第二章看构建配置如果你想知道里面那么多子目录到底是干嘛的那从第一章顺着读就行。我自己从最早用 LLVM 3.x 时代的 svn 仓库一路跟到现在的 git monorepo 形态中间踩过的坑不算少这篇算是把这些经验一次性整理出来。1. llvm-project 整体架构与核心模块拆解1.1 monorepo 仓库里到底装了什么llvm-project 在 2019 年底完成了从 Subversion 到 Git 的迁移之后所有核心项目都统一收进了一个 monorepo 仓库。这个仓库的第一层目录就是一套完整工具链的骨架llvm、clang、lld、lldb、clang-tools-extra、compiler-rt、libcxx、libcxxabi、libunwind、mlir、flang、polly、openmp、bolt、libclc、pstl。很多人第一次打开这个仓库会有点懵不知道从哪看起。我建议第一个先看llvm目录它是整个项目的心脏其他东西都是围绕它长出来的。llvm目录里面包含了最核心的 IR 定义、优化 Pass、目标描述、代码生成、汇编器、反汇编器以及llvm命令行工具本身。clang目录是 C/C/Objective-C 的前端负责把源码解析成 AST再降级到 LLVM IR。lld是链接器lldb是调试器clang-tools-extra里则是 clangd、clang-tidy、clang-format 这些日常开发高频使用的工具。还有一类容易被忽略的子项目比如compiler-rt提供 sanitizer 和 builtinslibcxx是 C 标准库实现mlir是面向编译器和机器学习框架的多级中间表示。它们不在同一个版本节奏里但是都在同一个仓库里做统一管理这就是 monorepo 的好处做一次提交可以把跨项目的改动一起进去做版本回退时各模块的匹配关系也不会错乱。1.2 前端、中端、后端三层分离的设计思想理解 LLVM 最重要的角度是“前端、中端、后端三层分离”。前端负责把高级语言变成 IR中端基于 IR 做机器无关的优化后端把优化后的 IR 变成目标机器指令。这三层通过一个稳定的 IR 接口解耦所以理论上只要写一个前端就能复用所有中端优化和后端代码生成只要写一个新后端就能支持所有已经接入的前端语言。Clang 是这种解耦最典型的佐证。C、C、Objective-C 共用 Clang 前端的解析层和语义分析层但 Rust 用的是自己的 rustc 前端并直接生成 LLVM IRSwift 也走类似路线。你不需要为每种语言重新发明寄存器分配和指令选择这部分直接交给 LLVM 后端就好。IR 本身是类似汇编但层次更高的一种指令形式。比如加法会变成add i32 %a, %b控制流变成br和phi指令函数和全局变量的声明都有明确的类型系统。正是因为 IR 是一种中间语言优化 Pass 才好用一种通用的方式去改写它既不用关心原始语言语法也不用关心目标机器的寄存器数量。1.3 子项目之间的依赖关系与版本匹配在实际编译中子项目之间的依赖关系值得留意。clang依赖llvm的库lld也依赖llvm的库但是compiler-rt和libcxx与clang不是简单的库依赖它们更多是被 Clang 在编译产物时隐式引用。比如你用 Clang 开-fsanitizeaddress时编译阶段需要编译器生成对__asan_*函数的调用链接阶段则由clang驱动找到compiler-rt里的 ASan 运行时库。因为这些依赖很复杂官方才强烈建议从 monorepo 做整体构建而不是各自独立下载源码再手动配置。手动配置的版本错位问题非常多最典型的就是 Clang 版本和 libcxx 版本不一致导致链接期找不到符号或者 ABI 冲突。整体构建时版本由同一个标签决定能少掉很多麻烦。2. 构建 llvm-project配置参数才是真正的分水岭2.1 CMake 构建基础Ninja 与生成器选择构建 llvm-project 的标准姿势是 CMake Ninja。虽然官方也支持 Unix Makefiles但 Ninja 在增量构建上明显更友好而且能很好地利用多核并行。Ninja 本身只是一个构建调度器不负责编译器调用它比 Make 更快的地方在于依赖分析和并行调度策略。建议预先安装 ccache这对重复构建的帮助极大。llvm-project 代码量巨大即使只编译核心的llvm和clang也要消耗很长时间启用 ccache 后同一个源文件如果没变化就直接命中缓存能够把二次构建的时间从几十分钟压到几分钟。第一次构建时建议这样做git clone https://github.com/llvm/llvm-project.git cd llvm-project cmake -S llvm -B build -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld;clang-tools-extra \ -DLLVM_TARGETS_TO_BUILDX86;AArch64 cmake --build build -j32这里的-S llvm是指定 CMake 的源目录为仓库里的llvm子目录不是仓库根目录。CMake 配置脚本在llvm/CMakeLists.txt它会统一读取LLVM_ENABLE_PROJECTS等变量来组织整个项目的构建。LLVM_TARGETS_TO_BUILD控制为目标架构生成后端默认会构建所有支持的后端这会导致编译时间和二进制体积成倍增长。如果你只在 x86 机器上开发只填 X86 就够等到需要交叉编译到 ARM 时再把 AArch64 加进去。2.2 LLVM_ENABLE_PROJECTS 与 LLVM_ENABLE_RUNTIMES 的区别这个可能是新手最容易搞混的地方。LLVM_ENABLE_PROJECTS负责在同一个 CMake 构建里启用那些和 LLVM 核心一起编译的工具链项目比如 clang、lld、clang-tools-extra、mlir。它们的构建类型和核心一致会在同一个 build 目录下产出。LLVM_ENABLE_RUNTIMES则对应另一类项目面向“运行时”的库典型是 libcxx、libcxxabi、libunwind、compiler-rt。这些项目有一个特点它们需要为目标环境专门配置而且有可能不是当前正在运行的主机环境。比如你要用刚刚构建出来的 Clang 去交叉编译一套 C 运行库给 ARM 设备这时候就需要用LLVM_ENABLE_RUNTIMES把它们作为第二阶段的运行时构建来编译也就是说编译器本身已经先构建好了然后再用这个新编译器去编译运行库。在实践中我一般这样处理如果只想在本机快速用上 Clang那LLVM_ENABLE_PROJECTSclang;lld;clang-tools-extra就够了。只有当我想测试最新版 libc 或者做交叉编译时才把 libcxx 相关模块放进LLVM_ENABLE_RUNTIMES。这个选择会直接影响构建时长和复杂度一开始不要贪多。配置项适合场景常见模块LLVM_ENABLE_PROJECTS常规编译工具链与核心一起构建clang, lld, mlir, clang-tools-extraLLVM_ENABLE_RUNTIMES运行库、交叉编译目标库libcxx, libcxxabi, libunwind, compiler-rt2.3 常用构建选项与真实配置建议除了上面这些还有几个选项值得提前设置能直接影响构建成功率和后续使用体验。-DCMAKE_BUILD_TYPERelease是性能优化后的构建方式适合日常使用和跑大工程。Debug模式适合调试 LLVM 本身的逻辑但生成的二进制会大很多运行速度也慢不少。RelWithDebInfo介于两者之间带调试信息但已经做过优化适合需要打断点又不想损失太多性能的场景。-DLLVM_PARALLEL_LINK_JOBS1是我强烈建议加的。LLVM 的链接阶段非常耗内存尤其是使用 Debug 模式时链接一个libLLVM.so或者clang可执行文件可以轻松吃掉 10GB 以上的内存。如果不限制并行链接任务数Ninja 会同时启动多个链接任务16GB 内存的机器很容易直接 OOM。限制为 1 会让链接步骤一个一个来虽然时间略长但不会崩。另外推荐配置-DLLVM_CCACHE_BUILDON这是 LLVM 对 ccache 的原生支持选项比手动设置CMAKE_C_COMPILER_LAUNCHER更省心。启用后 CMake 会自动处理 ccache 的缓存路径和参数。还有-DLLVM_ENABLE_ASSERTIONSON值得在开发模式下开启它会在 IR 处理中插入更多安全检查很多问题在断言阶段就会被暴露出来而不是等到运行阶段出现莫名崩溃。2.4 内存、磁盘和编译时间的心理预期编译 llvm-project 不是一个瞬时操作。以 Release 模式构建llvm clang lld在 16 核机器上大概需要 15-30 分钟在 4 核机器上可能要两小时以上。Debug 模式因为优化减少、代码膨胀编译时间只会更长。磁盘方面一个 build 目录轻松超过 10GBDebug 模式可能到 30GB建议把 build 目录放在空间足够的磁盘最好不要放在系统盘里。内存方面编译阶段的峰值内存通常出现在链接时这也是前面为什么强调并行链接限制。如果你用的是 8GB 内存的小机器建议同时把-DLLVM_PARALLEL_COMPILE_JOBS也调低一点否则多核编译加链接一起上内存很容易告急。不要一开始就试图全量编译所有子项目只用LLVM_ENABLE_PROJECTSclang起步后面的模块按需再加这是对机器和耐心都比较友好的策略。3. 核心子系统实操Clang、LLD 与 LLVM IR 的组合拳3.1 用 Clang 编译并验证 LLVM IR构建完成之后第一步建议体验的是 Clang 输出 IR 的过程。很多人在理解 IR 时是从书本概念开始的但更直观的方式是自己生成一遍。比如写一个简单的sum.cpp#include cstdint int32_t sum(int32_t a, int32_t b) { return a b; }用 Clang 编译并输出 IRclang -S -emit-llvm sum.cpp -o sum.ll打开sum.ll会看到对应 IRdefine i32 _Z3sumii(i32 %a, i32 %b) { entry: %add add i32 %a, %b ret i32 %add }这段 IR 已经很接近大家常说的“和机器无关的中间表示”。函数名_Z3sumii是 C 名字改编后的符号i32是整数类型add是无符号/有符号统一处理的加法指令。-emit-llvm后面还可以加上-O2做优化观察 IR 的变化比如常数传播、指令合并等 Pass 的效果会直接反映出来。如果要看更底层的信息可以用llc sum.ll -o sum.s生成对应架构的汇编。通过对比 IR、汇编和原始 C能比较直观理解编译器每层做了什么这也是很多编译器方向的工作者入门时第一个实验。3.2 LLD 的高效链接与常见用法lld 是 LLVM 生态里的链接器。它的定位是快速、兼容 GNU ld 的主流选项并且支持多种输出格式ELF、PE/COFF、Mach-O、WebAssembly。日常使用中最少见的门槛是很多人不知道如何在编译命令中调用它。最简单的验证方式是直接指定链接器clang sum.o -fuse-ldlld -o sum如果你构建出的 lld 不在系统默认路径里可能需要加上类似-B/path/to/llvm/build/bin的选项让 clang 能找到这个链接器驱动。我自己在构建完 LLVM 后会立刻用 lld 链接一遍系统里的测试程序确认工具链是否完整可用。lld 对大项目的链接速度提升是显著的。像 Chromium 这样的大型工程在相同硬件上lld 常常比 GNU ld 快数倍。原因在于 lld 内部使用了更高效的并行处理和更少的内存拷贝同时对常见重定位类型做了专门优化。不过需要注意有些项目会依赖链接脚本或者某些 GNU ld 特有的兼容行为在切到 lld 时必须做回归测试。3.3 结合 clang-tidy、clangd 提升日常开发效率clang-tidy 和 clangd 都是 clang-tools-extra 产出的工具。clangd 是基于 Clang 的 C/C 语言服务器可以对 IDE 提供代码补全、诊断、跳转等功能。很多 VSCode 和 Neovim 用户陪它的时间比陪编译器的命令行还多。启动 clangd 前通常需要生成compile_commands.jsonCMake 构建时只要加-DCMAKE_EXPORT_COMPILE_COMMANDSON就会自动生成然后给 clangd 指定--compile-commands-dirbuild/就能正常工作。clang-tidy 做的是静态检查和重构。它不像 clang-format 那样只处理格式能做空指针判断、资源泄漏、命名规范等更语义化检查。比如对项目目录执行clang-tidy sum.cpp -- -stdc17它会先编译该文件再针对 AST 跑一系列检查。如果不满足于默认规则可以写.clang-tidy配置文件选定cppcoreguidelines-*或者bugprone-*等规则集合。这套机制特别适合需要统一团队代码风格和检查常见 bug 的场景。4. 深入优化流水线写一个自定义 Pass 并集成进工具链4.1 从 IR 到机器码的优化流程概述LLVM 中端优化通过一串 Pass 完成。所谓 Pass就是对 IR 做一次遍历和修改的过程。旧版 Pass 管理器按顺序执行已经注册好的 Pass新版 Pass Manager 则在功能上做了更好的依赖分析和并行化。优化流水线可以简单归纳为前端生成未优化的 IR中端优化 Pass 对 IR 做目标无关优化比如死代码删除、循环展开、内联后端把优化后的 IR 转成目标指令选择再做寄存器分配、指令调度、基本块布局最终产出汇编或目标文件。对这一串过程有疑问时可以用opt -O2 sum.ll -S直接跑中端优化再用llc做后端阶段这样把每个阶段分开调试比一路编译到底容易定位问题。4.2 编写并注册一个简单的 FunctionPass写自定义 Pass 是学习 LLVM 很好的切入点。下面用新版 Pass 管理器的写法实现一个没有任何实际作用、只为演示流程的 Pass它遍历函数指令并打印数量。首先创建MyPass.cpp#include llvm/IR/Function.h #include llvm/IR/Instructions.h #include llvm/Pass.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Support/raw_ostream.h using namespace llvm; namespace { class MyPass : public PassInfoMixinMyPass { public: PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { unsigned count 0; for (auto BB : F) { count BB.size(); } errs() Function F.getName() has count instructions.\n; return PreservedAnalyses::all(); } }; } // namespace注册插件extern C ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, MyPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name my-pass) { FPM.addPass(MyPass()); return true; } return false; }); }}; }写完后用 CMake 或者直接编译链接成动态库然后在opt里通过-load-pass-plugin加载并运行opt -load-pass-plugin./MyPass.so -passesmy-pass sum.ll -S -o /dev/null运行后会打印每个函数名和指令数。虽然这个 Pass 没什么实际意义但整个框架的工作方式已经完整暴露出来了插件加载、Pass 注册、Pass 执行、结果返回。后面的逻辑不管是做插桩、做分析还是做变换都是在这个架子上继续加肉。4.3 在 Clang 里启用自定义 Pass 的路径选择如果你希望 clang 在正常编译时也执行自己的 Pass有几种做法。最直接的是把 Pass 编译进一个单独插件再通过 clang 的-fpass-pluginMyPass.so选项指定插件路径。这样在编译 C/C 代码时插件里的 Pass 就会被加入优化流水线。另一种更“侵入”的方式是把 Pass 源码直接放进llvm/lib/Passes目录修改相关注册代码重新编译整个 llvm-project。这种方式能更深地嵌入流水线但每次改动都要重新编译大部分工程迭代成本高不建议日常开发使用。插件的做法更轻量速度也快多数实验场景用插件就够了。编写 Pass 时要特别注意新 Pass 管理器的接口变化。老接口里的runOnFunction已经逐渐淘汰新接口统一是run(Function , FunctionAnalysisManager )返回值是PreservedAnalyses。如果你在网上搜到老写法需要先判断那篇文章是否过时不然编译时会在 API 匹配上报一堆错误。5. 常见构建与运行问题排查实录5.1 链接时内存不足与并行任务控制这是我在帮同事配环境时遇到最频繁的问题。编译过程中报collect2: fatal error: ld terminated with signal 9或者直接被 OOM Killer 杀掉绝大多数都发生在链接阶段。LLVM 的库非常庞大Debug 模式下链接clang可执行文件往往峰值内存要 8GB 以上多个链接进程并发时内存就被吃光了。解决办法就是刚才说的-DLLVM_PARALLEL_LINK_JOBS1。这个参数是控制链接阶段并行度的设为 1 后同时最多只跑一个链接任务。如果你还担心编译阶段内存高可以用-DLLVM_PARALLEL_COMPILE_JOBS4来限制编译并发度。Ninja 本身也可以用-j控制作业数但它不会区分编译和链接这才需要 CMake 层单独设置。5.2 自定义 Pass 加载失败的常见原因加载 Pass 时最常见的报错是unknown pass name或者找不到llvmGetPassPluginInfo符号。前者通常是因为注册回调的名字和opt命令里传的字符串不一致仔细检查registerPipelineParsingCallback里的判断逻辑。后者通常是因为插件编译时的 LLVM 头文件版本和实际运行时的opt版本不一致动态库里链接的符号名对不上。确保编译插件时和构建 llvm-project 时用的工具链一致Clang 版本也保持一致基本能规避这个问题。还有一个隐蔽但常见的问题是 Pass 返回的PreservedAnalyses不准确。如果你修改了指令但返回PreservedAnalyses::all()上层分析会被错误地当作没有失效可能引发难以定位的随机错误。我的建议是拿不准时返回PreservedAnalyses::none()最多损失一些后续分析的优化机会但正确性有保障。5.3 版本升级后 API 失效的迁移策略llvm-project 的 API 变动非常频繁升级大版本后很可能出现旧代码无法编译的情况。拿 Pass 管理器来说我在 LLVM 12 到 15 的升级过程中就遇到过几次接口变更比如runOnFunction变runPassPluginLibraryInfo的注册方式也有过调整。面对这种情况最可靠的办法是用官方自带的重命名脚本和迁移指南。llvm/utils/release里有时候会提供脚本不过更多时候需要靠手动改。建议在看旧代码时先确认它对应的是哪个版本再到新版本头文件里找出对应接口。千万不要直接用旧代码尝试在新版本上编译很多报错信息并不直观容易在非关键接口上浪费时间。5.4 测试框架 llvm-lit 的使用与调试技巧LLVM 自带一套测试框架llvm-lit是它的驱动工具。运行一个测试文件可以这样llvm-lit -v test/Transforms/MyPass/my-pass.ll测试文件本身写成.ll格式里面用RUN命令描述执行方式。比如; RUN: opt -load-pass-pluginMyPass.so -passesmy-pass -S %s -o /dev/null | FileCheck %s ; CHECK: Function sum has 3 instructions. define i32 sum(i32 %a, i32 %b) { %add add i32 %a, %b ret i32 %add }FileCheck会检查输出中是否包含CHECK后面的模式。这种测试格式对开发者非常友好因为它在纯文本层面验证结果不依赖二进制输出。遇到测试失败时可以用llvm-lit -v看完整输出也可以直接手动运行 RUN 命令里的实际指令观察结果。6. 给初学者的实用建议与个人经验6.1 从 Clone 到第一次成功构建的最小路径如果你是第一次接触 llvm-project我最推荐的最小路径是这样只构建llvm和clang目标架构只保留本机架构。不要一开始就加lld、mlir、flang这些模块会增加构建负担也不会在你最初的实验里发挥作用。等到确有必要时再重新跑 CMake 配置并增量构建。配置示例cmake -S llvm -B build -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_CCACHE_BUILDON \ -DLLVM_PARALLEL_LINK_JOBS1这样构建完你已经有可用的 clang、opt、llc、llvm-dis 等工具足够做 IR 生成、优化和汇编生成了。等熟悉了这套流程再逐步加入 lld、clang-tools-extra 等子项目。6.2 学习路径建议读代码、跑测试、改 Pass我的个人经验是学习 LLVM 最有效的方式不是从头到尾读文档而是“带着问题改代码”。比如你想搞明白内联优化是怎么工作的就去看llvm/lib/Transforms/IPO/Inliner.cpp然后尝试改一个阈值重新构建后看 IR 变化。这个过程远比单纯背书里的概念来得深刻。跑测试也是很好的学习素材。llvm/test/Transforms目录下有大量的 IR 测试样例每个测试文件都把一个优化场景压缩成了最简形式。读这些测试能快速理解某个优化 Pass 的输入输出格式。你可以用llvm-lit -v跑单个测试观察 RUN 命令和 FileCheck 检查项之间的配合逐步把整个测试习惯建立起来。6.3 关于调试与文档阅读的几点体会调试 LLVM 问题时-debug-onlyxxx这种输出方式非常有用。很多 Pass 内部存在带调试等级的日志输出给opt或者clang加上-debug-onlyloop-vectorize之类的参数就能看到对应 Pass 的详细诊断信息。配合-print-after-all可以打印每个 Pass 运行后的 IR用来定位 IR 在哪个环节发生了变化。文档方面官方llvm/docs目录里的WritingAnLLVMPass.rst、LangRef.rst、Passes.rst都很值得反复查阅。其中LangRef.rst是 IR 语义的权威定义Passes.rst收录了常见 Pass 的说明。我经常在写完一个 Pass 之后再去对照这些文档会发现自己很多理解是在动手之后才真正补全的。6.4 用 ccache 和分布式缓存提升迭代效率开发 LLVM 相关代码时反复编译是常态。启用 ccache 是最基础的优化但如果你在一个团队里还可以配置 ccache 共享缓存或者使用 sccache 这类支持远端存储的工具。共享缓存可以让新成员第一次构建就直接命中大量缓存节省非常多时间。我自己的习惯是在.bashrc里设置CCACHE_DIR指向专门的大分区然后给 CMake 配置加上-DLLVM_CCACHE_BUILDON。每次改代码之后增量构建的时间基本能控制在可接受范围。如果你要频繁清除缓存需要注意只清理对应版本的缓存不要一个ccache -C把所有内容清掉否则又要重新受一次全量编译的苦。根据我个人经验真正把 llvm-project 用起来第一关永远是构建环境第二关才是源码理解。只要先把一个小工具链跑顺后面再往里闯就会顺畅很多。