ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

从零构建LLVM项目:源码结构、核心模块与Pass开发实战

从零构建LLVM项目:源码结构、核心模块与Pass开发实战 如果你点开过 llvm-project 的 GitHub 仓库十有八九第一眼会被它庞大的目录结构、数不清的.cpp文件和令人头皮发麻的 CMake 配置吓到。我最早接触这个项目时也差不多翻了几页就关掉了心里只有一个想法这种级别的源码不是给人读的。后来因为工作里要搞编译优化相关的事硬着头皮从零开始把它拉下来、编译跑通才发现这个仓库其实有非常清晰的骨架而且它离普通开发者的距离可能比你想象中近得多。这篇文章不打算从“编译器原理”这种教科书话题开讲我想以 llvm-project 这个仓库为一条主线说说它到底是什么、怎么从源码构建出来、里面的核心模块分别在做什么、有哪些坑是外面文档不会告诉你的以及你真的动手用它做出一点小东西时整个流程长什么样。如果你是对编译器、工具链、底层性能优化感兴趣的开发者这篇内容应该能帮你省下不少自己摸索的时间。1. llvm-project 到底是什么拆解项目核心价值初次看到这个仓库名的人很容易把它理解成“一个叫 LLVM 的开源编译器项目”。这个理解不算错但不够完整。llvm-project 实际上是整个 LLVM 生态的统一代码仓库采用 monorepo 模式管理里面装的不是“一个编译器”而是围绕编译技术展开的一整套工具链基础设施。1.1 一个仓库装下整个编译器生态真正把仓库克隆下来你会发现根目录下并列排着 llvm、clang、lld、lldb、mlir、flang、compiler-rt、libc、libcabi、libunwind、polly、openmp 等一堆子目录。每个目录都是一个可独立发展的子项目但又共享同一套构建系统、同一套代码提交历史、同一套版本号。这种 monorepo 的设计在编译器这种模块耦合度极高的项目里显得尤其关键——Clang 前端生成 IRLLVM 核心做优化LLD 负责链接LLDB 负责调试这几个环节如果分仓库管理每次跨仓库改动都要同步提交一堆互相关联的 commit早就乱套了。我个人的感受是monorepo 最大的好处不是“方便管理代码”这种抽象优点而是让工具链的各个阶段始终处于同一套版本体系里。你从 llvm-project 拉下来的代码保证 Clang 生成的 IR 能被当前版本的优化器正确消费LLD 也认识当前版本生成的目标文件格式。这在三四个子项目各自独立演化时是一个非常实惠的工程决策。子项目之间的关系可以粗略理解为一条完整的编译流水线clang 负责把 C/C/Objective-C 源码转换成中间表示llvm 核心库对中间表示做优化并生成目标平台汇编lld 把汇编产物链接成可执行文件或库lldb 负责对最终产物做源码级调试libc 和 compiler-rt 则提供运行时的支持。搞懂这条流水线整个仓库的骨架就清楚了一大半。1.2 为什么现代工具链都在往 LLVM 上靠聊到 LLVM绕不开的一个问题是GCC 已经存在这么多年为什么苹果、Google、Rust、Swift 这些大厂和项目都要往 LLVM 上靠原因其实不复杂LLVM 的架构设计从一开始就奔着一个目标去的——前后端分离。GCC 虽然也分前端和后端但因为历史包袱各语言前端共享的中间表示设计得像一个巨大的内部约定各种语言特有的信息经常泄露到后端导致想在这套框架上支持一种新语言、一个新芯片工作量都相当大。LLVM 的做法就纯粹很多前端只需要负责把源码变成 LLVM IR后端只负责把 LLVM IR 变成目标机器码中间插一个强大的优化器而且这三层之间的接口是公开、稳定、文档化的。这意味着你想发明一门新编程语言可以只写一个能把源码翻译成 LLVM IR 的前端其余优化、代码生成、调试信息、链接器支持全部白嫖。Rust 早期就是这么干的Swift 也是这么干的。对我们这些普通开发者来说这套架构带来的直接好处是你可以用 Clang 的语法检查能力、LLVM 的优化框架、LLD 的链接速度而不需要理解底层每一条指令是怎么生成的。甚至在写完代码后通过一条命令看到自己源码对应的中间表示理解编译器到底对代码做了什么这种可观察性在 GCC 体系里是很难获得的。2. 从零构建 llvm-project一次完整的编译体验老实说第一次编译 llvm-project 这件事本身就非常有教育意义。不是因为它多难而是它把“现代大型 C 项目的构建复杂度”暴露得淋漓尽致。如果你的机器配置不算很高这个过程甚至可以成为一次对耐心的极限测试。这一节我把完整的操作流程和关键参数讲清楚尽量让你少走弯路。2.1 准备工作硬件、系统与依赖从源码构建 llvm-project 之前先评估一下手上的机器。按我实测的经验只构建 LLVM 核心加 Clang、LLD 这三件套Release 模式下大概需要 20 GB 左右的磁盘空间源码目录本身约 2 GB构建目录在 15 GB 以上很正常。如果是全量构建所有子项目包括 MLIR、Flang、compiler-rt、libc 全打开磁盘占用奔着 50 GB 去也不奇怪。内存方面8 GB 是勉强能跑的水准16 GB 会比较舒服如果你同时开多个并行链接任务32 GB 也不嫌多。最容易爆的不是编译而是链接——链接 clang 这种巨型二进制时单个链接进程吃 3 到 4 GB 内存非常常见。所以我的建议是在 cmake 配置时顺手限制一下并行链接任务数具体参数后面会提到。操作系统方面我主要在 Linux 和 macOS 上构建过。Windows 也能构建但要走 Visual Studio 的生成器或者使用 Windows 版的 Ninja踩坑概率更高。如果你手头只有 Windows建议先开个 WSL 用 Linux 环境来跑体验会顺滑很多。依赖项主要是这几个CMake3.20 以上版本、Ninja、Python 3、zlib 开发库以及一个能用的 C/C 编译器。这里有个小建议如果你机器上已经装了比较新版本的 Clang就用 Clang 来构建 llvm-project不要用 GCC。不是说 GCC 不行而是新版 LLVM 代码很多地方会用一些较新的 C 特性GCC 版本不够新的话编译期报错会非常酸爽。用 Clang 构建 Clang也就是所谓的自举构建是官方主推路径。2.2 CMake 配置的关键参数解析构建目录建议放在源码目录之外的独立文件夹避免把源码目录搞脏。整个流程是标准的 CMake 三连但参数选择很讲究。下面是我常用的配置逐个参数解释一下为什么这么设git clone https://github.com/llvm/llvm-project.git cd llvm-project mkdir build cd build cmake -G Ninja ../llvm \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld;clang-tools-extra \ -DLLVM_TARGETS_TO_BUILDX86;AArch64 \ -DLLVM_USE_LINKERlld \ -DBUILD_SHARED_LIBSON \ -DLLVM_PARALLEL_LINK_JOBS2-DLLVM_ENABLE_PROJECTS这一项决定你要构建哪些子项目。新手最容易踩的坑是老老实实全量构建结果发现时间成本高得离谱。实际上你只需要按需开启比如我现在只是想要 clang 和 lld就写这两个想顺便编译 clang-tidy、clang-format 这些工具就再加一个 clang-tools-extra。-DLLVM_TARGETS_TO_BUILD是控制后端的不建议使用默认值。默认会把所有支持的 CPU 后端全部编译出来包括 AArch64、ARM、PowerPC、RISCV、Mips 等十几二十个这对绝大多数人是纯浪费。如果你的主力机器是 x86_64直接写 X86 就够了。我在工作中偶尔要交叉编译到 ARM所以会加上 AArch64。这一项对编译时间和产物体积的影响非常大务必手动指定。-DLLVM_USE_LINKERlld用 lld 做链接器能显著加速链接过程。要使用这个选项意味着你手头已经有一个可用的 lld或者系统的默认链接器是 lld。如果你还没装 lld可以先用系统自带的链接器完成第一轮构建之后再用 lld 做增量构建。-DBUILD_SHARED_LIBSON会把 LLVM 的各核心库编译成动态库而不是静态库。这样做的好处是链接期内存占用小、速度快代价是产物体积大一些、部署时多了一堆 .so 文件要带。只做开发调试的话这个选项很划算。如果是做分发用的 Release 包可以关掉。最后那个-DLLVM_PARALLEL_LINK_JOBS2就是前面说的限制链接并行数防止内存被撑爆。编译阶段老老实实让 ninja 全速跑链接阶段限定 2 个并行任务是我验证过比较稳的组合。2.3 构建提速与内存控制经验配置完成之后构建命令本身没什么花头ninja clang lld opt注意我没有直接跑ninja全量构建而是指定了 clang、lld、opt 这三个 target。这样能构建出我真正需要的东西时间上比全量构建省太多了。全量构建默认会编译大量的测试工具、示例代码和辅助二进制这些东西对日常使用完全没用。如果你需要反复编译、调试 LLVM 源码强烈建议上 ccache。LLVM 这种体量的项目C 模板展开极其消耗 CPUccache 能把同一份头文件反复编译的开销摊平很多。配置方法很简单cmake 时加上-DLLVM_CCACHE_BUILDON或者设置环境变量CCACHE_CMAKE_ARGS把 ccache 路径传给编译器。有了 ccache 之后只改了一个 Pass 源码再重新链接 clang耗时能从一个小时级别降到几分钟级别这个体感差异谁用谁知道。内存控制方面还有一个细节如果你是 16 GB 内存的机器除了限制链接并行数还可以把编译并行数适当调低。ninja 默认会按 CPU 核心数开满线程比如 16 核 CPU 同时跑 16 个编译任务每个任务 2 GB 内存的话桶直接爆掉。稳妥做法是用ninja -j8限制为 8 个编译线程。编译慢一点可以忍内存被打满导致系统卡死或者被 OOM killer 杀掉才是真正的浪费时间。3. 源码结构地图与核心模块速写构建跑通之后接下来要做的事不是急着写代码而是先熟悉一下这堆源码的布局。LLVM 的源码目录设计有很强的规律性掌握了这个规律后面查文档、看代码、改东西都会顺手很多。3.1 重要目录与子项目速查先来个速查表把 llvm-project 根目录下的主要子项目和它们的功能对应起来以后看到不会迷路目录功能定位llvm整个项目的核心包括 IR 定义、优化器、代码生成后端、Pass 框架clangC/C/Objective-C 编译器前端clang-tools-extra基于 Clang 的附加工具clang-tidy、clang-format、clangd 等lld高性能链接器支持 ELF、Mach-O、PE/COFFlldb调试器类似 gdb 但基于 LLVM 架构mlir面向机器学习与编译优化的多级 IR 框架flangFortran 语言前端compiler-rt运行时库包括 sanitizer、覆盖率、profile 等支持libc / libcabi / libunwindC 标准库与底层运行时polly基于多面体模型的依赖分析与自动向量化优化openmpOpenMP 并行编程运行时实现从这个表能看到llvm-project 已经远远超出“编译器”的范畴。它是一整套程序分析、优化和底层基础设施的集合。我自己用得比较频繁的除了核心的 llvm 和 clang还有 clang-tidy静态检查、compiler-rt 里的 AddressSanitizer内存错误检测和 libc换掉系统默认 C 库做性能对比。这些工具单独拎出来任何一个都是能写几千字教程的重量级存在。再看 llvm 子目录内部的结构。llvm/include/llvm 和 llvm/lib 是核心代码所在其中几个子目录要特别关注llvm/include/llvm/IR定义了中间表示的类型体系llvm/lib/Transforms是各类优化 Pass 的所在地llvm/lib/CodeGen是后端代码生成的核心llvm/lib/Target则按 CPU 架构分目录比如 X86、AArch64、RISCV 对应各自的指令集描述和代码生成逻辑。如果你想研究某一条指令是怎么从源码一步步变成汇编的按这个路径找就对了。3.2 LLVM IR这个生态的“通用语言”LLVM IR中间表示是整个生态的灵魂。不管前端是 C 还是 Rust不管后端是 x86 还是 ARM它们之间的共同语言都是 IR。理解 IR基本就等于理解了 LLVM 设计哲学的一半。LLVM IR 有文本格式.ll、字节码格式.bc和内存中的 C 数据结构三种形态。文本格式最容易入手看起来像一种低级的、类型标注明确的汇编语言。举个例子一个简单的加法函数写成 IR 长这样define i32 add(i32 %a, i32 %b) { entry: %sum add i32 %a, %b ret i32 %sum }每一行都是一个三地址形式的指令操作数要么是常量要么是虚拟寄存器以%开头。每个变量在使用前必须有define而且只能被赋值一次这就是 SSA静态单赋值形式。IR 的类型系统非常显式i32 表示 32 位整数每个指令都明确标注操作数的类型这跟汇编里“只知道是寄存器不知道里面装的是整数还是指针”是完全不同的思路。为什么要把 IR 设计成这样核心是为了方便优化。因为每个值只被赋值一次编译器可以很容易地追踪数据流判断某条计算会不会被用到、某个表达式是否可以被替换、某段代码是否可以挪到循环外面。经典优化里的死代码消除、常量传播、公共子表达式消除在 SSA 形式上都有非常优雅的实现方式。你可以用 Clang 把 C 代码变成 IR 来看这个过程会彻底改变你看编译器的视角clang -O2 -emit-llvm -S foo.c -o foo.ll加上-S输出文本 IR不加则输出字节码。编译出来的 IR 里能明显看到优化器做的事情常量折叠、函数内联、无关变量消除每一步都很直白。我强烈建议新手花半天时间拿几个小 C 程序生成 IR 对着看比读十篇介绍文章都管用。3.3 Clang 与 Pass 框架从源码到优化的旅程Clang 作为一个编译器前端从源码到 IR 的过程大致是词法分析把源码拆成 token 流语法分析根据 C/C 文法构建抽象语法树 AST语义检查确认类型和声明是否正确最后 CodeGen 把 AST 翻译成 LLVM IR。这个过程在clang/lib/CodeGen目录下有非常完整的实现不过对不打算深入前端开发的人来说知道每个阶段大致干什么就够了。真正值得深入研究的是 Pass 框架。LLVM 优化器跑的就是一串 Pass每个 Pass 对 IR 做一次遍历和变换。官方提供的 Pass 有几百个从简简单单的always-inline到复杂到团成球的loop-vectorize都有。Pass 有两大类——分析 Pass 和变换 Pass。分析 Pass 只收集信息比如统计函数指令数量、分析循环结构变换 Pass 则会对 IR 做修改比如死代码消除、指令合并。实际运行的优化流水线是一串精心排序的 Pass 组合-O2就是一份预设的 Pass 流水线配置。动手实验最快的方式是用opt工具加载 Pass 并查看效果。拿一段 IR跑一遍instcount分析 Pass能直观看到指令计数、基本块数量等统计信息opt -passesinstcount -analyze foo.ll这里用-passes是新的 Pass 管理接口老版本用的是-instcount这种写法版本差异比较大查看官方文档确认一下你用的版本语法即可。4. 实操示例手写一个简单的 Function Pass光看不练是学不会 LLVM 的。这一节我带你写一个最小的 Pass 插件功能非常简单遍历模块里的每个函数把函数名打印出来。虽然功能简单但整个插件开发、编译、加载到 opt 里的流程是完整的跑通之后你就有能力往里面加自己的优化逻辑了。4.1 准备 Pass 代码与编译脚本现代 LLVM 推荐使用 new Pass Manager如果你拉的是较新版本的 llvm-project官方示例基本都基于这个接口。新建一个目录比如叫 MyPass写一个MyPass.cpp#include llvm/IR/Function.h #include llvm/IR/Module.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Support/raw_ostream.h using namespace llvm; namespace { struct MyPass : public PassInfoMixinMyPass { PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { errs() Function: F.getName() \n; return PreservedAnalyses::all(); } }; } // namespace extern C ::llvm::PassPluginLibraryInfo LLVM_ATTRIBUTE_WEAK llvmGetPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, MyPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name my-pass) { FPM.addPass(MyPass()); return true; } return false; }); }}; }先别被这段代码的正常体积吓到。核心逻辑其实只有run函数里面的两行打印函数名然后返回PreservedAnalyses::all()表示这个 Pass 对 IR 没有做任何修改所以所有分析结果都可以保留。后面那大段extern C的东西是给 opt 做插件注册用的模板代码你以后写自己的 Pass 大概率也是照抄这段改个名字就行。编译时要用到 LLVM 的头文件路径。我假设你已经按前面第 2 节流程构建了 LLVM 并配置好了环境变量编译命令类似这样clang -shared -fPIC -fno-rtti \ -I/path/to/llvm-project/llvm/include \ -I/path/to/build/include \ MyPass.cpp -o MyPass.so注意-fno-rtti不能少LLVM 编译时默认关闭了 RTTI如果你的插件代码里开了 RTTI链接期会报一堆奇怪的找不到符号错误。4.2 运行与验证效果先准备一份测试 IR可以自己写也可以从 C 文件用 clang 生成。自己写一份最简单的define i32 foo(i32 %x) { %1 add i32 %x, 1 ret i32 %1 } define i32 bar(i32 %x) { %1 mul i32 %x, 2 ret i32 %1 }保存为 test.ll然后执行opt -load-pass-plugin./MyPass.so -passesmy-pass test.ll -disable-output输出会是Function: foo Function: bar看到这两个函数名说明你的 Pass 插件已经成功被 opt 加载并且跑起来了。从这一步开始你就可以研究各种 IR 指令的 API往run函数里加真正有用的逻辑比如找到所有add指令统计数量、检查是否有未使用的函数参数之类的。整个流程和 LLVM 官方开发者的工作方式是一致的。5. 常见问题与排查技巧实录把 llvm-project 从构建到使用的过程中遇到过的坑整理一下。如果你恰好也掉进其中一个希望下面的解决方案能直接帮你爬出来。5.1 构建失败的经典场景与对策OOM 进程被杀。这是新手区最常见的拦路虎。现象是构建进行到某个阶段终端突然报Killed信号或者系统开始疯狂卡顿。原因基本都出在链接阶段。解决办法按优先级排序第一把-DLLVM_PARALLEL_LINK_JOBS设成 1 或 2第二使用 lld 替换系统默认链接器内存占用能降一半以上第三加 swap 文件兜底哪怕只有 8 GB 的 swap也能救回不少内存紧张的场景。默认目标太多导致编译时间长得离谱。有朋友问我第一次构建 llvm-project 用了多久我说两小时他表示不可思议。一问才知道他直接用默认配置全量构建跑了整整一个周末。根因就是LLVM_TARGETS_TO_BUILD没设置所有 CPU 后端全编译进去了。对策就是前面说的手动指定自己需要的目标架构。对于绝大多数 x86 用户X86一个就够。GCC 版本过老导致编译失败。LLVM 对编译器版本的要求一直很激进因为它自己就是新语言特性的试验田。比如某些文件里用了 C17 的高级语法老版本 GCC 直接报语法错误。解决办法是尽量换用新版本 Clang 来构建或者升级 GCC 到官方要求的最低版本。用 Clang 构建 LLVM 本身就是官方主推路径。磁盘空间莫名被占满。构建目录、安装目录、ccache 缓存目录都会吃大量磁盘。你会经常在清理的时候发现构建目录比源码目录大了十倍不止。建议定期用du -sh *检查磁盘占用ccache 缓存也可以用ccache -C清空。5.2 开发调试中的实用技巧构建成功后开发调试阶段也有一些值得记录的技巧。增量构建不要全量 ninja。只构建你正在改动的 target比如正在写 Pass 就构建opt正在改 Clang 就构建clang。LLVM 的 target 依赖关系很复杂如果你只跑ninja opt构建系统会自动只编译 opt 依赖的库能省下非常多时间。使用 llvm-lit 跑测试。LLVM 自带的测试框架是 llvm-lit可以针对单个测试文件运行定位问题时比跑全量测试高效得多。比如测试某个 Pass可以在构建目录下执行python3 bin/llvm-lit -v ../llvm/test/Transforms/MyPass调试 Pass 用-debug-only参数。LLVM 内部的 debug 输出非常丰富很多 Pass 都支持-debug-only循环优化相关的名称这种细粒度控制。开发新 Pass 时合理使用调试输出能省下大量脑细胞。遇到奇怪崩溃先怀疑 Pass 注册。新写 Pass 加载到 opt 时崩溃大概率不是 Pass 逻辑本身的问题而是 Pass 注册信息少了某个字段或者llvmGetPassPluginInfo里的接口版本对不上。先确认 LLVM 版本与插件编译时头文件版本一致再检查插件注册代码这两处没问题再看逻辑。6. 学了 llvm-project 能做什么应用场景与进阶路线到这里llvm-project 的构建、结构、开发流程都已经走了一遍。最后聊聊它到底能用在哪些实际场景里以及作为学习者接下来该怎么继续深入。6.1 工业界的真实应用方向如果你以为 llvm-project 只是给编译器开发者研究的学术玩具那就大错特错了。现实是大量软件基础设施都跑在 LLVM 之上。编程语言领域Rust 的 rustc 后端用的就是 LLVMSwift 编译器也构建在 LLVM 生态之上。任何你想发明的语言只要写个前端翻译成 LLVM IR就能获得完整的优化器和大量后端支持省下从零实现代码生成的巨大成本。程序分析领域LLVM IR 天然适合做静态分析。因为有明确的类型、SSA 形式、丰富的指令信息分析工具可以精准追踪数据流。著名的静态分析工具 clang-tidy、clang analyzer 都基于 Clang/LLVM 框架。安全领域广泛使用的 AddressSanitizer、MemorySanitizer、UndefinedBehaviorSanitizer 也都是 compiler-rt 里的模块。机器学习编译器领域MLIR 正在成为 AI 编译器的主流基础设施。很多深度学习的编译器项目都基于 MLIR 构建因为它在传统编译器的中间表示之上增加了一整套适合表达张量计算、循环嵌套、数据布局变换的抽象层级。如果你对 AI Infra 感兴趣MLIR 是你绕不开的领域。系统和嵌入式方向LLD 因为链接速度快、内存占用低成为很多构建系统的默认链接器。LLVM 的交叉编译支持也做得很好一套工具链可以输出十几个不同目标平台的二进制这对嵌入式开发来说非常有价值。6.2 一条可行的学习路径如果你对 LLVM 产生了兴趣但不知道从哪下手我建议按这个顺序来走第一熟练使用 clang 和 opt理解 IR 文本格式能读懂一个函数的 IR 是干什么的第二写几个分析型 Pass跑instcount这类现成 Pass再用第 4 节的方式写一个自己的 Pass打印出函数名、指令数第三找一个简单的变换类 Pass 源码来读比如SimplifyCFG理解它如何改变 IR 结构第四尝试改一个优化选项比如调整函数内联的阈值观察对生成代码的影响第五选择你感兴趣的领域深入前端就研究 Clang后端就研究 CodeGen特殊场景就研究 sanitizer 或 MLIR。这个过程不必追求速度LLVM 生态里涉及的概念和代码量本身就很庞大但每走一步你都会对“程序是如何被处理的”有更深的理解。我个人比较推荐的资源除了 llvm-project 仓库里的官方文档和示例代码还有 LLVM 的官方在线文档里面关于 IR 语言参考和 Pass 开发的章节写得相当详细。另外阅读 LLVM 的提交记录和社区讨论帖能学到很多教科书里不会写的实战经验。还有一个很实用的建议拿到一个开源项目源码后不要从 main 分支的 HEAD 开始读而是挑一个固定的 release 版本比如 llvm-17 或 llvm-18 的 tag对照文档学习。release 版本的文档和示例代码都是经过验证的不会因为上游频繁变更产生太多理解上的偏差。我在实际使用中发现llvm-project 最磨人的阶段其实是第一次构建一旦构建跑通、第一个 Pass 成功加载后面就完全是另一个世界了。那种“我能看到编译器内部在干什么”的掌控感是单纯使用编译器的开发者很难体会到的。如果你已经打算动手建议从第 2 节的构建命令开始挑一个周末好好折腾一次。
返回列表