llvm-project 入门:核心结构解析与高效构建指南

发布时间:2026/9/19 8:49:47
llvm-project 入门:核心结构解析与高效构建指南 第一次拿到llvm-project这个仓库时我一度有点恍惚几百 MB 的源码压缩包、四五十个顶级目录、每个子目录都能单独开一场发布会这就是那个传说中支撑起无数编程语言和芯片架构的编译器基础设施。很多刚接触 LLVM 生态的人都会问同一个问题我到底该从哪一行代码开始看这篇文章我会从一个“踩过坑、重装过无数遍”的从业者视角把llvm-project这个 monorepo 的内部结构、构建流程、常见坑位一次讲清楚。不管你是想做自定义编译器 pass、想给一种新语言写前端还是只想搞清楚 Clang 和 LLVM 究竟是什么关系这篇文章都能给你一个落地的参考路径。1. 为什么 llvm-project 要把几十个项目打包进同一个仓库llvm-project不是一个普通的开源项目仓库它是 LLVM 官方把核心编译器、工具链、运行时库全塞进一个 Git 仓库后的“全家桶”。现在打开https://github.com/llvm/llvm-project你能看到llvm/、clang/、lld/、clang-tools-extra/等一串目录这在十几年前并不是这样。1.1 从 SVN 到 GitHub一次“搬家式”的结构调整早期 LLVM 使用的是 SVN 管理而且核心的llvm和前端clang分属不同的仓库各自维护各自的版本号发布时要靠脚本去对齐。听起来是件小事但对于每天都要跨项目改代码的开发者来说这简直是折磨——你改了 LLVM IR 的一个接口就得到另一个仓库里去同步修改 Clang 的调用点改完还得找版本节点对齐。2019 年 LLVM 团队把项目整体迁移到 GitHub并正式采用 monorepo 结构。“monorepo”这个词听起来高大上说白了就是所有子项目共用一个 Git 仓库、一份 issue 系统、一个 PR 流程。迁移之后仓库的体积肉眼可见地膨胀一个完整带历史信息的 clone 可能拉到几个 GB 的数据但它换来的代码协作体验提升是非常明显的。我印象最深的是这次迁移解决了“原子提交”的问题。以前改一个 LLVM 接口可能要拆成两个项目分别提交跨仓库的波纹处理非常痛苦现在一个 commit 里可以同时看到llvm/、clang/、clang-tools-extra/里的配套变更代码评审的人也能一眼看懂“你改了什么、为什么配套改这些”。1.2 monorepo 方案的三个核心收益如果你只是用户而不是维护者可能觉得“仓库大不大跟我有什么关系”。但 monorepo 的设计对下游开发者其实也有非常实际的好处。第一是版本一致性。整个工具链使用同一个 commit 号你编译出的 Clang、LLVM 库、lld 链接器、libc 标准库天然就是同一时刻的代码状态不会再出现“Clang 太新而 LLVM 库太旧”导致的灵异问题。第二是跨项目改代码成本骤降。评测一个 pass 效果时你常常需要同时修改 LLVM 核心、Clang 前端甚至 lld 的链接逻辑。在单仓库里这种改动可以在一次构建中完成不需要到处维护分支。第三是发布链路简化。LLVM 的发布流程基本就是打 tag、出 tar 包、跑测试。因为代码在一个仓库里release/17.x这样的分支可以直接覆盖全部子项目测试矩阵也更好管理。当然坏处同样肉眼可见磁盘占用高、首次 clone 等待时间长、对只想用某个子模块的人来说有额外的学习成本。后面我会专门讲 clone 和构建时怎么把这些成本降到最低。2. 目录结构几十个子项目先认清谁是谁说实话llvm-project刚 clone 下来时很多人的第一反应是找“主程序”结果发现没有任何一个目录叫“main.cpp”或“src”。整个仓库的顶层目录不是按模块分的而是按“工具链的组成部分”分的。把每个目录的名字和定位搞清楚后面看代码、写 pass、装环境都会顺很多。2.1 链路的主心骨LLVM 核心与 Clang最核心的目录自然是llvm/。LLVM 这个名字本身就表示“Low Level Virtual Machine”但今天你完全可以把“Virtual Machine”这个词忘掉它就是一套编译器基础设施包含中间表示IR的定义、优化 pass、指令选择、寄存器分配、目标代码生成等全套后端能力。平时大家说的“写一个 LLVM pass”基本就是在这个目录下的lib/Transforms/或llvm/lib/Passes/里做文章。clang/则是 C/C/Objective-C 的前端。它的任务是做词法分析、语法分析、语义分析把源码变成 AST再降级到 LLVM IR交给llvm/去优化和生成目标机器码。很多人分不清 LLVM 和 Clang可以这样记Clang 是“翻译官”把人类源代码变成 IRLLVM 是“优化师代码生成器”负责把 IR 变成高效的机器码。两者在 monorepo 里各占一席缺一不可。clang-tools-extra/这个目录也值得一提里面都是日常开发高频工具clang-tidy静态检查、clang-format代码格式化、clangdIDE 语言服务器都在这里。早年间这些工具独立发版后来一起搬进了 monorepo。2.2 容易被忽略但天天在用lld、compiler-rt、libc、mlir如果说llvm/和clang/是聚光灯下的主角下面这些目录就是幕后功臣大部分工具链使用者每天都在用它们却未必知道它们在llvm-project里。lld/是 LLVM 生态的链接器。它支持 ELF、Mach-O、COFF 等多种格式速度比传统 GNU ld 快出一个量级。我自己在大型 C 项目里实测过链接耗时从几十秒降到几秒是很正常的体验。这也是为什么很多构建系统优先选择 lld 作为默认链接器。compiler-rt/是 LLVM 的运行时库集合涵盖了很多底层能力比如 sanitizer 系列ASan/LSan/UBSan、profile 统计、内存内建函数实现。你在编译选项里加-fsanitizeaddress时真正干活的代码就在这个目录里。libc/、libcabi/、libunwind/组合起来就是一套完整的 C 标准库实现。想尝试在 Linux 上切换默认标准库或者研究 C 标准库内部实现这几个目录是绕不开的素材来源。mlir/是近年最热门的子项目之一。它提供了一套可扩展的多级 IR 框架很多 AI 编译器包括各类深度学习加速器工具链都基于它做算子调度和内存优化。国内很多芯片公司和互联网大厂的编译团队招人 JD 里写的 “熟悉 MLIR” 指的就是这个目录。至于flang/Fortran 前端、polly/循环变换优化、openmp/OpenMP 运行时属于特定场景才会深挖的方向。初次接触时看一眼顶层目录名知道它们是干什么的就好不必急于逐个精读。3. 从 clone 到跑起来完整构建流程很多人在这一步倒下的原因不是代码难而是构建姿势不对。这里我给出的是一套经过反复验证、适合 x86_64 Linux 环境的最小化流程。你不需要先通读任何文档跟着做就能得到一个可运行的 Clang 和 LLVM 工具链。3.1 动手前的三个准备磁盘、依赖、心态先说磁盘。构建llvm-project会吃大空间我建议至少准备 60GB 可用磁盘其中源码占 23GB构建产物通常会有 2040GB。如果你是全量 Debug 模式构建那 100GB 都可能紧张。构建前先跑df -h看磁盘剩余空间别等编译到一半才被报错打断那感觉实在酸爽。然后是系统依赖。以 Ubuntu 22.04 为例构建前需要确保已经安装了这些包sudo apt update sudo apt install build-essential cmake ninja-build python3 \ libz-dev libncurses-dev libxml2-dev如果你打算用 Clang 自身来编译 Clang也就是自举构建还需要先准备一个系统自带的 GCC 或 Clang 作为 bootstrap 编译器。CMake 版本建议 3.20 以上Ninja 是强烈推荐的构建系统比 Make 快且支持并行粒度更细。最后是心态准备不要第一次就追求全量构建。LLVM_TARGETS_TO_BUILD 如果默认全开所有架构后端都编译耗时和磁盘占用都会非常高。作为实验环境构建两三个你关心的后端架构就足够了。3.2 三条命令完成首次构建第一步拿到源码。为了节省时间和磁盘做浅克隆git clone --depth1 https://github.com/llvm/llvm-project.git想用某个稳定版本可以指定 branch比如git clone --depth1 -b llvmorg-17.0.1 https://github.com/llvm/llvm-project.git。浅克隆拿不到完整历史但对大多数人来说根本不需要完整历史。第二步创建构建目录并进入cd llvm-project mkdir build cd build第三步用 CMake 生成构建配置。下面这组参数是我常用的“初版方案”cmake -G Ninja ../llvm \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld;clang-tools-extra \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_ENABLE_ASSERTIONSOFF然后编译ninja -j4这里的-j4表示并行任务数。机器核多内存足就调大到-j8或-j16内存紧张就保持 4。大约等待 30 分钟到 1 小时取决于机器性能编译完成后build/bin/clang、build/bin/llc、build/bin/opt这些可执行文件就都出来了。3.3 CMake 配置参数背后的权衡构建参数不是随便填的每一个都有它的取舍逻辑。-DCMAKE_BUILD_TYPERelease。这是最容易忽略但最关键的一项。默认的构建类型可能是 Debug这种模式下整个 LLVM 没有做优化编译速度极慢运行也慢且生成的二进制巨大。对于第一次跑通全流程的场景Release 是绝对正确的选择。Debug 模式更多用于你想断点跟踪编译器自身逻辑的场景那属于进阶玩法需要更多内存和耐心。-DLLVM_ENABLE_PROJECTS。这里用分号分隔你想额外构建的子项目。必选至少有clang因为你最终要的是一个能编译 C/C 的工具链。lld强烈建议加上构建它花不了多少时间却能让后续链接阶段快非常多。clang-tools-extra则看你是否需要clang-tidy和clangd不需要可以不加能省一些编译时间。-DLLVM_TARGETS_TO_BUILDX86。很多人第一次构建时忽略这个参数结果默认把 X86、ARM、AArch64、RISC-V、Mips、PowerPC 等十几套后端全部生成编译时间直接翻几倍。我只写X86是告诉 CMake 只需要为当前平台生成后端。这个参数起到的效果是“让构建集中在你真正用的架构上”而不是“让所有架构都能被支持”。-DLLVM_ENABLE_ASSERTIONSOFF。关闭断言能大幅提高编译产物运行速度。但要注意如果你后续打算自己开发 pass 或修改 LLVM 源码断言其实是非常友好的“帮你及早发现错误”的机制。实验阶段我一般开着正式跑性能基准测试再关掉。-DLLVM_PARALLEL_LINK_JOBS这个参数在实际构建时也很有用。链接是最吃内存的环节如果内存不太够但 CPU 核数很多可以限制它比如-DLLVM_PARALLEL_LINK_JOBS2避免多个大二进制同时链接直接 OOM。4. 常见问题与排查技巧实录任何高门槛的开源项目九十步都容易卡在最后十步。构建llvm-project也同样报错信息五花八门但根因往往是几个固定的坑。下面这份速查表来自我自己几十次构建经验的浓缩。4.1 高频事故速查表问题表现常见根因解决办法clone 到中途卡住或失败仓库体积大、网络波动使用--depth1浅克隆必要时切换镜像源CMake 报错 “A compatible version of cmake is required”系统 CMake 太老apt 安装新版本或用 pip 安装 cmake 到用户目录编译早期大量 “cc1plus: out of memory”并行任务数过大、内存不够降-j数值同时设置LLVM_PARALLEL_LINK_JOBS2链接阶段 “nvalid memory pointer” 或 “undefined reference”不同编译器 ABI 冲突尽量使用同一套编译器构建自举构建时保证 C/C 编译器一致构建到 90% 时磁盘满build 目录巨大清理build/下CMakeFiles缓存为构建目录单独分配足量磁盘运行clang --version提示 GLIBC 找不到构建机器比运行机器系统新在目标环境上重新构建或用更老的系统镜像构建opt加载自定义 pass 报错Pass is not registered没有使用新 PM 接口参考 4.2 改用 PassBuilder 注册方式这里我想特别强调一下并行度和内存的关系。很多新手用ninja -j32在 16 核机器上构建结果 32 个编译器同时启动每个吃 1~2GB 内存机器直接卡死或 OOM。把-j设成 CPU 核心数的一半或者保持默认反而能稳定跑完。“只要编译快”的心态在这里不适用稳定性优先。另一个常见的隐藏问题是你系统里可能存在多个 CMake 或 Ninja 版本用了某些老版本时配置会产生诡异行为。建议构建前先跑cmake --version和ninja --version确认版本再决定要不要升级。4.2 想改 Pass 做实验看懂最小行动路径很多人 clonellvm-project不是只是为了编译 Clang而是想在 LLVM IR 上写一个自定义优化 pass 来实验。对于这类需求我建议不要一开始就在llvm/源码树里新建文件而是用动态库方式写一个独立的 pass 插件。这样修改后只需重新编译这个小插件不用重编整个工程。具体来说新建一个目录里面放一个非常简单的源码文件#include llvm/IR/Function.h #include llvm/IR/IRBuilder.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Support/raw_ostream.h using namespace llvm; namespace { class HelloPass : public PassInfoMixinHelloPass { public: PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { errs() Hello from function: F.getName() \n; return PreservedAnalyses::all(); } }; } // namespace llvm::PassPluginLibraryInfo getHelloPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, HelloPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name hello-pass) { FPM.addPass(HelloPass()); return true; } return false; }); }}; } extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return getHelloPassPluginInfo(); }这是标准的 New PM 写法。编译这个插件时需要让编译器找到 LLVM 头文件和库的路径。假设你已经构建完上面 3.2 节的工程头文件在llvm-project/llvm/include库在llvm-project/build/lib那么命令大致长这样clang -shared -fPIC -stdc17 hello_pass.cpp \ -I/path/to/llvm-project/llvm/include \ -I/path/to/llvm-project/build/include \ -L/path/to/llvm-project/build/lib \ -lLLVM-17 \ -o hello_pass.so然后用编译好的opt加载它/path/to/llvm-project/build/bin/opt -load-pass-plugin./hello_pass.so \ -passeshello-pass /path/to/some.ll -o /dev/null如果一切正常你会看到每个函数名都从终端打印出来。这就是一次完整的自定义 pass 开发闭环。后续想深入研究可以把断点打到 PassBuilder 的回调里或者用-print-after-all看优化每一步的变化。最后再说两句挨过揍才明白的话我个人在实际构建和使用llvm-project的过程中最大的体会是不要一上来就贪多贪全。第一次构建就想着 “把 RISC-V 后端也编进去”“顺便把 MLIR 也开了”结果往往是构建时间翻倍、内存不够、报错一片。正常的学习曲线应该是先建立最小可用闭环再一步步往里面加东西。如果你真的想在这个领域长期深耕我建议你专门找一块大硬盘把完整历史和所有子模块都拉下来没事翻一翻llvm/lib/Transforms/下面的代码再对应着看clang/lib/CodeGen是怎么生成 IR 的。这比在网上看任何二手教程都来得直观、扎实。编译器这条路没有捷径但llvm-project这个仓库本身就是最好的那条路。