LLVM源码解析:从Clang到Pass,深入llvm-project核心架构
第一次认真把llvm-project整个仓库翻完是在某个晚上准备编译器工具链调研的时候。说实话打开首页那一刻我有点懵接近两千万行代码的 monorepo里面装的并不只是一个编译器而是 Clang、LLD、libc、compiler-rt、MLIR、LLDB 一大堆项目的合集。后来我才想明白LLVM 真正做的事不是再提供一个“能编译 C 的编译器”而是想把“编译器”拆成积木让每种语言、每个硬件平台都能按需拼装出一套自己的工具链。这篇内容适合三类人看一是想读 LLVM 源码但不知道从哪下手的后端/编译器新人二是日常工作经常被 Clang、ASan、LLD 折腾想搞清楚这些工具背后共用一套什么逻辑的 C/C 开发者三是做编程语言、静态分析、GPU 编译器等人想评估“基于 LLVM 二次开发”是否靠谱的工程师。我会把 llvm-project 的架构拆开讲再带你把代码拉下来、编译一遍、亲手写一个 Pass最后聊一聊我实际踩过的坑。1. 项目结构拆解llvm-project 到底都装了些啥1.1 从编译器到工具链LLVM 想解决的其实是同一件事传统的 GCC 式编译器是一个“大而全”的单体软件词法分析、语法分析、优化、代码生成全部耦合在一起。你换一种新语言前端得重写后端也得跟着伤筋动骨你换一个新 CPU 架构整个中间表示和优化体系都要跟着适配。LLVM 的思路完全不同——它把编译过程拆成三段前端把源代码变成统一的中间表示IR中端只对 IR 做各种优化后端再把 IR 映射到具体硬件指令。这个听起来很朴素的思路真正落地之后的威力在于全世界只要维护一套优化器和一套后端就能让 C、C、Rust、Swift、Julia 这些语言共享同一套编译基础设施。llvm-project这个仓库把这条链路上的所有组件都收在了同一个版本控制目录里所以才叫“Project”而不是“Compiler”。我第一次看目录的时候发现每个子目录本质上都可以独立发展但它们通过llvm/include/llvm/IR里的 IR 定义紧密咬合在一起。对于一个想入门的开发者来说最重要的一步是先建立“一个程序从源码到可执行文件中间经过了多少个独立阶段”的完整画面。我在看代码之前习惯先手动跑一遍clang -S -emit-llvm和llc亲眼看到同一份.c文件先变成人类可读的.ll文本再变成目标汇编最后才链接成可执行文件。这个过程比直接扎进源码里高效得多。1.2 IR 是核心前后端分离是骨架LLVM IR 是整套体系的心脏。它既有高级语言的类型信息整数、浮点、指针、结构体又像一台无限寄存器的 RISC 机器一样工作。IR 里有三种表示形式内存里的Value/Instruction对象、二进制 bitcode.bc、以及文本形式的.ll文件。三种形式之间可以无损互转这也是为什么opt、llc、lli这些工具能像流水线一样衔接起来。编译器里所有的优化 Pass本质都是“对 IR 做等价变换”。比如mem2reg可以把allocaload/store提升为 SSA 形式的虚拟寄存器instcombine可以把零碎的模式匹配成一个更简单的指令simplifycfg负责整理基本块之间的跳转结构。这些 Pass 的输入输出始终是 IR所以它们不关心你最初写的是 C 还是 Rust也不关心最终跑在 x86 还是 ARM 上。这种设计最实际的好处是你写语言前端时只需要负责把语义映射到 IR然后用opt -O2就能白嫖到几十年积累的优化能力。同样你写后端时只需要把 IR 逐步降低到目标指令选择、寄存器分配前端的语法糖跟你没关系。我在公司里见过好几个项目做的事情不过几百行但就是因为把“前端”和“后端”解耦开整个团队的协作边界清清楚楚。1.3 千万行代码里怎么快速定位模块刚打开仓库的时候两个高频问题一定是“代码在哪”和“我该看什么”先说目录llvm/是核心库和优化器clang/是 C/C/Objective-C 前端lld/是链接器libc和libcabi是 C 标准库实现compiler-rt提供运行时库和 sanitizermlir/是多级 IR 框架lldb/是调试器flang/是 Fortran 前端。整个仓库的代码量级大约是llvm目录核心代码接近三百万行clang目录再贡献上百万行算上 MLIR、LLDB、libc 等全部子项目总量在 1500 万到 2000 万行之间。第一次看到这个数字其实不用慌绝大多数开发场景下你只会碰其中两三个目录而且真正核心的概念——比如Value、Instruction、BasicBlock、Function、Module这几个 IR 类——数量非常有限抓住它们之后庞杂的代码树会迅速变得有序。还有一个容易被忽略的重要组件叫TableGenllvm/utils/TableGen。它本质上是一个“生成代码的代码生成器”用.td文件描述指令、寄存器、Pass 注册信息等声明性内容然后自动生成大量重复的 C 代码。刚开始读后端代码时你可能觉得奇怪的宏和自动生成文件很多这里的关键是理解“.td文件 TableGen 生成了什么”而不是试图把生成的每个函数都背下来。2. 核心子项目逐个说清楚2.1 Clang前端的主战场Clang 不只是“又一个 C 编译器”它在工程上把“前端”这件事做到了极致。你用clang -emit-llvm -S就能拿到 LLVM IR用clang -fsyntax-only只做语法检查用clang -cc1进入编译器内部接口。Clang 基于库的架构非常舒服libclang和 Clangd 让 IDE 可以拿到精确到符号级别的代码补全和跳转clang-tidy和clang-format又是独立的静态检查和风格工具。对我这种经常写 C 的人来说Clang 最直观的体验是编译错误信息比传统编译器友好得多。它会把出错的代码片段、预期类型和实际类型打在屏幕上一行行列清楚有时候甚至直接给出修改建议。这些能力的背后是 Clang 的 AST 设计得极其规整大多数分析工具只是对 AST 做一次遍历而已。如果你打算写一门新语言Clang 最值得参考的地方是它的前端如何一步步把“源码文本”变成“语法树”再如何把“语法树”变成“带类型的 AST”最后通过CodeGen生成 LLVM IR。新手很容易直接跳到 IR 生成但其实语义分析阶段才是一整个前端最复杂的部分Clang 里大几千行的Sema相关代码值得反复阅读。2.2 LLVM 库中后端优化与代码生成llvm/目录里的核心库承担了优化器和后端的大部分工作。你可以把opt这个工具理解成 IR 的“瑞士军刀”它按指定顺序跑一组 Pass。默认的优化级别比如-O2本质是一套编排好的 Pass 管线而不是一个单一算法。这套管线里有的 Pass 负责内联有的负责循环变换有的负责向量化有的负责消除死代码组合起来才形成我们日常见到的优化效果。后端部分同样在llvm/目录里核心流程是先把 IR 降低到目标特定的选择 DAGSelectionDAG或者较新的GlobalISel做指令选择、指令调度、寄存器分配然后输出到 MC 层最终生成目标文件。现代 LLVM 后端对新增 CPU 的支持方式越来越模块化你只需要描述指令集架构信息通过 TableGen再补上指令选择模式就可以生成一个能工作的后端骨架。平时调试优化问题我最常用的快捷方式是用llc -O2 demo.ll -o demo.s直接看 IR 在某个目标平台上的汇编输出再用opt -print-after-all观察每个 Pass 前后 IR 的变化。这两招比瞎改源码猜结果有效得多。2.3 LLD、libc、compiler-rt被低估的三兄弟很多人以为 LLVM 厉害只是编译器厉害其实工具链里另外几个子项目同样重要。lld是一个用现代 C 重写的链接器链接速度比传统 GNU ld 快几倍到几十倍因为它针对多线程做了充分的并行化并且内部对符号表、字符串表等结构做了高度优化。你现在在很多 Linux 发行版里都能看到ld.lld作为默认链接器的影子用它构建大型 C 项目时能明显感觉到“链接不再等半天”。libc是 LLVM 家的 C 标准库实现libcabi处理 ABI 层的细节比如异常和 RTTI。在 Linux 上你可以用-stdliblibc切换过去在 macOS 上它已经是默认库。compiler-rt则提供了底层运行时包括我们熟悉的 AddressSanitizer、UndefinedBehaviorSanitizer、ThreadSanitizer 等一系列内存/并发检测工具以及 libFuzzer 模糊测试框架。很多 C/C 项目在 CI 里跑的-fsanitizeaddress就是这套代码在工作。这三者加起来的意思很明确LLVM 不只给你“编译器”还给你完整工具链里面的每一环。从编译到链接从标准库到运行时检测所有组件都在同一个版本体系下演进互相之间接口保持一致——这让使用者省去了很多“这个编译器配那个链接器到底兼不兼容”的麻烦。2.4 MLIR 与其他探索性组件mlir是 LLVM 圈子里这几年非常活跃的子项目。它解决的问题是编译优化不应该只发生在“高级语言 IR”和“机器指令”这两层之间很多 DSL、机器学习框架、HPC 算子库需要自定义多层 IR每一层都有自己的抽象级别。MLIR 允许你定义自己的“方言”dialect在不同方言之间做 progressive lowering最终降到 LLVM IR。如果你要做深度学习编译器或专用硬件编译器MLIR 大概率比直接写 LLVM Pass 更合适。lldb调试器也是 llvm-project 的一部分它用 LLVM 的库来解析调试信息、生成表达式求值代码天然对 Clang 支持得最好。flang则是为 Fortran 语言实现的前端这也说明 LLVM 不只服务于 C 家族语言。整个仓库里还有很多小而酷的实验性子项目比如polly做循环嵌套优化openmp提供运行时支持。当你把“编译器”这个词升级成“编译基础设施”才能真正理解llvm-project里为什么什么都有。3. 从零动手实践构建、改代码、写 Pass3.1 拉代码、配置构建别在环境上翻车在你开始堆功能之前先把环境准备稳妥。建议不要用系统包管理器装的旧版 LLVM因为版本之间 API 变化太大。最可靠的方式是直接拉git clone https://github.com/llvm/llvm-project.git然后切到你想研究的 release 分支。我常用的构建流程是这样的以 Linux 环境为例cmake -S llvm-project/llvm -B build \ -G Ninja \ -DCMAKE_BUILD_TYPERelWithDebInfo \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86 \ -DCMAKE_C_COMPILERclang \ -DCMAKE_CXX_COMPILERclang ninja -C build解释几个关键选项-G NinjaNinja 比 make 快很多能充分利用多核强烈建议。-DCMAKE_BUILD_TYPERelWithDebInfo编译器优化开启同时保留调试信息非常适合日常开发。纯 Debug 构建跑起来很慢纯 Release 又没法断点调试。-DLLVM_ENABLE_PROJECTS决定要多构建哪些子项目。如果只想读核心 Clang那就写clang;lld如果需要 sanitizer再加compiler-rt。-DLLVM_TARGETS_TO_BUILDX86如果不限制 target默认会把所有支持的 CPU 后端全编译进去构建时间会膨胀数倍。在一台 8 核 16GB 内存的机器上构建 Clang LLD 大概需要 40 分钟到 1 小时磁盘占用 20GB 上下。构建过程最怕的是内存不够导致链接阶段 OOM我一般会再通过-DLLVM_PARALLEL_LINK_JOBS2限制并行链接的任务数牺牲一点速度换取稳定。注意llvm-project 的 CMake 配置项极其多遇到构建失败先不要慌。第一看 CMakeCache.txt 里记录的配置是不是你要的第二看缺失的依赖是否安装第三确认磁盘剩余空间——这三个原因占了绝大多数构建失败场景。3.2 用 opt 与 llc “解剖”一份 IR调试必备代码构建好之后从一份简单的 C 文件开始观察 IR 的变化。我先建一个demo.cint add(int a, int b) { return a b; } int main() { int x add(1, 2); return x - 1; }生成 IRclang -S -emit-llvm demo.c -o demo.ll打开demo.ll你会看到define i32 add(i32 %a, i32 %b)这样的函数定义%a和%b是虚拟寄存器add i32 %a, %b是把两个 32 位整数相加。再看main函数Clang 在未优化时可能生成alloca指令在栈上分配变量然后通过load/store存取——这个形态已经很接近汇编只是还没有指定寄存器。接下来用优化器跑几个 Passopt -S -passesmem2reg,instcombine,simplifycfg demo.ll -o demo.opt.ll对比demo.ll和demo.opt.ll你会发现alloca被mem2reg消掉了add(1, 2)这种常量参数被常量传播后直接算出了结果。用opt -print-after-all可以看到每个 Pass 执行前的 IR 快照这比任何文档都直观。最后用llc生成目标汇编llc -O2 demo.opt.ll -o demo.s打开demo.s你会清楚地看到add函数被编译成几条简单的指令main里的常量计算被直接折叠成立即数。这一条链路走下来编译器的“前端—中端—后端”在脑子里就有具体画面了。3.3 手写一个函数内 Pass体验 IR 层面的修改单纯看工具链不算真正的 LLVM 开发。我建议你动手写一个最简单的 Function Pass这也是社区里最经典的入门练习。下面这个 Pass 的功能是遍历每个函数如果函数名叫main就把函数里所有指令的操作码打印出来。新建一个文件夹比如HelloPass/创建hello_pass.cpp#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 { struct HelloPass : public PassInfoMixinHelloPass { PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { if (F.getName() main) { for (BasicBlock BB : F) { for (Instruction I : BB) { errs() opcode: I.getOpcodeName() \n; } } } return PreservedAnalyses::all(); } }; } // namespace extern C ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, HelloPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name hello) { FPM.addPass(HelloPass()); return true; } return false; }); }}; }这段代码用到的是 LLVM 的 new Pass Manager 接口。PassInfoMixin是模板基类run方法会对每个函数调用一次getOpcodeName()返回指令操作码字符串。注意return PreservedAnalyses::all()表示我们没有修改任何 IR后续 Pass 不需要重新计算分析结果。编译成动态库需要写一个 CMakeLists.txtcmake_minimum_required(VERSION 3.20) project(HelloPass) find_package(LLVM REQUIRED CONFIG) message(STATUS Found LLVM ${LLVM_PACKAGE_VERSION}) include_directories(${LLVM_INCLUDE_DIRS}) add_library(HelloPass MODULE hello_pass.cpp) target_link_libraries(HelloPass PRIVATE LLVM)然后用系统里已安装的 LLVM 开发包llvm-dev配置构建cmake -S . -B build cmake --build build最后运行opt -load-pass-plugin./build/libHelloPass.so -passeshello demo.ll如果一切正常它会打印出demo.ll里main函数的所有指令操作码。这一步做完你就已经加入了“改写编译器中端”的行列——剩下的只是往run函数里填充你真正想要做的变换逻辑。4. 实战中常踩的坑以及对应的排查方法4.1 源码体积、git 管理与版本选择llvm-project 的 git 仓库非常大完整历史加上所有子项目体积轻松超过 3GB。第一次clone完整仓库需要不少时间如果只是想读代码或者基于某个 release 构建我建议加--depth 1做浅克隆只拉最近一次提交。等真正需要历史记录时再unshallow。另一个常见问题是把main分支拉到一半发现 API 又变了。LLVM 的 main 分支永远处于“面向未来”的状态今天写好的 Pass过几个月可能就编不过。强烈建议固定一个 release 版本比如llvmorg-17.0.1。我每年至少要碰到一次“明明照着文档写的为什么编译失败”最后发现文档对应的是旧版本接口这就是没锁定版本的后果。还有一点llvm-project 是 monorepo但是子项目之间并不是每次提交都同步。有时候clang目录刚改完一个接口lld还没跟上构建会意外失败。这种问题在 release 分支上基本不会出现但在 main 分支上很常见。所以“日常开发用 main稳定复现用 release”是我的默认选择。4.2 构建速度、内存限制与增量编译构建 LLVM 对硬件要求不低。我用一台 8 核 16GB 内存的笔记本构建全量 Clang 时最容易崩的就是链接阶段——ld需要为几个巨大的可执行文件分配好几 GB 内存。解决办法一个是前面提到的-DLLVM_PARALLEL_LINK_JOBS2限制链接并发另一个是开启lld作为宿主链接器因为它本身的内存占用和速度表现都优于 GNU ld。如果做的是长期开发强烈建议配置ccache。LLVM 的 C 代码量非常大每次touch一个头文件都可能触发上千个编译单元重编。有ccache之后改代码再编译的体验会质变第一次构建需要一小时之后大部分时候只需要几十秒。我在 CMake 配置时加上-DCMAKE_C_COMPILER_LAUNCHERccache -DCMAKE_CXX_COMPILER_LAUNCHERccache从此再也没被“重新编译整个世界”劝退过。内存受限时还可以砍掉不用的组件比如不构建clang-tools-extra、不构建测试用例、只用-DLLVM_TARGETS_TO_BUILDX86。这些操作能明显降低构建时间和磁盘占用。4.3 LLVM 版本之间 API 变动带来的一堆编译错误LLVM 是出了名的“接口不稳定”。从 legacy Pass Manager 迁移到 new Pass Manager从getValueName到getName从createXxxPass到直接类实例化几乎每个大版本都会有一批旧接口被移除。如果你从网上抄了一段 Pass 代码发现编译错误铺天盖地多半不是你的逻辑问题而是接口版本不匹配。我的排查步骤基本固定看当前版本对应的llvm/include/llvm/IR和llvm/lib/Passes目录确认目标 Pass 的正确写法。在llvm-project仓库内搜索旧接口名看它是否还有替代函数。对照docs/ReleaseNotes.rst里面会列出主要的 breaking changes。如果只是调试小问题用git log -S旧接口名找到代码在哪次提交里改掉了。写 Pass 时还有一个小技巧尽量用-print-changed而不是自己手动添加大量errs()输出。新版opt提供了很完善的 Pass 变更追踪机制能在每个 Pass 执行后输出差异对定位“哪个 Pass 把我的 IR 改成了这个样子”特别有用。5. 这些代码离我们到底有多近应用场景与后续方向5.1 编程语言与工具链的“宿主平台”很多你天天用到的语言实现底层就是 llvm-project。Rust 编译器 rustc 的后端通过 LLVM 生成机器码Swift 编译器同样构建在 LLVM 之上Julia 用 LLVM 做 JIT 编译Emscripten 基于 Clang 把 C/C 编译成 WebAssembly。从语言设计者的角度说选择 LLVM 等于站在巨人的肩膀上——你不用从零实现寄存器分配和指令选择。做工具链研发的同学可能会更关注 Clang 的静态分析能力。clang-tidy可以定制检查规则clang-query能交互式地查询 ASTlibclang提供稳定的 C 接口给 IDE 嵌入。这些能力背后都是 llvm-project 里那套规整的语法树和语义分析库。我自己做过一个基于 Clang 的代码规范检查工具只用了libclang的几百行代码就做到了对几十万行代码仓库的秒级扫描。5.2 静态分析、GPU 编译器与 MLIR 的未来空间LLVM 已经不只在 CPU 编译领域发光。很多 GPU 厂商的驱动编译器、Shader 编译器、深度学习编译栈都用到了 LLVM 或 MLIR。compiler-rt里的 sanitizer 系列支撑了无数 C/C 项目的内存安全测试libFuzzer则和 Clang 的覆盖率插桩配合成为许多安全团队做模糊测试的首选。MLIR更是一个值得长期关注的方向。它把“编译器可以有很多层 IR”变成了一门显式的工程实践。今天很多 AI 编译器比如一些训练/推理框架的算子编译器选择 MLIR 作为基础框架就是因为它可以在不丢失上层语义的情况下逐步降低到 LLVM IR 甚至专用硬件指令。如果你正在做 DSL 编译器或加速器工具链哪怕只是了解 MLIR 的 dialect 机制都会对系统设计很有帮助。5.3 学习路线的个人建议结合我自己读源码的经验我会建议按这个顺序推进第一步是“用起来”。把 Clang、opt、llc、lld 这些工具当成日常调试武器亲手跑一遍clang -emit-llvm和opt -passes。第二步是“读懂 IR”。花时间看 LLVM IR 文档理解 SSA、基本块、phi节点、undef和poison这些概念。第三步是“写一个 Pass”。从打印指令开始再到修改指令、插入指令最后尝试写一个简单的函数内优化。第四步才去翻后端代码比如看SelectionDAG如何做指令选择理解GlobalISel的引入动机。这四步走完你再看llvm-project里的代码就不会再有“大海捞针”的乏力感了。你会很清楚一类功能应该去哪个目录找哪种问题应该查哪部分设计文档。我个人在实际操作中最深的体会是LLVM 的源码目录虽然庞大但一旦理解了“IR 为中心Pass 环绕”这个主心骨几乎每个子项目都能用同一套方法论去拆解。所以别被它的代码规模吓住先跑起来再读进去最后你会发现自己已经站在了一套“编译器即积木”的体系里写起任何编译相关的工具都顺手很多。最后再分享一个小技巧遇到不确定的 LLVM 行为先写一个最小 C 文件生成 IR再逐步加 Pass用opt -print-after-all对比输出——这个办法能帮你省掉至少一半的查代码时间。