ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

llvm-project源码解析:从目录结构到构建调试与贡献指南

llvm-project源码解析:从目录结构到构建调试与贡献指南 “llvm-project”这个名字对很多刚开始接触编译器、或者已经写了几年业务代码但没深入过编译原理的同学来说是个既熟悉又模糊的存在。你大概率见过它知道它是 LLVM 的代码仓库甚至可能在 GitHub 上 star 过但真当你需要进入这个仓库做点什么的时候——不管是想研究优化 pass、给 Clang 提一个 warning还是出于好奇想把这个超大项目从源码构建一遍——你才会意识到这不是一个“下载下来看看”就能搞定的项目。它可能是目前人类开源社区里最复杂的 C 工程之一。我不夸张地说llvm-project 里面包含的不仅是编译器前端、中端、后端还包括调试器、链接器、标准库、运行时、向量化库、还有一整套用于构建编译器基础设施的框架。读完这篇文章不说让你成为 LLVM 专家但至少你会对整个仓库的布局、从哪里下手、怎么构建、怎么调试、怎么提交 patch有一个清晰且能直接落地的认识。1. 项目整体认知与领域定位1.1 它不只是一个编译器很多人以为“LLVM 就是编译器”这句话准确但不完整。llvm-project 是一个“编译器基础设施”集合。什么叫做基础设施就是说它不仅提供了一个能干活的产品比如 Clang 编译器更重要的是提供了一套被无数其他项目所依赖的底层能力。你可以把 LLVM 理解成一块“编译器乐高底板”Clang、Flang、lld、libc 这些具体工具都是在这块底板上拼出来的成品而其他像 Rust、Swift、Julia、GPU 编译、FPGA 综合工具、人工智能推理引擎也都在这块底板上面各取所需。这个仓库的边界在近几年越来越大。现在它的顶层目录里除了传统的llvm/和clang/还要加上lld/、lldb/、compiler-rt/、mlir/、flang/、polly/、libc/、libcxx/、libcxxabi/、libunwind/等十几个子项目。跟早年“一个 monorepo 装全家桶”的思路不同现在 LLVM 的开发模式已经默认把所有组件放到同一个仓库、同一个 commit 下做协同演进。这样的好处很明显跨组件的 API 变更不再需要繁琐的“先改 A 再改 B 再改 C”的独立 PR 流程而是可以像改一个工程内的一个模块那样直接完成原子修改。也正是因为这个原因如果你要参与 LLVM 开发或者你只是想在内部工具链里基于 LLVM 做一些二次开发你几乎必然要面对“整个仓库”而不是某个独立小项目。过去那种“我只想弄个 Clang 前端不想编译其他部分”的操作变得越来越困难因为架构已经高度耦合。所以我的建议是别抗拒 monorepo与其纠结“为什么这么大”不如先把它当成一个整体来理解。1.2 为什么值得读源码/参与贡献参与 LLVM 不等于“给世界上最难的编译器贡献代码”它有非常多种进入方式。我之前在社区里见过不少贡献者有的就是从修一个测试用例格式开始的有的是从改进llvm-mca的文档注释开始的真正一上来就改 Instruction Selection 的人反而是少数。这个项目最大的魅力在于它的代码质量和模块化程度在开源界属于顶级水平阅读它的源码就像读一本活的编译器设计教科书。你在大学里学过的词法分析、语法分析、中间表示、数据流分析、寄存器分配这里都有最完整、最“生产级”的工程实现。加上现在 LLVM 不只是传统编译器的代名词它背后的 MLIR 生态已经成了机器学习和芯片设计领域的“事实标准”。很多做 AI 编译器、做异构计算、做自动化芯片验证的团队每天都在跟 MLIR 里的linalg、tensor、async这些方言打交道。你如果能把 llvm-project 的源代码读明白收益不只是“我会写编译器”而是你等于掌握了现代计算系统里非常底层也非常值钱的那一类工程能力。2. 源码目录结构与仓库布局2.1 顶层目录到底是怎么分工的很多新手刚打开 llvm-project 仓库第一反应是这么多目录先看哪个我列几个最核心的并告诉你它们各自解决什么问题llvm/核心包含整个中间表示IR、优化器、目标描述、代码生成、向量化等底层实现。无论你以后用哪个组件都得依赖这部分。clang/C/C/Objective-C 的编译器前端。它的任务是把源码变成 LLVM IR并负责产生警告、错误诊断和语法树相关的所有工作。lld/一个新的、内置的链接器。现在很多 Linux 发行版、Android、iOS 工具链都把 lld 作为默认链接器之一速度比传统 GNU ld 快不少。lldb/调试器实现类似 GDB但是基于 LLVM 的库设计天然能读取很多 LLVM 格式的调试信息。compiler-rt/编译器运行时库提供各种底层支持比如sanitizer也就是 ASan、UBSan、TSan 这些东西。你要做内存安全检测离不开这个目录。mlir/MLIR一套构建“编译器编译器”的框架。你可能用到 MLIR 的地方包括深度学习编译、HLS高层次综合、算法优化等。flang/Fortran 前端。别小看它科学计算领域还有很多 Fortran 代码它正逐渐成为“LLVM 全家桶”里的重要成员。libcxx/、libcxxabi/、libunwind/C 标准库的实现以及 C ABI 支持。默认 Clang 在 Linux 上会用系统的 libstdc但你可以把这些库编译出来作为自定义工具链的配套。polly/一个基于多面体模型的循环优化器功能很强但没有成为默认优化流程的一部分更多是作为一个研究平台存在。这些目录之间不是孤立的比如 Clang 会大量使用llvm/下面的 ADT比如SmallVector、StringRef、llvm/IR、llvm/CodeGen等模块。你读代码时经常会看到一个文件里既包含 clang 独有的逻辑又频繁调用 llvm 的通用基础设施。2.2 llvm/include 与 llvm/lib 的目录约定拿llvm/子项目来说它的核心代码分成include和lib两个大块。include/llvm下面放的是所有对外暴露的头文件lib下面放的是实现。有一个细节值得记住这两个目录的结构是严格对应的。你在include/llvm/IR/IRBuilder.h里看到的IRBuilder实现一定在lib/IR/IRBuilder.cpp。掌握了这个约定你在阅读任何一个自己没见过的新模块时会非常轻松只要有头文件路径你就能推断出实现文件的路径甚至可以推断出一个新功能应该放在哪里。llvm/下面还有tools/、test/、unittests/、examples/等目录。tools/是一系列可执行文件opt、llc、llvm-dis、llvm-as、llvm-nm这些命令行工具都在这。很多人学习 LLVM 就是从在tools/opt里跑一个 pass 开始的。test/则是整个项目的核心测试文件它用的是litFileCheck这套测试框架。你在改动代码后需要保证ninja check-llvm、ninja check-clang这些测试能通过。这种目录组织方式也直接影响了社区对代码变更的审查文化。你在 code review 里经常能看到 “Please add test” 这样的要求。只要涉及行为变化教程文档不重要、注释可以补但测试是必须有的而且测试必须放在test/中对应的子目录下。这个习惯对新人来说是最需要适应的。3. 构建系统与实操配置3.1 构建前的环境准备LLVM 的构建对机器配置有一定要求尤其是当你第一次尝试完整构建的时候请务必做好心理准备这是一个比较耗时且吃内存的过程。官方推荐的内存底线是 8GB实际上你如果要同时构建 Clang 和 lld16GB 只是起步32GB 会更舒服一点。磁盘空间上一个普通的 Release 构建占 20GB 左右很常见Debug 构建因为包含大量调试符号甚至会奔着 40GB、50GB 去。系统层面我推荐先准备好这些依赖编译器的编译器LLVM 自身是用 C17 写的你需要一个比较新的编译器来编译它。Linux 上推荐 GCC 7 以上或者 Clang 12 以上macOS 上直接用 Xcode 自带的 Clang 就行。CMake建议 3.20 以上版本。各 LLVM 版本对 CMake 的最低版本要求不同但尽量新一点总没错。Ninja强烈建议用 Ninja 作为构建后端比 make 快出好几个量级。命令行敲-G Ninja就好。Python 3lit测试框架要用到它。在某些 Linux 版本上可能还需要安装zlib、libxml2这些开发包。如果构建过程中报找不到某个头文件优先检查对应依赖有没有装全。3.2 第一次完整构建的命令我以一个相对保守但实用的配置为例展示一下我平时构建 LLVM 时最常用的一份 CMake 参数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;clang-tools-extra \ -DLLVM_TARGETS_TO_BUILDX86;AArch64 \ -DLLVM_ENABLE_ASSERTIONSON \ -DCMAKE_C_COMPILERclang \ -DCMAKE_CXX_COMPILERclang \ -DCMAKE_CXX_FLAGS-marchnative \ -DLLVM_PARALLEL_LINK_JOBS2 ninja -j$(nproc)我解释一下这里面几个关键参数因为很多人就是馋 CC 没领会埋伏。LLVM_ENABLE_PROJECTS决定你需要构建哪些“上层项目”。clang、lld、clang-tools-extra是比较常规的选择。注意一个坑在比较新的版本里像libcxx这类库的构建已经转成了LLVM_ENABLE_RUNTIMES如果你把libcxx放进LLVM_ENABLE_PROJECTS构建系统可能会给你一个警告甚至直接报错。这个变化让不少人踩过坑现在如果你需要编译 C 标准库应该用类似-DLLVM_ENABLE_RUNTIMESlibcxx;libcxxabi;libunwind的配置。LLVM_TARGETS_TO_BUILD指定目标架构后端。默认会开启 X86、AArch64、ARM、PowerPC、RISCV 等等一大堆会明显拉长编译时间。如果你只是学习或者做 x86 上的开发只留X86足够我顺手加上了 AArch64因为现在做交叉工具链场景更多。LLVM_ENABLE_ASSERTIONS是个很微妙的选择。把它设成 ON你会得到一个更便于调试、能在运行时检查很多不变量的构建但编译器本身会变慢设成 OFF你会得到一个“干净”的发布版但调试 bug 时容易失去很多线索。开发 LLVM 的人几乎都会开 ON普通使用场景则一般关掉以获得性能。最后一个LLVM_PARALLEL_LINK_JOBS2非常实用。LLVM 的链接阶段特别吃内存尤其是 Debug 模式一次性链接几百个目标文件任何一个 linker 进程吃了 5GB、10GB 内存都不奇怪。限制并行链接任务数可以防止机器在链接阶段被永久卡死。3.3 常用构建参数与组合打法我见过不少同学在选构建类型时很随意结果后来不断返工。这里补充一个“组合拳”视角你其实不一定需要每次都用同一个构建目录可以准备多个 build 目录分别对应不同的目的。build-releaseCMAKE_BUILD_TYPEReleaseLLVM_ENABLE_ASSERTIONSOFF用途是得到一个可发布版本的二进制比如你要拿来替代系统默认的 Clang或者跑 benchmark。build-debugCMAKE_BUILD_TYPEDebugLLVM_ENABLE_ASSERTIONSON用途是排查问题、单步调试编译器本身。build-reldebCMAKE_BUILD_TYPERelWithDebInfoLLVM_ENABLE_ASSERTIONSON这个是我个人最常用的开发配置。它比 Debug 快不少又保留了调试符号和断言配合 gdb/lldb 用起来非常香。从性能角度再提一句如果你只是写个 pass 实验一下不需要默认开启所有目标架构。我们平时做实验的机器如果不涉及其他架构一般都会加-DLLVM_TARGETS_TO_BUILDX86编译速度会有质的提升。ninja本身也会做增量构建所以日常开发时其实只要第一次“熬”过去后面改一小块代码再编译就很快了。4. 核心组件拆解与工作场景4.1 LLVM CoreIR、Pass 和 CodeGenllvm/核心的数据库是全套的中间表示IR和优化器。所有前端Clang、Flang、Rust 等最终都会产出 IR所有后端也会从 IR 开始做指令选择、寄存器分配、指令调度。理解 IR 是你深入任何 LLVM 相关工作的第一步。IR 有三种表示形式文本格式.ll、二进制位码格式.bc和在内存中的 C 类结构。很多初学同学会直接看.ll文本文件我建议你一定要学会使用llvm-as、llvm-dis和opt这几个命令行工具。比如你把一个 C 文件编译成 IR可以这样clang -emit-llvm -S foo.c -o foo.ll opt -passesmem2reg foo.ll -S -o foo.mem2reg.llopt -passes是跑优化 pass 的入口。在 LLVM 17 之后新 Pass Manager 已经全面接管opt命令的-passes参数是主线旧的-foo形式已经移除。如果你想看 IR 优化前后的差异用-print-after-all或者-print-before-all配合llvm-diff都很方便。从代码结构上来讲llvm/lib/Passes/PassBuilder.cpp是优化流水线的核心文件之一。你现在看到的 O2、O3 优化级别并不是硬编码在某个 super pass 里而是各个 module、function、loop 级别的 pass 按照顺序组合而成。如果你想自己写一个 function pass最简单的方式是在PassBuilder.cpp中注册一个 pipeline 扩展点然后在opt里就能用-passesmy-pass来测试。我个人觉得这个流程对新手非常友好能让一个 pass 在一两个小时内跑起来。4.2 Clang前端和诊断信息Clang 可能是你平时最熟悉的 LLVM 产品。它提供了非常优秀的语法树AST、词法分析和语义分析。你日常写的代码在clang里的处理流程大概是词法分析产生 token语法分析产生 AST语义分析在这个阶段解决类型检查和重载决议然后通过 AST 到 IR 的降级codegen生成 LLVM IR。别被这些名词吓到工程实现远比教科书的描述要细但大方向就是如此。如果你想要亲手感受 Clang 的前端处理可以试试clang -Xclang -ast-dump -fsyntax-only foo.c它会输出一棵非常完整的语法树。有时候排查宏展开、模板匹配的问题这个命令比单步调试还好使。Clang 还有一个亮点就是它的诊断信息特别友好。你写错一个类型它不光告诉你“这里错了”还会标出“为什么错”甚至给出建议修改。这些诊断逻辑很多都实现了在非常深的语义分析代码里比如SemaExpr.cpp和SemaOverload.cpp体积非常大。但这也正是 Clang 能在市场份额上不断超过 GCC 的原因之一用户体验优秀工程结构清晰。4.3 LLD现代链接器链接器在传统认知里是个很“无趣”的模块但 lld 彻底改变了这种印象。它把原本基于脚本的 GNU ld 模式变成了一个库化、编译友好的链接器。在使用体验上大多数情况下只需要写clang -fuse-ldlld它就会用 lld 完成链接。从代码层面看lld 的 ELF 支持在lld/ELF/目录下涉及“符号解析”、“输入文件描述”、“输出节布局”、“重定位写入”等逻辑。很多人第一次看lld源码觉得很难一个重要原因是你对 ELF 文件格式不够熟。我建议你先补充一点 ELF 的基础知识section、symbol、relocation、dynamic section、GOT/PLT 是怎么工作的再回头看 lld 的代码就不会觉得是天书了。一个比较有意思的模块是 lld 的并行化。它跟很多传统链接器不同在设计上非常注重多线程性能所以在现代大项目链接场景下极快。这其实也体现了 LLVM 项目一贯的哲学不只是把一个工具做好而是把一个关键基础设施在工程上做到“库化”和“可复用”。4.4 MLIR 与跨领域生态MLIRMulti-Level Intermediate Representation最近几年热度极高它的定位已经超出了“编译器优化”更偏向“领域特化的编译器构建框架”。MLIR 的核心思想是提供很多可组合的 IR 抽象层每个抽象层被称为“dialect”。比如func这个 dialect 表示普通函数控制流linalg表示线性代数运算tensor表示 Tensor 操作scf表示结构化控制流。你可以把多个 dialect 混在一个 IR 里然后通过 pass 逐步把高层次的 dialect 下降到底层。如果你从事 AI 编译器相关的工作MLIR 一定会出现在你的工具链中。我之前做 GPU 算子编译时整套优化流程就是把 Torch 模型先是下降到tosa或linalg然后经过triton/gpu这些 dialect 再生成 PTX 码。这套流程几乎每一步都在 MLIR 的框架里完成。因此哪怕你暂时不碰传统 C/C 编译只要你在计算底层工作MLIR 都值得你投入时间。5. 调试工具链与测试方法5.1 用 lit 和 FileCheck 写测试在 llvm-project 里写测试你几乎绕不开 lit 和 FileCheck。lit 是测试驱动器负责执行测试命令、检查输出、汇总结果。FileCheck 是输出检查器你在RUN:行里面会看到类似CHECK:、CHECK-NEXT:这样的标记FileCheck 会从命令输出中查找这些标记是否按预期出现。早期我写测试时很容易犯一个错误把所有预期输出完整地贴到CHECK里结果代码一改测试就成片红。后来我才理解主流 LLVM 测试追求的是“只验证你关心的行为”不要抓大而全的输出。比如你测一个 pass 是否消除了某个指令你只需要在对应位置加CHECK-NOT: add或者CHECK: ret而不要把你中间每一步都写进去。这样既能保证测试的针对性又能降低后续维护成本。一个典型的 lit 测试文件长这样; RUN: opt -passesinstcombine -S %s | FileCheck %s define i32 test(i32 %x) { %a add i32 %x, 0 ret i32 %a } ; CHECK-LABEL: define i32 test ; CHECK-NOT: add%s表示当前文件路径FileCheck %s表示检查脚本也是同一个文件。这个模式在测试优化 pass 时非常常用。你可以通过ninja check-llvm-unit跑单元测试通过ninja check-clang跑 Clang 的集成测试。原则上每个代码变更都应该配套至少一个测试这是 LLVM 社区不可妥协的规矩。5.2 调试 LLVM 自身的方法调试 LLVM 自身是一门手艺跟你调试普通应用有区别。首先opt这类工具绝大多数情况下接收的是.ll或.bc文件输入是可复现的你可以用 lldb/gdb 直接放断点。其次LLVM 自己提供了非常强大的调试日志机制。最简单的是用-debug它会打印大量 debug 信息但输出量很惊人。建议配合-debug-onlypassname来筛选比如opt -passesloop-vectorize -debug-onlyloop-vectorize foo.ll -S -o /dev/null这个命令只会输出跟loop-vectorize相关的调试信息调试体验非常好。如果问题是出现在 Clang 后端生成某个特定指令时用llc配合-print-after-isel、-print-after-regalloc可以让你在这种后端 pipeline 的不同阶段看 IR 和指令序列。这套日志粒度很细但一旦你习惯了会大大提升你排查问题的效率。6. 社区协作与为项目做贡献6.1 从找到“第一次贡献”开始很多朋友想给 LLVM 做贡献但总有一种“无从下手”的焦虑。我给出的建议是三步走。第一从你真实的痛点和日常使用 bug 入手。你在用 Clang 时遇到一个不够友好的错误诊断、一个很诡异的警告误报或者一个可以在文档里补充的地方这些就是最好的起点。第二在 GitHub 的 issue 区、Phabricator 或 GitHub 的 pull request 区搜索带 “good first issue” 或 “beginner” 标签的问题。这里有专门为新手准备的坑位。第三先在本地把代码改出来、把测试补好、把格式调对然后再去了解社区的交流流程。社区协作最重要的技能其实是沟通能力。LLVM 的 code review 文化非常讲究“挑剔”但这并不是故意为难人而是因为编译器代码的影响面特别大。任何一个小改动都可能影响几十万行代码的编译结果所以评审者会偏保守。你提交 patch 前一定要写好 commit message说明问题背景、复现方式、修改思路、测试结果。这看起来像例行公事但它恰恰是社区信任建立的基础。6.2 实际贡献流程中的几个细节用 git 管理 llvm-project 源码时除非你已经是长期维护者否则不要直接从 master 开分支再推回 master。标准做法是 fork 一份到自己的账号下然后按 feature 分支开发开发完提 pull request 等待 review。代码合并到上游之前你需要遵守 clang-format 的代码风格。git clang-format是一个非常方便的命令它会在你上次提交的基础上对修改部分做格式化。如果你忘记跑 clang-formatCI快速可能直接给你挂掉。测试也必须用 lit 在本地跑过可以只跑你涉及的那一层。如果你改了llvm/lib/Transforms下的 pass至少跑一下相关的ninja check-llvm-transforms。社区对 commit message 同样有要求。规范的模板大致是第一行[folder] short description空一行后写详细说明再空一行写测试计划。比如[InstCombine] Simplify add with zero operands。这种命名方式能让整个 git log 按目录类别快速筛选也有利于后续的自动化工具跟踪。7. 常见问题与排查技巧实录7.1 构建阶段的高频问题我在多年的 LLVM 构建经验里碰到过不少几乎人人都会撞上的问题这里做一个速查记录症状常见原因解决方案链接阶段内存爆炸并行链接任务过多 / Debug 模式符号太多调低LLVM_PARALLEL_LINK_JOBS改成 Release 或 RelWithDebInfolibcxx设置进LLVM_ENABLE_PROJECTS后报错新版要求把库作为 runtime 构建把这些库转移到LLVM_ENABLE_RUNTIMESninja编译到一半报“No space left on device”build 目录过大清理构建产物用磁盘更大的分区或挂载卷某条头文件找不到缺少依赖开发包安装zlib1g-dev、libxml2-dev等或确认 CMake 路径配置修改llvm/下代码后clang工具没有生效没重装/没重编特定 target直接重跑ninja clang或ninja installopt -passes里不识别你注册的 passPass 没被链接进opt/未注册到 PassBuilder检查 Pass 库是否加入到LLVM_LINK_COMPONENTS或 target 依赖最让我意外的其实是第一个 “内存爆炸” 问题。很多新手以为“我在 12 核机器上就ninja -j12”结果链接阶段直接把机器跑死。我自己的经验是链接任务一定要降并发编译任务反而可以拉满。所以我都会在构建参数里把LLVM_PARALLEL_LINK_JOBS单独设小。另一个很实用的技巧是启用ccache用-DCMAKE_C_COMPILER_LAUNCHERccache -DCMAKE_CXX_COMPILER_LAUNCHERccache配置。第二次构建速度能提升好几倍几乎所有老 LLVM 开发者都会开。7.2 调试与测试阶段的避坑清单调试 LLVM 时最容易踩的坑是分不清“输入的问题”和“编译器的问题”。比如你写了一个 pass发现输出 IR 不对直接怀疑 pass 有 bug。但进一步排查后发现是opt工具本身读入的.ll文件有陷阱或者是之前的 pass 把 SSA 形式搞乱了。我现在的习惯是每次实验都先用opt -print-after-all或者-print-changed把整个优化链路的变更加载出来看先定位在哪一步变了样再深入那一层的实现。测试阶段的高频问题主要集中在 FileCheck 语法上。CHECK-NOT的语义常常被误解它检查的是从上一条CHECK行到下一个CHECK行之间没有出现某个模式并不是“整个文件都不许出现”。所以很多人写CHECK-NOT: blabla却总失败就是因为没搞懂它的扫描区间。同样CHECK-DAG适合匹配顺序不确定的行但不要滥用因为它会显著降低测试的确定性。还有一个细节可能影响测试结果测试文件里那些%开头的标识符。在 lit 脚本中%s、%t这些是 lit 的内置变量而在.ll文件里%是寄存器名。这两个概念容易混淆。如果你写; RUN: opt -passes... %s -o %t.ll这是正确的lit 会把%s替换成当前文件路径、%t替换成临时文件名。如果手滑写成%xlit 会报错或者不替换。结尾一点个人经验我在 LLVM 上折腾了几年最大的感慨是这个项目不像普通开源库那样 “装个包就能跑”它的门槛确实存在但这个门槛更多是在“信息组织方式”上而不是在“某个具体算法有多难”上。只要你愿意花一个周末认真看一下目录结构、跑通一次构建、读一个简单 pass 的实现你就会发现后面的路越来越顺畅。如果真的让我给新手一个最实用的建议那就是永远不要只跟着教程看挑一个你真正在业务里会碰到的编译问题带着问题去源码里撕开一条口子往里钻这比读一百篇导论文章都管用。
返回列表