
做编译器开发这几年llvm-project 算是跟我打交道最多的一个开源仓库了。不论你是做编程语言设计、性能优化、静态分析还是想把手上的芯片或者指令集跑起来最终大概率都会绕到它面前。很多刚接触编译技术的朋友面对这个仓库会觉得无从下手几百万行代码、横跨几十个子项目、光是 build 就要半天。但我想说llvm-project 里其实藏着一套非常优雅的工业化设计思路只要把主干脉络理清楚了它就是你值得长期投入的技术资产。这篇文章我会从一个实际做编译优化的人的角度把 llvm-project 的工程结构、核心设计理念、常用工作流和我踩过的坑串起来讲。适合刚开始接触 LLVM 的学生、想基于 LLVM 做二次开发的工程师或者只是好奇编译器内部到底怎么回事的爱好者。读完之后你至少能亲手把仓库跑起来能看懂一次优化 pass 从注册到生效的全过程也能在遇到编译错误或代码生成问题时知道往哪个方向排查。1. 项目整体设计与工程结构梳理1.1 从“古董编译器”到“模块化生态”的转型逻辑如果你用过早期的 GCC应该能体会传统编译器那种“一口大锅煮全家”的设计方式前端负责把语言翻译成内部表示后端负责生成目标代码中间的程序分析和优化逻辑跟前后端绑定得非常紧。你很难把 GCC 的某一个优化 pass 单独拿出来复用想为一种新语言接入后端基本等于把整套工具链重写一遍。llvm-project 的核心革命是把编译器拆成了三明治结构前端Clang 等、中端LLVM IR 与一系列分析优化 pass、后端目标指令集实现。中间那个 IR 层是全局的中枢前端只负责把代码翻译到 IR后端只负责把 IR 变成机器码而所有优化逻辑都挂在 IR 上跟具体语言和具体硬件解耦。这种设计带来的直接好处是今天你写了一个新的循环优化 pass它在 C 语言上能用在 Rust、Swift、Julia 上也能用因为大家的 IR 是同一套。而如果你做了一款新芯片只要把后端指令选择、寄存器分配、指令调度这几块补齐所有语言的前端都能通过 LLVM 把自己的代码编到你这款芯片上。这个“一次优化处处受益”的逻辑是 llvm-project 真正的价值所在。1.2 仓库目录到底该怎么看clone 下来之后llvm-project 根目录下会有十几个一级目录刚接触的人很容易看花眼。我的建议是不要按字母表顺序去逛先抓住这几个核心目录clangC/C 编译器前端。你写的clang -O2命令、头文件搜索路径、语法检查、AST 生成都在这里实现。llvm中端和后端主库。LLVM IR 的定义、优化 pass 框架、各目标架构的后端X86、AArch64、RISCV 等都集中在这个目录里。lld链接器。现在的clang -fuse-ldlld默认就是用 lld 去做链接速度明显快过系统自带链接器。compiler-rt运行时支持库。包括 address sanitizer、undefined sanitizer、profile 插桩这些运行时库。mlir面向机器学习的多层级中间表示是 LLVM 家族里后来长出来的一个重要分支。libcxx/libcxxabi/libunwindC 标准库及 ABI 层做嵌入式和高性能计算的人会经常折腾这块。剩下还有polly多面体优化、openmp并行运行时、flangFortran 前端等等但这些不是主干可以等有明确需求时再看。说白了这个仓库是一个“编译器全家桶”不是单一项目。1.3 版本管理和子项目之间的依赖关系llvm-project 是单体仓库monorepo策略所有子项目放在同一个仓库里统一发布。这样做的好处是版本一致性Clang 9 一定配的是 LLVM 9 的核心库不会出现前端版本跟中端接口对不上的尴尬。我在早期自己拼装版本的时候就吃过亏那时候用的是分散的旧版 LLVM 和单独 checkout 的 Clang结果每次升级都要手动对齐 API累得很。值得注意的是即使是同一个版本号不同分支比如 release/17.x 和 main之间的 API 差异也可能很大。LLVM 社区的习惯是不太维护向后兼容性的每个大版本都会调整一些接口。所以当你参考网上的旧教程时先确认一下对方用的是哪个版本否则编译报错排查起来相当折磨。2. 从零搭建可用的 llvm-project 开发环境2.1 磁盘、内存与构建系统的硬指标llvm-project 很吃机器配置。先说结论你要是打算从源码完整构建一个带 debug 信息的版本磁盘至少预留 80GB内存最好 16GB 以上构建时并发数别超过 CPU 核心数的 1.2 倍。我自己在 8 核 16GB 的笔记本上构建 Release 版大概需要 40 到 50 分钟如果是 Debug 版时间直接翻倍有时甚至能到两个小时。构建系统目前官方推荐用 CMake Ninja。Ninja 比 Make 快是公认的而且增量构建时表现更好。还有个关键点是配合ccache使用第一次构建虽然省不了多少事但后续修改重编时命中缓存的片段能省下大量时间我基本每次构建都会打开LLVM_CCACHE_BUILDON。2.2 命令行构建的推荐配置我个人常用的构建命令大致是这样的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 \ -DLLVM_TARGETS_TO_BUILDX86;AArch64 \ -DLLVM_ENABLE_ASSERTIONSON \ -DLLVM_CCACHE_BUILDON \ -DCMAKE_INSTALL_PREFIX/opt/llvm-project ninja sudo ninja install这里有几个参数值得解释清楚。LLVM_ENABLE_PROJECTS决定你要构建哪些上层子项目如果你只写 pass 不写前端那一个clang就够用LLVM_TARGETS_TO_BUILD默认会编所有目标架构这会拖慢构建建议只留你需要的。LLVM_ENABLE_ASSERTIONS建议开发期内保持开启它能帮你尽早发现 IR 非法操作、pass 误用等问题代价是运行时性能略降。注意第一次 clone 时建议加--depth1 --branchrelease/18.x这类参数只拉对应分支的最新提交能省不少网络时间和磁盘空间。全量历史对这个仓库来说动辄几个 GB。2.3 要是不想全量构建也有轻量路子如果你只对 LLVM IR 处理流程感兴趣其实可以用llvm-config去连接系统中已有的 LLVM 库或者直接用包管理器装llvm-dev。这种方式适合快速验证想法。但我要提醒一点系统自带的 LLVM 版本往往比较旧可能缺少你需要的 pass API 或者新特性。如果是从头开始做深入开发还是建议自己编一份带符号信息的基础版本。另外现在官方也提供docker.io/llvmorg镜像里面有构建好的工具链适合想快速跑 clang 和 lld 但暂时不想编译源码的人。不过你要是想改代码、断点调试 passDocker 镜像里的调试符号不一定齐全所以我还是保留了本地源码构建这条路。3. LLVM IR 与 Pass 框架深度拆解3.1 为什么 IR 是 LLVM 的“命根子”很多教程上来就讲 IR 语法和指令但很少解释为什么 LLVM 把宝押在 IR 上。我想用一个对比来帮助理解GCC 的 GIMPLE 虽然也起到中间表示的作用但它的设计目标更偏内部使用ASCII dump 出来你几乎没法阅读。而 LLVM IR 在设计上刻意让它具备“可读、可写、可验证”的特性。一个典型的 LLVM IR 函数长这样define i32 add_one(i32 %x) { entry: %add add i32 %x, 1 ret i32 %add }这里i32是 32 位整数类型add_one是全局函数名%x是局部虚拟寄存器。IR 采用静态单赋值SSA形式每个变量只被赋值一次后出现的指令只能引用之前定义的值。就是这种规则让数据流分析变得极其简单也让大多数优化 pass 能放心大胆地重排指令。更进一步LLVM IR 是分层级的内存中通过Module、Function、BasicBlock、Instruction这些 C 类来表示而磁盘上的.ll文件就是这些结构的文本输出。你用clang -S -emit-llvm foo.c就能看到 C 代码转译后的 IR。理解了这条链路后续所有 pass 的讨论才有了共同的语境。3.2 Pass 框架优化逻辑的“插件化”设计LLVM 的优化器本质上是一个 pass 管理器。每一个优化都是一个独立 pass比如LoopVectorize负责循环向量化、DeadCodeElimination负责删死代码、InstCombine负责指令模式匹配与简化。它们之间是串行执行的module(module-pass-manager) - function(function-pass-manager) - loop-pass-manager - simplifycfg, loop-rotate, lcssa, loop-vectorize...这种分层设计把不同粒度的优化分开管外部可以给某层插入自定义 pass。你不需要改 LLVM 主代码只要把自己写的 pass 注册进去工具链就能识别并调用它。我当时的第一个 LLVM 练习就是写一个最简单的 function pass遍历每个函数的每条指令统计add数量。代码核心大概 40 行注册方式如下class CountAddPass : public PassInfoMixinCountAddPass { public: PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { int n 0; for (auto BB : F) { for (auto Inst : BB) { if (auto *op dyn_castBinaryOperator(Inst)) { if (op-getOpcode() Instruction::Add) n; } } } errs() Function: F.getName() - add count: n \n; return PreservedAnalyses::all(); } };关键点有两个PassInfoMixin是 new pass manager 的基类老版本用的是FunctionPass接口已过时PreservedAnalyses::all()表示我这个 pass 没有修改任何 IR分析结果全部保留。如果误写成了PreservedAnalyses::none()会让后续所有分析全量重跑性能损失很大。3.3 三种基础分析结构Block、Loop 与 CallGraph很多优化在动手前必须先了解源码的控制流与调用关系。LLVM 为此提供了不同的分析 pass。首先是BasicBlock级别的分析它告诉你每个基本块从哪里开始、以什么终止指令结束、有哪些前驱和后继。这是最底层的信息控制流图CFG的基础就是这些块。其次是LoopInfo它把函数里的循环结构整理出来包含每个循环的入口块、出口块、嵌套层数、循环体的基本块集合。循环优化向量化、循环展开、强度削减几乎都要依赖 LoopInfo 给出的循环划分。再往上是CallGraph它描述的是函数之间的调用关系图。比如内联优化就要看被调用函数的调用频度、函数大小来决定是否值得内联。这些分析模块通常不是你手动跑出来的而是在 pass 里通过AM.getResultLoopAnalysis(F)这类接口按需取得。合理的分析复用机制是 LLVM 能在一轮轮优化中保持效率的关键。3.4 IR 合法性检查为什么一天到晚报 “Broken module”调试 pass 时最常见的噩梦之一就是运行到某个优化后 IR 崩溃或者验证失败。LLVM 里有个verifypass 专门做 IR 合法性检查。它检查的事情包括变量是否在定义前就被使用、phi 节点的规则是否正确、终结指令是否只在基本块末尾出现、函数返回值类型是否匹配等。我在早期踩过一个经典问题我写了一个简单优化想把add x, 0简化成x但在替换后忘记更新use-def链结果导致后续指令引用了一个已经被删除的 value。打开-verify-each后问题暴露得特别快opt -passesmy-pass -verify-each input.ll -o output.ll所以开发 pass 时一定要养成开验证选项的习惯。没有验证的优化链就像没有刹车的车出事了才知道错在哪。4. 实际操作写一个真正能跑的优化 pass4.1 构建一个与系统独立的新 pass 工程很多人第一次做 LLVM 开发时会把代码直接丢进 LLVM 源码树里改 CMakeLists 后随整个工程一起编译。这种方式的缺点是每次改动都要走一遍 llvm-project 的构建系统慢而且跟主项目耦合深。更灵活的方式是建一个独立工程通过find_package(LLVM)引用已安装的 LLVM 库。项目结构大致像这样my-pass/ ├── CMakeLists.txt ├── MyPass.cpp └── lit-tests/CMakeLists.txt 核心内容如下cmake_minimum_required(VERSION 3.20) project(MyPass) find_package(LLVM REQUIRED CONFIG) add_definitions(${LLVM_DEFINITIONS}) include_directories(${LLVM_INCLUDE_DIRS}) add_library(MyPass MODULE MyPass.cpp) target_link_libraries(MyPass PRIVATE LLVM)这样配置完MyPass.so就是个独立插件可以用opt -load-pass-plugin./MyPass.so -passesmy-pass动态加载完全不用重编整个 LLVM。4.2 从 C 代码到 MLIR/LLVM IR 的完整落盘路径为了测试我写的 pass通常我会先写一个小 C 程序int add_one(int x) { return x 1; }然后生成可见 IR 文件clang -O0 -S -emit-llvm add_one.c -o add_one.ll cat add_one.ll这里-O0是为了保留比较原始的 IR方便看清优化 pass 进场前的样子。如果你想看某项优化有没有生效可以分别生成未优化和优化后的 IR再用diff对比。比如clang -O2 -S -emit-llvm add_one.c -o add_one.opt.ll观察add_one是否被优化成ret i32里的常量或直接返回参数加一。当处理更复杂的场景比如机器学习编译器前端未必是 Clang 而是 TensorFlow 或 PyTorch 的模型转换器它们会先产出 MLIR多级 IR再逐步 lower 到 LLVM IR。但这篇文章聚焦的仍是传统语言路径C/C 源码 - Clang AST - LLVM IR - O2 优化 - 目标汇编。4.3 用 opt 单步调试 pass 的实践经验在写 pass 过程中我用的最多的工具是opt。它允许你单独对 IR 文件跑一个或多个 pass非常适合单步验证。opt -load-pass-plugin./MyPass.so -passesmy-pass add_one.ll -S -o add_one.passout.ll-S表示输出 IR 文本-o指定输出文件。假如我想看多个 pass 依次作用的结果可以这样opt -passesloop-rotate,loop-vectorize,my-pass add_one.ll -S -o result.ll注意 pass 名称要跟注册名完全一致不然后续 pipeline 会直接报 unknown pass。排这个错很无趣所以建议注册 pass 时就明确规定名字并且保持一致。如果你想看整个优化 pipeline 的执行过程可以加-debug-pass-manager老版本叫-debug-passStructure。这会在 stderr 里输出每个 pass 进入和离开的层级顺序我能很直白地看到自己的 pass 是否被调度、运行了几次。提示opt在部分系统上不随clang直接安装需要额外装llvm包或确认LLVM_ENABLE_TOOLS是 ON。不然找半天会发现命令不存在。4.4 常用调试手段与可视化方案除了打印日志我还会用 LLVM 自带的一些工具帮助理解 IR。llvm-dwarfdump能看 debug 信息llvm-nm能列符号表llvm-objdump可以反汇编目标文件llvm-mca可以做静态性能预估。复杂函数如果想看图可以用dot导出控制流图opt -passesdot-cfg add_one.ll -S生成的文件就是.dot可以用 Graphviz 转成图片。当你调试一个循环优化 pass 时看看基本块布局的移动比盯着命令行输出直观得多。另外给转义符较少的项目做可视化时我也建议用llvm-view或 VS Code 里的相关插件但这类工具通常只支持老版本接口新版本可能失效所以最稳的还是dot导图。5. 实际工程中的高频问题与避坑笔记5.1 构建阶段的问题清单与解决建议我在不同机器上构建 llvm-project 很多次积累了不少坑。这里列几个最高频的现象可能原因解决办法内存不足直接 OOM并发任务太多模块缓存膨胀降低-j并发数改用 Release 构建开启LLVM_PARALLEL_LINK_JOBS2限制链接并发链接时报重复符号子项目之间依赖重复或开启多个后端检查LLVM_TARGETS_TO_BUILD尽量只保留目标架构不要混用 make 和 ninja 产物cmake 找不到LLVMConfig.cmakeCMAKE_INSTALL_PREFIX不对或没执行 install确认llvm-config --cmakedir指向的路径并在find_package前设置CMAKE_PREFIX_PATHclang 版本和 LLVM 库版本不一致用系统 clang 链接了自编译库构建时用 LLVM 自带的 clang设置CC和CXX指向 toolchain 中的二进制增量构建经常全量重跑配置参数改了或者 cmake 重跑修改编译选项时尽量在同一 build 目录避免反复重新运行 cmake 的变量核对5.2 Pass 运行期常见的错误类型在跑自定义 pass 时我最常见到的三类错误第一类是断言失败。比如把nullptr传给replaceAllUsesWith或者修改了指令操作数后没有处理use链断言会直接崩给你看。这类错误运行时就能抓到关键是打开LLVM_ENABLE_ASSERTIONS。第二类是验证失败。IR 结构不合法verifypass 告诉你Instruction does not dominate all its uses。这往往意味着你把一条指令移到了它依赖的值定义之前或者删除了祖先块。出现这种问题就需要重新梳理支配树关系。第三类是优化结果不符合预期。IR 没有崩但生成的代码没有达到期望性能。这时候不要瞎调 pass 顺序建议先看一眼llvm-mca的吞吐和指令数再对比优化前后 IR定位到底是哪个 pass 没触发、哪个模式没匹配上。5.3 版本迁移的适应性建议LLVM 更新快API 变化也快。你从 LLVM 15 迁移到 18可能发现legacy::Pass没了、FunctionPass被清理、AnalysisManager的调用方式也不太一样。我自己比较依赖的策略是写一小层适配代码把自己的 pass 核心逻辑跟 LLVM 接口隔离。核心逻辑只操作 IR 的数据结构适配层负责注册和调用。这样即使 API 调整我通常只改几十行适配层就能复用到新版本。还有一个建议是关注 LLVM 每年的发布说明文档那里面会列出 breaking changes。虽然内容多但至少能在升级前对整个变化有数不至于在 compile error 中迷失。5.4 性能瓶颈排查先量化再动手优化 pass 本身也会影响编译时间。如果你的 pass 在大型源文件上运行极慢先用time或-ftime-report看看整体耗时分布。常见病根是 pass 内部使用了O(n^2)的循环扫描——比如遍历函数时对指令列表做线性查找。优化这类问题的常见思路有两个。一是尽可能用 LLVM 提供的分析结果例如要获取循环深度别自己遍历再判断直接取LoopInfo的结果二是注意在 pass 中不要频繁构造、销毁结构或复制容器尽量引用原始对象。如果排查后发现耗时其实出现在 pass 管理器的分析重建上就要重新审视你的 pass 是否正确声明了PreservedAnalyses。我在第 3.2 节提过申请所有分析保留是默认安全选项但你要是确确实实改了 CFG那就不能这么写了否则后续 pass 用了陈旧的分析结果轻则优化失效重则直接触发断言报错。6. 扩展思路这些方向值得持续深入学习6.1 MLIR 与多级中间表示带来的新可能llvm-project 现在的覆盖面已经远远超过传统“编译器”概念。MLIR 子项目是近年来热度非常高的方向它的核心思想是让开发者可以自定义中间表示层并且在不同抽象层级之间做逐步 lower。做 AI 编译器的人特别吃这套。比如一个模型可以先表示成较高层的tosa方言再逐步 lower 到linalg、affine最后到 LLVM 方言。每一层之间可以做对应的优化比如算子融合、缓存优化。这种“多级 IR”的灵活度是传统单层 IR 很难提供的。如果你对深度学习编译或者硬件设计空间探索感兴趣MLIR 值得好好研究。它学习的难点是概念多、方言多但一旦理顺了 dialect 和 lower 这两个核心逻辑写起来会顺手很多。6.2 结合其他语言与生态不只是 C/Cclang 只是 llvm-project 前端的其中一个。Rust 就通过 rustc_codegen_llvm 把 Rust MIR 转成 LLVM IR再用 LLVM 后端生成机器码。Julia 的默认编译器同样把多种语言语义统一到 LLVM IR。Swift 则深度参与 LLVM 开发甚至贡献了不少优化和运行时改进。这种“语言无关”的能力让 llvm-project 成为各种语言工具链的共同底座。如果你自己是某个语言生态的开发者看 LLVM 相关代码时会觉得那些优化 pass 并不是只服务于 C而是服务于所有编译语言。6.3 参与社区贡献的正确姿势想把 llvm-project 作为长期经营的开源项目建议从修小 bug 开始。github 上的 issue 和 Phabricator现在用的是 GitHub Pull Request 和 Discourse 讨论里都挂着很多good-first-issue标签的任务。我个人的经验是先挑文档修正、测试用例补充、边缘 case 处理这类低风险改动。等你对代码风格和 review 流程熟悉了再碰需要大改的优化 pass。LLVM 的 code review 非常严格一个 patch 改几十行来回 review 好几轮是常态。要有耐心。结尾离“精通”还差的那些事写到这里我知道很多人读这类长文都会想我是不是就能落地说会 LLVM 了实话实说光是 llvm-project 的代码量一个人即使全职读上几年也未必全读完。但我觉得真正重要的不是把所有代码读完而是建立一条完整的主线懂 IR、懂 pass 框架、会构建工具链、能调试问题。有了这条主线遇到具体需求时你能迅速地定位到相关的源码文件理解别人的实现思路再动手去做修改。我自己从最初对着opt手册发呆到后来能写出一两个看得过眼的优化 pass中间绕了不少弯路。如果非要说有什么捷径那就是多写、多跑、多读报错信息。LLVM 的报错绝大多数时候是诚实的它告诉你哪里非法你就去查那里的 API 文档和源码。另外常去翻 LLVM 的官方文档和邮件列表里面的讨论深度往往比多数二手博客高一个数量级。最后再分享一个小技巧这是帮你在源头减少麻烦的实践每次开始改代码之前先把当前基线编译一遍并记住带符号的clang和opt是什么状态。这样后续出现任何异常你都能判断是环境变化还是代码改动引起的。很多头疼的 debug 一夜问题其实都出在环境状态混乱上。打好地基后面走起来才踏实。