llvm-project 这名字,在很多程序员眼里几乎就是“编译器”的同义词,但它其实不是一个单一编译器,而是一整套编译工具链基础设施的集合仓库。你从 GitHub 上 clone 下来的 llvm-project 里,不仅包含 LLVM 核心库,还打包着 Clang 前端、LLD 链接器、libc++ 标准库实现,甚至还有 MLIR 这样面向机器学习编译的独立框架。这篇文章的定位是给那些已经能用 gcc/clang 写程序,但还没真正走进编译器源码世界的同学。我会从获取源码开始,带你把 llvm-project 构建成一套可用的本地工具链,然后讲透 IR 和 Pass 这两个最核心的概念,最后手写一个能被 opt 加载的 Pass,让整个认知链条真正闭合。
1. 先认清 llvm-project 到底是个什么项目
1.1 一个仓库里装下了整套编译生态
很多人第一次打开 llvm-project 仓库时,会被它的目录结构吓到。根目录下有几十个一级目录,其中最重要的几个,每一个单独拿出来都是一套完整的子系统。
llvm/:这是整个仓库的核心,包含 IR 定义、优化器、后端代码生成器,以及 opt、llc、llvm-as 这些命令行工具。我们通常说的“LLVM 核心”,指的就是这个目录。clang/:C/C++/Objective-C 前端。它的作用是把源代码解析成 AST,再降到 LLVM IR。lld/:高性能链接器,官方口号是“比默认系统链接器更快”,现在很多项目都用它替代系统 ld。libc++和libc++abi/:LLVM 版本的 C++ 标准库实现,macOS 上默认用的就是这两套。compiler-rt/:编译器运行时库,包含 ASan、UBSan 这些 sanitizer 的实现,以及一些底层 builtins。mlir/:多层中间表示框架,最初诞生于编译器领域,后来在机器学习框架加速里用得非常多。flang/:Fortran 前端,也是过去几年 LLVM 社区投入资源很多的方向之一。polly/:基于多面体模型的循环优化器,在 HPC 场景下能力很强。openmp/:OpenMP 运行时实现。bolt/:面向二进制级别的优化和布局工具,通过分析已编译二进制来提升性能,在大型服务端程序里表现突出。
你可以把 llvm-project 理解为一套“乐高积木”:前端负责把各种语言翻译成 IR 积木,优化器负责变换和重组积木,后端负责把积木拼装成目标机器指令。每一层都可以独立使用,这也是 LLVM 跟 GCC 这种“铁板一块”的编译器在设计哲学上最大的区别。
1.2 为什么上游要把这么多项目打进同一个仓库
早期 LLVM 并不是这样的。各子项目分属不同仓库,版本号各自演进,Clang 需要和特定版本的 LLVM 核心配合,使用者要自己拼装匹配版本,非常痛苦。后来官方做出了一个影响深远的结构调整:把所有子项目收拢成 monorepo(单仓库多项目),也就是现在的 llvm-project。
这个调整带来的好处很直接。第一,提交可以跨项目原子化,一次修 bug 涉及的多个子项目改动可以放在同一个 commit 里,CI 能一站式验证整套工具链。第二,对使用者来说,git clone 下来的天然就是经过搭配的一整套版本,不用再手动对齐版本号。第三,仓库的 issue、PR、tag 都能统一管理,社区协作效率提升非常明显。
对你我这样的学习者来说,monorepo 结构还有一个隐性好处:你一次 clone 下来的就是完整生态,后续无论想研究前端、中端还是后端,都不用再单独找项目源码,所有代码都躺在同一个仓库里,搜索和跳转都非常方便。
2. 从源码构建一套属于你自己的 llvm-project
2.1 环境准备与源码获取
构建 llvm-project 对硬件有一定的要求,但不像很多人想象的那么夸张。我实测下来的底线配置是 8GB 内存 + 4 核 CPU,如果内存低于 8GB,链接阶段大概率会被 OOM 干掉。更舒适的配置是 16GB 内存 + 8 核以上,编译时间会从“劝退级”降到“可以忍受”。
操作系统方面,Linux 是体验最好的,Ubuntu 22.04 或者更新的发行版都行。macOS 也可以,但工具链路径上偶尔会有一些小坑。Windows 用户建议直接用 WSL2,原生 Windows 构建 LLVM 的配置成本高不少,对于新手没必要。
先把源码拉下来:
git clone https://github.com/llvm/llvm-project.git如果网络一般,不想等太久的,有两个技巧。一是只拉一个分支的浅克隆:
git clone --depth 1 -b llvmorg-18.1.8 https://github.com/llvm/llvm-project.git二是使用镜像源加速。这里要提醒一句:浅克隆能省时间,但如果你想在 git 历史里翻找某些 IR 接口的演进过程,后面还是要补历史的。新手阶段先把代码跑起来更重要,浅克隆没什么问题。
源码本体约 1~2GB,构建产物因配置不同差异很大。如果只构建 clang、lld 和核心工具,Release 模式下大约需要 20~40GB 磁盘空间。所以请预留至少 60GB,免得构建到一半磁盘满了。
2.2 一份可以照抄的 CMake 配置
构建 LLVM 用的是 CMake,配置命令是这套流程里最关键的一步。我先给出一份我目前最推荐的新手配置,然后逐项解释:
cmake -S llvm -B build -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DLLVM_ENABLE_PROJECTS="clang;lld" \ -DLLVM_TARGETS_TO_BUILD="host" \ -DLLVM_ENABLE_ASSERTIONS=ON \ -DCMAKE_C_COMPILER_LAUNCHER=ccache \ -DCMAKE_CXX_COMPILER_LAUNCHER=ccache逐项拆解一下。
-S llvm指向源码目录。注意 llvm-project 根目录下的源码并不在根目录,而是分散在llvm/子目录里,所以 CMake 的源目录必须指到llvm,这个细节很多人第一次都搞错。
-G Ninja选择 Ninja 作为构建系统,比默认的 Unix Makefiles 快很多,支持并行、增量构建也更稳定。注意 ninja 的并行度不靠-j参数体现,而是默认吃满所有核。如果不想让它占满资源,可以用-j 4这样的参数限制。
-DCMAKE_BUILD_TYPE=Release选择 Release 模式。这个选项对构建时间的影响是数量级的,Debug 模式的构建时间是 Release 的三到五倍,而且产物巨大。新手阶段强烈建议直接 Release。
-DLLVM_ENABLE_PROJECTS="clang;lld"决定你要构建哪些官方子项目。在较新版本中,clang 和 lld 都是通过这个变量开启的;libc++、compiler-rt 这类运行时库,在新版本里通常走LLVM_ENABLE_RUNTIMES。对新手来说,clang 和 lld 已经足够支撑后续所有实验了。
-DLLVM_TARGETS_TO_BUILD="host"是很多人会忽略的加速神器。LLVM 默认会为 X86、ARM、AArch64、RISC-V 等一二十个后端生成代码,如果全开,构建时间和磁盘占用会直接翻好几倍。只保留当前机器的架构,比如在 x86 机器上就只构建 X86 后端,能大幅缩短编译时间。
-DLLVM_ENABLE_ASSERTIONS=ON打开断言。Debug 模式下默认开启,但 Release 模式下默认关闭。我强烈建议你显式开启,因为 LLVM 内部有大量检查依赖断言,开着能帮你更快发现 Pass 和 IR 操作的问题。代价是运行性能略有下降,但对学习场景完全不影响。
ccache这条比较特别。ccache 是编译缓存工具,第二次构建时可以直接复用第一次的编译结果,对频繁改代码重编的人来说,体验提升非常明显。需要先安装好 ccache,如果暂时不想装,去掉最后两行也不影响。
2.3 构建流程与第一次编译的时间管理
配置完成后,开始正式编译:
cmake --build build --target clang lld -j$(nproc)这里指定了clang和lld两个 target。如果不指定 target,Ninja 会把整个 LLVM 项目里所有东西都编一遍,耗时非常夸张。指定 target 之后,只会构建 clang、lld 以及它们依赖的核心库。
第一次构建需要多久?以我常用的 16 核机器为例,Release 模式、只构建 X86 后端的 clang 和 lld,大约在 30~60 分钟之间。8 核机器大概需要 1.5~2 小时,4 核可能要 3 小时以上。如果你性子急,想更快体验全流程,可以先只构建opt和llc这两个小工具,它们不需要 clang 前端,十几分钟就能编完:
cmake --build build --target opt llc -j$(nproc)构建完成后,验证一下产物:
build/bin/clang --version看到版本号输出,说明整套工具链已经能用了。clang 可执行文件在build/bin/下,这个目录就是我们之后反复要用到的工具集目录。建议把它加进 PATH,或者后续命令直接使用绝对路径。
关于内存还有一个很实用的选项。如果你的机器内存只有 8GB,链接阶段非常容易卡死,可以通过限制并行链接任务数来缓解:
cmake -S llvm -B build -DLLVM_PARALLEL_LINK_JOBS=2这个选项单独控制了链接阶段的并行度,编译阶段仍然吃满 CPU,链接阶段最多 2 个任务同时跑,内存压力会小很多。
3. 读懂 IR 和 Pass,才算摸到 llvm-project 的门
3.1 LLVM IR 是这样一种“中间态”
IR(Intermediate Representation,中间表示)是整个 LLVM 体系的灵魂。前端把源代码转成 IR,优化器在 IR 上做各种变换,后端再把 IR 转成目标机器指令。理解 IR,是理解 llvm-project 一切设计的起点。
我先从一个最简单的 C 函数开始:
int add(int a, int b) { return a + b; }用 clang 转成 IR:
clang -O0 -emit-llvm -S add.c -o add.ll生成的 IR 核心部分长这样:
define i32 @add(i32 %a, i32 %b) { entry: %sum = add nsw i32 %a, %b ret i32 %sum }这段 IR 非常直观。define i32 @add定义了一个返回i32、函数名为add的函数,参数是i32 %a和i32 %b。函数体里只有一个基本块entry,里面两条指令:%sum = add nsw i32 %a, %b表示把两个参数相加,结果赋给%sum;ret i32 %sum表示返回%sum。
LLVM IR 有三大核心特征,理解了它们就理解了 IR 的设计哲学。
第一,SSA 形式(静态单赋值)。所谓 SSA,是指每个变量在程序里只能被赋值一次。你看%sum,它的定义只有一条add指令,之后不会被再次修改。SSA 的优势在于数据流关系变得非常清晰,优化器可以直接从变量使用点跳到定义点,不用像传统 IR 那样做冗长的 def-use 链分析。遇到控制流汇合时,SSA 会借助phi节点来合并不同分支的值,这个后面遇到了再展开。
第二,强类型系统。IR 里的每个值都带类型,比如i32是 32 位整数,i64是 64 位整数,ptr是指针类型。类型信息会让指令选择和后端代码生成变得更简单,也是 LLVM IR 区别于很多“无类型”中间表示的重要特点。
第三,虚拟寄存器无限可用。IR 里你可以随意命名%1、%2、%3这样的虚拟寄存器,不用像真实机器那样担心寄存器数量。把 IR 翻译成目标机器指令时,后端寄存器分配器会负责把无限虚拟寄存器映射到有限物理寄存器上,装不下的就 spill 到栈上。
3.2 Pass 是优化器的执行单元
IR 是原料,Pass 就是加工工序。LLVM 里所有优化动作,比如死代码消除、循环展开、函数内联,本质上都是一个个 Pass。每个 Pass 接收 IR 作为输入,做一些分析和变换,然后输出新的 IR。
按照功能,Pass 可以分为两大类。一类是分析型 Pass,比如支配树分析(DominatorTreeAnalysis)、循环分析(LoopAnalysis),它们只产出信息,不改动 IR;另一类是变换型 Pass,比如 InstCombine、GVN、LoopUnroll,它们会实际修改 IR。
按照作用对象,Pass 又可以分为 ModulePass、CallGraphPass、FunctionPass、LoopPass 等几类。最常用的是 FunctionPass,它以函数为最小处理单位,遍历一个函数的所有基本块和指令,然后做处理。我们下一章要写的 Pass 就是一个 FunctionPass。
这里要特别提一个架构设计上的演进。老版本 LLVM 使用的是 Legacy Pass Manager,Pass 之间通过getAnalysis()这样的方式互相获取结果。新版本 LLVM(从 14 开始默认)使用 New Pass Manager,引入了AnalysisManager机制。你看新写的 Pass 代码,run函数的签名会多一个FunctionAnalysisManager &AM参数,这就是用来请求分析结果的。
为什么 LLVM 要费这么大力气切换到新 Pass Manager?核心原因是缓存和生命周期管理。编译大型程序时,同一个函数往往要经过几十个 Pass 的处理,其中很多 Pass 都需要拿到支配树、循环信息这类分析结果。如果每个 Pass 都用完之后不缓存,后面的 Pass 又要重新计算,整体编译时间会爆炸。New Pass Manager 用一个统一的 AnalysisManager 来管理这些分析结果,并在 IR 发生变化时智能地判定哪些结果需要失效、哪些可以继续复用,性能提升非常明显。
3.3 Pass 的注册与 opt 工具
写好的 Pass 怎么让优化器知道?这就要提到 opt 工具和 Pass 注册机制。
opt 是 LLVM 的命令行优化器,它的用法跟 Unix 下的文本处理工具很像:读入一个 IR 文件,执行指定的一系列 Pass,再输出新的 IR。最基本的用法是:
opt -passes="mem2reg,instcombine" input.ll -S -o output.ll-passes参数指定要跑的 Pass 流水线,多个 Pass 用逗号分隔。
对于自定义 Pass,opt 提供了两种接入方式。一种是 in-tree,把代码写进 llvm-project 源码里,重新编译 opt;另一种是 out-of-tree,把自定义 Pass 编译成一个动态库插件,用-load-pass-plugin参数加载。out-of-tree 方式不需要重新编译整个 LLVM,开发迭代速度更快,也是社区里最主流的做法。下一章我们就用 out-of-tree 方式,写一个完整可运行的 Pass。
4. 动手实现并注册一个最小 Pass
4.1 一个完整可用的 Pass 骨架
我们写一个非常老实的 Pass:它什么优化都不做,只是遍历每个函数,统计指令数量,然后打印出来。虽然简单,但它把 Pass 的结构、加载、运行、分析结果处理这几个关键环节全部串起来了。
#include "llvm/IR/Function.h" #include "llvm/IR/Instructions.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 { class InstructionCounter : public PassInfoMixin<InstructionCounter> { public: PreservedAnalyses run(Function &F, FunctionAnalysisManager &AM) { unsigned InstCount = 0; for (BasicBlock &BB : F) for (Instruction &I : BB) ++InstCount; errs() << "Function " << F.getName() << ": " << InstCount << " instructions\n"; return PreservedAnalyses::all(); } }; } // namespace static void registerInstructionCounter(PassBuilder &PB) { PB.registerPipelineParsingCallback( [](StringRef PassName, FunctionPassManager &FPM, ArrayRef<PassBuilder::PipelineElement>) { if (PassName == "instruction-counter") { FPM.addPass(InstructionCounter()); return true; } return false; }); } static PassPluginLibraryInfo getPluginInfo() { return {LLVM_PLUGIN_API_VERSION, "InstructionCounter", LLVM_VERSION_STRING, [](PassBuilder &PB) { registerInstructionCounter(PB); }}; } extern "C" LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return getPluginInfo(); }逐段解释一下。
class InstructionCounter : public PassInfoMixin<InstructionCounter>是 New Pass Manager 下的标准写法。PassInfoMixin是一个 CRTP 模板基类,它帮我们处理了一些类型识别和转换的样板代码。
run方法就是 Pass 的核心。它接收两个参数:当前要处理的函数F,以及分析管理器AM。这里的AM本次没有用到,但在复杂的 Pass 里,你可以通过AM.getResult<DominatorTreeAnalysis>(F)之类的方式获取依赖的分析结果。
统计指令数量的逻辑非常简单,两重循环:外层遍历函数的所有基本块BasicBlock,内层遍历基本块里的所有指令Instruction。
errs()是 LLVM 的标准错误输出流,相当于stderr。在用 opt 调试时,很多信息会通过它打印到终端,已经在 LLVM 源码库里被广泛使用。
注意最后return PreservedAnalyses::all();。这一行表示“本 Pass 执行完后,所有已有的分析结果仍然有效”。因为我们根本没有修改任何 IR,所以可以返回all()。如果你改动过 IR,这里就需要根据实际情况返回none()或者部分保留的分析集合。
4.2 编译插件并接入 opt
接下来把这段代码编译成 opt 可加载的插件。这里采用 out-of-tree 方式,新建一个独立目录,比如instruction-counter/。
在目录下放一个CMakeLists.txt:
cmake_minimum_required(VERSION 3.20) project(InstructionCounter) find_package(LLVM REQUIRED CONFIG) message(STATUS "Found LLVM ${LLVM_PACKAGE_VERSION}") include_directories(${LLVM_INCLUDE_DIRS}) add_library(InstructionCounter MODULE InstructionCounter.cpp) llvm_map_components_to_libnames(LLVM_LIBS core support irreader passes) target_link_libraries(InstructionCounter PRIVATE ${LLVM_LIBS})这里用find_package(LLVM REQUIRED CONFIG)去找 LLVM 的 CMake 配置。如果你的 LLVM 是从源码构建出来的,需要给 CMake 指定LLVM_DIR,指向构建目录下的lib/cmake/llvm:
cmake -S . -B build -G Ninja \ -DLLVM_DIR=$(pwd)/../llvm-project/build/lib/cmake/llvm cmake --build build编译完成后,build/目录下会生成插件文件,在 Linux 上叫libInstructionCounter.so。如果是在安装好的 LLVM 上做开发,通常需要先安装llvm-dev之类的开发包,并且要保证 LLVM 版本和插件编译版本完全一致。
接着生成一份测试用的 IR 文件:
cat > test.c <<'EOF' int foo(int x, int y) { int z = x + y; z = z * 2; return z; } EOF clang -O0 -emit-llvm -S test.c -o test.ll然后加载插件,在 IR 上跑我们的 Pass:
opt -load-pass-plugin=./build/libInstructionCounter.so \ -passes="instruction-counter" \ test.ll -S -o /dev/null如果一切顺利,终端会打印类似这样的输出:
Function foo: 6 instructions看到这个输出,你的第一个 llvm-project 自定义 Pass 已经跑通了。从这一刻开始,LLVM 对你来说就不再是一个黑盒了。
4.3 PreservedAnalyses 这里藏着一个大坑
很多初学者写 Pass 时,最困惑的就是PreservedAnalyses到底该怎么返回。这个返回值的作用是告诉 PassManager:经过我的处理之后,之前那些分析结果还有哪些是有效的。
PreservedAnalyses::all()表示所有分析结果都保持有效,适合如下场景:你的 Pass 只是读了一遍 IR,没有做任何修改。PreservedAnalyses::none()则表示一切都失效了,如果 Pass 对 IR 做了任意改动,且你懒得细算,直接返回none()是最安全的选择。
这中间的坑就在“你改了 IR 却返回了 all()”。你的 Pass 修改了循环结构,但是忘了让 LoopAnalysis 失效,后面的 Pass 拿到过期的循环信息,可能生成完全错误的代码。这类 bug 极其隐蔽,因为错误往往不发生在你的 Pass 里,而是在下游某个 Pass 里才暴露出来,排查起来非常痛苦。
实践建议:写新 Pass 的初期,如果拿不准是否真的改了 IR,哪怕只改了一点点,也先返回PreservedAnalyses::none()。性能差一点没关系,正确性优先。等 Pass 稳定了,再回过头来精确返回被修改的分析结果。
5. llvm-project 源码阅读的路线与建议
5.1 高频目录速查表
llvm-project 的源码量非常庞大,盲目阅读很容易迷失。这里整理一份高频目录速查表,帮你迅速定位自己想看的代码。
| 目录 | 内容 | 适合什么场景 |
|---|---|---|
llvm/include/llvm/IR/ | IR 核心类定义 | 理解 Instruction、BasicBlock、Function 等类型结构 |
llvm/lib/IR/ | IR 实现 | 查看 Verifier、打印逻辑等 |
llvm/lib/Passes/ | PassBuilder 与内置 Pass 流水线 | 弄清楚-O2到底执行了哪些 Pass |
llvm/lib/Transforms/ | 大量优化 Pass 实现 | 学习别人怎么写 Pass 的最佳范本 |
llvm/lib/CodeGen/ | 指令选择、寄存器分配、指令调度 | 研究后端代码生成流程 |
llvm/lib/Target/ | 各目标架构后端 | 看某个架构的指令如何被 lowering |
clang/lib/CodeGen/ | Clang 前端 AST 到 IR 的下译 | 理解 C/C++ 如何变成 IR |
5.2 用一条主线追完“前端到 IR”的旅程
最有效的源码阅读方式,不是从头到尾顺序读,而是带着问题追一条具体的执行路径。我们以一个简单的 C 函数为例:
int add(int a, int b) { return a + b; }这条路径的起点在clang/lib/CodeGen/。Clang 先把 C 代码解析成 AST,然后由 CodeGen 模块把 AST 转换成 LLVM IR。你可以在clang/lib/CodeGen/CodeGenModule.cpp里找到EmitGlobalDefinition,这是处理全局函数定义的入口点之一。顺着调用链追踪,可以看到一个函数如何从 AST 节点变成llvm::Function对象。
有了 IR 之后,接下来就是优化流程。opt 的 Pass 流水线定义在llvm/lib/Passes/PassBuilder.cpp里。你如果在里面搜索buildO0DefaultPipeline、buildPerModuleDefaultPipeline,就能看到不同优化等级下到底执行了哪些 Pass。
再往后是后端。用llc可以把 IR 转成目标汇编,入口在llvm/lib/CodeGen/。这里会依次经历指令选择、指令调度、寄存器分配等阶段。你可以在llvm/lib/Target/X86/X86ISelLowering.cpp里看到 X86 后端如何处理不同类型的 IR 节点。
这条链路追下来,你对整个编译流程的理解会有一个质变。即使很多细节记不住,但“前端-中端-后端”的阶段划分、每阶段的核心数据结构,都会清清楚楚。
5.3 给新手的三个阅读建议
第一个建议:不要一开始就碰后端。后端的复杂性远超前端和中端,TableGen DSL、SelectionDAG 各种节点类型、寄存器分配算法,任何一个单拎出来都够学半年。新手建议先专注 IR 和 Transform,这两块与我们的日常开发关系最紧密,也最容易获得正反馈。
第二个建议:从“读一个小 Pass”开始。去llvm/lib/Transforms/Utils/或者llvm/lib/Transforms/Scalar/里找一个你熟悉的优化,比如EarlyCSE.cpp或者GVN.cpp,先不要管它为什么这么优化,先看它怎么遍历 IR、怎么注册自己。看懂一个五百行左右的 Pass,比走马观花看一万行代码更有用。
第三个建议:善用 opt 的打印功能。在跑某条 Pass 流水线时,加上-print-after-all参数,opt 会在每个 Pass 之后打印当前 IR 的状态,这对理解优化的效果非常有帮助。比如你可以亲自跑一下mem2reg,看看它如何把alloca/load/store模式消除掉,这是理解 SSA 后端的经典案例。
6. 新手最容易踩的坑,以及排查思路
6.1 编译慢、内存炸,是配置问题不是代码问题
几乎每个第一次构建 llvm-project 的人,都会在编译阶段遇到挫折。最常见的现象是编到一半内存不足,进程被系统杀掉。原因通常就两个:一是开启了过多的目标架构,二是并行链接任务数太高。
解决办法很简单,回到第 2 章的 CMake 配置,检查LLVM_TARGETS_TO_BUILD是否设置为host,同时设置LLVM_PARALLEL_LINK_JOBS=2。编译阶段吃 CPU,链接阶段吃内存,这两项配置能把资源压力拆开控制,稳定性会好非常多。
还有一个很多人会遇到的问题是“明明加了 ccache,为什么重编还是很慢”。检查一下 CMake 缓存里是否真的启用了编译器启动器。有时候你是在配置好 build 目录之后才装的 ccache,那就需要删掉 build 目录重新 configure 一次。也可以用ccache -s查看命中率,如果命中率是 0,说明缓存根本没生效。
6.2 插件加载报错,先检查版本
out-of-tree 插件开发里,最常见的报错是这类:
opt: symbol lookup error: ./libInstructionCounter.so: undefined symbol: _ZN4llvm12FunctionPassC2Ev这通常是“插件编译时用的 LLVM 头文件版本”和“opt 运行时的 LLVM 库版本”不一致导致的。LLVM 内部有大量的符号导出版本控制,微小的版本差异都可能导致符号解析失败。
排查思路也很直接:确认llvm-config --version和opt --version输出的版本号完全一致。如果是从源码构建的 LLVM,确认你安装或者使用的llvm-config来自同一个 build 目录,而不是系统自带的另一个 LLVM 版本。如果 CMake 的find_package找到了错误的 LLVM,可以通过LLVM_DIR强制指到正确的 CMake 配置路径。
6.3 IR 文件加载失败时,用 verifier 找问题
自己动手改 IR 或者生成 IR 时,经常会遇到这种报错:
error: use of undefined value '%sum'或者invalid getelementptr indices之类的问题。这时候不要瞎猜,直接用 LLVM 自带的 verifier Pass 做一次体检:
opt -passes="verify" broken.ll -o /dev/nullverifier 会指出 IR 里哪条指令出了问题,比如类型不匹配、操作数未定义等。很多时候,一条指令的类型错误会引发一连串的报错信息,这时候从第一条报错开始修,往往能把后面的问题也一起解决。
这里还有一个非常实用的经验:写 Pass 时,在run方法最后对修改后的 IR 调用verifyFunction或者verifyModule,能在开发早期就发现很多错误。LLVM 的验证逻辑内部极其全面,习惯性调用它,能帮你省下大量后面排查的时间。
6.4 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 构建中内存耗尽 | 并行链接任务过多 | 设置LLVM_PARALLEL_LINK_JOBS=2 |
| 编了十几分钟还在编译 | 目标架构开太多 | 确认LLVM_TARGETS_TO_BUILD=host |
| 插件运行时 undefined symbol | LLVM 版本不匹配 | 让 llvm-config 和 opt 来自同一版本 |
| pass 输出完全没变化 | 忘记注册 pipeline 回调 | 检查registerPipelineParsingCallback是否匹配 |
| IR 验证失败 | IR 操作类型或操作数错误 | 用opt -passes=verify定位第一条错误 |
| 改了源码,重新编译没生效 | cmake 缓存路径指错 | 检查LLVM_DIR是否指向正确的 build 目录 |
6.5 最后再分享一个小技巧
玩 llvm-project 久了你会发现,真正的效率提升往往来自工具链的组合使用。我现在做 Pass 开发的标准调试流程是:先用 clang 生成 IR,把 Pass 编译成插件,在 opt 里用-print-after-all观察变化,定位到可疑的 Pass 后用llvm-reduce把大型 IR 文件缩小成最小复现用例,最后再集中修改 Pass 代码。这套流程一旦跑顺,开发速度会快很多。
我个人这两年接触 LLVM 下来的体会是:llvm-project 的代码量确实大,但它并不是没有路径可循的迷宫。抓住 IR、Pass、后端三大主线,先把自己的第一个 Pass 跑起来,后续的深入学习会自然展开。如果你也想真正理解编译器的运作机制,我的建议很直接,现在就在本地把 llvm-project 构建出来,然后照着上面的步骤写一个只给指令计数的 Pass,剩下的路会自己打开的。