深入理解LLVM编译器架构:从IR到Pass优化与JIT实践
2026/9/18 9:15:52 网站建设 项目流程

1. 先厘清一件事:llvmpipe 日志里的 "256 bits" 暴露了什么

如果你在 Linux 桌面上跑过需要图形渲染的程序,又恰好打开过终端,大概率见过类似这样一行日志:

llvmpipe (llvm 15.0.7, 256 bits)

我第一次看到它的时候,第一反应是"这程序是不是没装显卡驱动,拿 CPU 在硬扛"。这个判断对了一半,但 llvmpipe 这行字背后的信息量,远不止"软渲染"三个字能概括。它本质上是一个基于 LLVM 的软件光栅化器,而这行日志里的 "llvm 15.0.7" 说明了 Mesa 图形栈在运行时实际调用了 LLVM 15.0.7 的 JIT 编译能力,把渲染管线里的着色器程序编译成当前 CPU 能执行的机器码,"256 bits" 则代表这次 JIT 生成的代码里,SIMD 向量宽度最大用到了 256 位。

这个细节,直接把一个很多人日常接触但从未深究的东西拉到了台前:整个 llvm-project 项目,远不止"编译器"这么简单。我在过去几年里,既编译过 llvm-project 的完整源码树,也基于它的库写过自定义工具链,还排查过不少跟 LLVM 版本、Pass 优化、IR 解析相关的诡异问题。这篇文章不打算从零给你讲一遍编译原理教科书,而是想从实际使用者的角度,把 llvm-project 的结构、工作流程、关键工具和那些文档里不会细说的坑,系统性地过一遍。

只要你写过 C/C++/Rust,或者用过任何基于 Clang 的工具链,或者只是好奇"CPU 怎么把着色器跑出图形来",这篇东西应该都能给你一些有用的视角。

2. 三段式架构为什么能通吃编译器:IR 在链条中如何当"翻译官"

llvm-project 在 GitHub 上是一个巨大的 monorepo,里面包含 Clang、LLD、libc++、compiler-rt、lldb、MLIR 等等一堆子项目。但整个项目的核心,始终是那一套"三段式架构":前端解析源码,中端做平台无关优化,后端生成目标机器码。

这套架构不是 LLVM 发明的,但 LLVM 把它做到了极致。它最大的意义在于:你不需要为每一种编程语言单独写一套完整的编译器后端。前端只需要把语言翻译成统一的中间表示(IR),后端只需要消费 IR,二者通过一个明确定义的接口解耦。

2.1 前端:Clang 如何把 C/C++ 变成 IR

传统编译器,比如早期 GCC 的某些阶段,会把源码解析后直接往机器码方向带。但 LLVM 的思路是,前端把源码解析、语义分析做完之后,生成一份 LLVM IR,这份 IR 是静态单赋值(SSA)形式的,意味着每个变量只被赋值一次,所有的数据流关系都非常明确。

Clang 作为 LLVM 默认的 C/C++/Objective-C 前端,做的事情其实很纯粹:词法分析、语法分析、语义分析、生成 IR。它不负责做高层的优化,那些优化发生在中端。这种分工的价值在于,当你需要支持一种新语言时,只需要写一个新前端,把该语言翻译成 LLVM IR,就能立刻享受到 LLVM 中端和后端积累多年的优化能力和目标平台支持。

举个例子,Rust 的 rustc 早期用的是自家的前端加 LLVM 后端,前端的 MIR 最终会 lowered 到 LLVM IR。Swift 也类似。这些语言团队不需要从零实现一个能生成 ARM、x86、RISC-V 机器码的后端,直接接入 LLVM 就拿到了全平台能力。

2.2 中端:Pass 优化在 IR 上做了什么

中端部分由一系列 Pass 组成,它们在 LLVM IR 上做平台无关的优化。比如:

  • 死代码消除(DCE)
  • 常量传播
  • 循环不变量外提
  • 函数内联
  • 全局值编号

这些优化在 IR 层面做,有个天然优势:IR 不依赖具体 CPU 的特性,所以同一套优化逻辑,对 x86、ARM、RISC-V 全都生效。你写一次优化 Pass,所有用 LLVM 的语言和所有 LLVM 支持的后端都能受益。

Pass 的管理在 llvm-project 里有一套完善的框架。你可以通过opt工具单独加载并运行某个 Pass,观察它前后的 IR diff,这是学习编译优化最直观的方式之一。我后面会专门演示。

2.3 后端:从 IR 到机器码的最后一公里

后端接收优化后的 IR,进行指令选择、寄存器分配、指令调度、指令优化等步骤,最终生成目标平台的汇编或机器码。

LLVM 后端的代码量占了整个 llvm-project 的很大比重。每支持一个 CPU 架构,就要实现对应的 Target 描述文件、指令选择器、寄存器分配器等组件。这也是为什么 LLVM 能成为芯片厂商的"事实标准"——很多新架构在流片之前,软件团队就会先把 LLVM 后端起好,因为验证工具链远比等硬件出来再开始要高效。

理解了这个三段式架构,再看 llvmpipe 的工作方式就顺理成章了:Mesa 栈前端把 GLSL 着色器代码翻译成类似 IR 的中间形态,然后借助 LLVM 的 JIT 能力生成宿主 CPU 的机器码。每一帧渲染任务里的着色器,都是一次"源码 → IR → 机器码 → 执行"的完整编译旅程,只不过这次编译发生在运行时,不是编译期。

3. 亲手跑一遍 LLVM 工具链:常用命令、IR 示例与输出对照

很多人以为 llvm-project 就是"装个 clang 完事",其实工具链里藏着大量平时不常用但关键时刻能救命的工具。这一节我带你把最核心的一条链路手动跑一遍,你就能直观感受到 IR 在中间到底长什么样,以及优化 Pass 是怎么改变代码的。

3.1 用 clang 生成和查看 LLVM IR

先准备一段最简单的 C 代码:

int add(int a, int b) { return a + b; } int main() { int x = add(3, 4); return x - 7; }

用下面的命令生成可读的 IR 文本:

clang -S -emit-llvm test.c -o test.ll

打开test.ll,你会看到类似这样的内容:

define i32 @add(i32 %a, i32 %b) { %1 = add i32 %a, %b ret i32 %1 } define i32 @main() { %1 = call i32 @add(i32 3, i32 4) %2 = sub i32 %1, 7 ret i32 %2 }

这里i32是 32 位整数类型,@add是函数名,%a%b是虚拟寄存器。注意main里的结果其实是个常量0,但在未优化之前的 IR 里它还是老老实实地调用和计算。

试试加优化:

clang -O2 -S -emit-llvm test.c -o test.opt.ll

再看 IR,你会发现add函数被内联进了main,常量折叠已经生效,整个函数被化简成:

define i32 @main() { ret i32 0 }

这就是中端优化的直观体现。同样的源码,在 IR 层面做完整轮变换后,最终进入后端的代码已经完全不同了。

3.2 用 opt 单独驱动优化 Pass

opt工具允许你单独加载某个 Pass 看效果。比如我只想做内联,不跑其他优化:

opt -passes=inline test.ll -S -o test.inline.ll

-passes=inline是 LLVM 15 里的新版 pass 语法,旧版用-inline,如果你用的版本比较老需要留意。实际使用中我喜欢配合-print-after-all之类的方式调试自定义 Pass,但日常分析 IR 时,用opt -passes='function(instcombine)' -S这种组合更精确。

这里要强调一个"为什么":直接看 clang 加不加-O2的输出对比,容易让人把多个优化混为一谈。用opt把 Pass 拆开单独跑,你才能真正搞清楚哪个 Pass 干了哪件事。排查性能问题时,这种能力价值巨大。

3.3 用 clang 完成到机器码并检查汇编

IR 之后就是后端的事情。可以直接看汇编:

clang -O2 -S test.c -o test.s

生成的目标汇编里,main通常就只剩几条指令了。如果你想看最终机器码:

clang -O2 -c test.c -o test.o objdump -d test.o

到这里,一条完整的工具链就通了:C 源码 → clang 前端 → LLVM IR → opt Pass 优化 → 后端指令选择 → 汇编 → 目标文件。这个流程你在任何一门编译原理课里都会学到,但亲手跑一遍、亲眼看到 IR 变化,理解深度完全不同。

4. llvmpipe 与 LLVM JIT:软件光栅化的性能生命线

回到开头那行日志。llvmpipe 的完整拼写是 "LLVMpipe",它就是 Mesa 里的软件光栅化实现。在没有 GPU 驱动的环境、虚拟机里没做显卡直通、或者调试渲染 bug 需要强制软渲染的时候,llvmpipe 就会顶上。

4.1 llvmpipe 的 JIT 链路与 256 位向量

llvmpipe 的做法是,把 GLSL 着色器编译到一个中间表示,再把中间表示通过 LLVM 的 JIT 编译成宿主机 CPU 的机器码。每次 draw call 涉及的着色器都会触发一次或多次这种 JIT 编译。

"256 bits" 是什么意思?它指的是 JIT 生成的 SIMD 代码最大向量位宽。如果你的 CPU 支持 AVX2(256 位),那 LLVM 后端就会尝试生成ymm寄存器的向量指令;如果只支持 SSE(128 位),那就退回到xmm寄存器。

这个值不是 LLVM 拍脑袋定的,而是把宿主机 CPU 的特性探测结果传给了 LLVM。Mesa 在初始化时查询 CPU 特性,然后通过 TargetMachine 的配置告诉 LLVM 后端"你可以用到多宽的向量"。LLVM 后端根据这个约束,决定向量化 pass 怎么把标量着色器转换成向量操作。

4.2 向量宽度选择背后的硬件判断逻辑

为什么 LLVM 要关心一个"上限"?关键在于指令选择器在生成向量代码时,需要保证每条指令都有对应的硬件指令可以落地。

如果 CPU 只有 SSE,最高 128 位向量,你生成了一条操作 256 位向量的指令,CPU 根本不认识,直接 illegal instruction 崩溃。所以这个上限必须提前定好。但即便 CPU 支持 AVX-512,512 位一定最优吗?也未必。AVX-512 在某些 CPU 上会导致频率降低(downclocking),对于 llvmpipe 这种每一帧都在跑大量向量运算的场景,频率下降可能抵消向量宽度翻倍带来的收益。

在我的实测里,一台支持 AVX-512 的机器用 llvmpipe 跑图形负载,有时候强制只用 AVX2(256 位)反而整体更稳定,帧延迟还更可控。这个结论不一定普遍成立,但它说明了一个道理:向量宽度不是越大越好,要综合考虑指令吞吐、频率变化和功耗。

4.3 为什么这个细节能"骗过"硬件限制

了解 llvmpipe 的运作机制后,你会发现一个很有意思的用途:在完全没有 GPU 的环境里,通过 llvmpipe 也能跑起 OpenGL 应用,比如做离屏渲染、跑一些图形算法测试。它慢,但它让"没有 GPU 也能跑图形程序"这件事成立了。

很多 CI 环境就是这么干的:机器只有 CPU,没有显卡驱动,跑图形测试时直接使用软渲染。你只要在环境变量里指定:

export LIBGL_ALWAYS_SOFTWARE=true

然后 glxinfo 里就会显示 llvmpipe。这意味着你在开发图形应用时,可以随时切到软渲染路径来排查"是不是驱动的问题"或者"是不是着色器编译的问题"。我调试过不少渲染异常,最后定位到是厂商驱动对某些指令的代码生成有 bug,切到 llvmpipe 后一切正常,瞬间就锁定了问题范围。

5. 自己构建 llvm-project 时我踩过的坑与可复用参数

如果你只是装一个 clang,用发行版仓库里的包就够了。但如果你想改源码、用一些比较新的优化开关、或者需要一个更精简或更定制化的工具链,就得自己从头编译 llvm-project。这一步看起来很常规,实际坑非常多。

5.1 磁盘、内存、编译耗时预期

先给个参考表,这是我基于几台不同机器编译 LLVM 15.0.7 的实际经验总结的:

项目建议配置说明
源码大小下载约 600MB~1GBgit clone 整个仓库会更大
编译所需磁盘至少 60~80GBRelease 构建会产生大量中间产物
内存16GB 起步,32GB 更稳链接阶段内存峰值很高
多核 CPU核心数越多越好但注意内存瓶颈会先出现
编译时间8 核机器约 40~60 分钟取决于配置项和 Ninja 并行度

我第一次编译的时候,只给了 8GB 内存,结果链接阶段直接 OOM,整个构建进程被杀掉。后来查资料才意识到,LLVM 后端库在静态链接时,几个大的 .a 文件要被链接器打开处理,内存峰值能到 10GB 以上。

如果内存紧张,有两个办法:一是用动态链接库方式构建(-DBUILD_SHARED_LIBS=ON),代价是生成的工具运行速度略慢,而且安装时依赖库文件;二是减小并行度,ninja -j4ninja -j8的内存峰值低不少,编译时间会拉长但至少不会崩。

5.2 CMake 配置中容易被忽略的开关

llvm-project 用 CMake 配置构建选项,默认参数往往不是最优的。我强烈建议至少关注这几个:

cmake -S llvm -B build \ -DCMAKE_BUILD_TYPE=Release \ -DLLVM_ENABLE_PROJECTS="clang;lld" \ -DLLVM_TARGETS_TO_BUILD="X86;AArch64" \ -DLLVM_ENABLE_ASSERTIONS=Off

LLVM_ENABLE_ASSERTIONS这个开关很容易被忽略。默认开启时,LLVM 内部带大量断言,跑起来慢不少,而且一些原本能容忍的小问题会直接触发断言失败。用于日常开发或学习,开着没问题;用于生产环境工具链,建议关掉。

LLVM_TARGETS_TO_BUILD也很重要。默认它会为所有支持的架构生成后端代码,编译时间非常长。如果你只需要 x86 和 ARM,完全可以只留这两个。我新机器第一次构建就是因为没限制 target,多等了两个多小时,还占了几十 GB 磁盘。

还有一个CMAKE_BUILD_TYPE=Release不能省。有些人图省事直接默认配置,结果跑出来的 clang 性能比发行版自带的差一大截,因为没开优化。别在这上面省。

5.3 从 15.0.7 版本升级时值得关注的改动

LLVM 版本迭代很快,15 到 16、17 之间的变化很大。我实际升级过程中遇到过的兼容性问题包括:

  • Pass 管道命名变化,旧的opt -instcombine写法换成-passes=instcombine
  • 某些 IR 元数据格式微调,生产的.ll文件在旧工具上解析不了
  • clang 对部分-W警告默认启停有调整,导致 CI 里本来不报错的代码突然告警

所以,如果你在基于 llvm-project 做二次开发,建议盯住官方 release notes。尤其是从 15 往后的版本,MLIR、新 Pass Manager 相关的改动会持续影响下游项目。我自己在升级时,通常先跑一遍完整的测试套件,把工具链相关的用例全过一遍,而不是只编译能过就开始用。

还有一个小建议:如果你在使用面向未来的功能,比如实验性的 target 支持或者自定义 Pass,记得把构建的 LLVM 版本信息写进你项目的 README 里。很多"莫名其妙的问题"最后定位到是 LLVM 版本不一致导致的。

6. 基于 IR 的二次开发:一个自定义 Pass 从写到跑的过程

聊完上层工具链,最后压轴的部分,我分享一下在 llvm-project 上做二次开发时最常遇到的一类工作:写一个自定义 Pass。很多想深入 LLVM 的人卡在这一步,总觉得 Pass 框架很玄。其实走通一遍就不难了。

6.1 Pass 的基本骨架

以 New Pass Manager 为例,最简单的一个分析 Pass 大概是这个样子:

#include "llvm/IR/Function.h" #include "llvm/IR/Instructions.h" #include "llvm/Pass.h" #include "llvm/IR/LegacyPassManager.h" #include "llvm/Passes/PassBuilder.h" #include "llvm/Passes/PassPlugin.h" using namespace llvm; namespace { class DemoFunctionPass final : public PassInfoMixin<DemoFunctionPass> { public: PreservedAnalyses run(Function &F, FunctionAnalysisManager &AM) { for (auto &BB : F) { for (auto &I : BB) { if (auto *Call = dyn_cast<CallInst>(&I)) { errs() << "call: " << Call->getCalledFunction()->getName() << " in " << F.getName() << "\n"; } } } return PreservedAnalyses::all(); } }; } // namespace void RegisterDemoPass() { PassBuilder PB; PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager &FPM, ArrayRef<PassBuilder::PipelineElement>) { if (Name == "demo-pass") { FPM.addPass(DemoFunctionPass()); return true; } return false; }); } extern "C" ::llvm::PassPluginLibraryInfo LLVM_ATTRIBUTE_WEAK llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, "DemoPass", "0.1", [](PassBuilder &PB) { RegisterDemoPass(); }}; }

这个 Pass 做的事情很简单:遍历所有函数,找到函数里的call指令并打印出来。真正的工作发生在run方法里。你需要编译它为一个动态库,然后用opt加载:

clang++ -shared -fPIC -fno-rtti DemoPass.cpp -o DemoPass.so \ $(llvm-config --cxxflags --ldflags --libs) opt -load-pass-plugin=./DemoPass.so \ -passes=demo-pass \ test.ll -S -o /dev/null

如果输出里出现了call: add in main,就说明 Pass 被正常加载并执行了。

6.2 在跑通之前,最常卡住的三个点

第一,符号可见性。编译 Pass 插件时,llvmGetPassPluginInfo必须是外部可见的。如果你用了-fvisibility=hidden,记得把函数标记为__attribute__((visibility("default")))或者LLVM_ATTRIBUTE_WEAK,否则 opt 会报 "unable to load plugin"。

第二,ABI 兼容。Pass 插件必须和 opt 使用同一个 LLVM 版本编译。如果你系统里有一个 LLVM 15 的 opt,却用 LLVM 17 的头文件编译插件,加载时几乎必然崩溃。这也是为什么我一直强调,做 LLVM 二次开发最好自己编译一份,然后用同一份源码树的optclang++来编译插件。

第三,llvm-config需要指对。源码构建后在 build/bin 里的llvm-config,和系统中可能自带的版本不同。我建议显式使用:

/path/to/llvm-project/build/bin/llvm-config --version

确保它输出的是你构建的那个版本。

6.3 一个更贴近场景的例子:统计函数里的算术指令数

下面这个 Pass 更实用一点。它统计一个函数里addsubmuldiv指令的数量,并把结果打印出来:

for (auto &BB : F) { for (auto &I : BB) { switch (I.getOpcode()) { case Instruction::Add: ++AddCount; break; case Instruction::Sub: ++SubCount; break; case Instruction::Mul: ++MulCount; break; case Instruction::SDiv: case Instruction::UDiv: case Instruction::FDiv: ++DivCount; break; default: break; } } } errs() << "Function " << F.getName() << ": add=" << AddCount << " sub=" << SubCount << " mul=" << MulCount << " div=" << DivCount << "\n";

这时候如果先用clang -O0 -S -emit-llvm test.c -o test.ll生成未优化 IR,再用你自己的 Pass 跑,就能看到每个函数的算术指令分布。这个工具对分析"某个源码片段经过编译器后变成了什么指令"非常直接。

6.4 如何验证你的 Pass 真的做了优化

写 Pass 容易,证明它有效难。我通常的做法是,在 Pass 之前和之后分别生成两份 IR,做diff,然后再对同一条输入分别生成目标汇编,对比指令条数和实际跑出来的性能。

这里有个很关键的经验:不要凭肉眼判断 IR 变简单了就说性能变好。CPU 的实际执行效率受分支预测、访存模式、指令级并行影响,IR 里的指令数只是参考。我见过一个精简了不少 IR 指令的 Pass,跑起基准测试反而慢了 10%,原因是它把一些原本可以用向量化处理的代码拆成了大量标量分支。所以,最终结论要以 benchmark 为准。

7. 最后给大家的一个实操建议:构建产物别删,日志要留

关于 llvm-project,我最大的一条教训是:永远保留带符号的 Release 构建,别为了省磁盘删光中间产物。你在调试一个问题时,经常需要llvm-symbolizer去还原崩溃栈;如果你用发行版仓库的 clang,栈基本是干净的;如果你自己构建的版本把符号删了,遇到 JIT 或 Pass 崩溃你会非常痛苦。

另一个建议是:在跑optllc这类工具之前,先把输入和命令写进一个脚本里,不要随手在终端敲。LLVM 的调试往往需要反复重复同一条命令,手动敲还容易漏参数。我一般把最常用的三条命令存成一个Makefile

COMPILE = clang -O0 -S -emit-llvm OPT = opt -load-pass-plugin=./DemoPass.so %.ll: %.c $(COMPILE) $< -o $@ demo: demo.ll $(OPT) -passes=demo-pass $< -S -o /dev/null

然后make demo就完事了。看起来简单,但真到排查问题的时候,这套工作流比反复敲命令省下不少时间。

如果你正在基于 llvm-project 做二次开发,前期花一点时间把构建、测试、调试的脚本框架搭好,后面会顺畅非常多。LLVM 是个好东西,但它也是个庞然大物,别上来就把自己埋在源码里,先把工具链跑顺,再慢慢深入,这个顺序是对的。

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

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

立即咨询