LLVM 这个项目,我在不同阶段反复接触过好几次。最早是拿它当 C 编译器用,后来做性能分析时开始看它生成的中间码,再后来自己动手写过几个分析 pass,才算真正摸到门道。如果你也准备啃 LLVM,或者正在为“到底该怎么入手”发愁,这篇内容应该能帮你省掉不少弯路。
先说清楚 LLVM 到底是什么。它不只是一个编译器,而是一整套编译器基础设施。我们平时说的“ Clang ”只是 LLVM 项目里的一个 C/C++ 前端,真正核心的是那套中间表示(IR)、优化器、后端代码生成,以及围绕这些构建的庞大工具链。llvm-project 这个仓库,就是把所有子项目放在一起的统一代码库,包括 LLVM 核心库、Clang、LLD 链接器、libc++、compiler-rt、OpenMP 运行时等等。换句话说,你想研究的编译器技术,几乎都能在这个仓库里找到落地的样本。
这套东西能做什么,解决什么问题,对不同的人有不同的答案。对于做编译器的工程师,它是目前最活跃的开源编译器平台;对于做编程语言设计的人,它提供了一个完整的“前端-优化-后端”框架,你只需要写好前端,就能吃到 LLVM 的优化和后端生成能力;对于做性能工程的人,它能帮你精确控制代码生成,甚至直接查看优化前后的 IR 变化;对于纯粹想学习编译原理的人,llvm-project 是一本巨大的、可以运行的教科书。所以,无论你是学生、研究员、工具链开发者,还是系统软件工程师,这个项目都值得花时间去研究。
不过,llvm-project 的体量也决定了它的门槛。第一次 clone 仓库、第一次 CMake 构建、第一次看到那几十个 target 名字时,很容易一头雾水。我自己第一次构建是在一台配置一般的笔记本上,等了一个多小时,当时心里想的不是“好厉害”,而是“我到底干了什么”。所以这篇内容我不想写那种“ LLVM 简介”的官样文章,而是从一个实际使用者、二次开发者的角度,把这套项目从大到小、从原理到实操,一点一点拆开。
1. llvm-project 的整体面貌:它不是“一个编译器”,而是一条完整的编译器流水线
如果你去看 llvm-project 的仓库结构,会看到顶层有一堆目录——llvm、clang、lld、libcxx、libcxxabi、compiler-rt、polly、flang、mlir、openmp、parallel-libs,第一次打开的人多半会懵。这些目录看起来是平级关系,但在编译流水线里各自承担完全不同的角色。
最容易理解的方式是把编译器想象成一条工厂流水线:原料是源代码,产品是机器码。流水线第一站是“前端”,负责把源代码解析成编译器能理解的中间表达;第二站是“优化器”,在中间表达上做各种等价变换,让它跑得更快或更小;第三站是“后端”,把优化后的中间表达翻译成具体 CPU 的机器指令。LLVM 项目最了不起的地方,就是把这三个环节彻底解耦了。
具体到仓库里,Clang 是 C/C++/Objective-C 的前端,Flang 是 Fortran 前端,而 llvm 目录本身包含了优化器和后端。你写一个 C 文件,用 clang 编译时,实际发生的是:Clang 把 C 代码翻译成 LLVM IR,LLVM 优化器对 IR 做处理,然后 LLVM 后端按照你指定的目标架构生成汇编和机器码。这三步是清清楚楚分开的,这也是 LLVM 能支持那么多语言、那么多 CPU 架构的根本原因——新的语言只需要写前端,新的芯片只需要写后端,中间的优化器可以完全复用。
别忘了还有一堆“配套工厂”。LLD 是链接器,负责把编译出来的目标文件链接成可执行文件;compiler-rt 提供运行时支持,包括很多内建函数和 sanitizer;libc++ 是 C++ 标准库实现;polly 是一个基于多面体模型的循环优化器;MLIR 是后来加入的,它是一套构建编译器的框架,说白了就是“用来构建前端的框架”,在机器学习、异构计算领域非常活跃。
我看到很多初学者上来就钻进 llvm 目录里看代码,结果被各种 pass 和数据结构搞得头大。我的建议是,先用“流水线思维”建立整体框架:一个源文件经过哪几个阶段,每个阶段由哪个子项目负责,产物是什么。框架清楚了,再往里填细节就容易得多。
1.1 核心子项目之间的依赖关系,也就是“谁依赖谁”
clang / flang 依赖 llvm 与 clang 自身的头文件 lld 依赖 llvm 核心库 libc++ 与 compiler-rt 相互独立,但 libc++abi 依赖 libc++ polly 依赖 llvm,且需要特定的 pass 注册机制 mlir 依赖 llvm,但与 clang 没有直接依赖这套依赖关系直接影响了你构建时的 CMake 配置。如果你只需要 clang,那就可以关掉 lld、polly、mlir 等组件,显著减少构建时间。如果你要开发一个基于 MLIR 的方言,那就需要把 mlir 打开,但可以关掉 flang、openmp 之类的组件。
另一个需要理解的概念是“target”。LLVM 支持几十种 CPU 架构,但你在构建时不需要全部编译进去。常见的 target 包括 X86、AArch64、ARM、RISCV、PowerPC、SystemZ 等,配置时用LLVM_TARGETS_TO_BUILD指定。这个选项直接影响构建时间和生成的编译器体积。对于大多数在 x86 机器上做实验的人来说,只保留 X86 就够了,等真正需要交叉编译时再添加其他 target。
仓库的版本管理也值得一提。LLVM 的发布节奏是半年一个大版本,版本号通常是偶数。master 分支永远处于开发状态,API 变化频繁,所以如果不是要贡献代码,而是要做稳定开发,建议直接使用某个 release 分支或 tag。我看到不少人在 master 上写 pass,写完之后发现 LLVM API 变了,代码编译不过,这种体验真的非常打击人。
1.2 tablegen:LLVM 项目里最容易被忽略的“代码生成器”
看 LLVM 源码时,你会频繁看到 .td(TableGen)文件,比如 X86.td、RISCV.td。TableGen 是 LLVM 自己的一套 DSL,专门用来描述指令集、寄存器、调用约定等高度规律化的信息。它不会直接出现在编译产物里,而是在构建时由 llvm-tblgen 工具处理,生成 C++ 代码。
刚开始我很不理解:为什么指令集描述不用普通的 C++ 类?后来写了一点 .td 代码才明白,指令集描述里面的规律性太强了——每个指令有助记符、操作数类型、编码格式、指令选择模式、汇编打印方式……如果全部手写 C++,不仅量大,而且极容易出错。用 TableGen 描述后,一个指令只需写一行,自动生成对应的解析、打印、匹配代码,出错的概率小很多。而且,你新增一条指令时,只需要改 .td 文件,不用满世界找各个地方的 switch-case。
TableGen 是 LLVM 二次开发绕不过去的一道坎。如果你打算给某个后端加指令,或者自定义目标架构,至少要能读懂 .td 文件的结构。学习路径可以是:先看 include/llvm/Target/Target.td 里定义的基础类,再看某个具体后端的 .td 文件,理解 Instruction、Register、Pattern 这些核心概念,最后尝试修改一条指令,观察生成代码的变化。这个过程能帮你快速理解 LLVM 后端的工作方式。
2. LLVM IR:整个项目的灵魂,也是理解优化的关键入口
如果说 LLVM 项目有一个东西是必须理解的,那就是 LLVM IR。它对编译过程的意义,有点类似于 Java 字节码之于 JVM——但 LLVM IR 的设计目标更强调可分析性和可优化性。IR 是一种静态单赋值(SSA)形式,每个变量只被赋值一次,这种约束让数据流分析变得非常直观。
看一下实际的 IR 长什么样。写一个简单的 C 函数:
int add(int a, int b) { return a + b; }用clang -S -emit-llvm add.c -o add.ll编译,得到类似这样的 IR:
define i32 @add(i32 noundef %a, i32 noundef %b) { entry: %add = add nsw i32 %a, %b ret i32 %add }理解这一段有几个关键点。define i32 @add定义了一个返回 i32、名为 add 的函数。i32是 32 位整数类型。%a和%b是虚拟寄存器,也就是 SSA 变量,它们只在这一处被赋值。add nsw是带 no-signed-wrap 标志的加法,告诉优化器这里不会发生有符号溢出,从而允许更多优化。ret是返回指令。
IR 有三种形式:内存中的表示(Memory IR)、位码(Bitcode,.bc 文件)和文本形式(.ll 文件)。三者完全等价,可以在任意时刻互相转换。文本形式方便人阅读和调试,位码形式适合存储和传输,内存中的表示是优化器实际操作的对象。调试时最常用的思路是:用-S -emit-llvm生成文本 IR,用opt工具跑某个 pass,再对比优化前后的 IR 变化。
我自己的习惯是,每写一个 C 函数,都会顺手编译成 IR 看一眼。这样能直观感受到 clang 在前端做了哪些处理——比如struct的布局、static函数的内联、常量折叠等。这些在源码层面是看不到的,但 IR 会把它们暴露无遗。理解了 IR,你就真正理解了“编译器的视角”。
2.1 pass 机制:优化和分析都是一个个“插件”
LLVM 的优化器核心就是一个 pass 框架。所谓 pass,就是对 IR 做一次遍历和变换。有的 pass 只分析不修改,比如统计函数调用次数;有的 pass 会改写 IR,比如死代码消除、循环展开、函数内联。每个 pass 是一个独立模块,可以单独运行、组合运行,这也是 LLVM 能持续演进且保持稳定架构的原因。
举个例子,死代码消除(DCE)这个 pass,做的事情是:如果一个指令的计算结果没有任何后续使用,那就把它删掉。对于一个上了优化等级的编译器来说,这属于非常基础的清理。但它的实现却涉及数据流分析——编译器需要精确知道一条指令的定义是否被使用,而 SSA 形式让这个判断变得极其简单。这也是 LLVM IR 设计的精妙之处。
我看过很多初学者自己实现分析 pass 时,第一件事就是遍历函数的所有指令,这当然没错,但 LLVM 提供了一整套遍历和回调机制,能让你用更简洁、更安全的方式写 pass。比如在新版 pass 框架(New PM)里,你只需要实现一个run函数,框架会帮你处理模块、函数、循环的遍历顺序。这个设计把编译器的“流程控制”和“具体分析逻辑”解耦了。
从学习角度,我强烈建议你动手写一个最简单的 pass:统计每个函数的基本块数量,并打印出来。这个过程会逼着你理解 Module、Function、BasicBlock、Instruction 这四层结构,以及它们之间的遍历关系。写完之后,再用opt -passes=your-pass跑一下,看到输出的一瞬间,你对 LLVM 的理解会提升一个台阶。
2.2 通过 opt 和 clang 的参数,观察优化器的实际行为
LLVM 提供了一些命令行工具来单独跑优化器,最常用的就是opt。比如我们想对一个 .ll 文件做 mem2reg 优化,这个 pass 会把alloca+load+store这种栈上变量访问提升为 SSA 寄存器,是理解 SSA 与内存访问关系的最佳入门 pass:
opt -passes=mem2reg add.ll -S -o add.mem2reg.ll运行前你会看到函数体里有alloca指令,运行后这些指令就消失了,取而代之的是直接在寄存器间的操作。这个过程能让你直观感受到“优化”到底在做什么——它不是魔法,而是可预测、可理解、甚至可手动模拟的代码变换。
如果你想看整个优化流程的全貌,可以这样操作:
clang -O2 -S -emit-llvm add.c -o add.o2.ll用 -O2 编译后的 IR 与默认 IR 对比,你会发现函数可能被内联了、循环被展开了、某些计算被常量折叠了。这时候可以用llvm-dis或者文本编辑器直接打开 .ll 文件,仔细看每个 pass 留下的痕迹。理解优化器对于写出高性能代码非常有帮助——你会开始意识到,C 代码里的某些写法会在哪个阶段被转换成什么,哪些优化该由编译器负责,哪些必须手工完成。
3. 从零开始构建 llvm-project:一次成功的本地构建实践
这个部分我打算从自己的实际操作经验出发,完整走一遍构建流程。LLVM 的构建系统用的是 CMake,而 CMake 的配置选项非常多,不用全部掌握,但有几个关键选项因为你必须理解,否则构建体验会非常痛苦。
我这次是在 Ubuntu 22.04 上构建 LLVM 17 的 release 分支。先说硬件:构建 LLVM 非常吃内存和 CPU。官方推荐最少 8GB 内存、4 核以上,实际体验下来,如果你只有 4GB 内存,编译到链接阶段很容易把内存耗光,机器直接卡死。我的建议是至少 16GB 内存,如果条件允许,用 32GB 也不嫌多。磁盘空间也要留够,release 版本全量构建可能需要 40GB 以上,master 分支会更多。如果你空间紧张,构建完后可以执行ninja clean清理中间文件,只保留安装产物。
首先获取源码。这里要特别提醒:不要直接 clone master 分支。因为 master 是开发分支,API 一直在变,今天编译过,明天可能就编译不过了。正确做法是获取某个 release tag:
git clone --depth 1 --branch llvmorg-17.0.6 https://github.com/llvm/llvm-project.git cd llvm-project--depth 1是浅克隆,只拉取这一个 tag 的代码,速度会快很多。后续如果想切换到其他版本,可以去掉--depth 1做完整克隆,或者直接重新浅克隆一个新 tag。
然后是配置构建目录。LLVM 官方推荐“out-of-source”构建方式,也就是在源码树外面建一个 build 目录,避免污染源码。我用的是 Ninja 构建系统,配合 ccache 做编译缓存,第二次构建会快很多。配置命令如下:
cmake -G Ninja -S llvm -B build \ -DCMAKE_BUILD_TYPE=Release \ -DLLVM_TARGETS_TO_BUILD="X86;AArch64" \ -DLLVM_ENABLE_PROJECTS="clang;lld" \ -DLLVM_ENABLE_ASSERTIONS=ON \ -DCMAKE_C_COMPILER=clang \ -DCMAKE_CXX_COMPILER=clang++逐项解释一下这些选项的含义:LLVM_TARGETS_TO_BUILD指定要生成哪些后端的 target,这里只选了 X86 和 AArch64,如果全部默认值会把几十种架构都编译进去,构建时间会变得非常夸张。LLVM_ENABLE_PROJECTS是需要一起构建的其它子项目,我选了 clang 和 lld,如果你还需要 compiler-rt 或者 mlir,可以在这里加。LLVM_ENABLE_ASSERTIONS=ON会开启断言,对调试和开发有帮助,代价是生成的编译器运行时会慢一些。如果只是要一个高性能的发布版编译器,可以关掉它。编译器选择 clang 是 LLVM 社区的习惯做法,官方对 clang 构建 clang 的支持最好,但 gcc 也可以。
配置完成后再执行构建:
cmake --build build -j $(nproc)并行编译的核数根据你的 CPU 决定。我实测在一台 8 核 16 线程的机器上,构建 clang+lld 大约需要 30 到 45 分钟;如果只构建 LLVM 核心库,大概 20 分钟。如果把几十个 target 和所有项目都编进去,几个小时也不是没可能。
构建成功之后,下一步就是安装。这一步用到的路径要仔细规划,因为 LLVM 推荐把整个工具链安装到同一个前缀目录,比如/opt/llvm或$HOME/llvm-project-install:
cmake --install build --prefix /opt/llvm安装完成后,你会看到/opt/llvm/bin目录下有 clang、lld、llvm-objdump、opt、llvm-dis 等一大堆工具。把/opt/llvm/bin加到 PATH 里,就可以像使用系统编译器一样使用 LLVM 工具链了。
3.1 构建选项的取舍:如何定制出适合自己需求的 LLVM
很多人第一次构建 LLVM 都是默认配置一把梭,然后被漫长的编译时间劝退。我建议在配置前先想清楚自己的目标,再决定选项:
- 如果你只是想用 clang 这个编译器,那只需要构建 clang 和 lld,target 选一个就够了,甚至可以关掉 LLVM_ENABLE_PROJECTS,只构建 LLVM 核心,然后用系统自带的 clang。
- 如果你要做 IR 优化相关实验,比如写 pass 或做分析,那么 clang 不是必须的,LLVM 核心加了 opt 工具就够了,构建时间会大幅下降。
- 如果你要开发新后端,那么需要对应的 target,可能需要用 TableGen 生成代码,还需要确保 enable-timeline 之类的调试工具打开。
- 如果你是做性能分析,建议打开
LLVM_ENABLE_PERF或使用-ftime-report查看编译时间,这会给你很多有用信息。
另外一个常见争议是 build type。Release模式因为关闭了 debug 信息,构建速度快,运行时的 llvm 工具也快;Debug模式适合调试 LLVM 自身代码,但因为断言和 debug 符号,速度和大小都受影响。做二次开发时,我的经验是:用RelWithDebInfo构建一次,既能得到较高的运行性能,又保留了基本调试信息。如果遇到难以定位的问题,再单独编一个 Debug 版本。
3.2 利用 ccache 加速二次构建
LLVM 的代码量很大,哪怕改了很小一行,重新编译某几个目标文件可能就要几分钟,全部重编更是灾难。这里我非常推荐 ccache。安装好之后,在 CMake 配置时启用它:
cmake -G Ninja -S llvm -B build \ -DCMAKE_C_COMPILER_LAUNCHER=ccache \ -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ ...配置好之后,第一次构建会照常编译,但会把每个目标文件缓存起来。第二次构建时,如果你只是改了一个头文件,缓存命中率会很高,整体编译时间大幅缩短。我试过在改了某个 TableGen 文件后,重新构建从原来的半小时缩短到一分钟以内,这在调试 pass 和修改 IR 相关功能时非常受用。
4. 动手实践:在 LLVM 中新增一个自定义 pass 并运行验证
理论说再多,不如动手写一个 pass。这里我选一个入门级的任务——写一个 function pass,统计每个函数的基本块数量和指令数量,并在每个函数处理完后打印输出。这个任务覆盖了 LLVM 二次开发最核心的流程:编写 pass、注册 pass、构建、运行。
先建立一个独立目录,方便以后扩展。LLVM 的 pass 既可以放在 LLVM 源码树里一起构建,也可以作为外部插件单独构建。为了减少对主树的依赖,这里我选择外部插件方式,更贴近实际开发中“给 LLVM 加自定义功能”的使用场景。
在项目目录下创建源文件FunctionInfo.cpp:
#include "llvm/IR/Function.h" #include "llvm/IR/Instructions.h" #include "llvm/IR/Module.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) { unsigned numBlocks = 0; unsigned numInstructions = 0; for (auto &BB : F) { ++numBlocks; for (auto &I : BB) { (void)I; ++numInstructions; } } errs() << "Function: " << F.getName() << ", Blocks: " << numBlocks << ", Instructions: " << numInstructions << "\n"; return PreservedAnalyses::all(); } }; } // namespace llvm::PassPluginLibraryInfo getFunctionInfoPluginInfo() { 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 getFunctionInfoPluginInfo(); }这段代码有几个关键点需要解释。PassInfoMixin是新版 pass 框架(New PM)的标准基类,它要求你实现run方法。FunctionAnalysisManager是一个分析结果的缓存容器,虽然本例没用它,但在复杂 pass 中你会频繁需要它来获取分析信息,比如 TargetLibraryInfo、LoopInfo 等。PreservedAnalyses::all()表示该 pass 不修改任何 IR,优化器可以放心地复用之前的分析结果,这对性能很关键——如果你改了 IR 但没正确报告哪些分析失效,可能触发难以察觉的错误。
llvmGetPassPluginInfo是插件入口,LLVM_ATTRIBUTE_WEAK允许多个插件共存而不冲突,这个符号会被opt或clang在动态加载时调用。注册回调时,我用的是registerPipelineParsingCallback,这意味着你只能在opt的 pass 管道命令行中通过-passes=function-info来调用它,而不是默认跑在所有优化级别里。如果想默认启用,需要改用registerPipelineStartEPCallback。
接下来写构建文件CMakeLists.txt:
cmake_minimum_required(VERSION 3.20) project(FunctionInfo) find_package(LLVM REQUIRED CONFIG) message(STATUS "Found LLVM ${LLVM_PACKAGE_VERSION}") message(STATUS "Using LLVMConfig.cmake in: ${LLVM_DIR}") include_directories(${LLVM_INCLUDE_DIRS}) separate_arguments(LLVM_DEFINITIONS_LIST NATIVE_COMMAND ${LLVM_DEFINITIONS}) add_definitions(${LLVM_DEFINITIONS_LIST}) add_library(FunctionInfo MODULE FunctionInfo.cpp) target_link_libraries(FunctionInfo PRIVATE LLVMCore LLVMSupport)这里最关键的一点是find_package(LLVM REQUIRED CONFIG)。要让这段 CMake 找到 LLVM,需要先执行 LLVM 构建时的安装步骤,或者在配置 LLVM 时开启LLVM_BUILD_TOOLS,然后通过-DLLVM_DIR指定 LLVMConfig.cmake 所在路径。实际构建时,我用如下命令:
mkdir build && cd build cmake -DLLVM_DIR=/opt/llvm/lib/cmake/llvm .. make编译成功后会生成libFunctionInfo.so,这就是可加载的 pass 插件。接下来验证我们的 pass 是否有效。写一个简单的测试函数test.c:
int func_a(int x) { int sum = 0; for (int i = 0; i < x; i++) { sum += i; } return sum; } static int helper(int a, int b) { return a + b; } int func_b(int a, int b) { return helper(a, b) + 1; }先用 clang 生成 IR,再通过 opt 加载插件并指定 pass 名称:
clang -S -emit-llvm test.c -o test.ll opt -load-pass-plugin=./build/libFunctionInfo.so -passes=function-info test.ll -o /dev/null运行后,我得到的输出如下:
Function: func_a, Blocks: 3, Instructions: 11 Function: helper, Blocks: 1, Instructions: 3 Function: func_b, Blocks: 1, Instructions: 4从结果可以看到,func_a因为含循环,基本块数量是 3,分别是入口块、循环体块和退出块;指令数为 11,说明了它内部有很多加载、计算和跳转指令。helper只有 1 个基本块、3 条指令,对应a+b的加法和返回。这说明我们的 pass 已经能正确遍历和统计函数了。
4.1 从 FunctionPass 到 New Pass Manager:新旧两套框架的差异
我一开始接触 LLVM 时,网上的教程大多基于旧版 Pass Manager,也就是写FunctionPass子类、实现runOnFunction方法。但 LLVM 17 之后,New Pass Manager 已经成为默认且唯一支持的框架,所以学习时不要再看旧教程了。新框架的几个优势是:按需计算分析结果、更好的缓存机制、支持 pass 管道的细粒度控制。编写新的 pass 时,只需要像上面那样继承PassInfoMixin并实现run方法。
新旧框架的另一个重要区别是分析结果的获取方式。旧框架靠getAnalysis<T>()动态获取分析数据,新框架统一通过FunctionAnalysisManager的getResult<T>(F)获取。这意味着你在run方法里需要显式传入分析管理器,这种设计避免了很多隐式依赖和跨模块耦合问题。如果你是从旧教程入门,这里一定要切换思路。
注册层面,新框架用PassBuilder代替了原来的RegisterPass宏。上面代码中registerPipelineParsingCallback与-passes=命令行对应,PassBuilder::registerAnalysisRegistrationCallback用来注册自定义分析,PassBuilder::registerPipelineStartEPCallback用来在所有 pass 管道的开头插入你的 pass。这些回调机制让 LLVM 可以非常灵活地组装 pass 管道,但也增加了初次上手的理解成本,建议动手敲一遍代码,多实验几次。
4.2 调试 pass 的几种实用思路
写 pass 不可能一遍过,常见问题包括:IR 遍历范围不对、分析结果没保存、修改 IR 后 PreservedAnalyses 报告错误等。调试时我常用的方法有三招。
第一招,大量使用errs()打印。LLVM 自己的打印对象很方便,比如打印某个指令:errs() << I << "\n";,它会直接调用 Instruction 的 print 方法,输出成文本 IR 的样子。对于简单逻辑,这比用 gdb 快得多。
第二招,借助-print-after-all或-print-before-all查看 pass 执行前后的 IR。在 opt 命令里加上这些参数,它会在每个 pass 执行后输出当前 IR,能帮你确认 pass 是否真的改变了 IR,以及改变发生在哪一步。这个功能在排查“优化结果不符合预期”时非常有用。
第三招,使用llvm::verifyFunction和llvm::verifyModule验证 IR 合法性。如果你在 pass 里修改了 IR,修改后接着调用这两个函数,可以在开发阶段就发现 IR 结构损坏,避免问题被推迟到下游 pass 才暴露。检查功能在 Debug 构建下通常会自动开启,但显式调用一下更稳妥。
5. 探索 LLVM 的应用场景:从静态分析到异构编译和编程语言设计
掌握了构建和 pass 开发的基础,接下来可以从项目各子系统的角度,看看 LLVM 这套基础设施在真实世界里到底能做什么。它的影响范围远比“编译器”三个字大,这也解释了为什么 llvm-project 有着极高的活跃度和庞大的社区。
第一个很成熟的场景是静态分析和代码质量保障。Clang 自带一组静态分析器,比如clang --analyze能检查出空指针解引用、内存泄漏、逻辑错误等问题。更强大的是,你可以基于 clang 的 AST 编写自定义检查,这就是 clang-tidy 里大量 check 的实现原理。实际做代码评审时,我经常在 CI 里集成 clang-tidy,把很多问题从“运行期崩溃”提前到“编译期报警”。compiler-rt 里的 AddressSanitizer(ASan)、UndefinedBehaviorSanitizer(UBSan)、ThreadSanitizer(TSan)则是动态分析利器,它们通过插桩方式在运行时检测内存错误、未定义行为和数据竞争。用 ASan 抓内存越界几乎一抓一个准,代价是运行速度会慢一些,但和节省的调试时间相比完全值得。
第二个场景是异构计算。LLVM 对 GPU 等异构设备的支持已经相当成熟。NVPTX 后端可以把代码编译成 PTX 指令,AMDGPU 后端支持 AMD 显卡。在 AI 框架里,很多算子编译走的就是 LLVM 路线,输入是特定 DSL 或 IR(比如 MLIR),输出是 GPU 机器码。MLIR 项目让 LLVM 具备了构建更上层编译基础设施的能力——因为 AI 芯片、FPGA、专用加速器的编译器层级非常多,直接落到 LLVM IR 太粗糙,中间需要一个分层的抽象框架,MLIR 正好补上了这个空缺。你可以把 MLIR 理解为“编译器中间表示的乐高积木”,想搭什么方言都行,最后再通过转换下降到 LLVM IR 到机器码。
第三个场景是编程语言设计。很多现代语言都选择 LLVM 作为后端,比如 Rust 默认就使用 LLVM,Swift 也以 LLVM 为编译基础设施。原因很简单:语言设计者只需要写好前端(词法、语法、语义分析,生成 LLVM IR),剩下的优化、目标代码生成、调试信息等都可以直接复用。这大大降低了开发一门新语言的成本。即使你不做语言设计,理解这条路径也能帮你在阅读语言运行时或 JIT 引擎时更快上手。
其他值得留意的应用还包括:生成调试信息(DWARF、CodeView 等),LLVM 能生成丰富的调试信息,这也是众多调试器的基础;二进制工具集(llvm-objdump、llvm-readelf、llc 等),它们实现了对目标文件的分析和操作,是逆向工程、链接器开发的重要基础设施;以及安全领域的符号执行、模糊测试工具,它们大量利用 LLVM IR 或 clang 的插桩能力。你不需要立刻掌握所有这些,但了解 LLVM 的应用边界,有助于判断“某个工具链问题是否可以用 LLVM 生态解决”。
5.1 选型参考:什么时候用 clang、什么时候用 opt、什么时候自己写 pass
这里我把自己实际做工具链选型时的判断标准整理成一张表,方便你对照决策。
| 需求场景 | 推荐工具 | 原因 |
|---|---|---|
| 日常编译 C/C++ 代码 | clang | 兼容性好,报错信息友好,支持静态分析和 sanitizer |
| 观察优化前后的 IR 变化 | opt + llvm-dis | 可以直接在字符串或 bitcode 上运行 pass,直观可控 |
| 分析源码语义、做重构 | clang-tidy / clang-query | 基于 AST 和 matcher,能拿到丰富的源码级信息 |
| 完全自定义的 IR 变换 | 自己写 pass 插件 | 能精确控制要修改的指令,适合深度学习编译器内部机制 |
| 做链接期优化 | LLD + LTO | 跨编译单元的优化在链接阶段进行,LLD 是天然配合 |
| 为某种特定硬件生成代码 | llc | 能直接将 LLVM IR 编译成指定后端的汇编代码 |
这条选型路径的核心思路是:尽量复用官方工具,只有当现有工具不能满足你的分析或转换需求时,才考虑写自定义 pass。因为自定义 pass 增加了维护成本,还要与 LLVM 接口版本保持同步,能不写就不写,但一旦写了,收益往往也很显著。
6. 构建和使用 llvm-project 的常见问题排查
LLVM 项目体量大、依赖复杂,构建和使用过程中遇到的问题也很多。这里把我踩过的一些坑和排查思路整理出来,都是高频问题。
6.1 构建阶段的高频问题与解决办法
- 内存不足导致编译中断。表现是 ninja 或 make 进程被 kill,报 Killed 字样。解决办法:降低并行度,用
-j 2或-j 4;换用 Release 模式减少内存占用;关闭不必要的 target 和项目;考虑用多台机器做分布式编译。实际测试,同样的配置在 8GB 内存机器上很容易挂,在 16GB 内存机器上用-j8就没事。 ld: cannot find -lz或-ltinfo之类的链接错误。这是缺少系统库导致的。在 Ubuntu 上安装zlib1g-dev和libtinfo-dev就能解决。这类问题本质是 LLVM 依赖某些基础库,而系统没有安装对应开发包。- TableGen 文件修改后编译报错。如果你改了
.td文件,构建系统会重新生成相关 C++ 代码,报错信息往往指向生成文件而不是 .td 文件本身。建议先定位到build目录下的生成文件检查一下,再对照.td修改点排查,绝大多数情况是语法或类型不匹配。
6.2 运行自定义 pass 时遇到的问题
- 插件加载失败,提示
libLLVM版本不匹配。这是因为 pass 插件是用一套 LLVM 头文件编译的,而运行opt时用的却是另一套版本。解决方法是保证插件编译时的LLVM_DIR与运行opt的 LLVM 安装一致,最好使用同一个 build 目录。我一个朋友因为系统里有两个 LLVM 版本,插件加载时报错看了半天,最后发现就是路径指错了。 - pass 没有注册成功,
opt提示找不到-passes名字。检查插件是否成功加载,可以用opt -load-pass-plugin=./libFunctionInfo.so -print-pipeline-passes -passes=function-info看是否能识别;也要检查registerPipelineParsingCallback里匹配的名字是否和命令行一致。大小写和拼写都要严格一致。 - 编译通过但实际运行结果为空。通常是你 pass 中的分析逻辑没找到目标结构,比如函数被优化器内联了、循环被 unroll 了,或者遍历方式少了。这时先不优化,用
-O0生成原始 IR 来测试,或者增加打印信息观察遍历过程。
6.3 使用 clang 编译时的一些提示
当我想快速验证某个写法以及对应的 IR 时,常用命令是:
clang -O0 -S -emit-llvm test.c -o test.ll-O0 会保留比较直接的代码逻辑,适合分析前端行为;而 -O2 则能反映优化后的真实形态。
如果遇到 clang 编译时报错但 gcc 通过的情况,不要急着认定 clang 有问题。clang 的诊断虽然清晰,但它在 C/C++ 标准遵循上比较严格,很多 gcc 默认忽略的未定义行为或警告在 clang 下会显式报出。这时候仔细读诊断信息,往往能帮你发现源码里隐藏的问题。
7. 如何持续跟进 LLVM 社区与项目动态
llvm-project 是开源社区里少数始终维持高频开发和严谨治理的项目,代码审查、设计讨论、版本发布都有一套成熟流程。如果你想长期参与或借鉴它的技术演进,有必要了解它的动态获取渠道。
首先是官方的 Discourse 论坛和邮件列表,这是 LLVM 讨论的主阵地,RFC(Request for Comments)机制非常活跃。任何一个重大设计改动,比如新 pass 框架的引入、新 target 的提案,都会在这里先发 RFC,接受社区评议。如果你想深入了解某项设计的来龙去脉,翻 RFC 历史是绝佳路径。
其次是 Phabricator 或 GitHub Pull Request。LLVM 的代码审查传统上使用 Phabricator,最近逐步迁移到 GitHub PR。每次 commit 都能在 GitHub 上看到完整的 diff 和讨论。阅读一些与你关注模块相关的 commit,比看教科书更能学到“为什么这样实现”的深层原因。
再次是 LLVM 的年会——LLVM Developers' Meeting 和 EuroLLVM。这些会议通常免费,演讲视频会在 YouTube 发布,涵盖很多前端、中端、后端的实践和优化案例。看这几个演讲的收获远大于零散地刷代码。我自己的经验是,每次听完某个主题的演讲,回来再读对应模块的源码,理解深度会明显不同。
如果你想动手贡献代码,可以从简单的文档修正、测试用例补充开始,再逐步深入到某一个小功能的实现。LLVM 社区对新手的耐心和指导一般都不错,只要你的改动能通过测试和代码评审。通过这种方式,你能学到非常系统的工程实践。
8. 从 llvm-project 里能带走的工程思维
最后想聊一点专业技术之外的东西。LLVM 项目有很多工程上的决策值得借鉴,即使你将来不做编译器开发,这些思维方式也能在其他软件项目中发挥作用。
最明显的一点是分层与解耦。LLVM 把前端、优化器、后端拆成绝对独立的模块,接口通过 IR 这个中间产物来定义。谁都可以替换其中一层而不影响其它层。实际做大型软件系统时,不同模块之间定义清晰稳定的“中间语言”,往往比强耦合的接口设计长命得多。写库的时候,如果能找到一个“像 IR 一样稳定”的内部表达,整个系统的演进空间就会大很多。
其次是数据和代码分离的 DSL 设计。TableGen 用声明式描述代替手写 C++,让大量规律性强的代码可以被自动生成。这背后是一种“元编程”思维——与其写一万行重复代码,不如写一个能生成一万行代码的小工具。在工程实践中,识别出“规律性强、重复性高”的代码,然后用代码生成去解决,是一种很高效的策略。
还有就是对兼容性和演进节奏的重视。LLVM 有明确的版本发布节奏和弃用流程,即使内部 API 翻天覆地,release 分支也会在相当长时间内保持兼容。这套治理规范保证了它能在规模庞大的前提下保持高质量。做开源项目或团队内部基础设施时,提前设计好兼容和演进策略,能省掉大量后期维护成本。
和 LLVM 打交道这么久,我的感觉是:它是一个既庞大又精密的存在。刚接触时觉得到处都是源码,不知从何看起;一旦理解了 IR 和 pass 框架这条主线,再去看任何模块,都会有“原来如此”的感觉。希望这篇从整体到细节、从原理到实战的内容,能帮你迈过最初那道坎。如果你在构建或写 pass 时遇到什么奇怪的问题,很多时候去读一读 IR 输出,答案就自己跑出来了。