llvm-project完全指南:从仓库结构到Pass编写与源码贡献

📅 发布时间:2026/9/20 4:43:48
llvm-project完全指南:从仓库结构到Pass编写与源码贡献
1. 一个仓库装下整个编译生态llvm-project到底包含什么1.1 先打破一个常见误解我这些年给人做编译工具链分享开场经常听到一句话“我在学LLVM。”追问下去发现大部分人其实只是用clang编译了几个C项目或者跑了一下clang --version连llvm-project仓库里到底有哪些东西都没完整看过。这不能怪大家因为“LLVM”这个名字本身就很绕。严格来说LLVM是一整套编译器基础设施的设计理念前端把源代码变成中间表示中端对中间表示做优化后端再把优化后的中间表示变成机器码。而llvm-project是这一整套东西的官方monorepo仓库所有组件都在同一个Git仓库里开发、发布、维护。你从GitHub上git clone https://github.com/llvm/llvm-project.git拉下来的这一大坨就是这个仓库。这个仓库的体积非常大shallow clone后也有几个GB的源码完整clone加上历史记录经常超过几十个GB。我第一次完整clone的时候一度怀疑是不是网断了。体积大是因为里面并行维护着多个活跃项目而这些项目之间存在很强的依赖关系不放在一起版本同步会非常痛苦。1.2 llvm-project核心组成和各自定位很多人以为llvm-project就是“LLVM Clang”实际上远不止这些。我整理了一个核心子项目的功能对照表方便你按需查阅子项目功能定位典型使用场景llvm核心库IR、优化器、目标描述、后端代码生成写Pass、做静态分析、做自定义后端clangC/C/Objective-C前端编译C/C代码、做Clang Static Analyzerlld链接器替代系统ld/gold速度更快libcxx / libcxxabi / libunwindC标准库实现实验新标准特性、自定义标准库compiler-rt运行时库包含sanitizer系列AddressSanitizer、UBSan、ThreadSanitizermlir多层级中间表示框架AI编译器、硬件抽象、DSL编译flangFortran前端Fortran代码编译polly基于多面体模型的循环优化自动并行化、数据局部性优化clang-tools-extraclang-tidy、clangd等工具代码静态检查、IDE补全、重构openmpOpenMP运行时和编译支持并行计算程序编译和运行这里最容易被新手搞混的是“llvm”目录。它不是一个完整的编译器而是整个生态的核心框架。平时我们编译C用的clang可执行文件本质上是“Clang前端 LLVM中后端的缝合体”。理解了这些项目之间的关系后你才能真正明白为什么llvm-project值得整个编译生态围绕它运转。它不是一个孤立的工具而是一套可以让任何语言编译到任意平台的通用基础设施。2. 从零构建llvm-projectCMake配置与提速实操2.1 环境准备和第一印象很多人下载完llvm-project之后第一反应是打开README然后看到一大堆CMake选项就懵了。我当初也一样甚至因为构建失败一度怀疑自己是不是不适合搞编译器。所以这里我把一套我验证过多次的最小构建方案写出来你在自己机器上直接抄就行。先说我自己的环境Ubuntu 22.04CMake 3.24Ninja 1.11系统GCC 12内存32GB。如果你是新装的系统先确认依赖sudo apt update sudo apt install -y cmake ninja-build gcc g python3 git然后拉取仓库。这个仓库太大我强烈建议先做浅克隆等熟悉了再拉完整历史git clone --depth 1 https://github.com/llvm-project/llvm-project.git cd llvm-project这里有个小细节--depth 1只拉取最新的提交能省下大量时间和磁盘空间。如果你之后想参与社区、用git blame查历史或者git log追溯提交再在需要时git fetch --unshallow补全历史完全不冲突。2.2 最小可用构建命令进入仓库后你会发现外层没有传统的顶层CMakeLists.txt而是在llvm/这个子目录下。构建LLVM的标准做法是以llvm目录为源码根单独建一个build目录做out-of-source构建。我推荐这套配置cmake -S llvm -B build -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_ENABLE_ASSERTIONSON然后开始编译ninja -C build -j$(nproc)第一次构建时间取决于机器性能我实测在32核机器上大概10到15分钟8核机器可能要一两个小时。如果你只是想读源码、跑opt做IR实验其实不需要编译整个项目只编译llvm和opt、llvm-as、llvm-dis这几个工具就够用。但如果你想跑clang编译真实C代码就必须把clang和lld也加入LLVM_ENABLE_PROJECTS。为什么推荐用Ninja而不是默认的Makefiles最直接的原因是Ninja增量构建更快且在编译失败时给出的错误信息更友好。它默认利用多核并行对于这种巨大工程构建速度差距非常明显。我最初用Make构建过一次后来换Ninja之后明显感觉整个世界清净了。2.3 构建中容易踩的坑构建llvm-project时我踩过不少坑有些至今记忆犹新。这里列一个避坑对照表症状根因解决方案CMake报错找不到Ninja系统没有装Ninja或版本太老sudo apt install ninja-build确认ninja --version不是老版本configure时报C编译器版本过旧GCC版本低于LLVM要求升级GCC或用Clang来编译Clang编译过程中内存不足进程被杀Debug构建太吃内存用Release构建限制并行度-j4增加swapLLVM_ENABLE_PROJECTS拼错或包含不存在的项目项目名写错去llvm/CMakeLists.txt里查看支持的列表编译出来的clang运行时报库找不到没有正确设置LD_LIBRARY_PATHexport LD_LIBRARY_PATH$(pwd)/build/lib:$LD_LIBRARY_PATH最隐蔽的一个坑是-DCMAKE_BUILD_TYPERelease和-DLLVM_ENABLE_ASSERTIONSON的组合。很多初学者怕Debug版本太慢直接上Release结果跑opt调试Pass时什么信息都不输出因为很多断言信息和调试日志在Release下被编译掉了。我的习惯是日常学习用Release Assertions既保证运行速度又有基本断言如果真要深入调试某个LLVM内部状态再单独建一个Debug构建目录。另外强烈建议安装ccache如果你打算反复修改源码并重新编译它能省掉大量重复编译sudo apt install ccache cmake -S llvm -B build -G Ninja \ -DCMAKE_CXX_COMPILER_LAUNCHERccache \ -DCMAKE_C_COMPILER_LAUNCHERccache \ ...我加了ccache之后日常增量构建时间从十几分钟降到一两分钟这个收益对迭代调试来说极其重要。3. 打开优化器的黑盒IR与Pass的运行原理3.1 为什么IR是理解整个llvm-project的钥匙llvm-project之所以能支持那么多语言、那么多目标架构核心功臣就是中间表示IR。IR是一种经过精心设计的、同时保留高级语义和适合底层优化的指令形式。你可以把它理解成一种“跨语言的汇编语言”——C和Rust编译后都先变成IRLLVM再对IR做优化最后生成不同CPU的机器码。IR在LLVM中有三种形态内存表示Pass分析操作的是内存中的Module、Function、BasicBlock、Instruction对象。二进制表示即bitcode常被JIT或AOT预处理环节使用扩展名.bc。文本表示人类可读的.ll文件也是调试和学习时最常打交道的形态。想把一个简单的C函数变成IR一行命令就够cat test.cint add(int a, int b) { return a b; }clang -S -emit-llvm test.c -o test.ll然后打开test.ll你会看到类似这样的内容define i32 add(i32 %a, i32 %b) { %1 add i32 %a, %b ret i32 %1 }每个指令的语义清晰类型显式标注%a、%b是寄存器的抽象名。这种设计让你可以彻底摆脱具体机器的干扰专心研究优化算法。3.2 从写一个Pass开始理解IR后最直接的学习抓手就是写一个自己的Pass。LLVM的Pass框架说白了就是遍历IR并修改它的插件机制。每个Pass完成一个特定优化比如死代码消除、常量传播、循环展开。你写的每个Pass本质上都在回答两个问题我要遍历什么我要怎么改我建议用新Pass Manager写一个最基础的FunctionPass示例功能是统计每个函数的指令数量并打印函数名。下面的代码可以直接放到一个独立文件中#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 CountInstructionsPass : public PassInfoMixinCountInstructionsPass { PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { unsigned count 0; for (auto BB : F) { count BB.size(); } errs() Function: F.getName() ( count instructions)\n; return PreservedAnalyses::all(); } }; } // namespace extern C ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, CountInstructionsPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name count-instructions) { FPM.addPass(CountInstructionsPass()); return true; } return false; }); }}; }编译成一个动态库clang -fPIC -shared count.cpp \ $(llvm-config --cxxflags --ldflags --libs) \ -o count.so然后配合opt工具在IR上运行opt -load-pass-plugin./count.so -passescount-instructions test.ll你会看到Function: add (2 instructions)这个例子看似简单但它覆盖了Pass编写最关键的几个步骤声明Pass、实现run方法、注册到PassBuilder、加载到opt。你会实际感受到LLVM的工具链是如何环环相扣工作的——clang生成IRopt加载Pass运行优化llvm-as/llvm-dis做IR格式转换。一旦这个链路跑通再去看任何复杂的Pass都能更快理解。3.3 优化层级与Pass管线的关系写完第一个Pass之后很自然会问-O2到底执行了哪些Pass它们按什么顺序跑在LLVM的新Pass管理器中编译器内置的优化Pass管线定义在llvm/lib/Passes/PassBuilderPipelines.cpp。/O0基本不做优化O1做一些基础清理O2是大多数项目的默认选择O3在O2基础上还会开更多循环变换比如循环展开、循环向量化。你可以在命令行里直接看某个优化级别对应的Pass列表clang -O2 -S -emit-llvm test.c -o test.ll opt -O2 -passes-epilogue ... # 不更直接的方式 opt -O2 -debug-pass-manager -S test.ll -o /dev/null开启-debug-pass-manager之后opt会把每个Pass的执行顺序打印出来。我第一次看到那个输出时很震撼原来一个简单的return a b也要经过几十个Pass的轮番处理。这种“黑盒变白盒”的感觉正是学习LLVM最上头的阶段。现在写的这个简单统计Pass虽然不修改任何东西但它给了你一个自带探针的“装置”。之后你可以在这个文件基础上添加IR变换比如删除某个无用的load或者替换某个常量为立即数。自己动手改一次胜过读十篇优化器原理文章。4. 调试与分析源代码我常用的工具和工作流4.1 一次崩溃分析与最小化复现写Pass最怕的不是逻辑错误而是“优化后程序行为变了”或者直接崩溃。定位这类问题我有一套固定的排查链路这里以一个真实例子说明。有次我写了一个简单的Inlining分析Pass在run函数里天真地以为所有CallInst都一定指向一个Function结果跑opt加载Pass时当场段错误。这个问题的复杂性在于IR里的call指令指向的可能是间接调用也就是说被调用对象可能是函数指针而不是一个名字固定的函数。排查过程大致是先用opt -debug-pass-manager确认是哪个Pass崩溃。把待优化的.ll文件切成最小片段直到崩溃必须依赖的最小IR。用llvm-dwarfdump看bitcode调试信息或者直接加errs()打印崩溃点。我强烈建议在Pass里多用assert和早期打印LLVM的raw_ostream用起来很顺手assert(CI.getCalledFunction() direct call expected); if (!CI.getCalledFunction()) { errs() Skip indirect call in F.getName() \n; continue; }这种处理方式让我很快意识到编译器的世界里“默认所有调用都是直接调用”是个危险的假设。间接调用、虚函数、函数指针无处不在Pass必须对IR的每种形态有准备。4.2 关键命令行工具速览我用llvm-project的各种工具做日常分析已经很多年下面这几个是我使用频率最高的工具用途使用场景opt运行Pass、优化IR测试自定义Pass、查看Pass执行顺序llvm-as / llvm-disIR文本和bitcode互转生成.bc或者把.bc转成可读文本lli直接解释执行bitcode快速验证IR行为而不需要生成机器码llvm-nm查看符号表检查库导出符号llvm-objdump反汇编目标文件比较优化前后的机器码llvm-dwarfdump解析DWARF调试信息排查调试元数据问题llvm-mca静态性能分析分析指令吞吐、延迟bugpoint自动最小化崩溃用例当Pass导致crash时自动缩减IR其中bugpoint是LLVM提供的“自动二分定位”神器。有一次我给自定义后端做优化代码在特定IR输入下触发断言我原本打算手动一点点删代码来找最小复现后来发现LLVM早就提供了这个工具。它会自动尝试截取IR的不同部分找到仍能触发问题的子集省下来的时间让我吃了顿完整的午饭。4.3 把-debug-only用起来LLVM内部很多关键模块都内置了调试输出由-debug-only参数控制。想在Pass里加入自己的调试日志可以先在代码里用LLVM_DEBUG(dbgs() ...)然后开启编译时的LLVM_ENABLE_ASSERTIONS运行的时候带上opt -debug-onlymy-pass -load-pass-plugin./count.so -passescount-instructions test.ll这个参数的好处是默认情况下不输出任何内容不会污染正式运行但调试时就可以针对性打开某一类日志。很多LLVM子系统的调试类别名可以在源码里搜DEBUG_TYPE找到。我的习惯是每个自定义Pass都定义独立的调试类型例如DEBUG_TYPE my-pass。这样在庞大的输出里用grep过滤会清晰很多。5. 给llvm-project贡献代码从issue到merged PR5.1 如何找到适合新手的任务很多读者在学会写简单Pass之后都会冒出一个念头我能给llvm-project贡献点东西吗我的回答是当然能但最好别一上来就挑战核心优化。GitHub上llvm-project仓库的issues里偶尔会打上good first issue标签但数量不多。更适合新手的是先从这些切入点入手修文档和注释LLVM的文档量极大不少资料更新滞后修文档是一个低门槛且社区极其欢迎的贡献。补测试用例找到某个Pass没覆盖到的边界情况提交新的lit测试这是练手的好目标。修已知小bug优先找那些已经有复现用例的bug你可以直接拿来练手。代码格式化与重构LLVM很重视代码风格偶尔有需要重命名或重构的小任务。需要提醒的是LLVM社区很早之前从Phabricator迁移到了GitHub Pull Request但审查流程依然非常严格。你提交的每个改动都会有人逐行看而且很可能被要求修改好几轮。这不是针对你个人而是这类基础软件的质量门槛真的高。5.2 写测试、跑测试、提交PR的完整流程我参与社区最大的收获之一是被迫学会了写规范测试。LLVM的测试框架是lit加FileCheck。lit负责发现和运行测试FileCheck负责校验输出。一个最简测试文件长这样; RUN: opt -S -passesmy-opt-pass %s | FileCheck %s define i32 test() { ; CHECK-LABEL: test ; CHECK: ret i32 42 ret i32 42 }RUN行定义了执行命令CHECK行声明了期望输出的模式。写完测试后在build目录下运行ninja check-llvm也可以只运行特定目录的测试ninja check-llvm-unit llvm-lit -v ../llvm/test/Transform/MyPass/提交代码前还需要过代码格式这一关。LLVM使用clang-format直接运行git-clang-format HEAD~1能自动调整你引入的改动风格。另外每个提交都需要Sign-offDeveloper Certificate of Origin也就是提交信息末尾加一行Signed-off-by: Your Name youremail.com通常用git commit -s就能自动加上。5.3 我的几条实操体会我在提交PR过程中犯过不少错误总结下来最值得提醒的是这么几条第一不要等代码“完美”再提交PR。更合理的做法是先把一个最小可行版本提上去在PR描述里说清楚思路、实现的取舍、测试结果让reviewer尽早给反馈。这能避免你朝错误方向走太远。第二PR描述里一定要写清楚“为什么”改动。LLVM的reviewer对你的代码风格和实现方式有要求但最关心的是你有没有充分理由改这个行为。一个没有任何motivation说明的PR基本活不过第一轮review。第三跑测试别偷懒。你改的是编译器影响面极大至少要跑一遍check-llvm相关目录最好把clang相关测试也跑一下。测试挂掉就去修不要假装没看见。第四被reviewer拒绝或者要求大改不是坏事。我的第一个非文档PR被要求重构了三轮每一轮都在缩小改动范围、细化注释、补充边界测试。最后合并时那部分代码的质量确实远高于我最初提交的版本。这种被“打磨”的过程才是参与llvm-project最大的成长价值。6. 想深入LLVM我建议的源码阅读顺序与学习方法6.1 不同基础读者的源码阅读路径经常有人问我“我知道llvm-project很牛但源码几千个文件到底从哪里开始读”我的回答是不要从clang开始要从llvm/lib/IR开始。因为IR是整个项目的核心语言所有前端最终都生成IR所有优化都作用在IR上所有后端都消费IR。不懂IR看任何组件都会觉得是天文数字。我给不同基础的读者推荐过下面这条路径反馈一直不错阶段阅读目标参考资料入门llvm/lib/IR/里的核心数据结构Module、Function、BasicBlock、InstructionLLVM Language Reference Manual进阶llvm/lib/Transforms/里一个简单Pass例如SROA或EarlyCSEopt -debug-pass-manager观察Pass执行深入clang/lib/CodeGen理解C如何生成IR用自己的C代码跑clang -S -emit-llvm对照探索llvm/lib/Target/看某个目标后端的指令选择llvm-objdump反汇编比较机器码我自己就是因为好奇“前端解析完AST之后是怎么进入IR的”花了很多时间读clang/lib/CodeGen。看得越多越觉得编译器前端和后端之间的那个IR边界是整个工程最具设计美的地方。它不是某个人的临时主意而是一套历经十几年打磨形成的抽象——这个抽象是否合理直接决定了llvm-project能不能同时容纳几十种语言和几十种处理器。6.2 警惕过时教程和过时Pass写法网上关于“写LLVM Pass”的教程极多但很多都过时了。最典型的问题是老教程讲legacy Pass Manager教你用registerPass、FunctionPass继承而现代LLVM默认使用new Pass Manager写法和加载方式都变了。判断一篇教程是否过时的简单方法看它有没有出现llvm::PassInfoMixin和llvmGetPassPluginInfo。如果通篇都是InitializeNativeTarget、legacy::PassManager这类老式写法参考价值就要大打折扣。为了少走弯路我建议一手资料只信两个地方llvm.org官方文档尤其是WritingAnLLVMPass页面和LLVM Language Reference Manual以及llvm-project仓库里实际代码示例。教程可以看但最终要回到源码和实操去验证。6.3 保持长期参与的小建议每次涉及到llvm-project的项目我都会想起一个具体场景某次被一个循环优化问题卡了三天最后在llvm/lib/Transforms/Scalar/LoopRotate.cpp里找到一段注释解释了为什么某个看似废话的条件判断其实是处理一个极端合法性问题。那个瞬间我意识到阅读LLVM源码最宝贵的不是代码本身而是代码里沉淀下来的“工程判断”——什么事情必须严格保守什么事情可以做激进假设这些边界条件是论文里永远读不到的。所以我建议所有想深入这个项目的人除非你只是临时用clang编译代码只要你动了写Pass、改优化器、做后端的念头就去维护一个自己可复现的本地构建环境然后从IR开始一点点啃。不用给自己定太大目标每周能读懂一个Pass的输入输出和关键边界条件一年后你对现代编译器基础设施的理解已经能超过绝大多数“只谈概念”的泛泛之谈。在llvm-project里待得越久我越觉得这个项目的准入门槛不是智商而是耐心。你不需要是天才只需要愿意为了一条断言反复读代码、为了一次内存崩溃反复跑bugpoint、为一个边界条件反复翻文档。这些都做到之后你会发现编译器基础设施其实并没有想象中那么遥不可及——它只是一群极其较真的人在处理极其精确的问题而已。