做编译器开发这几年,llvm-project 算是跟我打交道最多的一个开源仓库了。不论你是做编程语言设计、性能优化、静态分析,还是想把手上的芯片或者指令集跑起来,最终大概率都会绕到它面前。很多刚接触编译技术的朋友面对这个仓库会觉得无从下手:几百万行代码、横跨几十个子项目、光是 build 就要半天。但我想说,llvm-project 里其实藏着一套非常优雅的工业化设计思路,只要把主干脉络理清楚了,它就是你值得长期投入的技术资产。
这篇文章我会从一个实际做编译优化的人的角度,把 llvm-project 的工程结构、核心设计理念、常用工作流和我踩过的坑串起来讲。适合刚开始接触 LLVM 的学生、想基于 LLVM 做二次开发的工程师,或者只是好奇编译器内部到底怎么回事的爱好者。读完之后,你至少能亲手把仓库跑起来,能看懂一次优化 pass 从注册到生效的全过程,也能在遇到编译错误或代码生成问题时知道往哪个方向排查。
1. 项目整体设计与工程结构梳理
1.1 从“古董编译器”到“模块化生态”的转型逻辑
如果你用过早期的 GCC,应该能体会传统编译器那种“一口大锅煮全家”的设计方式:前端负责把语言翻译成内部表示,后端负责生成目标代码,中间的程序分析和优化逻辑跟前后端绑定得非常紧。你很难把 GCC 的某一个优化 pass 单独拿出来复用,想为一种新语言接入后端,基本等于把整套工具链重写一遍。
llvm-project 的核心革命,是把编译器拆成了三明治结构:前端(Clang 等)、中端(LLVM IR 与一系列分析优化 pass)、后端(目标指令集实现)。中间那个 IR 层是全局的中枢,前端只负责把代码翻译到 IR,后端只负责把 IR 变成机器码,而所有优化逻辑都挂在 IR 上,跟具体语言和具体硬件解耦。
这种设计带来的直接好处是:今天你写了一个新的循环优化 pass,它在 C 语言上能用,在 Rust、Swift、Julia 上也能用,因为大家的 IR 是同一套。而如果你做了一款新芯片,只要把后端指令选择、寄存器分配、指令调度这几块补齐,所有语言的前端都能通过 LLVM 把自己的代码编到你这款芯片上。这个“一次优化,处处受益”的逻辑,是 llvm-project 真正的价值所在。
1.2 仓库目录到底该怎么看
clone 下来之后,llvm-project 根目录下会有十几个一级目录,刚接触的人很容易看花眼。我的建议是,不要按字母表顺序去逛,先抓住这几个核心目录:
clang:C/C++ 编译器前端。你写的clang -O2命令、头文件搜索路径、语法检查、AST 生成都在这里实现。llvm:中端和后端主库。LLVM IR 的定义、优化 pass 框架、各目标架构的后端(X86、AArch64、RISCV 等)都集中在这个目录里。lld:链接器。现在的clang -fuse-ld=lld默认就是用 lld 去做链接,速度明显快过系统自带链接器。compiler-rt:运行时支持库。包括 address sanitizer、undefined sanitizer、profile 插桩这些运行时库。mlir:面向机器学习的多层级中间表示,是 LLVM 家族里后来长出来的一个重要分支。libcxx/libcxxabi/libunwind:C++ 标准库及 ABI 层,做嵌入式和高性能计算的人会经常折腾这块。
剩下还有polly(多面体优化)、openmp(并行运行时)、flang(Fortran 前端)等等,但这些不是主干,可以等有明确需求时再看。说白了,这个仓库是一个“编译器全家桶”,不是单一项目。
1.3 版本管理和子项目之间的依赖关系
llvm-project 是单体仓库(monorepo)策略,所有子项目放在同一个仓库里统一发布。这样做的好处是版本一致性:Clang 9 一定配的是 LLVM 9 的核心库,不会出现前端版本跟中端接口对不上的尴尬。我在早期自己拼装版本的时候就吃过亏,那时候用的是分散的旧版 LLVM 和单独 checkout 的 Clang,结果每次升级都要手动对齐 API,累得很。
值得注意的是,即使是同一个版本号,不同分支(比如 release/17.x 和 main)之间的 API 差异也可能很大。LLVM 社区的习惯是不太维护向后兼容性的,每个大版本都会调整一些接口。所以当你参考网上的旧教程时,先确认一下对方用的是哪个版本,否则编译报错排查起来相当折磨。
2. 从零搭建可用的 llvm-project 开发环境
2.1 磁盘、内存与构建系统的硬指标
llvm-project 很吃机器配置。先说结论:你要是打算从源码完整构建一个带 debug 信息的版本,磁盘至少预留 80GB,内存最好 16GB 以上,构建时并发数别超过 CPU 核心数的 1.2 倍。我自己在 8 核 16GB 的笔记本上构建 Release 版,大概需要 40 到 50 分钟;如果是 Debug 版,时间直接翻倍,有时甚至能到两个小时。
构建系统目前官方推荐用 CMake + Ninja。Ninja 比 Make 快是公认的,而且增量构建时表现更好。还有个关键点是配合ccache使用,第一次构建虽然省不了多少事,但后续修改重编时命中缓存的片段能省下大量时间,我基本每次构建都会打开LLVM_CCACHE_BUILD=ON。
2.2 命令行构建的推荐配置
我个人常用的构建命令大致是这样的:
git clone https://github.com/llvm/llvm-project.git cd llvm-project mkdir build && cd build cmake -G Ninja ../llvm \ -DCMAKE_BUILD_TYPE=Release \ -DLLVM_ENABLE_PROJECTS="clang;lld" \ -DLLVM_TARGETS_TO_BUILD="X86;AArch64" \ -DLLVM_ENABLE_ASSERTIONS=ON \ -DLLVM_CCACHE_BUILD=ON \ -DCMAKE_INSTALL_PREFIX=/opt/llvm-project ninja sudo ninja install这里有几个参数值得解释清楚。LLVM_ENABLE_PROJECTS决定你要构建哪些上层子项目,如果你只写 pass 不写前端,那一个clang就够用;LLVM_TARGETS_TO_BUILD默认会编所有目标架构,这会拖慢构建,建议只留你需要的。LLVM_ENABLE_ASSERTIONS建议开发期内保持开启,它能帮你尽早发现 IR 非法操作、pass 误用等问题,代价是运行时性能略降。
注意:第一次 clone 时建议加
--depth=1 --branch=release/18.x这类参数,只拉对应分支的最新提交,能省不少网络时间和磁盘空间。全量历史对这个仓库来说动辄几个 GB。
2.3 要是不想全量构建,也有轻量路子
如果你只对 LLVM IR 处理流程感兴趣,其实可以用llvm-config去连接系统中已有的 LLVM 库,或者直接用包管理器装llvm-dev。这种方式适合快速验证想法。但我要提醒一点,系统自带的 LLVM 版本往往比较旧,可能缺少你需要的 pass API 或者新特性。如果是从头开始做深入开发,还是建议自己编一份带符号信息的基础版本。
另外,现在官方也提供docker.io/llvmorg镜像,里面有构建好的工具链,适合想快速跑 clang 和 lld 但暂时不想编译源码的人。不过你要是想改代码、断点调试 pass,Docker 镜像里的调试符号不一定齐全,所以我还是保留了本地源码构建这条路。
3. LLVM IR 与 Pass 框架深度拆解
3.1 为什么 IR 是 LLVM 的“命根子”
很多教程上来就讲 IR 语法和指令,但很少解释为什么 LLVM 把宝押在 IR 上。我想用一个对比来帮助理解:GCC 的 GIMPLE 虽然也起到中间表示的作用,但它的设计目标更偏内部使用,ASCII dump 出来你几乎没法阅读。而 LLVM IR 在设计上刻意让它具备“可读、可写、可验证”的特性。
一个典型的 LLVM IR 函数长这样:
define i32 @add_one(i32 %x) { entry: %add = add i32 %x, 1 ret i32 %add }这里i32是 32 位整数类型,@add_one是全局函数名,%x是局部虚拟寄存器。IR 采用静态单赋值(SSA)形式:每个变量只被赋值一次,后出现的指令只能引用之前定义的值。就是这种规则,让数据流分析变得极其简单,也让大多数优化 pass 能放心大胆地重排指令。
更进一步,LLVM IR 是分层级的:内存中通过Module、Function、BasicBlock、Instruction这些 C++ 类来表示,而磁盘上的.ll文件就是这些结构的文本输出。你用clang -S -emit-llvm foo.c就能看到 C 代码转译后的 IR。理解了这条链路,后续所有 pass 的讨论才有了共同的语境。
3.2 Pass 框架:优化逻辑的“插件化”设计
LLVM 的优化器本质上是一个 pass 管理器。每一个优化都是一个独立 pass,比如LoopVectorize负责循环向量化、DeadCodeElimination负责删死代码、InstCombine负责指令模式匹配与简化。它们之间是串行执行的:
module(module-pass-manager) -> function(function-pass-manager) -> loop-pass-manager -> simplifycfg, loop-rotate, lcssa, loop-vectorize...这种分层设计把不同粒度的优化分开管,外部可以给某层插入自定义 pass。你不需要改 LLVM 主代码,只要把自己写的 pass 注册进去,工具链就能识别并调用它。
我当时的第一个 LLVM 练习,就是写一个最简单的 function pass:遍历每个函数的每条指令,统计add数量。代码核心大概 40 行,注册方式如下:
class CountAddPass : public PassInfoMixin<CountAddPass> { public: PreservedAnalyses run(Function &F, FunctionAnalysisManager &AM) { int n = 0; for (auto &BB : F) { for (auto &Inst : BB) { if (auto *op = dyn_cast<BinaryOperator>(&Inst)) { if (op->getOpcode() == Instruction::Add) n++; } } } errs() << "Function: " << F.getName() << " - add count: " << n << "\n"; return PreservedAnalyses::all(); } };关键点有两个:PassInfoMixin是 new pass manager 的基类(老版本用的是FunctionPass,接口已过时),PreservedAnalyses::all()表示我这个 pass 没有修改任何 IR,分析结果全部保留。如果误写成了PreservedAnalyses::none(),会让后续所有分析全量重跑,性能损失很大。
3.3 三种基础分析结构:Block、Loop 与 CallGraph
很多优化在动手前必须先了解源码的控制流与调用关系。LLVM 为此提供了不同的分析 pass。
首先是BasicBlock级别的分析,它告诉你每个基本块从哪里开始、以什么终止指令结束、有哪些前驱和后继。这是最底层的信息,控制流图(CFG)的基础就是这些块。
其次是LoopInfo,它把函数里的循环结构整理出来,包含每个循环的入口块、出口块、嵌套层数、循环体的基本块集合。循环优化(向量化、循环展开、强度削减)几乎都要依赖 LoopInfo 给出的循环划分。
再往上是CallGraph,它描述的是函数之间的调用关系图。比如内联优化就要看被调用函数的调用频度、函数大小来决定是否值得内联。
这些分析模块通常不是你手动跑出来的,而是在 pass 里通过AM.getResult<LoopAnalysis>(F)这类接口按需取得。合理的分析复用机制,是 LLVM 能在一轮轮优化中保持效率的关键。
3.4 IR 合法性检查:为什么一天到晚报 “Broken module”
调试 pass 时最常见的噩梦之一,就是运行到某个优化后 IR 崩溃或者验证失败。LLVM 里有个verifypass 专门做 IR 合法性检查。它检查的事情包括:变量是否在定义前就被使用、phi 节点的规则是否正确、终结指令是否只在基本块末尾出现、函数返回值类型是否匹配等。
我在早期踩过一个经典问题:我写了一个简单优化,想把add x, 0简化成x,但在替换后忘记更新use-def链,结果导致后续指令引用了一个已经被删除的 value。打开-verify-each后,问题暴露得特别快:
opt -passes=my-pass -verify-each input.ll -o output.ll所以开发 pass 时,一定要养成开验证选项的习惯。没有验证的优化链,就像没有刹车的车,出事了才知道错在哪。
4. 实际操作:写一个真正能跑的优化 pass
4.1 构建一个与系统独立的新 pass 工程
很多人第一次做 LLVM 开发时,会把代码直接丢进 LLVM 源码树里,改 CMakeLists 后随整个工程一起编译。这种方式的缺点是:每次改动都要走一遍 llvm-project 的构建系统,慢,而且跟主项目耦合深。
更灵活的方式是建一个独立工程,通过find_package(LLVM)引用已安装的 LLVM 库。项目结构大致像这样:
my-pass/ ├── CMakeLists.txt ├── MyPass.cpp └── lit-tests/CMakeLists.txt 核心内容如下:
cmake_minimum_required(VERSION 3.20) project(MyPass) find_package(LLVM REQUIRED CONFIG) add_definitions(${LLVM_DEFINITIONS}) include_directories(${LLVM_INCLUDE_DIRS}) add_library(MyPass MODULE MyPass.cpp) target_link_libraries(MyPass PRIVATE LLVM)这样配置完,MyPass.so就是个独立插件,可以用opt -load-pass-plugin=./MyPass.so -passes="my-pass"动态加载,完全不用重编整个 LLVM。
4.2 从 C 代码到 MLIR/LLVM IR 的完整落盘路径
为了测试我写的 pass,通常我会先写一个小 C 程序:
int add_one(int x) { return x + 1; }然后生成可见 IR 文件:
clang -O0 -S -emit-llvm add_one.c -o add_one.ll cat add_one.ll这里-O0是为了保留比较原始的 IR,方便看清优化 pass 进场前的样子。如果你想看某项优化有没有生效,可以分别生成未优化和优化后的 IR,再用diff对比。比如clang -O2 -S -emit-llvm add_one.c -o add_one.opt.ll,观察add_one是否被优化成ret i32里的常量或直接返回参数加一。
当处理更复杂的场景(比如机器学习编译器),前端未必是 Clang 而是 TensorFlow 或 PyTorch 的模型转换器,它们会先产出 MLIR(多级 IR),再逐步 lower 到 LLVM IR。但这篇文章聚焦的仍是传统语言路径:C/C++ 源码 -> Clang AST -> LLVM IR -> O2 优化 -> 目标汇编。
4.3 用 opt 单步调试 pass 的实践经验
在写 pass 过程中,我用的最多的工具是opt。它允许你单独对 IR 文件跑一个或多个 pass,非常适合单步验证。
opt -load-pass-plugin=./MyPass.so -passes="my-pass" add_one.ll -S -o add_one.passout.ll-S表示输出 IR 文本,-o指定输出文件。假如我想看多个 pass 依次作用的结果,可以这样:
opt -passes="loop-rotate,loop-vectorize,my-pass" add_one.ll -S -o result.ll注意 pass 名称要跟注册名完全一致,不然后续 pipeline 会直接报 unknown pass。排这个错很无趣,所以建议注册 pass 时就明确规定名字,并且保持一致。
如果你想看整个优化 pipeline 的执行过程,可以加-debug-pass-manager(老版本叫-debug-pass=Structure)。这会在 stderr 里输出每个 pass 进入和离开的层级顺序,我能很直白地看到自己的 pass 是否被调度、运行了几次。
提示:
opt在部分系统上不随clang直接安装,需要额外装llvm包或确认LLVM_ENABLE_TOOLS是 ON。不然找半天会发现命令不存在。
4.4 常用调试手段与可视化方案
除了打印日志,我还会用 LLVM 自带的一些工具帮助理解 IR。llvm-dwarfdump能看 debug 信息,llvm-nm能列符号表,llvm-objdump可以反汇编目标文件,llvm-mca可以做静态性能预估。
复杂函数如果想看图,可以用dot导出控制流图:
opt -passes=dot-cfg add_one.ll -S生成的文件就是.dot,可以用 Graphviz 转成图片。当你调试一个循环优化 pass 时,看看基本块布局的移动,比盯着命令行输出直观得多。
另外,给转义符较少的项目做可视化时,我也建议用llvm-view或 VS Code 里的相关插件,但这类工具通常只支持老版本接口,新版本可能失效,所以最稳的还是dot导图。
5. 实际工程中的高频问题与避坑笔记
5.1 构建阶段的问题清单与解决建议
我在不同机器上构建 llvm-project 很多次,积累了不少坑。这里列几个最高频的:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 内存不足直接 OOM | 并发任务太多,模块缓存膨胀 | 降低-j并发数,改用 Release 构建,开启LLVM_PARALLEL_LINK_JOBS=2限制链接并发 |
| 链接时报重复符号 | 子项目之间依赖重复或开启多个后端 | 检查LLVM_TARGETS_TO_BUILD,尽量只保留目标架构;不要混用 make 和 ninja 产物 |
cmake 找不到LLVMConfig.cmake | CMAKE_INSTALL_PREFIX不对或没执行 install | 确认llvm-config --cmakedir指向的路径,并在find_package前设置CMAKE_PREFIX_PATH |
| clang 版本和 LLVM 库版本不一致 | 用系统 clang 链接了自编译库 | 构建时用 LLVM 自带的 clang,设置CC和CXX指向 toolchain 中的二进制 |
| 增量构建经常全量重跑 | 配置参数改了或者 cmake 重跑 | 修改编译选项时尽量在同一 build 目录,避免反复重新运行 cmake 的变量核对 |
5.2 Pass 运行期常见的错误类型
在跑自定义 pass 时,我最常见到的三类错误:
第一类是断言失败。比如把nullptr传给replaceAllUsesWith,或者修改了指令操作数后没有处理use链,断言会直接崩给你看。这类错误运行时就能抓到,关键是打开LLVM_ENABLE_ASSERTIONS。
第二类是验证失败。IR 结构不合法,verifypass 告诉你Instruction does not dominate all its uses。这往往意味着你把一条指令移到了它依赖的值定义之前,或者删除了祖先块。出现这种问题,就需要重新梳理支配树关系。
第三类是优化结果不符合预期。IR 没有崩,但生成的代码没有达到期望性能。这时候不要瞎调 pass 顺序,建议先看一眼llvm-mca的吞吐和指令数,再对比优化前后 IR,定位到底是哪个 pass 没触发、哪个模式没匹配上。
5.3 版本迁移的适应性建议
LLVM 更新快,API 变化也快。你从 LLVM 15 迁移到 18,可能发现legacy::Pass没了、FunctionPass被清理、AnalysisManager的调用方式也不太一样。
我自己比较依赖的策略是写一小层适配代码,把自己的 pass 核心逻辑跟 LLVM 接口隔离。核心逻辑只操作 IR 的数据结构,适配层负责注册和调用。这样即使 API 调整,我通常只改几十行适配层,就能复用到新版本。
还有一个建议是关注 LLVM 每年的发布说明文档,那里面会列出 breaking changes。虽然内容多,但至少能在升级前对整个变化有数,不至于在 compile error 中迷失。
5.4 性能瓶颈排查:先量化再动手
优化 pass 本身也会影响编译时间。如果你的 pass 在大型源文件上运行极慢,先用time或-ftime-report看看整体耗时分布。常见病根是 pass 内部使用了O(n^2)的循环扫描——比如遍历函数时对指令列表做线性查找。
优化这类问题的常见思路有两个。一是尽可能用 LLVM 提供的分析结果,例如要获取循环深度,别自己遍历再判断,直接取LoopInfo的结果;二是注意在 pass 中不要频繁构造、销毁结构或复制容器,尽量引用原始对象。
如果排查后发现耗时其实出现在 pass 管理器的分析重建上,就要重新审视你的 pass 是否正确声明了PreservedAnalyses。我在第 3.2 节提过,申请所有分析保留是默认安全选项,但你要是确确实实改了 CFG,那就不能这么写了,否则后续 pass 用了陈旧的分析结果,轻则优化失效,重则直接触发断言报错。
6. 扩展思路:这些方向值得持续深入学习
6.1 MLIR 与多级中间表示带来的新可能
llvm-project 现在的覆盖面已经远远超过传统“编译器”概念。MLIR 子项目是近年来热度非常高的方向,它的核心思想是让开发者可以自定义中间表示层,并且在不同抽象层级之间做逐步 lower。
做 AI 编译器的人特别吃这套。比如一个模型可以先表示成较高层的tosa方言,再逐步 lower 到linalg、affine,最后到 LLVM 方言。每一层之间可以做对应的优化,比如算子融合、缓存优化。这种“多级 IR”的灵活度,是传统单层 IR 很难提供的。
如果你对深度学习编译或者硬件设计空间探索感兴趣,MLIR 值得好好研究。它学习的难点是概念多、方言多,但一旦理顺了 dialect 和 lower 这两个核心逻辑,写起来会顺手很多。
6.2 结合其他语言与生态:不只是 C/C++
clang 只是 llvm-project 前端的其中一个。Rust 就通过 rustc_codegen_llvm 把 Rust MIR 转成 LLVM IR,再用 LLVM 后端生成机器码。Julia 的默认编译器同样把多种语言语义统一到 LLVM IR。Swift 则深度参与 LLVM 开发,甚至贡献了不少优化和运行时改进。
这种“语言无关”的能力,让 llvm-project 成为各种语言工具链的共同底座。如果你自己是某个语言生态的开发者,看 LLVM 相关代码时,会觉得那些优化 pass 并不是只服务于 C,而是服务于所有编译语言。
6.3 参与社区贡献的正确姿势
想把 llvm-project 作为长期经营的开源项目,建议从修小 bug 开始。github 上的 issue 和 Phabricator(现在用的是 GitHub Pull Request 和 Discourse 讨论)里,都挂着很多good-first-issue标签的任务。
我个人的经验是,先挑文档修正、测试用例补充、边缘 case 处理这类低风险改动。等你对代码风格和 review 流程熟悉了,再碰需要大改的优化 pass。LLVM 的 code review 非常严格,一个 patch 改几十行,来回 review 好几轮是常态。要有耐心。
结尾:离“精通”还差的那些事
写到这里,我知道很多人读这类长文都会想,我是不是就能落地说会 LLVM 了?实话实说,光是 llvm-project 的代码量,一个人即使全职读上几年也未必全读完。但我觉得,真正重要的不是把所有代码读完,而是建立一条完整的主线:懂 IR、懂 pass 框架、会构建工具链、能调试问题。有了这条主线,遇到具体需求时,你能迅速地定位到相关的源码文件,理解别人的实现思路,再动手去做修改。
我自己从最初对着opt手册发呆,到后来能写出一两个看得过眼的优化 pass,中间绕了不少弯路。如果非要说有什么捷径,那就是多写、多跑、多读报错信息。LLVM 的报错绝大多数时候是诚实的,它告诉你哪里非法,你就去查那里的 API 文档和源码。另外,常去翻 LLVM 的官方文档和邮件列表,里面的讨论深度往往比多数二手博客高一个数量级。
最后再分享一个小技巧,这是帮你在源头减少麻烦的实践:每次开始改代码之前,先把当前基线编译一遍,并记住带符号的clang和opt是什么状态。这样后续出现任何异常,你都能判断是环境变化还是代码改动引起的。很多头疼的 debug 一夜问题,其实都出在环境状态混乱上。打好地基,后面走起来才踏实。