尧图精选

LLVM与编译器基础:从IR到Pass的实战指南

🕒 发布时间:2026/9/20 19:18:38 📁 来源:尧图网络
前阵子有朋友问我“llvm-project到底是什么”聊完之后我发现大多数人对它的认知停留在“就是编译器”这个层面知道Clang是LLVM家的前端知道它和GCC打了很多年但再往里问就语焉不详了。作为一个在编译工具链领域折腾过不少真实项目的人我想从贴近实战的角度把整个仓库的轮廓、核心机制以及真正值得投入精力的方向完整盘一遍。这篇文章适合三类人一是准备用LLVM做二次开发的工程师比如想给自己的自定义指令集写后端或者基于LLVM搭静态分析工具二是在校学生想理解现代编译器到底怎么组织代码三是日常写C/C但被“-O2到底对我的代码做了什么”这个问题勾起了好奇心的开发者。我不会只堆概念工程上的选型逻辑、踩过的坑和一个实操里可照搬的Pass示例都会一并交代。1. 当编译器不再只是编译器LLVM项目的设计起点1.1 传统编译器的“一次绑定一生”困境在LLVM出现之前开源编译器生态里最主流的方案几乎就是GCC。GCC的设计虽然经历了极其漫长的生产环境考验但本质上属于典型的“前端和后端高度耦合”结构前端负责把C/C代码解析成中间表达后端再把它转换成目标机器的汇编或机器码。问题是这个中间表达在GCC里主要是GIMPLE和RTL跟具体语言、具体CPU架构的特性绑定得很深。带来的直接后果就是你想支持一门新语言基本上等于要把整个编译器的中后端工作流重新摸一遍你想为一种新CPU做后端适配也没法绕开前端的表达模型。我在早期参与某工具链适配时对这种耦合的痛点体会特别深。假设一个目标硬件存在极其特殊的访存指令在GCC后端里要做的工作远不止“加一个指令模板”那么简单——有些情况下甚至会牵扯到GIMPLE到RTL的下降过程中如何表达新指令语义的问题。这类改动的成本和风险都很高很多时候比从零写一个独立的小型后端还让人头疼。需求只要稍微复杂一点就会变成一场和既有架构的拉锯战。1.2 LLVM破局三层分离架构LLVM项目真正厉害的地方不是“又写了一个编译器”而是重新划分了编译器的边界。它的核心思路非常清晰把编译器拆成前端、优化器、后端三个互相独立的层次。前端负责把源代码转换成统一的中间表示也就是LLVM IR优化器只围绕IR做各种变换完全不关心原始语言是C还是Rust后端则把优化后的IR进一步下降到目标机器指令。这套拆分的威力在于任何一门新语言只要写出一个能生成LLVM IR的前端就能立刻复用整个LLVM优化器和全套后端。任何一款新CPU架构只要实现一个从IR到目标指令的后端就能立刻支持所有已经接入LLVM的语言。这个“一次接入、处处复用”的策略让LLVM在十几年间从一个学术项目长成了整个行业的基础设施。今天你看iOS上的Swift、Android的Kotlin/Native甚至很多GPU厂商的shader编译器底层用的都是这一套设施。1.3 LLVM IR为何能成为整个项目的枢纽拆得开只是第一步关键是中间这个IR要设计得够好。LLVM IR是一种静态单赋值SSA形式的强类型中间语言它有干净的指令集、显式的数据流、无限数量的虚拟寄存器以及一个可以被优化器自由操作的三地址码结构。相比传统编译器内部的复杂表示LLVM IR几乎没有历史包袱所以优化器可以非常容易地理解“每一行代码在做什么”。举个直观例子一段简单的C代码int add(int a, int b) { return a b; }经过Clang转成未优化的LLVM IR之后你会看到这样的表示define i32 add(i32 %a, i32 %b) { %sum add i32 %a, %b ret i32 %sum }每一个操作都清晰可见%a和%b是两个虚拟寄存器add指令做的加法ret指令返回结果。这就是为什么LLVM能支撑起那么多“编译器之上的编译器”——调试一个优化问题你不需要面对整个编译器只要盯着IR的变化过程就够了。我在实际排查性能问题时最常用的手法就是把源码编译成IR然后一步步看优化器到底把它变成了什么样。2. 深入llvm-project仓库核心子项目与各自的角色2.1 LLVM核心库不是“一个编译器”刚接触llvm-project仓库的人最容易蒙圈的是目录结构。这个仓库是一个超大单体仓库monorepo根目录下并列着llvm、clang、lld、compiler-rt、libcxx、mlir等多个子项目。其中llvm这个目录是整个仓库的心脏但严格来说它本身并不是一个完整编译器而是一整套编译器基础设施库IR定义、优化Pass、代码生成、目标描述、汇编器、链接器、调试器支持等全都做成可组装的模块。这意味着你可以用llvm目录里的各种工具自由组建编译器流程标准路径是从Clang前端进入生成IR再做优化最后交给后端产出目标代码但也可以完全绕过Clang直接从手写的IR文本开始工作。实际上很多硬件厂商就是这么干的他们可能用自家前端比如基于MLIR做的高层IR来生成LLVM IR然后接入LLVM的优化和后端最终生成目标代码。这种结构让LLVM核心库变得像一套“乐高积木”而不是一台已经焊死的机器。2.2 Clang前端C家族语言的门面clang子项目是LLVM生态里最广为人知的前端支持C、C、Objective-C和Objective-C。Clang的设计目标之一就是“足够快的编译速度和足够清晰的诊断信息”。相比老牌编译器Clang的错误提示在很多真实场景下确实更能帮人定位问题——它能指出你因为漏掉一个分号导致后面20行全部解析失败而不是只抛出一个让人摸不着头脑的unexpected token。Clang另一项重要资产是LibTooling它允许你基于Clang的AST编写独立的代码分析工具。这让我觉得Clang不只是一个“把源文件变成IR的转换器”更是一个可以做精确语法分析、语义分析的库。现在很多静态分析器、代码格式化工具、自动重构工具都是基于Clang这个库做出来的。如果你将来要做代码检查、代码生成或者项目级重构直接从Clang的AST入手比在源码级别用正则硬抓要靠谱得多。2.3 配套组件libc、compiler-rt、lld的分工llvm-project仓库里还有一堆容易被忽略但非常关键的组件。libc和libcabi是C标准库及其ABI兼容层的实现它们和Clang搭配兼容性最好提供了一套现代化的C运行时选择。compiler-rt则提供一系列运行时库其中以Sanitizer家族最为出名AddressSanitizer、UndefinedBehaviorSanitizer、ThreadSanitizer。这些工具在实际排查内存错误、未定义行为和并发竞争时帮了我大忙——只需要在编译命令加-fsanitizeaddress运行时就能精确捕捉越界访问发生的调用栈比事后看core dump要直观太多。lld是LLVM生态的链接器在并行链接性能和资源占用上比传统系统链接器优秀得多。大型C工程里用旧链接器链接可能需要几十秒甚至几分钟换成lld后同样规模的工程经常能在几秒内完成。lld的另一大特点是模块化它设计成可嵌入的库构建系统可以直接调用它的接口而不是启动子进程。这跟整个LLVM项目的理念一脉相承把编译过程中的每一个环节都变成可复用、可嵌入的模块。3. 把llvm-project跑起来构建、配置与定制3.1 动手之前先决定三件事在编译llvm-project之前我建议先把三件事想清楚版本、目标组件和构建类型。LLVM通常每半年发布一个大版本版本之间Pass接口和API常有变化。如果你要做二次开发强烈建议锁定一个固定版本并在项目文档里写明别直接跟着main分支走——否则上游接口改个不停你会发现上个月还能编译的代码这个月就彻底编不过了。目标组件决定了构建时间。llvm-project全量构建一遍在没有预编译缓存的情况下即使机器配置很好往往也要一两个小时甚至更久。如果只做Pass开发通常只需要llvm和clang两个子项目如果只是想验证后端的指令选择逻辑甚至只编llvm就够。至于构建类型的选择Debug构建能拿到完整调试信息但编译器和生成的IR处理速度都很慢Release构建速度快但调试信息受限我最常用的组合是RelWithDebInfo它兼顾运行性能和可调试性适合绝大多数开发场景。实际开发里我还强烈建议用Ninja作为构建系统并配合CCache缓存。Ninja的增量编译做得非常干净改动少量文件时不会触发无关模块重编CCache则能在反复切分支、删build目录后依然通过缓存命中避免大量C对象重编。加了这些设施后哪怕改了Pass接口导致很多文件要重编等待时间也能压缩到可接受范围。3.2 CMake构建的关键参数拆解一个典型的LLVM构建命令大致长这样git clone https://github.com/llvm/llvm-project.git cd llvm-project cmake -G Ninja -S llvm -B build \ -DCMAKE_BUILD_TYPERelWithDebInfo \ -DLLVM_ENABLE_PROJECTSclang \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_ENABLE_ASSERTIONSON \ -DBUILD_SHARED_LIBSON ninja -C build这里几个选项值得展开。LLVM_TARGETS_TO_BUILD指定要生成哪些后端目标如果只填X86构建体积和时间会大幅缩减做跨平台开发时再把AArch64、RISCV等加入。LLVM_ENABLE_ASSERTIONS开启断言检查能让Pass里的隐性问题在调试阶段就暴露出来否则很多错误会拖到运行时才莫名崩溃。BUILD_SHARED_LIBS会把所有模块编成动态库而不是静态库显著减少最终可执行文件的链接时间代价是部署时需要携带一堆动态库文件。做Pass开发的话还可以在CMake里加一个LLVM_BUILD_EXAMPLESON它会编译llvm自带的示例代码里面包含不少完整可用的Pass骨架是极好的参考材料。另一个容易被忽略的选项是LLVM_USE_LINKERlld——既然我们本来就在编译LLVM为什么不用自己的lld来加快链接这个选项在Ninja增量构建过程中带来的体验提升非常直观。3.3 写一个最简单的自定义Pass构建完成之后第一件值得做的事不是急着读源码而是动手写一个最简单的新Pass管理器Pass。这个Pass不需要多复杂只要能遍历一个函数里的指令并打印它们的操作码名称就足以让你建立起对LLVM优化管线工作方式的直觉。现代LLVM14及以后已经全面使用新Pass管理器注册流程比老版本清爽不少。这里是一个完整的自定义函数Pass插件骨架#include llvm/IR/Function.h #include llvm/IR/Instructions.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Passes/PassManager.h using namespace llvm; namespace { class DemoPass final : public PassInfoMixinDemoPass { public: PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { for (auto BB : F) { for (auto I : BB) { errs() I.getOpcodeName() \n; } } return PreservedAnalyses::all(); } }; } // namespace llvm::PassPluginLibraryInfo getDemoPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, DemoPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name demo-pass) { FPM.addPass(DemoPass()); return true; } return false; }); }}; } extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return getDemoPassPluginInfo(); }把这段代码编译成动态库之后你就可以在一个已有优化Pipeline里通过命令行参数加载它。这个模式看起来简单实际价值在于你终于拿到了一个可以随意修改、调试、插入到优化流程任意阶段的“自己的优化器”。3.4 用opt和llc验证自定义优化拿到Pass插件后的验证流程是把一个IR文件喂给opt让优化器按你指定的Pass顺序处理一次再对比处理前后的IR差异。比如clang -S -emit-llvm -O0 test.c -o test.ll opt -load-pass-plugin./libDemoPass.so -passesdemo-pass -S test.ll -o test.transformed.ll这里的-S表示输出IR文本而不是二进制格式-passes指定要运行的Pass序列。这相当于一个“IR实验室”每次只做一步变换盯着输出文件里的指令变化特别适合理解每个优化Pass的行为边界。等分析清楚了再把它真正挂到Clang的某级优化比如-O2流程里。llc则负责把IR降到目标机器汇编。想看某段IR在X86下的指令选择结果可以执行llc test.ll -o test.s --print-after-all 2 compile.log--print-after-all会输出每个指令选择阶段之后的完整IR或MI状态这是回溯“指令从哪里被埋错”的利器。我在做后端调优时几乎离不开这个开关。4. 我在实际使用中踩过的坑与排查思路4.1 构建时间爆炸与并行度设置第一次全量构建llvm-project的人很容易在“到底要编多久”这件事上心态崩溃。我自己就见过同事在8核虚拟机上编译Debug模式等了一下午还没编完。反直觉的地方在于一味增加并行任务数不是总是有效。LLVM构建是一个高度依赖内存的过程每增加一个Ninja并行任务内存消耗可能多出1GB甚至更多。如果机器只有16GB内存却使用-j32系统一旦开始swap构建速度反而会断崖式下跌。排查思路也很直接先观察内存和CPU负载不要盲目调高并行数。我的经验是从物理核心数减2开始然后边编边看内存占用如果剩余内存在编译高峰期还能稳定在4GB以上再逐步增加任务数。另一个更稳的方案是拆分构建目标只编当前需要的几个工具例如ninja -C build llc opt而不是一把梭地构建全部工具链。最后我再强调一次CCache——它对我反复切换分支、清理build目录后的编译时间改善非常显著几乎是我做LLVM开发离不开的设施。4.2 新老Pass管理器接口混用自定义Pass最典型的坑之一是把老Pass管理器API照搬过来然后发现opt根本不认。LLVM在十四个大版本之后全面转向新Pass管理器很多老教程里的registerPass、createXxxPass调用方式已经不再适用。如果你照着旧资料写最常见的结果是编译报错说找不到某个创建函数或者插件能正常加载但无论怎么写-passes参数它都说不认识这个Pass名。排查思路建议从编译错误开始。先确认你的include头文件路径来自当前源码树而不是系统自带的旧版LLVM这个可以通过clang -v查看头文件搜索路径来确认。然后把插件加载命令放到最前面保证opt确实读到了这个插件。最后对照官方文档里关于NewPassManager的示例检查注册回调是写在PassBuilder::registerPipelineParsingCallback里而不是误挂到别的注册宏下。我曾在开发中把一个Pass同时注册到多个回调里导致每次加载都被重复注册虽然不报错但运行时行为变得非常奇怪——这种问题只能靠逐行比对新老API在源码树里的真实用法来解开。4.3 Sanitizer报错和“看起来没问题”的悬空指针另一个让我印象深刻的问题发生在使用AddressSanitizer期间。当时我在写一个Pass需要在分析过程里修改IR跑测试时ASan直接报了一个use-after-free。代码逻辑从字面上看完全没有问题所有操作都发生在同一个函数作用域里。但最后定位到原因竟然是我持有一个指向Instruction的指针在中间又调用分析管理器获取另一项分析结果这次调用触发了pass的清理逻辑把我手里的指针变成了悬空指针。从此我养成一个习惯在Pass开发里凡是跨分析调用边界去保存IR对象指针都必须极其谨慎。LLVM IR里大部分对象虽然有自己的生命周期管理但分析管理器在某些情况下会重新计算内部缓存你手里的裸指针不会自动更新。正确做法是不要长期持有Instruction类型的裸指针每次需要时都从BasicBlock或者Function出发重新获取。这个习惯帮我省下了一大堆“原本不该出现”的bug也让我意识到在LLVM里理解对象生命周期和所有权规则比理解某个API本身更重要。5. 基于LLVM做二次开发的三种实用思路5.1 静态分析在IR层面“看穿”程序LLVM IR天生适合做静态分析因为它去掉了源码里的语法糖把控制流和数据流都显式地暴露出来。你可以在IR上实现污点分析、常量传播、不可达路径检测甚至做一个针对特定业务场景的性能瓶颈分析器。相比纯源码级分析IR层面做分析的核心优势在于不用处理各种语言特性也不用维护庞大的AST匹配规则。实际开发时LLVM自带的很多分析框架能帮你省下大量重复工作比如DominatorTree、LoopInfo、MemorySSA这些分析结果都可以在Pass里按需获取。你只需要在Pass里声明依赖在run函数里调用AM.getResultLoopAnalysis(F)这类接口框架就会保证分析的增量更新和失效处理。这个模式对写静态分析工具的效率提升是巨大的很多时候你只需要关注业务逻辑本身不需要自己再搭一套数据流框架。5.2 定制优化性能基准中的差异化武器优化Pass可能是普通人最容易切入的LLVM二次开发场景。比如说你在做某个特定领域的计算任务发现通用-O2优化并不完美某个关键循环的向量化策略不符合你的数据访问模式或者某个内联决策导致指令缓存局部性变差。这种情况下你可以写一个只针对热点函数的Pass实现更激进的循环展开或者禁止某些收益不确定的变换。我自己的一个项目里曾经通过自定义Pass把一个反复执行的短循环替换成对查表内存的批量预取整体性能提升了约15%。这种改动在源码层面几乎不可能干净实现因为编译器会认为你只是多做了多余内存访问然后把它优化掉。但在IR层面你拥有对最终指令序列的精确控制权可以告诉机器“这一步就是要先做预取后面对应的内存访问会因此受益”。5.3 为自定义指令集后端生成代码如果你所在项目涉及自定义处理器LLVM后端的接入是最硬核也最有价值的二次开发方向。核心工作包括编写表驱动指令选择器、寄存器信息描述、指令编码信息并在TableGen文件中描述新架构的指令集。这个路线学习曲线陡峭但一旦跑通你就拥有了一套完整编译器Clang能把C代码编译成目标指令集的汇编优化器替你处理中间表示层面的所有问题。我做后端开发时的一个血泪建议是先别引入复杂指令扩展把最基础的整数算术、访存指令描述好跑通一个简单的“加法函数”编译流程再逐步扩展到浮点、向量、原子操作。我刚开始接触后端时试图一次把所有指令都写进td文件结果在调试指令编码阶段被一堆莫名其妙的指令选择错误淹没。先把最小闭环跑通后续扩展会顺畅得多。6. 给初学者的学习路线建议6.1 不要一上来就啃源码llvm-project的源码规模非常庞大是几十万个文件量级的巨型项目。直接一头扎进源码很容易在目录树里迷路。我的建议是先建立“黑盒运行”的整体概念用Clang把一个C文件编成IR用opt跑一个Pass用llc生成汇编把所有工具链步骤跑通一遍。有了整体流程之后再带着“我想改的那个点”的问题出发循着调用链往内部看。带着问题读代码的效率远高于漫无目的的浏览。6.2 从“改”到“写”是最好的路径最有效的上手方式是找一个现有Pass稍微改动它的行为看看对IR输出有什么影响。比如把某个循环展开因子从2改成8然后观察生成的汇编长度和指令分布变化。改到一定量后你会自然理解IR的设计为什么是现在这个样子也会明白编译器为什么对某些代码模式特别偏好。这时候再开始写自己的Pass你会发现自己已经能看懂很多看似神秘的API调用到底是在解决什么问题。6.3 长期值得跟踪的资源LLVM官方文档里的“Getting Started”、“Pass Infrastructure”和“TableGen”这几个页面属于必读。另一个容易被忽略的资源是LLVM的test目录里面每一个测试文件都是一个微型“输入-期望输出”对读完这些测试基本等于看了一遍维护者的思路过程。想跟上游保持同步的话可以关注LLVM社区的Phabricator和GitHub上的RFC讨论持续一段时间就能感受到社区在往哪些方向演进。最后分享一点个人体会我在最初接触LLVM时也觉得它庞大到无处下手但后来发现这个项目最优秀的地方不是它有多少工具而是每一层都在严格遵循“可组合”的设计哲学。你不需要立刻理解整个项目只需要找到一个感兴趣的切入点——无论是IR、Pass优化还是后端描述——在那个层面上反复实践。过不了多久你就会发现自己已经能在整个工具链里顺畅地穿行了。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →