深入理解LLVM项目:从IR设计到Pass开发实战
2026/9/18 11:04:33 网站建设 项目流程

在编译器圈子里摸爬滚打这些年,如果要我选一个“最值得花时间吃透的基础设施项目”,我会毫不犹豫地说:llvm-project。这个项目几乎成了现代编译器、静态分析工具、代码优化框架的事实标准,从苹果的 Clang 到 Rust 的官方后端,再到各种 DSP、GPU 的专用编译器,底层都在用 LLVM 那套东西。这篇文章不打算做那种“名词解释式”的科普,我想把 llvm-project 当成一个真实项目来拆,讲清楚它内部是怎么组织的、为什么这样设计、你拿到源码之后从哪里下手、以及我在实际使用中踩过哪些坑。

如果你正准备入门编译器开发、想在 LLVM 上做二次开发,或者只是好奇“一个能支撑几十种编程语言和上百种后端芯片的编译器项目到底长什么样”,那这篇文章应该能帮你省不少时间。

1. 项目整体设计与思路拆解

1.1 从“虚拟指令集”说起:LLVM 最核心的设计决策

llvm-project 之所以能成为今天的样子,根源在于它早期定下的一个设计原则:把编译器的前后端彻底拆开,中间用一套独立于具体 CPU 和具体语言的目标指令集(IR,Intermediate Representation)来衔接

举个直观的例子。你用 C++ 写了一段排序代码,用 Clang 编译成 x86 的可执行文件;同时你用 Rust 写了一个排序逻辑,用 rustc 编译成 ARM 平台的可执行文件。从源代码到最终机器码,这两条路径的前半段完全不同,但进入 LLVM 后端之后,它们面对的是同一种“中间语言”。也就是说,Clang 负责把 C++ 语法树翻译成 LLVM IR,Rust 前端也在做类似的事,之后所有优化、寄存器分配、指令选择、汇编生成,都由 LLVM 后端统一完成。

这种“前端和架构解耦”的思路,让 llvm-project 具备了两个其他编译器很难做到的能力:

  • 新增一门语言时:你只需要写一个新的前端,把语法翻译成 LLVM IR,就能立刻获得全套优化和后端支持。
  • 新增一种芯片架构时:你只需要实现一个后端,把 LLVM IR 翻译成目标指令集,就能立刻让所有接入 LLVM 的语言都支持这个新架构。

我在实际项目中见过一个团队只用了几个月就给一个自研的 RISC-V 扩展指令集加上了完整的 Clang 支持,这在没有 LLVM 的年代几乎是不可想象的。所以理解 llvm-project,第一个要建立的心智模型就是“IR 是枢纽”,前端和后端都围着它转。

1.2 monorepo 结构:为什么所有东西都在一个仓库里

很多人第一次接触 llvm-project 会被它的仓库体积吓到,码农圈子有个梗叫“clone 一下 LLVM,磁盘空间就没了三分之一”。这其实是 LLVM 团队有意为之的 monorepo(单仓库多项目)策略,仓库里不只包含 LLVM 核心库,还包含了 Clang、LLD、libc++、compiler-rt、polly、flang、mlir 等一大堆子项目。

用 monorepo 的好处很实在:

  • 版本天然同步。Clang 和 LLVM 核心库的 API 经常一起变动,如果分开仓库,每次都要处理“Clang r12345 必须搭配 LLVM r67890”这类依赖地狱,monorepo 直接从物理上消灭了这些问题。
  • 跨项目重构方便。比如你改动了 IR 里的一条指令表示,需要同步修改调试器、反汇编器、优化 pass,在一个仓库里可以一次提交全改完。
  • 统一构建系统。整个项目用一套 CMake 逻辑组织,你可以按需裁剪,只编你想要的那几个子项目。

我在给团队分享 LLVM 源码结构时喜欢打一个比方:llvm-project 就像一个大型百货商场,LLVM 核心库是水电燃气这类基础设施,Clang 是一楼生意最好的餐饮区,MLIR 是刚开业的新商场,而那些 target 后端则是各个出租的铺位。你想研究哪个区域,就直奔哪个楼层,不用从一楼开始把所有铺位逛完。

2. 核心组件解析与关键技术点

2.1 LLVM 核心库:pass 架构与优化管线

LLVM 核心库是整个项目的发动机,它提供的核心能力是对 LLVM IR 做分析和优化。这些优化动作被封装成一个个“pass”,每个 pass 只做一件很具体的事,比如:

  • 删除永远不会执行到的代码(Dead Code Elimination)
  • 把常量表达式在编译期就算出来(Constant Folding)
  • 把循环里不变的表达式移到循环外面(Loop Invariant Code Motion)
  • 根据目标机器的缓存特性重新排列指令(Instruction Scheduling)

编译时这些 pass 会按顺序组成一条管线,像流水线一样,IR 从一端进去,经过一道道工序,从另一端出来的时候已经变得更高效、更精简。LLVM 的优化管线分为几个层次,比如-O0几乎不跑优化,-O2跑常规优化,-O3会额外开启向量化和循环展开这类激进优化。

我第一次深入阅读 LLVM 的 pass 源码时最大的感受是:这里的代码质量极高,每个 pass 都带有详尽的注释,说明它在什么条件下生效、有什么副作用。如果你对“如何设计一套可插拔的优化框架”感兴趣,LLVM 的 pass 管理器是绝佳的教材。后来我们团队做自研静态分析工具,几乎就是照抄了 LLVM 的 pass 机制:定义分析任务、声明依赖、按拓扑序执行,这套模式成熟可靠,省了我们很多设计上的反复。

2.2 Clang:不只是“苹果的 C 语言编译器”

很多人把 Clang 理解成“又一个 C 编译器”,这种理解太浅了。Clang 在 llvm-project 里的定位是提供完整的 C/C++/Objective-C 前端,它做的事情包括词法分析、语法分析、语义分析、生成抽象语法树(AST),最后把 AST 翻译成 LLVM IR。

Clang 最让我喜欢的一点是它的库化设计。传统编译器通常是一个庞大的可执行文件,你没法轻易地把“语法分析”这个功能单独拿出来用。Clang 不同,它把词法分析器、解析器、AST 构建器都做成了库,外部代码可以#include "clang/Parse/..."直接调用。这带来了一个巨大的生态:基于 Clang 做的代码补全工具、自动重构工具、代码风格检查工具,一抓一大把。

一个日常开发中很常见的例子:你想统计一个大型 C++ 项目里所有函数的定义位置,不用去解析文本(正则表达式处理 C++ 的模板和宏会让人崩溃),直接写一个 Clang 的 AST 遍历工具,几十行代码搞定。这种体验让我个人对 Clang 的定位是“编译器加 IDE 基础设施”,远远超出了传统编译器的范畴。

另外,Clang 的诊断信息(diagnostics)质量也是它比 GCC 更受欢迎的原因之一。它会给出带颜色高亮的错误定位,还会提示 “did you mean ...”。LLVM 项目里甚至有专门的代码负责把错误信息里的关键词做模糊匹配,用来推荐最可能的修正方案。这个思路后来被大量现代编译器借鉴。

2.3 LLVM IR 详解:SSA 形式与指令表示

既然 IR 是核心枢纽,就值得花点篇幅讲清楚它到底长什么样。LLVM IR 是一种基于静态单赋值(SSA,Static Single Assignment)形式的指令集,所谓 SSA,简单说就是每个变量只被赋值一次。比如:

define i32 @add(i32 %a, i32 %b) { %sum = add i32 %a, %b ret i32 %sum }

这段 IR 定义了一个add函数,接收两个i32类型参数,返回它们的和。%sum这个名字在整个函数里只会被赋值一次,这就是 SSA 的含义。SSA 形式给优化带来了巨大的方便,因为数据的依赖关系可以直接从名字上读出来,不需要去做复杂的“定义-使用链”分析。

LLVM IR 还有三种表示形态:

  • 内存中的表示:编译过程中 pass 操作的就是这种形态,以 C++ 对象的方式存在。
  • 文本表示(.ll 文件):人可以阅读和修改的文本格式,用来调试非常方便。
  • 二进制位码表示(.bc 文件):紧凑、加载快,适合存储在磁盘上。

我在实际调试时最常用的是文本表示,编译时加一个-S参数就能把 IR dump 出来。

很多初学者会被 IR 里繁多的指令类型吓到,其实 LLVM 的指令集被刻意设计得比较精简。核心就几大类:算术运算、内存访问(load/store)、控制流(br/switch/ret)、函数调用、比较与转换。真正复杂的是类型系统,比如i32float、指针、数组、结构体,还有带地址空间的指针。理解了类型再看 IR,思路就会清晰很多。

2.4 后端体系:TableGen、指令选择与寄存器分配

LLVM 能支撑几十种硬件架构,靠的是一套高度工程化的后端实现方式。后端开发里有三个关键词,我建议想深入的人记住:

  • TableGen:这不是一个库,而是一种领域特定语言,用来描述目标机器的指令集、寄存器、调用约定等信息。它有点像一个“超级宏”,你用声明式的语法写目标描述文件(.td),TableGen 工具会生成大量的 C++ 代码。一开始很多人不理解为什么非要多一层抽象,等你真的处理过上千条指令的架构(比如 x86)就会发现,手写这些代码会要命,TableGen 生成的代码虽然多,但一致性和准确性远超人肉拷贝。
  • 指令选择(Instruction Selection):把 LLVM IR 里的操作映射到目标机器的具体指令。LLVM 默认使用 SelectionDAG 框架,更现代的选择还有 GlobalISel。这个过程要处理指令模式的匹配、合法化(把目标不支持的指令拆成多条简单指令)等复杂问题。
  • 寄存器分配(Register Allocation):把无限多的虚拟寄存器映射到有限的物理寄存器上。寄存器不够的时候就要“溢出”到内存,这个决策的好坏直接决定了生成代码的质量。LLVM 默认的寄存器分配器是 Greedy 算法,它的实现思路很值得学习:先处理冲突图,再按权重贪心分配,实在不行就 spill。

如果你想快速理解 LLVM 后端的工作流程,我建议选一个简单的架构(比如 RISC-V 或 AArch64)去读它的指令选择代码,不要一上来就啃 x86,x86 的历史包袱太多。

3. 实操过程:从源码构建 LLVM 到跑通第一个 pass

3.1 环境准备与源码获取

llvm-project 对操作系统没有太多挑剔,Linux、macOS、Windows 都能构建,但如果你是第一次尝试,我强烈建议在 Linux 上做,依赖问题最少。

获取源码的第一步是 clone,我这里加一个提醒:llvm-project 仓库体积很大,而且历史提交极多,做一个浅克隆往往更明智:

git clone --depth=1 --branch llvmorg-18.1.8 https://github.com/llvm/llvm-project.git

这里选了 18.1.8 这个 release tag,原因是发行版相对稳定,API 已经冻结。如果你 clone 最新的 main 分支,可能在构建时碰到编译器版本不兼容或者 API 刚变动的幺蛾子。做技术研究,建议始终从一个 release 版本开始,之后想体验新特性再切 main。

依赖方面,最核心的就是 CMake 和一套可用的 C++ 编译器。注意,LLVM 的构建是一个“自举”过程,你的系统编译器会把 LLVM 编译出来,然后这个新编出来的 Clang 又可以用来编译其他程序。建议事先准备 Ninja 作为构建工具,它的并行加速效果比 make 好很多:

sudo apt-get update sudo apt-get install -y cmake ninja-build gcc g++ python3

3.2 构建配置与参数选择

LLVM 的 CMake 配置项非常多,大约一两百个,但真正核心的没几个。一个比较稳的构建配置如下:

cmake -G Ninja \ -B build \ -S llvm-project/llvm \ -DCMAKE_BUILD_TYPE=Release \ -DLLVM_ENABLE_PROJECTS="clang" \ -DLLVM_TARGETS_TO_BUILD="X86" \ -DLLVM_ENABLE_ASSERTIONS=ON

逐项解释一下:

  • CMAKE_BUILD_TYPE=Release:编译优化后的发布版本。有些人图省事直接用默认的 Debug,结果构建时间翻一倍,运行时还慢得离谱。除非你要调试 LLVM 自身,否则用 Release。
  • LLVM_ENABLE_PROJECTS="clang":指定除了 LLVM 核心库之外还要构建哪些子项目。如果只需要跑 IR 优化,只写clang就够了。如果还要链接器,可以加上lld,用分号分隔。
  • LLVM_TARGETS_TO_BUILD="X86":限制后端的 target 数量。全部 target 都编的话,构建时间会多出好几倍,但你平时根本用不到 PowerPC、SystemZ 这些后端。按需裁剪,是 LLVM 构建里最见效的一招。
  • LLVM_ENABLE_ASSERTIONS=ON:开启断言。发布版本可以关掉它,但开发阶段强烈建议打开,很多潜在内存问题都是靠断言在早期暴露的。

配置文件完成后,执行:

ninja -C build

以 18.1.8 为例,在主流 8 核 16G 内存的机器上,全量构建大概需要 15 到 30 分钟。如果你只编clang这一个 target,会快不少:

ninja -C build clang

构建完成后,可执行文件在build/bin/目录下,clangllvm-asoptllc这些工具都在里面。

3.3 写出并运行自己的第一个 LLVM pass

工具链跑起来之后,我们就可以做一件很有仪式感的事:写一个自定义的优化 pass。我们先从一个最基础的“函数级打印 pass”开始,它的作用是遍历模块里的每个函数,打印出函数名和基本块数量。这个 pass 不改变 IR,只是观察,但能帮你走通整个开发流程。

先在llvm/lib/Transforms/下建一个目录,例如HelloPass

#include "llvm/IR/Function.h" #include "llvm/IR/Module.h" #include "llvm/Pass.h" #include "llvm/Passes/PassBuilder.h" #include "llvm/Passes/PassPlugin.h" #include "llvm/Support/raw_ostream.h" using namespace llvm; namespace { struct HelloPass : public PassInfoMixin<HelloPass> { PreservedAnalyses run(Module &M, ModuleAnalysisManager &AM) { for (auto &F : M) { errs() << "Function: " << F.getName() << ", blocks: " << F.size() << "\n"; } return PreservedAnalyses::all(); } }; } // namespace llvm::PassPluginLibraryInfo getHelloPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, "HelloPass", LLVM_VERSION_STRING, [](PassBuilder &PB) { PB.registerPipelineParsingCallback( [](StringRef Name, ModulePassManager &MPM, ArrayRef<PassBuilder::PipelineElement>) { if (Name == "hello-pass") { MPM.addPass(HelloPass()); return true; } return false; }); }}; } extern "C" LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return getHelloPassPluginInfo(); }

这段代码用到了新的new pass manager接口,这是 LLVM 目前主推的框架。PassInfoMixin是 pass 的基类,run方法里拿到整个模块的引用,遍历每个函数并打印信息。

编译这个插件的 CMake 配置如下:

add_library(HelloPass MODULE HelloPass.cpp ) target_link_libraries(HelloPass PRIVATE LLVMCore LLVMPasses LLVMSupport )

然后在 llvm-project 的源码根目录里重新跑一次 CMake,把包含这个子目录的路径加到LLVM_OPTIONAL_SOURCES或直接作为add_subdirectory引入,再ninja HelloPass,就会生成一个.so插件文件。

测试一下这个 pass,先写一段测试代码:

// test.c int add(int a, int b) { return a + b; } int main() { return add(2, 3); }

用刚构建的 clang 编译成 IR,然后用opt加载插件:

build/bin/clang -S -emit-llvm test.c -o test.ll build/bin/opt -load-pass-plugin=build/lib/HelloPass.so -passes=hello-pass test.ll -o /dev/null

如果一切正常,你会看到终端打印出两个函数名,addmain,以及各自的基本块数量。恭喜,到这里你已经可以算半个 LLVM 开发者了,因为插件的加载机制、pass 的注册方式、工具链的使用方法,这些核心套路你都走通了。

3.4 实操中的构建细节与参数计算

很多人第一次构建 LLVM 会遇到内存不足或者磁盘不够的问题。这里有两个硬指标可以预先估算:

  • 磁盘空间:一个包含 Clang 的 Release 构建,大概需要 30GB 到 50GB 的磁盘空间。其中源码占一半,构建产物占一半。如果你想全部 target 都编,可能奔着 100GB 去。
  • 内存:编译过程中最耗内存的是链接步骤,尤其是链接clang这个巨大的可执行文件,多个并行链接任务同时跑的时候,内存很容易吃满。我自己的经验是 8GB 内存的机器,建议把ninja -j2降到 2 个并行任务;16GB 内存可以放心-j8;32GB 以上基本随便跑。

如果你机器配置一般,我建议在 cmake 时设置一个更小的并行度:

ninja -C build -j 4 clang

有时候源码构建会因为 GCC 版本太老而失败,报找不到<span>头文件或者类似 C++17 特性的错误。LLVM 18 要求编译器支持 C++17,gcc 版本至少要 7.1 以上。遇到这类问题,优先考虑升级编译器或者用 Clang 来编译 LLVM。

4. 常见问题与排查技巧实录

4.1 “undefined symbol: llvmGetPassPluginInfo” 错误

这个错误几乎是所有 LLVM 插件开发者的第一道坎。出现这个错误的时候,opt会报无法加载插件。原因通常是插件符号导出问题。

LLVM 的插件机制要求插件必须导出一个名为llvmGetPassPluginInfo的 C 接口符号,而且这个符号的可见性必须是默认的。我在代码里写了LLVM_ATTRIBUTE_WEAK,这个宏会设置合适的可见性和弱符号属性。

如果你自己手写导出符号,可以这样手动声明:

extern "C" __attribute__((visibility("default"))) ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { ... }

另外一个坑是链接顺序。某些版本的 LLVM 要求你的插件源码里引用的 LLVM 库在链接时出现在正确位置,否则会出现undefined reference。最简单的方案是直接用我们前面写的 CMake 方式,让它继承 LLVM 的链接配置,不要自己手动写g++ -shared命令。

4.2 link 阶段 OOM(内存耗尽)

链接clang这个可执行文件时,内存消耗特别凶。一个常见的缓解手段是使用lld作为链接器代替系统默认的ld

cmake -G Ninja \ ... -DLLVM_USE_LINKER=lld

lld 的内存占用和链接速度都明显优于 GNU ld,尤其是大型可执行文件的链接,体验差距非常明显。如果你在构建 LLVM 时还没装 lld,可以先用系统包管理器装好,然后重新跑 cmake。

另一个经验是控制链接并行度:

ninja -C build -j 2

构建慢一点没关系,至少不会让机器卡死。

4.3 IR 调试时不知道该看什么

你写了一个 pass,跑起来了,但优化效果不符合预期。这种时候不要瞎猜,建议按以下顺序排查:

  • 先用opt -print-after-all把所有 pass 处理后的 IR 打印出来,看你的 pass 是否按预期修改了 IR。
  • 如果你做的是分析类 pass,检查输入 IR 是否真的包含你想处理的 pattern。比如你想匹配循环结构,但测试代码在-O2下已经被完全展开或者矢量化了,那你看到的内容可能和预期相差很远。
  • 使用小规模测试用例来复现问题,避免在大型项目上盲目输出海量 IR 日志。

4.4 常见问题速查表

现象可能原因解决方案
编译报错undefined reference to llvm::...CMake 链接库配置缺失target_link_libraries里补上对应的 LLVM 组件,如LLVMCoreLLVMSupport
点击opt -passes=my-pass没有输出pass 注册名写错或没被加载成功检查插件加载日志,用opt -load-pass-plugin=... -passes=my-pass手动确认加载
构建时磁盘爆满Release 构建产物体积大使用LLVM_TARGETS_TO_BUILD裁剪后端,清理无用 build 目录
系统编译器太老LLVM 18 需要 C++17升级 GCC/Clang,或切换到较旧的 LLVM 版本
无法运行clang可执行文件动态库路径不对设置LD_LIBRARY_PATH指向build/lib,或者把build/bin加到PATH

5. 周边生态与进阶学习路径

5.1 MLIR:多级 IR 框架

llvm-project 里还有一颗冉冉升起的新星,MLIR(Multi-Level Intermediate Representation)。如果说 LLVM IR 是“一种中间表示”,MLIR 就是“中间表示的框架”,它允许你在不同抽象层级定义自己的 IR,然后逐级lower到LLVM IR。

我第一次接触 MLIR 是在做 AI 编译器项目的调研阶段,当时需要把深度学习模型的计算图转换成能在 GPU 上高效执行的代码。传统方式要么用 TVM 这种重框架,要么直接生成 CUDA 代码,灵活性和可维护性都不够好。MLIR 的方案完全不同:你可以为模型定义高层 IR(比如表示矩阵乘、卷积),再通过 pass 逐级下降到 LLVM 能理解的程度。这种“不是把所有问题都放到同一个层级解决”的思路非常优雅。

如果你已经熟悉 LLVM 的 pass 机制,MLIR 的学习成本会低很多,它们的设计哲学一脉相承。不过,MLIR 还处于快速演进期,接口变化频繁,学习和使用时尽量跟着官方 tutorial 走,不要依赖网上过时的博客。

5.2 学习路线建议

给不同目标的人几条可执行的学习路线:

  • 目标是日常开发,想理解编译原理:只研究 LLVM IR 和 opt,不必深入后端。学会看clang -S -emit-llvm的输出,用opt跑几个标准优化 pass,理解 SSA 和基本块就够了。
  • 目标是做编译器前端或语言实现:重点研究 Clang 的 AST 和 API,写一些小工具遍历和分析 C/C++ 代码。LLVM 官方有 Clang Tutorial 和 Clang AST 的示例代码。
  • 目标是做后端或芯片支持:从 TableGen 和 SelectionDAG 开始,选一个简单 target 深入代码阅读。建议先读 RISC-V 后端的.td文件,理解指令描述的基本语法。

还有一个我特别推荐的学习方法是“读 IR,不读源码”。先把大量 C/C++ 代码用 clang 转成 LLVM IR,体会哪些代码写法会产生什么样的 IR 模式。这个习惯会极大加速你对编译器优化的直觉判断。比如,一个for循环在-O2下会变成一堆复杂的控制流,你多读几次 IR,以后写代码的时候就会有意无意地写出更易被优化的代码。

5.3 从 llvm-project 中能学到什么

抛开工具本身,llvm-project 的源码是一座难得的学习矿藏。它里面至少有这几类值得精读的内容:

  • 大型 C++ 项目的工程组织:超过百万行代码的规模,它如何管理头文件、命名空间、目录结构、cmake 依赖,这套实践对任何一个大型 C++ 项目都有参考意义。
  • 算法与数据结构在真实世界中的应用:IR 的支配树分析、循环检测、图着色寄存器分配、数据流分析,教科书上的算法在这里都有工业级实现。
  • API 设计的边界思考:LLVM 是一个被几千个项目依赖的库,它如何在性能和易用性之间权衡,如何保证 API 演进不破坏既有用户,这些经验远远超出编译器领域。

每次深入 llvm-project 的某个模块,我都觉得不只是学会了一个工具,更像在跟一群世界级的工程师做一次无声的交流。

6. 写在最后的实操体会

最后分享一点我自己的心得体会。llvm-project 的入门曲线确实陡峭,最开始 clone 仓库、配置 CMake、看那些密密麻麻的.td文件时,很容易产生“这玩意儿太复杂了,我可能学不会”的挫败感。但一旦你跑通了第一个 pass、第一次成功修改了 IR、第一次看到自己的优化让生成的汇编变短了,那种正反馈会非常强烈。

我个人的建议是:不要一开始就想着读懂所有代码,挑一条最窄的路径走通,比如“源码构建 Clang -> 写插件 pass -> 修改 IR”,走通之后再往两侧扩展。整个过程不是线性阅读,而是像一个搜索算法,先找到一个可行的路径,再逐步扩大搜索范围。如果你在某个环节卡住了,优先怀疑构建配置,其次是 API 版本,最后才是你的算法或逻辑。

llvm-project 还在持续演进,MLIR 生态、Orc JIT、Sanitizer 工具链都在快速迭代,今天学到的东西明天可能就变了,但那一套“前端解耦、IR 中枢、pass 管线、后端可插拔”的核心思想,会在整个职业生涯里持续发挥作用。希望这篇文章能让你少走一些我当年走过的弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询