只要你是写代码的,肯定在各种工具链的角落里见过 LLVM 这个名字。但你可能和我最初一样有个困惑:LLVM 全称 Low Level Virtual Machine,听着像虚拟机,怎么实际上干的全是编译器的活?而且不光 C/C++ 用它,Rust、Swift、Julia 这些语言也要靠它,连 GPU 驱动、软件渲染器、SQL 数据库里都能看到它的影子。这里头其实有一条很有意思的演化线:LLVM 诞生时确实用过“虚拟机”的概念,可后来整个项目彻底转向编译器基础设施,名字保留了下来,内容早就不是原来的剧本了。
这篇文章,我想把这些年摸 llvm-project 的经验整理成一张“地图”。它的仓库里到底装了哪些子项目、版本号 15.0.7 这种补丁版本处在哪个历史坐标上、SIMD 优化里的 256 位到底指什么、Rust 和 llvmpipe 这些相差十万八千里的项目是怎么都挂在 LLVM 名下的,以及最关键的——一个普通开发者从哪入手才能看懂这套庞大的代码库。全文会用工程师唠嗑的语气来写,尽量不端着,适合对编译原理有兴趣、但还没深入源码的读者。
1. LLVM 是什么:从“虚拟机”到编译器基础设施之王
1.1 名字的由来,以及它坑了多少人
LLVM 项目始于 2000 年前后,发起人是当时还在 UIUC 读博的 Chris Lattner。他早期的想法确实和“虚拟机”沾边:设计一种可以在程序整个生命周期里反复使用的中间表示(IR),配合运行时进行持续优化。受 Java 虚拟机和动态编译思想的启发,他给项目起名叫 Low Level Virtual Machine。但随着时间推移,真正让 LLVM 赢得开发者认可的,并不是某个具体的虚拟机实现,而是那套“前端产出 IR、中端做优化、后端生成机器码”的编译器架构。于是虚拟机的定位逐渐被剥离,名字却一直用到了今天,随后演变为 llvm-project 这个巨型仓库。
所以很多第一次接触的人都会犯同一个错误:听到 LLVM 就想找它的 JVM 或者 V8 那种运行时。其实 LLVM 和这些东西完全是两条路线。它不执行代码,它生成代码,而且生成的是经过精心优化的本地机器码。可以把它想象成一家翻译公司:英语、日语、德语之间互译需要六条路径,但现在大家约定先翻译成一种万能中间文档,再从中间文档翻译成目标语言,两条路径就解决了所有组合。LLVM IR 就是那个万能中间文档,Clang 是负责把 C/C++ 翻译成这份文档的前端,后端则负责把文档再变成 x86、ARM、RISC-V 等不同的机器指令。
1.2 三段式架构为什么能颠覆编译器行业
在 LLVM 之前,编译器的主流模式是 GCC 那种“整体式设计”。每种语言就是一个完整的前端、优化器、后端耦合在一起的编译器,新增一门语言要从头写一整条工具链,新增一个 CPU 架构也要在多个编译器里分别适配。语言和硬件的组合是乘积关系,维护成本随着生态膨胀越来越不可控。
LLVM 把这条路改成了三段式:前端(Frontend)、中端(Optimizer)、后端(Backend)。前端负责把源代码翻译成 LLVM IR;中端针对 IR 做无数遍优化pass;后端再根据目标 CPU 把 IR 降级成机器码。这里最关键的是 IR 成了可复用的交换格式。前端不管后端有多少,只需把语义准确翻译成 IR;后端也不用了解 Swift 的 optional 或者 Rust 的所有权,只需要把 IR 优化好后映射到指令集。前端和后端彻底解耦,编译器从“一个黑盒二进制”变成了可以自由组合的零件。
这种设计听起来简单,做起来极其困难。LLVM IR 要足够底层才能承载 C、C++、Rust、Swift 等各种语言的不同语义,又要足够高层才能让优化器发现共同套路。它用 SSA(静态单赋值)形式表示所有变量只赋值一次,配合显式的控制流图,让数据流分析变得简单可靠。当前端把源码降级成这种 IR 后,任何语言写的循环都能被同一套循环优化、向量化、内联、常量传播所覆盖,这才是 LLVM 真正的护城河。
1.3 llvm-project 仓库里到底装了什么东西
很多人以为 llvm-project 只是一个编译器,实际上它是一整套工具链生态。光看顶层目录就能吓一跳:
| 子项目 | 一句话说明 | 典型应用场景 |
|---|---|---|
| LLVM 核心库 | 提供 IR、pass 优化框架、指令选择、寄存器分配、JIT 等基础能力 | 想写编译器的任何项目 |
| Clang | C/C++/Objective-C 前端,也是可复用的编译库 | 替换 gcc,重构工具、静态分析 |
| clang-tools-extra | 围绕 Clang 的小工具集合,比如 clang-tidy、clang-format | 代码风格检查、自动化重构 |
| LLD | 高性能链接器,比传统 GNU ld 快很多 | 大规模 C/C++ 项目链接 |
| libc++ / libc++abi | C++ 标准库实现 | 现代 C++ 项目运行时 |
| compiler-rt | 运行时支持库,包括 sanitizer、profile、builtin | 内存检查、覆盖率、编译指令补全 |
| MLIR | 多层 IR 基础设施,专为异构编译和 AI 编译器设计 | TensorFlow/PyTorch/IREE 等编译器 |
| Flang | Fortran 前端 | 老科学计算代码迁移 |
| Polly | 基于多面体模型的循环优化 | 高性能计算和自动并行化 |
| lldb | 调试器,提供库化调试能力 | 替代 gdb,集成到 IDE |
平时大家口语里说“我装了 LLVM”,多数时候装的就是这个仓库的一块或多块组合。官方发布时会统一打 tag,比如 llvmorg-15.0.7,指的就是某个时间点整个仓库的稳定快照。明白这一点,你再看网上那些“LLVM 15.0.7 发布了”的消息,就知道它不是单个工具的新版本,而是整套编译器基础设置的一次整编。
2. 版本演进与 LLVM 15.0.7 的坐标
2.1 LLVM 的版本节奏:每年一个大版本,API 从不客气
LLVM 的版本规划和很多开源项目不太一样。它保持每年一个主版本的节奏,比如 14、15、16 一直到现在的 21,时间点基本都在每年 9 月到 12 月之间。15.0.0 大约在 2022 年发布,之后会有一串补丁版本:15.0.1、15.0.2 一直到 15.0.7,这类补丁版本集中修复前一个主版本周期内被发现的功能回退、崩溃问题和代码生成质量问题。
更关键的是,LLVM 官方在版本迭代时很少保证 API 兼容。你的第三方 pass 是用 LLVM 14 写的,直接拿到 LLVM 17 下重新编译,大概率会编译失败。这个策略和 GCC 那种“尽量保持稳定”的思路截然不同。对 LLVM 核心团队来说,他们宁可牺牲向后兼容,也要让内部接口保持演进速度。这就导致所有基于 LLVM 做二次开发的公司,比如各种芯片厂商的工具链、编程语言项目、数据库 JIT 引擎,都必须跟着上游节奏不断适配。
从版本演进的大历史看,有几个节点特别重要。早期 LLVM 3.x 稳定了 IR 和 pass 框架;13 到 14 之间新 pass 管理器彻底上位;14 到 15 之间 opaque pointer(不透明指针)逐渐成为默认;17 左右彻底移除旧指针类型代码;19 之后则迎来了 Orc JIT 的大规模重构和更灵活的 target 扩展机制。LLVM 15.0.7 正好处于“新 pass 管理器和 opaque pointer 全面切换后”的第一个稳定窗口,对很多长期维护的系统项目来说,是一个值得记录的坐标。
2.2 为什么大家都在找 15.0.7 这个补丁版本
主版本和补丁版本的价值差异,只有被某个 bug 坑过的人才能真正理解。15.0.0 刚发布时,编译器领域的热度都在新特性和新 API 上,但真正在公司 CI 里跑上几天,各种在特定 CPU 上生成错误代码、特定模式下链接失败、记录型 debug 信息错乱的问题就会浮出水面。从 15.0.1 到 15.0.7,LLVM 团队逐个月修复这些回归,15.0.7 是 15 系列的最后一个补丁版本。
对普通用户而言,如果你的项目因为某条老依赖链绑定在 LLVM 15 上,千万不要停留在 15.0.0 或者 15.0.3,直接上 15.0.7 能少踩很多莫名其妙的问题。而且很多预编译包、系统发行版、第三方工具链,比如某些图形驱动的软件渲染模块,也倾向于固定在一个补丁版本上做测试。你会在各种软件日志里看到“LLVM 15.0.7”这种字样,它代表那个软件依赖的编译器组件是经过充分修整的稳定快照。
2.3 日志里的“256 bits”到底是什么
搜索词里出现的“llvmpipe (llvm 15.0.7, 256 bits”,看起来像某个环境在启动软件渲染器时打印出来的信息。这里的 256 bits 通常指的是 SIMD(单指令多数据)向量宽度。现代 CPU 除了能一条指令处理一个数据,还能用向量寄存器同时处理多个数据。x86 平台上的演进路线是:SSE 提供了 128 位寄存器,AVX 扩展到 256 位,AVX2 进一步补齐整数向量指令,AVX-512 则是 512 位寄存器加掩码等特性。
256 位寄存器意味着一条指令可以同时操作 8 个 32 位整数、8 个 float、或者 4 个 double。对编译器来说,它能否充分利用这一点,直接决定了在图像处理、科学计算、音视频编码这些任务里的性能上限。llvmpipe 这类软件渲染器会在运行时检测 CPU 是否支持 AVX2,支持的话就让 LLVM 生成使用 256 位 SIMD 路径的代码,不支持就回退到 128 位 SSE 路径。别小看这 128 到 256 的差距,像素填充和光栅化逻辑一旦向量化,吞吐量往往能差出接近一倍。
我这里稍微展开讲讲 LLVM 是怎么用上这些宽度的。你写一个简单的循环:
void add_vec(float *a, float *b, int n) { for (int i = 0; i < n; i++) a[i] += b[i]; }如果编译时开启-O2 -mavx2,LLVM 的 LoopVectorizer 会分析这个循环是否有循环携带依赖,是否适合向量化,然后尝试把每次迭代处理一个 float 改成处理 8 个 float。生成的代码里会看到 ymm 寄存器和对应的向量加指令。编译器要做好数据对齐检查、循环尾处理,还要在无法证明内存安全时生成运行时检查,这背后就是几十个 pass 协同工作的结果。
3. LLVM 如何改写编译器生态
3.1 编程语言新贵们为什么首选 LLVM
过去十几年里,几乎所有新出的系统级编程语言都走了同一条路:前端自己写,后端交给 LLVM。Rust 的 rustc 编译器先把 Rust 源码转换成自己的 HIR 和 MIR,再降级成 LLVM IR,后续优化和代码生成直接复用 LLVM;Swift 从诞生起就跟 LLVM 深度绑定,甚至专门设计了 SIL 层来衔接 Swift 的高层语义和 LLVM IR;Julia 这种动态语言更极端,直接在运行时用 LLVM 的 JIT 把函数编译成机器码,让动态脚本获得接近 C 的速度;Kotlin/Native、Zig、Crystal 的前端也大多是围绕 LLVM 做对接。
为什么大家不约而同选择 LLVM?因为写一个高质量代码生成器的成本实在太高了。如果你想在一门新语言里实现对数十种 CPU 架构的支持,同时还能拿到循环优化、向量化、内联、寄存器分配这些成熟能力,自己从零写后端基本等于重新发明一遍编译器。用 LLVM 后,一个几十人的语言团队可以把精力全部放在类型系统、语义分析、标准库这些真正有语言特色的部分,编译产物质量直接继承 LLVM 十多年的工程沉淀。可以说,没有 LLVM,Rust 和 Swift 想达到今天的成熟度,至少得多花好几年。
3.2 苹果、谷歌与整个系统级工具链的转向
LLVM 对行业的改写,不只是体现在小众语言圈。苹果从 Xcode 时代开始就逐步把默认编译器从 GCC 切换到 Clang/LLVM,到了现代 macOS 和 iOS 上,整个系统的构建都重度依赖 LLVM 工具链;谷歌在 Android NDK 里也早已把默认编译器换成了 Clang,Fuchsia 的开发更是一步到位,使用了大量 LLVM 组件;Windows 之外的很多开源社区软件,也越来越习惯用 Clang 而不是 GCC 来编译。
可能在普通用户看不到的地方影响更大。GPU 编译器领域,AMD 的 ROCm 编译栈、Intel 的 oneAPI 都大量采用 LLVM 技术路线;RISC-V 芯片的软件生态起步时,第一件事就是先把 LLVM 后端跑通;很多商业数据库、数据分析引擎为了把 SQL 表达式编译成本地代码,直接把 LLVM 当 JIT 引擎嵌在自己的执行引擎里。LLVM 已经从“编译器爱好者玩的库”变成了芯片厂商、互联网大厂、数据库公司共同押注的基础设施,这种地位在开源历史上没有几个项目能比。
3.3 图形学里的意外应用:llvmpipe 软件渲染器
聊到 llvmpipe,其实很多人没意识到,LLVM 能“编译”的东西远不止 C/C++ 和 Rust。llvmpipe 是 Mesa 项目里的一个纯软件实现图形渲染器,在没有 GPU 的环境下也能跑 OpenGL 和部分 Vulkan。它的工作方式是:把 GLSL/Vulkan shader 翻译成中间表示,然后在运行时交给 LLVM 的 JIT 引擎,动态生成针对当前 CPU 特性的机器码。你可以理解为它把“画三角形”和“算颜色”这件听起来很图形学的事,变成了一次编译器 JIT 的过程。
“llvmpipe (llvm 15.0.7, 256 bits”这行日志,翻译成人话就是:当前软件渲染器由 LLVM 15.0.7 驱动,并使用 256 位 SIMD 路径做加速。它默认检测 CPU 对 AVX2 的支持情况,支持就生成 ymm 寄存器的代码,一条指令处理 8 个 32 位像素分量;不支持就退回 128 位 SSE。为什么不能用 C++ 手写硬编码来实现这些 shader?因为每个 shader 的状态、输入参数、输出格式千变万化,手写模板覆盖不住所有组合。用 LLVM 的 JIT,正好可以针对具体场景即时拼装机器码,比解释器快好几个数量级,又比预编译模板灵活得多。这个例子特别能说明 LLVM 的应用边界早就不在“编译器”这个标签下了。
4. 从零看懂 llvm-project:目录地图与动手实验
4.1 先记住几个高频出现的目录
直接扎进 llvm-project 的源码树会感觉像进了迷宫,但核心模块其实就那么几个。顶层目录里最常打交道的是这几个:
llvm/ include/llvm/IR/ IR 定义:Instruction、BasicBlock、Function、Module lib/Transforms/ 优化 pass 的实现 lib/Target/ 各种后端:X86、AArch64、RISCV 等 lib/CodeGen/ 指令选择、寄存器分配、指令调度 tools/opt/ opt 命令行工具入口 clang/ lib/AST/ 抽象语法树的构建与操作 lib/Sema/ 语义分析 include/clang/ 给开发者的头文件 lld/ lib/ 链接器各阶段实现 mlir/ include/mlir/IR/ MLIR 的 IR 基类如果你只是想理解 LLVM 如何把源代码变成机器码,最优阅读顺序是:先看llvm/include/llvm/IR里的核心类定义,再挑lib/Transforms/InstCombine这种典型 pass 读一读,最后用opt和llc把实际代码过一遍,看看优化前后 IR 的变化。刚开始不建议直接读后端代码,那里头寄存器分配和指令调度的细节非常劝退。
4.2 最容易上手的三个命令行实验
没有比直接跑工具更适合理解 LLVM 的方式了。用 Clang 把 C 文件翻译成 IR:
clang -S -emit-llvm hello.c -o hello.ll生成的 .ll 文件是文本格式的 LLVM IR,打开后能看到 define、load、store、call 这些指令。你可以看看一个简单的 main 函数经过优化后变成什么样,再对比不同优化级别下 IR 的复杂度差异。如果想看控制流图,用 opt 生成 dot 文件:
opt -passes=dot-cfg hello.ll -o /dev/null这样会在目录下生成 .dot 文件,再用 dot 命令转成 png 或者 pdf,你就能非常直观地看到基本块之间的关系。还有一个办法能搞清楚某段 C 代码到底被哪些优化处理过:
clang -O2 -mllvm -debug-pass=Arguments hello.c -c -o hello.o这串命令会把 -O2 对应的 pass 序列全打出来。看到那些 pass 名字以后,再去源码里搜对应的实现,比从头到尾通读源码高效得多。
4.3 亲手写一个最小的 LLVM pass
想真正入门 LLVM 开发,绕不开写一个自定义 pass。下面是我在 LLVM 15 左右的版本上验证过的最小例子,用新 pass 管理器插件的方式,遍历所有函数里的指令并打印操作码:
#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 &) { for (auto &BB : F) { for (auto &I : BB) { errs() << "opcode: " << I.getOpcodeName() << "\n"; } } return PreservedAnalyses::all(); } }; } // namespace PassPluginLibraryInfo getPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, "demo", LLVM_VERSION_STRING, [](PassBuilder &PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager &FPM, ArrayRef<PassBuilder::PipelineElement>) { if (Name == "demo") { FPM.addPass(DemoPass()); return true; } return false; }); }}; } extern "C" ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return getPassPluginInfo(); }编译并加载的流程很简单,先有插件动态库,再用 opt 在 IR 上跑:
clang++ -fPIC -shared demo.cpp $(llvm-config --cxxflags --ldflags --libs) -o demo.so opt -load-pass-plugin ./demo.so -passes=demo hello.ll -o /dev/null第一次跑通这个例子的时候,你大概会有一种“编译器也没那么神秘”的感觉。把代码改一改,比如统计某个函数有多少条加法指令,或者看看哪些函数没有调用任何外部函数,这个小小的 pass 就是你进入 LLVM 内部世界的钥匙。
4.4 给入门者的避坑建议
我踩过的坑,总结下来有下面几条。第一,如果要开发 LLVM 插件或学习源码,建议用 Debug + Asserts 的构建方式。Release 版会把很多内部断言和调试工具关掉,跑出来问题你也看不出来,等到了机器码层面再排查就非常痛苦。构建命令大致是:
cmake -G Ninja -S llvm -B build -DCMAKE_BUILD_TYPE=Debug -DLLVM_ENABLE_ASSERTIONS=ON ninja -C build第二,千万别用系统全局安装的 libLLVM 来编插件。插件是用 opt 加载的,要求插件链接的 LLVM 版本、API 宏跟 opt 完全一致,混了就会报无法加载或者直接段错误。一定要用你自己编译出来的 llvm-config 去取值。第三,网上很多教程还停留在旧版 pass 管理器(Legacy PM)的写法,新版本默认用 New PM,两者接口差非常多,拷贝代码前先看清楚用的哪个版本。第四,llvm-project 编译时间很长,机器内存不够的话链接阶段容易爆,尽量用 Ninja 并且控制并行任务数,别把编译机器直接跑死。
5. LLVM 对今后几年技术方向的影响
5.1 多 CPU 架构时代,一套 IR 通吃的价值
过去十几年,大家写代码默认面向 x86,交叉编译是少数嵌入式工程师的事。但现在 ARM 服务器、RISC-V 开发板、各种专用 AI 芯片到处都是,一个产品要同时支持 x86、ARM、RISC-V 成了常事。在这种多架构并存的背景下,LLVM“前端-中端-后端”解耦的优势被放大到极致。
因为 IR 是统一的,语言团队只需对接 LLVM,就能一键支持所有后端;芯片厂商也可以只专注于实现 target 描述和指令选择,不用重新发明一套完整的编译器前端。对开发者的直接影响是:同一个 Rust 项目、同一个 C 工程,用 clang 编译时加个--target参数就能交叉编译到另一个架构,优化能力还保持一致。我没有夸张,这套体验在十年前几乎不可想象,LLVM 把“同一套工具链通吃所有硬件”变成了行业默认配置。
5.2 MLIR 与 AI 编译器的爆发
如果只能挑一个 LLVM 项目对未来的影响,我会投 MLIR。AI 芯片这几年碎片化严重,GPU、NPU、TPU、FPGA 各有各的指令集和内存层次,传统编译器很难在这么高的抽象层级上做统一优化。MLIR 的思路是在 LLVM IR 之上再架一层“多层 IR 基础设施”,让你可以按领域定义自己的中间表示,从计算图级别一直降级到循环级别,最后再接到 LLVM 后端。
TensorFlow、PyTorch 的编译栈、IREE、各种大模型推理引擎,几乎都在用 MLIR 搭建自己的编译管道。它让“为某个硬件写编译器”从一项博士级工作,变成可以复用组件的工程任务。以后你看到某个新芯片说支持 PyTorch 模型部署,背后很可能就是一条 MLIR 管道。这等于把 LLVM 的“三段式”思想再次延伸到 AI 领域,编译器基础设施这个概念的范围又被撑大了一圈。
5.3 软件渲染不再是“兜底方案”
llvmpipe 这类软件渲染器的价值,过去通常被理解为“没有 GPU 时凑合用”。但在云桌面、无头渲染、CI 测试、虚拟化环境这些场景里,软件渲染往往是刚需。云上 CPU 资源便宜,几十个核并行跑 CPU 渲染,配合 LLVM JIT 动态生成的 SIMD 代码,在很多工作负载上已经接近中低端 GPU 的效果。
关键是 LLVM 的 JIT 能力给了软件渲染器一个非常难得的特性:针对性。它在运行时才知道 CPU 具体支持哪些指令集,才能为特定 shader 生成高度特化的代码。这种“你是什么 CPU,我就给你生成什么代码”的动态优化,是预编译二进制做不到的。我判断未来几年,随着 AI 推理的 CPU 端加速、云游戏和虚拟桌面持续加码,llvmpipe 这种“LLVM 驱动的软件侧门”还会出现在更多意想不到的地方。
最后分享一个我自己的体会:llvm-project 最让人敬畏的地方不是代码量大,而是每段代码背后都压着几十年的工程决策。真正让我入门的是拿优化 pass 开刀,写了一个打印指令的小插件,然后对着-O2打出来的 pass 序列挨个去读源码。建议新手别一上来就啃 IR Builder 那套复杂 API,先跑测试、再改几十行的 pass、再试着提交一个修文档或者补测试用例的小 pull request,整个过程不需要你什么都会。你只需要在某个小角落先钻进去,剩下的地图会随着时间慢慢打开。