llvm-project实战指南:从架构原理到自定义Pass开发

发布时间:2026/9/20 7:21:38
llvm-project实战指南:从架构原理到自定义Pass开发
LLVM这个名字这些年出现频率实在太高了——从 Objective-C 到 Rust从 iOS 到 Android从 x86 到 RISC-V背后站着的几乎都是同一个项目家族llvm-project。你在 Xcode 里点下 Build 按钮编译器、链接器、标准库、代码生成这些动作绝大多数时候都是 Clang、LLD、libc 这些 LLVM 家族成员在干活。不夸张地讲llvm-project 已经从一门编译原理课程的论文产物长成了今天整个软件世界的编译器基础设施。这篇文章不是官方文档翻译也不是源码导读我想从一个实际用过、踩过坑、也靠它吃饭的人的角度把 llvm-project 拆开给你看它到底解决了什么问题仓库里那几十个子项目分别负责什么怎么从零把它构建出来以及如何自己动手写一个编译器插件Pass。如果你是想把构建链摸清楚的客户端工程师、想入门编译技术的在校学生或者正在研究语言前端/工具链的开发者这篇文章应该能帮你少走不少弯路。项目地址https://github.com/llvm/llvm-project。1. 先看懂 llvm-project 是什么1.1 从学术项目到行业基础设施LLVM 最早是 Chris Lattner 2000 年在伊利诺伊大学开始的一个研究项目全称是 Low Level Virtual Machine也就是“底层虚拟机”。这个名称其实挺误导人的——它一开始确实想做虚拟机做 JIT 编译后来整个项目的核心变成了“一套模块化的编译基础设施”跟“虚拟机”已经没有必然关系了。以后你见到有人把 LLVM 和 JVM 相提并论的时候心里要清楚这是完全不同维度的东西。但“模块化”这三个字才是真正的历史转折点。之前 GCC 独霸天下的年代编译器的各个阶段耦合很深前端解析源代码、优化器中间代码优化、后端生成机器码像是一条拧紧的麻绳。想给 GCC 加一个新语言支持你得理解整套中间表示、后端接口、甚至编译目标描述那套复杂机制。LLVM 从一开始就把这三层彻底拆开了每一层都定义得非常干净源代码先被前端变成 LLVM IR中间表示优化器只处理 IR后端把 IR 翻译成目标机器指令。任何一步都可以单独替换这就是后来万物基于 LLVM 的原因。今天的 llvm-project 已经不仅仅是编译器了它是一整套工具链生态C/C 编译器 Clang、链接器 LLD、调试器 LLDB、C 标准库 libc、二进制分析工具、OpenMP 运行时、GPU 编译器、MLIR 机器学习编译框架……同一个仓库里几乎全都有。1.2 三层架构与 IR 的意义理解 LLVM 的一个关键钥匙是理解它的三层架构设计和中间的 IR。拿编译一段 C 代码来走一遍流程int add(int a, int b) { return a b; }首先 Clang 把这段 C 代码解析成 AST抽象语法树再降级成 LLVM IR。IR 是一种介于高级语言和汇编之间的“伪汇编”拥有无限数量的虚拟寄存器。上面这段代码对应的 IR 大概是define i32 add(i32 %a, i32 %b) { %add add i32 %a, %b ret i32 %add }注意这里%add不是固定寄存器它是虚拟的可以无限命名。优化器跑一遍之后IR 可能会变成更简洁的形式也可能直接常量折叠。最后后端把 IR 翻译成目标架构的汇编代码比如 x86-64 上可能就变成addl %esi, %edi movl %edi, %eax retq整个过程里IR 是最关键的一个抽象边界前端处理“语言相关”的事情后端处理“机器相关”的事情优化器只需要面对统一的 IR不用关心源码是 C、C、Rust 还是 Swift。这也是为什么 Rust 可以选 LLVM 作为默认后端Swift 可以直接复用 Clang 的模块化能力因为大家都站在同一条 IR 分界线上。1.3 LLVM 和 GCC 到底有什么区别这个问题经常被问到简单说几句我的理解。GCC 也做了前端/中端/后端划分但它的中间表示GIMPLE/RTL更偏向具体实现新旧接口迭代时兼容负担很重。想给 GCC 加一门前端语言你需要深入 GCC 的整套内部约定想给 GCC 加一个新后端更是要啃大量机器描述和 RTL 模板。相比之下LLVM 的优势在于三点架构边界干净IR 有规范性文档Pass 接口设计清晰新语言前端只需把源码翻译成 IR 就算完成了一大半。生态工具齐全自带的 opt、llc、llvm-dis、llvm-nm、llvm-objdump 这些工具让开发者可以单步查看 IR 变化和汇编输出。模块化程度高可以像搭积木一样只取其中某个子项目来用比如只编译 LLD只编译 Clang甚至只编译 libc 去替代系统 C 库。当然 GCC 在老的架构、嵌入式支持、兼容性方面有它自己的优势但你要是打算做编译器相关开发或者学习编译原理从 LLVM 入手比从 GCC 源码入手要友好太多。2. 仓库里都装了些什么llvm-project 这个仓库的目录结构第一次看会很头大顶级目录有几十个。我捡最常用的讲每个子项目是什么、什么时候用得上。2.1 LLVM 核心库与 Pass 优化框架根目录里的llvm/目录就是 LLVM 的核心包括 IR 定义、Pass 优化框架、代码生成CodeGen、目标描述Target Description、MCMachine Code层和 TableGen 工具。你可能听过或见过这些名词opt运行优化 Pass 的工具可以加载自定义 Pass 插件是玩 Pass 开发的主要入口。llc把 LLVM IR 编译成目标汇编或目标文件后端调试常用。llvm-dis/llvm-asIR 和字节码之间的互相转换用来可视化查看 IR。llvm-nm / llvm-objdump / llvm-readobj查看目标文件、符号表、段信息的利器。这一层是所有语言共享的优化与代码生成基础设施。你给 Python、Julia、Rust 写后端实际上都是在这里扩展的。2.2 Clang、LLD、LLDB 与运行时库clang/是 C/C/Objective-C 的编译器前端也是 llvm-project 里最“重”的子项目之一。它的启动速度和错误提示比老一代 GCC 好很多很多开源项目已经切换到了 Clang。Clang 不只是编译器它还提供了 libclang适合做代码分析、clangd给 IDE 提供智能补全和 clang-tidy静态检查这些是现代开发工具链的重要拼图。lld/是链接器。毫不夸张地说我自己用 LLD 链接大型 C 项目时速度比 GNU ld 快了好几倍在增量构建的场景下体验极佳。它支持 ELF、Mach-O、COFF 和 WebAssembly也就是说 Linux、macOS、Windows、浏览器环境都能用它。lldb/是调试器类似 GDB但现在很多 IDE 默认集成的就是 LLDB。libcxx/和libcxxabi/是 C 标准库实现。如果你的项目需要一个开箱即用、符合现代 C 标准、又容易交叉编译的标准库libc 是很靠谱的选择。很多移动端场景为了摆脱 glibc 或系统库的限制都会换上这套实现。compiler-rt/是运行时库里面有我们常说的 Sanitizer 家族AddressSanitizer检测内存越界/释放后使用、MemorySanitizer检测未初始化内存读取、ThreadSanitizer检测数据竞争。CI 里抓内存类 bug靠的就是这东西。2.3 影响范围远不止 C/C再往大了说llvm-project 已经成了各种新语言和专用编译器的公共底座Rust默认后端就是 LLVMrustc 先把 Rust 转成 LLVM IR再用 LLVM 优化和生成机器码。Swift的整个工具链都是基于 LLVM 技术栈重写的。WebAssembly工具链 Emscripten 底层就走 LLD 的 wasm 支持。AI 编译器领域大火的 MLIR 也在 llvm-project 仓库里。苹果、Google、ARM、Qualcomm 等公司都在 LLVM 上投入了大量研发。所以说理解 llvm-project 的架构本质上是在理解今天编译器世界的地基长什么样。3. 从源码构建 llvm-project从源码构建可能是多数人第一次接触 llvm-project 的实际操作。这个仓库体量极大全量构建可以轻松吃满几十 GB 磁盘花掉一两个小时所以路线要选对。3.1 前置条件与获取源码我建议的版本选择不要直接 clone master 分支选一个 release 分支比如llvmorg-17.0.6。开发分支的接口变化快同时网上能搜到的资料大部分基于某个稳定版本。git clone --depth 1 --branch llvmorg-17.0.6 https://github.com/llvm/llvm-project.git cd llvm-project--depth 1是只拉最新一次的提交体积小很多。如果要改源码、做二次开发后面再git fetch --unshallow补全历史记录。系统依赖上Linux 环境需要有 cmake建议 3.20 以上、ninja-build、gcc 或 clang、zlib 和 libffi 的开发包。macOS 上需要 Xcode Command Line Tools。Windows 可以用 Visual Studio 生成器或者 Ninja LLVM 工具链。3.2 CMake 配置的关键参数LLVM 的构建系统非常灵活但参数多也意味着容易配错。我先给一个最实用的配置模板cmake -G Ninja -S llvm -B build \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld;mlir \ -DLLVM_ENABLE_RUNTIMESlibcxx;libcxxabi;compiler-rt \ -DLLVM_TARGETS_TO_BUILDX86;ARM;AArch64 \ -DLLVM_ENABLE_ASSERTIONSON逐一解释这些参数的含义-S llvm指定源码目录为仓库根下的llvm目录因为 LLVM 核心的 CMakeLists 就在那里。-B build构建目录生成的中间文件全部放到build。-DCMAKE_BUILD_TYPERelease编译类型。RelWithDebInfo 也行能够在调试时看到符号纯 Debug 编译体积巨大、速度慢构建过程极其痛苦建议没有特殊需求不要选。-DLLVM_ENABLE_PROJECTS指定要额外构建的“工程型”项目比如 clang、lld、lldb、mlir、flang。注意这是构建时需要用到的远程项目若是作为工具链的组成部分就在这里配置。-DLLVM_ENABLE_RUNTIMES指定要构建的“运行时型”项目像 libc、compiler-rt、libunwind。这类项目要先编译好编译器再用新编译器去编运行时所以和 PROJECTS 分开处理。-DLLVM_TARGETS_TO_BUILD目标架构。默认会构建所有后端耗时和磁盘占用直接翻倍。如果你只需要 x86 开发就只写X86构建速度显著提升。-DLLVM_ENABLE_ASSERTIONSON打开断言调试 Pass 或工具链时非常有价值多花一点时间也值得。关于LLVM_ENABLE_PROJECTS和LLVM_ENABLE_RUNTIMES的边界我最初也搞混过。后来经验是凡是最终要被工具链当成“编译器/工具/链接器”使用的放 PROJECTS凡是最终要装到 sysroot 里给目标程序链接使用的运行库放 RUNTIMES。这样想就清晰得多。3.3 并行编译与资源控制配置完成后直接构建ninja -C build注意默认情况下 ninja 会使用机器的全部核心并行编译。十六核机器编译可能直接抽干 30GB 内存如果内存不够建议限制并行度ninja -C build -j8说过多少次都不嫌多的一条坑LLVM 编译非常吃内存。一次 Release 构建保守估计磁盘只剩 20GB 就别折腾了Debug 构建直接准备 50GB 以上。我用一台 8 核 16GB 内存的笔记本编 Release 版 clanglld耗时大约 40 分钟到一小时内存占用始终在 12GB 上下刚好能用。你要是觉得速度还不够可以开启 ccache 做编译缓存-DLLVM_CCACHE_BUILDON同一台机器反复改动源码、重新构建的话ccache 能省下大量重复编译尤其是只改一个头文件的情况。构建完成后工具链会生成在build/bin下比如build/bin/clang、build/bin/opt、build/bin/llc。把build/bin加进 PATH后续使用就方便了。3.4 验证构建是否成功一个最简单的冒烟测试build/bin/clang --version然后写个 hello world 编一下echo int main() { return 0; } t.c build/bin/clang t.c -o t ./t如果这个流程走通了说明你的 LLVM 工具链基本可用。再看一眼链接器build/bin/ld.lld --versionLLD 如果也出来了说明lld子项目构建成功。到这里你已经拥有了一套自己编译的完整 LLVM 工具链。4. 动手写第一个自定义 Pass从源码构建完工具链之后很多人想问然后呢接下来最有意思、也最能帮助你真正理解 LLVM 架构的事情就是写一个自定义优化 Pass。它本质上就是你自己定义的编译器插件可以在 LLVM 优化流程某个阶段对 IR 做一些变换比如统计代码信息、做常量替换、内联某个函数等等。这一节我带你从头写一个极简的 Pass遍历一个模块里所有函数打印函数名。4.1 Pass 插件的基本概念LLVM 的优化流程分模块Module、函数Function、循环Loop、基本块BasicBlock等层级。你可以把优化 Pass 理解成“在某个 IR 层级上跑一遍的程序”。LLVM 社区现在主推新 Pass Manager插件基于 PassPlugin不再像旧版那样需要把源码编进 opt 里而是用opt -load-pass-pluginxxx.so动态加载。4.2 编写一个函数级 Pass创建一个目录里面放CMakeLists.txt和HelloPass.cpp。HelloPass.cpp的核心逻辑#include llvm/IR/Function.h #include llvm/IR/Module.h #include llvm/IR/LegacyPassManager.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Support/raw_ostream.h using namespace llvm; namespace { struct HelloPass : public PassInfoMixinHelloPass { PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { errs() visit function: F.getName() \n; return PreservedAnalyses::all(); } }; } // end anonymous namespace // 插件入口注册 Pass extern C ::llvm::PassPluginLibraryInfo LLVM_ATTRIBUTE_WEAK llvmGetPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, HelloPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineStartEPCallback( [](ModulePassManager MPM, OptimizationLevel Level) { MPM.addPass(createModuleToFunctionPassAdaptor(HelloPass{})); return true; }); }}; }代码里面的PassInfoMixinHelloPass是新 Pass Manager 的基类模版run(Function F,...)是每个函数都会调用的入口。createModuleToFunctionPassAdaptor负责把函数级 Pass 适配到模块级流水线上跑。返回值PreservedAnalyses::all()告诉优化器我这个 Pass 什么都没改不需要失效任何分析结果。再写一个CMakeLists.txtcmake_minimum_required(VERSION 3.20) project(HelloPass) find_package(LLVM REQUIRED CONFIG) message(STATUS Found LLVM ${LLVM_PACKAGE_VERSION}) message(STATUS Using LLVMConfig.cmake in: ${LLVM_DIR}) list(APPEND CMAKE_MODULE_PATH ${LLVM_DIR}) include(AddLLVM) add_llvm_pass_plugin(HelloPass HelloPass.cpp)关键一步是要让 CMake 找到 LLVM 的配置。你需要指定LLVM_DIR指向 LLVM 构建树里的lib/cmake/llvmcmake -G Ninja -S . -B build \ -DLLVM_DIR$(pwd)/../llvm-project/build/lib/cmake/llvm ninja -C build构建完成会生成build/lib/HelloPass.soLinux 下这就是要加载的插件。4.3 用 opt 加载插件并验证我们需要一份测试 IR。最简单的方式是用 clang 生成cat test.c EOF int add(int a, int b) { return a b; } int mul(int a, int b) { return a * b; } int main() { return add(1, 2) mul(3, 4); } EOF build/bin/clang -S -emit-llvm test.c -o test.ll然后用 opt 加载插件跑一遍build/bin/opt -load-pass-pluginbuild/lib/HelloPass.so \ -passesfunction(hello) -disable-output test.ll如果一切正常你会看到输出visit function: add visit function: mul visit function: main第一次在 Linux 上跑的时候我卡住的地方是passes名称。注意插件的 Pass 名是hello由add_llvm_pass_plugin自动根据目标名去掉 Pass 后缀生成不是HelloPass这一点很容易搞混。另外新版 opt 对参数格式比较严格function(hello)表示在函数级流水线上跑 hello Pass。4.4 为什么值得花时间做这件事写第一个 Pass 的体验很像学会一门新语言后第一次写出 “Hello World”但意义完全不同。它意味着你已经能够进入编译器的内部直接观察到 IR并且可以自定义修改优化流程。后面你要是想研究内联、向量化、Profile Guided Optimization、甚至是编译插桩起点都是这里。从这个小例子起步你可以试着在run里遍历函数的所有基本块、统计指令数、检查某种代码模式然后再去读 LLVM 官方文档中的 Pass 开发指南会顺畅得多。5. 常见问题与排查实录我把自己和身边同事实际踩过的一些坑整理在这里按症状、原因、解决办法来写。5.1 构建阶段的问题现象可能原因解决方案cmake 报LLVM_CONFIG_EXECUTABLE not set找系统里的旧 LLVM 而不是自己的构建树确保-DLLVM_DIR指向正确的build/lib/cmake/llvm编译中g: internal compiler error: Killed内存或交换空间不足调低-j并行度关闭部分浏览器/IDE 减少内存占用磁盘不够LLVM 构建产物巨大Release 留 20GB 以上Debug 留 50GB 以上只构建需要的 targetcannot find -ltinfo或cannot find -lz缺少系统库Ubuntu/Debian 安装libtinfo-dev zlib1g-devFedora 装ncurses-devel zlib-devel用了LLVM_ENABLE_PROJECTSclang;libcxxlibc 没构建libc 是 Runtime不是 Project把 libcxx 放到LLVM_ENABLE_RUNTIMESWindows 下构建报超长路径Windows 路径限制开启 Long Path 支持或缩短源目录/构建目录路径5.2 使用工具链时的问题现象可能原因解决办法用 clang 链接时提示找不到libstdc没有指定标准库/系统 GCC 版本过新加-stdliblibstdc或者换成-stdliblibc并链接对应库链接报错很大一片找不到符号LLD 默认按需拉取库某些情况下库顺序敏感用 LLD 时注意库依赖顺序必要时使用--start-group/--end-groupclangd 显示头文件找不到没有配置 compile_commands.json构建时设置-DCMAKE_EXPORT_COMPILE_COMMANDSON并把compile_commands.json软链到项目根目录ASan 报错后程序疯狂崩溃sanitizer 运行时与其他调试手段冲突确保启用了-fsanitizeaddress -fno-omit-frame-pointer并链接对应的 sanitizer 运行时编译速度很慢没有用 ccache 或者并行度太低开启-DLLVM_CCACHE_BUILDON加上-j控制5.3 调试和 Pass 开发时的问题开发 Pass 的时候最常遇到几类问题我再单独说一下一是 IR 和 Pass 版本对不上。LLVM 不同大版本之间 Pass API 变动很大你看着网上教程写的接口换到 17 版本可能就不编译了这时不要硬套去llvm/include/llvm/Passes/PassBuilder.h里看当前版本的注册函数签名。二是opt跑完后 IR 没变化。有可能是因为你 Pass 返回了PreservedAnalyses::all()而实际上你修改了 IR这个返回值告诉优化器“一切都没变”后续依赖旧分析数据的 Pass 就会出问题。正确的做法是如果你改了函数内容返回PreservedAnalyses::none()或者在修改完 IR 后调用相应分析对象的invalidate。新手阶段为了安全可以直接返回none()。三是加载插件时unknown pass name。检查llvmGetPassPluginInfo里注册的 pipeline callback 是否在正确位置加了 Pass以及名称是否是 opt 期望的。调试可以在 opt 前加-debug-pass-manager会打印出每个 Pass 的实际运行计划非常直观。四是想输出更多调试信息学会了errs()但发布插件时却忘了删。实际上没多大影响运行时会打一堆日志不够干净而已自己注意就是了。5.4 对新手来说最推荐的调试姿势我自己摸索出来比较好用的三板斧先用clang -S -emit-llvm生成 IR再用opt -passes... -print-after-all观察每个 Pass 结束后 IR 变成了什么样。这个参数会把整个流水线前后的 IR 差异打出来能帮你在秒级看到自己 Pass 的作用。在 Pass 里多用assert()和errs()输出结合-debug-pass-manager看流水线到底跑了哪些 Pass。LLVM 高度模块化出现问题往往不是“代码跑崩了”而是“某个阶段没按预期执行”这种环境下打印日志是最有效率的排障手段。用-S输出修改后的 IR 文件直接 diff 前后两个文件。如果你改了 IR这个 diff 能非常清楚地展示你 Pass 的实际效果。6. 回归到真实项目中的一点心得把这套东西学到手最有价值的感受是什么呢对我来说是终于不再把编译器当一个神秘黑盒了。以前遇到 C 项目链接报错只能反复试各种编译选项写完自定义 Pass 之后我可以自己写个工具去 dump 符号表、查看 IR 优化轨迹、甚至在构建流水线里插入一步自定义检查让不该出现的代码模式在编译阶段直接报错。这种感觉就像是从一个只会开车的司机变成了会修发动机的工程师。如果这篇文章能帮到你我建议你实际动手走一遍编译流程先跑通再去看源码最后再改一两个小功能试试。llvm-project 虽然大但它的设计思路非常清晰只要你跨过最开始的门槛后面就会发现原来编译器也可以像积木一样任你拼装。