做编译器底层这事,绕不开的一个名字就是 llvm-project。严格说它不是一个单独的编译器,而是一整套编译器基础设施,覆盖了从源码前端、中间表示、优化、代码生成到链接器、调试器、标准库、测试框架几乎完整工具链的巨型工程。现在主流的高性能语言和工具链,Rust、Swift、Julia、CUDA、还有各种国产芯片的编译栈,底层都有它的影子。这篇文章不聊虚的,直接拆解这个工程的核心架构、编译流程、实操方法,把新手入门时最需要搞明白的东西一次说透。
我最早接触 llvm-project 是给一个异构计算项目做自定义指令集支持,那时候对着几十万行代码一脸懵,啃了很长时间才摸到门路。回过头看,最佳路径不是直接冲到源码深处,而是先把它的设计哲学和代码分布搞清楚。
1. 核心架构解构:为什么 LLVM 能统治编译器的半边天
1.1 传统编译器的痛点,LLVM 是怎么绕过去的
传统编译器像 GCC,前端、优化器、后端是强耦合成一个整体的。你想支持一门新语言,通常意味着从词法分析到寄存器分配全部重写,工程量极大。而你想要给一个新 CPU 架构做支持,同样要面对一套庞大的、跟前端逻辑交织在一起的代码库。
LLVM 的核心思路是把编译器拆成三段:前端、中端优化、后端代码生成,中间用一套统一的中间表示(IR)做数据交换。前端只负责把源码变成 IR,后端只负责把 IR 变成目标机器码,优化器则在 IR 上做各种与语言无关、与架构无关的转换。
这个设计带来的直接好处是:每新增一门语言,只需要写一套新的前端,把 AST 翻译成 IR,就能复用整个优化管道和后端代码生成。每新增一个 CPU 架构,只需要实现一套新的后端,就能接住所有已经有前端的语言。这套插件式的架构,让我第一次接触到的时候就感觉像把一个巨大的单体巨石拆成了一块块模块化的积木,后面的所有工作都被盘活了。
1.2 LLVM IR:整个工程的心脏
真正让 llvm-project 区别于其他编译器项目的关键,是它的 IR 设计得够好。这套中间表示有几个特点非常重要:
- 静态单赋值(SSA)形式:每个变量只被赋值一次,这是现代编译器做数据流分析的基础,很多优化算法写起来会非常顺手。
- 强类型系统:IR 里每个值都有明确的类型标注,比如
i32、ptr、<4 x float>这样的向量类型,后端可以据此直接生成目标指令。 - 无限寄存器假设:IR 层面的虚拟寄存器是无限的,只有到了后端做寄存器分配时才考虑真实硬件资源,这让优化阶段不需要过早关注架构细节。
举个例子,你写了一段简单的 C 代码:
int add(int a, int b) { return a + b; }用 clang 生成对应的 LLVM IR,会看到类似这样的结构:
define i32 @add(i32 %a, i32 %b) { entry: %add = add nsw i32 %a, %b ret i32 %add }这里面%add就是一个 SSA 临时值,类型是i32,指令add nsw告诉优化器这是一个 signed 且不溢出的加法,可以放心做算术优化。后端拿到这段 IR,再结合目标架构寄存器数量和指令约束,完成最终的指令选择、调度、寄存器分配、指令布局。
理解 IR 是读懂 llvm-project 的第一步,也是写优化 Pass 的根基。
1.3 子项目地图:llvm-project 里到底有什么
很多人以为 llvm-project 就是一个编译器仓库,实际上下载下来之后你会发现它是一个多项目共存的 monorepo。架构规划层面看,项目整体指的是一个统一的大仓库,里面包含:
| 子项目 | 作用 | 形态 |
|---|---|---|
| LLVM | 核心库、优化器、目标后端、IR 框架 | 静态库 + 工具链 |
| Clang | C/C++/Objective-C 前端 | 可执行程序 |
| LLD | 链接器 | 可执行程序 |
| LLDB | 调试器 | 可执行程序 |
| compiler-rt | 运行时支持库,如 sanitizer、builtins | 库 |
| libc++ | C++ 标准库实现 | 库 |
| libc++abi | C++ ABI 支持 | 库 |
| libunwind | 栈展开库 | 库 |
| MLIR | 多层次编译器基础设施 | 库 + 工具 |
| Flang | Fortran 前端 | 可执行程序 |
| polly | 多面体优化框架 | 插件 |
| openmp | OpenMP 运行时与编译支持 | 库 |
| lldb 与 clang-tools-extra | 包含 clangd、clang-format 等 | 可执行程序 |
用的时候不必全部构建。大部分场景只需要构建clang和lld就够用了,如果做编译器和 IR 研究,或者自己写 Pass,才需要把 LLVM 核心库完整编译出来,再通过llvm-config或 CMake 包把自己的代码链上去。这一点一开始就要搞清楚,不然全量构建非常耗时,启动成本过高。
2. 编译流程与核心部件:从源码到机器码的一次完整旅程
2.1 一条 C 语言代码是怎么变成可执行程序的
先看一次完整编译的链路,这样后面再深入某一部分时心里有图。
- 预处理:clang 会先处理
#include、#define这些,把纯逻辑代码抽出来。 - 词法与语法分析:把源码拆成 token,再构造成 AST。
- 语义分析与 IR 生成:Clang 把 AST 转成 LLVM IR,这个环节会做一部分语言层面的类型检查和常量折叠。
- 中间端优化:优化器在 IR 上跑一系列 Pass,比如内联、循环展开、常数传播、死代码消除。
- 后端代码生成:优化后的 IR 进入 SelectionDAG 或 GlobalISel,经过指令选择、指令调度、寄存器分配最终变成汇编。
- 汇编与链接:汇编器把汇编变成目标文件,LLD 把多个目标文件和库链接成最终的可执行文件。
其中第 4 步和第 5 步你未必能直接看见,but是 llvm-project 里代码量最大、也最复杂的部分。
用一条命令就能直观看到 IR:
clang -O2 -S -emit-llvm foo.c -o foo.ll生产环境中大家常用的是:
clang -O2 foo.c -o foo加上-v参数还能看到完整的命令调用链,建议新手都跑一遍,把每一步对应到前面列的编译流程里,比自己硬读源码更有效。
2.2 优化层级和 Pass 机制:O0、O1、O2、O3 背后是什么
-O0、-O1、-O2、-O3对应的是不同优化等级就是一组预设好的 Pass 管道组合。实际控制它们的是一个叫 PassBuilder 的东西,它根据优化等级把顺序串起来。
常见的 Pass 大致分两类:分析 Pass 和变换 Pass。
- 分析 Pass:不修改代码,只收集信息,比如计算循环深度、函数调用图、别名分析。它们的结果会被变换 Pass 使用。
- 变换 Pass:真正改写 IR,比如 DeadCodeEliminationPass 会删除不可达代码,AlwaysInlinerPass 会把小函数直接嵌到调用点。
我想起第一次写 Pass 时,最深的体感是:不能只写变换逻辑,要先调用分析接口拿到依赖信息。比如你做 LICM(循环不变代码外提),先判断某个指令在循环里是否真的循环不变,实现依赖的是 DominatorTree 和 LoopInfo。分析 Pass 的结果作为缓存存下来,变换 Pass 需要时直接查询。
不同优化档位之间的差别比较明显:
| 优化档位 | 主要侧重点 | 典型场景 |
|---|---|---|
| O0 | 无优化,编译最快,调试体验最好 | 调试开发阶段 |
| O1 | 做基本优化,编译速度和代码质量折中 | 简单发布、嵌入式开发 |
| O2 | 大量优化,显著提升运行效率,编译时间适中 | 通用发布版本 |
| O3 | 在 O2 基础上继续做向量化、重排等激进优化 | 性能敏感型计算 |
| Os/Oz | 面向体积优化,Oz 比 Os 更极端 | 固件、移动端包体优化 |
2.3 代码生成与寄存器分配到底在干什么
再往下钻就是后端,这部分可能是 llvm-project 最劝退新人的地方,因为它涉及到很多硬件细节。你不需要一上来就把所有后端都搞懂,但至少要知道几个核心模块。
指令选择:把 IR 中的一些模式映射到目标架构的指令上。比如一个add i32在 x86 上会对应到addl指令,在 AArch64 上对应到add w指令。传统实现用的是 SelectionDAG,模式匹配规则非常复杂,而新一点的 GlobalISel 采用了更模块化的方式,逐步替换旧方案中难以维护的部分。
寄存器分配:这一步负责把无限的虚拟寄存器映射到有限的物理寄存器上。LLVM 默认的线性扫描分配器精度也够用,对于性能极敏感的场景也可以用图着色分配器。寄存器分配失败时,通常会把临时值 spill 到内存栈上,这个过程对性能影响非常大,所以优化 Pass 在半程会刻意减少寄存器压力。
指令调度:重排指令以减少流水线停顿、提高指令级并行的结构。这里就看出为什么需要目标描述文件(td 文件)来描述指令特性,后端生成器会把 td 文件转换成大量 C++ 代码。
对多数普通使用者,这些内容知道到什么程度大概就够了:llvm-project 的后端是极其结构化的,硬件支持被抽象为一层层的描述文件,你不需要把每一条汇编指令的手写逻辑都背下来,只需要理解新增架构时要补哪些文件的模式。如果是自己做 CPU 或者做指令集扩展,一般就是去llvm/lib/Target/下复制一个相近的 Target 目录,改 td 文件、改指令选择规则,重新构建。
3. 实操:把 llvm-project 跑起来并写一个自己的优化 Pass
3.1 获取源码与版本选择:别一上来就追 trunk
llvm-project 的官方仓库地址是 GitHub 上的 llvm/llvm-project,整个仓库 clone 下来时有几十万甚至上百万次提交,全量下载体积很大,很有耐心地等待可能也要一阵子。
版本选择方面,我的经验是:
- 做稳定开发,优先用最近的 release branch,比如 llvmorg-18.1.8,版本号固定,API 稳定。
- 追新特性和新后端支持才用 main 分支,但也要有不断适应 API 变化的准备。
- 用 git 做稀疏检出,只 checkout 需要的部分,能节省不少时间和磁盘。
最省事的方式是直接从 GitHub Releases 下载 tar.xz 源码包,再用 cmake 构建。
wget https://github.com/llvm/llvm-project/releases/download/llvmorg-18.1.8/llvm-project-18.1.8.src.tar.xz tar -xf llvm-project-18.1.8.src.tar.xz cd llvm-project-18.1.8.src3.2 构建配置:CMake + Ninja + ccache 是标配组合
如果你只想要 clang 和 lld:
cmake -S llvm -B build \ -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DLLVM_ENABLE_PROJECTS="clang;lld" \ -DLLVM_TARGETS_TO_BUILD="X86;AArch64" \ -DLLVM_USE_LINKER=lld这里有几个参数解释一下:
-DLLVM_ENABLE_PROJECTS:选择需要构建的子项目,用分号分隔。-DLLVM_TARGETS_TO_BUILD:只构建你需要的架构后端,默认是 X86,如果只做实验,通常只想保留一个目标,能省很多编译时间。-DLLVM_USE_LINKER=lld:用 lld 作为链接器,比系统默认的 ld 快很多,这点在大型项目里特别明显。-G Ninja:Ninja 构建系统在增量构建时比 make 快,配合 ccache,二次编译体验会好很多。
然后执行:
ninja -j$(nproc)如果遇到内存不足,减少并行度:
ninja -j4这里强烈建议配上 ccache:
cmake -S llvm -B build \ -DCMAKE_C_COMPILER=ccache \ -DCMAKE_CXX_COMPILER=ccache或者直接在环境变量里设置CCACHE_PREFIX的方式,不用改 CMake 配置也行。
构建完成后,你可以立刻验证一下:
echo 'int main() { return 0; }' | ./build/bin/clang -x c - -o /dev/null这段很简单的命令能把 pipeline 通跑,确认安装基础没问题。
3.3 手写一个最简单的 FunctionPass:体会 LLVM 扩展方式
优化器的现代化开发模式是基于新 PassManager,写法是定义一个PassInfoMixin结构体,并实现run方法。我先演示一个什么都不做但是能打印函数名的 Pass:
#include "llvm/IR/Function.h" #include "llvm/IR/PassManager.h" #include "llvm/Passes/PassBuilder.h" #include "llvm/Passes/PassPlugin.h" #include "llvm/Support/raw_ostream.h" using namespace llvm; namespace { class HelloPass : public PassInfoMixin<HelloPass> { public: PreservedAnalyses run(Function &F, FunctionAnalysisManager &AM) { errs() << "Hello from: " << F.getName() << "\n"; return PreservedAnalyses::all(); } }; } // end anonymous namespace llvm::PassPluginLibraryInfo getHelloPluginInfo() { return {LLVM_PLUGIN_API_VERSION, "HelloPass", LLVM_VERSION_STRING, [](PassBuilder &PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager &FPM, ArrayRef<PassBuilder::PipelineElement>) -> bool { if (Name == "hello-pass") { FPM.addPass(HelloPass()); return true; } return false; }); }}; } extern "C" LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return getHelloPluginInfo(); }要把它编译成插件,用 clang 自己的 C++ 编译器编译,同时链接 LLVM 库:
clang++ -shared -fPIC -fno-rtti -std=c++17 \ $(llvm-config --cxxflags --ldflags --libs) \ -Wl,-undefined,llvmGetPassPluginInfo \ -o libHelloPass.so HelloPass.cpp然后跑的时候通过 opt 加载:
./build/bin/opt -load-pass-plugin=./libHelloPass.so \ -passes=hello-pass input.ll -disable-output你会看到每个函数名都被打印出来。
这个例子的价值不在于 Hello World 有多高级,而是让你知道扩展点在哪里。真正的优化 Pass 是在这个骨架里填充具体的分析逻辑和变换逻辑的。想深挖,建议去看llvm/lib/Transforms/里现成的 Pass,比如InstCombinePass、LoopUnrollPass,跟着官网的 Writing an LLVM Pass 教程写一遍,进步很快。
3.4 用 opt 和 FileCheck 做回归测试
写完 Pass 之后,怎么证明它没写错?LLVM 生态有一套基于文本的测试方式,核心工具是opt加FileCheck。
思路是:准备一个 IR 输入文件,里面加注释,描述每个阶段应该出现什么结果,然后跑一个 shell 脚本校验。
; RUN: opt -passes=hello-pass -S %s | FileCheck %s ; CHECK: Hello from: add define i32 @add(i32 %a, i32 %b) { %r = add i32 %a, %b ret i32 %r }opt的输出交给 FileCheck 检查是否包含某段预期文本。如果 Pass 的结果改变了 IR 结构,也可以用-S输出检查某条指令是否存在。这个测试方法非常轻量,llvm-project 全仓库几万个测试都是这种格式,习惯了之后做编译器开发极其顺手。
3.5 使用 clangd 提升阅读和开发效率
llvm-project 代码量太大,直接用 Vim 或 VS Code 都容易卡。建议给 clangd 生成 compile_commands.json,最简单的办法是在 CMake 配置时加:
-DCMAKE_EXPORT_COMPILE_COMMANDS=ON构建目录下就会出现compile_commands.json,然后 clangd 会自动读取它。跳转定义、查询引用、搜索全局符号都非常快。尤其是在追踪 IR 数据结构、Pass 依赖关系时,clangd 的精确索引比自带全文搜索好用太多。
4. 性能数据、生态与应用场景:这个工程到底有多能打
4.1 性能表现:不是玄学,是有数据支撑的
llvm-project 本身就是一个超大体量的工程,用它自己编译自己是一个经典的性能测试方法,业内叫 bootstrap。代码总量在千万行级别,release 模式下全量并行构建,在几十核机器上通常也需要几十分钟到数小时。它的优化器对真实程序的提升效果,在很多基准里能比 O0 提升 20% 到数倍不等,具体取决于代码类型和优化选项。
在现代处理器上,clang 生成的二进制经常能在 SPEC 这类基准里和 GCC 打平或略胜出,而在某些大量使用向量化、自动向量化、循环优化的场景,clang 的发挥往往会更好。特别是配合 LTO(链接时优化)和 PGO(配置文件引导优化)时,性能潜力会进一步释放。我做过一次基于 PGO 的构建调优,启动阶段性能提升 10 个百分点,效果相当可观。
4.2 LLVM 之外的生态:不只有 C/C++ 编译器
llvm-project 的生态覆盖范围早就超出传统编译器范畴了。
- Rust 编译器 rustc 的代码生成直接复用 LLVM,这也是 Rust 能够快速支持多个架构、多个后端的重要原因。
- Swift 语言的前端和优化器大量依赖 LLVM 和它的运行时生态。
- Julia 的 JIT 编译流程也是基于 LLVM 的,它会把 Julia 代码动态编成高质量的机器码。
- GPU 领域,NVIDIA 的 CUDA 工具链、AMD 的 ROCm,以及各种 AI 加速器的编译器,都基于 LLVM 做扩展,在 IR 层面处理内核函数、数据布局和并行语义。
- MLIR 是从 LLVM 社区孵化出来的另一个项目,目标是让深度学习模型、HPC 算子能够用一套统一的中间层表示做优化。很多 AI 框架的编译器后端,核心逻辑都在 MLIR 上完成。
这部分市场之所以这么庞大,还是回到文章开头说的那句话:统一的 IR 加模块化后端,语言可以自由生长,硬件可以自由扩展,两边只需要各自的适配层。
4.3 社区和治理模式:大厂共建,开放演进
LLVM 社区从一开始就是开放治理的典范。核心贡献者来自苹果、Google、ARM、Intel、AMD、Qualcomm 等多家公司,也有大量个人开发者。它的 code review 流程严格到让新人一开始会很不适应,但正是这种严格保证了主线质量。规范方面,有 LLVM Coding Standards,有明确的 C++ 版本要求,有 TableGen 描述规则,还有一整套架构决策记录(RFC)。
如果你想长期参与这个项目,我建议从自己实际遇到的问题入手。比如你发现某个 Pass 在特定 IR 模式下的优化机会,先提交一个小 bug 或者一篇 RFC,获得社区反馈后再实现。千万不要闷头写几万行代码直接往邮件列表扔,资源有限,迭代着来成功率更高。
5. 新手常踩的坑与排查技巧实录
5.1 构建慢和内存不足
全量构建 llvm-project 是相当大的工程量,新手最常遇到的就是内存不够或者磁盘不够。应对方法:
- 只构建需要的 target,减少子项目和架构数量。常见的是
LLVM_TARGETS_TO_BUILD只保留一个目标。 - 使用 Ninja 后,并行度调到内存容量能承受的范围内。比如 32GB 内存机器,建议
-j8或-j16,再高容易 OOM。 - 开启 ccache,后面改代码重新编译的时间会大幅缩短。
- 使用 lld 做链接,链接阶段的时间省得很明显。
5.2 API 变化导致代码频繁编译失败
llvm-project 各版本之间的 API 兼容性并不保证。你跟着网上教程写的 Pass,如果版本和教程不一致,很可能编译失败。遇到error: no member named ...,先看对应版本的官方头文件,不要盲目猜。方法就是去头文件里搜报错符号改名情况,或者用 git log 追踪这个文件的改动历史。
5.3 手写 Pass 最常见的逻辑错误
新手写变换 Pass 时最常见的坑是迭代器失效。你遍历一个指令列表,同时删除了当前指令,就会触发断言。稳妥的做法是先收集要删除的 Instruction 到SmallVector里,遍历结束后再统一删。类似的还有修改 CFG 时的后继块更新问题,需要提前用succ_begin获取 block 列表。
5.4 链接错误和符号找不到
使用 llvm-config 生成链接参数时,很容易出现库顺序问题、循环依赖问题。其实 clang 本身就是一个巨大的库集合,手工敲链接命令很容易搞混。建议用 CMake 的find_package(LLVM)模块,它会自动处理所有库的依赖顺序。这点对自动化编译器开发的工程尤其重要。
5.5 调试版本与优化版本的差异
同样一段 Pass,Debug 版本和 Release 版本的行为可能不一致。Debug 版本保留了大量断言,会暴露很多问题,但你也会遇到Assertion failed后不知所措。Release 版本跑得快,but 出错了可能看不到明确信息。我建议日常开发用 Debug 或 RelWithDebInfo 构建,跑性能测试时再切 Release。另外要留意llvm::enableDebugBuffering之类调试开关的作用,别看它们在代码里不起眼,关键时候能帮大忙。
5.6 常见问题速查表
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 构建时 OOM | 并行任务数过高 | 降低-j参数,启用 LLD |
| 插件加载失败 | Pass 注册回调名字不匹配 | 确认registerPipelineParsingCallback中的名字一致 |
| opt 运行报断言 | IR 不合法或分析依赖没更新 | 检查 IR verifier,确保 pass 正确更新分析结果 |
| 链接提示很多重复符号 | 正在同时链接 LLVM 静态库和动态库 | 统一链接方式,用 CMake 的 LLVM package |
| 代码里的 Pass 新版找不到 | 版本 API 变化 | 通过git log追踪头文件变化历史 |
| 编译产物慢得离谱 | 忘了开优化或 LTO | 加-O2,配合-flto再测试 |
6. 一点个人经验:怎么真正学好 llvm-project
如果有人问我,学这个项目的最好路径是什么,我通常会建议这样分阶段走。
第一阶段,先熟练使用 clang 的各种命令行参数,理解编译到不同阶段的操作。第二阶段,写一个能输出函数名的最小 Pass,体验「插件扩展」这个核心工作流。第三阶段,找一个小而完整的后端或 Pass 源码认真读,比如llvm/lib/Target/AArch64下的某个子模块,从 td 描述到指令选择一点点看,这时候很多之前看不懂的概念会自然串起来。第四阶段,尝试给项目提交一个小的 bugfix 或测试用例,走一遍社区的 review 流程。
我自己在做的过程中感觉到,最关键的并不是一开始就把所有细节记住,而是先把主干的骨架立起来。你不需要背下每个 Pass 的名字,但是当遇到性能问题时,能随时想到还有这样一类工具可以调用。
最后先分享一个小技巧:llvm-project 的文档确实多,但很多价值藏在llvm/test目录下的测试用例里。你看不懂某一个优化 Pass 的文档时,去 test 目录搜那个 Pass 的名字,看它的基础测试用例怎么写的,直观理解往往比看文档快得多。这个项目的学习本来就是马拉松,稳住节奏,一点一点积累,后面你会有很多没想到的收获。