首页
/
行业洞察
/
正文
INDUSTRY INSIGHT · 深度
从零构建LLVM项目:核心概念、Pass开发与生态实践
📅 2026/9/20 12:25:01
✍️ 爱科研究院
👁 阅读 3,247
我第一次完整地构建 llvm-project 的时候还是在一台 8GB 内存的旧笔记本上编译到一半风扇狂转我以为电脑要炸了。那时候我对它的认知仅仅停留在“可以自己编译一个 Clang”但真正把这套 monorepo 拉下来之后我发现自己站在了一个完全不同的世界里。llvm-project 早就不是“编译器”这三个字能概括的东西了——它是一整套编译器基础设施是现代工具链事实上的底座也是很多人学了几年计算机之后才发现自己根本没真正碰过的一块硬骨头。这篇文章不打算给你念官方文档而是从我自己的实操视角出发把这个项目掰开揉碎仓库里到底有什么、怎么从源码构建一套能用的工具链、LLVM 的核心设计是怎么运作的、怎么从零写一个能跑起来的 Pass、以及 llvm-project 生态里那些容易被忽略但极其好用的宝藏。无论你是想入门编译原理、想给开源社区提补丁还是被公司内某个基于 LLVM 的改造任务折磨已久这篇都能给你一条相对顺畅的路线。1. 这个仓库究竟装了什么llvm-project 的目录地图1.1 别把 LLVM 当成“一个编译器”绝大多数人第一次接触 LLVM是因为 GCC 用得不顺心或者听说 Clang 编译报错信息更友好。但如果你打开 llvm-project 仓库的根目录会发现里面不仅有 llvm 和 clang还有一大排看起来跟“编译器”关系不大的目录libcxx、compiler-rt、lld、polly、mlir、flang、openmp、clang-tools-extra。这里的核心逻辑是LLVM 不是“一个编译器”而是“一套可以用来搭建编译器的积木”。Clang 只是基于这套积木搭出来的一个 C/C 前端LLVM 提供的真正核心是中间表示IR、优化管线、目标后端、链接器、运行时库这些基础设施。你完全可以基于它写出支援一种新语言的前端、一种新芯片的后端甚至和编译完全无关的静态分析器。整个项目从 2019 年前后切换成统一的 Git monorepo 之后看代码和跨子项目改动都方便了很多。以前用 SVN 各自拉取 llvm、clang、libcxx 不同仓库改一个跨仓库的 API 要协调半天版本现在一条git clone https://github.com/llvm/llvm-project.git就能拿到全量源代码配合cmake里的开关按需构建这也是新手最友好的入口。1.2 子项目速览与各自的生态角色我先用自己的话把几个最常用目录的功能和适用场景理一遍你可以把它当成一份“带路手册”。目录角色最常见的用途llvm核心基础设施包含 IR、优化器、目标后端等opt、llc、llvm-as 等工具clangC/C/Objective-C 前端日常编译用的 clang/clangclang-tools-extra基于 Clang 的独立工具clang-tidy、clangd、include-what-you-uselld高性能链接器取代系统 ld链接速度优势明显libcxx / libcxxabiC 标准库实现换用新标准库、嵌入式场景compiler-rt运行时支持库ASan、UBSan、MSan、libFuzzerlldb调试器调试 LLVM IR 或替代 gdb 的场景polly基于多面体模型的循环优化高性能计算、自动并行化mlir面向编译器基础设施的“编译器框架”AI 芯片编译器、领域专用语言flangFortran 前端科学计算、老代码迁移openmpOpenMP 运行时与并行支持多线程、GPU offload这些目录之间的关系可以类比成一个装修队clang 是前台的“客户经理”负责听清楚你的需求解析源代码把需求翻译成 LLVM IRllvm 核心是“施工队”在 IR 上做优化和代码生成lld 是“监理”把生成的目标文件拼成最终可执行文件compiler-rt 则是“售后”负责运行时检查和问题溯源。理解了这个分工你以后看到某个工具报错时就能快速定位问题到底出在哪个环节。2. 自己构建一次从克隆到可用的工具链2.1 磁盘、内存和耐心构建前的心理建设很多人头一次构建 llvm-project 失败根本不是命令敲错而是没评估好自己的机器。先说空间完整的 git 源码拉下来大约 2GB 以上如果你像我一样喜欢把历史记录也留下那占用会更高。编译产物会更夸张——一个开启大部分项目、Debug 带符号的构建目录轻松超过 100GB。所以第一个建议用--depth 1做浅克隆只保留当前版本git clone --depth 1 https://github.com/llvm/llvm-project.git cd llvm-project再说内存。比较稳妥的最低配置是 16GB 内存否则链接阶段可能会出现内存不足或者编译速度慢到你想放弃。8GB 机器不是不能跑但建议少开几个浏览器标签页也别同时跑-j$(nproc)满并行度。搭配ninja比make更好这一方面是因为 Ninja 在增量构建时更快另一方面 CMake 对 Ninja 的生成器支持也更现代错误信息更友好。最后是时间在 8 核 CPU NVMe 固态的机器上Release 模式构建clanglld大概需要 20~40 分钟。Debug 模式会翻倍甚至更久。我的习惯是第一次构建只用 Release不要一上来就折腾 Debug跑通流程之后再根据需求单独建 Debug 目录。2.2 CMake 和 Ninja 组合配置llvm-project 的构建和大多数 CMake 项目类似但你会在-DLLVM_ENABLE_PROJECTS上花不少心思。这个开关决定你要同时构建哪些子项目比如我要 Clang 和 LLD就写成cmake -S llvm -B build -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86;AArch64 \ -DLLVM_ENABLE_ASSERTIONSOFF \ -DLLVM_CCACHE_BUILDON这里有几个关键选择我逐个解释理由-DLLVM_ENABLE_PROJECTS是按;分隔的列表。想加compiler-rt、libcxx、mlir也是在这里。注意项目之间有依赖关系比如构建clang-tools-extra需要clang构建flang需要mlirCMake 会在配置期告诉你缺了什么。-DLLVM_TARGETS_TO_BUILD只保留你需要的目标架构。默认会构建所有平台后端这块非常耗时。如果只是 x86 环境日用写X86就够了做交叉编译再按需加AArch64、RISCV等。-DLLVM_ENABLE_ASSERTIONSOFF会把各种内部检查关掉编译速度更快二进制也更小。但如果你打算基于 LLVM 开发或调试建议保留为 ON价值远大于编译时间成本。-DLLVM_CCACHE_BUILDON强烈建议开启它会用 ccache 缓存中间编译对象。改一个头文件重新编译时你会回来感谢这个选项。配置完成后执行cmake --build build --target clang lld如果你用 Ninja 生成器也可以直接ninja -C build clang lld第一次构建时别贪心把all作为目标否则会把测试工具、文档、示例全部编译一遍。日常开发只需要特定 target按需构建能省下一大块时间。2.3 构建失败的常见坑和排查方向我前前后后踩过不少构建的坑挑几个高频的说一下。一是 Python 和 CMake 版本过老。LLVM 对构建环境的版本要求逐年提高比如较新版本的 LLVM 至少要求 CMake 3.20 以上、Python 3.6 以上有些工具需要比系统默认更新的版本。遇到 “Unsupported Python version” 或者奇怪的 CMake 语法错误优先检查版本。二是系统缺依赖库。常见的是zlib、libxml2、ncurses等。Linux 下装全开发包就能解决大部分问题sudo apt install cmake ninja-build ccache python3 zlib1g-dev libxml2-devmacOS 用户可以用 Homebrew 装cmake ninja ccache注意如果系统自带的 clang 版本过旧有些 LLVM 新版本也会拒绝构建这时候需要先用 Homebrew 装一个较新的 clang 来引导构建。三是磁盘满。这不是开玩笑Release 构建在几十 GB 级别如果只给/tmp分配了很小的空间而 CMake 又把临时文件放在那构建到一半就会报 “No space left on device”。可以用df -h看一下分区情况必要时指定-DCMAKE_C_COMPILER_LAUNCHERccache等参数之外还要把构建目录放到磁盘足够的挂载点。四是 “fatal error: error in backend: Cannot select”。这个报错一般出现在你把后端目标架配置精简过头之后试图为未包含的架构生成代码。比如只构建了 X86 后端却想着用llc -mtripleaarch64来交叉编译 AArch64 指令自然会失败。这就是“按需裁剪”的代价遇上了把目标加回去重新编译就好。3. LLVM 的三个核心概念IR、Pass、目标描述3.1 IR 不只是“中间层”LLVM IR 是这门技术里最值得先理解的东西。它既有高级语言的语义清晰度又有汇编级别的底层表现力是前端和后端之间的通用语言。你可以看到三种形态的 IR文本形式.ll方便人阅读和调试二进制位码形式.bc方便存储和传输内存中的表示编译过程中真正被 Pass 操作的对象。这三种形态内容等价就像同一个程序可以被表示成源码、编译后的二进制和加载到内存后的进程映像。实际开发中你常会用到opt和llvm-as来切换文本和位码通过llvm-dis来反汇编位码快速查看优化前后的差异。有一段最简单的 IR 长这样define i32 add(i32 %a, i32 %b) { %sum add i32 %a, %b ret i32 %sum }这是从“人类可读”角度理解编译器内部的好入口。i32是 32 位整数类型add是全局函数名%a%b是局部值add是 IR 的指令。阅读 IR 的难度主要来自“无穷无尽的变量名”和无条件跳转的标签结构但看多了就会习惯其实每个基本块就是一组顺序执行的指令块与块之间用跳转连接。3.2 Pass 就是“算法附魔师”如果说 IR 是编译器处理的对象Pass 就是施加在 IR 上的“算法”。编译器之所以能把a * 2优化成a a或a 1能把循环简化甚至删除都是一个个 Pass 在 IR 上执行的结果。LLVM 的 Pass 有几种粒度ModulePass作用于整个模块FunctionPass作用于单个函数LoopPass作用于循环。现代 LLVM 已经切换到 New Pass Manager按更细的管道做调度但我们写自定义 Pass 时最常接触的还是PassInfoMixin这样的模板基类。整个优化流程是Clang 前端生成 IR - 一系列分析 Pass 收集数据 - 一系列变换 Pass 修改 IR - 后端把 IR 翻译成目标汇编。opt工具就是专门用来手动跑 Pass 的你可以用opt -passesmem2reg,instcombine这样一行命令像拼乐高一样随意组合 Pass观察优化结果。这是学习编译器优化最直接的方式。3.3 后端和 TableGen为什么碰指令选择会想哭相比之下LLVM 的后端要复杂得多。它要做的事情包括指令选择、寄存器分配、指令调度、基本块布局等。这部分代码的生成严重依赖TableGenLLVM 自家的一套 DSL你会在llvm/lib/Target/X86/下看到大量.td文件用来描述指令的编码格式、操作数约束、合法化规则等。TableGen的工作方式很像代码生成器你写.td描述工具根据这些描述生成 C 头文件和源文件。所以改后端时往往不是直接写 C而是改.td然后重新生成。我第一次看X86InstrInfo.td的时候完全懵了因为文件里全是类似let Constraints $src0 $dst这样的声明性代码。但后来我尝试给一个实验性指令集写基本的加载存储指令时才发现用.td来描述指令编码比手写一堆 C 更不容易出错代价只是必须忍受一次“生成代码”的抽象距离。4. 零基础写一个可加载的 Pass4.1 New Pass Manager 下的插件骨架现在 LLVM 已经移除了旧版 Pass Manager网上很多老教程里的registerPass写法已经失效。这里给出一个当前版本可用的插件骨架功能非常简单遍历每个函数统计其中CallInst指令函数调用的数量打印出来。#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 { class MyFirstPass : public PassInfoMixinMyFirstPass { public: PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { int count 0; for (auto BB : F) { for (auto I : BB) { if (isaCallInst(I)) { count; } } } errs() [MyFirstPass] Function F.getName() has count call(s)\n; return PreservedAnalyses::all(); } }; } // namespace extern C ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, MyFirstPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name my-first-pass) { FPM.addPass(MyFirstPass()); return true; } return false; }); }}; }这里有几个点需要专门说明。第一llvmGetPassPluginInfo是插件的入口点名字是固定的和编译出的.so文件名无关。第二registerPipelineParsingCallback的作用是告诉opt如果命令行里出现名为my-first-pass的 Pass就把MyFirstPass加入函数优化管线。第三返回值PreservedAnalyses::all()表示这个 Pass 没有破坏任何分析结果编译器可以做更多缓存复用。如果你的 Pass 真的改了 IR而且改得比较彻底就需要更谨慎地声明哪些分析被保留、哪些失效否则可能产生错误优化结果。4.2 独立编译和加载运行为了让插件不依赖 llvm-project 里的复杂构建系统我们可以把它放在一个单独目录里用 CMake 调用find_package(LLVM)来定位llvm-config。先确认llvm-config在环境变量里可用。如果找不到说明构建产物没被安装或者没有把build/bin加入PATH。export PATH/path/to/llvm-project/build/bin:$PATH llvm-config --version然后创建一个CMakeLists.txtcmake_minimum_required(VERSION 3.20) project(MyFirstPassPlugin) find_package(LLVM REQUIRED CONFIG) message(STATUS Found LLVM: ${LLVM_PACKAGE_VERSION}) include_directories(${LLVM_INCLUDE_DIRS}) separate_arguments(LLVM_DEFINITIONS_LIST NATIVE_COMMAND ${LLVM_DEFINITIONS}) add_definitions(${LLVM_DEFINITIONS_LIST}) add_library(MyFirstPass MODULE MyFirstPass.cpp) target_link_libraries(MyFirstPass PRIVATE LLVMCore LLVMSupport LLVMPasses)构建并运行cmake -S . -B build -G Ninja -DLLVM_DIR$(llvm-config --cmakedir) cmake --build build opt -load-pass-pluginbuild/libMyFirstPass.so -passesmy-first-pass -disable-output test.ll其中-disable-output表示我们只关注 Pass 的打印结果不产生新的 bitcode。如果一切正常test.ll里每个函数都会对应一行输出。做个简单的测试 IR 文件define i32 foo() { %v call i32 bar() ret i32 %v } declare i32 bar()运行后你会看到类似[MyFirstPass] Function foo has 1 call(s) [MyFirstPass] Function bar has 0 call(s)这个从零跑通的过程真的很关键。我见过太多人卡在“代码好像没问题但opt说找不到符号”这类问题上其实九成是llvm-config指向的版本和你编译.so时用的头文件版本不一致。插件对 LLVM 版本非常敏感一个不匹配就会在加载时报版本错误。所以请务必保证加载插件的opt和你编译插件时find_package找到的 LLVM是同一套构建产物。4.3 从 opt 到 clang把 Pass 接进真实编译流程能在opt里跑通只是第一步实际工作中你通常希望 Pass 能跟着clang一起工作这样编译真实项目时就能自动执行。LLVM 为这种场景提供了两个思路。第一个思路是使用 Clang 的插件加载接口。在编译命令里加上clang -fpass-pluginbuild/libMyFirstPass.so -c test.c -o test.o注意此时你的 Pass 必须能处理 Clang 生成的 IR。你之前在opt里看到的 IR 往往已经经过了-O0之后的简化但真实编译流程会有大量前端生成的调试信息、特定属性等所以插件要足够健壮不能随便假定 IR 结构。第二个思路是直接把 Pass 集成进默认的 Pass pipeline。例如在PassBuilder的注册回调里把MyFirstPass添加到优化层级的末尾。这对编译器定制者很重要如果你想构建一个带有公司内部优化策略的 Clang只需要改llvm/lib/Passes/PassBuilderPipelines.cpp注册一个回调并控制它在-O2时自动加载。这种做法比给每个编译命令手动加-fpass-plugin更透明统一由构建系统接管。对于只是想验证想法的同学我的建议是从-fpass-plugin开始它的侵入性最小不需要重新修改和构建 Clang 本身随时可以拆卸。5. 被低估的宝藏clang-tidy、sanitizers 与 MLIR5.1 静态分析不止是编译告警很多人以为clang-tidy只是一个“更严格的编译器警告”但它的定位其实是“基于 AST 的静态分析工具”。它能看到类型信息、作用域、模板实例化结果所以能检查出很多编译期告警发现不了的问题比如错误使用智能指针、非法重载、潜在资源泄漏等。实际使用中最方便的方式是通过clang-tidy直接对源码做检查clang-tidy -p build/compile_commands.json my_source.cpp -checks-*,bugprone-*,performance-*-p指向编译数据库目录这样clang-tidy就能知道每个文件的编译参数。如果你已经用 CMake 生成项目只要开启CMAKE_EXPORT_COMPILE_COMMANDSON就会自动生成compile_commands.json。在 llvm-project 的代码库里clang-tools-extra下面不仅有clang-tidy还有include-what-you-use这类极大改变代码维护体验的工具。静态分析能力的意义在于它把一部分“运行时才能暴露的 bug”前移到了编码阶段成本更低效果更稳定。5.2 sanitizers 与 libFuzzer让 bug 无处遁形compiler-rt目录里的 sanitizer 家族是我在正经 C 项目里最常使用的运行时防护工具之一。其中一个非常经典的组合是AddressSanitizerASan和UndefinedBehaviorSanitizerUBSan。用法几乎零成本clang -fsanitizeaddress,undefined -g -O1 test.c -o test ./test一旦程序发生堆越界、栈溢出、使用已释放内存、整数溢出等未定义行为它就会打印详细的调用栈和出错位置。相比valgrind那类纯动态插桩工具ASan 的编译期插桩方式运行开销更低、报错更精确这也是 Chrome、Firefox、Linux 内核等大型项目敢把 sanitizer 跑在 CI 里的原因。同样的compiler-rt还提供了libFuzzer可以用来做覆盖率引导的模糊测试。你要做的只是写一个入口函数让它接收const uint8_t* Data, size_t Size然后用这份数据驱动你要测的 APIextern C int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size) { // 把 Data 当作输入解析或执行被测逻辑 return 0; }然后和-fsanitizefuzzer一起链接编译器就会自动生成模糊测试二进制。配合 ASan它能一遍又一遍地尝试各种输入直到触发崩溃。llvm-project 本身的很多 bug 就是这么被揪出来的。5.3 MLIR、Flang 与新一代编译器生态在最近的 llvm-project 版本里mlir目录的存在感越来越强。它本质上是一套“构建编译器的编译器框架”为领域专用编译器提供了一组可复用的基础设施类型系统、操作定义、模式重写、多级 IR 等。你可以在 MLIR 里定义自己的方言Dialect把高级计算图逐步降低到目标硬件能识别的低级操作最后再落到 LLVM IR 或自定义后端。为什么它重要因为传统的编译器选择很少比如输入是 C/C输出是特定 CPU 汇编中间就是 LLVM IR 一套流程。但深度学习编译器要处理的是张量操作、形状推断、算子融合、内核调度主流的中间表示根本表达不了这些高层语义。MLIR 的出现相当于给“编译”这个概念做了一次重新抽象你可以为自己的领域设计多层 IR每一层只解决一个特定层次的问题。同样的趋势也体现在flang上。Fortran 社区几十年来积累了大量科学计算代码但生态一直比较封闭。flang的目标是把这些老代码接入 LLVM 世界共享优化器和后端能力。对高性能计算领域来说这是一个很重要的趋势即使是最传统的领域也在逐步向统一编译器基础设施靠拢。6. 新手参与 llvm-project 的建议与经验6.1 从读代码到提补丁的路线读 llvm-project 的代码最大的障碍是“入口太多”。我的建议是先从日常工具开始你最常用什么就先读什么。如果你常写 C可以从clang/lib/Sema或clang/lib/AST开始如果你对优化感兴趣直接去llvm/lib/Transforms/Scalar里挑一个常见 Pass 看比如GVN.cpp、LICM.cpp这些实现的注释质量普遍不错。当你觉得读代码已经不过瘾想实际贡献补丁时路径大概是在 GitHub 上找到对应仓库创建一个 fork改代码后发 Pull Request。LLVM 社区从 2021 年起将核心开发迁移到了 GitHub整体流程比早年用 Phabricator 时平滑了很多。提补丁之前务必先在你的分支上跑相关测试ninja -C build check-llvm这是 llvm-project 自带的测试驱动会运行成千上万个回归测试确保你没把别的东西弄坏。一些小建议第一次提交不要选太宏大的目标。改一个文档、修复一个错误提示信息、给某个 Pass 补一条测试用例都是很好的起点。这些改动看似小但能帮你完整走一遍“构建 - 测试 - review - 合并”的流程下次做大事时才不会手忙脚乱。6.2 我的个人体会配置好环境再动手我见过很多人试图在 Windows 上直接构建 llvm-project然后被各种链接错误劝退。不是不行而是坑多。个人经验是如果只是学习和开发优先用 WSL 里的 Ubuntu或者直接 Linux/macOS。Windows 原生构建虽然官方也支持但工具链链路上的问题排查成本比 Linux 高出不少。另外强烈建议把“构建 llvm-project”这件事当成“搭开发环境”的一部分来做而不是一次性任务。装了ninja、ccache、clangd之后你的日常开发体验会完全不同clangd能基于compile_commands.json提供精确的代码补全和跳转阅读十多万行的头文件时效率非常重要。最后关于“要不要从源码构建”这个问题我的建议是哪怕你用系统包管理器已经装了二进制版本也值得自己构建一次。因为只有亲手经历 CMake 配置、目标裁剪、按需编译这些步骤你才会真正理解 llvm-project 的组织方式也才能在后续开发中知道“改了这个模块”意味着“要重编哪些东西”。这个手感是任何文档都给不了的。
📌 标签:
工业官网
设计趋势
AI 建站
SEO
获取完整报告 →
RELATED ARTICLES
推荐阅读
2026/9/20 12:25:01
Flow 与 Relay 实战:用 component syntax 与 usePreloadedQuery/useFragment 构建类型安全的通知收件箱
2026/9/20 12:25:01
Cherry Studio 本地 Embedding 模型下载链路重构:应用自管下载、SHA-256 校验与断点续传
2026/9/20 12:25:01
静息态EEG微状态分析全流程:Cartool从GFP峰值提取到组水平聚类实战
2026/9/20 14:10:18
VLC 仓库 checkasm 测试编写完全指南:从 API 命名到汇编级验证与基准测试
2026/9/20 14:10:18
如何从 Unity 游戏里提取模型、贴图和音频:AssetRipper 资源提取完整实操指南
2026/9/20 14:10:18
Forem 委派 API 访问实战:基于 RFC 9068 短时 JWT 的 API 认证接入指南
2026/9/20 14:10:18
Slang 编译器 GLSL 目标实战指南:特性映射、布局控制与编译选项
2026/9/20 14:10:18
Sails Policies 权威指南:从 ACL 配置到授权中间件源码解析
2026/9/20 14:05:18
Terminal-Bench 2.1 跑不起来?先查 Base URL 后缀,TaoToken 通道不带 /v1
2026/9/20 0:03:47
深入解析Transformer多头注意力机制与工程优化
2026/9/20 0:03:47
OpenClaw 的 Skills 跑学习任务,模型通道改到 TaoToken 通道行不行?
2026/9/20 0:03:47
ChatGPT报错Oops, an error occurred! 全链路排查指南
2026/9/20 0:03:47
深入解析Transformer多头注意力机制与工程优化
2026/9/20 0:03:47
OpenClaw 的 Skills 跑学习任务,模型通道改到 TaoToken 通道行不行?
2026/9/20 0:03:47
ChatGPT报错Oops, an error occurred! 全链路排查指南