深入LLVM核心:从IR架构到自定义Pass的完整实战指南

发布时间:2026/9/18 8:55:05
深入LLVM核心:从IR架构到自定义Pass的完整实战指南 1. 项目概述与核心价值为什么 LLVM 值得花时间作为一个跟编译器和开源工具链打交道的人我很难绕开 LLVM。这个项目最初只是伊利诺伊大学的一个研究框架用来做所谓无限期的编译优化结果十几年下来它几乎成了现代编译基础设施的标准答案。你如果用手机、电脑、服务器凡是跑着高性能程序的地方背后大概率都站着 LLVM 的代码。Clang、Swift、Rust、Julia甚至连 GPU 厂商的编译器都建立在它之上。所以今天我想把 llvm-project 这个项目的核心脉络拆出来讲清楚从架构、组件到实际动手跑一个 pass一次说透。之所以这个项目值得深挖不只是因为它大、它流行而是因为它的设计思路跟传统编译器完全不同。传统编译器像一条单向流水线前端直接针对特定语言后端直接绑死特定 CPU想换语言或换硬件基本等于重写。LLVM 则强行切开成三段式前端负责把源代码变成本质上跟语言无关的中间表示IR中间层在这个 IR 上做优化后端再把优化过的 IR 翻译成具体目标机的指令。这个设计让一套优化管全家变成了可能也让编译器本身变成可组合、可嵌入的组件库。只要你某个环节有需求就可以拿到对应模块来改来用。写这篇文章我想面向的是两类人。第一类是被迫要碰 LLVM 的编译器爱好者和系统软件工程师比如想自己写个静态分析器、做个编程语言前端、或者只想搞懂 Clang 为什么报出那么诡异的错误信息。第二类是单纯想了解现代编译技术为何如此重要的学生和开发者。无论哪类这篇文章都会先帮你把框架立起来再带你跑一个真实案例省得一头扎进源码里迷路。2. 核心架构拆解LLVM 的三段式流水线是怎么运转的2.1 前端、中端、后端一次翻译-优化-落地的旅程LLVM 把传统编译器的解析 - 优化 - 生成拆成了三个阶段但它的特别之处在于每个阶段都产出一个标准化的中间数据尤其是中端的 LLVM IR它几乎是整个生态的枢纽。前端比如 Clang 或 Rustc把源代码解析、类型检查、语义分析然后最终吐出来一批 IR。中端拿到 IR 后会跑一系列被称为 pass 的优化比如死代码消除、循环展开、内联、向量化这些 pass 的目标是把逻辑上调优同时保持 IR 本身与硬件无关。后端再把这些优化的 IR 降低为目标机器的指令选择、寄存器分配和指令调度最终产物一般是可执行文件、目标文件或库。这个分割并不是为了好看。它带来的第一个巨大好处就是可重入性。你可以为几十种语言复用同一套优化器和后端只需要写一个前端你也可以让同一个 C 程序完美地移植到不同 CPU因为后端是自动切换的。更重要的是中间层成了一个公开、稳定的 API任何人可以在任意环节插入自己的 pass做语言扩展、安全分析、性能剖析都行。实际 ICU编译器基础设施里很多项目比如 KLEE、SVF都是通过自定义 LLVM pass 来搞事物。2.2 LLVM IR静态单赋值与无限定寄存器LLVM IR 是整个系统的心脏。它有两种形式一种是可以给人读的文本格式.ll文件一种是紧凑的二进制 bitcodeclass 和 struct 等类型系统都有对应表示。IR 的关键性质是静态单赋值SSA意味着每个变量只能被赋值一次这极大简化了数据流分析。比如在 SSA 形式下一个值从哪来、被用到哪去直接用 def-use 链就能追踪不需要重命名冲突的变量这个性质让几乎所有优化 pass 的实现都简洁许多。举个例子同样是计算a b在真实机器码里你需要确定用什么寄存器运算结果会被覆盖的旧值怎么办但在 LLVM IR 里你写的可能是%add add i32 %a, %b这里%add这个虚拟寄存器只能赋值一次并且不用关心它最后落在真实 CPU 的哪个物理寄存器上。这种无限定寄存器的设计让后端的寄存器分配过程变成了解决一个图着色问题而不是写死每一条汇编。理解这一点你才明白为什么 LLVM 能够很优雅地处理各种新架构。直接读 IR 是快速掌握 LLVM 的捷径。你平时可以用clang -S -emit-llvm foo.c -o foo.ll来看一段简单 C 代码被转换成什么样读几遍就能建立起高级代码到低级操作之间的对应感强烈推荐新手做这个练习。2.3 优化管道与 PassLLVM 的核心玩法如果把 LLVM 比作一家工厂那优化管道就是生产线上的各种工序每个工序是一个 pass。pass 大体分两种一种是 module pass它一次性看整个编译单元可以做跨函数分析另一种是 function pass只针对单个函数做变换。自 LLVM 14 之后新版 pass 管理器还用了一种 new PM 的风格强调 pass 之间的依赖和缓存结果避免一些老掉牙的顺序依赖问题。优化管道的核心挑战是顺序和力度。比如先做内联还是先做常量传播结果会差别很大-O0和-O3之间不只是开关不同而是定了完全不同的 pass 列表。如果你用llvm-as将 IR 变为 bitcode再用opt工具执行单个 pass就能手工体验链条上某一环带来的变化。比如opt -passesinstcombine test.ll会把很多冗余指令合并成更高效形式效果非常直观。对于一个普通开发者来说最有意思的入场点是编写自定义 pass。无论是做代码混淆、插桩、还是优化自己语言生成的 IR你只需继承一个基类、在runOnFunction或run()里写你的逻辑再通过llvm::PassRegistration注册即可。这个模型极度友好这也是为什么 LLVM 周围能长出一个庞大的分析/优化工具生态。2.4 后端的汇编与代码生成从 IR 到机器码的最后一段后端通常是最隐蔽也最复杂的部分。LLVM 后端采用了个强大的 tablegen 技术用.td文件描述指令集特性、寄存器文件、指令选择规则等而后生成大量 C 代码。这意味着当你给一个新 CPU 编写后端时大部分机械重复工作可以由 tablegen 生成你只需要填充描述。听起来很美但这一层的水极深因为涉及指令调度、寄存器分配优先级、调用约定等多个子问题。所以很多想搞语言的新人会先停留在后端直接选 LLVM 的现有目标这个层面——这也是一个非常聪明的策略。自己动手做后端对大多数人来说不现实但理解后端的职责仍然有价值。你至少该知道最终生成的汇编会经过指令选择DAG / GlobalISel、指令调度按时钟周期排布、寄存器分配将虚拟寄存器映射成物理寄存器、以及后续的各个优化小步骤。只有真正碰到需要调汇编性能的时候你才站在正确的知识地基上。3. 实操上手从零构建 LLVM 并跑通一个自定义 Pass3.1 环境准备与源码构建的坑构建 llvm-project 本身是很多人的第一个门槛因为它的体积和编译时间相当恐怖。一个完整的 Release 构建可能要花掉 30 到 60 分钟占十几 GB 磁盘。如果只是学习我建议不要全量构建而是只构 Clang 外加几个工具和库。典型命令如下git clone https://github.com/llvm/llvm-project.git cd llvm-project mkdir build cd build cmake -G Ninja ../llvm \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_ENABLE_ASSERTIONSON ninja这里最该注意的是LLVM_TARGETS_TO_BUILD。默认会构建所有目标既拖时间又耗磁盘如果你只是在本机跑指定 X86 就够了。另一个关键选项是LLVM_ENABLE_ASSERTIONS调试 pass 时强烈建议打开因为它会让 LLVM 内部很多断言生效早点暴露问题。如果你用的是旧版本源码还要小心 CMake 和 Ninja 版本、编译器版本的不匹配我遇到的最多问题出在 GCC 版本过低或者 Clang 被当作默认编译器但缺一些依赖。构建完后你会看到bin目录里有clang、opt、llvm-dis、llvm-as等一堆工具它们构成了我们后面操作的主战场。3.2 编写一个简单的 Function Pass为了不陷入太深的框架细节我建议从经典的 function pass 开始写一个遍历所有指令并统计二元运算数量的 pass。新建一个目录准备一个CMakeLists.txt然后按如下方式组织源码。先创建一个MyPass.cpp#include llvm/IR/Function.h #include llvm/IR/InstrTypes.h #include llvm/Pass.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/IR/IRBuilder.h #include llvm/IR/Instructions.h using namespace llvm; namespace { class CountBinaryOpPass : public PassInfoMixinCountBinaryOpPass { public: PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { unsigned BinOps 0; for (auto BB : F) { for (auto I : BB) { if (auto *BO dyn_castBinaryOperator(I)) { (void)BO; BinOps; } } } if (BinOps 0) { errs() Function: F.getName() has BinOps binary operators\n; } return PreservedAnalyses::all(); } }; } // namespace PassPluginLibraryInfo getPassPluginInfo() { const auto callback [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name count-bin-op) { FPM.addPass(CountBinaryOpPass()); return true; } return false; }); }; return {LLVM_PLUGIN_API_VERSION, CountBinaryOp, LLVM_VERSION_STRING, callback}; } extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return getPassPluginInfo(); }这个代码利用新 Pass 管理器的插件机制注册了一个叫count-bin-op的 pass。当你把它编译成动态库之后opt就能加载它管线里指定这个 pass 名字时就会调用我们定义的逻辑。核心点是dyn_castBinaryOperator用来判断指令类型而errs()是 LLVM 提供的输出流专门用来打调试信息。3.3 编译和运行你的 Pass接下来需要用 CMake 把上面的源码编成一个插件。CMakeLists.txt 内容如下cmake_minimum_required(VERSION 3.20) project(MyPass) find_package(LLVM REQUIRED CONFIG) message(STATUS Found LLVM ${LLVM_PACKAGE_VERSION}) include_directories(${LLVM_INCLUDE_DIRS}) add_definitions(${LLVM_DEFINITIONS}) add_library(MyPass MODULE MyPass.cpp) target_link_libraries(MyPass PRIVATE LLVMCore LLVMSupport)注意这里必须是MODULE库这样编译出来的产物会在 macOS 上是.so在 Windows 上可能是.dll。在编译之前要把 LLVM 安装或 build 目录的lib/cmake/llvm加入CMAKE_PREFIX_PATH。完成cmake -S . -B build cmake --build build之后你就有build/libMyPass.so了。测试它先用 clang 编一段小代码cat test.c EOF int foo(int a, int b) { int c a * b; int d c a; return d - b; } EOF clang -S -emit-llvm test.c -o test.ll opt -load-pass-plugin ./libMyPass.so -passescount-bin-op test.ll -disable-output如果一切正常你会看到Output: Function: foo has 3 binary operators。到这里你的第一个 LLVM pass 就跑通了。从 pod 到能看到输出这个过程比我第一次弄的时候顺畅很多因为新 PM 的插件机制比老版-load日常好用太多。你可以在test.ll里加一个既不生也死掉的函数比如一个空函数看它会不会被打印出来就能更清楚遍历规则。3.4 参数选择与性能观察为什么现在就这么重要当你实际用opt跑多个 pass 时会很自然地想知道每个 pass 到底有没有用。这时就该上-time-passes和-stats这类参数了。例如opt -time-passes -passesloop-unswitch -disable-output test.ll会给出每个 pass 消耗的时间-stats会打印各种统计信息。以前我只盯着 pass 的语义看忽略了成本直到有一次在测试大项目时才发现某个自定义 pass 因为 O(n^2) 的遍历把整体优化时间拉高了三倍。所以从一开始就养成用-time-passes观察的习惯能帮你识别热点。还有个小坑如果 codegen 时-O0和-O3行为差异大到没法接受别急着怀疑 pass 本身。很多差异来自后端在不同优化级别下的调度策略而不是前端或中端。你可以在 IR 层面用clang -S -emit-llvm -O3先确认优化是否都在期许的方向上。4. 实战中的常见问题与排查技巧4.1 构建阶段最常见的三类报错第一类是CMake Error: Could not find CMAKE_ROOT或者找不到目录多半是因为你的 CMake 是系统自带的太老版本建议直接用 pip 或者官网装一份新 CMake同时确保 Ninja 在 PATH 里。第二类是编译器版本过旧导致的 C17 特性报错比如 GCC 5 不支持某些模板特性这就是为什么官方文档会要求较新的 GCC/Clang。第三类是磁盘空间不足LLVM 构建临时文件极多建议准备至少 20GB 的剩余空间。解决这类问题最有效的手段不是一条条搜而是把错误信息往上滚找到第一个红色报错。LLVM 的构建基本都是单依赖传递首个错误往往能定位到根因。另外我强烈建议构建时用 Ninja因为它支持并行度高而且错误输出比 make 收敛得多。4.2 Pass 不生效打印和 IR 比对是王道写 pass 最痛苦的体验是编译通过、运行不报错但输出毫无变化。这时候先别猜直接用opt -print-before-all -print-after-all把 pass 前后的 IR 全部打出来再和你预期的 transformation 做逐行对比。表面上很暴力但实际上是最高效的。还有一种很隐蔽的情况你的 pass 返回了错误的PreservedAnalyses。在 new PM 中如果你修改了 IR却返回PreservedAnalyses::all()LLVM 会认为什么都没改后续依赖旧分析的 pass 就会拿到过期数据甚至导致优化结果不可思议。所以只要你在 pass 里动了函数体稳妥的做法是返回PreservedAnalyses::none()或者精确标记哪些分析失效。很多新人掉进这个坑查了一晚上都不知道问题出在返回值上。另外当 pass 没被注册时你会看到类似Could not create pass my-pass的报错。这时检查registerPipelineParsingCallback里的名字是否和你命令行完全一致大小写和短横线都不能马虎我的经验是名字一律用短横线风格kebab-case跟 LLVM 官方 pass 选型保持一致。4.3 版本迁移API 变化带来的心电图LLVM 是个迭代极快的社区API 变化之频繁让人又爱又恨。可能你前年写的 pass今年编译直接报错。某次我升级主干后PassInfoMixin还是那个名字但run方法的签名参数类型从FunctionPassManager变成了FunctionAnalysisManager导致一整个 pass 文件全部要改。这种情况建议先看官方迁移指南或者直接搜PassInfoMixin的 release note。如果你要长期维护自己的 pass最好的策略是只依赖一个稳定的小版本比如用 git tag 固定一个 release 分支。不要在主干上持续追赶除非你每周都愿意花时间修复兼容问题。我本人的做法是把 LLVM 版本写进CMakeLists.txt来自动检测并在发现 API 变化时踩一条兼容分支既保留老代码也给新代码留空间。5. 实操心得把这些经验沉淀下来接触 llvm-project 这几年我最大的感受是它的学习曲线虽然陡但只要掌握了 IR、Pass、后端这条主线很多工具都能融会贯通。比如你写的静态分析工具本质上就是一个遍历 IR 的 pass你做代码插桩本质上就是修改 IR你想优化某个热点本质上是找到正确的 pass 组合。这个思维模型一旦建立LLVM 就不再是黑盒而是一堆可组合的乐高块。在实际动手过程中我建议你始终保有一个最小样例。别用巨型项目去测试 pass就用一个只有两三个函数的 C 文件配合opt和-print-after-all不断调试。同时养成看 IR 的习惯哪怕是已经优化好的 IR也要读一遍再上机器。很多性能问题和诡异错误其实在 IR 阶段就能看出来。最后分享一个小技巧如果你想更深入地理解 clang 的调试行为可以在clang后加-fverbose-asm它会在汇编里注释很多 IR 和源码对应关系。再配合-g和函数边界标记你能非常直观地看到自己的代码如何一路“降级”到目标机器指令。这种从高层抽象到底层实现之间的视觉穿透是理解 llvm-project 最迷人的地方之一。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询