
1. 从一个开源仓库说起llvm-project到底是什么我最初接触llvm-project的时候也是一脸懵的。大家平时挂在嘴边的“LLVM”其实已经不是一个单纯的编译器了而是一整套围绕编译器、工具链、代码生成、软件渲染等底层基础设施的开源项目。你在GitHub上看到的llvm-project仓库就是这套体系的主仓库里面既有ClangC/C前端、LLVM Core中间表示与优化、后端代码生成、LLD链接器、libc标准库、compiler-rt运行时库还有polly、flang这些相对独立但和LLVM深度绑定的子项目。我见过不少刚接触的新人第一反应是“这仓库太大我搞不动”。确实llvm-project这个仓库体量极其惊人拉下来光源代码就几个GB完整构建一次可能要吃掉几十GB的磁盘空间。但这种“庞大”恰恰说明它的价值——LLVM已经不只是编译器的代名词它在静态分析、动态二进制插桩、GPU驱动、软件渲染领域都有应用。比如你们可能听过llvmpipe这是Mesa图形栈里用LLVM做软件渲染的实现它的出现让没有GPU的环境也能跑OpenGL靠的就是LLVM的JIT能力把图形渲染的着色器程序实时编译成本地代码执行。所以你如果问我llvm-project适合谁我的看法是如果你在校招里想投编译技术岗位或者你正在做编程语言、代码分析、图形渲染相关的实际项目或者单纯想把C/C工程构建能力提升一个层次那llvm-project值得你花段时间吃透。这篇文章我就用实际操作的视角把llvm-project从仓库结构、核心设计、构建方法到常见坑点一次讲清楚。1.1 一个仓库半个编译器生态llvm-project的源码版权归LLVM基金会管理采用Apache 2.0 License对商业使用非常友好这点也是它能在工业界和学术界广泛扎根的原因之一。仓库本身通过Git管理分支通常以release/15.x、release/16.x这样的形式命名。你看到“LLVM 15.0.7”这种版本号指的就是某个release分支上的具体发布版本。以常用的release/15.x举例对应Clang 15、LLD 15、libc 15也都会一并发布真正做到了一体化版本管理。仓库目录结构非常清晰顶层就有llvm、clang、lld、libcxx、libcxxabi、compiler-rt、polly、flang等十几个子目录。其中llvm目录是核心中的核心里面包含了我们常说的LLVM后端、优化器、IR等一系列内容。clang目录就是C/C编译器前端负责把C、C、Objective-C翻译成LLVM IR。lld是官方链接器链接速度比老牌GNU ld快很多尤其适合大型C工程。libcxx是LLVM自己的C标准库实现配合clang使用效果最好。初学者一定要先搞清楚的一点是llvm-project是“全部”而通常说的“LLVM”只是其中一部分。很多人会问“我要装LLVM是不是就是要拉llvm-project”如果你开发语言虚拟机或者做代码生成器核心的llvm目录就够了你需要编译C/C就要同时启用clang。构建的时候用LLVM_ENABLE_PROJECTS这个CMake参数控制比如只构建clang、lld这两个就能省下不少编译时间。这个操作方式我会在第四章里详细讲。1.2 为什么LLVM设计会影响整个软件栈LLVM的设计核心是一套语言无关的中间表示IR它像一道“通用语言”前端把各种高级语言翻译成IR后端再根据不同的CPU架构把IR翻译成机器码。这个设计最大的好处是写一种语言的编译器时不需要关心底层有多少种CPU架构写一个后端时也不需要关心前面到底有多少种前端语言。这也是为什么现在很多新语言比如Rust、Swift都直接选择LLVM作为编译后端省去了成千上万条指令集的适配工作量。从实用角度讲LLVM的优化器基于Pass机制每种优化都是一个Pass比如死代码消除、循环展开、函数内联等可以灵活启停、组合。你做编译器开发时甚至可以针对自己的IR写自定义优化Pass在优化流水线中插入。这种灵活性远超GCC的固定优化等级设计。再加上Clang的诊断信息质量很高报错带行列号、代码片段、高亮提示很多IDE和静态分析工具也借助Clang编译前端来解析代码像CLion、Qt Creator这些工具其内核就是Clang。可以说llvm-project既是编译器项目也是底层基础设施平台。理解了这一点你才不会在庞大的代码库中迷失方向。2. llvm-project的核心设计拆解作为综合项目如果上来就选一个目录闷头读效率很低。更值得做的是先把它的核心设计蓝图梳理清楚。llvm-project大体可以拆成三条主线前端、中端、后端。前端负责把高级语言降级成IR中端也就是优化器对IR做各种变换使其更高效后端负责将优化后的IR映射到具体指令集。下面我结合代码目录逐个拆解。2.1 LLVM IR承上启下的中间表示LLVM IR是llvm-project的“通用语”它是一种静态单赋值SSA形式的中间表示。静态单赋值意味着每个变量只能被赋值一次每次使用都指向明确的定义点。这个特性看起来简单实际上给优化器省了大事数据流分析变得非常直接做变量重命名、寄存器分配时都能基于SSA信息大大简化算法实现。IR有三种形态内存中的数据结构、人类可读的文本格式.ll文件、可序列化的位码格式.bc文件。我们写编译器工具时最常打交道的是.ll文本格式。它的语法类似C语言但更接近汇编。比如定义函数define i32 add(i32 %a, i32 %b) { %sum add i32 %a, %b ret i32 %sum }看懂这段代码就迈入了LLVM的大门。这里的i32命名是32位整数add表示全局函数%a、%b是局部变量add指令做加法ret返回。所有寄存器和临时变量都以%开头SSA约束下同一个变量名不会重复赋值。后面如果自己做编程语言想对接LLVM写IR生成器的时候这种模式会频繁出现。llvmpipe与LLVM IR也有关系它把图形着色器编译成LLVM IR再由LLVM的JIT引擎生成针对当前CPU优化的机器码从而保证软件渲染性能。比如Mesa的llvmpipe在运行时检测CPU支持AVX256位指令后会让LLVM生成256位宽的SIMD代码一次处理更多的像素数据。2.2 优化器Pass机制LLVM强大的核心优化器是LLVM的“心脏”几乎所有的性能优势都从这一层来。优化器采用Pass流水线设计一个Pass就是对IR做一次遍历和变换。常见的ScalarOpts目录里存放着几十个标量优化PassIPO目录处理跨函数的优化比如内联Vectorize目录做循环向量化配合llvmpipe这类需要批量处理的应用非常关键。官方给的经典优化组合是O1、O2、O3、Os、Oz。O2属于比较均衡的选择适合大多数应用程序O3开了更多激进优化比如更积极的循环向量化有时会让编译时间变长性能收益还不一定明显Os和Oz偏体积适合嵌入式。这些等级本质上是一组Pass的组合你使用opt工具的时候传入比如-pass-manager模式可以自定义Pass组合。优化Pass的编写是进阶开发者的必修课。LLVM 14之后默认使用新PMNew Pass Manager写一个简单Function Pass核心步骤包括声明Pass类重写run函数通过PassBuilder注册最后加入Pass插件让opt识别。这个过程你一旦学会就能针对性为你的语言做定制优化。比如我在一门DSL编译器里就写过循环展开Pass把密集数值计算的性能提升了约35%。这种实际收益比读一百遍教程都直接。2.3 后端代码生成从IR到机器码后端是llvm-project最绕也最精妙的部分。简单说后端接收优化后的IR经过指令选择、指令调度、寄存器分配、指令布局、代码发射等阶段最终生成汇编或机器码。每个后端目标比如X86、ARM、RISC-V都在llvm/lib/Target目录下以Target名称命名内部包含ISelLowering、SelectionDAG、GlobalISel、AsmPrinter等子模块。以X86后端为例文件路径是llvm/lib/Target/X86。这里的X86ISelLowering.cpp处理调用约定、参数传递等X86InstrInfo.td用TableGen语言描述X86指令集X86AsmPrinter负责输出汇编。如果你想支持一种新CPU架构最省力的方式是从LLVM现有后端克隆一个最接近的然后修改指令描述文件。这里引入TableGen它是一种领域特定语言专门用来描述指令集、寄存器等信息然后由TableGen工具生成C代码避免手写大量重复代码。对于只做上层应用、把LLVM当黑盒使用的人来说后端细节不用深挖。但如果你是做数据库协处理器、自研芯片的编译工具链那后端这一块必须啃下来。不管怎么说理解“IR是分水岭前端后端靠IR解耦”这张总图对使用llvm-project的都很有帮助。3. 让代码跑起来llvm-project的获取、构建与调试拿到llvm-project之后第一件事是构建。很多人在这第一关就放弃了因为编译LLVM对机器配置有要求过程漫长且容易报错。这块我会把常用步骤、CMake参数、磁盘内存要求都列出尽量让你一次成功。3.1 获取源码分支选择与克隆策略获取llvm-project源码建议从GitHub官方仓库拉取而不是随便下载源码包因为你需要管理后续的更新和切换分支。命令很常规git clone --depth 1 --branch llvmorg-15.0.7 https://github.com/llvm/llvm-project.git--depth 1是浅克隆只拉取最新一次提交的代码体积会小很多。--branch指定的是release标签llvmorg-15.0.7这个版本号对应你前面看到的“llvm-project 15.0.7”。如果你没有特定版本需求也可以直接用默认main分支但main分支处于活跃开发状态稳定性不如release版本我在做正式项目时一般不追踪main。有人可能会问浅克隆以后还想切换分支怎么办加-fetch参数拉取目标分支即可。另外强烈建议磁盘预留至少50GB空间因为源码展开约2GB构建目录动辄15GB以上加安装包还会更多。8GB内存是底线但16GB会更从容如果内存小并发编译任务别拉太高。3.2 CMake构建关键参数配置与选择逻辑LLVM统一使用CMake构建。在llvm-project目录下创建build目录专门用来存放构建产物这是业内标准做法避免源码目录和构建产物混在一起将来清理也方便。核心配置命令大概是cmake -G Ninja -S llvm -B build \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_ENABLE_ASSERTIONSON逐个说下参数。 -G Ninja表示生成Ninja构建文件Ninja比Make更快而且能充分利用多核并行。CMAKE_BUILD_TYPE设为Release开启优化正式使用必须用ReleaseDebug模式便于调试但性能差很多。LLVM_ENABLE_PROJECTS指定除了LLVM核心外需要构建的项目这里选了clang和lld。LLVM_TARGETS_TO_BUILD只构建X86后端如果你不搞交叉编译可以减少编译量否则默认会生成所有支持的后端非常耗时。LLVM_ENABLE_ASSERTIONS在调试开发时打开可在运行时检查很多错误生产环境建议关掉。配置完成后用cmake --build build -j16或者ninja -C build编译。整个构建时间取决于机器性能16核机器大概二三十分钟内能完成Clang和LLVM的构建单核机器可能要几个小时。这期间可以泡杯茶等着。3.3 使用构建产物替代系统自带编译工具链构建完成后llvm-project会生成可用的工具链例如build/bin/clang --version build/bin/llvm-as --version build/bin/llc --version这些二进制文件可以直接使用。我自己在项目里通常会把build/bin目录加到PATH中让clang优先于系统自带编译器。你还可以用clang -emit-llvm生成IR文件看看效果echo int add(int a,int b){ return ab; } test.c build/bin/clang -S -emit-llvm test.c -o test.ll cat test.ll这就是前面说的IR文本格式。再进一步用build/bin/llc test.ll -o test.s可以看到生成的X86汇编。这一条链路跑通你对“前端产IR后端出汇编”就有非常直观的感知了。项目里若需要静态分析run-clang-tidy、clang-check等工具也能在这次构建中一并生成非常方便。4. 实操记录动手跑通一个自定义优化Pass读代码和读文档始终是“纸上得来”真正让我理解Pass机制的还是在llvm-project上写了一个自定义FunctionPass。这里我把完整过程放出来含代码示例你可以照着在自己的工程里复现。4.1 确定要解决的问题和开发环境我的目标很简单写一个IR层面的优化Pass找到所有形如“立即数加0”的多余操作把它们替换成直接赋值。这个优化叫“strength reduction”的一种特例看起来不起眼却能说明Pass的编写范式。开发环境延续上一章的构建产物使用LLVM 15源码。开发Pass有两种方式一是直接在llvm-project源码里新增文件修改CMakeLists重新编译好处是可以内嵌进opt工具另一种是把Pass编译成独立插件用opt -load-pass-plugin加载开发时迭代更快。我推荐插件方式因为不需要每次修改都重编整个LLVM省时省力。4.2 编写函数Pass的核心代码我用New Pass Manager风格写因为LLVM 15已经推荐这种模式。先写头文件和源文件。以MyAddZeroPass为例它的run方法接收一个Function参数返回PreservedAnalyses表明修改了哪些分析结果。#include llvm/IR/Function.h #include llvm/IR/Instructions.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/IR/IRBuilder.h using namespace llvm; namespace { class MyAddZeroPass : public PassInfoMixinMyAddZeroPass { public: PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { bool Changed false; for (BasicBlock BB : F) { for (auto It BB.begin(); It ! BB.end(); ) { Instruction I *It; if (auto *BinOp dyn_castBinaryOperator(I)) { if (BinOp-getOpcode() Instruction::Add) { Value *LHS BinOp-getOperand(0); Value *RHS BinOp-getOperand(1); if (auto *CI dyn_castConstantInt(RHS)) { if (CI-isZero()) { BinOp-replaceAllUsesWith(LHS); BinOp-eraseFromParent(); Changed true; } } } } } } return Changed ? PreservedAnalyses::none() : PreservedAnalyses::all(); } }; } // namespace代码逻辑很直白遍历每个基本块里的指令如果是加法指令且右操作数是常量0就用左操作数替换所有使用该指令结果的地方然后删掉这条指令。替换顺序很重要必须先replaceAllUsesWith再eraseFromParent否则这条指令之后使用者会指向悬空指令。还需要注册Pass插件extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, MyAddZeroPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name my-add-zero) { FPM.addPass(MyAddZeroPass()); return true; } return false; }); }}; }这里的关键是注册回调函数告诉opt在遇到-mypass参数时要执行哪个Pass。函数名my-add-zero会出现在opt命令行中作为标识。4.3 编译并测试自定义Pass写一个CMakeLists.txt把Pass编译成共享库cmake_minimum_required(VERSION 3.20) project(MyAddZeroPass) find_package(LLVM REQUIRED CONFIG) message(STATUS Found LLVM ${LLVM_PACKAGE_VERSION}) include_directories(${LLVM_INCLUDE_DIRS}) add_library(MyAddZeroPass MODULE MyAddZeroPass.cpp) target_link_libraries(MyAddZeroPass PRIVATE LLVMCore LLVMSupport) install(TARGETS MyAddZeroPass LIBRARY DESTINATION lib)注意需要在环境中能找到LLVM的CMake配置。编译命令mkdir -p pass-build cd pass-build cmake -DCMAKE_PREFIX_PATH../build/lib/cmake/llvm .. make -j4成功后会生成libMyAddZeroPass.so。接着做测试。先用clang生成一份带x0的IRcat test.ll EOF define i32 foo(i32 %x) { %1 add i32 %x, 0 ret i32 %1 } EOF opt -load-pass-plugin ./build/libMyAddZeroPass.so -passesfunction(my-add-zero) -S test.ll -o out.ll看一下out.ll如果成功那行add i32 %x, 0应该在函数内被消除只剩ret。第一次跑通的时候非常有成就感。你可以拿这个例子替换成自己想要的IR变换比如把乘2改成移位、把与0取并改成置0从而逐步掌握Pass开发。4.4 踩过的坑与调试技巧写Pass时最容易踩的坑有三类。第一迭代器失效。我在遍历BasicBlock时用了It提前递增所以之后eraseFromParent不会破坏循环的迭代状态。如果你先erase再自增下次循环就可能崩溃。第二分析结果的保存。如果你的Pass没有修改IR却返回PreservedAnalyses::none()会导致后续Pass重新进行一些本不需要的分析编译性能降低。正确做法是只在确有修改时返回none。第三插件版本不匹配。LLVM各版本的API变化较大如果你的插件基于15版本API编写却在装有LLVM 16的opt上加载通常会报错这个没法兼容必须保证工具链版本一致。还有一个调试技巧单独跑opt之前先对简单的.ll文件用print-after-all参数查看每个Pass执行后的IR变化。比如opt -print-after-all -passesfunction(my-add-zero) -S test.ll这样能一帧一帧看到Pass执行的细节比盲目加打印语句高效得多。我在写复杂Pass时都是靠这个方式定位问题在哪一步丢信息。5. 常见问题与排查技巧实录在实际使用llvm-project的过程中我积累了不少问题排查经验这里整理成速查表方便你照着检查。5.1 构建阶段高频问题与解决方式我把构建过程中遇到最多的问题整理成一张表方便速查症状可能原因解决方案clang: error: unable to execute command: Killed内存不足链接阶段进程被杀降低并行度 -j4 或 -j2增加交换分区编译到约80%时报错“Error: No rule to make target”源码不完整或构建目录损坏删除build目录重新cmake保证网络稳定ld: cannot find -lz / -ltinfo缺少系统依赖库Ubuntu安装zlib1g-dev、libtinfo-devCMake Error: Could not find a package configuration fileLLVM_DIR未设置指定-DLLVM_DIR/path/to/lib/cmake/llvm编译时间异常长后端目标过多或Debug模式使用Release模式、只保留X86后端内存不足这条最隐蔽。我原先有一台8GB内存的机器编译LLVM总是到“链接clang”这个阶段直接进程被杀。后来查了进程监控发现单条链接命令峰值能吃掉近6GB内存再加上编译器的并行进程机器直接扛不住。解决方式很简单减少并行任务数或者临时加swap。你也可以用Ninja的串行链接机制把并行编译设为高、链接设为低具体语法是-DLLVM_PARALLEL_LINK_JOBS2效果立竿见影。5.2 链接与运行阶段的问题链接器报错也是常见状况。最典型的文本是“undefined reference to llvm::createXxxPass()”这通常意味着你链接的库不完整或者版本不匹配。比如你明明装了libLLVMCore但插件里使用了某个非导出符号或者你的CMakeLists没有链接对应组件库。排查思路是先用grep在LLVM源码或lib目录里确认符号是否存在如果有就在target_link_libraries里把库补上。另一个常见问题是运行opt时报“unknown pass name”。这几乎都是因为插件没有正确注册或者-passes参数的Pass名字和你注册的名字不一致。我在自己电脑上遇到过很多次多半是我把my-add-zero写成了myAddZeroopt当然不认识。可以在插件注册回调里加打印先看看Name是否接收到。还有一个和版本相关的问题。用build/bin/clang编译出的IR文件位码版本和系统自带的clang版本不一致如果你拿公司CI机器上其他版本的llvm-as来读可能会报“bitcode version mismatch”。处理办法就是统一工具链来源整个项目尽量都使用同一套llvm-project构建产物不要混用不同版本的LLVM工具。6. llvmpipe与LLVM的关联以及后续扩展方向前面多次提到llvmpipe。其实它并不是llvm-project仓库的一部分而是Mesa项目中的软件渲染驱动但它借助LLVM完成核心工作。它的工作方式是把OpenGL着色器翻译成TGSI或NIR中间表示再通过LLVM的JIT技术生成当前CPU上的机器码。如果CPU支持AVX256位向量指令llvmpipe生成256位宽的SIMD指令一次可以同时处理8个float数据这就是你看到的“LLVM 15.0.7, 256 bits”这类版本提示的由来。可以说llvmpipe是LLVM作为通用代码生成器能力的最直观展示它甚至没想过图形渲染领域也要依靠LLVM来“即时编译”。从扩展角度看llvm-project还提供了很多可以玩耍的方向。比如调试器LLDB、libFuzzer模糊测试框架、clang-tidy代码检查工具、BOLT二进制优化工具等这些都在llvm-project仓库中。新版本还引入了MLIR框架专门用来搭建针对特定领域的编译器比如机器学习加速器编译器。你在掌握基础工具链之后可以按兴趣深入这些子项目。它们在底层能力上是相通的一旦你理解了IR和Pass换到MLIR、Flang只是换了一批描述性语言和API而已。我在实际项目中就尝试过用LLDB调试一个自己写的解释器又用libFuzzer给解释器的解析器做了几轮模糊测试跑出了好几个边界崩溃。这种“一个仓库解决多种问题”的体验确实是llvm-project最有魅力的地方。7. 给新手的实战建议针对刚开始接触llvm-project的读者我想把路线浓缩成几点都是我在过去踩坑后总结出的做法。第一先构建后理解。很多人习惯先看文档反复犹豫选哪个分支结果浪费大量时间。我的建议是直接按第三章的流程用release分支构建一个基础版本把clang、opt、llc跑通再说。构建过程即是最好的热身。第二从改Pass入手而不是从改后端开始。改Pass对理解IR结构、优化流程非常高效因为它不需要关心指令选择和寄存器分配这些复杂环节。你在自己的Pass里添加一个指令替换很快就能看到IR变化这种正反馈能极大提高学习兴致。第三善用llvm-reduce和bugpoint。如果你在做开发时发现某个优化Pass导致生成代码出错可以用llvm-reduce把出错的IR文件最小化再用bugpoint定位是哪一步Pass引入的错误。这种工具链自带的调试手段比手工缩小测试样例高效太多。第四多读官方文档但别指望一次读懂。LLVM的文档写得挺全但很多是“参考手册”式的默认读者已经理解背景。我的读法是想弄清楚某个具体问题比如“为什么要用SelectionDAG”就去llvm.org/docs里搜相关专题配合源码里的注释来理解。不要从头到尾顺序读效率会很低。把这四点做到你在llvm-project上基本就能独立摸索了遇到问题时也可以去LLVM Discourse、LLVM Weekly这类社区找答案。实际用过的人都知道这门技术只要迈过“构建和写第一个Pass”这个门槛后面就是坦途。