很多搞编译器和工具链的同行最初接触llvm-project都有点发懵。这个仓库太大了clone下来好几个GB目录层次极深命名又不像普通开源项目那么直白。就算你把LLVM IR文档翻了一遍真打开源码还是不知道从哪下手。我刚接触那会儿也是这样花了很长时间才把它的结构和核心逻辑搞清楚。所以今天这篇就围绕llvm-project这个仓库本身讲讲它到底装了什么、为什么要这样组织以及一个新手怎么一步步把它跑起来、读进去、用起来。我默认你具备基本的C功底了解编译原理的大致流程词法分析、语法分析、中间表示、优化、代码生成。不具备也不慌我会把每个环节的关键概念做类比拆解保证你能跟上节奏。1. LLVM到底是什么——从学术项目到行业基础设施1.1 LLVM不是一个编译器而是编译器的基础设施很多人第一反应是LLVM就是编译器。准确说LLVM是一整套模块化、可复用的编译器组件库只是它自带了一个完整的编译器前端和后端而已。打个比方传统编译器比如GCC像一个集成度很高的餐厅从买菜到摆盘全包了你要改某一道菜的做法得把整个流程都捋一遍。LLVM更像一个标准化的中央厨房加配送体系它提供切菜机、炒菜锅、冷链运输、包装流水线你自己有特殊菜谱只需要用它的组件搭配出你要的那条线就行。这个设计哲学直接决定了llvm-project仓库为什么会是现在这个样子。它不是一个单一程序而是一组分工明确的库和工具。编译器的核心流程是源代码 → 前端Frontend→ 中间表示IR→ 优化器Optimizer→ 后端Backend→ 目标机器码。LLVM把这条链路彻底拆开了Clang负责前端把C/C/Objective-C变成IRopt工具跑优化Passllc和llvm-mc负责把IR变成汇编和目标文件。每段都是独立的库可以被任何其他语言的前端复用也可以被JIT运行时、静态分析工具甚至GPU驱动调用。1.2 设计哲学为什么模块化比一体化先进如果你对比GCC和LLVM的发展历史会发现路径差异非常大。GCC是典型的单体内核架构每个语言前端和后端交织在一起新增一门语言支持时改动范围会波及优化器和代码生成器LLVM从诞生起2000年左右Chris Lattner的硕士论文项目就坚持一旦前端产出IR后续工作就和源语言无关这一原则。IR成为所有语言共同的中间语言所有优化只针对IR所有后端也只吃IR。这意味着什么意味着你写一个新的编程语言只需要实现一个前端输出LLVM IR就自动免费拿到了整个LLVM生态几十个优化Pass、多平台代码生成、调试信息、链接器、JIT。包括Rust、Swift、Julia、Kotlin/Native在内都是这么干的。这个一次编写处处编译的思路让LLVM成为编译器领域的Linux内核你想绕开它自己造轮子成本和收益完全不成比例。1.3 对比GCC一个时代的选择题GCC的强项在于架构保守、验证充分、对极端嵌入式平台支持广LLVM的强项在于模块化、易于嵌入、IDE集成友好Clang的LibTooling让代码分析重构工具写起来非常舒服、新手学习曲线反而更平滑。我个人的经验是你想做语言前端实验选LLVM你想维护一个老旧的嵌入式工具链GCC生态更稳妥。这个选择题没有标准答案但如果你是为了学习和研究llvm-project绝对是最佳教材——它的代码组织方式比任何一本编译原理教材都更接近工业界实践。2. llvm-project仓库到底装了什么——源码布局拆解2.1 九个核心子项目别搞混llvm-project这个仓库实际上是个monorepo一次性包含多个相关项目。我第一次看到根目录的时候第一反应是这个仓库怎么这么乱等逐个搞清楚之后才明白monorepo策略就是为了保证多项目版本同步的一致性。下表是根目录下最重要的几个子项目目录名作用定位关键工具/产物llvm核心库与工具链框架opt, llc, llvm-as, llvm-dis, llvm-configclangC/C/Objective-C前端clang, clang-tidy, clang-formatclang-tools-extraClang的扩展工具集clangd, clang-query, clang-renamelld链接器ld.lld, ld64.lld, lld-linklldb调试器lldblibc / libcabiC标准库及ABI实现libc.so, libcabi.socompiler-rt运行时库与sanitizerasan, tsan, ubsan等flangFortran前端flang-newmlir多层中间表示基础设施mlir-opt, mlir-translatepolly循环优化与多面体模型框架polly-opt很多人一上来就盯着llvm目录里那些建成已久的转换Pass这其实不是最快入门路径。最快的是先把clang和opt配合跑通理解IR长什么样再琢磨内部实现。2.2 clang、lld、libc的协作关系一条典型的从源码到可执行文件的路径是clang把你的.c文件编译成IR经过优化器内置在clang -O2流程里交给LLVM后端生成汇编然后由lld链接成可执行文件。libc则负责提供C标准库实现也就是你的代码里那些std::vector、std::string背后的东西。这四者的边界非常清晰clang关心语义把人类可读的源代码变成机器可读的IRLLVM核心关心优化把IR变成更高效的IRlld关心拼装把多个目标文件组合成最终镜像libc关心运行时支撑。我经常给新人打这个比方clang是把中文需求翻译成标准工程图LLVM是优化这个工程图让它成本更低效率更高lld是把各种预制件拼成房子libc是水电管线那些必须铺好的基础设施。2.3 选择哪些子项目来构建如果全部一起构建时间非常惊人我自己的机器全量构建一次大约要1小时以上16核32线程。新手学习场景下我只建议构建llvm、clang、clang-tools-extra和lld这四个。lldb可以等需要调试器时再加flang、mlir、polly对大多数人是用不到的libc和compiler-rt也可以放后面。别贪多构建时间和磁盘占用都是成本先把主线跑通最重要。3. 从源码构建LLVM内存、磁盘和耐心3.1 环境准备与磁盘要求先给一组实测数据方便你做心理准备源码体积llvm-project的.git目录加全部工作区大概3GB多构建目录体积默认构建所有组件Debug模式会超过60GBRelease模式大概30GB构建时间16核32线程机器Release模式约40-60分钟内存链接某些大库特别是libLLVM.so和clang时峰值内存超过16GB是常态8GB内存机器强烈建议用MinSizeRel或限制并行度。建议磁盘剩余可用空间至少留出80GB。我踩过一个坑构建到一半磁盘满了Ninja直接报错重新构建时因为增量文件损坏只能删了build目录从头来。3.2 CMake配置的最佳实践LLVM用CMake构建配置命令看起来很长但核心就几个参数。下面这条是我目前用得最顺手的基础配置git clone --depth1 https://github.com/llvm/llvm-project.git cd llvm-project mkdir build cd build cmake -G Ninja -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;clang-tools-extra;lld \ -DLLVM_TARGETS_TO_BUILDX86 \ -DCMAKE_INSTALL_PREFIX~/llvm-install \ ../llvm解释一下每个参数的取舍-G NinjaNinja相对make更快、增量构建更强强烈推荐CMAKE_BUILD_TYPERelease优化全开构建时间适中生成的工具运行速度快LLVM_ENABLE_PROJECTS指定要构建哪些子项目注意分号和引号不能省LLVM_TARGETS_TO_BUILD只构建X86目标架构否则默认全平台后端X86、ARM、AArch64、RISCV等一堆都会构建时间和磁盘白白浪费CMAKE_INSTALL_PREFIX约定安装路径方便后续直接使用。配置完成后执行ninja -j$(nproc)然后ninja install。想更省时间可以在配置时加-DLLVM_PARALLEL_LINK_JOBS2限制链接并行数避免内存峰值爆炸。3.3 第一次构建的失败经历我第一次构建LLVM的时候几乎把所有新手坑都踩了一遍。第一是内存不足16GB机器不限制链接并行链接clang时直接OOM被系统kill日志看起来像是clang源码编译错误——这就是典型的错误信息误导第二是用了系统自带的gcc而不是clang/最新gcc某些旧版本gcc在编译LLVM时会触发C17标准库的bug没记全那些晦涩报错但换编译器就解决了第三是忘记装zlib、libxml2等依赖CMake报的错不太直观。后来我摸索出一套稳的方案Build目录用独立的硬盘分区、限制链接并行数、优先用Clang编译Clang、始终在干净环境里配置。这套方案后来在我写过的一篇构建踩坑文章里验证过多次如果哪天你跑LLVM构建报错了先从这三个方向排查大概率能解决。3.4 Verify构建完怎么确认一切正常构建完别急着写代码先跑一个冒烟测试~/llvm-install/bin/clang --version ~/llvm-install/bin/llc --version出现版本信息之后就说明核心工具能运行了。再用一段最简单代码验证完整链路// hello.c #include stdio.h int main() { printf(hello llvm\n); return 0; }~/llvm-install/bin/clang hello.c -o hello ./hello能输出hello llvm说明clang从编译、汇编、链接全链路都没问题。接下来就可以进入LLVM的世界腹地了。4. IR中间表示理解LLVM的钥匙4.1 为什么要先学IRLLVM IR是整个项目的核心抽象读源码也好、写Pass也好本质上都在围绕IR转。建议你学这部分的时候不要跳着读IR类型、指令、SSA形式这些概念没搞透后面全都寸步难行。IR之所以关键是因为它站在高级语言和机器代码之间的中间位置高级语言有循环、类、继承机器代码只有寄存器、跳转、内存读写。IR把那套复杂语义降维成一套非常规整的指令集同时又保留了对优化友好的信息比如类型、基本块、SSA的def-use链。前端努力生成IR优化器只关心IR后端把IR翻译成汇编三者之间通过IR解耦。4.2 SSA形式静态单赋值的魔法IR最让人印象深刻的特性就是SSAStatic Single Assignment形式。简单说每个变量只赋值一次。对学过普通编程的人来说这反直觉到不行——x x 1这种事在SSA里根本不成立你得写成x2 x1 1。那为什么LLVM要这么设计因为优化算法比如常量传播、死代码删除、公共子表达式消除在每个变量只定义一次的前提下做数据流分析复杂度会大幅降低变量的def-use关系也变成一个清晰的图结构。程序中的分支汇合要靠phi指令来解决这算是SSA里最绕的一个概念。我当初学phi指令时完全靠分叉的路最终要汇总到同一个十字路口这个类比想明白的每个if分支、循环入口最终汇聚处需要一个交通调度员phi节点来告诉数据到底从哪条路来。4.3 从一个C函数到LLVM IR光说不练不行。把下面这个简单函数int add(int a, int b) { return a b; }编译成IRclang -S -emit-llvm add.c -o add.ll你会得到类似这样的输出define i32 add(i32 %a, i32 %b) { entry: %add add nsw i32 %a, %b ret i32 %add }这里面有几个点值得注意i32是32位整数类型LLVM的类型系统显式标记了位宽和平台无关nsw表示no signed wrap是有符号整数不会溢出的承诺这个信息可以帮助优化器做更多变换%a、%b是虚拟寄存器SSA下它们只能被写一次add里的表示全局标识符%表示局部标识符。你把这个IR喂给opt -O2再让它llc成汇编完整走一遍你会发现IR可读性尚可机器友好度极高。想进一步压控变量命名让IR更人性化一点可以给clang传-fno-discard-value-names。5. 手写一个优化Pass从入门到自己的第一个IR变换5.1 Pass架构旧管理器与新管理器写Pass是深入LLVM最实操的训练方式。Pass就是对IR做一次遍历和变换的功能单元。早期LLVM用的是legacy PassManager通过registerPass这类宏注册现在官方主推的是new PassManager使用llvm::PassInfoMixin作为基类接口更清晰支持跨模块分析。新手写Pass不免两头迷惑我建议直接按新PassManager来学因为社区和文档已经默认新写法老接口逐渐进入维护模式。如下是最精简的一个分析Pass骨架#include llvm/IR/Function.h #include llvm/IR/Module.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Support/raw_ostream.h using namespace llvm; namespace { struct CountFunctionCalls : public PassInfoMixinCountFunctionCalls { PreservedAnalyses run(Module M, ModuleAnalysisManager AM) { int count 0; for (auto F : M) { if (!F.isDeclaration()) { count; outs() Function: F.getName() \n; } } outs() Total defined functions: count \n; return PreservedAnalyses::all(); } }; } extern C ::llvm::PassPluginLibraryInfo LLVM_ATTRIBUTE_WEAK llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, CountFunctionCalls, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, ModulePassManager PM, ArrayRefPassBuilder::PipelineElement) - bool { if (Name count-functions) { PM.addPass(CountFunctionCalls()); return true; } return false; }); }}; }别被这段代码吓到它只做一件事遍历模块内所有函数打印出非声明函数的数量和名字。LLVM_ATTRIBUTE_WEAK和llvmGetPassPluginInfo是插件的导出入口PassBuilder的注册回调让opt能通过命令行参数-passescount-functions找到并执行这个Pass。5.2 编译并加载Pass插件写成插件的好处是无需重新编译整个LLVM只需用同一版本的头文件编译这个.cppclang -fPIC -shared count_functions.cpp \ $(llvm-config --cxxflags --ldflags --libs) \ -o libCountFunctions.so然后opt -load-pass-plugin./libCountFunctions.so \ -passescount-functions add.ll -disable-output正常你会看到一行一行函数名输出。如果因为链接缺库汇报错检查llvm-config和当前系统LLVM版本是否匹配版本不一致是最容易出问题的地方后面第6章专门讲。5.3 实战一个真正的 IR 变换 Pass光分析不过瘾我再写一个能做点实际事情的Pass——把add指令里的立即数翻倍纯教学demo改成struct DoubleConstants : public PassInfoMixinDoubleConstants { PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { bool changed false; for (auto BB : F) { for (auto I : BB) { auto *BinOp dyn_castBinaryOperator(I); if (!BinOp || BinOp-getOpcode() ! Instruction::Add) continue; auto *RHS dyn_castConstantInt(BinOp-getOperand(1)); if (!RHS) continue; // 只处理右边是常数的加法 Constant *NewC ConstantInt::get(RHS-getType(), RHS-getValue() * 2); BinOp-setOperand(1, NewC); changed true; } } if (changed) return PreservedAnalyses::none(); return PreservedAnalyses::all(); } };然后按同样方式注册成double-constants。对之前的add.ll参数是%a和%b并不是立即数它不会产生任何变化。你需要构造一个真实含常数的IRdefine i32 add_one(i32 %a) { %r add i32 %a, 1 ret i32 %r }再用opt -load-pass-plugin./libDoubleConstants.so \ -passesdouble-constants constant_add.ll -S你会发现输出变成了add i32 %a, 2。一个小小的胜利但它展示了Pass化变换的完整闭环匹配模式、修改IR、报告分析结果、保留/废弃分析结果。5.4 我常给新手的一句话写第一个Pass时别急着抄大而全的优化逻辑先把遍历IR结构和修改IR的API练熟。IR遍历APIFunction、BasicBlock、Instruction两层循环、dyn_cast类型转换、ConstantInt这种常数的处理方式这些是后续所有Pass的地基。6. 避坑指南我在llvm-project上踩过的那些坑6.1 版本错配问题LLVM迭代非常快main分支几乎每天都有合入。如果你的Pass插件是用LLVM 17头文件编译的却拿它去加载进LLVM 18的opt轻则提示符号找不到重则内存布局不兼容运行时崩溃得莫名其妙。我的习惯是写插件时用源码构建出来的那套工具build/bin/opt、build/bin/clang和同目录下的头文件永远不要混用系统自带的LLVM。如果你的项目需要分发插件给用户就直接绑定一个最低LLVM版本要求并在文档里写上只保证在X.Y版本上验证通过。6.2 Debug构建慢Release构建没断言LLVM有两套符号构建模式Debug构建链接非常慢、运行时很卡但带了丰富断言能帮你提早发现IR被改坏的问题Release构建快、运行流畅但断言全被关掉一旦IR结构写错可能到很后面的Pass才爆炸。很多人一开始图省事用Release结果自己写Pass时改坏了IR的use-def链几千个Pass跑完后在某处崩溃信息又看不懂。我建议学习阶段、写Pass阶段务必用CMAKE_BUILD_TYPEDebug或者至少RelWithDebInfo等到追求性能优化的时候再切Release。Debug构建的最大痛点是链接慢但你可以把LLVM_PARALLEL_LINK_JOBS调高一点或者只用-DLLVM_ENABLE_PROJECTSclang构建这样构建成本可控。6.3 TableGen生成文件的神秘错误llvm-project里大量使用TableGen.td文件来声明指令集、寄存器等信息。有时候你改了后端的一个td文件编译报错的内容却指向一堆XXXXGen.inc文件——这些文件是构建时由TableGen自动生成的报错行号完全对应不到你的修改位置。遇到这种情况别慌先看错误信息里是否有文件路径在build目录里找一下对应的.td文件路径同时确认LLVM_TARGETS_TO_BUILD是否包含了你在改的那个目标后端比如改RISCV后端却只构建了X86td文件直接没参与编译错误信息会更绕。TableGen错误的一个通用排查顺序是语法错误 → 类型错误 → 定义冲突 → 递归引用。用-debug-onlytablegen跑一次可以输出更多诊断信息。6.4opt的手册页和实际行为不一致这不算bug而是LLVM迭代太快部分命令行参数在新版本里改了名字或者废弃了。比如老PassManager的-mem2reg在新PassManager里要用-passesmem2reg有些优化Pass被拆分、合并一个参数对应的Pass可能变了。遇到这个参数怎么找不到的问题不要第一时间查旧的博客直接跑opt --help-list opt --passes查看当前版本实际可用的Pass列表。社区里很多老教程还是五六年前的信息已经过时以源码和--help为准。6.5 链接错误的最常见来源llvm-config --libs输出的库列表很长很多时候直接全部拼上去没问题但有一些库带依赖顺序顺序错了链接直接失败。如果你只是写独立插件强烈建议不要手动链一堆LLVM库插件里用到的LLVM符号大多会从opt主程序里解析你只需要clang -fPIC -shared my_pass.cpp -I/path/to/llvm/include $(llvm-config --cxxflags) -o libMyPass.so不带--libs因为opt本身已经导出了这些符号。如果你确实要做一个独立的LLVM工具再认真考虑静态链接否则会被LLVM库的循环依赖折磨得够呛。7. 进阶扩展mlir、lldb和跨语言前端7.1 MLIRLLVM生态里的新风暴llvm-project仓库里还有一个越来越重要的子项目MLIRMulti-Level Intermediate Representation。它的定位比LLVM IR更高一层LLVM IR太接近机器了做深度学习编译器、做特定领域优化时直接生成LLVM IR总是很别扭需要更高层次的结构信息保持到比较后面。MLIR提供了一套可以自定义多种IR层级的框架让编译器开发者从我要直接生成LLVM IR的苦海中解放出来。如果你接触过TVM、XLA这类深度学习编译器会发现它们内部都在做类似多层IR的事但MLIR把这个概念平台化、标准化了。它的学习曲线比LLVM IR还要陡模板元编程用得很重。我的建议是先把LLVM IR的Pass写顺再碰MLIR两个一起学容易互相干扰。7.2 LLDB不只是给Swift/Objective-C用很多人以为LLDB只是Xcode的调试器实际上它在llvm-project里是独立的一套调试基础设施。LLDB的价值在于它和LLVM共享了大量代码反汇编引擎、目标描述、表达式求值器Clang表达式、DWARF解析。这意味着你在LLDB里敲expression myVar 1底层其实调用了Clang来编译这段表达式到IR再由LLVM JIT执行。如果以后你想做一个自己的调试器或者分析工具认真读一读LLDB的架构会有很大启发。它不像GDB那样是一个巨无霸单体而是拆成了lldbAPI、lldbCore、lldbTarget、lldbExpression等一堆库很容易嵌入到IDE、CI或自动化测试里。7.3 用LLVM做一个简单脚本语言解释器我一直推荐的实战项目是用LLVM做一个你专属的小语言编译器或解释器。不需要写太复杂的语法能支持整数、变量、if/while和函数调用就够。流程是写一个手写词法分析器生成token流写一个递归下降解析器生成AST自己做类型检查和简单常量折叠把AST翻译成LLVM IR有现成API可调用用llvm::orc::LLJIT做JIT执行或者输出目标文件再用lld链接。这个项目做完你对前端、优化器、后端、运行时的理解就不是零散的了。我在带一些同学入门编译器的时候发现能不能独立做完这个mini语言基本决定了他们后来在LLVM这块能走多远。8. 给不同水平读者的路线图8.1 完全小白先跑通再深究如果你刚知道编译原理甚至还没完整看过龙书别急着啃源码先把第3章的构建流程走通然后拿clang -S -emit-llvm把本书所有例子的IR都生成出来配合llvm官网的LLVM Language Reference查每个指令的含义。花一两周把IR指令集混个眼熟再回来读Pass代码就不那么吃力了。8.2 有一定编译基础直接读官方示例如果你熟悉GCC或者有编译器课程基础可以从llvm/examples/目录下的示例开始那里有最正统的Pass写法配合llvm/docs/里的NewPassManager和WritingAnLLVMPass文档。注意千万别直接读llvm/lib/Transforms/里的复杂优化实现比如InstCombine那玩意层层调用魔法太多不适合新手。8.3 想深入底层的门外汉从后端做起如果你对指令选择、寄存器分配、指令调度这些后端问题感兴趣先学会用llc看不同后端的汇编输出然后研究llvm/lib/Target下的某个简单后端比如X86的后端虽然完整但冗长其实RISCV后端相对短小好读配合RISCVInstrInfo.td能快速建立概念。需要提醒的是后端的内容比前端和优化器更庞杂而且文档更稀缺需要自己耐得住性子一点点抠。8.4 做工具链产品的人关注llvm-17和更新版本如果你所在团队正在做SDK、移动端工具链或者云原生编译服务LLVM版本选择很重要。旧版本比如14、15的工具链表现稳定但新版本在编译速度、二进制体积、C20支持、安全加固比如MTE、HWASAN上都有显著进步。我的习惯是每半到一年升级一次LLVM工具链先做评估再灰度最后全量切换。9. 最后分享几个小技巧如果你已经走到这一步说明开始认真研究llvm-project了。最后我再分享几个实践下来很管用的小技巧第一永远先备份你的构建配置。自己在终端里跑过的cmake命令要写进项目的README或者shell脚本里不要凭记忆。一个环境重建后你会发现当时随手能用的配置丢失有多痛苦。第二遇到不懂的IR指令用opt -passesprint...可以输出pass运行后的IR用llvm-dis可以把bitcode还原成可读的.ll文件不要盯着二进制产物瞎猜。第三学会用断言定位问题。Debug构建下自己写错IR修改LLVM的verify检查会直接报错告诉你哪个use-def链坏了。Release构建下这些全被优化掉了报错基本靠血泪教训。第四积极利用llvm-dev邮件列表和Discord。我早期遇到过不少冷门问题Stack Overflow上根本搜不到但在邮件列表里发问题经常有LLVM开发者直接指出是哪个commit引入的行为变化。当然提问前一定把最小复现样例准备好LLVM社区的人对帮我写PR式提问没有耐心。最后一条也是最重要的不要怕读源码。LLVM文档虽然不算全但源码是最好且最新的文档。你想知道某个Pass做了什么直接找对应.cpp文件读配合--debug-only日志比任何二手教程都准确。遇到读不懂的地方拉宽时间线多调试几遍慢慢就会了。