如果你在编译器、编程语言或者底层系统方向工作过一段时间,大概率绕不开一个名字:llvm-project。它不是一个单一软件,也不是只能做C/C++编译的玩具仓库,而是一整套模块化编译基础设施。无论是Clang、LLD、libc++、LLDB,还是近些年活跃的MLIR、Flang,全都挂在这棵大树下面。对于想搞懂“编程语言到底怎么变成机器码”的人、想做静态分析工具的人,或者是只想更高效使用编译器的人,llvm-project都值得花时间仔细拆一遍。这篇文章我就从一个实际使用者的角度,把它的结构、构建方式、核心机制、实操脚本以及踩坑经验一次性讲透。
1. 项目整体认知:LLVM到底是什么,解决了什么问题
1.1 从“编译器工具链”到“编译基础设施”
很多初学者会把LLVM理解成一个编译器,这没错但不完整。更准确地说,它是一个“编译器工具链的组装车间”。传统GCC是单体的:前端解析C/C++,中端优化,后端生成汇编,所有逻辑揉在一起,维护成本高,想支持一个新语言或新芯片,往往要动整条链路。LLVM把这条链路拆成了三个独立的层:前端、优化器、后端,中间用一种叫LLVM IR的中间表示来衔接。
这种拆分带来的好处非常直接。后端和优化器一旦稳定,新的语言只需要实现一个新的前端,把源码翻译成LLVM IR,就能复用后续所有优化与代码生成能力。这也解释了为什么Rust、Swift、Julia这些语言都会选择LLVM作为底层支撑。对硬件厂商来说也一样,支持一套LLVM后端就等于瞬间拥有了几十种语言的编译器生态。
当你说“我要研究llvm-project”的时候,本质上不是研究某一个程序,而是要理解这套从源码到机器码的总线设计逻辑。它的核心价值在于把“编译”从一个黑盒,变成了可以按需替换、定制、插件的流水线。
1.2 子项目与生态定位
llvm-project仓库不是单个repository里面一个项目,而是多个项目并列在同一个monorepo中。具体大家最常用到的包括:
- clang:C/C++/Objective-C前端。
- clang-tools-extra:clang附带的各种工具,比如clang-tidy、clangd、clang-format。
- lld:一个用LLVM理念重新实现的高性能链接器。
- lldb:取代GDB的调试器。
- libcxx / libcxxabi / libunwind:C++标准库与底层运行支持。
- compiler-rt:编译器运行时库,包含sanitizer、profile等。
- mlir:多层级IR基础设施,用来构建更灵活的机器学习、异构计算编译器。
- flang:Fortran前端。
- polly:多面体优化器。
- openmp:OpenMP运行时实现。
这几个子项目之间是既独立又协作的关系。比如你在编译一个普通C++程序时,Clang负责前端解析与IR生成,opt负责中端优化,llc负责后端汇编,lld负责链接,最终的跑起来的程序中还会链接进libc++和compiler-rt的一些运行时函数。每个环节都可以被替换、单独测试、单独研究。官方把所有代码放进一个仓库,是为了方便同步版本和统一测试,但对使用者来说,并不需要全部编译,构建时按需选择就好。
2. 首次接触必读:源码结构、构建方式与日常工具链
2.1 源码仓库目录到底该怎么看
第一次clone llvm-project下来,面对几十个顶层目录,很多人会有种无从下手的感觉。其实只需要抓住几个关键目录就行。
- llvm-project/llvm:这是核心仓库,包含IR定义、优化pass、代码生成后端、目标描述等。真正研究LLVM逻辑的地方就是这里。
- llvm-project/llvm/include/llvm:头文件目录,弄懂了include的结构就基本了解了LLVM的模块划分。
- llvm-project/llvm/lib/IR:LLVM IR的数据结构定义,比如Module、Function、BasicBlock、Instruction等。
- llvm-project/llvm/lib/Passes:新Pass管理器的入口逻辑。
- llvm-project/llvm/lib/CodeGen:代码生成相关,包含SelectionDAG、GlobalISel等。
- llvm-project/llvm/lib/Target:各种硬件后端的实现,X86、ARM、RISCV等都在这里。
- llvm-project/clang:前端代码,包含词法分析、语法树构建、语义分析、AST与IR转换。
- llvm-project/lld:链接器源码。
- llvm-project/libcxx:C++标准库实现。
我个人建议的学习顺序不是从代码量最多的库开始,而是先从llvm/lib/IR开始读数据结构,然后通过opt工具实验不同pass的行为,最后再深入CodeGen。因为IR是整个体系的“公约数”,理解了IR,前端和后端的代码看着就有方向了。
2.2 构建配置实战:从CMake到Ninja
LLVM的构建系统基于CMake,但工程量大,直接默认配置构建会非常慢,所以一定要按需裁剪。我常用的构建命令大致是这样:
git clone https://github.com/llvm/llvm-project.git cd llvm-project cmake -S llvm -B build -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DLLVM_ENABLE_PROJECTS="clang;lld;clang-tools-extra" \ -DLLVM_TARGETS_TO_BUILD="X86;AArch64" \ -DLLVM_USE_LINKER=lld \ -DLLVM_CCACHE_BUILD=ON cmake --build build --target clang lld几个参数解释一下。LLVM_ENABLE_PROJECTS决定要构建哪些子项目,不要一次性全列,构建时间会爆炸。LLVM_TARGETS_TO_BUILD指定后端架构,初学者如果只在本机实验,写个“X86”就够了,省下大量编译和链接时间。LLVM_USE_LINKER=lld是用lld作为链接器,这一步能明显加速多次链接过程,前提是系统里已经安装了一个可用的lld。LLVM_CCACHE_BUILD=ON打开ccache,对于反复编译(比如改一个pass)非常友好,命中缓存能省掉一半以上时间。
如果你用的LLVM版本比较新,还需要注意LLVM_ENABLE_PROJECTS和LLVM_ENABLE_RUNTIMES的划分。简单来说,libcxx、compiler-rt这类运行时库更适合放到RUNTIMES构建,因为它们要做多配置矩阵编译。第一次实验阶段,不建议直接开启libcxx和compiler-rt,等主链路跑通了再按官方文档调整。
2.3 日常高频工具的作用
llvm-project里有一批命令行工具,不需要天天编译全部,但下面这几个是理解和debug编译器流程的必备:
- clang:前端入口,负责把源代码转成IR、汇编或目标文件。
- opt:中端优化器实验台,可以在纯IR上跑任意pass组合。
- llc:后端代码生成工具,把IR变成汇编或目标文件。
- llvm-as / llvm-dis:文本IR与bitcode之间的互转。
- lli:直接解释执行IR,适合快速验证IR语义。
- llvm-mc:机器码编解码工具,常用来看汇编怎么编码成二进制。
- llvm-lit:测试运行工具,用来跑lit测试套件。
举一个典型流程:你想看一个C文件经过优化后长什么样,可以这样操作:
# 生成文本IR clang -S -emit-llvm test.c -o test.ll # 跑一遍O2管道 opt -S -passes='default<O2>' test.ll -o test.o2.ll # 生成汇编 llc test.o2.ll -o test.s这套“clang->opt->llc”的流程可以拆开使用,正是LLVM模块化设计最直接的体现。任何一步都可以单独替换,比如换一个优化管道、换一个目标架构,这在GCC里几乎是做不到的。
3. 核心机制拆解:从源码到机器码的流水线
3.1 LLVM IR是编译器世界的“通用语”
LLVM IR是一种采用SSA(Static Single Assignment,静态单赋值)形式的低层级中间表示。所谓SSA,本质上就是要求每个变量只能被赋值一次,变量一旦定义就不再变化。如果后续代码要修改同一个“逻辑变量”,就通过phi指令来合并不同分支上的值。这个设计简化了优化器的数据流分析,也方便指令重排、常量传播、死代码消除等操作的实现。
老看到有人问:为什么不直接用汇编做优化?原因很简单,汇编没有类型信息,没有变量作用域,指令绑定具体架构,优化逻辑会被细节淹没。LLVM IR则保留了类型(i32、ptr、struct等)和内存访问的层级,又比源码抽象层次低很多,方便进行机器无关优化。说人话:IR是给优化器准备的标准操作模型,不是给人直接写业务代码的。
一段最简单的LLVM IR长这样:
define i32 @add(i32 %a, i32 %b) { entry: %sum = add i32 %a, %b ret i32 %sum }这个函数接收两个i32,相加后返回。注意%sum只赋值一次,符合SSA约束。如果分支里有多个可能值,就要用phi合并,优化器和后端就能准确追踪每个值的来源。
从优化器的视角看,IR上的每条指令都对应一个“操作语义”,比如add、load、store、br、ret等。而后续的pass可以自由地增删或修改这些指令,只要不改变程序的可见行为。要深入理解LLVM,第一步就是学会“用IR思考”:一个C语言语句会被翻译成哪几条指令,一个循环使用的是自然循环还是更底层的基于跳转和cond_br的形式。
3.2 Pass管道与优化流程
LLVM的优化是全套pass的流水线作业。所谓pass,就是一次对IR或分析信息的遍历和处理。它可以做转换(transform),比如把一条乘法替换为移位加加法;也可以做分析(analysis),比如算出循环不变量供后续pass使用。
早期LLVM使用“legacy pass manager”,用字符串identifier来注册pass。现在已经全面切换到“new pass manager”,核心是显式的PassBuilder和Pipeline字符串。比如我想依次跑函数内联和指令合并,可以这样写:
opt -S -passes='function(instcombine,inline)' test.ll -o out.ll如果你想看看O2的完整管道到底跑了什么,可以加转储参数:
opt -S -passes='default<O2>' -debug-pass-manager test.ll -o out.ll这段命令会打印每一次pass执行的顺序和耗时。我实际看下来,即使是简单函数,O2下也有几十个pass在跑。它们在功能上分层:有的做规范化(如mem2reg把栈变量提升到寄存器),有的做标量优化(如gvn、licm),有的做向量化(loop vectorizer),最后再做一遍清理。理解这些pass并不需要全知全能,抓住几个核心代表就够了。
- mem2reg:消除
alloca,构建真正的SSA形式。 - instcombine:把多种模式匹配规则合并成更强表达式,为后续优化铺路。
- simplifycfg:简化控制流,合并基本块。
- gvn:全局值编号,消除公共子表达式。
- licm:循环不变量外提。
- loop-vectorize:把循环向量化。
我自己的经验是:拿到一段IR后,先无脑跑-passes=default<O2>看整体效果,再用-debug-pass-manager看执行顺序,最后用-print-before-all或-print-after-all观察每个pass对IR的影响。这套方法论比直接读源码更高效。
3.3 代码生成链路:SelectionDAG、GlobalISel、MIR
优化完的IR不会直接变成汇编,还要经过后端代码生成(CodeGen)。这一层的流程通常包含:
- IR 被转化为SelectionDAG(一种有向无环图),进行指令选择(Instruction Selection)。每个IR操作会被匹配到目标机器的具体指令模式。
- 指令调度(Schedule)和寄存器分配(Register Allocation)。寄存器分配是决定变量放物理寄存器还是栈上的关键环节,会直接影响到性能。
- 最后由MachineFunction逐步展开成MCInst,再交给汇编层导出ELF、Mach-O等二进制格式。
不同目标架构可以自己选择采用SelectionDAG还是较新的GlobalISel。GlobalISel的优点是把指令选择拆成更大的块,便于复用和调试,AArch64后端已经默认使用它处理部分优化级别。如果你想亲眼看看这部分,可以用llc的选项把中间的各步dump出来:
llc -O2 -run-pass=isel -stop-after=isel test.mir看MIR类似于看IR,但它已经包含目标机器的寄存器号、栈帧等信息。对于想移植后端或者做性能调优的人来说,MIR是比汇编更友好的研究材料。不过新手入门阶段,不需要把所有细节背下来,先把“IR -> SelectionDAG -> MIR -> MCInst -> 汇编”的整体流程记住,再针对具体架构去查阅TableGen描述文件就行。
4. 实操案例:手写IR、自定义Pass与调优
4.1 手写IR并跑通优化器
很多人在学习阶段会写一段C语言,再通过clang生成IR来观察。但有时候手写IR是更快的验证方式,因为可以精确控制输入。比如我想验证一个简单的表达式能否被优化成常数,可以直接写:
define i32 @mul_add() { entry: %v1 = mul i32 7, 6 %v2 = add i32 %v1, 3 ret i32 %v2 }然后用opt跑常量折叠:
opt -S -passes='instcombine' mul_add.ll -o out.lloutput的IR中mul i32 7, 6可能会变成i32 42,add也会被合并到45。这个实验能让你直观理解每个pass具体怎么改变IR。如果你想一步步观察,可以加上-print-after-all,把每个pass前后immediately后的IR都打印出来。
这种手写IR的练习我强烈建议多做几轮。因为IR就是LLVM世界的“汇编语言”,你能看懂IR,后面理解优化管道、写pass、看后端日志就都顺了。常见的练习题目包括:写一个间接跳转switch、写一个调用外部函数puts的代码、写一个带多个返回路径且需要phi的代码块。
4.2 写一个最简单的自定义Pass并集成到opt
如果要正式学习LLVM底层能力,写自定义pass是绕不开的一步。现在官方建议通过插件方式编写,无需修改LLVM源码本身。下面是一个新Pass管理器下最简单的FunctionPass示例,功能只是打印每个函数的名称:
#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 { struct HelloLowerPass final : public PassInfoMixin<HelloLowerPass> { PreservedAnalyses run(Function &F, FunctionAnalysisManager &AM) { errs() << "visit function: " << F.getName() << "\n"; return PreservedAnalyses::all(); } }; } // namespace extern "C" ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, "HelloLowerPass", LLVM_VERSION_STRING, [](PassBuilder &PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager &FPM, ArrayRef<PassBuilder::PipelineElement>) { if (Name == "hello-lower") { FPM.addPass(HelloLowerPass{}); return true; } return false; }); }}; }编译成.so插件:
clang++ -shared -fPIC -fno-rtti $(llvm-config --cxxflags) hello_pass.cpp -o hello.so然后通过opt加载测试:
opt -load-pass-plugin=./hello.so -passes=hello-lower mul_add.ll如果一切正常,你会看到每个函数名被打印出来。这个示例虽然简单,却包含了插件化pass的完整骨架:注册回调、匹配pass名、插入到FunctionPassManager、返回PreservedAnalyses。后面要做的分析或者变换,都可以在这个骨架上扩展。
说几个对我影响很大的注意点:第一,插件版本的LLVM_PLUGIN_API_VERSION要和主程序严格匹配,不匹配会报版本错误;第二,在pass中不要修改IR结构后还返回PreservedAnalyses::all(),那是虚假声明,会让后续分析失效甚至导致崩溃;第三,不要在分析时保留过期的指针,因为IR在优化管道的每个阶段都在变化。
4.3 编译时间与产物体积的调优心得
LLVM虽然优化能力强,但默认配置下编译时间和产物体积都比较“豪放”。我近期做一个嵌入式项目时,发现-Oz相比-O2能让固件体积缩小12%左右,代价是编译时间增加约30%。在实际使用时,要分场景设定优化策略:
- 开发调试阶段用-O0和关闭优化器,节省编译时间。
- 发布阶段用-O2或-Oz,配合LTO进一步跨模块优化。
- 需要极致体积时,还要加上
-fno-exceptions -fno-rtti -ffunction-sections -fdata-sections以及链接器侧--gc-sections。
LTO(Link Time Optimization)是我比较推荐的一招。默认单个编译单元内只能看到局部的优化机会,LTO把整个程序的IR都拿给优化器,跨函数、跨模块进行内联和常量传播,效果明显。启用也简单:
clang -flto=thin -O2 source_a.c source_b.c -o appThinLTO比full LTO的并行度更高,链接速度和内存占用更友好,我在多数项目里都用-flto=thin。代价是构建系统要配合处理bitcode文件,并且会有额外的链接期开销。如果项目里同时用了大量模板,LTO编译时间增加会更明显,建议用ccache缓存中间结果。
5. 生态延伸与工具链联动
5.1 Clang、LLD、libc++等子项目的定位
先说Clang。作为C/C++前端,它要处理词法、语法、语义,生成AST,最后落到LLVM IR。和GCC相比,Clang的优势是模块化、报错信息更友好、内置静态分析框架。你在日常命令行里敲的clang命令,其实是一整套驱动:它会调用cc1、opt、llc、lld等多个内部工具完成编译链接。
LLD是链接器。它最大的特点是快,启用lld后链接速度能达到传统GNU ld的数倍,对大型C++工程来说改善非常明显。此外,它的内部逻辑也更容易配合插件做分析,比如检查二进制中是否有未定义的符号、生成map文件,都比旧的脚本方式方便。
libc++则是C++标准库的实现。如果你用的标准库是默认的GNU libstdc++,那么Clang默认也会用GNU头文件。只有显式指定-stdlib=libc++时,才会用到llvm-project里的libc++实现。两者在ABI(函数签名、内存布局)上有差异,混合使用会出现莫名其妙的链接错误,所以整个项目的标准库选择一定要统一。
5.2 在语言实现中的应用:从MLIR到Flang
近几年LLVM生态最吸引我目光的是MLIR。简单说,MLIR允许你定义多种层级的中间表示,从接近上层语义的表示,一直降到LLVM IR。这让编译器的开发者可以用更细粒度去表达领域特定的优化,而不用把一切都压成通用的SSA指令序列。比如做机器学习编译器时,可以把“卷积”这种高层算子保留在高层IR里做融合,而不是先翻译成底层load/store,再依赖通用优化器去恢复结构。
Flang使用了一套类似思路:Fortran的数组语义、do循环语义可以在高层IR阶段做多重分析和变换,最终才降到LLVM IR做标量优化和代码生成。对编译器开发者来说,MLIR并不是替代LLVM,而是作为LLVM IR上层的“翻译车间”,两者协同工作。如果你不想深入MLIR,只做普通的C/C++编译,也可以暂时不关注,但如果有意向接触新语言设计或AI编译器,MLIR是绕不开的关键模块。
5.3 跨平台与交叉编译场景
LLVM天然支持交叉编译。你需要为ARM或RISC-V开发程序时,只需安装对应的target支持,并用--target指定三元组即可:
clang --target=aarch64-linux-gnu -O2 test.c -o test_aarch64前提是系统中有对应的sysroot和链接器。LLVM的各个后端是独立编译的,在构建时通过LLVM_TARGETS_TO_BUILD控制。如果你的llvm-project只编译了X86后端,却想生成ARM代码,clang会报“assembler not supported”或“cannot find target”之类的错误。解决方法是重新构建,把需要的架构加入target列表。
交叉编译时最容易被坑的是标准库头文件和库文件缺失。LLVM编译器本身只是把源码转成目标平台代码,但调用printf、malloc这些函数时,还是需要目标平台的libc和libc++。一般嵌入式项目会提供交叉编译的sysroot目录,通过--sysroot参数指定即可:
clang --target=aarch64-linux-gnu --sysroot=/path/to/sysroot -O2 test.c -o app经验是:先确认LLVM的target是否编译,再确认sysroot是否正确,最后检查链接阶段是否提供了所需库。不要一上来就在命令行堆参数,很多时候问题是链路里哪一层没配齐。
6. 常见问题与排查技巧实录
6.1 构建期问题速查
llvm-project因为体积大、组件多,构建时最容易出问题。下面是我实际遇到过的经典情况:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| make/ninja进程被Killed | 构建并行度太高,内存不足 | 降低-j参数;换gold或lld链接器;减少target列表 |
| 编译clang时没有头文件 | 系统没有安装对应的依赖 | 安装zlib、libxml2等基础开发包;或检查CMake缓存 |
| 版本不匹配的ABI错误 | 插件或工具和主程序版本不一致 | 统一llvm-project版本;编译插件时用相同llvm-config |
| ccache不命中 | 编译参数和路径发生变化 | 检查LLVM_CCACHE_BUILD;确认ccache配置正确 |
| 链接时缺libffi、libedit | 某些子项目开启了额外依赖 | 关闭对应选项,如LLVM_ENABLE_LIBEDIT=OFF、LLVM_ENABLE_FFI=OFF |
构建是我的建议是:不要开全部子项目,不要开全部target,不要用并行度过高的-j,留出至少16GB内存。如果机器配置一般,还可以先构建llvm核心再看clang。还有个小技巧:用cmake --build build --target install之前先ninja -t targets看看目标名,避免明明编了却没安装在预期路径的情况。
6.2 运行期与IR问题
IR相关的问题也是高频。比如opt运行报错“Invalid bitcode signature”,常见原因是.bc文件版本和opt版本不一致。另一个高频报错是“Bitcode file requires a different language ID”,多发生在bitcode是在不同语言前端下生成的情况下。最简单的排查路径是先用llvm-dis把bitcode转成文本IR,看看内容是否符合预期,再决定是不是工具版本不匹配。
在写pass时,我最常遇到的问题是unsafe iteration。假设你在遍历一个BasicBlock的指令列表,同时删除其中一些指令,就会导致迭代器失效。正确的做法是先把要删除的指令收集到临时容器里,遍历结束后再删除,或者使用make_early_inc_range来提前自增。这个问题在源码仓库中也有很多历史提交反复出现过,现在官方Review标准里专门有这条要求。
再补充一个关于Pass运行顺序的经验:如果你写的pass依赖某个analysis的结果(比如LoopInfo),务必在getAnalysis或通过AnalysisManager.getResult方式明确获取,不要自己重新计算。原因很简单:PassManager已经为分析和转换提供了缓存机制,不按接口走容易拿到的就是过期数据。
6.3 我踩过的一些坑与心得
我在写自己的编译器玩具时,踩过不少坑,挑几个有代表性的分享。
第一个是关于mem2reg和alloca的。一开始我把所有局部变量都用alloca分配,以为这是最“简单”的IR,结果优化效果非常差。后来才意识到,alloca本质上代表内存地址、别名分析的结果不准确,很多优化都白费。正确做法是让前端尽量生成虚拟寄存器形式的SSA值,只在取地址时才用alloca,然后让mem2reg把这些alloca提升回去。这也是为什么Clang的CodeGen会做两步:先创建一个alloca映射,再通过store/load访问,最后靠优化pass提升成寄存器IR。
第二个是关于phi节点的。在做CFG结构修改时,如果把一个block合并到另一个block,别忘了修复phi指令的操作数,否则验证器会直接报错“PHINode should have one entry for each predecessor”。这类错误的定位方法是用opt -verify跑一遍,让IR验证器告诉你具体哪里失效。
第三个是关于TableGen的。如果你想新增或修改后端的指令格式,会发现TableGen描述文件非常容易出错。我建议先用llvm-tblgen -print-records查看展开后的记录,再用llvm-mc做小样本测试,不要直接编译整个llc。这种“小步快跑”的方式能极大减少定位时间。
说到底,llvm-project是一个非常庞大的系统,但它的奖项就在“模块化”三个字。只要沿着“前端->IR->优化->后端”这条主线,配合opt、llc这些实验工具,慢慢就能把陌生感消除。如果你急着改后端,就去看lib/Target;如果想做前端,就去看clang;如果想理解优化,就从写一个最简单的pass开始。每走通一步,你会觉得这个项目的设计其实非常通透。
最后再分享一个小技巧:与其搜别人写好的宏达分析,不如在本地把源码clone下来,用llvm-config --help和ninja -t targets看看实际可用的命令,再配合llvm-undname、llvm-readobj这些小工具去反向验证你的理解。这些不起眼的工具,往往才是真正能帮你“确认自己懂了”的入口。