拓冰建站拓冰建站
首页 / 资讯中心 / 正文

llvm-project从入门到实践:目录解析、构建配置与自定义Pass开发指南

看到llvm-project这个仓库名的时候大多数人的第一反应是这是一个编译器。等真的 clone 下来才发现顶层目录二三十个、源码几十万个文件、CMake 配置看得人头皮发麻压根不知道从哪下手。我在这套工具链里断断续续折腾了好几年从 Clang 插件写到后端指令选择再到 LLD 裁剪和 Pass 开发中间踩过的坑比吃过的饭还多。这篇文章不是给你讲 LLVM 的源码分析而是给那些“拿到这个项目想快速跑起来、做出第一次自己的改动”的人准备的。我会按照我实际工作的顺序把目录结构、构建参数、第一个 Pass、IR 调试以及效率红线全部交代清楚每一处都附上我自己的实测数据和教训。1. 别再对着根目录发呆先读懂llvm-project的目录分工1.1 一屋子编译器而不是一个编译器llvm-project最迷惑人的地方在于它看起来像一个大仓库实际拆开是十几个独立项目共享一套 CMake 构建体系。你在根目录看到的clang、lld、lldb、mlir每一个都有独立的仓库地址只是官方把这些统一放进了一个 monorepo方便统一版本、统一构建、统一测试。这个设计理念是“modular”也就是模块化编译器前端负责把高级语言变成中间表示优化器负责处理中间表示后端负责把中间表示变成机器码链接器负责收尾。前端不用关心目标芯片后端不用关心源码语法这才是 LLVM 能一年接入一个新语言的根本原因。如果你只是想做编译流程上的改动比如改优化策略、加一个编译告警、做代码插桩大部分时间只会在llvm和clang这两个目录里转。clang是 C/C/Objective-C 的前端llvm放的是 IR 定义、优化 Pass、目标后端和opt、llc、llvm-as这些核心工具。lld是链接器clang-tools-extra里是你常用的clangd和clang-tidy。还有一批目录像compiler-rt、libcxx、libunwind这些是运行时库一般跟编译器的机器码生成没什么关系。1.2 开发时真正要关心的几个目录我整理了一张我平时最常用的目录分工表新手只需要先记住这些就不会在根目录里迷路目录作用什么时候会碰它llvmIR、优化器、目标后端、核心工具opt/llc/llvm-as等写 Pass、改优化、改后端绝大多数时间在这里clangC/C/ObjC前端语法语义分析、AST、代码生成加编译选项、改告警、做静态检查、插桩clang-tools-extraclangd、clang-tidy等附加工具做代码分析工具、改 IDE 补全逻辑lldELF/Mach-O/COFF链接器改链接行为、做链接器优化、分析空间占用lldb调试器调试器和编译器协同工作时compiler-rtsanitizer、builtins、profile运行时做内存检测、覆盖率工具时libcxx/libcxxabiC标准库实现研究标准库实现、改STL行为时mlir多级IR框架做深度学习编译器、自定义IR时polly多面体优化做循环变换、数据局部性优化时对应到编译流水线上就更容易理解了。clang把 C 代码翻译成 IRopt对 IR 做优化llc把 IR 变成汇编lld把目标文件链接成可执行文件。新手最容易做错的一件事是跑去clang目录里找优化相关的代码结果发现优化全在llvm/lib/Transforms下面。语法是前端的事优化是中端的事拿到 IR 之后的玩法都归llvm管这两个概念的边界越早建立后面越少走弯路。2. 首次构建CMake参数怎么选才不翻车2.1 环境准备与工具链自举构建llvm-project前你需要一个能跑起来的系统编译器。官方推荐用 Clang 或者 GCC版本要求通常写在大版本说明里比如 17.x 要求 GCC 7.4 及以上或者 Clang 6.0 及以上。我用的是 Linux Clang 工具链自举也就是先用系统的 Clang 编译第一版 LLVM之后整个工具链都由自己生成的编译器再编译。这一步对大多数人来说其实不是必须的直接apt install build-essential装一个 GCC 也能正常构建只是我个人后面打算长期做编译器和工具链开发所以一开始就按自举的路子走。clone 的时候建议先切到一个稳定 tag不要直接拿main分支当开发基线。main上面的代码每天都有大量提交今天能编译通过明天可能就因为一个 API 改名构建失败。我自己的习惯是git clone https://github.com/llvm/llvm-project.git cd llvm-project git checkout llvmorg-17.0.6切到稳定版本后源码树就冻结了接下来所有构建和改动都基于这个基线。至于为什么不直接用系统apt装的 LLVM原因很简单你想要在opt里加载自己的 Pass 动态库必须保证 Pass 的编译环境和你跑opt的那个 LLVM 是同一个版本、同一套头文件任何一处不对加载时就是 ABI 不兼容直接报错。2.2 关键的CMake开关与含义LLVM 的构建系统是 CMake NinjaNinja 的增量构建速度比 Make 快出一个量级多核支持也更稳。我建议不要在源码目录里直接 build而是单独建一个build目录后面你会同时维护 debug 和 release 两个构建目录这个习惯能帮你少踩非常多坑。cd llvm-project mkdir build cd build cmake -G Ninja ../llvm \ -DCMAKE_BUILD_TYPERelWithDebInfo \ -DLLVM_ENABLE_PROJECTSclang;lld;clang-tools-extra \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_ENABLE_ASSERTIONSON \ -DLLVM_USE_LINKERlld \ -DLLVM_CCACHE_BUILDON ninja clang opt lld这里每个参数都是有讲究的逐个说CMAKE_BUILD_TYPE我强烈建议用RelWithDebInfo也就是“优化开启 带调试信息”。如果你只想要最快的构建速度可以选Release如果你打算在 Pass 里下断点、单步跟踪必须用Debug或者RelWithDebInfo。我个人的方案是日常开发用RelWithDebInfo跑性能测试时再用Release因为 Release 会把断言全关掉行为跟线上部署更一致。LLVM_ENABLE_PROJECTS告诉构建系统要额外编译哪些子项目。默认只构建核心 LLVM也就是你没有 Clang。如果你只写 IR Passclang其实都可以不选但为了从源代码一路跑到机器码我一般把clang、lld、clang-tools-extra都带上。注意是分号分隔不要加空格。LLVM_TARGETS_TO_BUILD用来限制后端目标。默认会构建全部架构后端X86、ARM、AArch64、RISC-V、WebAssembly全都带上的话编译时间至少多出一倍。在 x86 机器上做实验只留X86生成的目标文件体积也小很多。如果你后面要交叉编译或者研究 ARM 后端再加AArch64就行。LLVM_ENABLE_ASSERTIONSON会让 LLVM 内部的断言生效。调试 Pass 时这几乎是必须的很多内存错误和不合法 IR 操作在 Release 下会静默失败开了断言能直接告诉你问题在哪一行。LLVM_USE_LINKERlld是提速的关键。LLVM 库非常大用系统的ld链接最终阶段极其吃内存、极慢而 LLVM 自家的 lld 在内存占用和速度上都要好太多。默认是空值也就是让 CMake 自己找但既然你就在构建 lld直接指定它即可。LLVM_CCACHE_BUILDON开启 ccache 缓存第一次全量构建后后续增量构建会有很大提升。没有 ccache 的话改一个头文件可能导致几千个文件重新编译那感觉非常难受。2.3 实测构建时长与硬件建议我拿一台 16 核 32G 内存的机器做过一次基准RelWithDebInfo、只留 X86、带clang;lld;clang-tools-extra首次全量构建用 Ninja 默认并发大约花了 25 分钟。8 核 16G 的机器按同样配置大概要 50 到 60 分钟。如果选择Debug模式构建时间会再拉长不少磁盘占用也更大。内存是一条硬红线。LLVM 链接的峰值内存可以到 15GB 左右如果并发链接多个目标32G 内存都会显得紧张。常见处理方式有两个一是限制并行链接任务数在 CMake 配置时加上-DLLVM_PARALLEL_LINK_JOBS2意思是只让 2 个链接任务同时跑。二是用-DLLVM_USE_LINKERlldld 在链接大型库时内存比 GNU ld 低不少。还有一个偷懒但实用的办法ninja -j不要设得太高不要盲目按核心数 x2来至少给链接阶段留出内存余量。磁盘方面一个RelWithDebInfo的 build 目录占 40 到 70GB 很正常。别问我怎么知道的我曾在一块 120G 的机器上同时建了 debug 和 release 两个目录最后连临时缓存都放不下只能删掉一个重来。3. 从修改一个Pass开始让你的改动真正进入llvm-project3.1 为什么先写Pass很多新人想“改 llvm-project”第一反应是去改clang的告警输出或者去动后端的指令选择。这两个方向不是不行而是路径太长你需要理解 AST、需要理解 SelectionDAG、需要看大量后端代码。相比之下写一个优化 Pass 是收益最高、见效最快的上手路径。Pass 工作在最核心的 IR 层输入和输出都是文本可读的.ll文件你可以用opt单独加载、单独调试不需要每次改动都编译整个编译器的前端和后端。我平时做性能分析或者代码插桩时也优先写 Pass因为它能拿到完整的函数、循环、调用关系以及指令信息比在 AST 层做分析更接近最终生成的机器码。这篇文章里的示例是一个分析型 Pass遍历每个函数统计 Call 指令和二元运算指令的数量把结果打印到调试输出。这个 Pass 不修改 IR所以只展示分析不用处理 IR 重写带来的各种和数据流相关的问题特别适合入门。3.2 用New Pass Manager写一个统计指令的插件从 LLVM 15 之后官方默认使用 New Pass Manager老式的legacy::FunctionPass已经不再推荐使用。新 PM 的核心思想是把 Pass 按照依赖关系组织成流水线Pass 之间通过FunctionAnalysisManager共享分析结果谁依赖谁一目了然顺序也更可控。我通常把自定义 Pass 写成外部插件不往llvm-project源码树里塞文件。这样能保持仓库干净后续升级 LLVM 版本时直接换 tag 就行。使用外部插件方式需要让 CMake 找到你刚构建的 LLVM 的配置信息cmake_minimum_required(VERSION 3.20) project(MyFirstPass) find_package(LLVM REQUIRED CONFIG) message(STATUS Found LLVM ${LLVM_PACKAGE_VERSION}) llvm_map_components_to_libnames(LLVM_LIBS core support irreader passes) add_library(MyFirstPass MODULE MyFirstPass.cpp) target_include_directories(MyFirstPass PRIVATE ${LLVM_INCLUDE_DIRS}) target_compile_definitions(MyFirstPass PRIVATE ${LLVM_DEFINITIONS}) target_compile_options(MyFirstPass PRIVATE ${LLVM_COMPILE_FLAGS}) target_link_libraries(MyFirstPass PRIVATE ${LLVM_LIBS})如果你的 LLVM 是通过我前面那种方式在llvm-project/build里构建出来的并没有安装到系统配置时可以显式指定LLVM_DIR指向 build 目录下的 CMake 配置cmake -B build-pass -S . \ -DLLVM_DIR/path/to/llvm-project/build/lib/cmake/llvmPass 主体代码如下#include llvm/IR/Function.h #include llvm/IR/InstIterator.h #include llvm/IR/Instructions.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Support/raw_ostream.h using namespace llvm; namespace { struct MyFirstPass : public PassInfoMixinMyFirstPass { PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { unsigned CallCount 0; unsigned BinaryOpCount 0; for (BasicBlock BB : F) { for (Instruction I : BB) { if (isaCallBase(I)) CallCount; if (I.isBinaryOp()) BinaryOpCount; } } dbgs() [my-first-pass] F.getName() calls CallCount binops BinaryOpCount \n; return PreservedAnalyses::all(); } }; } // namespace static void registerMyPass(PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name my-first-pass) { FPM.addPass(MyFirstPass()); return true; } return false; }); } extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, MyFirstPass, LLVM_VERSION_STRING, registerMyPass}; }这里有几个关键点。PassInfoMixinMyFirstPass是新 PM 的标配写法里面只有一个run方法参数就是当前函数和分析管理器。如果你以后要写模块级的 Pass就把Function换成Module方法签名整体换掉。isaCallBase(I)比isaCallInst(I)更稳因为它把普通函数调用、invoke、callbr都算进去了。I.isBinaryOp()能识别add、sub、and、or这类双目运算判断一个 IR 指令是不是算术/逻辑运算用这个成员函数最简单。PreservedAnalyses::all()表示这个 Pass 没有改动任何 IR所有分析结果都可以保留。如果之后你写的 Pass 真的改了代码要返回PreservedAnalyses::none()告诉优化器“我的改动可能影响所有分析结果请重新计算”。这一步做错会导致很麻烦的疑难问题最常见的是数据流分析引用到已过期的信息程序表现出非确定性行为。3.3 让opt认识你的Pass注册与运行插件的导出入口是llvmGetPassPluginInfo外部加载器就是通过这个函数拿到的插件信息。我前面代码里registerPipelineParsingCallback注册了一个命令行可用的 pass 名my-first-pass这样opt的-passes参数里可以直接写这个名字。构建插件cmake --build build-pass构建成功后你会得到一个MyFirstPass.so。现在用一个简单的 C 文件做测试// test.c int foo(int a, int b) { return a b; } int bar(int *p, int n) { int s 0; for (int i 0; i n; i) s p[i]; return s; } 先生成 IR clang -O1 -S -emit-llvm test.c -o test.ll然后把你的 Pass 加载进去opt -load-pass-pluginbuild-pass/MyFirstPass.so -passesmy-first-pass -S test.ll -o /dev/null注意-load-pass-plugin是加载动态库-passesmy-first-pass是告诉opt运行哪个 Pass。如果一切正常你会看到类似这样的输出[my-first-pass] foo calls0 binops1 [my-first-pass] bar calls1 binops2我当初第一次跑通这个流程的时候觉得就两行日志没什么了不起但仔细想想你看到的是编译器在 IR 层遍历每个函数、计算每条指令的统计结果这一整套编译设施已经完整地握在手里了。后面想做循环分析、内存访问分析、插桩都是在这个骨架上加逻辑。4. 看穿IR定位代码优化的唯一标准4.1 IR的读写与工具链写 Pass 之后你绕不开一件事读懂 IR。很多 Pass 新手改完代码只凭“跑出来的程序没崩”就认为优化生效了这完全不够。你要亲眼看到 IR 从什么样子变成了什么样子才能确定 Pass 是否真的做了你期望的变换。LLVM 提供了两套文件格式可读的文本 IR.ll和不可读的 bitcode.bc。两者互相转换的工具分别是llvm-as和llvm-dis但平时我们很少直接调这两个命令因为clang和opt都帮你处理好了。要生成可读 IRclang -S -emit-llvm test.c -o test.ll-S -emit-llvm的意思是“生成汇编文件但汇编语言是 LLVM IR 而不是目标架构汇编”。如果你要看优化后的 IR就把优化等级打开clang -O1 -S -emit-llvm test.c -o test.ll优化前后的 IR 差异能直观告诉你编译器做了哪些变换。opt工具就是专门在 IR 上运行 Pass 的llc再把 IR 变成目标汇编。这三者的分工我一直推荐给团队里的新人记成一句话clang 是前端负责把 C 变成 IRopt 是优化器负责把 IR 变好llc 是后端负责把 IR 变成机器码。你在llvm/lib/Transforms下写的 Pass本质上就是在opt这一步插入自己的逻辑。4.2 优化前后的IR对比盲写 Pass 是最容易犯的错误我的建议是动手前先看一颗最小例子。比如下面这段代码int addmul(int a, int b, int c) { return a * c b * c; }不做优化时生成的 IR 片段是两条独立的乘法再相加%mul1 mul nsw i32 %a, %c %mul2 mul nsw i32 %b, %c %add add nsw i32 %mul1, %mul2 ret i32 %add如果开启-O1LLVM 会做指令合并可能把代码优化成基于乘法分配律的mul (ab), c%add add nsw i32 %a, %b %mul mul nsw i32 %add, %c ret i32 %mul这只是一个极简示例但能说明一个非常重要的道理你在 IR 层看到的每一步变换都对应着优化器里一个具体的 Pass。想找到是哪个 Pass 做的这件事可以用opt的-passes一点一点二分排查。这种对照方式是我平时定位优化问题和写新 Pass 时用得最多的手段。4.3 在Pass中以调试API观察状态代码里我用了dbgs()这在 LLVM 里等价于调试输出流。没有加-debug-only时dbgs()输出一样会打到标准错误。如果使用 LLVM 自带的调试宏还能实现按需输出LLVM_DEBUG(dbgs() CallCount CallCount \n);运行时用-debug-onlymy-first-pass控制是否打印opt -load-pass-pluginbuild-pass/MyFirstPass.so -passesmy-first-pass -debug-onlymy-first-pass test.ll -o /dev/null这比到处写printf干净很多因为调试信息可以直接挂在 Pass 的DEBUG_TYPE上生产环境下不会被到处刷屏。不过要提醒一句LLVM_DEBUG在Release模式下默认会被编译器丢弃所以你调试 Pass 时一定要用RelWithDebInfo或者Debug构建并且开启LLVM_ENABLE_ASSERTIONSON。我在 Debug 和 Release 之间切换时被这个问题坑过不少次后来干脆所有开发构建都开 assertions。如果 Pass 修改了 IR你想看修改前后的差异不用改代码直接跑opt -passesmy-first-pass -print-before-all -print-after-all -S test.ll -o /dev/null配合-filter-print-funcsfoo可以把日志限制在某个函数内日志量会小很多。这几个参数对定位“Pass 为什么没跑到某个函数”这类问题特别有效。5. 构建与开发的效率红线踩坑记录和实用配置5.1 增量编译与ccache/硬链接先说现实中最常见的问题你只是改了一个 Pass 的run方法结果ninja把整个opt重新链接了一遍那很正常。但如果改动llvm/IR/Instruction.h这种公共头文件会导致几乎所有源文件重新编译这个无法完全避免除非你降低对底层头文件的改动频率。ccache 是第一个救星。开启之后相同的源文件内容不会再重新编译哪怕中间改了其他文件已经处理过的编译单元也能命中缓存。我在 CMake 里用的组合是cmake -G Ninja ../llvm \ -DLLVM_CCACHE_BUILDON \ -DCMAKE_C_COMPILER_LAUNCHERccache \ -DCMAKE_CXX_COMPILER_LAUNCHERccache第二个救星是把LLVM_USE_LINKERlld固定下来。第一次全量链接libLLVM.so的时候用系统ld差点把内存打满换成 lld 之后链接速度快了一倍内存峰值也低了不少。如果你是 macOS默认用ld64.lld也有同样效果。还有一个经验是不要直接在源码目录里 build。LLVM 明确规定不支持 in-source build但总有新人会被某篇古早教程带偏。源码目录一旦被 build 产物混入后续 git 操作会很难看而且容易产生“改了源码但是 Ninja 不重新编译”这种诡异现象。我现在的固定工作流是llvm-project # 源码 ├── build-debug # Debug assertions └── build-release # Release lld ccacheDebug 目录用于写 Pass 和调试Release 目录用于跑性能测试、比对优化效果。两个目录并存的好处是Release 出来的是真实优化后的产物不会被断言拖慢坏处是磁盘占用翻倍如果你没有 60GB 以上空闲可以先只建一个build-debug。5.2 版本一致性陷阱外部插件方式有个最坑的规则Pass 动态库必须和运行它的opt来自同一套 LLVM 源码和构建参数。我之前遇到过一种情况用llvmorg-17.0.6构建了opt但 Pass 插件在编译时链接的是系统自带的libLLVM.so加载时直接报 “Consumed invalid Pass” 或者 API version mismatch。这不是你代码写错了而是插件和宿主 LLVM 版本不一致。坚持以下几点就能避开永远从同一个llvm-project源码树的同一 tag 构建opt和 Pass 插件。外部插件用find_package(LLVM CONFIG)时明确指定LLVM_DIR指向你源码树的build/lib/cmake/llvm不要让它自动找到/usr/lib/...。如果你的项目需要长期维护建议直接在llvm-project/llvm/lib/Transforms/下新建一个目录用add_subdirectory加进源码树一起编译。这样虽然要重新编opt但 ABI 一定一致而且还可以使用 LLVM 的测试框架集成。还有一个相关坑升级 LLVM 版本时头文件变化常常会让外部插件编译失败。比如 API 改名、Pass 构造方式变化。所以我在前面才反复强调不要追main分支。稳定 tag 至少让你在同一个版本周期内不受上游频繁变动影响。5.3 用llvm-lit验证Pass行为手动运行opt验证 Pass 只是第一步真正要保证功能稳定得把测试用例固化下来。LLVM 官方测试框架是llvm-lit配合FileCheck来检查输出。如果你把 Pass 放进了llvm-project源码树可以在llvm/test/Transforms/MyFirstPass/下建测试文件; RUN: opt -passesmy-first-pass -S %s -o - | FileCheck %s define i32 addmul(i32 %a, i32 %b, i32 %c) { ; CHECK: calls0 binops3 %add add i32 %a, %b %mul1 mul i32 %add, %c %mul2 mul i32 %b, %c ret i32 %mul2 }然后跑ninja check-llvm或者只运行这个测试文件llvm-lit path/to/test.ll这里有个容易忽略的细节测试文件里即便当前 IR 没有函数调用也要故意设计一个带调用的用例因为binops的数量会直接影响输出。建议至少覆盖三种情况普通函数、带调用的函数、空函数。你还可以在测试里检查 IR 是否被修改如果 Pass 是分析型那 FileCheck 的输出应该完全一致如果它改了 IR你就要把修改后的指令也CHECK出来。用llvm-lit做回归测试的原因很简单改了公共头文件或者上游更新后跑一遍全量测试就能快速知道有没有破坏已有 Pass。我见过很多人在本地手动跑一两个例子觉得没问题结果 push 之后 CI 挂了原因就是没把测试用例沉淀成自动化检查。还有一个提升效率的小技巧ninja -t targets可以列出所有可用的构建目标你不需要把整个 LLVM 都编译出来。日常写 Pass 只需要opt、clang、llvm-as、llvm-dis构建时直接指定ninja opt clang llvm-as llvm-dis这样增量构建的时间通常能压到几分钟甚至几十秒比每次全量构建舒服太多了。最后再分享一个我现在一直在用的工作流。拿到llvm-project后先切到稳定 tag然后建build-debug和build-release两个目录CMake 里固定LLVM_USE_LINKERlld和 ccache。写 Pass 时一定先用llvm-lit建最小测试用例不在大型项目上直接看日志。改到公共头文件时做好心理准备先让 Ninja 跑一轮利用这段时间去把 IR 和优化流程的文档再看一遍。等你把第一个 Pass 从dbgs()日志一路跑到llvm-lit回归测试这个仓库对你来说就不再是某个网上“很牛但看不懂的编译器”而是一套你能随时拆零件、换零件、加零件的工具链。我的体会是编译器的门槛不在于代码难写而在于那个“从源码到可执行文件”的闭环太长任何一个环节版本不对、参数不对、构建方式不对都会让你卡住半天。先把闭环跑通后面的事情就都是量的问题了。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门