资讯详情

llvm-project实战:架构拆解、源码构建与自定义Pass开发

📅 2026/9/19 2:17:00 | 华诺云谱 👁 阅读
llvm-project实战:架构拆解、源码构建与自定义Pass开发
从零开始玩转 llvm-project架构拆解、源码构建与第一个自定义 Pass如果你写过 C/C肯定听过 Clang如果你研究过编译器或语言设计大概率绕不开 LLVM。但“llvm-project”这个仓库名字对于很多刚接触的人来说其实有点劝退仓库庞大——clone 下来几个 GB构建一次等半小时目录结构复杂得让人不知道从哪看起。这篇文章就是我基于 llvm-project 实际使用经验的完整拆解会从整体架构、为什么选它、源码构建的关键参数、到写一个自定义优化 Pass 并跑通全流程把每一步背后的原理和坑都讲清楚。不管你是想基于 LLVM 做一门新语言的编译器后端还是想在现有 C/C 工具链上做定制优化、插桩、静态分析或者只是好奇“编译器到底是怎么工作的”这篇内容都能帮你把 llvm-project 从“听过名字”变成“能上手操作”。我会尽量站在实操角度讲少说空话多贴可以直接抄走的命令、代码和配置。1. 核心设计与架构思路llvm-project 为什么这么重要1.1 先搞清楚 llvm-project 里到底有什么很多人会把“LLVM”和“Clang”混为一谈其实这俩是包含关系。llvm-project 是一个多仓库合并后的 monorepo里面最核心的几个子项目分别是LLVM 核心库提供中间表示IR、优化器、目标后端代码生成、汇编器、反汇编器等基础设施。ClangC/C/Objective-C 的前端把源码解析成 AST再降级为 LLVM IR。LLD一个高性能的原生链接器链接速度比系统自带 ld 快好几倍。libc / libcabiC 标准库的 LLVM 实现主要在 macOS、嵌入式等场景用得多。compiler-rt运行时库包含 ASan、UBSan、TSan 等各类 sanitizer 的实现。MLIR用于构建可复用、可扩展编译器基础设施的多层级 IR 框架近几年 AI 编译器特别喜欢用。flang、libunwind、polly、lldb等其他组件。本质上llvm-project 给你的是一套完整的“编译器工厂”。如果你只想编译 CClang 是成品但如果你想做一门新语言或者想往编译流程里插一段自定义逻辑LLVM 提供的是积木你自己拼。这也是它和 GCC 最大的区别GCC 是一个完整的编译器但它的内部结构没那么容易拆出来复用LLVM 从第一天起就是按库来设计的。1.2 三段式架构前端、中端、后端的解耦LLVM 最经典的架构就是三段式前端负责把源代码转成中间表示 IR中端优化器对 IR 做各种变换后端把 IR 生成目标机器码。React/Vue 开发者看到这个可能觉得眼熟——这不就是“编译器的 MVVM”吗。前端对应模板解析IR 对应虚拟 DOM后端对应渲染器。换一个后端就是换一套渲染目标前端和中端完全不用动。这种解耦带来的实际好处太明显了一门新语言只需要写前端 比如 Rust 早期直接用 LLVM 做后端省掉了几乎所有指令选择和寄存器分配的工作。一个新的 CPU 架构只需要写后端 前端和优化器直接复用。苹果从 x86 切到 ARM 时Clang/LLVM 的迁移成本远低于想象。优化器独立于语言和硬件 所有前端产出的 IR 都过同一套优化管线这意味着你做一次优化所有语言都能受益。理解这点是使用 llvm-project 的关键。你在写 Pass 时处理的不是 C 代码也不是汇编而是中间层级的 IR。IR 大概是这样的define i32 add(i32 %a, i32 %b) { %sum add i32 %a, %b ret i32 %sum }它把变量、类型、控制流都显式表达出来每条指令都是一个操作码加若干操作数。你后续写的所有自定义逻辑基本都是在跟这种 IR 打交道。1.3 为什么 llvm-project 能成为“编译器的事实标准”2010 年之后LLVM 几乎把编译器基础设施赛道赢麻了。背后有几个原因可以展开说说。首先是模块化设计带来的可嵌入性Android NDK 用它iOS 的 Xcode 用它Rust、Swift、Julia 的全链路编译用它甚至 PlayStation、Xbox、Switch 这类游戏主机的官方 SDK 底层也有它的影子。其次是宽松的开源协议LLVM 使用 Apache 2.0 with LLVM exception对商业闭源使用非常友好企业可以放心把它嵌进自己的产品里而不需要开源代码。第三是工具生态的完备性sanitizer 系列让内存错误排查变得极其方便clang-tidy 是静态检查利器lld 让链接速度起飞这些配套工具和编译器本身配合得天衣无缝。这些优势叠加起来就让 llvm-project 成了一个“你迟早要用到”的仓库。哪怕你不是编译器方向的人只要做 C/C 性能优化、做交叉编译、做安全审计都绕不开它。2. 从源码构建 llvm-project关键步骤与参数选型2.1 环境准备硬件、系统和前置依赖在使用 llvm-project 之前你最好先确认一下自己的机器能不能扛住编译。这个项目构建起来极度吃内存和 CPU。我自己的实际经验是4 核 8G 内存的机器全量编译三个小时起步i7 八核十六线程外加 32G 内存Ninja 并行拉满二十分钟到四十分钟可以搞定核心部分。操作系统上Linux 和 macOS 都比较顺利Windows 上也能编译但体验会打折扣。如果你在 Windows 上建议直接用 Visual Studio 的 CMake 生成器否则各种工具链的小问题会消耗大量时间。前置依赖要看你的系统。Ubuntu/Debian 上安装这几个包基本就够sudo apt install build-essential cmake ninja-build python3 gitmacOS 上装个 Xcode Command Line Tools 就行xcode-select --install另外建议确认一下 CMake 版本不低于 3.20LLVM 新版本对 CMake 版本有硬性要求。太老的 CMake 会在 configure 阶段直接报错。2.2 CMake 配置参数每个选项背后的逻辑克隆代码git clone https://github.com/llvm/llvm-project.git cd llvm-project默认分支是 main还在快速迭代中。如果你想求稳建议切到 release 分支比如llvmorg-18.1.0这种我平时用 17 和 18 比较多git checkout llvmorg-18.1.0然后创建一个构建目录在 llvm-project 外面建千万不要在源码目录里直接 build否则源码目录会被构建产物弄脏mkdir build cd build接下来是核心的 cmake 命令。我列一个自己常用的配置cmake -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86;AArch64 \ -DLLVM_ENABLE_ASSERTIONSON \ -DLLVM_OPTIMIZED_TABLEGENON \ -DCMAKE_INSTALL_PREFIX$HOME/llvm-install \ ../llvm-project/llvm逐项解释一下这些参数因为理解参数比复制命令更重要-G Ninja使用 Ninja 作为构建系统。相比默认的 Unix MakefilesNinja 并行度更高增量编译更快。强烈建议使用。-DCMAKE_BUILD_TYPERelease编译 LLVM 自身用 Release 模式。如果想调试 LLVM 或者你写的 Pass改成 Debug库体积和编译时间都会暴涨但对排查问题很友好。-DLLVM_ENABLE_PROJECTS决定要构建哪些子项目。我一般至少带上 clang 和 lld。注意不要贪多全选的后果是构建时间成倍上涨。-DLLVM_TARGETS_TO_BUILD指定生成哪些后端的机器码生成器。默认是“全部”但你大概率只需要 X86 和 AArch64其他目标会白白增加编译时间。-DLLVM_ENABLE_ASSERTIONSON开启断言。这对后续写 Pass 非常有用很多错误会在断言里直接暴露出来而不是在运行时静默崩溃。-DLLVM_OPTIMIZED_TABLEGENON用 Release 模式单独构建 TableGenLLVM 的代码生成器能显著加快整个构建过程强烈推荐开启。这些参数并不是我随手写的每一个都对应实际开发中的真实痛点。比如不限制LLVM_TARGETS_TO_BUILD的情况下你会花大量时间编译一堆用不到的后端不开LLVM_OPTIMIZED_TABLEGEN构建速度可能慢 30% 以上。2.3 构建与验证产物配置完成之后执行构建ninja如果你只想先快速验证可以只构建部分目标比如只要 clangninja clang clangd构建完成之后验证一下版本./bin/clang --version ./bin/llc --version如果能看到版本信息恭喜你llvm-project 核心工具已经可用了。注意此时所有产物都在 build/bin 目录下不需要执行 ninja install 也完全够开发用。我之前开发 pass 时从没装到系统目录直接在 build 目录里操作是最省心的。2.4 几个我踩过的构建期大坑关于构建有太多悲伤故事可以讲这里集中提炼几个最有价值的内存不足导致编译被 kill。Ninja 默认并行任务数等于 CPU 核心数内存不够时直接 OOM。解决办法是限制并发任务数比如ninja -j4或者降低优化等级把-DCMAKE_BUILD_TYPE改成RelWithDebInfo以省内存。磁盘空间爆炸。完整构建 llvm-project 大约需要 30G 以上空间确保预留足够。另外建议构建目录和源码目录放在同一块读写速度较快的磁盘上。编译器版本太低无法编译 LLVM 本体。LLVM 18 要求宿主编译器支持 C17且 GCC 版本高于 7.1Clang 版本高于 5.0。老系统自带的 GCC 4.8 是没戏的。不要乱开 BUILD_SHARED_LIBS。网上有些教程让你开BUILD_SHARED_LIBSON说可以减小二进制体积但对 LLVM 这种重度模板化的 C 项目开共享库反而会显著降低运行时性能而且动态链接配置极容易出问题。默认静态库就好。3. 实操开发一个自定义 LLVM Pass 并跑通全流程3.1 认识新旧两套 Pass ManagerPass 是 LLVM 优化器的最小功能单元你可以把它理解成“对 IR 的一次遍历和变换”。LLVM 这几年经历了从 Legacy Pass Manager 到 New Pass Manager 的迁移这个切换坑了无数人。简单说新 Pass Manager 从 LLVM 14 开始成为默认它解决了旧 PM 的不少痛点比如可以更精确地管理 Pass 间依赖可以做更细粒度的缓存复用也能支持 PIPass Instrumentation之类的扩展点。写新代码时直接使用 New PM 接口不要再碰旧的那套。虽然网上大量老教程都在讲 Legacy PM 的写法比如继承FunctionPass、实现runOnFunction、用宏INITIALIZE_PASS注册这些内容在 2024 年已经逐渐失去参考价值如果你按着它们写大概率会在编译阶段碰上各种 API 不匹配的报错。3.2 最小可运行的 Function Pass完整代码与解释我来写一个最简单但五脏俱全的示例遍历模块里的每个函数打印函数名、基本块数量、指令总数。这看起来简单但是一个极好的骨架后续你往run里加任意分析逻辑整个框架都不用改。先看项目结构my-pass/ ├── CMakeLists.txt └── MyPass.cppCMakeLists.txt 内容如下cmake_minimum_required(VERSION 3.20) project(MyPass) find_package(LLVM REQUIRED CONFIG) message(STATUS Found LLVM ${LLVM_PACKAGE_VERSION}) message(STATUS Using LLVM include dir: ${LLVM_INCLUDE_DIRS}) include_directories(${LLVM_INCLUDE_DIRS}) add_compile_definitions(${LLVM_DEFINITIONS}) add_library(MyPass MODULE MyPass.cpp) target_link_libraries(MyPass PRIVATE LLVMCore LLVMSupport)注意find_package(LLVM REQUIRED CONFIG)需要 CMake 能找到 LLVM 的配置。如果你是在前面的 build 目录里构建自己的 pass需要这样指定cmake -G Ninja -DLLVM_DIR$HOME/llvm-project/build/lib/cmake/llvm ..MyPass.cpp 内容如下针对 LLVM 17/18 的 New PM 接口#include llvm/IR/Function.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 { class HelloPass : public PassInfoMixinHelloPass { public: PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { unsigned instrCount 0; for (auto BB : F) { instrCount BB.size(); } errs() [MyPass] Function: F.getName() , basic blocks: F.size() , instructions: instrCount \n; return PreservedAnalyses::all(); } }; } // namespace // 注册插件入口 extern C ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, MyPass, 0.1, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name hello-pass) { FPM.addPass(HelloPass()); return true; } return false; }); }}; }这段代码有几个关键点值得展开。PassInfoMixinHelloPass是 New PM 里所有 Pass 的基类模板。run(Function F, FunctionAnalysisManager AM)是核心入口返回PreservedAnalyses。这里返回PreservedAnalyses::all()意思是我这个 Pass 不对 IR 做任何修改所以所有分析结果都保持有效。如果我们真的修改了 IR就不能这么写了需要返回PreservedAnalyses::none()或者精细声明到底哪些分析被破坏比如PreservedAnalyses::none()是最省事但是牺牲了优化效率的写法。llvmGetPassPluginInfo是插件模式的统一入口它让我们的 Pass 可以被opt这个工具动态加载。registerPipelineParsingCallback表示注册一个可以通过-passes命令行参数触发的 Pass 名称。这里注册的名字是hello-pass等下在命令行里就是-passeshello-pass。3.3 使用 opt 加载 Pass 并跑通验证先编译mkdir build cd build cmake -G Ninja -DLLVM_DIR~/llvm-project/build/lib/cmake/llvm .. ninja注意事项这里的两个 build 不是同一个目录。一个是 llvm-project 自己的 build一个是你的 pass 项目的 build。我见过有朋友搞混了在 llvm-project 源码目录里直接建了 pass 文件去编译一报错就懵了。严格区分“宿主 LLVM”和“插件工程”。生成一个测试用 C 文件int add(int a, int b) { int c a b; return c 10; } int main() { return add(1, 2); }先用 clang 生成 LLVM IR~/llvm-project/build/bin/clang -S -emit-llvm test.c -o test.ll然后运行我们的 Pass~/llvm-project/build/bin/opt -load-pass-plugin./MyPass.so -passeshello-pass test.ll如果一切正常你会在终端看到类似这样的输出[MyPass] Function: add, basic blocks: 1, instructions: 4 [MyPass] Function: main, basic blocks: 1, instructions: 3到这里一个完整的 “自定义编译优化插件” 就打通了。从 C 源码到 IR再经过我们的自定义分析全过程都在你的掌控之中。这就是 llvm-project 最迷人的地方。3.4 从 HelloPass 扩展做点有实际价值的事如果只是打印信息那确实没啥用。我再说一个更贴近实际需求的扩展思路统计每个函数里的函数调用次数也就是做一个简化版的调用关系统计工具。把run改成这样PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { unsigned callCount 0; for (auto BB : F) { for (auto I : BB) { if (auto *CI dyn_castCallInst(I)) { if (Function *Callee CI-getCalledFunction()) { errs() call - Callee-getName() \n; } else { errs() call - (indirect)\n; } callCount; } } } errs() [MyPass] Function: F.getName() , calls: callCount \n; return PreservedAnalyses::all(); }这里的关键是dyn_castCallInst(I)在 LLVM 里指令Instruction有很多子类dyn_cast就相当于“安全地向下转型”如果不是 CallInst 就返回空指针不会像 C 风格强转那样产生未定义行为。这在使用 LLVM 开发时极其常见几乎每个分析 Pass 都会用 dyn_cast 去判断指令类型。更进一步如果你想做插桩比如在每条 call 指令前插入一个计数的函数调用就会用到IRBuilder它类似一个“IR 版本的代码拼接器”。这一层玩熟了你就能做性能剖析、安全检测、代码插桩等很多有意思的事情。4. 常见问题与排查技巧实录4.1 用 llvm-project 开发时的高频报错这里总结硬核问题排查清单。都是我或者其他人在实际开发中经常遇到的可以直接对照排查。症状可能原因解决办法opt 加载插件失败提示invalid plugin插件编译时的 LLVM 版本和 opt 版本不一致确认你的LLVM_DIR指向的宿主 LLVM 和 opt 是同一套构建产物报错undefined symbol: llvmGetPassPluginInfo插件工程没启用以-fPIC编译在 CMake 里加set(CMAKE_POSITION_INDEPENDENT_CODE ON)或者用add_library(... MODULE ...)时默认会带clang 生成的 IR 在opt -passeshello-pass时什么都输出Pass 运行期间遇到空模块或优化器把未用函数删掉了编译 C 文件时加上-O0并确保函数没有被 dead code eliminationAssertion failed: (isaX(Val) castTy() argument of incompatible type!)用了cast而不是dyn_cast如果你不确定类型用dyn_cast确定类型再考虑cast编译自己的 Pass 时fatal error: llvm/Passes/PassPlugin.h file not foundLLVM_INCLUDE_DIRS未正确配置检查${LLVM_INCLUDE_DIRS}变量是否为空通常是因为find_package(LLVM REQUIRED CONFIG)没找到配置函数名出现一圈数字后缀比如add.1可能是优化过程已经发生函数名冲突或者 multiple definition查看 IR 文件确认是否确实只有一个 add 函数优化器修改后出现数字后缀通常正常4.2 构建太慢、内存爆炸的优化方案如果你只是写 Pass不需要每次都全量构建 LLVM。有两个技巧很实用编译 LLVM 时使用 ccache。这是编译器的缓存工具第一次全量编译之后后面清理重编的速度会大幅提升。配置方法是在 cmake 时加-DLLVM_CCACHE_BUILDON或者设置环境变量CCACHE_MAXSIZE50G。使用 clang-cl / lld 加速自身构建。LLVM 项目有一个官方脚本叫clang-cl用的构建配置在 Windows 上尤其有效。Linux 上用lld链接也能明显缩短链接耗时。我在自己的机器上实测开 ccache lld 之后LLVM 全量重编时间从四十多分钟降到了十几分钟。这两个优化非常值得。4.3 调试 Pass 的另类技巧直接用 IR 小文件验证写 Pass 时最怕的是“编译通过但结果不符合预期”。排查手段很重要。我的经验是尽量用最精简的 IR 做实验。不要上来就跑 Clang 编大型项目直接用 C 文件生成一小段 IR手工删掉无关函数甚至直接在 .ll 文件里手写几行 IR 来验证逻辑。调试新 PM Pass 时善用日志机制。在 LLVM 里你不用随便printf可以用LLVM_DEBUG(dbgs() ...)在跑 opt 时加-debug-onlyhello-pass才能看到。注意这里的关键字是你在源码里用DEBUG_TYPE宏定义的不是一个随机字符串。断点调试用 lldb 或 gdb 都行。因为 Pass 是动态库opt在加载后会执行你的代码所以在run函数里下断点是有效的。我自己习惯在opt上加断点直接在 run 函数开头打断点比打印日志效率更高。4.4 版本兼容性LLVM 每一代都在变llvm-project 有一个让很多初学的人崩溃的地方API 变动非常频繁。18 版本的 Pass 写法拿到 14 可能编译不过不同版本的llvm::unique_function签名、CallInst的方法名甚至头文件路径都可能不同。我的建议是标明你使用的 LLVM 版本如果网上教程没有写版本先按最新 release 分支来验证。写代码时尽量少用被标记为 deprecated 的接口。编译时留意 warning能避免很大一部分未来的迁移成本。如果版本差异太大导致编译错误优先查看这个版本的官方llvm/examples目录和 release notes。5. 项目目录与源码阅读指南从哪几个目录开始看如果你不只是想用 LLVM还想读源码、理解它的实现盲目浏览整个 llvm-project 会非常痛苦。我建议按下面顺序来看llvm/include/llvm/IR这里定义了你最常接触的类型比如 Instruction、BasicBlock、Function、Module、Type。读懂这些头文件你就知道 IR 长什么样、可以怎么遍历。llvm/include/llvm/Passes新 Pass Manager 的核心接口。读懂 PassBuilder 的注册方式你就能理解一个 Pass 是怎么被框架调度的。llvm/lib/Transforms/InstCombine这是一个极其经典的优化 Pass用实际代码示范了怎么遍历指令、怎么重写指令。它的代码量适中注释也比较清楚。llvm/lib/CodeGen如果你想深入后端这里是你最终要啃的地方。但建议先把前三个目录搞熟之后再看。还有一个比较省力的阅读技巧用 clangd 或 ccls 这类 Language Server 配置好索引跳转非常流畅。直接在源码里搜索你已经在 Pass 里用过的类名顺藤摸瓜就能把整个调用链串起来。6. 实战扩展把 llvm-project 应用到你的实际项目6.1 基于 Clang 的静态分析工具很多团队会引入 clang-tidy 来规范代码风格但如果你有更特殊的规范就需要写 clang-tidy check。这本质上也是写一个 Pass只不过入口和接口与优化 Pass 不同。你可以基于 libTooling 库写一个独立的可执行程序解析源码 AST按你团队自己的规则检查代码模式。举个例子我曾经给一个嵌入式团队写过一条规则禁止使用malloc必须用自定义的内存池分配函数。用 clang 的 AST 匹配器几十行代码就搞定了。比起靠代码评审人肉眼检查这种工具靠谱得多。6.2 交叉编译工具链和定制后端如果你的目标平台是 RISC-V、MIPS 或者某种自研芯片LLVM 提供了一个清晰的路径写一个 Target 后端。这确实是编译器领域最难的方向之一但 llvm-project 已经把指令选择SelectionDAG 或 GlobalISel、寄存器分配、指令调度、汇编输出这些都给你搭好了框架。你要做的主要是描述指令集和寄存器TableGen 文件会帮你生成大量重复代码。6.3 用 MLIR 做 AI 编译器的上层 IR如果你关注 AI 编译器MLIR 值得单独开一篇。它的一大优势是可以在高层级做算子融合、布局转换等优化再逐层 lower 到 LLVM IR。很多 AI 芯片公司就是基于 MLIR 自研上层编译器的。建议先玩熟 LLVM IR 的 Pass 机制再上手 MLIR 会顺很多因为它俩共享大量基础设施。7. 个人体会与最后的提醒回头再看 llvm-project 这个项目我最大的感触是它学习曲线确实陡峭但一旦跨过第一道坎获得的是对编译器的“掌控感”——不再只是黑盒调用 gcc/clang而是能亲手往编译流程里塞自己的逻辑。这种能力在很多领域都能直接产生价值。踩过这么多次坑之后我最后分享几个小习惯第一永远在单独的 build 目录构建不要污染源码第二每次动手前先确认 LLVM 版本版本不同很多代码写法完全不同第三实验性质的 Pass 一定要用最简单的 IR 测试等逻辑稳定了再上真实项目第四遇到 API 变动时直接去看你手头这个版本的头文件比任何过时教程都可靠。说实话llvm-project 里还有太多内容我也没有完全吃透比如向量化、Interprocedural 优化、sanitizer 的内部实现这些都是值得花大量时间去啃的方向。希望你读完这篇文章之后能直接 clone 一份源码、配好环境、写出自己的第一个 Pass。编译器领域的一个新世界就这么打开了。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。