深入LLVM编译器基础设施:从IR到Pass的全流程实践

发布时间:2026/9/20 3:57:29
深入LLVM编译器基础设施:从IR到Pass的全流程实践 好直接进入正题。今天想聊聊llvm-project这个仓库。如果你关注编译技术、编程语言实现或者日常开发里用的工具链Clang、Rust、Swift甚至GPU相关的编译器栈底层都有它的影子。我最初接触llvm-project是为了给一个内部程序做静态分析优化结果一头扎进去之后发现这东西不仅能改代码生成逻辑还能支撑起整套语言工具链的建设。对于想深入编译器底层、或者想搭建自己的语言玩具的开发者来说这个仓库就是绕不开的核心参考。先说结论llvm-project不是一个单一编译器而是一整套编译器基础设施的集合。它的价值不在于“能编译C”而在于模块化的编译流水线、中间表示IR的设计以及围绕IR构建的Pass框架。搞清楚这个仓库的目录结构、构建方式、核心IR语法和Pass编写逻辑你就等于拿到了一把能打开编译器黑盒的钥匙。很多人拿到llvm-project第一反应是直接编译然后开始用clang其实这样浪费了大部分价值。这个项目真正的学习路径应该是分层的先理解三阶段架构再跑通构建流程然后动手改IR、写分析Pass最后把自定义优化串进编译流程里。下面我按这条路径拆开讲。1. LLVM项目的定位与整体架构1.1 它不是编译器而是编译器工厂一个传统的编译器比如GCC是一个整体前端负责把C语言解析成GCC内部的GIMPLE表示优化和后端都是围绕这套私有表示做的。如果你想支持一种新语言、或者换一个目标平台你得在GCC庞大的代码库里找到对应模块理解它内部的数据结构改动成本很高。llvm-project完全换了一种思路。它把编译器拆成几个相对独立的模块前端负责处理语言中端负责优化后端负责生成机器码。模块之间靠一个稳定的接口连接这就是LLVM IR。这种设计带来的直接好处是支持新语言只需要写新的前端可复用的优化和后端逻辑直接继承支持新CPU架构只需要开发后端所有语言的代码都能通过这套后端生成目标机器码。我拿一个生活化的类比来说传统编译器像一家“从设计到生产全包”的综合制造厂产品绑定工厂内部流程LLVM则像一套标准化的“图纸体系”前端是“把需求转换成图纸”的部门中端是“优化图纸结构”的部门后端是“按图纸加工零件”的车间。图纸就是LLVM IR只要符合图纸规范谁画的图都能进车间生产。1.2 前、中、后端如何协同工作实际用起来三阶段的分工非常清晰。前端以Clang为例它把C/C源码解析成AST再逐层Lowering成LLVM IR这个阶段的工作量语言相关度极高C的类继承、模板实例化、重载决议都在这里处理。中端是优化器读取LLVM IR应用一道道Pass做转换比如循环展开、函数内联、公共子表达式消除IR还是那个IR只是结构上更精简。后端则把优化后的IR逐步下降成SelectionDAG、机器指令再做寄存器分配和指令调度最终输出汇编或目标文件。这个链条里IR是整个体系的骨架。它不像汇编那么底层因为还有虚拟寄存器这一层抽象它也不像AST那么高层因为它已经去掉了语言语法糖跳转、基本块、指令的关系很清晰。这种“中间层”的设计极大地降低了后端开发的难度一个全新的后端可以复用优化器的全部能力只需要关注如何把IR映射到目标架构的指令集上。1.3 仓库里的核心工程模块llvm-project模块挺多但核心的其实就几个。LLVM本体是这个仓库的核心包含IR定义、Pass基础设施、优化器、目标后端实现以及opt、llc这种命令行工具。Clang是C/C/Objective-C前端在LLVM体系中地位特殊因为最广泛的语言入口就是它。Clang-Tools-extra包含format、tidy等工具。LLD是链接器直接面向目标文件做链接优化。LLDB是调试器也是基于LLVM库构建的。如果你把仓库clone下来会看到不同的顶级目录llvm、clang、lld、lldb、mlir、flang、libcxx等。多年来我用得最多的是llvm目录下的include、lib和tools一般改代码集中在llvm/lib/Transforms、llvm/lib/CodeGen和clang/lib/Sema这几个区域。工程组织上每个模块都有独立的CMakeLists构建系统通过LLVM_ENABLE_PROJECTS这个变量来控制要构建哪些子项目第一次看会有点不适应熟悉之后会发现这套工具链非常灵活。2. 通用的构建流程与环境准备2.1 源码获取与版本选择llvm-project更新速度极快几乎每天都有新commit合入。所以选版本特别重要不能直接拿main分支来学习。最近几年我一般会用打tag的release版本比如llvmorg-18.1.8这种既有稳定性社区问答和文档匹配度也高。拿main分支做开发有个隐患代码接口变化频繁特别是新Pass管理器的API往往你昨天写的Pass还是对的今天编译就报错了。获取源码可以用git clone也可以直接下载release tar包。源码包通常比较大完整clone下来大概2到4GB不等具体看是否带历史记录。建议下载release版本tar包避免不必要的git历史和磁盘占用。如果一定要用git建议加上--depth1参数来浅克隆只保留最新的内容。2.2 系统依赖与工具需求llvm-project构建依赖几个基础工具CMake、Ninja、Python3构建脚本需要、GCC或Clang作为宿主编译器、以及zlib等开发库。构建时宿主编译器版本不能太老否则某些特性缺失会导致配置失败。我试过在CentOS 7的旧GCC上构建llvm-project结果因为编译器不支持C17的某些语法特性而中断最后只能升级系统编译器或者用devtoolset。磁盘空间要预留充足。Debug版构建每个目标文件体积都很大完整构建所有子项目磁盘占用可以轻松突破100GB。我建议无论如何都启用Release或者RelWithDebInfo类型构建能显著减小编译单体和最终磁盘占用。如果想要调试信息RelWithDebInfo是不错的选择有优化有调试信息问题定位也够用。2.3 CMake配置的关键参数构建llvm-project前先规划CMake参数。以我的经验核心参数有这么几个。LLVM_ENABLE_PROJECTS决定构建哪些子项目例如clang;lld构建Clang和LLD。LLVM_TARGETS_TO_BUILD决定是否构建所有目标后端默认全量构建包含X86、ARM、AArch64、RISC-V等耗时很长如果短期只关注X86务必指定。LLVM_BUILD_TESTS控制是否生成测试target开发时会用到但正式构建可以关闭以节省时间。CMAKE_BUILD_TYPE选Release或RelWithDebInfo。CMAKE_C_COMPILER和CMAKE_CXX_COMPILER指定宿主编译器。LLVM_USE_LINKER指定使用lld或gold能显著降低链接耗时尤其在链接大型二进制时。CMAKE_JOBS和LLVM_PARALLEL_LINK_JOBS控制编译和链接并行度防止内存被打满。不带任何选项的完整构建即使机器很强往往也要几十分钟到几小时具体取决于机型和并行度。坦白讲我第一次构建时没做任何裁剪直接全默认结果在8核16GB的机器上跑了超过4小时还一度因为内存不足让链接失败。后来我改用Ninja、只保留X86后端、启用lld链接整体构建时间压缩到40分钟左右差距非常大。2.4 分步构建实例与验证构建时我通常按这个流程操作。先创建构建目录放在源码目录之外比如build-release避免源码目录被污染。然后执行cmake配置命令指定Ninja作为生成器设置构建类型和项目列表。mkdir build-release cd build-release cmake ../llvm-project/llvm \ -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_USE_LINKERlld \ -DCMAKE_C_COMPILERclang \ -DCMAKE_CXX_COMPILERclang构建时用ninja -j参数控制并行度。我习惯先看内存大小决定并行度不然会OOM。然后构建clang和lld这两个主要target。ninja -j8 clang lld构建完成后用新构建出的clang编译一个hello world验证工具链是否正常工作同时查看版本信息确认是我们自己构建的版本。bin/clang --version bin/clang hello.c -o hello3. 深入LLVM IR与Pass编写3.1 理解LLVM IR的三种形态LLVM IR有三种呈现形式本质相同内存中的数据结构、可读的文本形式.ll文件、二进制位码形式.bc文件。日常分析我会用文本形式读起来像弱化版汇编但更高级。逻辑上它由模块、函数、基本块和指令四层构成一个模块包含多个函数每个函数由一系列基本块组成基本块以终止指令结尾最后一条跳转或返回指令代表这个块的控制流终点。IR的特点在于强类型的指令集每个虚拟寄存器都有类型函数签名、调用约定显式可见。静态单赋值形式是另一大特性每个变量只能赋值一次控制流汇合处通过phi指令来合并不同路径上的值。这种设计做数据流分析时很好用每个值的定义和用途关系一目了然。把源码转成人可读IR很简单clang -S -emit-llvm test.c -o test.ll如果没有Clang用llvm-project自带工具也可以但直接用Clang最方便。IR文件里你能看到全局变量、define段、基本块标签、指令操作符。我平时最常搜的就是load、store、br、icmp、ret这一类指令它们基本覆盖了普通C代码的IR表示。3.2 IR的核心指令与数据流表示举个例子一段简单的C代码int add(int a, int b) { int sum a b; if (sum 10) return 1; return 0; }对应IR大致如下define i32 add(i32 %a, i32 %b) { entry: %sum add i32 %a, %b %cmp icmp sgt i32 %sum, 10 br i1 %cmp, label %then, label %else then: ret i32 1 else: ret i32 0 }这里最值得注意的是SSA形式%sum、%cmp都只赋值一次。if分支汇合在return处但要是有更复杂的汇合路径就需要phi节点。比如把两个分支的值合并到最终返回里就会看到phi指令。理解IR不只是为了会读更是为了写Pass时知道往哪个层面改。想做函数级别的数据流分析用IR最舒服想处理结构化控制流语义由于IR是扁平的跳转结构反而要还原回CFG结构才能分析。3.3 编写一个真实的ModulePass/函数PassPass是最核心的存在它代表一次对IR的遍历和变换。老版本里写FunctionPass需要继承FunctionPass类但新Pass管理器下的接口略有不同。我以新Pass管理器为例演示一个简单的“函数指令计数Pass”。基本框架是定义一个类重写run方法遍历函数里的每个基本块、每条指令并计数然后打印到errs()输出流。#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 CountInstructionsPass : public PassInfoMixinCountInstructionsPass { public: PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { int count 0; for (auto BB : F) { count std::distance(BB.begin(), BB.end()); } errs() [CountInst] Function F.getName() has count instructions\n; return PreservedAnalyses::all(); } }; } // namespace extern C ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, CountInstructions, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name count-instructions) { FPM.addPass(CountInstructionsPass()); return true; } return false; }); }}; }代码不长但它涵盖了Pass最核心的几个部分Pass主体、插件注册入口、流水线解析回调。通过registerPipelineParsingCallback我们把命令行参数count-instructions映射到实际Pass对象。编译这个第三方Pass需要链接LLVM开发库。CMake配置里会用到llvm_map_components_to_libnames等工具这些在官方文档里都有模板。构建后生成一个.so共享库文件。用opt工具加载该Passopt -load-pass-plugin./libCountInstructions.so \ -passescount-instructions \ -S test.ll -o test_out.ll通过上面的操作每个函数的指令数都会被打印出来。这就是最基础的扩展方式。3.4 用opt和llc跑通自定义优化链Pass写好后通常是插在opt的passes参数里和内置Pass混用。新Pass管理器的命令行语法很有表现力比如opt -passesfunction(instcombine,count-instructions) ...也就是说你可以在内置Pass序列中无缝插入自定义Pass。编译代码生成阶段llc读取IR并负责后端流水线如果需要对机器指令层做自定义修改就要在后端的TargetPass里扩展代码生成逻辑。养成把IR打印出来的习惯非常有用。做优化前存一份IR优化后再存一份对比两份IR的差异就能直观看到Pass做了什么变换。我日常排查优化问题基本都靠这个方法。4. 高频问题与排查经验实录4.1 构建过程中常见的坑llvm-project构建期的坑我踩过不少大致可以整理成一个速查表。问题现象典型原因解决办法编译过程中内存不足OOM或链接进程被杀并行任务过多或链接大型二进制时内存超限调低-j参数设置LLVM_PARALLEL_LINK_JOBS2甚至1链接报错ld.lld error如undefined symbol宿主编译器版本过旧、相关库缺失升级编译器或改用Release构建避免Debug旧工具链的组合clang/cmake提示某些C17特性不支持宿主编译器过老使用更新的GCC/Clang作为CMAKE_CXX_COMPILER构建速度极慢未裁剪目标后端、Debug模式、未启用ccacheLLVM_TARGETS_TO_BUILD只留需要的启用ccacheDebug可选RelWithDebInfo运行测试时文件找不到环境变量未设置导出llvm-project安装路径下的bin目录到PATH设置LLVM_EXPERIMENTAL_TARGETS_TO_BUILD等第一次接触时最容易忽略LLVM_PARALLEL_LINK_JOBS这个参数。编译过程是并行但链接大二进制时每个进程都可能占用数GB内存。我曾在16GB内存机器上8个链接任务同时跑系统直接卡死。后来把LLVM_PARALLEL_LINK_JOBS设成2问题彻底解决。4.2 调试自定义Pass时的经验写Pass调试时会发现Pass不直接打印日志输出Run时报错也不一定在stderr可见。最好用LLVM_DEBUG宏配合调试专用输出流然后运行opt时加-debug-onlyxxx选项。习惯是给Pass加一个调试flag比如#define DEBUG_TYPE my-pass LLVM_DEBUG(dbgs() Processing function: F.getName() \n);运行命令加上对应参数控制输出opt -load-pass-plugin./libxxx.so -passesmy-pass -debug-onlymy-pass ...如果不加-debug-onlyLLVM_DEBUG的内容默认不会输出只有开了对应类型才打印。比errs()乱打规范得多也方便保留在源码里。4.3 分析IR变更和确认Pass是否生效确认一个Pass到底有没有改变IR最直接的方法是打印前后IR做对比。更工程化的做法是为Pass声明它保留了哪些分析结果。如果一个Pass没有修改任何函数返回PreservedAnalyses::all()告诉优化管道所有分析都还有效这个环节相当关键如果乱保留会导致后续Pass基于失效数据做错误假设。Pass的验证阶段可以用编译器自带的验证工具比如opt加-verify或者llvm-as对IR做合法性检查。IR有问题时验证器会给出相对明确的信息指出哪个函数或哪条指令违反了IR规范。信号很直接比自己在DEBUG里面到处打印高效得多。5. 基于LLVM的现代工具链与扩展5.1 已经用上LLVM的主流项目和语言如今几乎所有新一代工具链都在围绕LLVM构建。Rust编译器rustc的后端在默认平台上就是LLVM它先把自己的MIR转成LLVM IR再交给LLVM后端生成机器码。Swift编译器也使用LLVM作为后端所以Swift能快速适配Apple新处理器底层就是LLVM后端在支撑。其他语言里Julia对LLVM依赖很深HotSpot JVM也有实验性的LLVM后端还有一大批新语言像Zig、Carbon都直接基于LLVM或Clang的基础设施构建。GPU计算领域中NVIDIA的CUDA编译器、AMD的ROCm编译器都有LLVM后端参与。这些例子说明掌握了LLVM基础和自定义Pass开发相当于拿到了一套能横跨CPU/GPU语言/工具链的底层技能。5.2 MLIRLLVM生态里的下一层抽象MLIR是llvm-project仓库里偏前沿的方向。它在LLVM IR之上再抽象出一层目标是为多级IR转换提供基础设施。编译器前端直接产出LLVM IR往往太生硬中间要经历大量语义信息丢失MLIR允许你在高层保留循环结构、张量形状、算子语义等逐级Lowering到LLVM IR。这个思路很适合AI编译器方向PyTorch的torch-mlir、TensorFlow的TFRT底层都不同程度融合了MLIR思路。如果你对“前端语言设计优化后端生成”这个链条感兴趣建议先掌握LLVM经典IR和Pass再上MLIR会平滑很多。MLIR里的Operation、Dialect这些概念在理解LLVM的Instruction和Module之后直觉迁移很自然。5.3 学习路径建议从使用到贡献想用好llvm-project不能只停留在构建和写简单的第三方Pass。我建议按下面这条线推进。第一层是用用Clang编译项目、用opt做IR分析、用llc做后端检查、用llvm-nm/readobj这类工具查看目标文件。第二层是改在llvm/lib/Transforms里添加一个分析Pass或Transform Pass跑测试套件跑通。第三层是可扩展框架理解Pass依赖和AnalysisManager机制、下游Loops。第四层是上去做贡献从fix-in-code或test-case开始提交到Phabricator或GitHub PR。这个项目的社区协作模式很成熟新手完全可以从“补一个测试用例”“修复一个文档错误”开始逐步过渡到核心优化算法。这是学习曲线陡峭但收获极高的路径。6. 项目带来的通用工程能力6.1 阅读大型C项目源码的方法llvm-project代码量巨大但代码风格相当统一命名规范清晰类层次设计严谨。阅读它的时候可以借鉴一些方法先从IR定义入手因为这是所有模块共享的数据结构理解了Instruction和Value的继承体系后续看任何Pass都能快速定位。再选一条“垂直链路”精读比如从Clang前端emit一个函数到LLVM IR再到选一个后端目标生成汇编全程串起来比零散看很多模块更有效。同时要善用工具直接基于cscope或ctags浏览源码代码跳转效率比看网页高很多。另外结合git log看代码演进能理解很多设计取舍搜某些Pass时常用的手段就是看它某次commit里为什么加一个判断条件。6.2 编译器基础设施建设思维LLVM的模块化设计已经影响了一整代编译器和语言实现。设计任何复杂系统时先把接口定义清楚再允许不同模块独立演进是LLVM给的最典型案例。它的IR定义、Pass接口、AnalysisManager都体现了这个原则。甚至Debug Info元数据、异常处理模型都有专门的结构设计。工程上它的CMake构建系统、分层测试体系、严格代码review机制都是大型基础设施项目的范本。这些东西我不会在普通业务代码里直接照搬结构化流程但设计思路上能学到的非常多。6.3 个人实际使用中的体感这几年我陆陆续续基于llvm-project做了不少事情给一个私有语言写Clang插件做静态检查、为图像处理库定制循环优化、跑通了一套完整的链接时优化LTO流程。每次回头都发现真正让人收益的往往不是某个具体Pass写得多精巧而是对整个编译器工作链条的掌控感。你知道源码怎样一步步变成IRIR又怎样被优化最后变成机器码出问题的时候你是直接定位到具体层级的而不是黑盒猜谜。比如有一次发现线上一个C服务某些热点函数的性能始终上不去。用优化后的IR比对了半天发现是因为异常处理路径导致基本块布局不合理函数开头就多了一条跳转破坏了指令预取。后来调整了机器码基本块布局参数性能立刻好了很多。这种体感不深入LLVM内部是得不到的。写在最后llvm-project这个仓库从表面看是一个编译器集合本质却是一整套关于“如何设计可复用编译器基础设施”的实践。它会逼着你面对SSA、数据流分析、指令选择、寄存器分配这些硬核概念但每跨过一个坎你对程序在机器上如何真正运行的认知都会彻底刷新。相关的工作和学习我其实只用了很基础的一部分但已经感受到这套基础设施的强大和深不可测。所有对程序性能、语言设计、工具链实现有好奇心的人都能在这个项目里持续挖到东西。如果你准备开始建议不必贪多从我上面第二部分那套构建流程入手把工具链跑起来再照着第三部分写一个最小的Pass跑通之后你会发现编译器并不是什么高不可攀的黑盒子。后续想深入可以沿着IR、Pass依赖、后端代码生成这条线继续挖下去这里面还有很多有趣的东西等着你。