前段时间有个朋友跑过来问我,说自己在看某个渲染相关的开源项目时,经常看到llvmpipe这个名词,还看到类似“llvm 15.0.7, 256 bits”这样的日志,一头雾水。这其实是很典型的现象:很多人知道 LLVM 是编译器,但不知道它到底是怎么构成的,更不明白为什么一个图形渲染项目会跟编译器扯上关系。
llvm-project是一个庞大的仓库,它不只是你装 Clang 时看到的那个编译器。它是一整套编译基础设施,涵盖了从前端语言解析、中间表示优化、后端代码生成,到汇编器、链接器、运行时库、测试框架的完整链路。理解它的结构,比掌握某一条具体命令要重要得多,因为几乎所有现代编程语言工具链和 GPU 软件栈都在这个框架之上运作。
这篇文章我不想讲那种“什么是 LLVM”的科普,而是从实际使用者的角度,把llvm-project拆开来看:它的核心 IR 如何处理表达、优化管线如何工作、后端如何把 IR 变成机器码,以及llvmpipe这种软件渲染器如何借助 LLVM 的 JIT 能力跑出接近硬件的性能。最后我会讲一讲自己搭建 LLVM 环境时踩过的那些坑,希望能给你省点时间。
1. LLVM全景:它究竟解决了什么问题
1.1 传统编译器架构的困境
在 LLVM 出现之前,绝大多数编译器都是“铁板一块”的。比如 GCC,前端和后端耦合得非常紧密:你想支持一种新语言,就要在 GCC 的后端框架里做大量适配;你想支持一种新 CPU 架构,必须理解特定语言前端的语义规则。这种设计导致编译器变成了一座迷宫,修改一处代码往往牵一发动全身。
我最早接触编译器时,面对 GCC 的源码浩如烟海,几乎找不到一个清晰的切入点。你只是想给某种教学语言加个后端,但要理清整个 AST 到 RTL 的转换过程,学习曲线极其陡峭。这就是传统编译器最核心的痛点:可扩展性太差。
1.2 LLVM的“编译器积木”哲学
LLVM 的破局思路非常直接:把编译过程拆成三个独立的阶段——前端、优化器、后端,并且用一套统一且稳定的中间表示(IR)把它们连接起来。你可以把 IR 理解成编译器内部的“通用语言”:前端负责把各种高级语言翻译成 IR,后端负责把 IR 翻译成各种机器码,而优化器在 IR 层面做各种变换。
这样的好处是,不是每做一款新语言编译器都要从零开始。你只需要写一个新的前端,把语言翻译成 LLVM IR,优化和后端这部分完全可以直接复用。今天市面上的 Rust、Swift、Julia,以及各种 DSL 编译器,绝大多数都构建在 LLVM 之上。
llvm-project这个仓库实际包含的东西比我最初想象得多。核心的llvm/目录是编译器基础设施本体;clang/是 C/C++/Objective-C 的前端;lld/是链接器;libc++和libc++abi是 C++ 标准库的实现;compiler-rt提供各种运行时支持;polly做循环和多面体优化;mlir是面向机器学习等领域的多层 IR 框架。
启动llvm-project时,它的模块化设计给我最深的体会是:你可以像搭积木一样选择自己需要的组件,甚至可以把 OptimizationRemark、Sanitizer、DebugInfo 这些功能单独抽出来集成到自己的工具链里。这种设计思路就是 LLVM 能够成为整个软件生态基础设施的根本原因。
1.3 LLVM能做什么
一句话概括,LLVM 能让一个软件开发者用“和写解释器差不多”的成本,做出一款能编译成多个平台原生代码的编译器。更实际的应用场景包括:
- 给现有语言做静态分析工具,比如把代码转成 IR 后检查越界访问、未初始化变量、内存泄漏。
- 做代码格式化、重命名、重构工具,因为 IR 里保留了完整的类型和调用关系,分析起来比直接用文本处理可靠得多。
- 在 GPU 栈里做着色器编译,比如 Mesa3D 中的
llvmpipe就用 LLVM 把着色器编译成可执行的机器码,在 CPU 上模拟 GPU 的渲染管线。 - 做那些非常消耗算力的“自动调优”,比如用 LLVM 的 Pass 管线尝试不同的优化组合,找出最合适的一组。
这里面每一个方向单独拿出来都够写一本书。本文接下来会重点展开 IR 和优化管线这两个核心主题,因为它们是理解 LLVM 的钥匙,也是你将来写任何 LLVM 相关工具时绕不开的部分。
2. LLVM IR:为什么它是这个项目的立身之本
2.1 IR的设计美学:低层次、强类型、显式控制流
LLVM IR 通常以三种形式存在:内存表示(llvm::Value等 C++ 类)、字节码表示(.bc文件)、文本表示(.ll文件)。对初学者来说,文本表示最容易上手。
一段典型的 LLVM IR 长这样:
define i32 @add(i32 %a, i32 %b) { %res = add nsw i32 %a, %b ret i32 %res }这看起来跟汇编很像,但它的核心是**静态单赋值(SSA)**形式,也就是每个变量只被赋值一次。%res这个变量从出生到死亡只有一个定义点,这让数据流分析变得异常轻松。编译器可以很快地找出变量之间的关系,然后在上面做常数传播、死代码消除这些优化。
IR 是强类型的,i32表示 32 位整数,float表示 32 位浮点数,ptr表示指针。有了类型之后,编译器的很多优化就能做得很激进,因为它能准确知道一个内存位置的大小和类型,不需要像汇编那样靠猜测。
控制流在 IR 里也非常显式,用的是基本块(Basic Block)加跳转。你找不到while或for,看到的都是br指令加上各种条件,整个控制流图(CFG)就清清楚楚地摆在眼前。优化器在 IR 上做分析的时候,不需要回溯语言层面的语法结构,只需要看 CFG 就够了。
2.2 用一个小函数看懂IR生成过程
为了更直观地理解 IR 长什么样,我们先写一个简单到不能再简单的 C 函数:
int add(int a, int b) { return a + b; }用 Clang 编译并输出 IR:
clang -S -emit-llvm add.c -o add.ll生成的 IR 可能比想象中复杂,因为 Clang 默认会带上一堆 DWARF 调试信息、属性、模块标志这些东西。如果你只想看纯净的 IR,可以用:
clang -S -emit-llvm -O2 add.c -o add.ll-O2之后优化器会做一些常量传播、指令合并之类的操作。由于函数太简单,结果可能就只剩下几行指令,甚至直接被内联到调用点。这让我意识到一件事:去理解 LLVM 的时候,不要被那些花里胡哨的属性弄晕,核心就是“定义函数——分配虚拟寄存器——操作数之间运算——返回结果”这种线性思维。
2.3 内存操作和SSA的摩擦
SSA 的优点很突出,但跟内存读写配合时有一个天然的冲突:如果一个变量在循环中被反复修改,SSA 要求它只能被赋值一次,那循环迭代之间怎么传递新值?LLVM 的解决方案是插入phi指令。
比如这段 C 代码:
int sum = 0; for (int i = 0; i < 10; i++) { sum += i; }在 IR 层面,sum会变成一个phi节点,根据是从循环入口进入还是从循环内部跳转回来,选择不同的前驱值。看着费解,但正是这个设计,让优化器能轻松追踪值的来源,从而做各种变换。
不过实际开发中,Clang 默认不会把所有变量都放进 SSA 形式。它会把局部变量存到内存栈上,然后用load和store指令来读写,只有在mem2reg这个 Pass 运行之后,才会把简单的栈变量提升为 SSA 虚拟寄存器。这也是初学者看-O0和-O2生成的 IR 差别很大的原因:-O0下到处都是栈操作,-O2下栈操作被大幅消除,只留下清晰的寄存器逻辑。
2.4 编写和调试IR的实用技巧
千万不要手工维护大段的 IR 文本,效率太低。我的建议是:
- 想快速验证一个 IR 片段的语义,用
lli(LLVM 解释器)直接执行,它会读取字节码或文本 IR,在本地 JIT 或解释执行,非常适合做小实验。 - 调试优化问题,用
opt -passes='...' -S input.ll -o output.ll来单步跑某个 Pass,输出文本 IR 对比优化前后的差异。 - 熟练之后可以直接写 C++ 来生成 IR,用
IRBuilder这个辅助类,它能把那些指令构造的细节处理好。
从 14 版本开始,LLVM 全面强化了opt的新 Pass 管理器,原来的-pass-name风格逐渐被-passes='pass-name'取代。如果你在网络上看到旧版命令报错,可以优先排查版本差异。
3. Pass优化管线:如何在IR上做出高效的机器码
3.1 从源语言到汇编,经历了什么
大概官方的说法是,LLVM 优化管线由大量 Pass 组成。每个 Pass 就是一个独立的 IR 变换或分析,它们按顺序执行,前一个 Pass 的输出作为后一个 Pass 的输入。这种流水线式的架构优势是:你可以为调试方便跳过某个 Pass,也可以为性能测试调整顺序。
从用户角度看,整个编译流一般是:
- 前端(Clang)生成初始 IR。
- 一系列“规范化 Pass”把 IR 整理成较为统一的形式,比如消除冗余的
load/store、简化控制流。 - “目标无关优化 Pass”做常量和代数优化、向量化、内联、循环变换等。
- “目标相关 Pass”根据目标 CPU 的特性(比如是否支持 AVX-256、是否有 FMA 指令)做指令选择、寄存器分配、指令调度。
- 最后生成汇编或目标文件。
这个过程中最有意思的是,现代 CPU 特性复杂,优化器需要根据-march、-mtune等参数动态调整策略。比如llvmpipe在运行时检测到 CPU 支持 256 位 SIMD,就会让 LLVM 编译着色器时采用 256 位的向量操作;如果只支持 128 位,就退回到 SSE 级别。
3.2 新旧Pass管理器的差异
LLVM 历史上有过两套 Pass 管理器:旧的legacy和新的new PM。老的 Pass 管理器用下来的体验是:
- Pass 之间维护状态比较随意,多线程的时候容易出问题。
- 很难精确表达一个 Pass 依赖另一个 Pass 分析结果的时机。
- 在 JIT 场景下,频繁注册和运行 Pass 的开销很重。
新 Pass 管理器用AnalysisManager来解决这些依赖问题,Pass 之间的分析结果可以被缓存,并且显式声明依赖关系。现在opt默认用的就是新 Pass 管理器。
写自定义 Pass 的时候,强烈建议直接用新 Pass 管理器的接口。虽然它要求你包装成llvm::PassInfoMixin这种风格,刚开始有点绕,但写熟悉之后,你会发现它比旧的继承方式清晰太多。
3.3 自定义Pass的编写套路
一个最简的函数级 Pass 长这样:
#include "llvm/IR/Function.h" #include "llvm/IR/InstrTypes.h" #include "llvm/Pass.h" #include "llvm/IR/LegacyPassManager.h" #include "llvm/Transforms/IPO/PassManagerBuilder.h" using namespace llvm; namespace { struct MyPass : public FunctionPass { static char ID; MyPass() : FunctionPass(ID) {} bool runOnFunction(Function &F) override { // 在这里遍历基本块和指令 return false; // 返回是否修改了 IR } }; } // namespace char MyPass::ID = 0; static RegisterPass<MyPass> X("my-pass", "My Pass Description");这是传统写法。如果使用新 Pass 管理器,要写成:
#include "llvm/IR/PassManager.h" #include "llvm/Passes/PassBuilder.h" #include "llvm/Passes/PassPlugin.h" using namespace llvm; namespace { struct MyNewPass : public PassInfoMixin<MyNewPass> { PreservedAnalyses run(Function &F, FunctionAnalysisManager &AM) { // 做变换 return PreservedAnalyses::all(); } }; } // namespace新 Pass 管理器的run方法返回PreservedAnalyses,告诉调度器哪些分析结果仍然有效,哪些需要被重新计算。如果你的 Pass 修改了控制流图,就需要markPreserved时谨慎一点,否则后续 Pass 可能用了过期的分析结果。
3.4 优化日志的利用
调试优化器时,最烦的是“优化得太抽象,不知道它动了哪些指令”。LLVM 提供了-Rpass、-Rpass-missed、-Rpass-analysis三组选项,可以把 Pass 触发的优化、未命中的优化、以及分析结果以类似编译器警告的形式输出。
比如想看循环展开的情况:
clang -O2 -Rpass=loop-unroll -Rpass-missed=loop-unroll test.c输出里会标明文件名、行号、循环位置,以及为什么没能展开。这些信息在分析性能瓶颈时至关重要。很多时候你以为编译器帮你做了某个优化,实际上做不了,原因就藏在-Rpass-missed里。
4. 从IR到机器码:后端机制与llvmpipe的实战结合
4.1 指令选择的本质
拿到优化好的 IR 之后,后端要把 IR 翻译成目标 CPU 的机器指令。这个过程的第一步是指令选择,它要把 IR 指令映射成目标机器的指令,比如 IR 里的加法、比较、跳转,在 x86 上都有对应的具体指令。
指令选择绝非简单的 1 对 1 映射,因为目标机器指令往往带副作用、带隐式操作数,而且一条指令可能同时完成好几件 IR 指令的事。比如 x86 的add指令可以直接操作内存操作数,IR 里可能需要load一个值、add再store回去,但指令选择时最好合并成一条add mem, reg。
LLVM 的做法是使用 SelectionDAG,它会生成一个与目标无关的 DAG(有向无环图),然后再逐步 legalize 成目标合法的指令。这个过程非常复杂,llc -view-dag-combine1-dags之类的工具还能把 DAG 可视化成图形,调试起来直观很多。
4.2 寄存器分配和指令调度
指令选择完之后,IR 里的虚拟寄存器还是无限的,但真实 CPU 寄存器数量有限。寄存器分配就负责把这些虚拟寄存器映射到物理寄存器上,装不下的就溢出到内存栈上。这是编译器性能的关键,因为一次栈访问比寄存器访问慢一两个数量级。
LLVM 的寄存器分配器提供了多种算法,常用的有greedy、basic、fast等。通常优化级别高时用 greedy,它能更好地处理长生命周期的变量。
指令调度则是在不改变程序语义的前提下,重新排列指令的顺序,让 CPU 流水线更顺畅。现代 x86 CPU 能乱序执行,所以调度的收益有时候不那么明显,但在 SIMD、ARM、GPU 这类体系结构上,指令顺序对性能影响非常大。
4.3 llvmpipe如何利用LLVM做软件渲染
回到开头提到的llvmpipe,它是 Mesa3D 图形库里的一个软件渲染器。当你的系统没有可用的 GPU 驱动,或者你在虚拟机里、CI 环境里需要离屏渲染时,llvmpipe会在 CPU 上模拟整个 GPU 渲染管线。它之所以能做到相对高的性能,靠的就是 LLVM 的 JIT 能力。
渲染过程中,llvmpipe拿到一个着色器(比如 GLSL 编译出来的中间表示),会把着色器转换成 LLVM IR,然后调用 LLVM 的 JIT 编译成当前 CPU 的机器码。因为 CPU 支持 256 位 SIMD(AVX2),它就能用这些向量指令一次处理多个像素/顶点,大幅提升吞吐量。你看到的“llvmpipe (llvm 15.0.7, 256 bits)”日志,其实就是在告诉你三件事:当前软件渲染器、用的是 LLVM 15.0.7 的 JIT 引擎、SIMD 宽度是 256 位。
这种设计和传统解释执行图形指令的方式相比,最大的优势在于:LLVM 的优化管线会自动把渲染循环里的重复计算优化掉,把向量化做好。我测试过一个专门针对着色器的计算场景,在某些时候 llvmpipe 的性能比纯 C++ 手写的软件渲染要快好几倍,就是因为 LLVM 的自动向量化和调度确实抓住了现代 CPU 的特性。
4.4 面向CPU特性的编译策略
在 LLVM 里,TargetMachine负责把 IR 接回到具体目标 CPU。你可以在创建TargetMachine时指定 CPU 类型和特性,例如:
std::string Err; auto TM = std::unique_ptr<TargetMachine>( TargetRegistry::lookupTarget("x86-64", TheTriple, Err)); TM->setTargetFeatureString("+avx2,+fma");如果你在写 JIT 或自定义编译器,最好在程序启动时通过sys::getHostCPUName()拿当前 CPU 的名字,再把它的特性(如是否支持 AVX、AVX2、FMA)传给 TargetMachine。这样你产出的代码就能充分利用宿主机的 SIMD 能力。否则默认配置可能只生成比较保守的指令集,性能会差一大截。
5. 自己动手:搭建LLVM环境与Mini JIT
5.1 构建llvm-project的注意事项
不建议从源码全量编译整个llvm-project。如果你只想要 Clang 和 LLVM 的核心工具,直接装发行版软件包或下载预编译二进制更省时间。但如果要改 LLVM 源码、调试 Pass、或者交叉编译到非主流平台,就得手动构建。
我踩过的坑主要是这三处:
- 磁盘空间。一个带调试信息的 LLVM 构建,占用轻松超过 30GB。建议在 CMake 里开启
LLVM_ENABLE_ASSERTIONS=ON帮助调试,但别开启LLVM_ENABLE_PROJECTS里的所有项目,用哪个装哪个。 - 内存不足。链接 Clang 和 LLVM 的时候,单靠
-j4也可能吃掉 16GB 内存。最好给链接器加-fuse-ld=lld,或者降低并行度,不然大概率会 OOM。 - 版本匹配。如果你在项目中用了
find_package(LLVM),务必保证 CMake 能找到一个 LLVM 版本与编译参数匹配的安装位置。不同版本之间的 API 变动很大,最常见的报错就是某个头文件找不到,或者某个函数签名对不上。
推荐的 CMake 配置长这样:
cmake -G Ninja \ -DCMAKE_BUILD_TYPE=RelWithDebInfo \ -DLLVM_ENABLE_PROJECTS="clang;lld" \ -DLLVM_TARGETS_TO_BUILD="X86" \ -DBUILD_SHARED_LIBS=ON \ ../llvm只构建 X86 后端能显著减少编译时间。如果想支持 ARM 或其它架构,再把对应的 target 加进去。
5.2 用LLVM C++ API写一个Mini JIT
简单看一下如何用 LLVM 的 ORC JIT 引擎执行一段自己生成的 IR。这里以 LLVM 15 左右的 API 为例:
#include "llvm/ExecutionEngine/Orc/LLJIT.h" #include "llvm/IR/IRBuilder.h" #include "llvm/IR/Module.h" #include "llvm/IR/LLVMContext.h" #include "llvm/Support/InitLLVM.h" using namespace llvm; using namespace llvm::orc; int main(int argc, char **argv) { InitLLVM X(argc, argv); // 创建一个模块,里面放一个返回 42 的函数 auto Ctx = std::make_unique<LLVMContext>(); auto M = std::make_unique<Module>("my-jit", *Ctx); auto Int32Ty = Type::getInt32Ty(*Ctx); FunctionType *FT = FunctionType::get(Int32Ty, false); Function *F = Function::Create(FT, Function::ExternalLinkage, "answer", M.get()); BasicBlock *BB = BasicBlock::Create(*Ctx, "entry", F); IRBuilder<> Builder(BB); Builder.CreateRet(ConstantInt::get(Int32Ty, 42)); auto J = ExitOnErr(LLJITBuilder().create()); ExitOnErr(J->addIRModule(ThreadSafeModule(std::move(M), std::move(Ctx)))); auto Sym = ExitOnErr(J->lookup("answer")); auto *Answer = Sym.toPtr<int()>(); return Answer(); }这段代码做的事情就是:用 IRBuilder 生成一个函数,塞进 LLJIT,然后查到这个函数的地址,当成原生函数直接调用。整个过程不需要写汇编,不需要手动分配内存和标记可执行,这比自己做 JIT 要省太多事。
实际写 JIT 时,你会经常遇到符号找不到、内部链接问题、异常处理等特殊情况。我的建议是先从这种最简示例开始调试,确认链路通了再逐步加需求。
5.3 常见报错和解决办法
我把自己遇到过的一些报错整理成了表格:
| 报错或现象 | 常见原因 | 解决办法 |
|---|---|---|
Assertion failed: (isa<X>(Val) && "cast<Ty>() argument of incompatible type!") | 拿 IR 值的类型和实际类型不一致,常见于把指令结果当成基本块或者把基本块当成值 | 检查 IRBuilder 的插入点、检查getOperand顺序 |
symbol lookup error: undefined symbol: _ZN4llvm... | LLVM 库版本不一致,或者某个符号声明了但没实现 | 确认链接时的 LLVM 库和你编译头文件的版本一致 |
Invalid bitcode signature | 喂给lli或 JIT 的不是 LLVM bitcode,而是汇编或对象文件 | 确认用-emit-llvm生成的.bc或.ll |
| JIT 编译时报错“unable to find target” | 构建时没有启用对应的 Target,或运行环境缺少LLVM_TARGETS_TO_BUILD指定 | 重新编译并确保把目标架构启用 |
这些报错在 Stack Overflow 上一搜一大把,但如果你理解 LLVM 的分层结构,排查起来会更快。尤其那个 cast 断言,本质就是 IR 的类型系统比你想象的严格,任何类型不匹配都会在 debug 版本里炸出来。
5.4 学习路线建议
如果你是想认真学 LLVM,我给你一条自认为比较顺的路线:
- 先把
.ll文本 IR 读熟。找几个 C/C++ 小函数,用clang -S -emit-llvm生成,对照源码逐行看。 - 用
opt -passes跑一遍常见的优化 Pass,观察 IR 变化。比如-passes='instcombine,mem2reg,loop-vectorize',看哪些变量消失了、哪些循环被向量化了。 - 用
llc -O2生成汇编,对照 IR 看指令选择的结果。 - 再用 C++ API 生成一个自定义函数并 JIT 执行。
- 等到能熟练跑通这些流程,再去看某个具体 Pass 的源码实现。
这条路走下来,你会对“编译器”这个词有完全不同的理解,也能看懂像llvmpipe这类底层基础设施到底是怎么运作的。
6. 写在最后的体感心得
我从“只会用 Clang 编译 C++”到“能在 LLVM IR 上写自定义 Pass”,中间最大的障碍不是 API 不熟悉,而是思维方式的转变。写普通应用程序时,你关心的是业务逻辑,编译器帮你把语法糖变成机器指令;写 LLVM 相关代码时,你在跟一堆显式的控制流和类型打交道,状态全都摊开在你面前,反而有点不适应。
这个过程中最值钱的习惯是:用最简单的最小复现例去验证你关于编译器的猜想。不要一次性写一堆 Pass 代码然后丢给 clang,那样你会分不清是前端的问题、IR 生成的问题、优化的问题,还是后端代码生成的问题。把环节切开,逐个验证,才能真正把那套庞大的工程体系装进脑子里。
如果读完这篇你只记住一件事,那我希望是:LLVM 不是一个“编译器”,而是一套“造编译器”的基础设施。理解了它内部的 IR、Pass 管线、后端机制,再去面对其他基于 LLVM 的项目时,你会感觉自己拿到了同一张地图。