在编译器这个圈子里摸爬滚打这些年,llvm-project可以说是我电脑里最常打交道的一堆代码了。不管是给某个芯片架构做指令集支持,还是想把一门语言编译成高效的机器码,绕来绕去最后都会落到这个项目上。如果你正准备进入编译器领域,或者工作中突然被指派去调一个莫名其妙的编译报错,那么搞清楚 llvm-project 的结构和用法,是值得先做的事。
这篇文章我会用偏工程实践的角度,讲清楚 LLVM 的核心设计、怎么把源码跑起来、怎么写第一个 Pass,还会分享一些我自己踩过的坑和排查思路。内容不追求教科书式的面面俱到,但求每个技术点都落到实处,你看完能直接动手试。
1. LLVM 到底是什么:一个工具链,而不只是一个编译器
先把这个概念掰开。很多人听到 LLVM 第一反应是“哦,一个 C/C++ 编译器”,这个说法对了一半。Clang 确实是个编译器,而且是非常优秀的 C/C++/Objective-C 编译器,但 Clang 只是 llvm-project 这个巨大工程里的一个前端。LLVM 真正厉害的地方,是一套设计精良的编译器基础设施,尤其是那个处于中间层的中间表示(IR,Intermediate Representation)。
1.1 先搞明白编译器的三段式架构
一个现代编译器,如果你想让它支持多种语言、多种目标架构,最经典的架构就是拆成三段。
前端负责把源代码解析成某种统一的中间表示;中端在这份中间表示上做与机器无关的优化,比如循环展开、常量传播、死代码消除;后端则把优化后的中间表示翻译成目标机器的汇编指令。
这个三段式架构的好处是,你要支持一门新语言,只需要新写一个前端;你要支持一种新 CPU,只需要新写一个后端。前端和中端之间靠 IR 解耦,中端和后端之间也靠 IR 解耦。
llvm-project 里打包的就是这套完整的东西。Clang 是 C 系语言的前端,LLVM core 是中端和后端,LLD 是链接器,compiler-rt 提供运行时库,libc++ 是标准库实现,MLIR 则是面向机器学习和其他领域的一层更灵活的多级 IR 框架。
1.2 LLVM 能做什么:几个最常见的应用场景
从我实际接触到的案例来看,LLVM 的价值体现在几个非常具体的方向上。
第一类是编程语言开发。如果你在折腾一门新的静态编译语言,不用从零写代码生成器,直接把自己的 AST 降级成 LLVM IR,就能凭空获得几十个目标架构的支持,以及各种成熟的优化功能。这个红利非常实在,市面上不少新兴编程语言就是这么干的。
第二类是针对特定硬件做代码生成。比如公司设计了一颗新的处理器核心,想让 GCC 和 LLVM 都支持这颗核心,LLVM 通常比 GCC 更友好,模块化更好,新后端开发的周期短得多。
第三类是静态分析与代码插桩。LLVM IR 是结构化的、类型化的,而且保留了大量控制流信息,基于 IR 做程序分析比在汇编层面做要轻松太多。很多代码安全扫描工具、溯源工具、覆盖率工具,底层实际就是跑在 LLVM Pass 之上。
第四类是 GPU 生态与异构计算。NVIDIA 的 NVVM、AMD 的 ROCm、苹果的 Metal 编译器,底层都吸收或借鉴了 LLVM 的技术体系。想深入高性能计算领域,LLVM 是绕不开的核心。
2. 为什么我劝你先别急着写代码:先搞懂 LLVM 的设计哲学
我刚接触 llvm-project 的时候,第一反应是直接拉代码然后 make,结果把环境整得乱七八糟,跑了半天还在编依赖。后来回头看,先用一个周末把设计哲学搞清楚,收益远大于盲目动手。
2.1 IR 是整个项目的灵魂
LLVM IR 是一种静态单赋值形式(SSA,Static Single Assignment)的强类型中间表示。它有三个互相可转换的形态:内存中的数据结构、可读的文本形式,以及序列化的 bitcode 形式。
文本形式一般以.ll为扩展名,bitcode 一般是.bc。你可以随时用llvm-as把文本转成 bitcode,用llvm-dis把 bitcode 还原成文本。这个设计对调试极其友好,因为任何优化流程中间状态都可以 dump 成文本拿来看,问题出在哪一个 Pass 一目了然。
一个简单的 LLVM IR 长这样:
define i32 @add_one(i32 %x) { entry: %add = add i32 %x, 1 ret i32 %add }define用来定义函数,i32是整数类型,@add_one是函数名,entry是基本块标签。你可以看到每个中间值都有类型,每条指令都体现 SSA 思想:每个变量最多被赋值一次,之后只被引用。
为什么要设计成 SSA?这是 LLVM 能高效做优化的大前提。SSA 形式下,变量只有唯一一个定义点,数据流信息直接体现在名字的依赖关系上,很多优化算法都能实现得更优雅。比如常量传播,在 SSA 里只需要沿着use-def链往前看定义指令是否为常量,非常直观。
为了维护 SSA 的约束,当变量的值来自多个控制流路径时需要引入phi节点。这个概念对新手来说略抽象,但非常重要。你可以把 phi 理解成一条合流指令,它根据当前到达基本块的路径,从几个候选值里选中一个作为结果。理解了 phi,你才算真正理解了 IR 的数据流。
define i32 @max(i32 %a, i32 %b) { entry: %cmp = icmp sgt i32 %a, %b br i1 %cmp, label %then, label %else then: br label %merge else: br label %merge merge: %result = phi i32 [ %a, %then ], [ %b, %else ] ret i32 %result }2.2 为什么选择三种形态可互换的设计
我自己在开发 Pass 的时候,最常用的调试方法是这样的:
写一个.c文件,用clang -emit-llvm -S编译成.ll,然后用opt -passname跑某个单独的 Pass,再用llc看生成的目标汇编。整个过程每走一步都能看到中间产物。
这个工作流说明了大工程的工程哲学:每一步都设计成可观测、可替换的。你在里面做研究也好,做工程也好,都不会有“一黑到底”的无力感。
2.3 TableGen:定义一切描述的领域专用语言
LLVM 的后端描述文件里充满了.td文件,这些文件用 TableGen 语言编写。如果你要开发新后端,TableGen 是你每天都要打交道的 DSL。
它的作用一句话说清楚:用声明式的方式写出指令集架构的特征,然后由 TableGen 工具在构建时生成大量 C++ 代码。例如,target description、指令选择匹配器、寄存器信息等,都可以从.td文件里自动生成。
很多人第一次被.td文件搞崩溃,是因为不知道它不是在写普通的代码,而是在写“元描述”。理解这一点之后再上手,就顺多了。
3. 从源码把 LLVM 跑起来:第一份交到手上的资产
说完了设计,来点动手的。llvm-project 本身是个超大的 CMake 工程,全量构建需要很长时间,但掌握正确的姿势之后,整个过程并不复杂。
3.1 依赖准备与版本选择
先强调一点:LLVM 的 main 分支更新非常激进,几乎每天都有大量提交,如果对稳定性有要求,不要用 main 分支,而是用 release 分支。目前我推荐 17.x 或 18.x 版本,功能完善且社区资料多。
编译 LLVM 需要 GCC 或 Clang 编译器、CMake 3.20 以上、Ninja 或 Make,以及 Python 3.6 以上用于运行测试脚本。在装依赖时不要贪多,缺什么再补什么就行。
3.2 配置构建的常用参数
拉取代码并进入目录之后,我的推荐配置是:
git clone --depth 1 --branch llvmorg-18.1.8 https://github.com/llvm/llvm-project.git cd llvm-project mkdir build && cd build cmake -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DLLVM_ENABLE_PROJECTS="clang;lld" \ -DLLVM_TARGETS_TO_BUILD="X86;AArch64" \ -DLLVM_INSTALL_UTILS=ON \ ../llvmLLVM_ENABLE_PROJECTS决定额外构建哪些子项目。如果不需要 Clang,可以只留空或单独调 core;但我基本每次都带 clang 和 lld,因为日常调试离不开它们。
LLVM_TARGETS_TO_BUILD是一个非常值得谨慎设置的参数。默认情况下会把所有后端的代码都编译一遍,包括 SystemZ、MIPS、PowerPC 等可能永远用不到的架构。只保留自己需要的架构,能显著减少编译时间,我实测大概能省 20% 到 40% 的时间。
LLVM_INSTALL_UTILS=ON会把FileCheck、llvm-lit这些测试工具装好,后面做测试时没它不行。
设置完执行:
ninja如果你机器配置一般,这一跑可能要等很久。建议打开ninja -j4或者根据自己的 CPU 核数适当调低并行度,别把内存打满导致系统卡死。我试过在 16 核 32G 内存的机器上全量构建,大约 30 到 50 分钟,具体取决于项目和机器。
构建完毕之后,所有二进制都在build/bin下。你可以先验证一下:
./bin/llc --version能看到版本信息基本就说明构建成功。
3.3 手动写一个最简单的 LLVM IR 并运行
有了二进制,第一时间体验 IR 执行链路。保存上面那个add_one函数为add.ll,然后执行:
./bin/lli add.lllli是 LLVM 的解释器,可以直接运行 IR。不过这个函数只定义没有入口,跑不出结果。可以改造成一个可执行程序:
define i32 @main() { entry: %a = call i32 @add_one(i32 41) ret i32 %a } define i32 @add_one(i32 %x) { entry: %add = add i32 %x, 1 ret i32 %add }然后用:
./bin/lli add.ll echo $?输出结果为 42,说明 IR 解释执行链路是通的。再试一下编译到目标汇编:
./bin/llc add.ll -o add.s打开add.s就能看到为你本机架构生成的汇编代码。这个过程虽然简单,但你是亲手让一个现代编译器从 IR 走到了汇编,这对理解整个编译链路特别有帮助。
4. 亲手写第一个 Pass:入门与进阶的分水岭
很多人在 LLVM 里真正想干的第一件事,不是跑通工具链,而是写一个属于自己的优化 Pass。这件事也是初学者最容易卡住的地方。我自己也在这上面折腾了很久,所以这里把心得写透。
4.1 Pass 的类型与新旧 Pass Manager
先解释一下 Pass 是什么。Pass 是 LLVM 中进行分析与转换的基本单元。你写了一个 Pass,把它注册进 Pass 管道,LLVM 就会按你指定的顺序在 IR 上执行它的逻辑。
从使用方式上看,Pass 分为分析 Pass 和转换 Pass。分析 Pass 只计算并缓存信息,比如统计函数调用次数、识别循环结构,并不修改 IR;转换 Pass 则会对 IR 进行改写,比如删除死代码、内联函数。
从实现接口上看,早期大量 Pass 继承自FunctionPass,而新的 Pass Manager 框架里,推荐实现llvm::PassInfoMixin<YourPass>配合llvm::FunctionAnalysisManager。新 PM 解决了旧 PM 的很多问题,比如更清晰的依赖管理、更好的并发能力、更严格的分析结果缓存。19 版本里旧 PM 基本已经退出历史舞台,所以新同学直接从新 PM 开始学就好,不要再被网上老教程带偏。
4.2 一个能跑的 Function Pass
我们实现一个很小的功能:统计一个函数里add指令的数量,并且对每个函数打印出来。纯粹为了熟悉框架。
新建文件FunctionInfo.cpp:
#include "llvm/IR/Function.h" #include "llvm/IR/Instructions.h" #include "llvm/Pass.h" #include "llvm/Passes/PassBuilder.h" #include "llvm/Passes/PassPlugin.h" #include "llvm/Support/raw_ostream.h" using namespace llvm; namespace { class FunctionInfoPass : public PassInfoMixin<FunctionInfoPass> { public: PreservedAnalyses run(Function &F, FunctionAnalysisManager &AM) { int addCount = 0; for (BasicBlock &BB : F) { for (Instruction &I : BB) { if (auto *BinOp = dyn_cast<BinaryOperator>(&I)) { if (BinOp->getOpcode() == Instruction::Add) { addCount++; } } } } errs() << "Function " << F.getName() << " has " << addCount << " add instructions\n"; return PreservedAnalyses::all(); } }; } // namespace llvm::PassPluginLibraryInfo getLLVMFunctionInfoPluginInfo() { return {LLVM_PLUGIN_API_VERSION, "FunctionInfo", LLVM_VERSION_STRING, [](PassBuilder &PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager &FPM, ArrayRef<PassBuilder::PipelineElement>) { if (Name == "function-info") { FPM.addPass(FunctionInfoPass()); return true; } return false; }); }}; } extern "C" LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return getLLVMFunctionInfoPluginInfo(); }这段代码里最关键的两个机制是:
PassInfoMixin是新 PM 的 min 风格接口,你只需要实现一个run方法,返回PreservedAnalyses表示当前 Pass 保留了哪些分析结果。如果你的 Pass 没改任何 IR,只做分析,就应该返回PreservedAnalyses::all(),这样后续 Pass 就不用重新计算分析信息。如果你改了 IR,需要准确声明破坏了哪些分析,否则会有难查的错误。
pass-plugin机制允许你把 Pass 编译成.so文件,运行时动态加载,不污染主程序。这在开发调试阶段非常方便。
用opt编译并加载这个插件:
clang++ -shared -fPIC -fno-rtti FunctionInfo.cpp -o libFunctionInfo.so \ `llvm-config --cxxflags --ldflags --libs` ./bin/opt -load-pass-plugin=./libFunctionInfo.so \ -passes="function-info" add.ll -disable-output如果看到类似:
Function add_one has 1 add instructions说明你的 Pass 已经生效。
4.3 调试技巧与常见错误
写 Pass 最容易栽的坑,我用惨痛经验帮你列一下。
第一,忘了返回正确的PreservedAnalyses。如果你改了指令却返回all(),等于告诉后续分析“我啥都没动”,后续 Pass 拿了过期的分析信息,可能会导致错误的优化结果。这个问题极其阴险,因为不一定立刻崩溃,而是某些优化行为变得奇怪。调试时可以在 Pass 里把assert打开,或对比开不开你这个 Pass 的输出差异。
第二,opt的-passes=语法写错。新版 Pass 管道语法和旧版差距很大,比如函数内 Pass 的管道要用双引号包起,并且可能需要在前面加function()。我给你的建议是先用-print-after-all配合-passes=测试,再看opt --help-hidden里对 pipeline 的说明。
第三,插件 API 版本不匹配。llvmGetPassPluginInfo里返回的LLVM_PLUGIN_API_VERSION必须和你编译时的 LLVM 头文件一致。如果动态库是在旧版本 LLVM 上编的,load 进新版 opt 会直接报 plugin API 版本不匹配。解决办法只有一个:用同一份源码编译你的插件和 opt。
5. 自己动手折腾一个精简后端结构:理解指令选择与寄存器分配
写完 Pass,很多人下一个目标就是“给 LLVM 加一个新后端”。这一步难度不小,但它恰恰是 llvm-project 被大规模用于芯片生态的核心原因。为了让你有个正确的心理模型,我把一个后端的主要组成部分拆开讲讲。
5.1 理解后端的主要组成部分
一个 LLVM 后端,核心要做的事情包括:
代码从 LLVM IR 到 SelectionDAG(有向无环图,是 LLVM 做指令选择时采用的中间结构)的转化,然后做指令选择,把 IR 指令映射成目标机器的指令;接着做指令调度,让指令顺序更符合硬件流水线;然后做寄存器分配,把无限的虚拟寄存器映射到有限的物理寄存器;最后做指令输出,生成目标架构的汇编文本。
在 LLVM 后端里,一个常见的文件组织方式包括:
X86.td、RISCV.td这类 target description 文件,用 TableGen 定义寄存器、指令、调用约定等;ISelLowering.cpp负责处理目标相关的操作合法化,比如某些架构没有 64 位整数除法,就得在这里拆成多次 32 位运算;InstrInfo.td定义每条指令的形式、编码、语义;RegisterInfo.td定义寄存器类和寄存器别名;AsmParser和MC层负责处理汇编的解析与生成。
这个图景很庞大,但好消息是 LLVM 的模块化设计允许你一步一步来。你完全可以先写一个只有少数几条指令的极简后端,编译 tiny C 程序,看到汇编输出,再逐步补充指令。
5.2 从 RISC-V 后端开始学
如果你想练手,我建议不要直接裸写一个全新的目标,而是复制或参考 RISC-V 的后端,因为 RISC-V 的指令集设计精简、规范,寄存器数量适中,是 LLVM 后端的“hello world”。
我自己当初是这么做的:在llvm/lib/Target下复制一份 RISCV,全局改名成 MYRISCV,然后从最小子集开始删指令。改 TableGen 文件里的指令定义、寄存器定义,再加上编译系统里的 CMake 依赖,最后用 clang 把一段简单的return 42编译到我的“新后端”。
这个过程非常容易出幺蛾子,每个细节都可能让你卡住半天。但每次通过llc看到自己定义的指令出现在汇编输出里,那种成就感是别的项目给不了的。
5.3 后端常见的编译错误与定位思路
给后端做开发,最常遇到的错误有两类。
一类是 TableGen 报错,比如error: No match for intrinsic,一般是你的Intrinsics.td写出的 intrinsic 签名和调用处不一致。可以运行llvm-tblgen -gen-instr-info来看看生成的指令信息表,快速排查定义问题。
另一类是SelectionDAG类型合法化崩溃,类似于Cannot select。遇到这种情况,通常需要看llvm-mc或者llc -debug-only=isel对某个具体指令的 ISel 流程跟踪输出。把-debug-only=isel开起来,你就能看到 IR 节点的 legalize 过程在哪里断了。
不要害怕这些报错,后端开发的本质就是在这些报错里反复定位、修改、验证。一定要熟练使用llc -debug-only=xxx系列的调试开关,这是 LLVM 开发者最重要的武器之一。
6. 学习资源与进阶路线建议
如果你想在 llvm-project 这条路线上走得更深,光靠我这篇文章肯定不够,所以我把自己亲测有效的学习路线整理出来。
6.1 官方文档怎么读
LLVM 的官方文档整体质量很高,但组织比较分散,新手容易迷茫。我的建议是不要按顺序通读,而是按任务去查。
写 Pass 之前,重点看Writing an LLVM Pass,这是最核心的一篇;理解 IR 结构,翻LangRef,这是 LLVM IR 的完整参考;想通设计和哲学,看LLVM Programmer’s Manual,能帮你了解各种实用 API 和数据结构。
6.2 通过读代码来学架构
读 llvm-project 的源码比看任何文档都有用。我推荐几个入门的阅读路径:
- 阅读
lib/IR/Instructions.cpp和lib/IR/BasicBlock.cpp,了解 IR 基本对象如何管理; - 阅读
lib/Transforms/InstCombine/InstCombineAddSub.cpp,看官方如何做指令级别的化简; - 阅读
lib/Target/RISCV/RISCVISelLowering.cpp,了解后端如何将目标无关 IR 转换成目标语义。
阅读时要配合调试器,比如用lldb或gdb在InstCombine设置断点,亲眼看着一条 IR 指令如何被改写。这在学习早期比任何抽象理解都管用。
6.3 社区与交流建议
LLVM 社区活跃度很高,邮件列表和 Discourse 论坛上经常有大牛回答,但提问前一定要遵循基本礼仪:先跑bugpoint或opt -print-after-all获取最小复现用例,把环境和版本信息写清楚。提问质量越高,获得有效回答的概率越大。
还有一点:llvm-project 的贡献指南写得非常清楚,如果你想提交代码,先在 Discourse 上发 RFC,尤其是指令集扩展、Pass 新功能这种涉及面较大的改动,提前沟通能避免做无用功。
7. 常见问题速查表:我在实践中踩过的坑
最后,把我在实操中遇到的高频问题整理成一张速查表,方便你以后排查。
| 现象 | 可能原因 | 解决措施 |
|---|---|---|
构建时 CMake 报找不到ninja | 未安装或未加入 PATH | 安装 ninja-build,或改用-G "Unix Makefiles" |
llc大量输出指令选择错误 | TableGen 中指令模式不完整 | 用-debug-only=isel跟踪选择流程,补全模式 |
| 加载插件报 no such plugin | 插件编译时的 LLVM 版本与 opt 不一致 | 用同一份 build 产物下的头文件和库重新编译插件 |
-passes=命令行解析失败 | 新旧 Pass 语法混用 | 先查看opt --help-hidden确认 Pass 注册名 |
| Pass 改了 IR 但输出不变 | 未正确修改到 IR,或 Pass 被跳过 | 在 run 函数打印指令数量确认是否执行 |
| 无法跑通 RISC-V 后端 | 目标未加入LLVM_TARGETS_TO_BUILD | 重新 CMake 时加入 RISC-V |
| Release 模式断点不生效 | 优化级别较高导致指令重排 | 用-DCMAKE_BUILD_TYPE=Debug重新构建 |
写 Pass 时dyn_cast总是返回空 | 类型不匹配 | 先确认Instruction::getOpcode()的枚举值 |
| 链接时出现重复符号 | 插件和主程序都编译了同一份代码 | 编译插件时避免链接整个 LLVM 库,用-fvisibility=hidden |
修改.td文件后构建不报错但行为不变 | TableGen 没有重新生成 | 删除 build 目录里相关.inc文件后重新构建 |
这张表不可能覆盖所有问题,但绝大多数新手期的困惑都能在里面找到方向。
8. 关于后续扩展的一些想法
写完第一个 Pass、跑通一次后端修改之后,你会发现自己对“编译器”这个黑盒的掌控感完全不同了。很多人学到这个阶段会问:接下来往哪走?
我觉得有几个方向可以看兴趣选。
一是深入了解优化算法的设计。从 InstCombine 的指令化简,到 GVN 的全局值编号,再到 LoopUnroll 的循环优化,每一块拿出来都够研究很久。这里的经验是:用 LLVM 的opt -print-after-all配合具体例子,亲手对比开和关某个 Pass 前后的 IR,理解速度特别快。
二是往 MLIR 方向走。MLIR 是 llvm-project 里发展非常快的子项目,它允许你定义多级 IR,在高层做领域专属优化,再逐步 lower 到 LLVM IR。做机器学习编译器、做硬件综合,都会遇到它。
三是往工具链周边走,比如学习 LLD 的链接原理、学习 compiler-rt 里各种运行时库的实现、甚至研究 libc++ 的实现细节。编译器的世界非常深,但每一层都和 LLVM 这一套基础设施紧密相关。
根据我个人的经验,学习 LLVM 最大的收获,往往还不只是“会写 Pass”和“会调后端”,而是一种理解程序的新视角。当你开始习惯用 IR 的粒度去思考程序的语义,很多东西会变得清晰很多。