
很多人第一次接触llvm-project这个仓库是因为 Xcode 的默认编译器从 GCC 换成了 Clang也有人是在折腾 Rust 工具链时发现rustc背后真正干重活的是 LLVM还有做 AI 芯片和 GPU 编译器的朋友写到最后发现所有代码都要落到 LLVM IR 上。但有个误解特别普遍大家以为llvm-project就是一个编译器甚至是C 语言编译器。实际上这个仓库更像一整座编译器生态城——里面有前端、优化器、后端还有调试器、链接器、C 标准库、运行时库甚至一套专门给 AI 和硬件厂商准备的通用中间表示框架。这篇文章我以实际使用者的视角把llvm-project从到底是什么讲到怎么在本地把整条工具链编出来、再写一个自定义 Pass 跑通适合打算入门编译器、做静态分析、搞工具链集成或者做性能优化的同学。1. llvm-project 到底是什么一个关于编译器全家桶的真相1.1 和传统编译器完全不同三段式架构如何改写游戏规则编译器这行传统做法是一块铁板。GCC 虽然也分前端、中端、后端但内部耦合程度很高你想把它的中间表示抽出来做点自己的事基本等于重写一遍。LLVM 从诞生那天起就是另一套思路——它把编译过程彻底拆成三个独立阶段前端Frontend把源代码变成中间表示 IR比如 Clang 负责 C/C/Objective-C。优化器Optimizer对 IR 做各种与机器无关的优化这一层就是opt和无数个 Pass。后端Backend把 IR 变成具体架构的机器码X86、ARM、RISC-V、NVPTX 都在这一层。关键设计是三个阶段的唯一接口就是 LLVM IR。前端不需要知道目标机器长什么样后端不需要知道源代码是什么语言优化器则只认 IR 这一种通用语。打个比方传统编译器像一条定制流水线每一段都绑死在一起LLVM 则像乐高积木前端是积木 A优化器是积木 B后端是积木 C你想换哪块就换哪块只要接口对上就行。我第一次真正理解这个设计的分量是自己写了一个简单 Pass 想统计代码里的函数调用关系。放在 GCC 里你得去读它的 GIMPLE 内部结构文档少、API 不稳定。在 LLVM 里我只需要写一个继承PassInfoMixin的类遍历Function的BasicBlock和Instruction然后让opt加载这个插件就完事了。这种门槛低到不可思议的体验是 LLVM 生态最核心的竞争力。1.2 谁在往这个仓库里押注从苹果到整个芯片行业llvm-project不是某个公司关起门来的玩具。2000 年 Chris Lattner 在伊利诺伊大学香槟分校启动了这个项目后来被苹果招至麾下苹果用 Clang 替代 GCC 成了 Xcode 默认编译器。再后来苹果开源了整个工具链Google 在 Android NDK 里默认使用 ClangMicrosoft 的 Visual Studio 也集成了 Clang 工具集。但真正让这个仓库封神的是它成了无数编程语言的后端。Rust 的rustc使用 LLVM 做代码生成Swift 编译器基于 LLVMJulia、Kotlin/Native、Zig 也都挂在 LLVM 这条线上。你在这些语言里写的代码经过各自的前端处理最后都会变成同一套 LLVM IR 体系里的东西。这也是为什么 LLVM IR 文档值得所有工具链开发者仔细读一遍——你学会它等于同时看懂了半条现代编译世界的地图。硬件厂商同样离不开它。NVIDIA 的 CUDA 工具链里藏着 LLVM 的身影AMD 的 ROCm 编译器基于 LLVM越来越多的 AI 芯片公司直接修改 LLVM 后端来生成自家指令集。这些场景不是锦上添花而是没了它根本没法干活。1.3 仓库目录地图该找什么去哪个目录克隆下来的llvm-project第一眼会很吓人目录多到数不过来。我按使用频率给你标一下重点目录作用使用场景llvm/LLVM 核心IR、优化器、后端框架写 Pass、学 IR、做后端移植clang/C/C/Objective-C 前端日常编译、静态分析lld/链接器链接加速、自定义链接流程lldb/调试器调试 C/C/Rust 代码libcxx/、libcxxabi/C 标准库与 ABI 层替代 libstdccompiler-rt/运行时库、sanitizer 实现AddressSanitizer、内存分析mlir/多级中间表示框架AI 编译器、芯片编译器flang/Fortran 前端科学计算工具链polly/多面体优化循环优化、数据局部性优化bolt/二进制优化与布局工具Profile 优化后重新排布二进制llvm/是心脏mlir/是新贵。如果你只想快速体验一下编译流程llvm/加clang/就够如果你想做编译器二次开发llvm/必须吃透如果你是做 AI 编译栈的mlir/那套 TableGen 和 dialect 机制值得单独花三个月啃。2. LLVM IR整套工具链的灵魂和中间契约2.1 SSA 形式与无限虚拟寄存器IR 凭什么是优雅的SSAStatic Single Assignment静态单赋值是 LLVM IR 的基石。它的核心约束是每个变量只能被赋值一次。听起来像自缚手脚实际上是为了让优化器能够安全地做数据流分析。你想如果一个变量在代码里被改来改去那任何一处读取都需要搞清楚现在读到的是哪次赋值分析起来成本极高。SSA 直接把这个乱局消灭在源头每个值都有唯一的生产者优化器看到%x add i32 %a, %b就知道%x的值只可能来自这一条指令没有任何歧义。另一个让新手印象深刻的点是无限虚拟寄存器。LLVM IR 里的%1、%2这些临时值数量不受真实机器寄存器限制。真正的寄存器分配是后端阶段的事前端和优化器根本不用操心物理寄存器够不够用。这就把数据处理和硬件约束两个问题彻底解耦了。我第一次读 IR 代码时有个特别舒服的体验不需要关心变量的生命周期管理。IR 里没有malloc/free的说法内存操作变成了显式的load/store指令对象生命周期在编译器层面表现为 alloca 和指针分析。这种少一层心智负担的设计让后续所有分析工具都变得好写多了。2.2 从一句 C 代码到 IR 再到汇编完整流水线演示我经常用一段很小的代码给新人演示整条流水线。假设有这样的函数int add(int a, int b) { return a b; }先用 Clang 把它转成 LLVM IRclang -S -emit-llvm add.c -o add.ll生成的 IR 核心部分长这样define i32 add(i32 %a, i32 %b) { entry: %add add nsw i32 %a, %b ret i32 %add }i32是 32 位整数类型%a、%b是函数参数本身就是 SSA 值add nsw表示这个加法不考虑带符号溢出no signed wrap这给优化器留了更多变换空间。再把 IR 变成 X86 汇编llc add.ll -o add.s会得到类似这样的结果add: # add leal (%rdi,%rsi), %eax retq看到没中间那层 IR 把语义表达和机器细节完美隔开了。你在 IR 层面只关心add nsw i32 %a, %b这个抽象操作后端负责选择合适的指令leal一条指令同时完成加法并放到%eax这是后端优化的功劳。2.3 Pass 管线优化就是一层层脱衣服优化器的工作方式是让 IR 依次经过一系列 Pass。每个 Pass 只做一件小事mem2reg把alloca提升为 SSA 虚拟寄存器instcombine做指令合并和常量折叠gvn做全局值编号消除冗余计算loop-unroll做循环展开。这些小 Pass 串联起来就是完整的优化管线。opt命令就是干这个的。举个例子把一个未优化的 IR 跑一遍标准优化opt -S -O2 add.ll -o add_opt.ll如果你好奇每个 Pass 到底做了什么可以加上-print-after-all看每一轮优化前后的完整 IR。我第一次跑这个命令时看着 IR 里那些冗余的 load/store 一点点消失才真正理解了编译优化四个字的分量——它不是玄学而是一套有迹可循的变换序列。这里给一个经验学习阶段不要直接去看opt -O2的完整输出信息量太大。先用opt -passesmem2reg单独跑一个 Pass观察它对同一段 IR 做了什么修改这样积累起来的知识才是扎实的。3. 从源码构建 llvm-project踩过坑才知道的编译细节3.1 环境准备与磁盘规划一次性说清楚要多少家底自己编译llvm-project是很多人的噩梦但不是因为难而是因为没想清楚就开始。先说最基本的硬性条件磁盘Release 构建带 Clang/LLD 大概要 3050 GB带全部子项目更夸张我见过装完超过 100 GB 的。记得提前df -h确认。内存链接阶段最吃内存。16 GB 内存会有点紧张32 GB 会比较舒服。内存不够就限制并行链接任务数。操作系统Linux 和 macOS 都很顺畅Windows 也能构建但坑更多新手建议先拿 WSL 或 Linux 虚拟机练手。工具链需要装好 CMake3.20、Ninja、Python 3、GCC 或 Clang。推荐的构建命令git clone --depth1 https://github.com/llvm/llvm-project.git cd llvm-project mkdir -p build cd build cmake -G Ninja ../llvm \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_PARALLEL_LINK_JOBS2 ninja--depth1只拉最新提交能省大量下载时间。LLVM_TARGETS_TO_BUILDX86只保留 X86 后端这一步能砍掉非常可观的编译量。如果你还需要 ARM 或者 RISC-V再往列表里加但要清楚每个 target 都是要编译时间的。3.2 CMake 里那些改一个省半小时的开关构建 LLVM 的 CMake 参数多到让人发晕但真正值得反复调整的就这么几个参数推荐值说明CMAKE_BUILD_TYPERelease编出的编译器性能最好Debug会慢数倍LLVM_TARGETS_TO_BUILD按需选只编需要的后端快速省时LLVM_ENABLE_PROJECTSclang;lld;lldb需要哪些子项目就列哪些LLVM_USE_LINKERlld或gold用并行链接器链接速度差好几倍LLVM_PARALLEL_LINK_JOBS24内存不够时的保命选项LLVM_CCACHE_BUILDON配合 ccache 做增量编译二次构建极快很多人第一次构建失败都栽在链接阶段。默认情况下 CMake 会尽量多路并行链接导致内存直接爆掉然后出现clang: error: unable to execute command: Killed。我踩过一次后就养成了先nproc看核数、再根据内存设置LLVM_PARALLEL_LINK_JOBS的习惯。内存 16 GB 的机器设成 2 比较稳妥32 GB 可以试试 4。另外如果你机器上已经有 lld一定把LLVM_USE_LINKERlld开上ld.lld比系统默认的ld.bfd快得多尤其是最后链接clang这个大型可执行文件时差距是分钟级别的。还有一个很多人忽略的点开发调试模式下记得加上-DLLVM_ENABLE_ASSERTIONSON。它会让 LLVM 内部的断言全部打开很多隐性问题会直接崩溃并给你报出错位置而不是悄悄产出错误结果。代价是运行速度下降但调试阶段这完全值得。3.3 构建提速的实操组合多核、ccache、增量构建构建慢是劝退很多人的第一原因。我的实测经验是一套组合拳打下来全量构建时间可以压缩到原来的三分之一增量构建甚至能压到分钟级。第一招用 Ninja 不要用 Make。Ninja 的依赖分析和并行调度比 Make 精细得多这是 CMake 默认推荐它的原因。第二招开 ccache。在 CMake 里加一行-DLLVM_CCACHE_BUILDONccache 会缓存每个编译单元的产物。你改了一个.cpp文件重新构建时其他文件直接命中缓存编译时间从几十分钟降到几十秒不是梦。我第一次体会到这个差距时当场把 ccache 列入了所有 C 项目的默认配置。第三招拆解增量构建。ninja clang -j16只构建单个目标不碰其他子项目。日常开发中我基本都是ninja clang加ninja llc、ninja opt这几个高频目标不会傻乎乎等全量。最后如果你只是需要opt来做 Pass 开发其实不需要编完整 Clang。可以只编llvm核心加tools/opt相关目标ninja opt llc这样构建时间和内存压力会小很多。等确实需要把自定义 Pass 接进 Clang 流水线时再编 Clang 也不迟。4. 仓库里的宝藏子项目除了 Clang 还有哪些值得用4.1 工具链四件套Clang、LLD、LLDB、libcClang 已经是苹果、Android、各大云厂商的默认 C/C 前端了。它最突出的不是性能跟 GCC 互有胜负而是错误信息质量。GCC 报错的语气是我不懂你在干什么Clang 报错更像我觉得你想干这个但这个变量没定义。对新手来说这条差距直接决定调试体验。LLD 是链接器速度比 GNU ld 快好几倍。项目里如果用了 LTO链接时代码优化用 LLD 能把链接时间从十几分钟压到几十秒。我现在所有 C 项目基本都把 CMake 里加上-DLLVM_USE_LINKERlld几乎是无痛提速。LLDB 是调试器API 设计比 GDB 现代支持表达式求值、脚本化。最实用的是配合 Clang 做反汇编和内存调试时体验比 GDB 顺畅不少。做编译器开发的人还可以用 LLDB 直接调试opt和clang进程非常方便。libc 是 C 标准库的独立实现。跟 libstdcGCC 的相比它的代码结构更清晰对 C20/23 新特性的支持也非常积极。Apple 生态默认用它Android NDK 也可以切。如果你做的是嵌入式或者对标准库行为有精细控制需求的场景值得单独编一个libc试试。4.2 MLIR给芯片和 AI 编译准备的通用 IR 层mlir/是这几年llvm-project里热度最高、最活跃的部分。它解决的是一个真实痛点AI 模型和高性能计算有很长的编译降级链路从框架层算子图到硬件指令中间隔着数据布局、循环变换、内存层级、并行调度等好多层。用一套固定的 IR 表示全部这些抽象要么太高层导致硬件信息丢失要么太低层导致优化无从下手。MLIR 的思路是多级 IR 可扩展 dialect。你可以定义自己的 dialect方言来表达某层抽象比如linalg表达张量运算、affine表达仿射循环、gpu表达 GPU 内核。每一层做自己的优化然后通过 dialect 间的转换逐级降级最终落到 LLVM IR。TensorFlow、PyTorch 的某些编译路径以及大量 AI 芯片厂商的编译器栈底层都是这套机制。对想进入 AI 编译器方向的开发者MLIR 是绕不开的关键技术。我建议先跑通官方 tutorial 里的 Toy 语言例子理解 dialect 定义、mlir-optpass 管线如何工作再看mlir源码里的转换代码是怎么写的能少走很多弯路。4.3 BOLT 与 compiler-rt性能优化和动态分析的隐藏武器compiler-rt里藏着一堆宝藏最出名的是 Sanitizer 家族AddressSanitizer 抓内存越界和 use-after-freeThreadSanitizer 抓数据竞争UndefinedBehaviorSanitizer 抓未定义行为。编译时加一个-fsanitizeaddress跑测试时立刻能定位问题行。很多基础软件的内存漏洞就是靠这套工具抓出来的。bolt/是 Facebook/Meta 贡献的二进制优化工具可以读取 profile 数据对已经编译好的二进制做重新布局优化指令缓存命中率和分支预测。服务端大型二进制用 BOLT 优化后性能提升 2%5% 是常见的事。但它的使用门槛不低需要 perf 采集 profile还要对 ELF 格式有一定理解。5. 写一个自定义优化 PassLLVM 二次开发的入门路径5.1 新旧 PassManager先搞清楚你该学哪一套LLVM 历史上做过一次重大换代旧 PassManagerlegacy PM用RegisterPass宏注册通过opt -load mypass.so -mypass加载新 PassManagernew PM从 LLVM 14 开始默认启用改成了基于PassInfoMixin的结构化写法加载方式是opt -load-pass-plugin./mypass.so -passesmy-pass。新 PM 的核心理念是 Pass 之间的依赖关系显式化每个 Pass 声明自己需要哪些分析结果管理分析缓存避免重复计算。这种设计让 Pass 编写更安全、并行度更高。我第一次从旧 PM 迁移到新 PM 时最大感受就是终于不用再手写Pass基类那一堆样板了代码量直接少一半。对新读者我的建议是直接学新 PM不要再碰旧写法。老项目维护另说新项目一律按PassInfoMixin的套路来。5.2 用 opt 加载一个插件级 Pass我的第一个自定义优化写一个最简单的插件 Pass它做的事是遍历每个函数的调用指令并打印函数名// MyPass.cpp #include llvm/IR/Function.h #include llvm/IR/Instructions.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Support/raw_ostream.h using namespace llvm; namespace { class PrintCallPass : public PassInfoMixinPrintCallPass { public: PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { for (BasicBlock BB : F) { for (Instruction I : BB) { if (auto *Call dyn_castCallInst(I)) { Function *Callee Call-getCalledFunction(); if (Callee) { errs() Function F.getName() calls Callee-getName() \n; } } } } return PreservedAnalyses::all(); } }; } // namespace static void registerPasses(PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name print-calls) { FPM.addPass(PrintCallPass()); return true; } return false; }); } extern C ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, MyPass, LLVM_VERSION_STRING, registerPasses}; }编译这个插件注意要和你的 LLVM 版本一致clang -shared -fPIC -fno-rtti MyPass.cpp \ $(llvm-config --cxxflags) -o MyPass.so然后生成一份测试用 IR 并运行clang -S -emit-llvm test.c -o test.ll opt -load-pass-plugin./MyPass.so -passesprint-calls test.ll -S -o /dev/null运行结果会打印每个函数的调用关系。dyn_castCallInst是 LLVM 类型转换的核心操作比 C 的dynamic_cast更高效因为它只做类型比较不做运行时解析这也是 LLVM 大量使用 RTTI 但又能保持高性能的原因。5.3 调试 Pass 时常用的命令和技巧Pass 写多了调试是个大课题。我积累的几个实用命令分享给你。一是看中间结果。最常用的是在 opt 后加-print-after-all这样每一轮 Pass 执行后的 IR 都会打印出来。但输出量巨大建议配合grep过滤opt -S -O2 test.ll -print-after-all -o /dev/null 21 | grep -A 50 After .*instcombine二是单步验证某个 Pass 的效果。不要上来就跑-O2先用-passesmem2reg单独跑对比输入 IR 和输出 IR 的差异。我学 Pass 的阶段就是在文件两边放着一边clang -S -emit-llvm生成原始 IR另一边跑opt变体反复比对才逐渐理解每个 Pass 的精确行为。三是利用 LLVM 内置的-debug-only参数。很多核心 Pass 内部有LLVM_DEBUG调试输出用-debug-onlyloop-unroll之类的参数可以只打开某类调试信息避免看一堆无关输出。四是写单元测试时用litLLVM 集成测试框架组织测试用例。它支持在.ll文件里写CHECK注释指定期望的输出片断配合 FileCheck 工具做自动化断言。项目里.test结尾的文件就是这类测试读几个就明白怎么写了。6. 我在实际使用中的几个体会和教训llvm-project是个庞然大物想一次读完全部代码不现实也不必要。我实际用了几年最大的体会是按需深入日常做工具链集成把 Clang 和 LLD 玩熟就够了做编译优化就把 IR 和 Pass 体系吃透做 AI 编译器MLIR 是必修课。不要一上来就囤积全套源码然后对着目录发呆。踩过几次坑之后有几件事必须提醒你。第一版本对齐很重要。llvm-config输出的是什么版本你的插件就必须用同版本的头文件编译否则链接时各种符号找不到或者运行时直接崩。我自己就吃过亏系统里同时装了 LLVM 14 和 LLVM 17llvm-config指向旧版本编出来的插件在新版opt里死活加载不进去。排查十分钟后发现是版本不匹配当场想拍桌子。第二Pass 的PreservedAnalyses返回值千万别乱写。如果你的 Pass 修改了指令但返回了什么都没改后续 Pass 会基于错误的分析结果继续优化产生难以追踪的 bug。拿不准的时候返回PreservedAnalyses::none()最安全顶多让后续 Pass 多算一次绝对不会错。这个坑我是调试一个莫名其妙的编译错误时歇斯底里地翻源码才找到的。第三用 ccache 是必须的不是可选的。LLVM 这类大型 C 项目每次全量重编的时间成本高到离谱。配好 ccache 之后日常改代码的编译体验完全不一样强烈建议所有做 LLVM 二次开发的人第一时间配好。最后分享一个小技巧在.lldbinit里给opt和clang配好调试符号路径结合-print-after-all基本能解决 90% 的 Pass 调试问题。工具链这个东西用得越熟越觉得 LLVM 那套三阶段分离 统一 IR的设计真的是编译器世界里罕见的长期主义——它让一代又一代的工具都能生长在同一棵树上你我写的自定义 Pass也只是这棵树上又一片新叶子而已。