LLVM实战指南:从llvm-project构建到自定义Pass开发
2026/9/19 16:20:45 网站建设 项目流程

1. 从一个开源仓库说起:llvm-project到底是什么

我最初接触llvm-project的时候,也是一脸懵的。大家平时挂在嘴边的“LLVM”,其实已经不是一个单纯的编译器了,而是一整套围绕编译器、工具链、代码生成、软件渲染等底层基础设施的开源项目。你在GitHub上看到的llvm-project仓库,就是这套体系的主仓库,里面既有Clang(C/C++前端)、LLVM Core(中间表示与优化、后端代码生成)、LLD(链接器)、libc++(标准库)、compiler-rt(运行时库),还有polly、flang这些相对独立但和LLVM深度绑定的子项目。

我见过不少刚接触的新人,第一反应是“这仓库太大,我搞不动”。确实,llvm-project这个仓库体量极其惊人,拉下来光源代码就几个GB,完整构建一次可能要吃掉几十GB的磁盘空间。但这种“庞大”恰恰说明它的价值——LLVM已经不只是编译器的代名词,它在静态分析、动态二进制插桩、GPU驱动、软件渲染领域都有应用。比如你们可能听过llvmpipe,这是Mesa图形栈里用LLVM做软件渲染的实现,它的出现让没有GPU的环境也能跑OpenGL,靠的就是LLVM的JIT能力,把图形渲染的着色器程序实时编译成本地代码执行。

所以你如果问我llvm-project适合谁,我的看法是:如果你在校招里想投编译技术岗位,或者你正在做编程语言、代码分析、图形渲染相关的实际项目,或者单纯想把C/C++工程构建能力提升一个层次,那llvm-project值得你花段时间吃透。这篇文章我就用实际操作的视角,把llvm-project从仓库结构、核心设计、构建方法到常见坑点,一次讲清楚。

1.1 一个仓库,半个编译器生态

llvm-project的源码版权归LLVM基金会管理,采用Apache 2.0 License,对商业使用非常友好,这点也是它能在工业界和学术界广泛扎根的原因之一。仓库本身通过Git管理,分支通常以release/15.x、release/16.x这样的形式命名。你看到“LLVM 15.0.7”这种版本号,指的就是某个release分支上的具体发布版本。以常用的release/15.x举例,对应Clang 15、LLD 15、libc++ 15也都会一并发布,真正做到了一体化版本管理。

仓库目录结构非常清晰,顶层就有llvm、clang、lld、libcxx、libcxxabi、compiler-rt、polly、flang等十几个子目录。其中llvm目录是核心中的核心,里面包含了我们常说的LLVM后端、优化器、IR等一系列内容。clang目录就是C/C++编译器前端,负责把C、C++、Objective-C翻译成LLVM IR。lld是官方链接器,链接速度比老牌GNU ld快很多,尤其适合大型C++工程。libcxx是LLVM自己的C++标准库实现,配合clang使用效果最好。

初学者一定要先搞清楚的一点是:llvm-project是“全部”,而通常说的“LLVM”只是其中一部分。很多人会问“我要装LLVM是不是就是要拉llvm-project”?如果你开发语言虚拟机或者做代码生成器,核心的llvm目录就够了;你需要编译C/C++,就要同时启用clang。构建的时候,用LLVM_ENABLE_PROJECTS这个CMake参数控制,比如只构建clang、lld这两个,就能省下不少编译时间。这个操作方式我会在第四章里详细讲。

1.2 为什么LLVM设计会影响整个软件栈

LLVM的设计核心是一套语言无关的中间表示(IR),它像一道“通用语言”,前端把各种高级语言翻译成IR,后端再根据不同的CPU架构把IR翻译成机器码。这个设计最大的好处是:写一种语言的编译器时,不需要关心底层有多少种CPU架构;写一个后端时,也不需要关心前面到底有多少种前端语言。这也是为什么现在很多新语言,比如Rust、Swift,都直接选择LLVM作为编译后端,省去了成千上万条指令集的适配工作量。

从实用角度讲,LLVM的优化器基于Pass机制,每种优化都是一个Pass,比如死代码消除、循环展开、函数内联等,可以灵活启停、组合。你做编译器开发时,甚至可以针对自己的IR写自定义优化Pass,在优化流水线中插入。这种灵活性远超GCC的固定优化等级设计。再加上Clang的诊断信息质量很高,报错带行列号、代码片段、高亮提示,很多IDE和静态分析工具也借助Clang编译前端来解析代码,像CLion、Qt Creator这些工具,其内核就是Clang。

可以说,llvm-project既是编译器项目,也是底层基础设施平台。理解了这一点,你才不会在庞大的代码库中迷失方向。

2. llvm-project的核心设计拆解

作为综合项目,如果上来就选一个目录闷头读,效率很低。更值得做的,是先把它的核心设计蓝图梳理清楚。llvm-project大体可以拆成三条主线:前端、中端、后端。前端负责把高级语言降级成IR;中端也就是优化器,对IR做各种变换,使其更高效;后端负责将优化后的IR映射到具体指令集。下面我结合代码目录,逐个拆解。

2.1 LLVM IR:承上启下的中间表示

LLVM IR是llvm-project的“通用语”,它是一种静态单赋值(SSA)形式的中间表示。静态单赋值意味着每个变量只能被赋值一次,每次使用都指向明确的定义点。这个特性看起来简单,实际上给优化器省了大事:数据流分析变得非常直接,做变量重命名、寄存器分配时都能基于SSA信息,大大简化算法实现。

IR有三种形态:内存中的数据结构、人类可读的文本格式(.ll文件)、可序列化的位码格式(.bc文件)。我们写编译器工具时,最常打交道的是.ll文本格式。它的语法类似C语言,但更接近汇编。比如定义函数:

define i32 @add(i32 %a, i32 %b) { %sum = add i32 %a, %b ret i32 %sum }

看懂这段代码,就迈入了LLVM的大门。这里的i32命名是32位整数,@add表示全局函数,%a、%b是局部变量,add指令做加法,ret返回。所有寄存器和临时变量都以%开头,SSA约束下,同一个变量名不会重复赋值。后面如果自己做编程语言,想对接LLVM,写IR生成器的时候,这种模式会频繁出现。

llvmpipe与LLVM IR也有关系,它把图形着色器编译成LLVM IR,再由LLVM的JIT引擎生成针对当前CPU优化的机器码,从而保证软件渲染性能。比如Mesa的llvmpipe在运行时检测CPU支持AVX256位指令后,会让LLVM生成256位宽的SIMD代码,一次处理更多的像素数据。

2.2 优化器Pass机制:LLVM强大的核心

优化器是LLVM的“心脏”,几乎所有的性能优势都从这一层来。优化器采用Pass流水线设计,一个Pass就是对IR做一次遍历和变换。常见的ScalarOpts目录里,存放着几十个标量优化Pass;IPO目录处理跨函数的优化,比如内联;Vectorize目录做循环向量化,配合llvmpipe这类需要批量处理的应用非常关键。

官方给的经典优化组合是O1、O2、O3、Os、Oz。O2属于比较均衡的选择,适合大多数应用程序;O3开了更多激进优化,比如更积极的循环向量化,有时会让编译时间变长,性能收益还不一定明显;Os和Oz偏体积,适合嵌入式。这些等级本质上是一组Pass的组合,你使用opt工具的时候,传入比如-pass-manager模式,可以自定义Pass组合。

优化Pass的编写是进阶开发者的必修课。LLVM 14之后默认使用新PM(New Pass Manager),写一个简单Function Pass,核心步骤包括声明Pass类,重写run函数,通过PassBuilder注册,最后加入Pass插件让opt识别。这个过程你一旦学会,就能针对性为你的语言做定制优化。比如我在一门DSL编译器里就写过循环展开Pass,把密集数值计算的性能提升了约35%。这种实际收益,比读一百遍教程都直接。

2.3 后端代码生成:从IR到机器码

后端是llvm-project最绕也最精妙的部分。简单说,后端接收优化后的IR,经过指令选择、指令调度、寄存器分配、指令布局、代码发射等阶段,最终生成汇编或机器码。每个后端目标(比如X86、ARM、RISC-V)都在llvm/lib/Target目录下,以Target名称命名,内部包含ISelLowering、SelectionDAG、GlobalISel、AsmPrinter等子模块。

以X86后端为例,文件路径是llvm/lib/Target/X86。这里的X86ISelLowering.cpp处理调用约定、参数传递等;X86InstrInfo.td用TableGen语言描述X86指令集;X86AsmPrinter负责输出汇编。如果你想支持一种新CPU架构,最省力的方式是从LLVM现有后端克隆一个最接近的,然后修改指令描述文件。这里引入TableGen,它是一种领域特定语言,专门用来描述指令集、寄存器等信息,然后由TableGen工具生成C++代码,避免手写大量重复代码。

对于只做上层应用、把LLVM当黑盒使用的人来说,后端细节不用深挖。但如果你是做数据库协处理器、自研芯片的编译工具链,那后端这一块必须啃下来。不管怎么说,理解“IR是分水岭,前端后端靠IR解耦”这张总图,对使用llvm-project的都很有帮助。

3. 让代码跑起来:llvm-project的获取、构建与调试

拿到llvm-project之后,第一件事是构建。很多人在这第一关就放弃了,因为编译LLVM对机器配置有要求,过程漫长且容易报错。这块我会把常用步骤、CMake参数、磁盘内存要求都列出,尽量让你一次成功。

3.1 获取源码:分支选择与克隆策略

获取llvm-project源码,建议从GitHub官方仓库拉取,而不是随便下载源码包,因为你需要管理后续的更新和切换分支。命令很常规:

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

--depth 1是浅克隆,只拉取最新一次提交的代码,体积会小很多。--branch指定的是release标签llvmorg-15.0.7,这个版本号对应你前面看到的“llvm-project 15.0.7”。如果你没有特定版本需求,也可以直接用默认main分支,但main分支处于活跃开发状态,稳定性不如release版本,我在做正式项目时一般不追踪main。

有人可能会问,浅克隆以后还想切换分支怎么办?加-fetch参数拉取目标分支即可。另外,强烈建议磁盘预留至少50GB空间,因为源码展开约2GB,构建目录动辄15GB以上,加安装包还会更多。8GB内存是底线,但16GB会更从容,如果内存小,并发编译任务别拉太高。

3.2 CMake构建:关键参数配置与选择逻辑

LLVM统一使用CMake构建。在llvm-project目录下,创建build目录专门用来存放构建产物,这是业内标准做法,避免源码目录和构建产物混在一起,将来清理也方便。核心配置命令大概是:

cmake -G Ninja -S llvm -B build \ -DCMAKE_BUILD_TYPE=Release \ -DLLVM_ENABLE_PROJECTS="clang;lld" \ -DLLVM_TARGETS_TO_BUILD="X86" \ -DLLVM_ENABLE_ASSERTIONS=ON

逐个说下参数。 -G Ninja表示生成Ninja构建文件,Ninja比Make更快,而且能充分利用多核并行。CMAKE_BUILD_TYPE设为Release,开启优化,正式使用必须用Release,Debug模式便于调试但性能差很多。LLVM_ENABLE_PROJECTS指定除了LLVM核心外需要构建的项目,这里选了clang和lld。LLVM_TARGETS_TO_BUILD只构建X86后端,如果你不搞交叉编译,可以减少编译量,否则默认会生成所有支持的后端,非常耗时。LLVM_ENABLE_ASSERTIONS在调试开发时打开,可在运行时检查很多错误,生产环境建议关掉。

配置完成后,用cmake --build build -j16或者ninja -C build编译。整个构建时间取决于机器性能,16核机器大概二三十分钟内能完成Clang和LLVM的构建,单核机器可能要几个小时。这期间可以泡杯茶等着。

3.3 使用构建产物:替代系统自带编译工具链

构建完成后,llvm-project会生成可用的工具链,例如:

build/bin/clang --version build/bin/llvm-as --version build/bin/llc --version

这些二进制文件可以直接使用。我自己在项目里通常会把build/bin目录加到PATH中,让clang优先于系统自带编译器。你还可以用clang -emit-llvm生成IR文件看看效果:

echo 'int add(int a,int b){ return a+b; }' > test.c build/bin/clang -S -emit-llvm test.c -o test.ll cat test.ll

这就是前面说的IR文本格式。再进一步,用build/bin/llc test.ll -o test.s,可以看到生成的X86汇编。这一条链路跑通,你对“前端产IR,后端出汇编”就有非常直观的感知了。项目里若需要静态分析,run-clang-tidy、clang-check等工具也能在这次构建中一并生成,非常方便。

4. 实操记录:动手跑通一个自定义优化Pass

读代码和读文档始终是“纸上得来”,真正让我理解Pass机制的,还是在llvm-project上写了一个自定义FunctionPass。这里我把完整过程放出来,含代码示例,你可以照着在自己的工程里复现。

4.1 确定要解决的问题和开发环境

我的目标很简单:写一个IR层面的优化Pass,找到所有形如“立即数加0”的多余操作,把它们替换成直接赋值。这个优化叫“strength reduction”的一种特例,看起来不起眼,却能说明Pass的编写范式。开发环境延续上一章的构建产物,使用LLVM 15源码。

开发Pass有两种方式:一是直接在llvm-project源码里新增文件,修改CMakeLists重新编译,好处是可以内嵌进opt工具;另一种是把Pass编译成独立插件,用opt -load-pass-plugin加载,开发时迭代更快。我推荐插件方式,因为不需要每次修改都重编整个LLVM,省时省力。

4.2 编写函数Pass的核心代码

我用New Pass Manager风格写,因为LLVM 15已经推荐这种模式。先写头文件和源文件。以MyAddZeroPass为例,它的run方法接收一个Function参数,返回PreservedAnalyses表明修改了哪些分析结果。

#include "llvm/IR/Function.h" #include "llvm/IR/Instructions.h" #include "llvm/Passes/PassBuilder.h" #include "llvm/Passes/PassPlugin.h" #include "llvm/IR/IRBuilder.h" using namespace llvm; namespace { class MyAddZeroPass : public PassInfoMixin<MyAddZeroPass> { public: PreservedAnalyses run(Function &F, FunctionAnalysisManager &AM) { bool Changed = false; for (BasicBlock &BB : F) { for (auto It = BB.begin(); It != BB.end(); ) { Instruction &I = *It++; if (auto *BinOp = dyn_cast<BinaryOperator>(&I)) { if (BinOp->getOpcode() == Instruction::Add) { Value *LHS = BinOp->getOperand(0); Value *RHS = BinOp->getOperand(1); if (auto *CI = dyn_cast<ConstantInt>(RHS)) { if (CI->isZero()) { BinOp->replaceAllUsesWith(LHS); BinOp->eraseFromParent(); Changed = true; } } } } } } return Changed ? PreservedAnalyses::none() : PreservedAnalyses::all(); } }; } // namespace

代码逻辑很直白:遍历每个基本块里的指令,如果是加法指令,且右操作数是常量0,就用左操作数替换所有使用该指令结果的地方,然后删掉这条指令。替换顺序很重要,必须先replaceAllUsesWith再eraseFromParent,否则这条指令之后使用者会指向悬空指令。

还需要注册Pass插件:

extern "C" LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, "MyAddZeroPass", LLVM_VERSION_STRING, [](PassBuilder &PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager &FPM, ArrayRef<PassBuilder::PipelineElement>) { if (Name == "my-add-zero") { FPM.addPass(MyAddZeroPass()); return true; } return false; }); }}; }

这里的关键是注册回调函数,告诉opt在遇到-mypass参数时要执行哪个Pass。函数名my-add-zero会出现在opt命令行中作为标识。

4.3 编译并测试自定义Pass

写一个CMakeLists.txt,把Pass编译成共享库:

cmake_minimum_required(VERSION 3.20) project(MyAddZeroPass) find_package(LLVM REQUIRED CONFIG) message(STATUS "Found LLVM ${LLVM_PACKAGE_VERSION}") include_directories(${LLVM_INCLUDE_DIRS}) add_library(MyAddZeroPass MODULE MyAddZeroPass.cpp) target_link_libraries(MyAddZeroPass PRIVATE LLVMCore LLVMSupport) install(TARGETS MyAddZeroPass LIBRARY DESTINATION lib)

注意需要在环境中能找到LLVM的CMake配置。编译命令:

mkdir -p pass-build && cd pass-build cmake -DCMAKE_PREFIX_PATH=../build/lib/cmake/llvm .. make -j4

成功后会生成libMyAddZeroPass.so。接着做测试。先用clang生成一份带x+0的IR:

cat > test.ll <<'EOF' define i32 @foo(i32 %x) { %1 = add i32 %x, 0 ret i32 %1 } EOF opt -load-pass-plugin ./build/libMyAddZeroPass.so -passes="function(my-add-zero)" -S test.ll -o out.ll

看一下out.ll,如果成功,那行add i32 %x, 0应该在函数内被消除,只剩ret。第一次跑通的时候非常有成就感。你可以拿这个例子,替换成自己想要的IR变换,比如把乘2改成移位、把与0取并改成置0,从而逐步掌握Pass开发。

4.4 踩过的坑与调试技巧

写Pass时最容易踩的坑有三类。第一,迭代器失效。我在遍历BasicBlock时用了It++提前递增,所以之后eraseFromParent不会破坏循环的迭代状态。如果你先erase再自增,下次循环就可能崩溃。第二,分析结果的保存。如果你的Pass没有修改IR,却返回PreservedAnalyses::none(),会导致后续Pass重新进行一些本不需要的分析,编译性能降低。正确做法是只在确有修改时返回none。第三,插件版本不匹配。LLVM各版本的API变化较大,如果你的插件基于15版本API编写,却在装有LLVM 16的opt上加载,通常会报错,这个没法兼容,必须保证工具链版本一致。

还有一个调试技巧:单独跑opt之前,先对简单的.ll文件用print-after-all参数查看每个Pass执行后的IR变化。比如:

opt -print-after-all -passes="function(my-add-zero)" -S test.ll

这样能一帧一帧看到Pass执行的细节,比盲目加打印语句高效得多。我在写复杂Pass时,都是靠这个方式定位问题在哪一步丢信息。

5. 常见问题与排查技巧实录

在实际使用llvm-project的过程中,我积累了不少问题排查经验,这里整理成速查表,方便你照着检查。

5.1 构建阶段高频问题与解决方式

我把构建过程中遇到最多的问题整理成一张表,方便速查:

症状可能原因解决方案
clang: error: unable to execute command: Killed内存不足,链接阶段进程被杀降低并行度 -j4 或 -j2,增加交换分区
编译到约80%时报错“Error: No rule to make target”源码不完整或构建目录损坏删除build目录重新cmake,保证网络稳定
ld: cannot find -lz / -ltinfo缺少系统依赖库Ubuntu安装zlib1g-dev、libtinfo-dev
CMake Error: Could not find a package configuration fileLLVM_DIR未设置指定-DLLVM_DIR=/path/to/lib/cmake/llvm
编译时间异常长后端目标过多或Debug模式使用Release模式、只保留X86后端

内存不足这条最隐蔽。我原先有一台8GB内存的机器编译LLVM,总是到“链接clang”这个阶段直接进程被杀。后来查了进程监控,发现单条链接命令峰值能吃掉近6GB内存,再加上编译器的并行进程,机器直接扛不住。解决方式很简单:减少并行任务数,或者临时加swap。你也可以用Ninja的串行链接机制,把并行编译设为高、链接设为低,具体语法是-DLLVM_PARALLEL_LINK_JOBS=2,效果立竿见影。

5.2 链接与运行阶段的问题

链接器报错也是常见状况。最典型的文本是“undefined reference to `llvm::createXxxPass()'”,这通常意味着你链接的库不完整或者版本不匹配。比如你明明装了libLLVMCore,但插件里使用了某个非导出符号,或者你的CMakeLists没有链接对应组件库。排查思路是先用grep在LLVM源码或lib目录里确认符号是否存在,如果有,就在target_link_libraries里把库补上。

另一个常见问题是运行opt时报“unknown pass name”。这几乎都是因为插件没有正确注册,或者-passes参数的Pass名字和你注册的名字不一致。我在自己电脑上遇到过很多次,多半是我把my-add-zero写成了myAddZero,opt当然不认识。可以在插件注册回调里加打印,先看看Name是否接收到。

还有一个和版本相关的问题。用build/bin/clang编译出的IR文件,位码版本和系统自带的clang版本不一致,如果你拿公司CI机器上其他版本的llvm-as来读,可能会报“bitcode version mismatch”。处理办法就是统一工具链来源,整个项目尽量都使用同一套llvm-project构建产物,不要混用不同版本的LLVM工具。

6. llvmpipe与LLVM的关联,以及后续扩展方向

前面多次提到llvmpipe。其实它并不是llvm-project仓库的一部分,而是Mesa项目中的软件渲染驱动,但它借助LLVM完成核心工作。它的工作方式是:把OpenGL着色器翻译成TGSI或NIR中间表示,再通过LLVM的JIT技术生成当前CPU上的机器码。如果CPU支持AVX(256位向量指令),llvmpipe生成256位宽的SIMD指令,一次可以同时处理8个float数据,这就是你看到的“LLVM 15.0.7, 256 bits”这类版本提示的由来。可以说,llvmpipe是LLVM作为通用代码生成器能力的最直观展示:它甚至没想过图形渲染领域也要依靠LLVM来“即时编译”。

从扩展角度看,llvm-project还提供了很多可以玩耍的方向。比如调试器LLDB、libFuzzer模糊测试框架、clang-tidy代码检查工具、BOLT二进制优化工具等,这些都在llvm-project仓库中。新版本还引入了MLIR框架,专门用来搭建针对特定领域的编译器,比如机器学习加速器编译器。你在掌握基础工具链之后,可以按兴趣深入这些子项目。它们在底层能力上是相通的,一旦你理解了IR和Pass,换到MLIR、Flang只是换了一批描述性语言和API而已。

我在实际项目中就尝试过用LLDB调试一个自己写的解释器,又用libFuzzer给解释器的解析器做了几轮模糊测试,跑出了好几个边界崩溃。这种“一个仓库解决多种问题”的体验,确实是llvm-project最有魅力的地方。

7. 给新手的实战建议

针对刚开始接触llvm-project的读者,我想把路线浓缩成几点,都是我在过去踩坑后总结出的做法。

第一,先构建,后理解。很多人习惯先看文档,反复犹豫选哪个分支,结果浪费大量时间。我的建议是直接按第三章的流程,用release分支构建一个基础版本,把clang、opt、llc跑通再说。构建过程即是最好的热身。

第二,从改Pass入手,而不是从改后端开始。改Pass对理解IR结构、优化流程非常高效,因为它不需要关心指令选择和寄存器分配这些复杂环节。你在自己的Pass里添加一个指令替换,很快就能看到IR变化,这种正反馈能极大提高学习兴致。

第三,善用llvm-reduce和bugpoint。如果你在做开发时发现某个优化Pass导致生成代码出错,可以用llvm-reduce把出错的IR文件最小化,再用bugpoint定位是哪一步Pass引入的错误。这种工具链自带的调试手段,比手工缩小测试样例高效太多。

第四,多读官方文档,但别指望一次读懂。LLVM的文档写得挺全,但很多是“参考手册”式的,默认读者已经理解背景。我的读法是想弄清楚某个具体问题,比如“为什么要用SelectionDAG”,就去llvm.org/docs里搜相关专题,配合源码里的注释来理解。不要从头到尾顺序读,效率会很低。

把这四点做到,你在llvm-project上基本就能独立摸索了,遇到问题时也可以去LLVM Discourse、LLVM Weekly这类社区找答案。实际用过的人都知道,这门技术只要迈过“构建和写第一个Pass”这个门槛,后面就是坦途。

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

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

立即咨询