深入LLVM:从项目构建到自定义Pass的完整实践指南 📅 发布时间:2026/9/20 3:56:32 👁 浏览次数: LLVM这个项目我从第一次编译它的懵圈状态到现在能熟练在它上面做各种二次开发中间踩过的坑比写过的代码还多。很多人一听到llvm-project第一反应是哦就是那个编译器但真正进去之后才发现它根本不是一个简单的编译器项目而是一整套能让你自定义编译器、静态分析工具、代码优化管线、甚至自己造一门编程语言的基础设施。这篇文章我打算从一个过来人的角度把这套东西掰开揉碎了讲清楚——它到底在解决什么问题、仓库里那些子项目分别是什么、怎么从零开始把它构建出来、IR到底是什么、以及如何动手写一个自己的Pass扩展它的能力。无论你是想入门编译器开发、做代码分析、还是给AI芯片写编译器后端读完这篇应该能少走很多弯路。1. 别被名字骗了LLVM真正在做的事1.1 Low Level Virtual Machine这个名字的误会2000年Chris Lattner在UIUC开始这个项目时给它起了个名字叫Low Level Virtual Machine也就是底层虚拟机。所以LLVM最早确实是想做一个虚拟机类似Java虚拟机那样只不过面向的是底层指令。但后来的发展完全超出了预期——这个项目变成了一个编译器基础设施名字却一直沿用了下来成了历史遗留问题。我在很多技术交流场合发现仍然有人把这个缩写当作虚拟机来理解然后问出LLVM能跑什么字节码这种问题。实际上今天的LLVM已经和虚拟机的概念关系不大它更像是一个编译器的工具箱或者按官方文档的说法是一个模块化、可重用的编译器与工具链技术的集合。理解这一点很重要因为它决定了你后续怎么用它。如果你拿它当普通编译器用那Clang只是它表面的一个前端如果你拿它当可执行文件去运行那只是用了不到它全部能力的一小部分。真正的价值在于它暴露了成体系的C库API让你可以像搭积木一样把解析源代码优化中间表示生成机器码这些阶段组合成自己的工具。1.2 把编译这件事拆成三段要理解LLVM的设计哲学得先看传统编译器的痛点。在LLVM出现之前写一个编译器通常是这样的你拿到一种编程语言比如C语言然后要为每一个目标平台分别写一套完整的前端、优化器、后端。GCC就是这么干的。语言有M种、目标平台有N种你就要做M乘以N份工作而且每份工作都高度耦合语言特性跟指令选择逻辑搅在一起牵一发而动全身。LLVM把这条链路彻底解耦成了三个阶段前端Frontend把源代码解析成抽象的中间表示IRIntermediate Representation中端Optimizer对IR做各种与目标平台无关的优化后端Backend把优化后的IR翻译成特定目标架构的机器码这样做的好处非常直观新增一种语言只需要写一个新的前端把语法翻译成LLVM IR新增一种目标平台只需要写一个新的后端把IR翻译成对应指令集。M加N的工作量而不是M乘以N。且慢这个中间表示的概念恰恰是整个LLVM能走红的关键。它不是某一种语言特有的也不属于某一种CPU特有的它是所有前端和后端之间的普通话。前端负责各种方言后端负责各种土话IR就是中间的翻译层。1.3 谁在用它从苹果到英伟达到你自己你可能已经在不知情的情况下使用了LLVM。苹果的Xcode从Xcode 5开始默认就用Clang而不是GCCAndroid NDK的编译器链就是基于Clang和LLD的游戏主机、路由器固件、各种嵌入式设备的编译器链也大量使用它。但更值得你留意的是那些不像编译器的用法。英伟达的CUDA编译器、AMD的ROCm编译栈、各种AI加速芯片的编译器研发底子几乎都建在LLVM和它的子项目MLIR上。苹果的Swift语言编译器、Rust的官方编译器rustc也是把前端接入了LLVM后端。也就是说哪怕你不打算做一个传统意义上的编译器只要你想快速实现一种新的编程语言、想解析分析现有代码、想给特定硬件生成优化代码LLVM都可能是最合理的起点。这也是我为什么强烈建议每个系统软件方向的工程师都应该花一段时间把llvm-project摸透的原因。2. 从仓库布局看清llvm-project的家底2.1 一个仓库装下十几套独立工具链llvm-project这个仓库之所以叫项目集是因为它把整个LLVM生态的所有子项目都收纳成了一个monorepo。2019年之前各个子项目分布在不同的Git仓库版本同步非常痛苦后来谷歌和苹果牵头把它们合并到了一起。合并后的仓库体积巨大我第一次做完整clone的时候光Git历史就下载了很久。这个仓库的一级目录每一个都可以看作一个独立的项目有些甚至有自己的邮件列表、自己的发布节奏、自己的代码评审流程。初学者最容易犯的错误就是以为只要进了llvm-project仓库所有东西都跟编译C语言有关。其实里面有很多东西完全不在编译主链路上。下面是仓库里最核心的几个一级目录我按和日常工作关系的紧密程度列一个表目录角色定位典型使用场景llvm/核心库IR、优化器、目标后端、基础工具写Pass、做代码分析、自定义后端clang/C/C/Objective-C编译器前端编译C/C代码、做AST层面的分析与改写clang-tools-extra/基于Clang的附加工具clang-tidy静态检查、clangd语言服务、clang-format格式化lld/链接器替代GNU ld/gold链接速度极快lldb/调试器替代GDB调试现代C和Swift等语言compiler-rt/运行时库AddressSanitizer等内存检查工具、内置函数实现libc/ libcabi/C标准库实现使用现代C特性的运行时支持mlir/多级IR编译器基础设施AI芯片编译器、领域特定编译器flang/Fortran前端科学计算Fortran编译openmp/OpenMP运行时并行计算polly/多面体优化针对循环嵌套的自动并行化/向量化优化bolt/二进制优化工具对编译完的二进制做性能调优libunwind/栈回溯库C异常的栈展开支持这里面我需要特别强调一下llvm目录和其他目录的区别。你可能会困惑仓库根目录下就有个llvm文件夹这个llvm和整个llvm-project是什么关系实际上狭义的LLVM核心库就在这个llvm目录里包括IR的定义、优化Pass、代码生成器、以及opt、llc、llvm-as这些命令行工具。Clang、LLD这些是外围项目它们依赖llvm核心库像插头一样插上去。所以如果你想做和优化、目标代码生成相关的工作重点精力要放在llvm/这个目录。2.2 Clang不仅仅是一个编译器前端Clang是LLVM生态里最广为人知的前端它负责C、C、Objective-C这些语言解析为LLVM IR。但Clang能做的事情远不止编译生成可执行文件。Clang对外暴露了libclang和Clang的C API这意味着你可以不用自己写词法和语法分析器就能解析任何C/C代码拿到完整的AST做代码重构、静态分析、代码补全。clangd就是这样一个基于Clang的语言服务器很多现代IDE的后台都是它。还有clang-tidy它是一堆静态检查规则的集合能在编译之前发现代码里的潜在问题。我在实际项目里经常用它做代码评审的自动化关卡配合clang-format统一代码风格效果相当好。如果你写过GCC插件就会知道做这种工具在GCC生态里是很痛苦的而Clang把这些工具链的能力作为一等公民暴露出来这是它能在工具链市场快速占领份额的重要原因。clang-tools-extra目录里的东西绝对值得你去翻一翻。很多初学者只盯着clang目录看忽略了旁边还有这样一个宝库。2.3 运行时阵营编译器不只是编译期的事很多人忽略的一个事实是编译器项目往往还附带一堆运行时库。llvm-project里compiler-rt、libc、libcabi、libunwind这些目录都属于运行时阵营。compiler-rt里最出名的是Sanitizer系列这是编译器自动插桩代码所依赖的运行时库。比如AddressSanitizerASan能在程序越界访问内存时立刻报错并打印调用栈UBSan能检查未定义行为。它们的使用方式往往是编译时加一个-fsanitizeaddress翻译出来的代码就会调用compiler-rt里对应的运行时代码。这些工具在C/C项目调试中几乎是必备的我在自己的项目里一直默认开ASan抓出过无数莫名其妙的越界崩溃。libc则是C标准库的另一种实现。Linux上大家默认用的是GCC的libstdc但libc对C标准的支持更快更干净特别是C17之后很多新特性libc跟进得都很积极。如果你想给某个平台定制C运行时用它比改动libstdc友好太多。2.4 面向未来的MLIR和底层倚仗的LLDMLIRMulti-Level Intermediate Representation是llvm-project里相对年轻但势头最猛的一个项目。它出生于2019年目的是设计一组可自由组合的多级中间表示框架专门解决编译器在不同抽象层级之间的表示匹配问题。我看过不少人不理解为什么有了LLVM IR还非要搞MLIR。一个很现实的原因是LLVM IR的抽象层级过于接近机器码不适合表示深度学习计算图这类高层结构。TensorFlow对着LLVM IR干瞪眼无法有效优化矩阵乘法、卷积这些高层算子。MLIR允许你定义自己的方言Dialect让计算图、张量算子、循环结构、向量化这些层级各自表达再逐层Lower到LLVM IR最终生成机器码。今天主流的AI编译器几乎都在MLIR之上构建。LLD则是一个链接器它的核心卖点就一个字快。同样的项目GNU ld链接可能要几十秒LLD往往几秒就跑完了。在大型软件迭代中这个提速带来的体验提升是质变的。我现在构建任何C项目只要条件允许都会把链接器换成LLD配合-fsplit-dwarf链接时的等待感基本消除。3. 亲手把它编出来构建llvm-project的完整姿势3.1 构建前的硬件准备和心态建设很多人第一次构建llvm-project被它庞大的编译时间和磁盘占用吓住。我这里先给你一个心理预期全套默认配置编译下来大约会消耗40到60GB磁盘空间取决于是Debug还是Release用一台8核16线程的现代CPURelease构建大概需要40到60分钟Debug构建可能要2到3个小时以上。这还没算你后面改代码的增量编译时间。所以我的建议是磁盘至少留80GB内存至少16GB能用SSD就用SSD机械硬盘编译LLVM会让人怀疑人生。另外编译时-j参数不要无脑拉满内存不够的话Ninja容易被系统OOM杀死一般来说并行度设为逻辑核心数的一半到三分之二比较稳妥。如果你觉得全量构建太慢完全可以只构建你需要的子项目或者先用预编译包配合源码做局部开发这是很多LLVM开发者的日常做法。3.2 一次标准构建的完整命令拆解我平时用的构建命令大概是这样的形式先clone再配置再编译# 获取代码--depth1只拉最新提交能省大量时间和流量 git clone --depth 1 https://github.com/llvm/llvm-project.git cd llvm-project # 配置构建系统-G指定生成Ninja构建脚本 cmake -G Ninja -S llvm -B build \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld;clang-tools-extra;compiler-rt \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_ENABLE_ASSERTIONSON # 开始构建nproc获取逻辑核心数 cmake --build build -j $(nproc)这里-S llvm -B build的含义要解释一下-S指定源码根目录这里填的是llvm因为要构建的核心项目源码在这个目录下Clang这些子项目是作为额外项目参与的-B指定构建目录所有中间文件和生成文件都放在build下面。把构建产物和源码分开是CMake的标准实践千万不要直接在源码目录里就地构建否则后面清理和切换配置会非常痛苦。构建完成后验证一下是否成功build/bin/clang --version echo int main(){return 0;} /tmp/hello.c build/bin/clang /tmp/hello.c -o /tmp/hello /tmp/hello echo OK如果输出了一行OK说明整个工具链已经能正常工作了可以从零开始编译C代码了。3.3 这些CMake开关到底在控制什么上面的命令里用了几个CMake变量很多新手对这些变量一知半解随意改动导致各种编译失败。我这里逐个说清楚。CMAKE_BUILD_TYPE控制优化级别和调试信息。Release会开-O2编译出来的Clang效率高适合日常使用Debug则不做优化便于用gdb调试LLVM自身的代码但速度极慢而且生成的文件巨大。我平时以Release为主只有在需要打断点调试Pass逻辑时才构建Debug。LLVM_ENABLE_PROJECTS用来指定除了llvm核心之外还要一起构建哪些子项目。注意这里填的每一个项目都会显著拖长编译时间。如果只是打算用Clang编译C代码-DLLVM_ENABLE_PROJECTSclang就够了如果要做链接器的替换再加lldcompiler-rt这些运行时组件虽然有用但不是每次都得编。这个变量还区分了编译期组件和运行时组件运行时组件libc、compiler-rt、libunwind在较新版本里往往需要通过LLVM_ENABLE_RUNTIMES来构建而不是LLVM_ENABLE_PROJECTS。LLVM_TARGETS_TO_BUILD控制后端要支持哪些目标架构这个变量最容易被忽视但实际上对编译时间的影响极大。LLVM默认会构建它支持的所有目标包括X86、ARM、AArch64、RISC-V、PowerPC等等每一个目标后端都有一堆代码生成逻辑。如果确定只需要X86就把其他目标全部砍掉能省下大量时间。如果你做的是交叉编译或者嵌入式开发就按需加上ARM;AArch64等。LLVM_ENABLE_ASSERTIONS则决定LLVM自身代码里的assert宏是否生效。如果你是开发LLVM功能、写Pass、调试后端强烈建议打开如果只是使用Clang做日常编译可以关闭以获得更好的性能。3.4 构建必踩的三个坑构建LLVM有一个特点报错信息往往比较隐晦不像普通项目那样一行错误就能定位问题。我总结三个我栽过跟头的地方第一个坑是系统GCC版本太旧。LLVM要求编译器支持C17如果系统默认GCC旧于7.1配置阶段会直接报错提示编译器不支持相关特性。解决办法是用Clang本身来编译LLVM反正Clang也可以编译自己或者安装一个较新的GCC再指定CC和CXX环境变量。第二个坑是磁盘空间在编译过程中耗尽。LLVM的编译会产生大量中间文件、静态库和二进制产物Debug模式尤其严重。我在一次Debug构建时发现竟然吃掉了80多GB。这个坑最难受的地方在于它不是一开始就爆而是编到一半才提示No space left on device重来一遍又浪费时间。所以开始之前一定用df -h确认磁盘余量。第三个坑是默认Python版本问题。LLVM的测试框架和部分工具脚本依赖Python3如果系统里默认Python指向Python2会有一堆诡异错误。现代Linux发行版一般没问题但如果你在旧系统上构建记得确保python3命令可用。如果你打算长期开发LLVM建议安装ccache并开启缓存cmake -G Ninja -S llvm -B build \ -DCMAKE_CXX_COMPILER_LAUNCHERccache \ -DCMAKE_C_COMPILER_LAUNCHERccacheccache在多轮清理重建时的加速效果非常恐怖某些场景下能把重复编译时间缩短到十分之一。4. 中间表示IR整个项目的命脉4.1 为什么非要一个IR不可我一直认为没有理解IR就不可能真正理解LLVM。IR是LLVM生态的血液循环系统前端负责生产它优化器负责加工它后端负责消费它。如果把编译比作一个跨国物流系统源代码是在A国写的委托书机器码是B国能识别的货物清单那IR就是两国之间的标准集装箱。无论A国的委托书用什么语言写成都统一装箱成标准集装箱无论B国怎么处理货物都从标准集装箱里提取。这样一来物流公司只需要维护各国语言到集装箱的翻译员以及集装箱到B国仓库的拆箱员而不需要给每一对国家-到-国家的组合都配备专门的翻译。更关键的一点是IR让优化这件事有了落脚点。在没有IR的编译器里优化逻辑要针对源语言写、针对目标机写两头都不讨好。有了统一的IR优化器只需要处理一种结构规范的数据语言无关的优化常量传播、死代码消除、函数内联、循环展开就能把各种语言一视同仁地优化。4.2 SSA和基本块IR的语法骨架LLVM IR的数学基础是静态单赋值形式Static Single AssignmentSSA。SSA的核心规则是每个变量只能赋值一次。你可能会想这一条规则有什么用它让数据之间的依赖关系变得显式了。如果一个变量只被赋值一次那么到底谁在用它的结果、它依赖于谁的赋值结果都一目了然后续的寄存器分配和许多优化都因此简单了很多。在SSA的形式下IR程序的基本单位是基本块Basic Block。一个基本块是一条线性执行的指令序列只有一个入口和一个出口函数则由若干基本块组成控制流图CFG。分支、循环、函数调用这些控制流变化都会表现为基本块之间带条件的跳转边。我看过不少初学者试图用C语言的直觉去阅读IR看到一堆%1、%2这种临时虚拟寄存器就觉得头大。其实掌握一个规律就好虚拟寄存器名只是编号重点是每条指令的操作和它引用的依赖关系。4.3 用一段C代码看懂IR和优化前后的变化耳听为虚动手操作一遍比看书强一百倍。我们把下面这段C代码转成IR看看。// test.c int abs_sum(int a, int b) { int x a - b; if (x 0) x -x; return x; }在构建好的LLVM目录里执行build/bin/clang -O1 -S -emit-llvm test.c -o test.ll生成的test.ll里关键部分长这样define i32 abs_sum(i32 %a, i32 %b) { %sub sub i32 %a, %b %cmp icmp slt i32 %sub, 0 br i1 %cmp, label %if.then, label %if.end if.then: %neg sub i32 0, %sub br label %if.end if.end: %x phi i32 [ %sub, %entry ], [ %neg, %if.then ] ret i32 %x }我们来逐行理解这份IR。%sub用sub指令计算a - b这里的i32表示32位整数类型。%cmp用icmp slt做有符号小与比较。br i1 %cond, label %A, label %B是条件跳转如果条件为真跳到if.then基本块否则跳到if.end。重点看phi指令它是SSA的一个特色指令根据控制流的来源选择某个值。如果从entry基本块来%x取值%sub如果从if.then基本块来%x取值%neg。之所以需要phi是因为x在C代码里被赋了两次值第一次是a - b第二次是-x这在SSA规则下是两个不同的变量phi负责把这两条历史值合并回当前值。这说明一个非常重要的道理IR比C代码更接近机器码很多高级语言里已经消失的底层细节它都保留下来了。接下来再看优化器的威力。改用-O2重新生成build/bin/clang -O2 -S -emit-llvm test.c -o test_o2.ll结果会简洁到只剩几行define i32 abs_sum(i32 %a, i32 %b) { %sub sub i32 %a, %b %neg sub i32 0, %sub %cmp icmp slt i32 %sub, 0 %x select i1 %cmp, i32 %neg, i32 %sub ret i32 %x }可以看到优化器把phi指令消掉了把分支变成了select指令省掉了基本块的跳转开销。这就是优化器在IR上做的好事——它纯粹在IR层面发现这段控制流可以等价转换为一条select指令然后把IR重写得更高效。这种优化和原始语言无关和最终目标CPU也无关。4.4 优化管道如何运转LLVM的优化工作不是一条巨型Pass横扫一切而是由几十个各管一段的Pass按固定顺序组成管线Pass Pipeline。每个Pass只做一类非常聚焦的变换比如instcombine各种指令层面的代数化简和模式匹配gvn全局值编号消除冗余计算inline函数内联loop-rotate把while循环改写成do-while形式以便优化dce死代码消除-O2参数的本质就是告诉Pass管理器把这条标准管线跑一遍。不同的优化级别对应不同的管线O0几乎不做任何优化O1做轻量优化O2和O3逐步加入更强也更耗时的Pass。理解这一层后你会发现写一个Pass本质上就是在往管线里插入自己的一段处理逻辑。这也是我下一步要讲的实操内容。5. 写一个自己的PassLLVM扩展的正确打开方式5.1 为什么要用插件方式而不是改LLVM源码几乎每个学过LLVM的人都会经历一次我要写个Pass实现XX功能。但写Pass有两种方式方式选错会让你后续迭代极其痛苦。一种方式是直接把Pass源码加进LLVM的源码树里修改CMakeLists然后重新编译整个LLVM。这种方式的缺点是显而易见的每次改动都要等全量或增量编译动辄几分钟到十几分钟而且改乱了核心库还影响其他功能的稳定性。另一种更推荐的方式是把Pass编译成动态库用opt的-load-pass-plugin参数在运行时加载。你把Pass作为一个独立的.so文件想加载就加载想卸载就卸载主项目一行代码都不用改编译自己这个插件只需要几秒钟。我的所有Pass开发工作现在都用插件方式只有确认某个优化值得合入主项目后才会考虑移植到LLVM源码树里。LLVM官方也把PassPlugin作为扩展的主要推荐机制它和LLVM自身的PassManager能够无缝集成。5.2 一个能跑的FunctionPass统计函数指令数下面这个插件是一个完整可运行的示例。它的功能是在编译器的优化阶段遍历每个函数统计函数体内的指令数量并把结果打印到标准错误输出。我先给完整代码然后逐块解释。// CountInstruction.cpp #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 { class CountInstructionPass : public PassInfoMixinCountInstructionPass { public: PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { unsigned InstCount 0; for (auto BB : F) { InstCount BB.size(); } errs() [CountInstruction] Function F.getName() has InstCount instructions\n; return PreservedAnalyses::all(); } }; } // namespace extern C ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, CountInstructionPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name count-instruction) { FPM.addPass(CountInstructionPass()); return true; } return false; }); }}; }这个代码的几个关键点我要详细说说。PassInfoMixinCountInstructionPass是LLVM新Pass管理器New Pass Manager从老式继承接口迁移过来的风格。你只需要提供run函数框架会负责调用它。run的返回类型PreservedAnalyses用来告知Pass管理器我这个Pass跑了以后哪些分析结果仍然有效。因为我们只是打印信息、不修改任何IR所以返回PreservedAnalyses::all()表示所有分析都保持原样这样可以避免后续Pass做无效的重复计算。extern C部分的llvmGetPassPluginInfo是插件的约定入口点。opt加载.so文件时会查找这个符号拿到一个结构体里面包含插件版本、名称和注册回调。这个回调里registerPipelineParsingCallback做的事情是当用户在-passes命令行参数里写了count-instruction时就创建一个我们的Pass并加入FunctionPassManager。新PassManager把Pass按作用域分为ModulePass、FunctionPass、LoopPass等不同级别运行机制也各自不同。如果你只需要单个函数级别的处理把Pass放进FunctionPassManager就够了。5.3 编译、加载、跑通的完整过程首先是编译成动态库。我用构建好的Clang来编译这个插件# 需要先知道LLVM头文件和库的位置用llvm-config获取 LLVM_CXXFLAGS$(build/bin/llvm-config --cxxflags) LLVM_LDFLAGS$(build/bin/llvm-config --ldflags --libs --system-libs) # 编译成动态库 build/bin/clang $LLVM_CXXFLAGS -shared -fPIC -fno-rtti \ CountInstruction.cpp -o CountInstruction.so $LLVM_LDFLAGS注意两个细节-fno-rtti必须加因为LLVM默认禁用了RTTI如果编译器开启RTTI链接时会报一堆typeinfo相关的符号找不到错误另外这个插件只用了头文件和少量核心库符号链接参数理论上不必拉全所有库但在复现阶段图省事直接加上--libs也没问题就是生成的.so会大一点。接下来是拿到一份IR作为输入。还是用之前生成的test.ll先编译成字节码文件build/bin/llvm-as test.ll -o test.bc然后运行我们的插件build/bin/opt -load-pass-plugin./CountInstruction.so \ -passescount-instruction \ test.bc -o /dev/null如果你看到类似这样的输出说明插件加载成功并被执行了[CountInstruction] Function abs_sum has 7 instructions如果你在-passes里写了不存在的Pass名opt会提示注册失败。这通常意味着插件的函数名跟你注册时的字符串不一致或者插件根本没被加载进去。5.4 从传统Pass到新PassManager接口怎么适应网上的很多LLVM教程还停留在老PassManager的写法类要继承FunctionPass使用getAnalysis获取依赖分析通过llvm-as编译后再用opt -load加载。其实在LLVM 13之后新PassManager已经成了默认新代码应该优先写PassInfoMixin风格的Pass。老接口的问题在于它依赖全局状态写起来虽然直接但不利于并行执行和模块化。新接口把配置和依赖关系的管理挪到了PassManager层Pass本身变得无状态——只是接收输入、产生输出从而允许框架决定何时运行、如何缓存分析结果。如果你接手的老代码是传统格式写的也不用太慌张。核心优化逻辑遍历函数、指令等可以几乎原样保留需要改的只是类继承方式、run函数签名、以及注册宏这几层结构。把上面示例的框架套进去把里面函数体换成你原来的逻辑通常就能迁移过来。6. 折腾llvm-project这一年多最值钱的经验6.1 三件套调试工具opt、llc、llvm-dis很多人在LLVM里调试代码只会加打印语句和用gdb效率很低。其实LLVM自带一套处理IR的命令行工具它们才是日常调试的主武器。opt负责跑优化Pass它既可以加载外部插件也可以执行内置的所有标准Pass。我是用它来快速验证某个Pass的输出是否符合预期的几乎每次改完Pass逻辑都要跑一遍小样例。llc负责把IR变成汇编或目标文件。当你想看某个IR经过后端代码生成后变成什么样的指令时用它执行llc test.bc -o test.s打开汇编文件逐行对照即可。配合--debug参数还能输出指令选择的具体过程这是排查后端问题时的利器。llvm-dis则是把二进制bitcode反汇编成文本IR。它和llvm-as正好是一对。这两个工具让IR可以自由地在文本和二进制之间切换——文本适合人看二进制适合机器快速加载传递优化结果时通常会转成.bc文件。我调试代码的标准模式是这样的写一个很小的测试源文件用clang -emit-llvm转成IR再通过opt跑自己要调试的Pass观察IR变换发现问题后再缩小到具体指令。整个过程往复迭代几分钟就能完成一个验证周期不需要重新编译LLVM主工程。6.2 库版本和头文件不一致是整个项目最隐蔽的坑LLVM的ABI兼容性政策很严格同一份代码如果链接的LLVM库版本和头文件版本不一致几乎百分之百会出问题而且报错信息往往非常难懂。我自己就吃过一次大亏。当时系统里同时装了发行版的LLVM比如Ubuntu的默认包和从源码编译的LLVM。我在写插件时用llvm-config拿的是源码版路径但CMake在查找依赖时找到了系统安装版的库导致一堆undefined symbol和version mismatch错误。这个问题的解决思路是搞清楚你的llvm-config到底是哪一个。如果编译插件时用了build/bin/llvm-config那么编译和链接都要保证它指向同一个构建产物目录。为避免混乱我建议你构建插件时始终用绝对路径并且在CMake里显式指定LLVM_DIR为构建目录下的lib/cmake/llvm。优先使用find_package(LLVM)并传入确切的LLVM_DIR路径比靠环境变量碰运气可靠得多。6.3 TableGen生成的代码手改就是给自己挖坑如果你深入LLVM的目标描述代码会看到大量.td文件。这些文件不是C源码而是用TableGen语言写的目标描述文件。TableGen是整个LLVM里的一个代码生成器它读取.td文件自动生成C代码比如指令匹配表、寄存器信息表、调度模型输出的.inc文件会在编译时被包含进源码里。很多人第一次看到X86GenInstrInfo.inc这类文件时会因为它看起来像正常代码而尝试直接修改这是大忌。所有手工修改生成文件的努力都会在下一次TableGen重新生成时被覆盖。正确的姿势是改.td文件然后重新编译让TableGen重新生成.inc。理解了这条规则就能看懂为什么LLVM的每个后端目录下面都有.td文件、工具链里还有一个叫llvm-tblgen的可执行文件。如果你要添加一条新指令或新寄存器核心工作是在.td文件里描述它的属性而不是手写C代码。6.4 官方文档和测试就是最好的学习路径最后分享一个学习效率最高的路径LLVM文档里有一个Writing an LLVM Pass的教程页面但那个页面我建议不要作为唯一的Pass入门资料因为它更新的速度有时跟不上代码演进的步伐。最好的办法其实是直接读代码。在llvm/lib/Transforms/目录下随便挑一个小型Pass比如Hello、SROA、GVN用我上面说的方法单步跑一遍看它处理前后的IR差异理解它调用了哪些分析接口。LLVM的单元测试目录llvm/test/下面有海量的测试用例每个用例的IR小文件都附带了期望输出天然就是一个个为什么这么写Pass的答案。我自己学某一类优化算法时常用的顺序是先看该Pass的.cpp源码里注释开头的算法概述然后在llvm/test/Transforms/下找到对应的测试文件观察它期望的变换方式最后用opt手工跑一个小样例验证理解。这个循环比任何教程都扎实。还有一点LLVM源码里的错误消息和断言信息是很有价值的文档。遇到崩溃不要急着打补丁先认真读一下崩溃位置的断言信息往往会发现它是为了拦截某种你没有预料到的情况而专门写下的。我在LLVM上从看文章、跑例子、写第一个Pass到给公司做了一套内部代码插桩工具前后也就一两个月的事。现在回想起来最关键的转折就是动手写了自己的Pass并跑通了那个编译、加载、验证的最小循环。一旦你跨过这道门槛LLVM庞大的源码就不再是迷宫而是一个按模块摆放整齐的工具库你需要什么就去哪个抽屉里拿。