如果你是在搜索引擎里敲下“llvm-project”这个词才点进来的,我猜你大概率正面临三种情况之一:要么是编译某个开源项目时看到一长串 LLVM 依赖手足无措,要么是在 glxinfo 输出里看到llvmpipe (LLVM 15.0.7, 256 bits)这样的字符串想知道它到底什么意思,要么是打算往编译器方向走,想从源码层面搞明白 LLVM 究竟是个什么东西。这三种情况我都经历过,所以这篇不打算写成文档翻译,也不打算写成功劳簿,就是想从 llvm-project 这个仓库本身出发,把它到底是什么、核心概念怎么理解、源码怎么构建、实际应用里的 llvmpipe 又是怎么回事、以及想动手改造该从哪下手,一次讲清楚。读完你应该能建立一张完整的脑内地图,不再被那一堆子目录吓到。
1. LLVM 不是编译器,是一套编译器工厂
1.1 从“编译器三段式”说起
先说一个最容易被误解的地方。很多人把 llvm-project 当成“一个 C/C++ 编译器”,这个说法不算错,但会严重限制你对它后续所有设计的理解。真正的 LLVM 是一个编译器基础设施,换句话说,它不是某一种编译器的成品,而是生产编译器的“机床和零件库”。
传统编译器的结构大体上是三段式:前端负责把源代码解析成中间表示,中端负责对中间表示做各种与机器无关的优化,后端负责把优化完的中间表示翻译成目标机器的汇编或机器码。GCC 也是这个架构,但它的前端、中端、后端是强绑定的,你想给 GCC 加一个新语言前端,或者加一个新 CPU 后端,工作量大到足以劝退绝大多数团队。
LLVM 做的事情是把中间表示(IR)和后端(CodeGen)抽出来,做成完全语言无关的公共设施。前端只需要把任何语言翻译成 LLVM IR,后端只需要把 LLVM IR 翻译成目标指令,剩下大约百分之六十的优化逻辑完全不用重写。这就是所谓“编译器工厂”的含义:Clang 只是在工厂里用一套模具造出来的 C/C++ 编译器,Rust 的 rustc 后端、Swift 编译器、各种 DSL 编译器,都是从同一个工厂里出来的不同产品。
1.2 llvm-project 仓库里到底躺了些什么
我第一次打开 llvm-project 仓库时整个人是懵的,因为根目录下不是一个叫 llvm 的文件夹,而是一大堆并行目录。你不需要每个都懂,但至少要分清它们的角色:
| 目录 | 角色 | 简单理解 |
|---|---|---|
| llvm | 核心库 | IR 定义、Pass 优化框架、CodeGen、各目标后端 |
| clang | C/C++/Objective-C 前端 | 把 C/C++ 变成 LLVM IR |
| clang-tools-extra | 基于 Clang 的工具 | clangd、clang-tidy、clang-format 等 |
| lld | 链接器 | 用 LLVM 库重写的高性能链接器 |
| lldb | 调试器 | LLVM 生态的调试器 |
| libcxx / libcxxabi / libunwind | C++ 标准库实现 | 对应标准库、ABI、栈展开 |
| compiler-rt | 运行时库 | sanitizer、builtins 等底层支持 |
| flang | Fortran 前端 | LLVM 官方 Fortran 编译器 |
| mlir | 多级 IR 基础设施 | 面向 AI/芯片方向的编译器框架 |
| polly | 循环优化 | 多面体模型优化器 |
看到这个列表你就明白了:llvm-project 是一个 monorepo,把完整工具链全家桶放在同一个仓库里统一管理和构建。平时你可能只用到 clang 和 lld,但如果你要自己定制编译器,这些目录都是你的素材。
1.3 为什么整个行业都在围绕它转
业内选择 LLVM 不是因为它性能碾压其余工具,而是因为它把“编译”这件事的复用性做到了极致。几条典型的例子:iOS 生态很早就用 Clang 替代了旧的编译器前端;Rust 的 rustc 官方后端最初就构建在 LLVM 之上,Rust 社区并行开发过的若干替代后端都没能撼动 LLVM 的地位;GPU 厂商、FPGA 厂商也都在基于 LLVM 做芯片编译器。原因惊人地一致:他们只需要写一个新后端,或者借用 TableGen 描述指令集,就能获得一套经过十余年打磨的优化器、寄存器分配器、指令调度器。你想想,如果每做一个芯片都要从零写一遍优化器,那这个行业根本转不动。
所以,当你面对 llvm-project 时,先不要问“它怎么编译我的代码”,而要问“我能利用它的哪一层”。这是衔接整个项目知识体系的第一把钥匙。
2. LLVM IR 与 Pass 管线:先搞懂优化到底动的是什么东西
2.1 SSA 与无限虚拟寄存器:不那么“显然”的设计
LLVM 的整个中端构建在一个叫静态单赋值(SSA)的形式之上,核心约束是每个变量只能被赋值一次。你可能觉得这很反直觉,毕竟正常代码里x = x + 1到处都是。但正是这个“只能赋值一次”的限制,让很多优化变得非常简单。
举个例子,一条a = b + c的指令,如果后面的代码修改了 c,普通编译器得做数据流分析才能确认 a 的旧值是否还能用。SSA 形式下,c 只有在定义处才有那个值,后来再给 c 赋值,实际是创建了一个新的 SSA 名字,老名字所代表的数值永远不变。这种不可变性让全局值编号、公共子表达式消除、死代码消除都从“需要复杂分析和证明”变成了“局部模式匹配就能处理”。
更妙的是 LLVM 还用“无限虚拟寄存器”。中端优化时,你根本不用关心目标机器到底有几个物理寄存器,可以认为要多少临时寄存器就有多少。寄存器分配这件事被彻底推迟到了后端,由后端的寄存器分配器把虚拟寄存器映射到真实寄存器。这个分层让中端代码与目标机器完全解耦,再奇怪的新架构也不用改中端逻辑。
2.2 一份真实的 LLVM IR 长什么样
光说不练是空的。下面这段 C 代码:
int clamp_add(int a, int b) { int s = a + b; if (s > 100) s = 100; return s < 0 ? 0 : s; }用clang -S -emit-llvm -O2 clamp.c编译,得到的核心 IR 大概是这样:
define i32 @clamp_add(i32 %a, i32 %b) { %s = add i32 %a, %b %cmp = icmp sgt i32 %s, 100 %val = select i1 %cmp, i32 100, i32 %s %cmp2 = icmp slt i32 %val, 0 %ret = select i1 %cmp2, i32 0, i32 %val ret i32 %ret }你需要掌握三个最基本的元素。一是类型系统,i32表示 32 位整数,每条指令都带完整的类型信息;二是强类型指令集,add、icmp、select都是语义非常明确的指令,不存在“读内存通用指令”这种模糊操作;三是函数、基本块、指令三层结构,函数由若干基本块组成,每个基本块是一个直线指令序列,块之间通过跳转指令连接。理解这三根支柱,你读 IR 的速度会立刻上来。
2.3 Pass 管线:优化是一条 IR 到 IR 的加工流水线
LLVM 的优化并不是一个巨型函数把所有事情一次做完,而是拆分成几十个独立的 Pass。每个 Pass 只干一件事:要么做分析(比如统计循环信息、计算别名关系),要么做变换(比如把一段代码替换成等价的更优形式),然后 IR 在流水线上被逐站加工。所谓-O2,本质上就是用opt工具加载一大串固定顺序的 Pass。
几个你迟早会接触到的 Pass 名字:InstCombine 负责各种指令级恒等式化简,s < 0 ? 0 : s这类 clamps 就有专门的简化规则;GVN 做全局值编号,消除冗余计算;Inliner 决定哪些小函数要被内联;LoopVectorize 会把标量循环改写为向量循环。记住一个重点:这些 Pass 全部工作在 IR 层,输入输出都是 IR,机器码生成是后面后端的事。如果你以后想优化自己的 DSL 编译器,大部分时间其实都在写这样的 Pass,而不是去碰汇编生成。
3. 手搓一份 LLVM 15:CMake 配置、编译时长与三个真实翻车点
3.1 构建前的硬性条件:内存、磁盘和基础工具
源码构建 LLVM 不是一件“./configure && make”就能轻松搞定的事,它是我见过对构建机器要求最苛刻的开源项目之一。LLVM 15 的最低要求大致是这样:
| 项目 | 建议配置 | 说明 |
|---|---|---|
| 内存 | 8GB 以上,推荐 16GB | 链接 clang 时单个 C++ 链接进程可能吃掉 5GB+ |
| 磁盘 | 至少 50GB 可用空间 | Release+Debug 构建很容易膨胀到 30-60GB |
| CMake | 3.20 以上 | 太老版本会直接报错 |
| 编译器 | GCC 7.1+ 或 Clang 5+ | 推荐用 Clang 自举,产物性能更好 |
| 构建工具 | Ninja | 并行度和增量构建都远胜 Make |
| 其他 | Python 3、zlib、zstd | 测试系统和压缩组件需要 |
这里有个容易忽略的点:构建 LLVM 自身需要的是一个“能编译现代 C++17 代码”的编译器。如果你的系统 GCC 版本偏老,构建过程中会爆出各种莫名其妙的模板错误,那通常不是源码问题,而是你的编译器该升级了。
3.2 一份可以用到生产环境的 CMake 配置
LLVM 15 的 CMake 配置项非常多,但真正每次都要调的也就那么几个。我常用的配置是这样:
cmake -G Ninja -B build \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_C_COMPILER=clang \ -DCMAKE_CXX_COMPILER=clang++ \ -DLLVM_ENABLE_PROJECTS="clang;lld;clang-tools-extra" \ -DLLVM_TARGETS_TO_BUILD="X86" \ -DLLVM_ENABLE_ASSERTIONS=ON \ -DLLVM_PARALLEL_LINK_JOBS=2 \ -DLLVM_CCACHE_BUILD=ON逐个解释为什么这么设。CMAKE_BUILD_TYPE=Release控制生成的 LLVM 工具自身的优化级别,Release 模式编译最快、运行也最快,是你日常使用最合适的;如果你要做 LLVM 开发调试,可能需要RelWithDebInfo,但千万别默认用Debug,否则编译会慢到怀疑人生。LLVM_ENABLE_ASSERTIONS=ON打开内部断言检查,虽然会让构建产物变慢一些,但对于开发阶段排查问题是无价的。LLVM_TARGETS_TO_BUILD="X86"是加速神器,它告诉 LLVM 只需要生成 X86 后端,否则默认会编译 ARM、RISCV、PowerPC 等一大堆你根本用不上的 Target。
配置完之后,不需要 ninja 全量构建,按需构建目标就行:
ninja -C build clang lld opt clang-format你只需要 clang 和 lld,那就只构建这两个目标,速度会快很多。
3.3 三个我实际撞过的坑:链接 OOM、断言过慢、目标过多
第一个坑是链接 OOM。早期我在一台 8GB 内存的笔记本上构建 clang,全核并行跑,到了链接阶段机器直接卡死。原因就是 clang 这个 C++ 程序本身太庞大,链接单个可执行文件需要数个 GB 内存,并行链接会瞬间把内存打爆。解决方法是-DLLVM_PARALLEL_LINK_JOBS=1,把链接任务串行化,同时如果系统里有 lld,还可以加-DLLVM_USE_LINKER=lld用 lld 做宿主链接器,链接速度快一大截。
第二个坑是 Debug 模式慢成幻灯片。我早期想看优化流程,图省事直接用了默认的 Debug 构建,结果连opt --help都等了好几秒,跑一遍 O2 流水线的时间够我泡三杯咖啡。后来我学乖了,平时分析用 Release+Assertions,真需要调试某个 Pass 时才单独用 RelWithDebInfo 重编那一个目标。经验教训就是:不要把整个工具链都放在 Debug 模式里,调试是点状行为,全量 Debug 是自虐。
第三个坑是目标架构默认全开。如果你完全不设置LLVM_TARGETS_TO_BUILD,LLVM 会默认生成所有官方支持的后端代码,编译时间直接乘好几倍。很多新手第一次构建失败,其实不是报错,而是构建了一晚上还没建完。只需要按需开 X86 或你实际部署的架构,省下的都是实打实的时间。
还有一个针对 LLVM 15 版本特有的小知识:clang、lld 这类粒子项目用LLVM_ENABLE_PROJECTS配置,而 compiler-rt、libcxx 这类运行时组件在 15 里更推荐用LLVM_ENABLE_RUNTIMES单独配置。两个变量写反或者混用,CMake 会给出比较磨人的警告,第一次见容易懵。
4. glxinfo 里的 llvmpipe:从“LLVM 15.0.7,256 bits”看 JIT 与向量化
4.1 llvmpipe 是什么:没有 GPU 时谁在帮你画界面
如果你在 Linux 服务器、虚拟机或者没有安装 GPU 驱动的机器上跑过glxinfo | grep "renderer",大概率见过这样一串输出:
OpenGL renderer string: llvmpipe (LLVM 15.0.7, 256 bits)很多人看到这行字会误以为“系统装了个叫 llvmpipe 的软件”。准确地说,llvmpipe 是 Mesa 图形库中的一个软件光栅化驱动。Mesa 是 Linux 图形栈里 OpenGL/Vulkan 的开源实现,当你的机器没有可用的硬件 GPU 驱动时,Mesa 会退回到软件渲染路径,用 CPU 去执行本来该由 GPU 完成的图形计算。而 llvmpipe 就是这个软件渲染路径里性能最强的那个驱动。
那 LLVM 在里面扮演什么角色?简单说,shader(着色器)是一段小型的并行计算程序,硬件 GPU 里有成千上万个核心来跑它。可你没有 GPU 驱动时,CPU 是通用的标量处理器,要让 CPU 效率足够高地跑 shader,就必须做两件事:把着色器语言编译成 CPU 能执行的机器码,并且尽量利用 CPU 的 SIMD 向量指令来模拟 GPU 的并行宽度。这两件事恰好都是 LLVM 的看家本领。
4.2 从 shader 到机器码:Gallivm 的 JIT 流程
Mesa 内部做了一个叫 Gallivm 的模块,专门负责把 shader 编译成 CPU 指令。流程大体是这样:开发者写 GLSL,Mesa 前端把它翻译成自己的中间表示 NIR;Gallivm 拿到 NIR 之后,逐条翻译成等价的 LLVM IR,比如一个fma运算就映射成 LLVM 的llvm.fma内建函数;紧接着 LLVM 用一套经过裁剪的优化流水线对这个 IR 做优化,再进行向量化;最后通过 LLVM 的 JIT 执行引擎把 IR 变成当前 CPU 的机器码,放入内存直接调用。
这里最妙的地方是,llvmpipe 不是一个解释器,而是一个真正的 JIT 编译器。它生成的代码质量直接决定了软件渲染的流畅度。你玩游戏卡不卡,很大程度取决于 LLVM 有没有把那几条最核心的内层循环向量化到位。这也解释了为什么 Mesa 的 llvmpipe 版本高度绑定特定的 LLVM 版本,因为一旦 LLVM 升级带来更好的代码生成,整个软件渲染器的性能都会跟着受益。
4.3 “256 bits”是怎么来的,以及它为什么重要
渲染器字符串里的256 bits,指的是 llvmpipe 在当前 CPU 上选用的原生向量宽度,单位是 bit。x86-64 平台上一共有三档常见向量宽度:SSE2 是 128 位,AVX/AVX2 是 256 位,AVX-512 是 512 位。llvmpipe 在初始化时会探测 CPU 支持哪些指令集,如果发现支持 AVX2,就会把 256 位作为默认向量宽度,于是 glxinfo 里就出现256 bits这个字样。
这意味着它可以一次把 8 个单精度浮点数打包成一个向量并行计算。假设你在做逐像素颜色计算,一个像素要算 4 个 float,256 位向量一次能处理 8 个 float,相当于两条 SIMD 指令就能覆盖两个像素的核心运算。相比之下,纯标量代码得一条一条地算,性能差距是数量级的。反过来你也可以通过这个字符串判断 llvmpipe 当前用了哪档向量宽度:看到 256 说明 AVX2 生效,如果看到 128 那大概率是运行在只支持 SSE2 的旧 CPU,或者环境变量/编译参数限制了向量宽度。如果你真想给一个无 GPU 的服务器配软件 OpenGL,记得确认 CPU 支持 AVX2,这比任何软件层面的调优都更直接。
5. 15 分钟跑通一个自定义 LLVM Pass:插件化开发的起点
5.1 llvm-project 源码目录的快速定位法
真要动手改 LLVM,第一件事不是写代码,而是学会在源码里找路。llvm-project 的llvm/子目录下有两大块核心:include/llvm/放头文件和 API 声明,lib/放实现。对我这种靠搜索活命的人来说,经常用的路径就几个:
include/llvm/IR/:Instruction、BasicBlock、Function 等核心类定义,几乎所有 Pass 都要用到。lib/Transforms/:中端优化 Pass 的源代码,比如InstCombine/、Scalar/、Vectorize/。lib/CodeGen/:后端代码生成相关,包括寄存器分配、指令选择。lib/Passes/:Pass 管线的注册和编排。
读源码顺序推荐从 IR 模块开始,先搞懂Instruction有哪些常见子类,再到FunctionPass的文档和示例。不要一上来就啃 CodeGen,那是整座冰山的最底层,新手容易被淹死。
5.2 开发一个 Pass 插件并编译加载它
LLVM 15 里,最推荐的起步方式是写插件(plugin),不需要改 llvm-project 源码,也不需要重新编译整个工具链。新建一个demo-pass.cpp:
#include "llvm/IR/Function.h" #include "llvm/IR/Instructions.h" #include "llvm/Passes/PassBuilder.h" #include "llvm/Passes/PassPlugin.h" #include "llvm/Support/raw_ostream.h" using namespace llvm; namespace { struct DemoPass : public PassInfoMixin<DemoPass> { PreservedAnalyses run(Function &F, FunctionAnalysisManager &AM) { for (BasicBlock &BB : F) { for (Instruction &I : BB) { if (auto *Call = dyn_cast<CallInst>(&I)) { errs() << "callsite: " << Call->getCalledFunction()->getName() << " in " << F.getName() << "\n"; } } } return PreservedAnalyses::all(); } }; } // namespace extern "C" LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, "demo-pass", LLVM_VERSION_STRING, [](PassBuilder &PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager &FPM, ArrayRef<PassBuilder::PipelineElement>) { if (Name == "demo-pass") { FPM.addPass(DemoPass()); return true; } return false; }); }}; }这段代码用 LLVM 15 默认的新 Pass Manager 接口,定义了一个“打印所有函数调用点”的 Pass。编译它不需要整个 LLVM 构建产物,只需要已安装的 LLVM 头文件和库:
clang++ -fPIC -shared -std=c++17 \ -I/path/to/llvm-project/llvm/include \ -o demo-pass.so demo-pass.cpp然后对一份 IR 执行:
opt -load-pass-plugin=./demo-pass.so -passes=demo-pass input.ll -S看到每个函数里的调用点被打印出来,你的第一个 LLVM Pass 就跑通了。这个最小闭环的意义在于,你理解了新 PM 下 Pass 的注册、运行、返回PreservedAnalyses的模式。再往深里走,无非就是在这个函数体里写更复杂的分析和变换逻辑。需要注意 LLVM 15 的opt默认就是新 Pass Manager,你上网搜旧教程时如果看到-load ./xxx.so而没有-load-pass-plugin,那多半是讲老 PM 的文章,接口对不上时别慌,查一下你的 LLVM 主版本对应的文档。
5.3 用 lit 与 FileCheck 做测试
自己写 Pass 不写测试,跟没写差不多。LLVM 官方测试框架是 lit + FileCheck,思路极其直接:测试文件里用RUN:行描述要执行的命令,然后让 FileCheck 去匹配输出。举个例子,为上面的 DemoPass 建一个测试文件:
; RUN: opt -load-pass-plugin=%T/demo-pass.so -passes=demo-pass -S < %s | FileCheck %s define i32 @main(i32 %x) { %r = call i32 @helper(i32 %x) ret i32 %r } declare i32 @helper(i32) ; CHECK: callsite: helper in main%T会指向测试输出的临时目录,%s是当前测试文件自身。FileCheck 逐行寻找匹配,CHECK那行指定了我们期望看到的输出。把测试文件放进llvm/test/对应的子目录里,然后用ninja -C build check-llvm跑全量测试,或者用llvm-lit单跑这一个文件。这套流程看起来简单,但它是你今后提交代码的基本门槛,LLVM 社区对测试覆盖率的要求非常高。
6. 什么情况该自编译 LLVM,什么情况该直接用发行版
6.1 什么时候“apt install llvm-15”就够了
如果你的头号目标是“用 clang 编译我的 C/C++ 项目”,那完全没有必要源码构建。各主流发行版都有打包好的 LLVM 版本,比如 Debian/Ubuntu 上的llvm-15、clang-15、lld-15等软件包,装完直接用,版本配套、补丁也打好了。这个场景下的核心诉求是稳定可用的编译器工具链,而不是自己折腾环境。类似的还有 macOS 用户,直接使用系统自带的工具链或者 Homebrew 的 llvm 包即可。很多人在这一步纠结“我要不要为了学 LLVM 源码而自编译全套”,我建议不要混为一谈:学源码可以只读仓库代码,不必先全量构建再开始学;只有当你确实需要修改编译器的行为时,构建才变得必要。
6.2 需要自编译的三种典型场景
第一种是需要精确版本控制。发行版里的 LLVM 往往不是你想要的版本,比如 Mesa 的 llvmpipe 链接的 LLVM 版本和系统提供的不一致,或者某个显卡驱动的编译器后端要求特定 minor 版本。这种情况自编译一个带LLVM_VERSION_MAJOR匹配的版本会比强行给发行版打包补丁省心得多。
第二种是你要给 LLVM 打补丁或者扩展新功能。比如给目标后端添加一个自定义指令支持,或者想改某个 Pass 的优化策略。这种场景下你不仅要构建,还必须构建得能被你增量修改,所以会用 ccache 缓存编译产物,用LLVM_CCACHE_BUILD=ON配合ninja实现很快的增量构建。
第三种是你要把 LLVM 作为库嵌入自己的产品。比如你做一个编程语言,要用 LLVM 做 JIT 后端,那你链接的是libLLVM库,需要自己定制构建选项,还要注意把不需要的目标架构裁掉以减小体积。这时候发行版的包通常就不够灵活了,自己构建是一种刚需。
6.3 如何判断自己编译出的库版本是否真的生效
很多人在这一步踩坑:自己编译了一个 LLVM,但运行程序时发现系统还是调用了旧版本。判断方法很简单,先确认你安装的前缀:
/path/to/llvm/build/bin/llvm-config --version /path/to/llvm/build/bin/clang --version如果你写代码链接的是源码构建出的libLLVM.so,建议直接用绝对路径引用库,或者在 Linux 下临时把LD_LIBRARY_PATH指到构建输出目录,避免动态链接器捡到系统旧货。至于 llvmpipe 那种场景,glxinfo 里显示的是 Mesa 编译时检测到的 LLVM 版本,通常也是运行时链接的版本,但如果你想验证实际加载的是哪个库,可以用ldd看 Mesa 驱动文件里 LLVM 的解析路径,把“编译期版本”和“运行库版本”对一下,不一致时优先解决库加载路径。
最后分享一个我自己的习惯:每当我在陌生环境拿到一份 llvm-project,不会急着全量构建,而是先跑一遍llvm-config --host-target --assertion-mode检查默认配置,再用ninja -t targets | grep clang看看有哪些可用构建目标,随后按需构建最小集。先把“最小可用闭环”跑通,再往里加需求,这个思路能帮你省下大把无意义的编译等待。编译器这个领域看着吓人,其实门槛就藏在那些没人愿意讲清楚的构建细节里,迈过去之后,你会发现自己读任何开源编译器代码都从容得多。