深入LLVM编译器基础设施:从核心架构到Pass编写与llvmpipe实践
2026/9/19 11:40:46 网站建设 项目流程

我用接近一年的时间断断续续读完了 llvm-project 的几个核心仓库,又从零开始把 LLVM 15.0.7 完整构建过两遍,期间还顺带折腾了 Clang、LLD、compiler-rt、libc++ 和 MLIR 这些子项目。这篇文章不是教材,也不是源码注释翻译,而是把我自己从“只知道 clang 能编译 C++”到“能看懂 LLVM 代码结构、能自己写简单 Pass、能调链接错误、能分辨 llvmpipe 和 GPU 驱动的区别”这段过程中的理解整理出来,希望对想碰 LLVM 但一直觉得无从下手的读者有点帮助。

1. 先搞清楚 llvm-project 到底是个什么东西

很多人第一次接触 LLVM,心里是懵的:有人说它是一个编译器,有人说它是一个虚拟机,还有人管它叫“底层虚拟机”。这些说法都不太准确。LLVM 的全称虽然确实是 Low Level Virtual Machine,但现在的 LLVM 已经远远超出“虚拟机”这个概念,更准确的说法是:一套模块化的编译器基础设施。所谓“基础设施”,意思是它不直接给你一个开箱即用的完整编译器,而是提供一大堆积木,让你能拼出编译器、代码分析工具、代码生成工具、运行时库,甚至 GPU 驱动里的着色器编译器。

llvm-project 这个仓库就是所有这些积木的集合。它并不是一个单一项目,而是被拆成很多子项目的“超级仓库”。我最初看的时候经常被目录吓到,llvm-project 根目录下有几十个文件夹,不能每个都看,得先知道谁负责干什么。

1.1 子项目地图:llvm-project 家族的组成

先列一个我后来确认过、又按实际用途简化过的子项目地图:

  • llvm:核心库,包含 IR、优化 Pass、目标描述、代码生成、汇编器、链接器等底层功能。整个 LLVM 存在于llvm/libllvm/tools/opt里。我们常说的“LLVM Core”就是它。
  • clang:C、C++、Objective-C 的前端,负责解析源码,生成 AST,最后翻译成 LLVM IR。这是绝大多数人最熟悉的入口,日常clang -O2 main.c走的就是它。
  • lld:新的链接器,特点是快。日常构建 C++ 项目如果觉得 GNU ld 太慢,换成 lld 有立竿见影的效果。
  • libc++:C++ 标准库实现,专门给 Clang 配合用的。
  • compiler-rt:编译器运行时库,里面包含很多内置函数、sanitizer(地址消毒器、未定义行为消毒器等)的实现、profile 相关的运行时,还有libfuzzer
  • mlir:多层 IR 框架,多用于做机器学习编译器,也承载了很多芯片厂商的编译器项目。
  • lldb:调试器,对应 GNU 那一堆里的 gdb。
  • clang-tools-extra:clangd、clang-tidy、include-what-you-use 这类工具。
  • polly:多面体优化,主要做循环变换。
  • flang:Fortran 前端。
  • llvmpipe:软件光栅化器,它属于 Mesa 那边经常提到,但 LLVM 本身也提供底层支持。它用 LLVM 的 JIT 能力把图形着色器编译成 CPU 指令,运行在没有 GPU 的环境里。

这张图里的子项目之间,不是简单的并列关系。llvm 核心库是底座,clang 和前端们把高级语言翻译到 IR,llvm 接着做优化和 codegen,lld 负责把目标文件合成可执行文件,compiler-rt 提供运行时支援,mlir 又把 IR 概念向上抽象了几层。整个是一个从语言到二进制的流水线。

1.2 为什么值得专门去研究它

我从实际开发里的体验来说,之所以值得花时间研究 LLVM,不只是因为它“很火”或者“GCC 太老土”。更实际的原因有三个:

第一,它让你真正理解编译过程的每一层。你写的一行x + y不是直接变成一条指令,而是先被 Clang 解析成 AST,然后被降级成 IR,经过一系列优化,再被翻译成目标机器的指令。每一层都对应一段可读的代码或文本,你可以把中间状态打印出来,这一点比 GCC 或者 MSVC 透明太多。

第二,它已经成为工业界的通用底座。除了 Apple 的编译工具链之外,Android NDK、Rust 的后端、Zig、Swift,甚至很多私有芯片的编译器都基于 LLVM 构建。学会了 LLVM 的结构,这些工具链对你就不是黑盒。

第三,它的代码组织方式是很大的设计样本。llvm-project 里的 TableGen、Pass 架构、优化流水线、指令选择框架,每一样单独拎出来都够写几十篇深度文章。能读懂这套架构,对写大型 C++ 工程也有帮助。

2. 把 LLVM 当成一条“翻译流水线”来理解

我在读源码的过程中,最深的感受是:LLVM 的一切设计都是为了把“编译”这件事拆分成清晰、可组合、可复用的阶段。

整个 LLVM 的核心设计思想可以用一个非常朴素的比喻讲清楚:你有一篇中文文章要翻译成英文,但你不是直接从中文逐词翻成英文,而是先翻译成“一种所有人都能看懂的国际通用语”,然后在这门通用语里做润色、精简、调整语序,最后再翻译成英文。这门“通用语”就是 LLVM IR,前面负责把各种高级语言翻译成 IR 的是前端,后面负责把 IR 翻译成各 CPU 指令的是后端。

2.1 三段式架构:前端、中端、后端

LLVM 把传统编译器区别得最清楚的地方,就是三部分独立:

  • 前端(Frontend):负责语法分析、语义分析、生成 AST,最后生成 LLVM IR。Clang 是 C/C++/ObjC 的前端,Rust 也有基于 LLVM 的后端,前端语言可能是 Rust 自己的 HIR/MIR。
  • 中端(Optimizer / Middle-end):拿到 IR 之后,不知道它是 C++ 写的还是 Rust 写的,只做与语言无关的优化。函数内联、循环展开、常量传播、死代码消除都在这里做。
  • 后端(Backend / CodeGen):把优化后的 IR 变成目标机器的汇编或机器码,需要处理指令选择、寄存器分配、指令调度等硬核问题。

这种拆分最大的好处是:如果芯片厂商想支持一门新语言,只要写前端;如果想支持一个新架构,只要写后端。两边互相独立,不用从头造轮子。

2.2 LLVM IR 看起来到底是什么样子

我最早被吓到的地方就是 IR,因为总感觉它既不像汇编,也不像 C。后来把例子跑了一遍,才意识到它其实很有规律。

一个最简单的 C 函数:

int add(int a, int b) { return a + b; }

clang -S -emit-llvm add.c -o add.ll编译,再打开add.ll,你会看到类似这样的 IR:

define i32 @add(i32 noundef %a, i32 noundef %b) { entry: %add = add nsw i32 %a, %b ret i32 %add }

这里面值得留意的点:@add前带@表示全局函数或全局变量,%a%b前带%表示局部值。i32是 32 位整数类型。add nsw i32 %a, %b这条指令做了加法,nsw表示“no signed wrap”(无符号溢出),这是一个给优化器用的标志。

更重要的设计是 SSA(静态单赋值)形式:每个变量只能被赋值一次。比如一段复杂的 C 代码经过 Clang 生成 IR 后,所有临时中间结果都会变成一个新的%1%2%3……这种形式天生就适合做数据流分析和优化,减少了很多其他编译器需要额外分析的麻烦。

2.3 优化流水线:Pass 是怎么一个个串起来的

中端优化在 LLVM 里是以“Pass”为单位组织的。一个 Pass 就是对 IR 做一趟遍历或变换,比如做完死代码消除的 pass,再做一遍循环展开的 pass,每个 pass 都是相对独立的模块。

LLVM 的优化流程可以在命令行里显式控制。用opt工具单独跑某个 pass 是非常常见的调试手段。假设我写了一个 pass 路径,想看它单独作用的效果,可以这样:

clang -S -emit-llvm foo.c -o foo.ll opt -passes=instcombine -S foo.ll -o foo_optimized.ll

-passes参数指定 pipeline,旧版本也可以用-instcombine这种短横线名称,但我个人建议新代码一律用-passes的 new pass manager 语法。因为 LLVM 15 之后 old pass manager 相关接口已经逐步移除了,很多网上的资料还停留在旧写法,踩坑得很明显。

Pass 的返回值、依赖关系、如何声明,这些代码写多了之后会形成肌肉记忆。对于第一次接触的人来说,关键是理解一个事实:编译器优化不是一次性把一个函数变成最优的,而是不同 pass 反复作用,互相配合收敛到更好的结果。所以你在一个 pass 里看到的效果很有限,但整个流水线叠加起来,提升非常明显。

3. 从 llvm-project 仓库获取源码并构建一次完整的 LLVM

聊完设计思想,该动手了。源码级别的实践,第一步就是拉代码和构建。这一步有很多细节,稍不注意就是几个小时的等待之后才发现配置错了。我在这里把过程完整捋一遍。

3.1 从 GitHub 拉取代码

llvm-project 的官方仓库在 GitHub,路径就是代码库本身。直接完整 clone 有点大,仓库带历史可能在几个 GB 级别。我习惯做浅克隆,只拿最新的历史记录,这样省时间又省磁盘:

git clone --depth=1 --branch llvmorg-15.0.7 https://github.com/llvm/llvm-project.git

这里我特意选了llvmorg-15.0.7这个 tag,因为之前很多实验都基于 15.0.7 验证过。如果读者想用更新的版本也没问题,但构建选项有些地方可能有细微差异,需要看对应版本的文档。

浅克隆之后如果不小心切分支,可能会因为深度限制报错。所以如果是想深度研究某个模块的 git 历史,建议再单独git fetch --unshallow

3.2 环境准备与磁盘规划

构建 LLVM 最容易被低估的是磁盘和内存。我头一次构建没规划好,直接在磁盘剩 40G 的时候开跑,结果中途爆了。这里说一下我的经验值:

  • 完整构建所有子项目,源码之外需要大约 30~50GB 磁盘空间。
  • 如果只构建clanglldllvm这几个目标,大概 20GB 左右就够。
  • 内存少于 8GB 的话,建议限制并行任务的并发数,否则容易 OOM。

系统方面,Linux 上需要cmakeninjagccclangpython3以及zliblibxml2之类的开发库。macOS 上安装 Xcode Command Line Tools 就行。Windows 上比较麻烦,得用 Visual Studio 的生成器,我一般不推荐在 Windows 上直接全量编译,除非特定场景。

3.3 用 CMake + Ninja 完成构建的推荐配置

LLVM 官方文档推荐用 CMake 和 Ninja,我自己也是这么用的。下面是一个经过实测、比较平衡的配置:

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_ENABLE_ASSERTIONS=ON \ -DCMAKE_C_COMPILER=clang \ -DCMAKE_CXX_COMPILER=clang++ \ ../llvm

然后:

ninja clang lld

如果想把所有项目都编了,可以直接ninja。但我不建议一开始就这么干,因为时间很长。只编clanglld可以很快拿到可用的工具链。

配置里几个选项的解释:

  • LLVM_ENABLE_PROJECTS:指定要构建哪些子项目。子项目之间用分号分隔,而且必须放在引号里,否则 CMake 解析容易出现诡异问题。
  • LLVM_TARGETS_TO_BUILD:指定后端目标架构。全量构建默认会包含很多 target,我一般只留 X86 和 AArch64,能大幅缩短编译时间。
  • LLVM_ENABLE_ASSERTIONS:开启断言。开发调试时强烈建议打开,能让很多错误提前暴露。但如果只要一个朴素的 release 工具链,为了性能也可以关掉。
  • CMAKE_BUILD_TYPE=Release:影响优化等级和调试信息,Release 模式下 pass 运行最快。

编译过程中时常会遇到单个 C++ 文件把内存吃满的问题。如果系统内存紧张,我一般用-j 4-j 8来控制并行度,比如:

ninja -j8 clang lld

Ninja 的默认并行度可能直接拉满所有核心,机器差一点就卡死了,新手很容易在这里被劝退。

3.4 构建完成后怎么确认装好了

构建生成的工具默认在build/bin目录下。我刚构建完的时候习惯先跑一个版本命令确认:

./bin/clang --version ./bin/llc --version ./bin/opt --version

然后写个 hello world 测试:

int main() { return 42; }
./bin/clang -O2 hello.c -o hello ./hello echo $?

能输出42,基本说明工具链是可用的。

4. 实操:用 clang / opt / llc 亲手拆解一段代码的编译过程

前面构建出了工具链,现在用来做一件最有意思的事情:亲手把一段代码从 C 语言变成最终汇编,并且每一步都亲眼看一看。这个流程能让你把“三段式架构”从概念变成体感。

4.1 生成 IR 并观察优化效果

先准备一份带点计算的 C 代码,方便看到优化的变化:

int square(int x) { return x * x; } int compute(int n) { int sum = 0; for (int i = 0; i < n; i++) { sum += square(i); } return sum; }

第一步,生成未优化的 IR:

clang -S -emit-llvm test.c -o test.ll

打开test.ll,你会看到squarecompute两个函数,以及storeload这类指令。未优化时,循环里的变量全在栈上存取,看起来很啰嗦。

第二步,生成优化后的 IR:

clang -S -emit-llvm -O2 test.c -o test_opt.ll

这时候square很可能已经被内联到compute里,循环也做了很多变换。我对比过两版 IR,差异非常明显。这个对比是理解“优化 pass 的作用”最直观的实验。

4.2 使用 opt 单独执行某个 Pass

如果想单独看某个 pass 的效果,可以用opt。例如我想看mem2reg(提升内存为寄存器的 pass)的作用:

clang -S -emit-llvm -Xclang -disable-O0-optnone test.c -o test.ll opt -passes=mem2reg -S test.ll -o test_mem2reg.ll

不过我实际用clang -O0生成的 IR 里函数会带optnone属性,直接跑很多 pass 不生效。所以我上面用了-Xclang -disable-O0-optnone来去掉这个属性。这个细节是我踩过坑之后才明白的。

如果你自己写了新的 LLVM pass,想快速验证,一般就是把opt挂载上你的 pass 插件,然后输入 IR 文件,看输出 IR 是否符合预期。这是 LLVM 开发最正常的调试循环。

4.3 用 llc 查看最终后端生成的汇编

llc是 LLVM 的静态编译器后端工具,能把 IR 变成汇编或者目标文件。看汇编最直接:

clang -S -emit-llvm -O2 test.c -o test.ll llc test.ll -o test.s

打开test.s,你能看到 X86 汇编。如果想看 ARM64 的,可以指定目标:

llc test.ll -mtriple=aarch64-linux-gnu -o test_arm64.s

这里的-mtriple指定目标三元组。它能帮你在 x86 机器上生成其他架构的汇编,不需要交叉编译器也能研究指令选择过程。

4.4 链接中的 LLD 与常见错误排查

当代码从.s变成.o,最后就需要链接。LLVM 自带的链接器是 lld,它在处理大型 C++ 项目时往往比 GNU ld 快很多。日常使用,我经常这么切换:

clang -O2 -fuse-ld=lld main.cpp -o main

如果项目用 CMake 构建,在配置时可以这样设:

cmake -DCMAKE_EXE_LINKER_FLAGS="-fuse-ld=lld" ..

LLD 除了快,错误信息也一般更友好。特别是链接 C++ 时最常见的“undefined reference”,它会给出更清晰的符号归属信息。但也别指望它全知全能,遇到 LTO 相关问题时,错误信息一样会很绕。我见过最崩溃的链接错误是符号版本脚本和 LTO 一起使用时产生的奇怪报错,当时只能一点点把-flto去掉排查,最后发现是 ld.lld 对某个 section 保留策略的处理跟 GNU ld 不同。

5. 再深一层:手写一个最简单的 LLVM Pass

如果只是用工具链,其实不需要懂太多内部原理。但真正的乐趣和深度,都在写 Pass 和改后端代码里。我建议每一位想深入了解 LLVM 的读者,都亲手写一个简单的 function pass,哪怕只是打印函数名。

5.1 Pass 的种类:Module、Function、Loop、BasicBlock

LLVM 的 Pass 有几种作用粒度:

  • ModulePass:作用于整个 module。适合做全局分析。
  • FunctionPass/FunctionAnalysisManagerPass:作用于每个函数。大多数优化都是这个级别。
  • LoopPass:作用于循环。
  • BasicBlockPass:作用于基本块。

现代 LLVM 使用 New Pass Manager,注册方式已经模板化了。写 pass 时一般从一个AnalysisInfoMixin或者PassInfoMixin派生,然后实现run方法。

5.2 准备一个可以独立编译的 Function Pass

下面这个例子我验证过,能直接在 LLVM 15 上跑。它遍历所有函数,打印函数名和参数数量:

#include "llvm/IR/Function.h" #include "llvm/IR/Module.h" #include "llvm/IR/LegacyPassManager.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 PrintFunctionPass : public PassInfoMixin<PrintFunctionPass> { public: PreservedAnalyses run(Function &F, FunctionAnalysisManager &AM) { errs() << "Function: " << F.getName() << ", args: " << F.arg_size() << "\n"; return PreservedAnalyses::all(); } }; } // namespace extern "C" ::llvm::PassPluginLibraryInfo LLVM_ATTRIBUTE_WEAK llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, "PrintFunctionPass", LLVM_VERSION_STRING, [](PassBuilder &PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager &FPM, ArrayRef<PassBuilder::PipelineElement>) { if (Name == "print-function") { FPM.addPass(PrintFunctionPass()); return true; } return false; }); }}; }

这段代码里,关键的几个概念:

  • PassInfoMixin是 New Pass Manager 的 mixin 基类。
  • run方法返回PreservedAnalyses,表示这个 pass 保留了哪些分析结果。如果不确定,最保守的是返回PreservedAnalyses::all()表示什么都不破坏。但如果你修改了函数内容,就该返回PreservedAnalyses::none(),否则容易导致后续 pass 用了过期的分析结果,产生很难查的 bug。
  • 最后那个llvmGetPassPluginInfo是动态库插件导出的标准入口,编译之后可以用opt -load-pass-plugin加载。

5.3 编译插件并在 opt 中使用

把上面的文件保存为PrintFunctionPass.cpp,用clang++编译成动态库:

clang++ -shared -fPIC \ -I/path/to/llvm-project/llvm/include \ -I/path/to/llvm-project/build/include \ PrintFunctionPass.cpp \ -o libPrintFunctionPass.so

注意,build/include是 CMake 构建过程中生成的头文件目录,里面有llvm/Config/llvm-config.h等文件。源码 include 目录加上 build include 目录,缺一不可。

然后找一个 IR 文件,加载插件跑一下:

opt -load-pass-plugin=./libPrintFunctionPass.so \ -passes="print-function" \ -disable-output test.ll

如果看到控制台打印了函数名和参数数量,说明 pass 工作正常。

这一步跑通之后,你就真正具备了“给 LLVM 添加自定义编译优化能力”的基础。后面的方向可以是写常量折叠、死代码消除、内联策略自定义等等。不管哪种,调试思路都一样:编成插件,用 opt 加载,看 IR 变化。

5.4 关于 Legacy Pass 的兼容性经验

网上很多教程还在用legacy::FunctionPassRegisterPass那套接口。在 LLVM 14 之后,legacy pass manager 在新代码里已经逐渐被边缘化。我自己第一次写 pass 时参考的是旧文章,代码能编过但是警告一大堆,运行方式也和旧版本不一样。

所以我的建议是:直接用New Pass Manager接口写新代码。旧代码可以看懂思想,但不用照着抄。遇到接口差异,最有效的办法是去llvm/include/llvm/Passes/PassBuilder.h里查函数声明,它比任何旧教程都可靠。

6. 深入理解 llvmpipe 与 256-bit 向量:LLVM 在图形/性能领域的影子

聊到这里,“llvm-project” 的核心脉络已经讲得差不多了。但有网友在热词里提到llvmpipe (llvm 15.0.7, 256 bits),这个组合很有意思,它把 LLVM 引向了一个很多人不太注意的领域:图形渲染与高性能计算。

6.1 llvmpipe 是什么,和 LLVM 有什么关系

llvmpipe 是 Mesa 项目里的一个软件渲染器,它用 LLVM 的 JIT 引擎在 CPU 上动态生成渲染代码。当系统没有可用 GPU 驱动时,或者用户强制用软件渲染时,llvmpipe 会把 OpenGL / Vulkan 的着色器编译成 CPU 指令,然后靠多核 CPU 完成光栅化。

我在一台无 GPU 的虚拟机上跑过 Vulkan 应用,mesa 会自动加载 llvmpipe,虽然帧率很低,但至少能跑。它背后的工作原理可以这样理解:GPU 驱动里有一个着色器编译器把 GLSL 翻译成 GPU 指令,而 llvmpipe 把这个编译器目标从 GPU 指令替换成了 LLVM IR,再用 LLVM 的 JIT 生成当前 CPU 的机器码。

所以 llvmpipe 不是 llvm-project 仓库里的一个目录(Mesa 在调用 LLVM 的 API),但它完全建立在 LLVM 的基础能力之上。可以说,没有 LLVM,软件渲染就很难做到这种跨 CPU 架构的动态优化能力。

6.2 “256 bits” 指的是什么

热词里提到 “256 bits”,指的是 LLVM 后端在 x86 平台上可以利用 256 位向量寄存器 AVX2/AVX 等指令集进行 SIMD 计算。llvmpipe 编译出的代码会尽量把多个像素、多个顶点的计算打包到同一组向量指令里。

如果你在 x86 机器上跑clang -march=native,LLVM 后端的向量化器会识别代码里的并行模式,生成 YMM 寄存器相关的指令。这些 256 位寄存器可以一次处理 8 个 32 位整数或者 8 个单精度浮点数,这就是性能飞升的基础。

我可以拿一个简单的例子说明向量化:

void add_array(float *a, float *b, float *c, int n) { for (int i = 0; i < n; i++) { c[i] = a[i] + b[i]; } }

clang -O3 -mavx2 -S编译,生成的汇编里会看到很多vaddps ymm0, ymm1, ymm2这样的指令。vaddps是一次性对 256 位寄存器里的多个单精度浮点数做加法。如果没有开向量化,可能就是addss一次次处理单精度浮点,性能差一个量级。

“llvm 15.0.7, 256 bits” 这种信息通常出现在打印 log 或者软件渲染的 debug 输出里,表示当前的 llvmpipe 构建基于 LLVM 15.0.7,而且它认为当前 CPU 支持 256 位向量指令。如果 CPU 支持 AVX-512,还可能出现 512 bits 的字样,性能更强。

6.3 向量宽度对软件渲染和通用代码优化的启示

从这里能引出一个关键经验:在 x86 平台上做性能优化,首先要想代码是否适合做 SIMD 向量化,其次才是考虑缓存、多线程这些东西。LLVM 的自动向量化能力很强,但前提是循环结构足够规整,不存在过多的复杂依赖和分支。

我给普通开发者的建议是:

  • 优先开启-O2-O3,让 LLVM 自动向量化。
  • 尝试用-march=native让编译器使用本机 CPU 支持的指令集,但要注意部署环境是否一致,否则二进制可能在其他机器上无法运行。
  • 如果循环的迭代之间没有依赖,可以适当使用#pragma clang loop vectorize(enable)引导编译器向量化。
  • 要是追求极致性能,可以手动写__m256类型的内部函数,但开发成本高,而且可读性差,非必要不推荐。

llvmpipe 的例子也说明,LLVM 不仅可以把高级语言编译成目标文件,还能在运行时 JIT 编译,这对很多软件项目都是隐藏能力。类似的应用还包括 OpenCL CPU 设备、WebAssembly 的 JIT 编译等。

7. 给刚入门的开发者:源码阅读顺序与避坑指南

最后一块内容,讲一点我个人的学习路径和踩坑记录。如果你准备长期和 llvm-project 打交道,一个合理的阅读顺序能帮你少走很多弯路。

7.1 推荐阅读顺序

我自己的顺序大致是:

  • 先读llvm/include/llvm/IR下的头文件,了解ModuleFunctionBasicBlockInstructionValueType这些核心类的关系。这是所有其他模块的基础。
  • 再看llvm/lib/IR下的实现,理解这些类怎么被创建和操作。
  • 然后跑一遍官方的llvm/examples/Kaleidoscope教程,这是一个完整的“实现一门新语言并生成 LLVM IR”的教程。能真正让人把前端的语义和后端的 IR 生成串联起来。
  • 接着写一个简单 Pass,像上面那个插件,体会 pass 机制。
  • 再往深走,就可以研究llvm/lib/Transforms/InstCombinellvm/lib/CodeGen/SelectionDAG等具体模块。

这套顺序的好处是,每一步的知识都能被下一步用到,不会出现“读了很多概念但用不起来”的情况。

7.2 常见坑:C++ 模板、TableGen、CMake 构建

第一条坑:LLVM 用了大量现代 C++ 特性,模板元编程、CRTP、Mixin 都很常见。如果平时主要写业务代码,刚开始读 LLVM 源码会有很强的陌生感。不用焦虑,先看函数名、注释以及面向使用者的接口,别一上来就扎进特别深的模板实现里。

第二条坑:TableGen是 LLVM 自定义的一种描述语言,用于描述指令集、寄存器、Pass 参数等。在后端开发里几乎无处不躲。不熟悉的时候会觉得很别扭,但它其实就是一种高级 DSL,生成的.inc文件才是真正参与编译的代码。如果看不懂某个.td文件,可以看它生成的.inc,有时候能豁然开朗。

第三条坑:CMake 配置出错。最经典的问题是LLVM_ENABLE_PROJECTS的值必须用引号包裹,否则分号会被 shell 拆成两个参数。另外,修改了 CMake 缓存后不会自动清理旧配置,遇到诡异问题可以直接删掉 build 目录重建。

第四条坑:构建太慢。缓解办法是不要默认构建所有 target,只保留需要的目标;使用ccache缓存编译结果;增量构建尽量只跑ninja clangninja lld这种局部目标。我自己第一次全量构建花了几个小时,二次基于 ccache 构建快到可以接受。

7.3 快速定位代码的实用工具

平时阅读和调试 LLVM,我常用的工具有:

  • llvm-dis:把 bitcode(.bc)转回 IR 文本。
  • llvm-as:把 IR 文本转成 bitcode。
  • opt -print-after-all:打印每个 pass 之后的 IR。这在排查“哪个 pass 把代码变坏了”时极其有用。
  • clang -Rpass=loop-vectorize -Rpass-missed=loop-vectorize:打印向量化成功/失败的诊断信息,做性能分析时很爽。
  • llvm-symbolizer:配合 sanitizer 输出的堆栈信息使用,能还原出源码行号。

有些问题还需要用git loggit bisect定位引入嫌疑的 commit。特别是你在新版本上发现行为变化,但旧版本正常的时候,bisect 是最科学的办法。

8. 一个老实践者的最后建议

研究 llvm-project 不是一蹴而就的事。它体量巨大,领域横跨编译原理、操作系统、底层体系结构、语言标准、图论优化、并发编程。任何人都不可能在一两篇文章里“学会 LLVM”,但反过来,只要你坚持每天花一点时间去读、去编、去跑,它的回报也比大多数技术栈都更扎实。

我到现在仍记得第一次自己写的 pass 成功改变 IR 输出的那种兴奋感。那时候我终于明白,那些看似深不可测的编译优化,不过是成千上万个严格定义的 Pass 在按规则反复“修理”代码。LLVM 最大的价值不在于某一个优化特别神,而在于它把整个编译过程体系化了、工程化了、工业化了你不需要成为天才才能参与,只要有耐心,人人都能在这套体系里找到自己可以改进和贡献的位置。

如果你现在还在观望,我的建议只有一个:立刻新建一个 build 目录,先跑一遍 LLVM 15.0.7 的构建,然后写一个打印函数名的 pass,加载进 opt 里看一眼输出。走完这一圈,你就已经比昨天更接近 LLVM 的核心了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询