
前段时间有个朋友跑过来问我说自己在看某个渲染相关的开源项目时经常看到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和libcabi是 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 编译并输出 IRclang -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 片段的语义用lliLLVM 解释器直接执行它会读取字节码或文本 IR在本地 JIT 或解释执行非常适合做小实验。调试优化问题用opt -passes... -S input.ll -o output.ll来单步跑某个 Pass输出文本 IR 对比优化前后的差异。熟练之后可以直接写 C 来生成 IR用IRBuilder这个辅助类它能把那些指令构造的细节处理好。从 14 版本开始LLVM 全面强化了opt的新 Pass 管理器原来的-pass-name风格逐渐被-passespass-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 RegisterPassMyPass 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 PassInfoMixinMyNewPass { 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 -Rpassloop-unroll -Rpass-missedloop-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 位 SIMDAVX2它就能用这些向量指令一次处理多个像素/顶点大幅提升吞吐量。你看到的“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_ptrTargetMachine( TargetRegistry::lookupTarget(x86-64, TheTriple, Err)); TM-setTargetFeatureString(avx2,fma);如果你在写 JIT 或自定义编译器最好在程序启动时通过sys::getHostCPUName()拿当前 CPU 的名字再把它的特性如是否支持 AVX、AVX2、FMA传给 TargetMachine。这样你产出的代码就能充分利用宿主机的 SIMD 能力。否则默认配置可能只生成比较保守的指令集性能会差一大截。5. 自己动手搭建LLVM环境与Mini JIT5.1 构建llvm-project的注意事项不建议从源码全量编译整个llvm-project。如果你只想要 Clang 和 LLVM 的核心工具直接装发行版软件包或下载预编译二进制更省时间。但如果要改 LLVM 源码、调试 Pass、或者交叉编译到非主流平台就得手动构建。我踩过的坑主要是这三处磁盘空间。一个带调试信息的 LLVM 构建占用轻松超过 30GB。建议在 CMake 里开启LLVM_ENABLE_ASSERTIONSON帮助调试但别开启LLVM_ENABLE_PROJECTS里的所有项目用哪个装哪个。内存不足。链接 Clang 和 LLVM 的时候单靠-j4也可能吃掉 16GB 内存。最好给链接器加-fuse-ldlld或者降低并行度不然大概率会 OOM。版本匹配。如果你在项目中用了find_package(LLVM)务必保证 CMake 能找到一个 LLVM 版本与编译参数匹配的安装位置。不同版本之间的 API 变动很大最常见的报错就是某个头文件找不到或者某个函数签名对不上。推荐的 CMake 配置长这样cmake -G Ninja \ -DCMAKE_BUILD_TYPERelWithDebInfo \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86 \ -DBUILD_SHARED_LIBSON \ ../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_uniqueLLVMContext(); auto M std::make_uniqueModule(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.toPtrint()(); return Answer(); }这段代码做的事情就是用 IRBuilder 生成一个函数塞进 LLJIT然后查到这个函数的地址当成原生函数直接调用。整个过程不需要写汇编不需要手动分配内存和标记可执行这比自己做 JIT 要省太多事。实际写 JIT 时你会经常遇到符号找不到、内部链接问题、异常处理等特殊情况。我的建议是先从这种最简示例开始调试确认链路通了再逐步加需求。5.3 常见报错和解决办法我把自己遇到过的一些报错整理成了表格报错或现象常见原因解决办法Assertion failed: (isaX(Val) castTy() 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或.llJIT 编译时报错“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 变化。比如-passesinstcombine,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 的项目时你会感觉自己拿到了同一张地图。