1. 一个仓库装下整个编译生态:llvm-project到底包含什么
1.1 先打破一个常见误解
我这些年给人做编译工具链分享,开场经常听到一句话:“我在学LLVM。”追问下去,发现大部分人其实只是用clang编译了几个C++项目,或者跑了一下clang --version,连llvm-project仓库里到底有哪些东西都没完整看过。这不能怪大家,因为“LLVM”这个名字本身就很绕。
严格来说,LLVM是一整套编译器基础设施的设计理念:前端把源代码变成中间表示,中端对中间表示做优化,后端再把优化后的中间表示变成机器码。而llvm-project是这一整套东西的官方monorepo仓库,所有组件都在同一个Git仓库里开发、发布、维护。你从GitHub上git clone https://github.com/llvm/llvm-project.git拉下来的这一大坨,就是这个仓库。
这个仓库的体积非常大,shallow clone后也有几个GB的源码,完整clone加上历史记录经常超过几十个GB。我第一次完整clone的时候一度怀疑是不是网断了。体积大是因为里面并行维护着多个活跃项目,而这些项目之间存在很强的依赖关系,不放在一起,版本同步会非常痛苦。
1.2 llvm-project核心组成和各自定位
很多人以为llvm-project就是“LLVM + Clang”,实际上远不止这些。我整理了一个核心子项目的功能对照表,方便你按需查阅:
| 子项目 | 功能定位 | 典型使用场景 |
|---|---|---|
| llvm | 核心库:IR、优化器、目标描述、后端代码生成 | 写Pass、做静态分析、做自定义后端 |
| clang | C/C++/Objective-C前端 | 编译C/C++代码、做Clang Static Analyzer |
| lld | 链接器 | 替代系统ld/gold,速度更快 |
| libcxx / libcxxabi / libunwind | C++标准库实现 | 实验新标准特性、自定义标准库 |
| compiler-rt | 运行时库,包含sanitizer系列 | AddressSanitizer、UBSan、ThreadSanitizer |
| mlir | 多层级中间表示框架 | AI编译器、硬件抽象、DSL编译 |
| flang | Fortran前端 | Fortran代码编译 |
| polly | 基于多面体模型的循环优化 | 自动并行化、数据局部性优化 |
| clang-tools-extra | clang-tidy、clangd等工具 | 代码静态检查、IDE补全、重构 |
| openmp | OpenMP运行时和编译支持 | 并行计算程序编译和运行 |
这里最容易被新手搞混的是“llvm”目录。它不是一个完整的编译器,而是整个生态的核心框架。平时我们编译C++用的clang可执行文件,本质上是“Clang前端 + LLVM中后端的缝合体”。
理解了这些项目之间的关系后,你才能真正明白为什么llvm-project值得整个编译生态围绕它运转。它不是一个孤立的工具,而是一套可以让任何语言编译到任意平台的通用基础设施。
2. 从零构建llvm-project:CMake配置与提速实操
2.1 环境准备和第一印象
很多人下载完llvm-project之后,第一反应是打开README,然后看到一大堆CMake选项就懵了。我当初也一样,甚至因为构建失败一度怀疑自己是不是不适合搞编译器。所以这里我把一套我验证过多次的最小构建方案写出来,你在自己机器上直接抄就行。
先说我自己的环境:Ubuntu 22.04,CMake 3.24+,Ninja 1.11+,系统GCC 12,内存32GB。如果你是新装的系统,先确认依赖:
sudo apt update sudo apt install -y cmake ninja-build gcc g++ python3 git然后拉取仓库。这个仓库太大,我强烈建议先做浅克隆,等熟悉了再拉完整历史:
git clone --depth 1 https://github.com/llvm-project/llvm-project.git cd llvm-project这里有个小细节:--depth 1只拉取最新的提交,能省下大量时间和磁盘空间。如果你之后想参与社区、用git blame查历史或者git log追溯提交,再在需要时git fetch --unshallow补全历史,完全不冲突。
2.2 最小可用构建命令
进入仓库后,你会发现外层没有传统的顶层CMakeLists.txt,而是在llvm/这个子目录下。构建LLVM的标准做法是:以llvm目录为源码根,单独建一个build目录做out-of-source构建。我推荐这套配置:
cmake -S llvm -B build -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DLLVM_TARGETS_TO_BUILD=X86 \ -DLLVM_ENABLE_PROJECTS="clang;lld" \ -DLLVM_ENABLE_ASSERTIONS=ON然后开始编译:
ninja -C build -j$(nproc)第一次构建时间取决于机器性能,我实测在32核机器上大概10到15分钟,8核机器可能要一两个小时。如果你只是想读源码、跑opt做IR实验,其实不需要编译整个项目,只编译llvm和opt、llvm-as、llvm-dis这几个工具就够用。但如果你想跑clang编译真实C代码,就必须把clang和lld也加入LLVM_ENABLE_PROJECTS。
为什么推荐用Ninja而不是默认的Makefiles?最直接的原因是Ninja增量构建更快,且在编译失败时给出的错误信息更友好。它默认利用多核并行,对于这种巨大工程,构建速度差距非常明显。我最初用Make构建过一次,后来换Ninja之后明显感觉整个世界清净了。
2.3 构建中容易踩的坑
构建llvm-project时我踩过不少坑,有些至今记忆犹新。这里列一个避坑对照表:
| 症状 | 根因 | 解决方案 |
|---|---|---|
| CMake报错找不到Ninja | 系统没有装Ninja,或版本太老 | sudo apt install ninja-build,确认ninja --version不是老版本 |
| configure时报C++编译器版本过旧 | GCC版本低于LLVM要求 | 升级GCC,或用Clang来编译Clang |
| 编译过程中内存不足进程被杀 | Debug构建太吃内存 | 用Release构建;限制并行度-j4;增加swap |
LLVM_ENABLE_PROJECTS拼错或包含不存在的项目 | 项目名写错 | 去llvm/CMakeLists.txt里查看支持的列表 |
| 编译出来的clang运行时报库找不到 | 没有正确设置LD_LIBRARY_PATH | export LD_LIBRARY_PATH=$(pwd)/build/lib:$LD_LIBRARY_PATH |
最隐蔽的一个坑是-DCMAKE_BUILD_TYPE=Release和-DLLVM_ENABLE_ASSERTIONS=ON的组合。很多初学者怕Debug版本太慢,直接上Release,结果跑opt调试Pass时什么信息都不输出,因为很多断言信息和调试日志在Release下被编译掉了。我的习惯是:日常学习用Release + Assertions,既保证运行速度又有基本断言;如果真要深入调试某个LLVM内部状态,再单独建一个Debug构建目录。
另外强烈建议安装ccache,如果你打算反复修改源码并重新编译,它能省掉大量重复编译:
sudo apt install ccache cmake -S llvm -B build -G Ninja \ -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ -DCMAKE_C_COMPILER_LAUNCHER=ccache \ ...我加了ccache之后,日常增量构建时间从十几分钟降到一两分钟,这个收益对迭代调试来说极其重要。
3. 打开优化器的黑盒:IR与Pass的运行原理
3.1 为什么IR是理解整个llvm-project的钥匙
llvm-project之所以能支持那么多语言、那么多目标架构,核心功臣就是中间表示(IR)。IR是一种经过精心设计的、同时保留高级语义和适合底层优化的指令形式。你可以把它理解成一种“跨语言的汇编语言”——C++和Rust编译后都先变成IR,LLVM再对IR做优化,最后生成不同CPU的机器码。
IR在LLVM中有三种形态:
- 内存表示:Pass分析操作的是内存中的
Module、Function、BasicBlock、Instruction对象。 - 二进制表示:即bitcode,常被JIT或AOT预处理环节使用,扩展名
.bc。 - 文本表示:人类可读的
.ll文件,也是调试和学习时最常打交道的形态。
想把一个简单的C函数变成IR,一行命令就够:
cat test.cint add(int a, int b) { return a + b; }clang -S -emit-llvm test.c -o test.ll然后打开test.ll,你会看到类似这样的内容:
define i32 @add(i32 %a, i32 %b) { %1 = add i32 %a, %b ret i32 %1 }每个指令的语义清晰,类型显式标注,%a、%b是寄存器的抽象名。这种设计让你可以彻底摆脱具体机器的干扰,专心研究优化算法。
3.2 从写一个Pass开始
理解IR后,最直接的学习抓手就是写一个自己的Pass。LLVM的Pass框架说白了就是遍历IR并修改它的插件机制。每个Pass完成一个特定优化,比如死代码消除、常量传播、循环展开。你写的每个Pass本质上都在回答两个问题:我要遍历什么?我要怎么改?
我建议用新Pass Manager写一个最基础的FunctionPass示例,功能是统计每个函数的指令数量并打印函数名。下面的代码可以直接放到一个独立文件中:
#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 CountInstructionsPass : public PassInfoMixin<CountInstructionsPass> { PreservedAnalyses run(Function &F, FunctionAnalysisManager &AM) { unsigned count = 0; for (auto &BB : F) { count += BB.size(); } errs() << "Function: " << F.getName() << " (" << count << " instructions)\n"; return PreservedAnalyses::all(); } }; } // namespace extern "C" ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, "CountInstructionsPass", LLVM_VERSION_STRING, [](PassBuilder &PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager &FPM, ArrayRef<PassBuilder::PipelineElement>) { if (Name == "count-instructions") { FPM.addPass(CountInstructionsPass()); return true; } return false; }); }}; }编译成一个动态库:
clang++ -fPIC -shared count.cpp \ $(llvm-config --cxxflags --ldflags --libs) \ -o count.so然后配合opt工具在IR上运行:
opt -load-pass-plugin=./count.so -passes="count-instructions" test.ll你会看到:
Function: add (2 instructions)这个例子看似简单,但它覆盖了Pass编写最关键的几个步骤:声明Pass、实现run方法、注册到PassBuilder、加载到opt。你会实际感受到LLVM的工具链是如何环环相扣工作的——clang生成IR,opt加载Pass运行优化,llvm-as/llvm-dis做IR格式转换。一旦这个链路跑通,再去看任何复杂的Pass都能更快理解。
3.3 优化层级与Pass管线的关系
写完第一个Pass之后,很自然会问:-O2到底执行了哪些Pass?它们按什么顺序跑?
在LLVM的新Pass管理器中,编译器内置的优化Pass管线定义在llvm/lib/Passes/PassBuilderPipelines.cpp。/O0基本不做优化,O1做一些基础清理,O2是大多数项目的默认选择,O3在O2基础上还会开更多循环变换,比如循环展开、循环向量化。
你可以在命令行里直接看某个优化级别对应的Pass列表:
clang -O2 -S -emit-llvm test.c -o test.ll opt -O2 -passes-epilogue ... # 不,更直接的方式 opt -O2 -debug-pass-manager -S test.ll -o /dev/null开启-debug-pass-manager之后,opt会把每个Pass的执行顺序打印出来。我第一次看到那个输出时很震撼:原来一个简单的return a + b也要经过几十个Pass的轮番处理。这种“黑盒变白盒”的感觉,正是学习LLVM最上头的阶段。
现在写的这个简单统计Pass虽然不修改任何东西,但它给了你一个自带探针的“装置”。之后你可以在这个文件基础上添加IR变换,比如删除某个无用的load,或者替换某个常量为立即数。自己动手改一次,胜过读十篇优化器原理文章。
4. 调试与分析源代码:我常用的工具和工作流
4.1 一次崩溃分析与最小化复现
写Pass最怕的不是逻辑错误,而是“优化后程序行为变了”或者直接崩溃。定位这类问题,我有一套固定的排查链路,这里以一个真实例子说明。
有次我写了一个简单的Inlining分析Pass,在run函数里天真地以为所有CallInst都一定指向一个Function,结果跑opt加载Pass时当场段错误。这个问题的复杂性在于,IR里的call指令指向的可能是间接调用,也就是说被调用对象可能是函数指针,而不是一个名字固定的函数。
排查过程大致是:
- 先用
opt -debug-pass-manager确认是哪个Pass崩溃。 - 把待优化的
.ll文件切成最小片段,直到崩溃必须依赖的最小IR。 - 用
llvm-dwarfdump看bitcode调试信息,或者直接加errs()打印崩溃点。
我强烈建议在Pass里多用assert和早期打印,LLVM的raw_ostream用起来很顺手:
assert(CI.getCalledFunction() && "direct call expected"); if (!CI.getCalledFunction()) { errs() << "Skip indirect call in " << F.getName() << "\n"; continue; }这种处理方式让我很快意识到:编译器的世界里,“默认所有调用都是直接调用”是个危险的假设。间接调用、虚函数、函数指针无处不在,Pass必须对IR的每种形态有准备。
4.2 关键命令行工具速览
我用llvm-project的各种工具做日常分析已经很多年,下面这几个是我使用频率最高的:
| 工具 | 用途 | 使用场景 |
|---|---|---|
| opt | 运行Pass、优化IR | 测试自定义Pass、查看Pass执行顺序 |
| llvm-as / llvm-dis | IR文本和bitcode互转 | 生成.bc或者把.bc转成可读文本 |
| lli | 直接解释执行bitcode | 快速验证IR行为而不需要生成机器码 |
| llvm-nm | 查看符号表 | 检查库导出符号 |
| llvm-objdump | 反汇编目标文件 | 比较优化前后的机器码 |
| llvm-dwarfdump | 解析DWARF调试信息 | 排查调试元数据问题 |
| llvm-mca | 静态性能分析 | 分析指令吞吐、延迟 |
| bugpoint | 自动最小化崩溃用例 | 当Pass导致crash时,自动缩减IR |
其中bugpoint是LLVM提供的“自动二分定位”神器。有一次我给自定义后端做优化,代码在特定IR输入下触发断言,我原本打算手动一点点删代码来找最小复现,后来发现LLVM早就提供了这个工具。它会自动尝试截取IR的不同部分,找到仍能触发问题的子集,省下来的时间让我吃了顿完整的午饭。
4.3 把-debug-only用起来
LLVM内部很多关键模块都内置了调试输出,由-debug-only参数控制。想在Pass里加入自己的调试日志,可以先在代码里用LLVM_DEBUG(dbgs() << "..."),然后开启编译时的LLVM_ENABLE_ASSERTIONS,运行的时候带上:
opt -debug-only=my-pass -load-pass-plugin=./count.so -passes=count-instructions test.ll这个参数的好处是:默认情况下不输出任何内容,不会污染正式运行;但调试时就可以针对性打开某一类日志。很多LLVM子系统的调试类别名,可以在源码里搜DEBUG_TYPE找到。
我的习惯是:每个自定义Pass都定义独立的调试类型,例如DEBUG_TYPE "my-pass"。这样在庞大的输出里用grep过滤,会清晰很多。
5. 给llvm-project贡献代码:从issue到merged PR
5.1 如何找到适合新手的任务
很多读者在学会写简单Pass之后,都会冒出一个念头:我能给llvm-project贡献点东西吗?我的回答是:当然能,但最好别一上来就挑战核心优化。
GitHub上llvm-project仓库的issues里偶尔会打上good first issue标签,但数量不多。更适合新手的是先从这些切入点入手:
- 修文档和注释:LLVM的文档量极大,不少资料更新滞后,修文档是一个低门槛且社区极其欢迎的贡献。
- 补测试用例:找到某个Pass没覆盖到的边界情况,提交新的lit测试,这是练手的好目标。
- 修已知小bug:优先找那些已经有复现用例的bug,你可以直接拿来练手。
- 代码格式化与重构:LLVM很重视代码风格,偶尔有需要重命名或重构的小任务。
需要提醒的是,LLVM社区很早之前从Phabricator迁移到了GitHub Pull Request,但审查流程依然非常严格。你提交的每个改动都会有人逐行看,而且很可能被要求修改好几轮。这不是针对你个人,而是这类基础软件的质量门槛真的高。
5.2 写测试、跑测试、提交PR的完整流程
我参与社区最大的收获之一是被迫学会了写规范测试。LLVM的测试框架是lit加FileCheck。lit负责发现和运行测试,FileCheck负责校验输出。
一个最简测试文件长这样:
; RUN: opt -S -passes="my-opt-pass" < %s | FileCheck %s define i32 @test() { ; CHECK-LABEL: @test ; CHECK: ret i32 42 ret i32 42 }RUN行定义了执行命令,CHECK行声明了期望输出的模式。写完测试后,在build目录下运行:
ninja check-llvm也可以只运行特定目录的测试:
ninja check-llvm-unit llvm-lit -v ../llvm/test/Transform/MyPass/提交代码前还需要过代码格式这一关。LLVM使用clang-format,直接运行:
git-clang-format HEAD~1能自动调整你引入的改动风格。另外,每个提交都需要Sign-off(Developer Certificate of Origin),也就是提交信息末尾加一行:
Signed-off-by: Your Name <your@email.com>通常用git commit -s就能自动加上。
5.3 我的几条实操体会
我在提交PR过程中犯过不少错误,总结下来最值得提醒的是这么几条:
第一,不要等代码“完美”再提交PR。更合理的做法是先把一个最小可行版本提上去,在PR描述里说清楚思路、实现的取舍、测试结果,让reviewer尽早给反馈。这能避免你朝错误方向走太远。
第二,PR描述里一定要写清楚“为什么”改动。LLVM的reviewer对你的代码风格和实现方式有要求,但最关心的是你有没有充分理由改这个行为。一个没有任何motivation说明的PR,基本活不过第一轮review。
第三,跑测试别偷懒。你改的是编译器,影响面极大,至少要跑一遍check-llvm相关目录,最好把clang相关测试也跑一下。测试挂掉就去修,不要假装没看见。
第四,被reviewer拒绝或者要求大改不是坏事。我的第一个非文档PR被要求重构了三轮,每一轮都在缩小改动范围、细化注释、补充边界测试。最后合并时,那部分代码的质量确实远高于我最初提交的版本。这种被“打磨”的过程,才是参与llvm-project最大的成长价值。
6. 想深入LLVM?我建议的源码阅读顺序与学习方法
6.1 不同基础读者的源码阅读路径
经常有人问我:“我知道llvm-project很牛,但源码几千个文件,到底从哪里开始读?”我的回答是:不要从clang开始,要从llvm/lib/IR开始。因为IR是整个项目的核心语言,所有前端最终都生成IR,所有优化都作用在IR上,所有后端都消费IR。不懂IR,看任何组件都会觉得是天文数字。
我给不同基础的读者推荐过下面这条路径,反馈一直不错:
| 阶段 | 阅读目标 | 参考资料 |
|---|---|---|
| 入门 | llvm/lib/IR/里的核心数据结构:Module、Function、BasicBlock、Instruction | LLVM Language Reference Manual |
| 进阶 | llvm/lib/Transforms/里一个简单Pass,例如SROA或EarlyCSE | opt -debug-pass-manager观察Pass执行 |
| 深入 | clang/lib/CodeGen理解C++如何生成IR | 用自己的C++代码跑clang -S -emit-llvm对照 |
| 探索 | llvm/lib/Target/看某个目标后端的指令选择 | llvm-objdump反汇编比较机器码 |
我自己就是因为好奇“前端解析完AST之后是怎么进入IR的”,花了很多时间读clang/lib/CodeGen。看得越多越觉得,编译器前端和后端之间的那个IR边界,是整个工程最具设计美的地方。它不是某个人的临时主意,而是一套历经十几年打磨形成的抽象——这个抽象是否合理,直接决定了llvm-project能不能同时容纳几十种语言和几十种处理器。
6.2 警惕过时教程和过时Pass写法
网上关于“写LLVM Pass”的教程极多,但很多都过时了。最典型的问题是:老教程讲legacy Pass Manager,教你用registerPass、FunctionPass继承;而现代LLVM默认使用new Pass Manager,写法和加载方式都变了。
判断一篇教程是否过时的简单方法:看它有没有出现llvm::PassInfoMixin和llvmGetPassPluginInfo。如果通篇都是InitializeNativeTarget、legacy::PassManager这类老式写法,参考价值就要大打折扣。
为了少走弯路,我建议一手资料只信两个地方:llvm.org官方文档(尤其是WritingAnLLVMPass页面和LLVM Language Reference Manual),以及llvm-project仓库里实际代码示例。教程可以看,但最终要回到源码和实操去验证。
6.3 保持长期参与的小建议
每次涉及到llvm-project的项目,我都会想起一个具体场景:某次被一个循环优化问题卡了三天,最后在llvm/lib/Transforms/Scalar/LoopRotate.cpp里找到一段注释,解释了为什么某个看似废话的条件判断其实是处理一个极端合法性问题。那个瞬间我意识到,阅读LLVM源码最宝贵的不是代码本身,而是代码里沉淀下来的“工程判断”——什么事情必须严格保守,什么事情可以做激进假设,这些边界条件,是论文里永远读不到的。
所以我建议所有想深入这个项目的人:除非你只是临时用clang编译代码,只要你动了写Pass、改优化器、做后端的念头,就去维护一个自己可复现的本地构建环境,然后从IR开始一点点啃。不用给自己定太大目标,每周能读懂一个Pass的输入输出和关键边界条件,一年后你对现代编译器基础设施的理解,已经能超过绝大多数“只谈概念”的泛泛之谈。
在llvm-project里待得越久,我越觉得这个项目的准入门槛不是智商,而是耐心。你不需要是天才,只需要愿意为了一条断言反复读代码、为了一次内存崩溃反复跑bugpoint、为一个边界条件反复翻文档。这些都做到之后,你会发现编译器基础设施其实并没有想象中那么遥不可及——它只是一群极其较真的人在处理极其精确的问题而已。