LLVM源码开发实战:构建配置、Pass编写与测试验证
如果你点开这个标题大概率是已经被 LLVM 那套庞大的代码库折磨过一轮了要么是想自己写个编译器后端要么是想在中间表示层做点静态分析再要么就是纯粹想搞懂 Clang 到底是怎么把 C 变成机器码的。不管哪种动机llvm-project 这个仓库都是绕不开的庞然大物。我在里面摸爬滚打了几年从最初面对上百个子目录不知所措到后来能熟练地改代码、跑测试、提 patch中间踩过的坑攒了一堆。今天这篇文章不打算复述官方文档而是把我自己从零开始接触 llvm-project、逐步深入的过程和心得完整写下来希望能帮你少走点弯路。这篇文章适合两类人一类是刚把 llvm-project clone 下来、正准备构建却不知道从哪下手的初学者另一类是已经在用 LLVM 但只是在 API 层面调用、还没真正深入源码内部的中级用户。我会从仓库结构、构建配置、编写 Pass、测试验证、社区协作这几个角度展开尽量把“为什么这样做”也讲清楚而不是只丢给你一串命令。1. 先搞明白llvm-project仓库里装的是什么第一次 clone llvm-project 的时候你大概率会被它的体积吓到——完整历史好几个 GBcheckout 出来之后光.git目录就能占用一大块磁盘。但这其实不是坏事因为这个仓库已经不是一个单纯的编译器了而是一整套工具链生态。理解仓库的整体布局是之后所有操作的基础。1.1 顶级目录不是随便分的打开 llvm-project 根目录你会看到clang、lld、lldb、compiler-rt、mlir、flang、libc、libcxx、libcxxabi、openmp、polly、lldb、clang-tools-extra、bolt、cross-project-tests等一堆目录。很多人以为 LLVM 就是编译器其实严格来说llvm这个子目录才是“核心编译器基础设施”——它包含了优化器、代码生成、目标后端、以及一系列库。而clang是 C/C/Objective-C 的前端负责把源代码解析成 AST再降级到 LLVM IR。这里有个新手很容易混淆的点我们平时说的“用 LLVM 写编译器”实际上说的往往是“用 LLVM 的库”来写而这些库分散在llvm/include、llvm/lib、llvm/tools等目录里。比如你想实现一个静态分析工具通常会用到llvm/lib/IR、llvm/lib/Analysis、llvm/lib/Transforms这些地方的代码。而clang目录下的东西只有在你需要处理 C 语言家族的语法和语义时才会牵涉进来。1.2 各子项目的分工其实很清晰为了让你快速建立心智模型我把最关键的几个子项目整理如下目录作用典型用途llvm/核心IR、优化、代码生成、目标后端写 Pass、研究优化、支持新指令集clang/C/C/ObjC 前端修改语法支持、静态检查、AST 相关工具lld/链接器想深入链接过程时重点关注lldb/调试器和调试器相关的研究mlir/多层级 IR 框架做 DSL、HPC、AI 编译器libcxx/C 标准库实现需要换标准库实现时关注compiler-rt/运行时库sanitizer 等ASan、UBSan、profile 相关需要特别说明的是mlir虽然是后起之秀但它现在几乎成了 llvm-project 里最活跃的部分之一。如果你关注 AI 编译器、芯片厂商的软件栈肯定会发现它们底层大量用到 MLIR。不过这次我不打算展开 MLIR因为它的体系足够再写几篇文章今天仍然聚焦在核心 LLVM 和 Clang 上。1.3 monorepo 到底意味着什么llvm-project 采用 monorepo单仓库多项目的管理方式这意味着所有子项目的版本是同步的。以前 LLVM 是分仓库管理的人们经常因为 Clang 和 LLVM 版本不匹配导致各种诡异问题。现在统一在一个仓库里你只需要保证 checkout 的是同一个 commit所有子项目就都对齐了。这个设计极大降低了协作成本但也带来了新的问题——仓库太大clone 太慢。我的建议是使用--depth1做浅克隆或者干脆用 GitHub 的“下载 ZIP”方式只在你真正需要 git 历史时才做完整克隆。2. 源码构建是绕不过去的第一道坎很多人问我为什么不直接装发行版自带的llvm包如果你只是普通用户确实没必要从源码构建。但如果你要改 llvm-project 里的代码或者需要自定义配置就必须自己构建。而且源码构建的很多细节会影响你后续的开发效率所以我专门用一个章节来聊构建这件事。2.1 选择合适的版本和构建目录首先确定你要构建哪个版本。我建议不要直接 clone main 分支因为 main 分支是持续集成的状态可能今天还能编译明天就被一个未完成的改动搞坏。最好选择一个 release 分支比如llvmorg-18.1.8这种打了 tag 的版本。如果你要跟社区最新特性那就做好心理准备偶尔构建失败是常态。其次强烈建议使用“单独构建目录”out-of-source build。也就是在 llvm-project 外面建一个build目录然后在里面运行 cmake。这样能避免污染源码树出问题时删掉 build 目录就能重新开始。我自己习惯这样组织llvm-project/ build-release/ build-debug/这样我可以保留一个 release 版本用于日常测试另起一个 debug 版本来开发调试验证。两个目录互不干扰切换成本几乎为零。2.2 CMake 参数里面藏着大学问LLVM 的 CMake 配置选项多到你怀疑人生。但核心的我只会用这几个cmake -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86;AArch64 \ -DCMAKE_INSTALL_PREFIX$HOME/llvm-install \ ../llvm-project/llvm一个个来解释-G Ninja用 Ninja 而不是默认的 Unix Makefiles。Ninja 的增量编译速度明显更快而且输出更容易阅读。-DCMAKE_BUILD_TYPERelease构建优化版本。如果不开优化LLVM 自身代码运行效率很低跑测试会慢得让人崩溃。但如果你要调试 LLVM 本身的代码就得用Debug或RelWithDebInfo。我推荐RelWithDebInfo它既有优化也有调试信息在多数场景下是折中的好选择。-DLLVM_ENABLE_PROJECTSclang;lld决定要构建哪些子项目。默认只构建核心 LLVM这样编译时间会短很多。当你需要 Clang 时一定要把它加进来。多个项目用分号分隔注意 CMake 的变量传递里分号要加引号。-DLLVM_TARGETS_TO_BUILDX86;AArch64指定要生成哪些后端的机器码。如果全开会额外花费大量编译时间而且你大概率用不上那些嵌入式后端。只保留你本机架构以及你研究需要的架构就够。-DCMAKE_INSTALL_PREFIXinstall 时放置二进制的位置。此外如果你是做编译优化研究大概率还会用到-DLLVM_BUILD_EXAMPLESON和-DLLVM_INCLUDE_TESTSON。前者会编译一些示例后者在运行时可以跑 lit 测试。2.3 构建时间怎么压下来纯编译 llvm-project 全套项目包括 clang 和 lld在 8 核 16 线程的机器上通常要 20-40 分钟。第一次构建慢是正常的但之后增量编译就会快很多。如果你想进一步压缩时间有几个技巧加-DLLVM_CCACHE_BUILDON并安装 ccache针对头文件频繁改动的情况能大幅节省时间。代价是磁盘多占几个 GB。不要用-j把 CPU 跑满留一两个核给系统。否则你的机器会卡到你怀疑人生。用-DCMAKE_C_COMPILERclang -DCMAKE_CXX_COMPILERclang可以用系统已有的 clang 来编译 llvm-project生成出来的编译器性能通常比 GCC 编译出来的更好而且能减少一些兼容性问题。如果只是写一个 Pass你其实不需要构建完整的 clang很多情况下只构建 llvm 即可。用opt工具配合一个预生成的.ll文件就能完成测试。2.4 构建失败的常见原因和排查手段我见过的构建失败九成是因为版本不匹配或内存不够。LLVM 的 C 模板爆炸和 header 依赖导致编译时内存占用很高单个文件如X86ISelDAGToDAG.cpp在 Debug 模式下可能吃掉好几个 GB 内存。如果编译中途突然报Killed被系统杀掉通常就是内存不足这时应该减小并行度或改用 Release 模式。另一个经典问题是在 macOS 上不使用系统自带的 Clang 而是使用旧版 Xcode Command Line Tools导致标准库头文件版本不一致报出一堆莫名错误。解决办法是安装完整版 Xcode 或更新 Command Line Tools。如果你遇到了cmake报找不到ninja先检查ninja --version是否正常。如果你之前用过源码安装的 ninja可能版本太旧或与 CMake 不兼容建议用包管理器安装最新版。3. 我的第一个LLVM Pass从零开始改代码熟悉完构建之后就可以动手改代码了。很多初学者对“写 Pass”望而生畏其实核心概念并不复杂LLVM 对整个编译过程做了模块化每个优化或分析功能都作为一个 Pass 在 IR 上执行。你写一个 Pass就是在实现对 IR 的一段转换或分析逻辑。下面我用一个最简单但完整的例子带你走一遍整个过程。3.1 Pass 是什么它运行在哪一层先建立直觉。想象 LLVM IR 是一个三地址码的中间表示里面包含函数Function、基本块BasicBlock、指令Instruction。一个 Pass 能读取这些结构也能修改它们。打个比方IR 像是一篇文章Pass 就像是做重写和批注的人。你可以只读不改Analysis也可以大改特改Transformation。编译器在-O2时执行的一系列优化就是由几十个 Pass 组成的流水线。3.2 创建一个新 Pass 的完整步骤为了和社区推荐的方式保持一致我以“新的 Pass 管理器New PM”为准从一个简单的“打印函数名”的 Pass 开始。第一步在llvm/lib/Transforms/Utils/下新建一个文件比如HelloWorld.cpp#include llvm/IR/Function.h #include llvm/IR/LegacyPassManager.h #include llvm/Pass.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Support/raw_ostream.h using namespace llvm; namespace { class HelloWorldPass : public PassInfoMixinHelloWorldPass { public: PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { errs() Hello from: F.getName() \n; return PreservedAnalyses::all(); } }; } // namespace llvm::PassPluginLibraryInfo getHelloWorldPluginInfo() { return {LLVM_PLUGIN_API_VERSION, HelloWorldPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name hello-world) { FPM.addPass(HelloWorldPass()); return true; } return false; }); }}; } extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return getHelloWorldPluginInfo(); }这段代码虽然看着长但核心只有两个部分run函数是 Pass 真正工作的位置llvmGetPassPluginInfo是让opt能动态加载这个插件的入口。第二步把它编译成动态链接库。这里的关键是用clang或g并且要加$(llvm-config --cxxflags)和-fPIC -shared。具体命令大致如下clang -shared -fPIC HelloWorld.cpp $(llvm-config --cxxflags) -o libHelloWorld.so注意llvm-config是你构建出来的工具它在build/bin目录下。如果你的环境变量没配好可以用build/bin/llvm-config的完整路径。第三步准备一个测试用的 IR 文件test.lldefine i32 add(i32 %a, i32 %b) { %sum add i32 %a, %b ret i32 %sum }第四步用opt加载插件并运行 Passopt -load-pass-plugin./libHelloWorld.so -passeshello-world test.ll -o /dev/null如果一切正常你应该能在终端看到Hello from: add看到这行输出说明你的 Pass 已经成功跑起来了。3.3 为什么推荐插件方式而不是改源码构建你可能注意到上面的示例没有动 llvm-project 源码树里任何文件而是用-load-pass-plugin动态加载。这比在llvm/lib/Transforms/Utils/CMakeLists.txt里加一个add_llvm_component_library然后重新构建要快得多。因为修改源码树内文件会触发重新编译该目录下的所有内容而且构建时间很长。作为初期试验使用插件是非常高效的手段。不过这也有局限插件无法注册到默认的优化流水线中只能在命令行用-passes显式手动运行。如果你希望它成为-O2流水线的一部分那还是得走源码内修改。这个我们后面再展开。3.4 run 函数里能干哪些事上面的例子只实现了打印函数名这显然没有实际价值。真正写 Pass 时你通常需要遍历指令分析数据流进行模式匹配和重写。我给你一个重要原则先理解不可变性const再动手修改。LLVM 大量使用了 const 正确性和迭代器失效规则。在修改指令之前务必深入理解Instruction类的方法比如getOperand、setOperand、replaceAllUsesWith等。否则很容易在遍历过程中因迭代器失效导致崩溃。举一个简单的实用例子把所有add指令的操作数顺序交换。这听起来简单但要考虑浮点加法是否有交换律还要处理vector、fast-math标志等边界情况。实际写代码时你会慢慢体会到 LLVM 设计的严谨。4. 改完代码怎么验证lit测试和FileCheck的正确玩法很多新手写完 Pass跑一个简单例子就觉得自己完工了。这远不够。LLVM 社区有一套非常成熟的测试体系核心是lit和FileCheck。你要想你的代码被社区接受测试是必须的。即使不参与社区测试也能帮你防止后续改动破坏已有功能。4.1 lit测试的路径和语法在 llvm-project 源码里几乎每个子目录下都有一个test目录。比如llvm/test/Transforms/下就按 Pass 分类存放了很多.ll文件每个文件就是一个测试用例。这些文件并不是单纯的 IR而是用注释形式写上了 RUN 指令和期望输出。一个典型的 lit 测试长这样; RUN: opt %s -passeshello-world -S | FileCheck %s ; CHECK: Hello from: add define i32 add(i32 %a, i32 %b) { %sum add i32 %a, %b ret i32 %sum }解释一下; RUN:会被 lit 子进程执行。%s是当前文件路径-S表示输出 IR而不是二进制 bitcode输出会通过管道传给FileCheck %s。FileCheck 会扫描当前文件里的; CHECK注释并在实际输出中查找匹配的行。4.2 FileCheck 的关键规则FileCheck 的行匹配默认是“子串包含”不是“完整匹配”所以; CHECK: Hello from: add能在输出Hello from: add时匹配成功也能在输出Hello from: add_with_default时误匹配。所以测试时最好把 CHECK 行写得更具体一些。常用的匹配符号{{.*}}匹配任意字符。[[NAME:...]]定义一个变量后续可以用[[NAME]]引用用于检查一致性。比如你要检查计算后的值是否相同可以把变量捕获下来反复使用。{{[0-9]}}匹配数字。CHECK-NOT检查某个模式在指定范围内不出现用于排除错误的输出。CHECK-LABEL用于分割检查块通常放在函数定义之前防止匹配串行。新手最容易犯的错误是在 FileCheck 里写正则时把大括号直接写出来导致匹配失败。记住 FileCheck 的正则外层需要用双层大括号包裹比如{{.*}}。这是我踩过很多次的坑。4.3 运行测试的正确姿势构建完后build/bin/llvm-lit就是 lit 的入口。如果你想跑全部测试那时间很长。通常我们会指定某个子目录或文件build/bin/llvm-lit llvm/test/Transforms/HelloWorld如果只跑单个文件build/bin/llvm-lit -v llvm/test/Transforms/HelloWorld/hello.ll-v能显示更多细节包括 stdout/stderr。如果测试失败lit 会打印出实际输出和期望输出的差异。这个差异信息非常宝贵一定要仔细看而不是只盯着 PASS/FAIL 的结论。4.4 测试代码的“边界意识”写测试用例时不要只准备一个 happy path。编译器开发里最容易出的问题恰好是不常见但合法的输入。比如写一个循环展开 Pass你必须测试循环边界是变量时动态边界循环体中含有 continue / break循环体含有多个退出边带 vector 类型的循环几乎无限循环有无限循环保护的情况这些边界条件能逼着你思考实现的完备性。社区里那些经验丰富的 reviewer 在 review 你的 patch 时一定会问“如果遇到这种边界情况怎么办”如果你在测试里提前覆盖了就能省去大量沟通成本。5. 提交补丁之前必须懂的社区协作惯例如果你打算把自己的改动贡献给 llvm-project而不仅仅是自娱自乐那么了解社区的协作流程很重要。LLVM 社区有一套相对固定但可能对新手不太友好的规矩。按照惯例走你的 patch 才可能被 merge。5.1 代码评审是绕不开的环节llvm-project 的代码变更不直接 push 到分支而是通过 review 系统目前主要用 Phabricator也有一部分迁移到了 GitHub Pull Requests。在提交之前你需要用git diff或git format-patch生成 patch传到 review 平台然后等待社区成员 review。Reviewer 会给出很多意见——语气可能比较直接但都是对代码质量负责。千万别把 reviewer 的意见当成个人否定。我自己第一次提交 patch 时被一个很资深的维护者连续打了七八次回票原因包括代码风格不符合clang-format规范、缺少测试、注释不清晰、处理边界情况不对。虽然过程挺难受但改完之后整个代码质量确实明显上了一个台阶。5.2 提交信息要遵循规范提交信息要简洁但信息量足。LLVM 的惯例是第一行是概要使用命令祈使句句式例如[InstCombine] Fold (X - Y) Y to X然后空一行写详细描述。描述里要说明为什么做这个改动以及主要改动的逻辑。不要写“Fix a bug”这种没营养的话。5.3 用 clang-format 规范代码格式LLVM 有自己严格的代码风格缩进、空格、命名等都和常见工程不太一样。好在社区提供了clang-format工具配置就在.clang-format文件里。提交前对你的改动运行clang-format -i 文件这样能省去很多格式争论。但要注意clang-format只处理格式不管语义所以还需要自己检查逻辑。5.4 关联 bug 和文档如果修复了某个 issue在提交信息里加上Fixes #12345。如果是新功能同时更新相关文档通常在llvm/docs下社区特别看重这一点。没有文档的改动可能会被 reviewer 要求补文档才允许合入。这说明 LLVM 团队非常注重可维护性和可理解性。6. 新人在llvm-project里常见的坑和我的建议最后这个章节我想直接给新人提供一份“避坑地图”。这些问题如果你不提前知道遇到一个就会卡很久。6.1 不要一开始就啃后端LLVM 后端涉及目标描述文件TableGen、指令选择、寄存器分配、指令调度等非常复杂的东西新手直接上手很容易被绕晕。我建议先从Transforms和Analysis入手因为这两块的 IR 层逻辑相对直观你能快速看到自己的改动对编译结果产生的影响。等你理解 IR 的结构和 Pass 管理机制后再往后端走会顺畅很多。接着就是TableGen。LLVM 用 TableGen 这一 DSL 来写指令集描述和各类目标信息。不看 TableGen你基本看不懂后端代码。很多人在这上面栽跟头因为 TableGen 的语法和传统编程语言差别很大。我的建议是先掌握它的核心概念class、def、multiclass、foreach以及字段继承覆盖规则。把这个搞清楚再看XXXInstrInfo.td就不至于一脸懵。6.2 遇到问题先查官方文档再查邮件列表LLVM 的官方文档质量普遍不错尤其是llvm/docs/下的HowToWriteAPass.md、LangRef.md、CodingStandards.md等很多资料虽然年代久远但核心概念没变。社区邮件列表llvm-devarchive 里几乎可以搜到任何你遇到的问题。Stack Overflow 上关于 LLVM 的老问题也很多但现在很多答案已经过时。我的经验是如果你的问题和“新版 Pass Manager”有关直接看源码别依赖网上教程。6.3 修改IR之前先学会阅读IR写 Pass 的前提是你能熟练读懂 IR。建议你用clang -O0 -emit-llvm -S生成一个简单 C 文件对应的.ll逐行理解每条指令的含义。再试试opt -passesinstcombine,simplifycfg之后的 IR看看优化前后的差异。这个过程比看十篇文章都有效。我自己培养新人的时候会让他们做一个练习把下面的循环手动从 C 翻译成 LLVM IR再用opt验证int sum(int n) { int s 0; for (int i 0; i n; i) s i; return s; }这个练习看上去简单实际做完之后你对 basic block、phi 节点、分支跳转的理解会完全不同。6.4 版本的坑API变化比你想象的快LLVM 的 API 变动非常频繁特别是 Pass 相关的接口。网上很多旧文章还在用 legacy Pass Manager 的接口写 Pass但新代码已经全面使用 New Pass Manager。如果你照着旧文章敲代码很可能编译都过不去。建议你在git log里查看相关函数的演进或者直接用最新 release 分支的文档。遇到接口变化去llvm/include里搜头文件中的对应声明往往比搜索引擎更快、更准。6.5 贡献代码的心态准备最后聊一点心态。llvm-project 的开发节奏非常快维护者的时间和精力都很有限所以他们的回复通常很直接并不会像教程作者那样考虑你的感受。这其实是好事只要能写上几句话你就已经从“读代码的人”变成“参与项目的人”了。不要害怕被拒绝每个经验丰富的贡献者都是从一版版被否定的 patch 中走过来的。送你一个小技巧在提交 patch 前自己先跑一遍完整的相关测试至少是check-llvm把能自己发现的问题都解决掉这样 review 的效率会高很多reviewer 对你的印象也会好很多。6.6 如何高效阅读 llvm-project 源码读 LLVM 源码和读普通 web 项目不同。普通项目可能顺着函数调用链就能理解LLVM 则到处都是抽象接口和设计模式。我的建议是“从入口反推”选一个具体工具比如opt看它的main函数然后沿着命令行解析、Pass 执行、IR 输出的路径走一遍。这个过程中你会接触到llvm/Support/CommandLine.h的参数系统、PassBuilder的流水线构造、raw_ostream的输出机制等收获会比零散地看文件大得多。另外一个实用工具是clangd配合 VS Code 或 Neovim 能提供很好的跳转和补全体验。由于 LLVM 代码量大纯文本搜索效率太低必须靠符号索引。构建时生成 compile_commands.json然后配置 clangd 读取基本能做到“点到哪里跳到哪”。准备好这些工具你阅读和修改 llvm-project 的效率会提高不少。结束前再分享我的几个习惯说实话最开始接触 llvm-project 的半年我无数次动过放弃的念头。后来调整了学习方式才慢慢走上正轨。如果你也在这条路上摸爬给你几个最实在的小建议把 llvm-project 仓库放在固态硬盘上构建目录也放固态否则编译慢到你怀疑人生。构建时一定要开ccache哪怕你暂时用不上等依赖改动频繁时你就知道它能救命。手边准备一份 release tag 的文档和一份 main 分支的源码查旧接口时用 tag学新特性时看 main。不要一次性追逐太多目标。专注一个小功能从设计、实现到测试完整走通比同时打开十个半途而废的方向都有效。llvm-project 就像一片巨大的森林每一条小径后面都藏着新的风景。我现在依然觉得它复杂但至少不再害怕。希望这篇文章也能帮你把恐惧变成好奇在编译器的世界里找到属于自己的乐趣。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →