LLVM实战指南:从源码构建到Pass开发与IR调试 📅 发布时间:2026/9/19 5:58:50 👁 浏览次数: 如果你点开这个仓库只是想看看“LLVM 到底是怎么把 C 代码变成机器码的”那今天这篇文章应该挺适合你。llvm-project这个 GitHub 仓库是 LLVM 官方统一维护的 monorepo里面不只有 LLVM 核心库还有 Clang、LLD、libc、compiler-rt、MLIR 等一系列子项目。很多人第一次打开这个仓库时都会愣住结构庞大、历史提交极其密集完全不知道从哪里入手。而我就是那个曾在这上面花过不少时间摸索、踩过不少坑的人所以这篇内容会避开那些泛泛而谈的入门介绍直接从一个实际项目视角出发把我在使用llvm-project过程中的核心认知、构建经验、IR 调试技巧、以及源码阅读方法一次讲清楚。这篇文章适合这些读者刚接触编译器相关工作、准备基于 LLVM 给某个领域专用语言做后端、需要为团队维护自定义 Clang 工具链或者纯粹想把llvm-project当作一座大型 C 开源项目来精读的人。我不会只堆概念会尽量把我验证过的命令、参数、代码示例和一些反直觉的坑点都放进来。1. llvm-project 到底是什么一套编译器基础设施不是一个编译器1.1 编译器领域里的搭积木思维很多人对编译器的理解还停留在拿源码进去出二进制出来的黑盒阶段。但llvm-project给我们的不是某个单一编译器的完整实现而是一整套可以按需组合的编译器组件库——这就是它叫 infrastructure 而不是 compiler 的根本原因。LLVM 的经典三段式架构是这样分工的前端Frontend负责把源代码解析成中间表示。Clang 就是这个角色它支持 C、C、Objective-C还有实验性的 CUDA、HIP 等。优化器Optimizer / Middle-end对中间表示做各种分析、变换和优化和具体 CPU 架构无关这一层核心就是 LLVM 库本身。后端Backend把优化后的中间表示翻译成目标平台机器码这里面有 X86、ARM、RISC-V、AArch64、PowerPC、WebAssembly 等目标后端。这三者的关系可以用一个现实中的场景来类比。假如前端是一位把中文小说翻译成通用国际手语的人优化器是一位只懂手语、负责把故事改得更精炼的编辑后端则是把手语分别转成英文、日文、法文的三个翻译官。只要大家都使用同一套中间手语体系那么任何一个前端都可以配上任意后端。这就是 LLVM 被称为编译器的编译器的原因它把编译过程中最难复用、最容易绑死在某一种语言和后端上的部分全部解耦了。所以当你看llvm-project时不要把它当成一个像 GCC 那样的整体程序而是一个巨大的工具箱。真实的编译器可执行程序比如clang、clang只是把几个库拼装起来的薄薄一层。1.2 llvm-project 里的核心子项目盘点打开llvm-project仓库根目录你最先看到的几个目录可能会让你迷惑。我把它们按依赖关系和工作角色梳理了一份清单方便你建立地图认知目录作用我的使用场景llvm/核心库、优化器、后端、目标描述LLVM IR 的定义和 Pass 基础设施自研 Pass、写优化分析、读后端代码clang/C/C/ObjC 编译器前端还包含clang-tidy、clang-format等工具日常编译 C、做静态分析、写 Clang Pluginlld/官方链接器支持 ELF、Mach-O、COFF、Wasm 等替代系统自带链接器显著提升链接速度libcxx/、libcxxabi/、libunwind/C 标准库、ABI 兼容层、栈展开库构建自己的 C 工具链compiler-rt/运行时库和 sanitizerASan、UBSan 等内存检查、覆盖率收集mlir/多层中间表示框架面向编译器和 AI 加速器设计做自定义 DSL 到硬件后端的编译流程polly/基于多面体模型的循环优化高性能计算场景flang/、openmp/、parallel-libs/Fortran 前端、OpenMP 运行库等大型科学计算编译器构建单看llvm/内部又分为llvm/include公共头文件、llvm/lib核心实现、llvm/toolsopt、llc、lli、llvm-as 等命令行工具、llvm/test测试套件这些模块。真正决定 LLVM 对某目标平台支持程度的 TableGen 描述文件都在llvm/lib/Target下的对应目录里比如你要研究 RISC-V 后端就主要看llvm/lib/Target/RISCV。有一个很常见的误区是初学者以为学 LLVM 就是把clang源码背下来。其实不是如果你要基于 LLVM 做自定义编译流程你更多接触的是llvm/include/llvm/IR、llvm/include/llvm/Passes、llvm/include/llvm/CodeGen这些头文件和对应实现。前端 Clang 与核心 LLVM 之间靠一层稳定的 API 接驳理解这层边界比陷入某一份源码细节要重要得多。2. 仓库管理方式monorepo 结构与为什么这样设计2.1 所有子项目同步版本的意义llvm-project是一个典型的 monorepo单仓库多项目模式。CLang 的代码、LLVM 的代码、LLD 的代码以及它们对应的回归测试都在同一个 Git 仓库里以相同节奏发版。这样做的好处非常直接当你 checkout 一个指定 Release 分支时比如llvmorg-16.0.0你拿到的 Clang、LLVM、lld、compiler-rt 之间是经过统一测试的版本组合。对编译器这一类项目来说子项目版本错配是灾难性的——Clang 某个版本可能依赖 LLVM 某个刚刚变化的 API如果两者版本差太远代码通常直接编译不过。你可能想问那为什么不把每个子项目分开建仓库、用依赖管理解决问题是 LLVM 项目之间的依赖不是简单的 A 依赖 B而常常是互相引用和工具链紧耦合。比如 Clang 生成 IR 之后直接回调 LLVM 的 API而 LLVM 的测试又依赖 Clang 来生成 IR 输入。这种情况下并行开发分仓库维护的成本非常大monorepo 反而最省心。不过 monorepo 的代价是仓库体积大、克隆慢。我实际测试过完整克隆llvm-project含全部历史大约要占 5GB 以上的磁盘空间网络一般的时候可能要几小时。所以我本人更推荐浅克隆或者只取指定的 release taggit clone --depth 1 --branch llvmorg-16.0.0 https://github.com/llvm/llvm-project.git这样克隆下来的文件体积小很多而且对于大多数研究、构建、二次开发场景历史提交不是刚需。如果你确实需要看某个文件的演进历史后面再git fetch --unshallow补齐也不迟。2.2 目录怎么读才高效先看顶层再钻底层面对这个仓库我建议的阅读顺序是先看顶层的README.md、CONTRIBUTING.md、llvm/CMakeLists.txt、clang/CMakeLists.txt把项目的构建入口、版本信息、子项目开关搞清楚再往深层钻。llvm-project根目录里还有几个值得注意的文件llvm/CMakeLists.txt是 LLVM 主项目的 CMake 入口clang/CMakeLists.txt是 Clang 的入口cmake/Modules目录里则放着大量 CMake 工具函数。如果你要增加一个新的子项目或者修改 LLVM 的构建选项都需要跟这里打交道。你需要特别关注的是各个子项目里存放测试的目录。LLVM 长期以来形成了每个功能的正式实现旁必然跟着对应 test的开发习惯。比如你在llvm/lib/Transforms/InstCombine里看到了优化逻辑那么相应的测试文件会出现在llvm/test/Transforms/InstCombine下。这些测试文件通常以.ll、.c、.mir为后续里面还带着RUN:命令行前缀说明这条测试怎么执行。想快速理解某个优化 Pass 的行为先看它的测试用例比直接看几千行 C 源码高效得多。这也是 LLVM 社区非常独特的经验传递方式。3. 从零构建 llvm-project参数选型与避坑记录3.1 构建前必须想清楚的三件事llvm-project的构建是个相对吃资源的过程尤其是一次性编译全部子项目。我强烈建议在执行cmake前先回答以下三个问题否则你可能会等一晚上然后发现跑出来的二进制不是自己想要的。第一你只需要 Clang 前端还是需要整个 LLVM 优化器和后端如果你想编译 C 代码后运行在本地机器上并且需要调试信息和优化那clang;lld这两个项目基本够了。第二你面向什么架构默认情况下 LLVM 会为它支持的所有目标架构都生成后端描述这会显著拖慢构建时间。大部分人的实际只需要一个或少数几个目标架构所以你会看到几乎所有 LLVM 构建教程都推荐设置-DLLVM_TARGETS_TO_BUILDX86或-DLLVM_TARGETS_TO_BUILDX86;AArch64。第三你要哪种构建类型Debug模式保留了全部调试符号构建速度慢、生成的二进制体积极大但方便你用 gdb/lldb 追踪源码Release模式开启优化二进制小而且执行快但调试体验会受限还有一种RelWithDebInfo也就是 Release 加调试信息对很多场景来说是最佳折中。基于这些考虑我目前最常用的个人构建命令是cmake -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_ENABLE_ASSERTIONSON \ -DCMAKE_INSTALL_PREFIX$HOME/llvm-release \ ../llvm-project/llvm注意这里的-DLLVM_ENABLE_ASSERTIONSON。很多 LLVM 内部的逻辑检查只有在断言开启时才会触发如果你要写自定义 Pass、做调试或二次开发我建议务必开启该选项否则很多错误会以极其诡异的方式表现出来而不是稳定报错。当然如果你只是要一个生产环境的编译器关掉断言可以稍微提高性能也能减少偶发的运行时开销。3.2 使用 Ninja 与 ccache 加速构建以及内存不足的应对我默认用 Ninja 而不是 Makefiles因为它支持并行度控制更精细、增量构建更快。构建 LLVM 这种超大型项目增量编译是每天都在发生的事选 Ninja 能省下大量等待时间。另一个强烈建议是加ccache。LLVM 工程中大量头文件相互包含任何一个小改动都可能触发几百个编译单元的重新编译ccache 可以把相同的编译缓存下来。启动方式是在 CMake 配置时加一行-DLLVM_CCACHE_BUILDON如果你安装了 ccache 但 CMake 没有自动找到它也可以手动指定-DCMAKE_C_COMPILER_LAUNCHERccache -DCMAKE_CXX_COMPILER_LAUNCHERccache在我本地的 8 核机器上首次完整编译 X86 后端的 clang lld 大概需要 20~40 分钟全量Release模式大约占内存 2~4GB但如果选择Debug模式同样构建可能飙升到 8GB 以上。如果内存不够可以降低并行度ninja -j 4或者干脆精简目标架构把-DLLVM_TARGETS_TO_BUILD设为当前机器对应的架构即可。另一个容易忽略的点是构建目录必须和源码目录分离直接在llvm-project/llvm里配置会导致源码被构建产物污染后期切换构建选项时会很痛苦。我通常在外层新建一个build-release目录来放构建缓存。3.3 我踩过的三个构建坑第一个坑是缺少 zlib。LLVM 的某些压缩相关功能比如压缩调试信息依赖 zlib如果你没有安装开发头文件cmake 配置阶段可能仍然能顺利通过但编译到一半会报头文件缺失。这提醒我在开始大型构建前提前装好依赖包zlib、libxml2、ncurses 等省得中途浪费几十分钟。第二个坑是-DLLVM_ENABLE_PROJECTS的写法。某些版本里项目名用分号分隔时不能多加空格且compiler-rt写成compiler_rt会直接配置失败。遇到这种问题不要慌去看 CMake 的报错信息它通常会列出所有可用的项目名。第三个坑和链接相关。构建 lld 时某些老版本 GCC 会因为默认链接器太老导致链接失败。解决方法一般是在 cmake 时显式加-DCMAKE_CXX_FLAGS-fuse-ldlld或者用ninja -t clean清理后用更高版本的 GCC 重新编译。这类底层工具链问题很难靠改自己的代码绕开认识它很重要。4. LLVM IR整个项目的灵魂值得花时间彻底吃透4.1 从 clang 到 .ll 文件一次简单的 IR 生成演示不论你最终目标是修改优化器、做新语言前端还是编写自定义后端LLVM IR 都是你无法绕开的核心。IRIntermediate Representation是 LLVM 中间层的通用语言优化器处理的输入输出都是它。理解 IR是理解整个llvm-project的一条钥匙。先看一个简单例子。写一个 C 文件// add.c int add(int a, int b) { return a b; }用 clang 生成 LLVM IR 文本clang -S -emit-llvm add.c -o add.ll得到的add.ll内容大致如下; ModuleID add.c source_filename add.c target datalayout e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128 target triple x86_64-unknown-linux-gnu define i32 add(i32 noundef %a, i32 noundef %b) { entry: %add add nsw i32 %a, %b ret i32 %add }我们逐行看。define i32 add(i32 %a, i32 %b)表示定义一个返回i32、参数为两个i32的函数。函数体内有基本块basic block这里只有一个名为entry的基本块。%add是一个虚拟寄存器virtual registeradd nsw i32 %a, %b执行 32 位整数加法并把结果赋给它nsw表示 no signed wrap即假设发生有符号溢出时行为未定义这样优化器可以更激进。最后ret指令返回结果。这种文本形式对人类友好但 LLVM 真正处理的是二进制 bitcode.bc文件。llvm-as可以把.ll转成.bcllvm-dis则反向转回文本这样你可以随时检查优化前后 IR 的样子。4.2 SSA 形式、use-def 链与基本块三个绕不开的概念LLVM IR 是建立在静态单赋值Static Single AssignmentSSA基础上的。所谓 SSA简单说就是每个变量只能被赋值一次。比如上面例子里的%add在整个函数生命周期里只会出现一次赋值。这看起来很别扭但它给优化器带来了巨大的便利变量与定义它的指令一一对应数据依赖关系一目了然。优化器里常说的 use-def 链就是从使用某个值的地方 回溯到 定义这个值的指令 的路径。因为有 SSA 形式use-def 链几乎是天然的、不需要额外分析的。而数据流分析、死代码消除、指令合并等关键优化都建立在快速追踪这些链条的能力之上。基本块basic block则是一段只能从顶部进入、从底部离开的指令序列其中没有分支指令。基本块之间通过控制流图CFG连接。日常调试优化器时你看到一个函数被拆分成几十个基本块有时候感觉像在看迷宫但正是这种规范化的分割让各种路径分析成为可能。如果你第一次接触这些概念我建议用opt工具实际观察一下优化效果opt -S -passesinstcombine add.ll -o add.opt.ll对比优化前后的 IR 差异你会立刻明白 SSA 和基本块的价值所在——优化器就是在这些结构上做模式匹配和重写的。4.3 调试 LLVM Pass 的实战小技巧写自定义 Pass 时你不可能只在最终编译产物里看效果。效率最高的调试模式是先用 clang 生成.ll文件然后用opt只加载并执行你的 Pass最后用llc生成目标汇编。整个过程不涉及完整编译迭代速度很快。一个常用技巧是在优化流程中打印 IR。例如给opt加上opt -S -passesmy-pass -print-after-all add.ll -o add.opt.ll这样每个 Pass 执行后都会把 IR 打印出来你能直观看到你的 Pass 在哪个节点改变了什么。还有一个隐藏利器如果想深入追踪某一条指令的生成过程可以在 LLVM_DEBUG 代码里使用-debug-onlymy-pass开启指定调试信息。比如我们自己写的 Pass 里用了LLVM_DEBUG(dbgs() matched add: *Inst \n);那么运行时开启opt -S -passesmy-pass -debug-onlymy-pass add.ll即可精准输出。我在实际开发中强烈建议先给 Pass 提供极小的 C 函数作为测试用例比如只有两三行、边界条件明确的函数。大函数一旦出问题定位成本是几何级增长的。LLVM 官方测试套件里的用例也大多是这种风格你模仿它们写用例配合FileCheck工具做模式匹配断言能极大提高调试效率。5. 源码阅读与二次开发给打算深入的人一条可行路径5.1 从哪里开始读Pass 框架是最佳入口如果你和我一样并不是编译器理论研究者而是工程实践者我建议不要从头到尾按目录顺序读源码而是从一个具体目标倒推源代码。其中写一个自定义优化 Pass 是最典型的练手项目。LLVM Pass 的作用是在 IR 层次做分析和变换。一个最简单的 FunctionPass 长这样基于 LLVM 16 的新 Pass Manager 接口#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 MyFirstPass : public PassInfoMixinMyFirstPass { public: PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { bool Changed false; for (auto BB : F) { for (auto I : BB) { if (auto *BinOp dyn_castBinaryOperator(I)) { if (BinOp-getOpcode() Instruction::Add) { outs() Find add: ; BinOp-print(outs()); outs() \n; Changed true; } } } } return Changed ? PreservedAnalyses::none() : PreservedAnalyses::all(); } }; } // end anonymous namespace extern C ::llvm::PassPluginLibraryInfo LLVM_ATTRIBUTE_WEAK llvmGetPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, MyFirstPass, 0.1, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name my-first-pass) { FPM.addPass(MyFirstPass()); return true; } return false; }); }}; }这段代码做了三件事遍历函数中的所有基本块和指令用dyn_castBinaryOperator判断指令是否为二元运算再检查 opcode 是否为Add如果发现add指令就打印出来。PreservedAnalyses::none()告诉 LLVM 这个 Pass 修改了 IR后续分析结果需要重新计算如果没有改动就返回PreservedAnalyses::all()这个区别在未来做大规模分析时会直接影响性能。写完之后用编译器把这份源码编成动态库clang -shared -fPIC -fno-rtti my_pass.cpp \ $(llvm-config --cxxflags --ldflags --libs) \ -o libMyPass.so再用opt加载它opt -load-pass-plugin./libMyPass.so -passesmy-first-pass add.ll -S看到终端打印出add指令的那一刻你就完成了第一次与 LLVM 内部的直接对话。之后可以尝试把你的 Pass 注册到PassBuilder对应的优化管道里让它真正参与-O2优化流程。5.2 读代码的顺序与资料取舍读 LLVM 源码时很多人在入口处就会迷路。我的具体建议是先读这些llvm/include/llvm/IR/Instructions.h了解指令类层次。llvm/include/llvm/IR/PassManager.h理解新 Pass Manager 的运行机制。llvm/lib/Transforms/InstCombine/InstCombineAddSub.cpp看一个成熟优化 Pass 是怎么写的包括它如何用match匹配 IR 模式。llvm/lib/Target/X86/X86ISelLowering.cpp看后端怎么把 IR 上的节点选择到目标指令。同时也不要忽视官方文档里被很多人低估的几份资料llvm/docs/Passes.rst、llvm/docs/WritingAnLLVMPass.rst、以及llvm/docs/CodeGenerator.rst。它们不是给纯理论研究者看的而是直接服务于代码实现很多篇幅都在讲接口设计和注意事项比零散逛论坛高效得多。还有一个小经验用git log --oneline -- path/to/file查看某个文件的历史往往能看到维护者重构这个功能的动机和提交说明比直接读最新代码更能理解为什么长成这样。遇到不理解的地方去 GitHub 上搜对应代码的测试文件和 review 讨论通常比百度/Google 零散博客更有权威性。5.3 二次开发如何避免破坏现有功能很多人做二次开发时最大的恐惧是我改了 LLVM怎么知道没把别的功能弄坏答案就是跑测试。LLVM 的测试体系非常完善但全量跑一遍耗时很久。你需要学会精准跑测试# 进入构建目录 ninja check-llvm ninja check-clang如果只想跑某一个 Pass 的测试llvm-lit -sv ../llvm-project/llvm/test/Transforms/InstCombine/add.llllvm-lit是 LLVM 统一的测试驱动工具它会解析测试文件里的RUN:命令并在临时目录中执行。平时修改了某个 Pass 后我习惯先跑该 Pass 目录下的所有相关 test再跑整个check-llvm基本能覆盖大部分回归风险。另一个容易忽略的点是 ABI 稳定性LLVM 的 C API 几乎不承诺长期稳定甚至会在大版本之间直接改名或删除函数。所以如果你开发了基于 LLVM API 的插件或工具建议明确锁定要支持的 LLVM 版本范围避免盲目跟随主分支升级。我个人的做法是把插件代码中与 LLVM 版本有关的部分做成条件编译或统一封装虽然麻烦一点但能在升级 LLVM 时省下大把适配时间。最后再分享一个小技巧给 LLVM 提补丁或自己维护分支时尽量遵循它已有的 code style见llvm/docs/CodingStandards.rst最重要的几点是类型名用 CamelCase、函数名用 camelBack、变量名首字母小写、类内成员加LLVM_前缀的注释风格。对于大型项目格式一致性本身就决定了合并效率和可维护性。你可以用clang-format配合仓库里的.clang-format自动格式化代码减少不必要的 review 往返。