LLVM 这个项目我最早接触是在做编译器后端的时候,当时只觉得它是个“能生成各种架构机器码的工具链”,后来用得越深越发现,它已经成了整个软件生态里绕不开的基建。无论是 Clang 把 C/C++ 转成机器码,还是 Rust、Swift、Julia 这些语言拿它当前端和优化层,甚至图形栈里 Mesa 的 llvmpipe 软渲染,背后都是同一套 LLVM 中间表示(IR)在起作用。这个项目名字叫 llvm-project,但它远不止“一个编译器”那么简单。
这篇文章我就围绕 llvm-project 本身,从架构思路、核心组件、构建方式到写 IR 优化 Pass 的完整流程,把这些年踩过的坑和积累的经验一次说清楚。想搞懂 LLVM 到底在做什么的人、准备上手改 LLVM 源码的人,或者只是好奇 llvmpipe 这种“用 CPU 模拟 GPU”是怎么实现的人,这篇都应该对你有用。
1. 内容整体设计与思路拆解
1.1 为什么说 LLVM 不只是一个编译器
很多人第一次听到 LLVM,第一反应是“哦,就是那个比 GCC 新的编译器”。这个说法不算错,但格局小了。LLVM 的定位从来不是一个完整的编译器,而是一套编译器基础设施。它把传统编译器的流程拆成了三个独立阶段:前端负责解析源代码,优化器负责对中间表示做变换,后端负责把优化后的 IR 变成某种目标架构的机器码。
这三段式拆法,最大的好处是可以自由组合。今天你想写一门新语言,不用从零开始写代码生成和寄存器分配,只要写一个前端,把源码翻译成 LLVM IR,之后所有的优化和后端支持全部白嫖。Rust 的 rustc 就是这么干的,早期的 Swift 也是这么干的,Julia 更是直接把 LLVM 当成了 JIT 引擎。
那 llvmpipe 是啥?它就是这套基础设施在图形学方向的典型应用。Mesa 里的 llvmpipe 是一个纯软件光栅化驱动,它把 OpenGL 的着色器编译成 LLVM IR,然后 JIT 成 CPU 上跑的机器码。也就是说,你的机器没有独立显卡,或者显卡驱动出了问题,它也能用 CPU 把 3D 画面给你渲染出来。我见过不少开发者在无头服务器上跑 EGL 程序,就是靠 llvmpipe 顶着把 OpenGL 环境跑起来的。
1.2 模块化设计的核心价值
llvm-project 这个仓库之所以用一个“project”而不是“compiler”来命名,就是因为它在仓库层面就是一个多组件集合。核心的 LLVM 库在llvm/目录下,Clang 前端在clang/,调试器 LLDB 在lldb/,链接器 LLD 在lld/,C++ 标准库实现 libc++ 也在里面。这种布局意味着你可以按需选择要构建的组件。
我记得第一次从源码构建 LLVM 的时候,不知道有 CMake 开关这回事,直接把所有东西全编译了,结果在只有 8GB 内存的机器上硬生生等了一个多小时,还一度把内存吃到 swap。后来才学会用LLVM_ENABLE_PROJECTS指定只构建需要的组件。LLVM 的设计哲学就是,宁可让你多花点时间去理解构建配置,也不让你在运行时承受一坨用不上的代码。
这种模块化设计的另一个好处,是生态的繁荣。因为 IR 是公开的稳定接口,各路第三方工具可以随意接入。Rust 编译器可以选择 LLVM 的某个版本固化成自己的后端,Swift 可以在 LLVM 之上再做一层自己的 SIL 优化,连 Python 生态里的 Numba 都是把 Python 字节码翻译成 LLVM IR 再做 JIT。整个编译工具链的发展节奏,其实是跟着 LLVM 这个大齿轮转的。
2. 核心架构与关键原理解析
2.1 三段式编译流程:前端、中端、后端
传统编译器像是 GCC,它在内部把源代码解析成自己的中间表示(GIMPLE),然后在 GIMPLE 上做优化,最后生成后端指令。这套流程所有环节揉在一起,如果你想复用它的优化器去支持一门新语言,基本不可能。LLVM 的设计在整个流程上就清晰得多:
- 前端阶段:Clang 负责把 C/C++/Objective-C 解析成抽象语法树(AST),再降级为 LLVM IR。rustc 也是在这个环节把 Rust 的 MIR 翻译成 LLVM IR。这个阶段只关心“语言无关的语法和语义”,不关心目标 CPU。
- 中端优化:优化器拿到 LLVM IR 之后,做一系列 pass,比如内联、常量传播、循环优化、死代码消除。这一阶段既有与目标无关的优化,也有带目标特征的向量化等。优化结果还是 LLVM IR。
- 后端阶段:后端把优化后的 IR 逐层下降到 SelectionDAG、指令选择、寄存器分配,最终生成目标机器的汇编和机器码。
一套 IR 贯穿三个阶段,这就让“写一次优化,所有硬件受益”成为可能。新架构支持(比如 RISC-V 的迅猛发展)只需写好后端的指令描述,前面所有前端的语言支持都能无缝覆盖。这个特点让 LLVM 在嵌入式、AI 芯片等新兴领域成了默认选择——芯片厂商想支持新指令集,通常最省力的路径就是往 LLVM 后端的 TableGen 描述文件里加指令。
2.2 LLVM IR 到底长什么样
理解了架构之后,真正的门槛就是对 LLVM IR 的熟悉程度。IR 是一种静态单赋值(SSA)形式的强类型中间语言。SSA 的意思是每个变量只能被赋值一次,这听起来有点绕,但它让数据流分析变得极其简单。比如:
define i32 @add(i32 %a, i32 %b) { entry: %result = add i32 %a, %b ret i32 %result }这段 IR 定义了一个函数add,接受两个 32 位整数,返回一个 32 位整数。%result是这条add指令的结果,在 SSA 约束下它不可能被重新赋值。你如果想做“这个变量被谁用了”这类分析,顺着 SSA 的 use-def 链一遍就能找出所有使用点。
这种设计对优化 pass 极其友好。编译器优化本质就是对 IR 做图算法,SSA 让图的边非常干净,没有“别名”带来的不确定性。当然,实际的 IR 比这个复杂得多,还有 phi 节点、load/store 指令、block 标签、metadata 等,但核心思想就是这一套:强调类型、强调数据流、强调可验证性。
2.3 llvmpipe 与软件渲染里的 LLVM
回到开头的 llvmpipe。它名字直译就是“LLVM pipe”,这个 pipe 指的是为 OpenGL 光栅化管线实现软件渲染。常规 CPU 上实现图形渲染的思路,是把显式循环一个个像素去填充;llvmpipe 的做法是把顶点着色器和片段着色器的 GLSL 代码,通过 Mesa 的编译器前端翻译成 LLVM IR,再像 JIT 一样生成针对当前 CPU 的机器码。
而且 llvmpipe 非常聪明地利用了 LLVM 的矢量化能力。现代 CPU 都有 SSE、AVX 这类 SIMD 指令,一次能算 4 个或 8 个 float。llvmpipe 会对 IR 做向量化 transform,让 CPU 一次处理多个像素。热词里提到的“llvm 15.0.7, 256 bits”指的就是 AVX2 的 256 位向量宽度——这条信息就是 llvmpipe 在运行时打印出来的渲染器信息。你如果跑过glxinfo | grep renderer,大概率见过类似输出。
所以 llvmpipe 不是“凑合能跑”的软件渲染,它在 LLVM 的加持下拥有一条真正的深度优化管线。我甚至见过有人拿 llvmpipe 跑机器学习推理,因为 C++ 推理框架在 CPU 上做算子融合的思路,跟它在渲染管线上做 shader 融合的思路如出一辙。
3. 实操过程与核心环节实现
3.1 从源码构建 llvm-project
上手 LLVM 最快的方式,是直接拿官方发布的 release 分支来构建。我以 llvmorg-15.0.7 为例,完整走一遍这个过程。你需要先满足基础依赖:CMake 3.20 以上、支持 C++17 的编译器(GCC 7.1+ 或 Clang 5+)、Ninja 构建工具。
git clone --depth 1 --branch llvmorg-15.0.7 https://github.com/llvm/llvm-project.git cd llvm-project cmake -S llvm -B build -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DLLVM_ENABLE_PROJECTS="clang;lld" \ -DLLVM_TARGETS_TO_BUILD="X86;AArch64" \ -DLLVM_ENABLE_ASSERTIONS=ON ninja -C build构建时长取决于机器配置。Release 模式、只编 X86 和 AArch64 两个 target,四核机器大概 20~40 分钟。如果你把所有 target 都编上,时间会翻好几倍。这里有几个关键参数值得解释一下:
CMAKE_BUILD_TYPE=Release:开启优化并去掉调试信息。编译 LLVM 本身太吃资源,Debug 模式的速度慢到怀疑人生,没特殊需求不要选。LLVM_ENABLE_PROJECTS="clang;lld":这是你要额外构建的顶层项目。构建 Clang 用来测试自己的 IR pass 非常有用。LLVM_TARGETS_TO_BUILD:控制后端支持哪些架构。默认是全部支持,但如果你只在 X86 上跑,把范围缩到 X86 能砍掉不少构建时间和二进制体积。LLVM_ENABLE_ASSERTIONS=ON:开启内部断言。虽然会影响性能,但开发 pass 时它能帮你尽早暴露 IR 构造错误,强烈建议开。
构建完成之后,build/bin/下会有一堆工具,日常最常用的是clang、opt、llc、lli和llvm-dis。clang负责把 C/C++ 翻译成 IR,opt负责跑优化 pass,llc负责把 IR 转成汇编或目标文件,lli可以直接解释执行 IR,llvm-dis则能把 bitcode 反汇编成可读的文本 IR。
3.2 用 Clang 和 opt 跑通第一个优化
拿到工具链之后,先做一个最直观的验证:写一段 C 代码,看它变成 IR 是什么样。新建一个add.c:
int add(int a, int b) { return a + b; }执行:
clang -S -emit-llvm add.c -o add.ll cat add.ll你会看到类似之前展示的 IR 输出。注意-S -emit-llvm的意思是输出人类可读的 IR 文本,如果不加-S,默认会生成二进制的.bcbitcode 文件。接着试试优化:
opt -S -passes=mem2reg add.ll -o add.opt.llmem2reg是最经典的优化 pass,它的作用是把内存操作提升为 SSA 寄存器变量。如果源代码里有局部变量,优化前你会看到大量的alloca和load/store指令,跑完mem2reg之后,这些冗余操作会被消除,IR 清晰得多。
这里有个版本差异的坑需要特别说明:LLVM 15 默认使用的是New Pass Manager,优化 pass 的写法是-passes=...,而 LLVM 14 之前常用的-mem2reg这种旧语法在老版本里有效,到 15 你会收到 deprecation 警告或者报错。网上的很多教程还停留在旧语法,照着抄就踩坑。确认版本后统一用-passes=的写法比较省心。
3.3 动手写一个简单的自定义 Pass
对很多人来说,真正上手 LLVM 的标志性动作是写一个自己的 pass。这里我写一个非常简单的 FunctionPass,功能是统计每个函数里基本块(BasicBlock)的数量,然后打印到标准输出。基于 LLVM 15 的 New Pass Manager 来写。
新建BlockCounter.cpp:
#include "llvm/IR/Function.h" #include "llvm/IR/LegacyPassManager.h" #include "llvm/Passes/PassBuilder.h" #include "llvm/Passes/PassPlugin.h" #include "llvm/Support/raw_ostream.h" using namespace llvm; namespace { struct BlockCounter : public PassInfoMixin<BlockCounter> { PreservedAnalyses run(Function &F, FunctionAnalysisManager &AM) { errs() << "Function: " << F.getName() << " has " << F.size() << " basic blocks\n"; return PreservedAnalyses::all(); } }; } // namespace extern "C" ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, "BlockCounter", LLVM_VERSION_STRING, [](PassBuilder &PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager &FPM, ArrayRef<PassBuilder::PipelineElement>) { if (Name == "block-counter") { FPM.addPass(BlockCounter()); return true; } return false; }); }}; }这个 pass 的核心逻辑在run方法里:F.size()返回的就是一个函数里基本块的个数。我们把它编译成动态库,然后让opt在跑 pipeline 的时候加载它。编译和运行命令如下:
clang++ -shared -fPIC -fno-rtti BlockCounter.cpp \ -o BlockCounter.so \ `llvm-config --cxxflags --ldflags --libs` opt -load-pass-plugin=./BlockCounter.so \ -passes=block-counter add.ll -o /dev/null如果一切顺利,终端会打印出Function: add has 1 basic blocks。这个例子虽然简单,但它把整个 pass 开发的框架走通了:你写 Transform 或 Analysis pass、通过 plugin 动态加载、在编译管线里按名字挂载,之后的功能都是在这个骨架上长出来的。
3.4 从 IR 到机器码:llc 的完整流程
优化过的 IR 最终要变成机器码,这是 LLVM 后端的活。你可以用一个非常短的命令完成:
llc add.opt.ll -o add.s cat add.s如果目标机器是 X86_64,你会看到生成的汇编,里面可能包含addl %esi, %edi这种指令。llc还支持通过-march参数指定目标架构,比如-march=aarch64生成 ARM64 汇编。这正是 LLVM 跨架构的典型用法:同一份 IR,可以交叉编译到不同的 CPU 指令集。
后端流程里最重要的一个开关是-mcpu和-mattr。比如你想针对-mcpu=skylake做指令调度和向量化,或者通过-mattr=+avx2显式启用 AVX2 指令。llvmpipe 那个“256 bits”的打印,就是它在运行时探测了当前 CPU 的向量能力之后,选择使用 AVX2 指令集做 JIT 的结果。了解这些之后,你就明白为什么 LLVM 在所有高性能计算场景都有存在感——它把“针对具体硬件做代码生成”这件事做到了极致的可配置。
4. 常见问题与排查技巧实录
4.1 构建阶段的高频报错
构建 LLVM 期间遇到最多的问题,基本集中在资源不足和依赖版本不匹配。下面这几条是我和身边同事都踩过的:
内存或磁盘不足:Release 模式下链接clang本身需要几个 GB 内存,如果你在云主机上构建,建议至少配 4GB swap。磁盘方面,整个构建目录很可能占用超过 30GB,df -h先看一眼再动手。遇到 OOM 时,可以用-j2甚至-j1限制并行度,慢一点总比中断强。
CMake 版本过低:LLVM 15 要求 CMake 3.20 以上,Ubuntu 20.04 自带的 3.16 就不行。解决方案就是自己装新版 CMake,我建议直接用 pip 安装:pip install cmake,版本既新又不污染系统。
链接器内存爆掉:在 macOS 上构建 LLVM,默认 ld 常常在链接期直接 OOM,甚至报错退出。解决办法是在 CMake 配置里加一行-DLLVM_USE_LINKER=lld,让 LLVM 用自带的 lld 做链接。Linux 上如果内存不太够也可以加上这个参数,lld 的链接速度比 GNU ld 快一倍以上,内存占用也更低。
4.2 理解 opt 的 pass 命名与语义差异
在自己写 pass 的时候,最容易困惑的是 Legacy Pass Manager 和 New Pass Manager 的 API 差异。LLVM 15 虽然默认新 Pass Manager,但llvm/IR/LegacyPassManager.h这类头文件依然存在,很多旧教程的代码是给旧 API 写的。两者的核心区别是,前者用FunctionPass继承,后者用PassInfoMixin+run方法。
具体写的时候,如果你看到runOnFunction这样的方法名,说明是旧 API;如果你看到run(Function &F, FunctionAnalysisManager &AM),这是新 API。一定不要混用。另外,新 Pass Manager 还要求你在llvmGetPassPluginInfo里注册好构建回调,否则opt -load-pass-plugin加载了也找不到 pass。我见过不少人卡在这一步,报错信息又不够直观,最后才发现是注册函数写漏了。
4.3 llvmpipe 软渲染环境怎么确认
如果你需要在无 GPU 环境跑 OpenGL,确认 llvmpipe 是否生效很重要。命令行里跑:
glxinfo | grep renderer输出类似llvmpipe (LLVM 15.0.7, 256 bits)就说明用的是 llvmpipe 软渲染。这个 256 bits 表示当前 SIMD 宽度是 256 位,对应 AVX2。如果是比较老的 CPU,可能会显示128 bits,对应 SSE 指令集,性能会差一些。
有时候你已经装了 Mesa,却发现程序报错找不到 OpenGL 驱动。这时可以手动指定软件渲染:
export LIBGL_ALWAYS_SOFTWARE=true export GALLIUM_DRIVER=llvmpipe第一行强制 OpenGL 走软件渲染,第二行指定直接使用 llvmpipe 驱动。这个技巧在 CI/CD 环境跑图形测试时特别常用,也适合调试那些必须依赖 OpenGL 但机器又没有独立显卡的程序。
5. 优化技巧与实战经验
5.1 善用 llvm-config 和工具链组合
日常开发中,我强烈建议把llvm-config学好。它就是你安装的 LLVM 的“参数查询器”。比如你想知道编译器和链接器需要什么 flag,可以用:
llvm-config --cxxflags --ldflags --libs它输出的参数可以直接拼进clang++编译命令,省去手写一堆-I和-L的麻烦。我在写插件、写自定义工具时基本都会用它生成编译参数。
lli也是被忽视的工具。它可以直接解释执行或者 JIT 执行一份 IR 文件,不用经过汇编链接成可执行文件的步骤。在做 IR 层面的快速原型验证时特别高效:
echo 'int main() { return 42; }' > test.c clang -S -emit-llvm test.c -o test.ll lli test.ll echo $?直接输出 42。像这样把验证周期缩到最短,对理解 IR 语义非常有帮助。
5.2 用-time-passes定位性能瓶颈
当你开始做复杂的 IR transform,可能会发现某段代码经过优化后性能没提升,甚至变差。这时候不要瞎猜,LLVM 自带的 pass 计时工具能帮你快速定位:
opt -time-passes -passes=inline,constprop,gvn,mem2reg add.ll -o add.opt.ll它会输出每个 pass 的耗时,让你知道热点在哪。另一个常用技巧是跑完 pass 之后用llvm-opt-report或者llc -stats查看优化前后指令数的变化。定位问题的时候有个原则:先确认你添加的 pass 有没有真正改变 IR,再谈性能收益。很多时候问题出在 pass 没跑上,而不是优化策略不对。
5.3 利用 TableGen 学习指令集描述
如果你想深入了解 LLVM 怎么生成一条指令,可以进入llvm/lib/Target/X86/X86InstrInfo.td这类 TableGen 文件去看看。TableGen 是 LLVM 用于描述指令集、寄存器文件、调用约定等领域特定语言。它看起来有点吓人,其实本质就是声明式配置。新架构的移植工作里,指令描述占了很大比例,写好 TableGen,代码生成器基本就完成了一大半。
我的建议是,不必一开始就去啃 TableGen,而是先通过一个具体的后端问题切入,比如“为什么我写的 IR 编译出来会有多余的 move 指令”,然后回溯到指令选择阶段,看看如何通过 TableGen 约束指令模式。边查边学比通读文档效率高得多。
写 pass 还有个小经验值得分享:在 IR 里打日志用errs(),它会输出到标准错误流,避免和 opt 的输出文件混淆。千万不要用std::cout,在 LLVM 的工具链里,混合stdout的输出可能把输出文件弄乱,尤其当输出文件时通过重定向到文件的方式实现时,更容易踩这坑。
LLVM 的上手曲线确实陡,但它的每一层设计都有迹可循。从 IR 到 pass,从 TableGen 到 JIT,每一个环节都是这块庞大生态里的可替换积木。我当初从“照着教程写 pass 报错”到“能按需改 IR 做自定义优化”,中间最大的坎其实是心态——面对纷繁的 API,最好的方式不是试图通读全部文档,而是带着具体问题去源码里翻答案。llvm-project 的代码结构非常规整,每个 pass 都是一个独立目录,相近功能放在一起,这大概就是这个项目最让人舒服的地方。