尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

深入LLVM:解读IR设计与Pass框架,从零构建编译器基础设施

深入LLVM:解读IR设计与Pass框架,从零构建编译器基础设施 1. LLVM到底是什么以及它为什么值得你花时间研究很多人第一次听到“LLVM”这个名字都会下意识地以为它是个虚拟机类似JVM那样的东西。这不能怪大家因为这个名字本身就是历史遗留产物——LLVM最初是Low Level Virtual Machine底层虚拟机的缩写。但今天你再去翻LLVM官方文档会发现它早就更名为“The LLVM Compiler Infrastructure”也就是LLVM编译器基础设施。名字保留了下来项目早已脱胎换骨。那么问题来了一个编译器基础设施到底“基”在哪里我这样解释。你写一门新编程语言最痛苦的事情不是设计语法不是写解释器而是让你的语言跑在各种CPU上。今天是x86明天是ARM后天冒出一个RISC-V还有GPU上的各种架构每换一个目标平台你就要重新写一遍代码生成、寄存器分配、指令选择这一整套东西工作量是以“人年”为单位的。LLVM的思路是你把源代码翻译成我定义的一种中间表示IRIntermediate Representation剩下所有优化和生成机器码的脏活累活我全包了。这就是“基础设施”的含义——你只需要盖房子不需要自己发电、铺路、通下水道。这个理念被验证得有多成功看事实就够了macOS和iOS上从Xcode 5开始用Clang替换了GCC做默认编译器Swift整个编译器就是建在LLVM之上的Rust的rustc后端用的也是LLVMJulia、Kotlin/Native、Zig、Crystal数不清的语言都跑在LLVM底座上。芯片厂商那边ARM、Qualcomm、Apple都在LLVM后端上做自己的深度定制。也就是说今天你手机上跑的每一行代码大概率都经过LLVM优化过。这已经不是“要不要用”的问题而是整个现代软件工业的底部承重墙之一。而“llvm-project”这个GitHub仓库则是把这套庞大体系打包在一起的单体仓库monorepo。它与早期分散在SVN/Git各仓的时期不同现在所有核心子项目——LLVM核心库、Clang编译器、LLD链接器、libc标准库、compiler-rt运行时库、MLIR、Flang、Polly等——都统一存放在这一个仓库里版本对齐构建统一开发体验好了不止一个量级。这也是我今天想重点拆解的东西。这篇文章适合谁如果你是做编译原理课程作业的学生或者正在评估语言后端选型的工程师再或者只是想搞明白“每次Xcode编译时那个Clang到底干了什么”的好奇者这篇文章就是给你准备的。我会从仓库结构、核心架构、IR设计、Pass机制一直讲到实际构建和写出第一个Pass保证每一步都能落地。2. llvm-project仓库解剖一个Monorepo里的完整编译工具链2.1 顶层目录各有分工一张表理清全家谱打开llvm-project仓库排在最前面的那几个目录估计能把不少新人绕晕。llvm、clang、lld、libcxx、compiler-rt、mlir、flang、polly每个看起来都像是独立项目怎么全塞在一起了其实这就是monorepo的典型形态。因为它们是同一个编译器生态的组成部分更新、发版、测试都是同步的放在一个仓里能保证提交的一致性。如果分开维护一个子项目的commit和另一个子项目的commit对不上版本号下游用户根本没法组合使用。下面这张表帮你快速定位每个目录的角色顶层目录全称/角色一句话定位llvmLLVM核心库与工具集中间表示、优化器、后端代码生成以及opt、llc、llvm-as等核心工具clangC/C/Objective-C前端把C家族语言解析、语义分析后生成LLVM IRclang-tools-extraclangd、clang-tidy等基于Clang的工具链生态静态检查、代码补全都在这lldLLVM链接器把目标文件链接成可执行文件速度比老牌GNU ld快很多libcxx / libcxxabi / libunwindC标准库及底层支撑新语言的运行时底座Swift的C生态也用这套compiler-rt运行时库和sanitizer工具ASan、UBSan、TSan等内存/未定义行为检测工具mlirMulti-Level IR框架面向AI编译器、异构计算的中间表示框架flangFortran前端老牌科学计算语言的编译器前端polly多面体优化器在LLVM IR上做循环变换、自动并行化、数据局部性优化openmpOpenMP运行时实现并行编程模型的实现和polly配合使用我个人的建议是第一次接触不需要全部搞懂只把llvm、clang、lld这三个目录的职责记清楚后面的路就顺了。MLIR和Flang属于进阶领地等你搞清楚基础IR之后再去碰会有完全不同的理解深度。2.2 llvm目录内部核心库的藏宝图llvm这一个目录本身就是个小宇宙。理解它的内部结构是进入LLVM开发的第一步。include/llvm全部公开头文件。注意是include/llvm而不是include/llvm-project所有接口声明都在这里。lib核心库实现。下面又按照功能拆成IR、Analysis、Transforms、CodeGen、Target等子目录。Transforms就是Pass的家后面我们要写的Pass就在这个目录体系里。tools面向用户的命令行工具。opt跑Pass的瑞士军刀、llc生成汇编/目标文件、llvm-as汇编IR转bitcode、llvm-disbitcode转IR文本、llvm-config查询构建配置全在这里。utils开发者用的辅助脚本和工具。比如生成某些TableGen后端的脚本。test回归测试集。LLVM的测试体系非常庞大runline匹配、FileCheck匹配都很有门道但那是后话。examples示例代码。想快速了解Pass怎么写先翻这里的例子。在开发时你用include里的头文件链接lib里的库用tools里的工具调试自己写的Pass再用test支撑你改动的正确性——这四件事构成LLVM开发的基本循环。2.3 一条C代码走到可执行文件的完整旅程为了把上面的目录串起来我带你走一遍一条流水线你写了一个hello.c。clang前端读取源码做词法分析、语法分析、语义分析生成AST再lower成LLVM IR默认不展开先以内存IR形态存在。opt或clang的优化流水线对IR跑各种Pass——inline、loop unroll、dead code elimination一个不少。llc或clang内部的后端把优化后的IR下降成目标平台的汇编代码。汇编器把汇编转成目标文件.o。lld链接器把目标文件、运行时库、动态库组合在一起最终产出可执行文件。注意看第2步和第3步之间的IR就是整个架构的分界线。前端的产物到这里为止后端的输入从这里开始中间共享的是语言无关、指令集无关的IR层。这就是LLVM最了不起的抽象。你不需要为C、Rust、Swift各写一套优化器优化器只要吃IR谁生成的IR都能伺候。我经常这样跟朋友打比方LLVM像一家全球化的物流公司。前端是各国的本地供应商把货物按统一规格打包进标准集装箱IR物流公司的公路上跑着统一的卡车优化器到了目标国家的港口再由本地的拆箱队后端把货物分发给当地买家。规格一旦统一整条链路的每一段都能独立演进这就是它的核心竞争力。3. IR设计为什么中间表示是整个项目最核心的资产3.1 三种形态一个本质LLVM IR是语言前端和后端之间传递的“通用语言”。它有三种形态但表达的是同一个东西文本格式.ll给人读的。你能直接打开看长得很像汇编但抽象层级比汇编高很多。比特码格式.bc给机器存取的紧凑二进制形态适合缓存和传输。内存中的表示LLVM在编译过程中真正操作的数据结构。Module、Function、BasicBlock、Instruction这些C类构成了内存IR的骨架。这三种形态可以互相转换。llvm-as把.ll转成.bcllvm-dis把.bc转回.ll内存对象可以dump成文本也可以从文本读进来。这个设计让调试和工具链集成都极其方便——你可以手写.ll文件测一个Pass也可以用工具生成.ll再手动改一改验证自己的想法。3.2 看一个真实例子C函数转成IR空讲抽象没意思直接看代码。比如一个最简单的C加法函数int add(int a, int b) { return a b; }用clang -S -emit-llvm add.c -o add.ll生成的IR精简后长这样define i32 add(i32 %a, i32 %b) { entry: %sum add i32 %a, %b ret i32 %sum }逐行拆解一下define i32 add(i32 %a, i32 %b)定义了一个函数返回值是32位整数两个参数也是32位整数。add是函数名全局符号用前缀局部变量用%前缀。entry:一个基本块basic block的标签。基本块是一段没有分支语句的直线代码序列。%sum add i32 %a, %b执行加法结果绑定到%sum。这条指令左边和右边的类型都是i32。ret i32 %sum返回这个值。注意一个细节“%sum”这个变量一旦定义就不会被重新赋值。这不是巧合而是LLVM IR的核心设计——静态单赋值形式SSAStatic Single Assignment form。3.3 为什么要坚持SSA和显式控制流SSA的约束是每个变量只能被定义一次。编译器教科书告诉我们SSA能简化数据流分析因为“定值”和“使用”之间的依赖关系直接写在这个约束上了不需要解方程。优化器拿到一个变量名就能立刻找到它的唯一定义点不用去跟踪时间线也不用担心变量值被覆盖。死代码消除、常量传播、公共子表达式消除这些优化在SSA下写起来都事半功倍。那在分支结构里怎么办比如if-else的两个分支分别给同一个变量赋值怎么保证“只定义一次”LLVM的答案是引入phi指令也叫phi node发音同“费”。它像一个多选一的汇合点根据控制流来自哪个基本块选对应分支的值。还是看例子int max(int a, int b) { if (a b) return a; return b; }对应的IR大致是define i32 max(i32 %a, i32 %b) { entry: %cmp icmp sgt i32 %a, %b br i1 %cmp, label %then, label %else then: ret i32 %a else: ret i32 %b }这里如果还要继续往下走的话就要用phi把所有汇聚的值合并在一起。虽然看起来绕但正是这种显式控制流图让优化器不需要从高层语法树的嵌套结构里反推控制流而是直接遍历基本块之间的跳转关系。编译器优化的前提是“看清程序结构”SSA和显式基本块把结构摊开了摆在优化器面前。3.4 强类型体系带来的优化底气LLVM IR的另一个特点是强类型。每个指令的操作数类型必须匹配类型不匹配的IR甚至无法通过验证器verifier。这一点跟多数高级语言IR如GCC的GIMPLE不一样它让优化器在互相传递信息时不需要重新做类型推理。举个例子你看到一条load i32, ptr %p指令立刻就知道这是从指针%p读取4字节数据。类型信息被编码在每条指令里到后端做指令选择时匹配指令模板的过程就快得多。代价是指令数比一般汇编略多类型信息也占据内存但换来的是各阶段共享信息的确定性非常划算。3.5 IR稳定性的衍生产品因为IR是整个生态的通用协议它催生出一个意想不到的结果工具链可以自由组合。你可以用别的语言的编译器生成IR然后用LLVM优化和生成代码比如GHC的LLVM后端、Rust的rustc、Julia等都是这么玩的。甚至可以直接手写IR做实验这在很多编译器项目里是做不到的。说得再直白一点IR的稳定让你可以把“前端”和“后端”当成两个乐高积木块任意搭配。不过也要有心理准备IR稳定不是承诺永远不变而是一旦变了llvm-project内部会同步调整所有依赖方给你的印象是它一直很稳定。真要追踪新变化最好的办法是看release notes和LangRefLanguage Reference文档。4. Pass框架优化器里的每一刀如何运作4.1 一个Pass就是一次“检查修改”的单元优化器不会一次性把代码变成最优解而是拆成一堆互不干扰的小步骤各自只做一件事。这些步骤在LLVM里就叫Pass。有点像一个流水线上的工人一个人只负责拧紧A螺丝另一个人只负责贴上标签各司其职互相只见中间件。Pass分成两大类Analysis Pass只分析不改代码。比如统计每个函数的指令数、分析函数调用的依赖关系结果供其他Pass查询。名字里常带“Analysis”。Transform Pass真正修改IR。删除死代码、函数内联、循环展开这些都是Transform Pass。一个Pass在运行时会“保留”或“破坏”某些分析结果。如果你修改了某个函数原先基于旧IR的分析结果就失效了。PassManager要跟踪这个变化决定下一个Pass是否需要重跑分析。这个模型听起来简单但实际写起来非常讲究因为Pass之间的依赖关系一旦理不清就会产生隐藏bug。4.2 日常实验必备opt是Pass的测试平台聊Pass绕不开opt这个工具。它就像一个“Pass调试器”# 从 .ll 文件读取IR执行死代码消除输出到新文件 opt -passesdce input.ll -o output.ll # 跑多个Pass先inline再dce opt -passesinline,dce input.ll -o output.ll注意新版opt用的是-passes参数不再是以前-dce这种shortcut风格。这个变化对应的是LLVM的New Pass Manager新Pass管理器。如果你是照着老教程学大概率会遇到-dce is not a registered pass之类的报错正当操作就是切换到新写法。这也是LLVM的经典劝退点之一提前打个预防针。4.3 写出并注册第一个Pass工具用的再熟不如自己写一个。下面这段代码是一个最简单的Function Pass作用极其卑微每看到一个函数就打印函数名啥也不改。#include llvm/IR/Function.h #include llvm/IR/IRBuilder.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Support/raw_ostream.h using namespace llvm; namespace { struct HelloPass : public PassInfoMixinHelloPass { PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { errs() func: F.getName() \n; return PreservedAnalyses::all(); } }; // namespace } // namespace extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, HelloPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name hello-pass) { FPM.addPass(HelloPass()); return true; } return false; }); }}; }这段代码的核心逻辑PassInfoMixinHelloPass是New Pass Manager下写Pass的基类模板。run(Function F, FunctionAnalysisManager AM)是入口函数。返回值PreservedAnalyses::all()告诉上层“我没有动任何分析结果”因为确实没改代码非常诚实。llvmGetPassPluginInfo()是动态链接插件入口让opt能通过-load-pass-plugin找到你这个Pass并注册成名字hello-pass。编译命令长这样前提是你按下一章的方式构建好了LLVM并安装了LLVM开发库clang -shared -fPIC hello_pass.cpp -o libHelloPass.so \ $(llvm-config --cxxflags --ldflags --libs)然后在IR文件上跑opt -load-pass-plugin./libHelloPass.so -passeshello-pass input.ll你会看到每个函数名被逐个打印出来。虽然这个Pass没有改变任何IR但它帮你验证了“我写的代码真的被编译器框架执行了”这件事。我至今记得第一次看到自己写的Pass跑起来时的感觉——就好像亲手给一台钢琴调了一根弦虽然只是微末工作却搞明白了每个发音的机制。4.4 让Pass真正改点东西一个死代码消除练习打印函数名只是热身。想看到Pass真正改变IR最直观的练习是写一个“偷懒版”的常量传播把x 2 3这样的折叠成x 5。LLVM里这个现成的Pass叫instcombine或constant-folding你直接用就可以了先构造一个带冗余操作的IRdefine i32 test(i32 %a) { entry: %bad mul i32 %a, 0 ret i32 %a }%bad这个变量算出来了没人用属于教科书级的死代码。跑一下opt -passesdce dead.ll -o clean.ll再看clean.ll%bad就被清理掉了。这个过程简单得让人觉得有点傻但它的意义在于你亲眼看到了优化器在工作。后面你写的Pass越复杂就会越依赖这个“写IR→跑Pass→看输出”的实验闭环。4.5 New Pass Manager与Legacy Pass Manager的区别这两套框架的历史可以讲很久但对你上手最关键的一点是Legacy Pass Manager是旧的、基于继承和动态注册的体系C代码里一堆static RegisterPass宏后来维护成本越来越高。New Pass Manager在新版本里是默认方案它用Mixin组合的方式写Pass生命周期更清晰分析结果缓存更可靠Pipeline解析也更稳定。现在上游已经启动Legacy PM清理计划写新代码一律默认New PM别在老路上继续投入时间。遇到网上教程还在写static char ID 0这种代码可以直接跳过那套体系正在被淘汰。5. 从零到一在自己的机器上构建llvm-project5.1 构建前的身体检查硬件和依赖LLVM是个重量级项目构建它是对机器的一场考试。我的建议配置内存至少16GB32GB更舒服链接阶段非常吃内存保守估计一个clang链接能吃掉3-6GB磁盘配置和源码加一起需要40GB以上空闲只多不少CPU多核是王道8核起步系统Linux或macOS最顺Windows也能跑但坑相对多一些依赖方面C编译器是必须的用Clang自己来编译会更好一些避免GCC和Clang在边缘情况下的差异。还有CMake版本看LLVM release note要求推荐3.20和Ninja构建加速比Unix Makefiles快不少。5.2 实际操作cmake配置与ninja构建我推荐在llvm-project/llvm目录外做out-of-source构建把build目录和源码隔离开。具体命令如下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 \ -DLLVM_PARALLEL_LINK_JOBS2 \ -DCMAKE_C_COMPILERclang -DCMAKE_CXX_COMPILERclang逐项解释-S llvmCMake的源码根目录是llvm注意不是llvm-project根目录因为整个monorepo由llvm/CMakeLists.txt驱动。-DLLVM_ENABLE_PROJECTSclang;lld启用Clang前端和LLD链接器。不启用的部分不会参与构建可以大幅节省时间。你先用这两个就够了。-DLLVM_PARALLEL_LINK_JOBS2链接是最耗内存的一步限制同时链接的job数量可以有效防止内存被撑爆。如果你内存32GB可以设416GB设2比较稳。CMAKE_BUILD_TYPERelease用Release编译整个工具链构建产物性能好适合日常使用。配置完成后开始构建ninja -C build在8核机器上全套构建可能需要40-60分钟16核能压到20-30分钟。第一次跑的时候可以去喝杯茶练练瑜伽别干蹲着。这里还有个小技巧如果你只是想初体验可以加-DLLVM_TARGETS_TO_BUILDX86只保留本机架构的后端构建时间能再砍一半。5.3 验证安装并快速跑一遍工具链构建完成后的工具和库都在build/bin和build/lib里。验证一下build/bin/clang --version build/bin/opt --version然后写个hello.c试试完整链路echo int main() { return 0; } hello.c build/bin/clang hello.c -o hello ./hello echo $?转成IR看一下build/bin/clang -S -emit-llvm hello.c -o hello.ll cat hello.ll到这里你自己构建的LLVM已经可以当日常编译器用了。后续想更新git pull后在build目录里重新执行ninja就行。5.4 新手最容易翻车的三个构建问题我见过太多人在这一环节卡壳也踩过相同的坑提前帮你排掉**问题一GCC编译Clang时出现奇怪错误。**比如找不到gcc_s库或某种ABI不匹配。解决办法优先用系统自带的clang编译clang鸡生蛋问题其实是最不容易出问题的或者明确指定binutils路径。如果实在没有clang至少GCC 9然后把-DCMAKE_C_COMPILERgcc -DCMAKE_CXX_COMPILERg放在cmake参数里。**问题二链接期内存爆炸。**最常见的就是机器死机或OOM killed。别硬扛把LLVM_PARALLEL_LINK_JOBS设小或者把CMAKE_BUILD_TYPE改成RelWithDebInfo占内存小一点但还是接近Release。内存实在不够就得考虑swap或加内存了这是硬性门槛。**问题三报错说tablegen不能执行。**这通常发生在源码目录和build目录权限不匹配或目录带中文路径的情况下。确保整个llvm-project路径纯英文、无空格再重新配置。6. 进阶路线从入门到能在llvm-project里干活6.1 把官方文档当“字典”而不当“教材”很多人一上来就把LangRef从头读到尾结果一周后全忘了实际上LangRef是当字典用的。当你对某条IR指令或者某个Pass的参数有疑问时翻对应章节查完就合上。真正的上手方式是先跑通几个例子带着问题去查文档。文档里我最推荐的前三个LLVM Language ReferenceIR语法和语义的权威写Pass时的“圣书”Writing an LLVM Pass官方教你写Pass的入门教程包含新旧PM的对比示例LLVM Coding Standards代码风格规范。如果你想向社区提交代码先读这个能少挨很多骂6.2 在源码里考古远胜于在搜索引擎里考古LLVM API变化快网上很多博客教程已经过时。我自己的经验是老博客提到的函数名在当前源码里可能已经被换掉了。与其满网搜索不如直接在llvm-project源码里搜索你想用的API或者Pass名字你会发现大量真实用法。例如你搜PassInfoMixin能看一整套用新PM写的Pass搜registerPipelineParsingCallback能发现每个插件Pass怎么注册自己。这些源码比任何教程都新鲜、可靠。LLVM的代码风格也很统一读多了你就知道“这一类东西该这么写”。6.3 从打酱油到提交补丁参与上游社区的现实路径参与LLVM社区给我的最大感受是门槛不像想象那么高但你必须先真的用起来。论坛LLVM Discourse和官方issue tracker是讨论的主阵地补丁通过GitHub或Phabricator历史上用的来走Review流程。新人的第一个补丁建议从极小的bugfix开始比如修正一个错误诊断信息、文档拼写、或者某个Pass在边界情况下的崩溃。直接一上来就“我要重构优化器核心”的人往往因为没有反馈而很快放弃。社区欢迎新人的点是诚实说明“这是我的第一个LLVM补丁”老成员通常会用足够耐心来帮你看清楚手头这些庞杂的代码。我自己做过的一个小补丁只是修复了一个循环转换Pass在整数溢出时的未定义行为诊断。那个bug小到几乎没人会在意但它让我完整经历了“发现问题→构造IR复现→改代码→补测试→提交→等review→修问题→合入”这一整条链路。回头看这条链路比任何教程都更能让你理解LLVM的工作方式。6.4 最后几句掏心窝的话如果把整个llvm-project比作一座城市那IR就是贯穿全市的道路网Pass框架就是一套精密的交通信号系统c首端、后端的工具链则是四处穿梭的车辆。每个人最初都只能看到城市边缘的一角但只要你顺着道路网走就会发现每栋建筑之间都通过这条路连接起来。我个人的体会是学LLVM最不该犯的错误是想一口吃成胖子。有人一上来就想写林辉循环优化有人盯着GPU后端不放结果都在源码海洋里失去了方向。正确的姿势很简单先把它当成一个工具使用再用工具去观察IR的偏移最后从你最熟悉的那个小小的Pass开始写起。日拱一卒等你回头看第一天编译时长长的日志会发现自己已经能在这座城市里找到任何想去的地方。
返回列表