项目标题就一个llvm-project,但做过编译器、搞过性能优化、甚至只是啃过《编译原理》的人,看到这个名字应该都会心一笑。这个仓库几乎是现代编译器世界的“第一公民”,从苹果的 Clang 到 Rust 官方工具链,再到 GPU 厂商的底层编译器,底层都有它的影子。这篇文章不是入门导览,不是官网文档翻译,而是把我自己从零开始追这个项目、读源码、改 IR、自己编 Pass 的整个路径拆开揉碎,把那些文档里不会写的坑和门道一次性讲清楚。适合已经能跑通clang hello.c,但想真正深入 LLVM 内部、准备往编译器方向深耕的开发者。
1. 项目全貌:为什么说 llvm-project 是一个“编译器宇宙”
1.1 核心需求解析:从“一个编译器”到“一套基础设施”
最容易被忽视的一点是:llvm-project不是“一个编译器”,而是一整套编译器基础设施。它解决的核心问题是:如何让编程语言的编译成本降到最低,让新语言、新硬件、新优化算法都能站在同一个肩膀上复用彼此成果。
传统 GCC 的思路是“一个语言前端配一个后端”,如果你要支持一种新语言,整个优化器和代码生成器都要跟着重写。而 LLVM 的核心抽象是 IR(中间表示)。前端把源代码翻译成 IR,优化器只处理 IR,后端的任务是把 IR 变成目标机器码。这样切分之后,前端和后端完全解耦,理论上给 LLVM 加一门新语言,你只需要写一个前端;给 LLVM 加一个新 CPU 架构,你只需要写一个新后端。这是因为这个设计,llvm-project 才能同时滋养 Clang(C/C++/ObjC)、Rustc、Swift、Julia、Zig 等一大票语言生态。
我在实际看代码的时候有一个体会:这个项目的重心分配极其夸张。你 clone 下来之后会看到,clang目录占了将近一半的体积,lld和lldb又是两个大块头,而纯正的 LLVM 核心(llvm/lib)反而是很多人没耐心细读的部分。但恰恰是核心 IR 和 Pass 基础架构,定义了整个项目的灵魂。
1.2 工程目录解剖:clang、lld、lldb、mlir、polly 的角色分工
刚接触这个项目的人,最容易被十来个一级目录搞晕。我建议你把它们按“功能角色”分类:
| 目录 | 角色 | 通俗理解 |
|---|---|---|
llvm/ | 核心基础库 | 整个宇宙的“通用物理法则”,包含 IR、Pass 框架、代码生成、优化器 |
clang/ | C/C++/ObjC 前端 | 把高级语言变成 IR 的“翻译官” |
lld/ | 链接器 | 把编译产物装配成可执行文件的“装配车间” |
lldb/ | 调试器 | 让开发者能“透视”程序运行状态的“显微镜” |
mlir/ | 多层 IR 框架 | 专为 AI 计算图、硬件编译而生的“积木系统” |
polly/ | 多面体优化 | 针对循环嵌套的“数学级”优化器 |
compiler-rt/ | 运行时库 | 提供内存检测、profile 等底层运行时支持 |
libcxx//libcxxabi/ | C++ 标准库实现 | Clang 默认搭配的 C++ 库 |
openmp/ | OpenMP 运行时 | 并行编程模型的底层实现 |
实际操作中最容易踩坑的是:很多人把llvm当作“完整项目”,只构建llvm目标,结果后面用clang -O2一切正常,但一用lld就找不到,一调试就提示没有lldb。这是因为默认构建目标可能不包含所有组件。我的做法是一开始就明确指定LLVM_ENABLE_PROJECTS,把真正要用的组件一网打尽。
1.3 这套生态能干什么:从自定义语言到 AI 编译器
理解llvm-project能干什么,比“它是什么”更重要。我概括成三类典型场景:
- 新语言后端落地:你发明了一门语言,不想从零写优化器和机器码生成器。你只需要把语法树降级成 LLVM IR,立刻得到世界级的优化器、多个 CPU 平台支持和几十种调试/分析工具。
- 海量性能优化实验:学术界和工业界要做编译优化研究,不必整个重写编译器。你只需要在 Pass 管理器里注册一个自定义 Pass,像插拔 U 盘一样对 IR 做变换,跑基准测试验证。
- AI 计算图与异构编译:MLIR 的出现让 LLVM 家族进入神经网络编译赛道。DeepMind、Google 的合作,以及各类 AI 芯片工具链,都基于这一层做计算图优化。
这些都是实打实的使用场景,不是概念吹嘘。我自己就接过一个自定义 DSL 编译到 WASM 的小项目,核心工作量基本都在前端语法处理,真正的代码生成和优化直接白嫖 LLVM,大概两个月就做出一个能跑的 MVP,这个效率靠传统方案根本做不到。
2. 核心设计拆解:IR 的魔法与 Pass 的流水线
2.1 LLVM IR:编译器世界的“通用语”
LLVM IR 是理解整个项目的钥匙。如果你在终端里执行clang -S -emit-llvm hello.c,就能看到它的真面目。它有一个很有趣的性格:它既有高级语言的层次结构(函数、基本块、指令),又有低级语言的干脆直接(只有三种指令格式:对齐、加载、存储、算术等),像是 C 和汇编之间的一个完美折中。
这种设计的合理性在于:优化器需要能对程序做“语义级”的重构,所以 IR 不能像汇编那样琐碎;后端又需要能很容易地把 IR 映射成机器指令,所以 IR 不能像 C 那样藏着指针和内存模型。最常见的alloca指令就是一个典型,它负责在栈上分配内存,但优化器往往会在后面把它替换成寄存器,这就是 mem2reg Pass 的经典工作。
我强烈建议新手花时间阅读llvm/docs/LangRef.rst,那是我见过最诚实的语言规范之一。你会看到getelementptr这类指令为何如此“反直觉”——因为它专门为数组/结构体地址计算设计,坑多但效率极高。理解了 IR 语法,读任何 Pass 源码都不会再像看天书。
2.2 前端前端之后:Pass 管线的编排逻辑
拿到一份 IR 之后,llvm-project 的“魔法”就发生在 Pass 管线里。你平时敲的-O2、-O3并不是一个单一优化,而是一条精心编排的流水线。每个 Pass 负责一个极小的变换,比如:
- Dead Code Elimination(死代码消除):把计算了但没用到的指令删掉。
- Loop Unroll(循环展开):减少循环控制开销,提升指令级并行。
- Inliner(内联):把函数调用替换为函数体,减少调用开销。
这些 Pass 之间的顺序非常讲究,调换顺序可能直接让最终代码体积暴涨或性能下降。例如mem2reg必须尽早跑,因为它把栈变量提升到 SSa 寄存器,后面的优化才有更好的分析基础;而loop-unroll通常跑得比较靠后,此时前面的分析已经积累了良好的上下文。
在llvm/lib/Passes/PassBuilder.cpp里有非常详细的管线定义,我每次想查某个优化到底在哪个阶段执行、前后是谁,就会翻这个文件。它的编译顺序就是新版本基于新 Pass 管理器的核心入口。
2.3 新旧 Pass 管理器切换:入坑者最容易遇到的版本断层
如果你在网上搜索写 Pass 的教程,会看到大量基于“legacy Pass manager”的代码,比如RegisterPass<MyPass>这类写法。而在 LLVM 14 之后的正式主流是“new Pass manager”,它的 API 和注册方式完全不同。这正是初学者最容易迷惑的地方:抄来的代码放在现代版本上根本编译不过。
新 Pass 管理器的优势在于:Pass 之间的依赖明确化,可以并行执行互不干扰的分析结果,安全性更高。从FPM (FunctionPassManager)到ModuleAnalysisManager,整个设计都更现代。具体往后看,我会给一个基于新 Pass 管理器的可用示例。这里先提醒:如果你搜到资料里出现头文件<llvm/IR/LegacyPassManager.h>,基本可以判断那是古董教程,建议趁早换源。
3. 构建实战:从 clone 到可用的最快路径
3.1 版本选择与代码拉取:不要直接 git clone 了事
首先要明确:llvm-project 主分支永远处于活跃开发状态,今天 clone 主分支,明天可能就变了。做实际项目,一定要用 release 分支或 tag。我一般去 GitHub 的 Releases 页面看最新的稳定版本号,然后git clone --depth 1 --branch llvmorg-17.0.6 https://github.com/llvm/llvm-project.git。浅克隆节省网络和时间,完整历史对绝大多数人来说毫无必要。
选择版本时还注意一下你的依赖环境。比如 LLVM 17 要求 CMake 3.20+、GCC 7.1+ 或 Clang 5.0+。如果系统自带的老版本编译器不满足要求,老老实实先装新 GCC 或者用包管理器升级,不然会在配置阶段撞上一堆摸不着头脑的报错。
3.2 CMake 参数要义:这些是你真正需要知道的关键开关
llvm-project 采用 CMake 构建,参数浩如烟海,但真正决定命运的没有几个。我通常这样配置:
cmake -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DLLVM_ENABLE_PROJECTS="clang;lld" \ -DLLVM_TARGETS_TO_BUILD="X86;AArch64" \ -DLLVM_ENABLE_ASSERTIONS=On \ -DBUILD_SHARED_LIBS=Off \ ../llvmCMAKE_BUILD_TYPE=Release:生成优化后的二进制,速度快。LLVM_ENABLE_PROJECTS:决定你要构建哪些子项目,按需加载,省时间。LLVM_TARGETS_TO_BUILD:指定目标架构。不需要把全部架构都编出来,只留自己用得到的,构建速度提升明显。LLVM_ENABLE_ASSERTIONS=On:在开发模式下保持断言开启,能更容易定位到 IR 或 Pass 的问题。生产跑性能测试时可以关掉。BUILD_SHARED_LIBS=Off:默认静态库,便于部署;如果频繁改动库自己做实验,开 On 能大大加速增量编译,但产物会散落很多.so文件。
实际构建时,llvm-project是个巨无霸,全量构建核心加 clang 很容易吃掉 60GB 磁盘和几十分钟时间。我自己的建议是:第一次构建不要追求“全家桶”,先只选clang,把核心跑通。后面缺什么再补什么,重新 cmake 并构建对应 target。
3.3 增量构建的清醒认识:Ninja 与 ccache 的组合拳
用 Ninja 替代 Makefile 基本是共识,-G Ninja的并行度和依赖追踪都优秀得多。更进一步的提速神器是ccache。第一次全量构建时,ccache 没什么用;但从第二次开始,改了 clang 的一个文件而要重新验证整个项目时,命中缓存的那部分就能直接跳过。
配置方式很简单:
export CCACHE_MAXSIZE=50G cmake -G Ninja \ -DCMAKE_C_COMPILER_LAUNCHER=ccache \ -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ ../llvm这里有个值得注意的事项:编译器启动器对默认 GCC 和 Clang 都有效,但如果你的宿主编译器本身就是 clang,建议直接指定-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++,能再快一截。一定要把磁盘空间预算进去,完整构建一次 llvm-project 需要的空间远超直觉,至少留出 100GB 再开始。
构建命令也建议分步执行:ninja clang先构建编译器,ninja lld再构建链接器,ninja check-clang跑测试。不要一开始就ninja全量执行,我第一回就是全量执行,中间一个组件编译失败,排查半天才发现是一个老版本的 Python 模块不兼容 lldb 的脚本文档生成。
4. 实操过程:亲手写一个真正的 LLVM Pass 并跑通
4.1 场景设定与工程结构:做一个函数级计数优化实验
为了把手真正弄脏,我这里选一个最小但完整的场景:编写一个模块级 Pass,遍历所有函数,统计每个函数的基本块数和指令数,并将结果打印到标准输出。这个小实验覆盖了 Pass 编写、注册、编译、加载执行的全流程,而且完全没有领域前置知识负担。
我的工程结构如下:
MyPass/ ├── CMakeLists.txt ├── MyCountPass.cpp4.2 源代码实现:基于 New Pass Manager 的现代写法
#include "llvm/IR/Function.h" #include "llvm/IR/Instructions.h" #include "llvm/IR/Module.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 MyCountPass : public PassInfoMixin<MyCountPass> { public: PreservedAnalyses run(Module &M, ModuleAnalysisManager &MAM) { for (auto &F : M) { if (F.isDeclaration()) continue; // 跳过声明,只统计有函数体的 unsigned bbCount = 0; unsigned instrCount = 0; for (auto &BB : F) { ++bbCount; instrCount += BB.size(); } errs() << "[MyCountPass] Function: " << F.getName() << ", BasicBlocks: " << bbCount << ", Instructions: " << instrCount << "\n"; } return PreservedAnalyses::all(); // 我们没改任何东西,全保留 } }; } // namespace // 注册插件入口 extern "C" LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, "MyCountPass", LLVM_VERSION_STRING, [](PassBuilder &PB) { PB.registerPipelineParsingCallback( [](StringRef Name, ModulePassManager &MPM, ArrayRef<PassBuilder::PipelineElement>) -> bool { if (Name == "my-count-pass") { MPM.addPass(MyCountPass()); return true; } return false; }); }}; }这里几个关键点值得解释。PassInfoMixin<MyCountPass>是新 Pass 管理器的基类模板,不需要自己去管理getPassName()之类的繁琐接口,只需要实现一个run方法。PreservedAnalyses告诉框架你的 Pass 修改了哪些分析,如果完全没有改动任何 IR 数据,就返回all(),让下游分析得以保留,进而提升整体编译速度。
注册回调里,registerPipelineParsingCallback允许我们定义 Pass 在命令行管线中的名字,这里的"my-count-pass"就是后面 opt 命令里传入的名字。插件入口采用 C 接口,保证动态链接可识别。
4.3 编译链接细节:CMake 的写法与坑
cmake_minimum_required(VERSION 3.20) project(MyCountPass CXX) find_package(LLVM REQUIRED CONFIG) message(STATUS "Found LLVM ${LLVM_PACKAGE_VERSION}") message(STATUS "Using LLVMConfig.cmake in: ${LLVM_DIR}") include_directories(${LLVM_INCLUDE_DIRS}) add_definitions(${LLVM_DEFINITIONS}) add_library(MyCountPass MODULE MyCountPass.cpp ) target_link_libraries(MyCountPass PRIVATE LLVMCore LLVMSupport LLVMPasses) set_target_properties(MyCountPass PROPERTIES PREFIX "" )第一处要注意:find_package(LLVM REQUIRED CONFIG)要求你的 LLVM 构建产物里有LLVMConfig.cmake。如果直接在构建目录里用,那没问题;但如果是自己构建完准备安装到某个 prefix,记得在 CMake 命令中加-DCMAKE_INSTALL_PREFIX=/your/path,并在后续使用该 Pass 的项目里指定-DLLVM_DIR=/your/path/lib/cmake/llvm。
第二处是链接库的选择。我 min 到只链接LLVMCore、LLVMSupport、LLVMPasses,实际运行时还可能需要其他库,但插件从opt进程加载时,大部分核心符号已经存在,所以这里一个最简单的集合就够。
第三处最隐蔽:模块型插件在 Linux 上需要无前缀的.so,所以要设 `PREFIX