1. 从“能用”到“看懂”:llvm-project到底是什么
我第一次认真翻 llvm-project 这个仓库,是在被C++的模板报错折磨到怀疑人生之后。当时只是想知道编译器到底是怎么把我的代码变成机器指令的,结果一头扎进了一个比想象中大得多的世界。GitHub上这个仓库常年霸榜,star数量高得吓人,但很多人其实跟我当初一样:知道它厉害,不知道它厉害在哪,更不知道自己能拿它干什么。
先给个最朴素的定义:llvm-project 是 LLVM 编译器基础设施的官方聚合仓库。它不是一个单独的软件,而是一整套构建编译器的“积木”。C/C++ 的 Clang、Rust 的底层后端、Swift 的编译器、Julia 的即时编译,甚至很多 AI 芯片的编译器,底层都是这套东西。你用 Xcode 写 iOS 代码,底层走的是它;你用 Android Studio 写 Java/Kotlin,里面的 ART 运行时也借鉴了它的思想。
这篇文章我想从一个“用工具的人”的角度,而不是“写论文的人”的角度,把下面这些事说清楚:llvm-project 为什么能成为编译器领域的标准基础设施,它内部那堆子项目分别负责什么,怎么自己动手从源码构建一条好用的工具链,以及我在实践里踩过的坑和总结出来的排查方法。
适合看这篇文章的人很明确:一是想搞懂编译器工作原理的开发者,二是需要在自有环境里定制工具链的工程团队,三是对 AI 编译器、编程语言设计感兴趣的同学。如果你只是写业务代码,那这篇文章也能帮你理解“为什么编译器会报这个错”——这其实比大部分人想象的更有价值。
2. 整体架构拆解:为什么 LLVM 能通吃这么多语言和芯片
2.1 三段式设计才是真正的灵魂
传统编译器(比如早期 GCC 的粗暴时期)往往是“前端+后端”硬耦合:解析语言的代码和生成机器码的代码绑死在同一个流程里。这意味着每支持一种新语言,生成代码的那套逻辑就得跟着改一遍;每支持一种新 CPU 架构,解析语言的那套逻辑也得跟着遭殃,维护成本极高。
LLVM 把这件事彻底拆开了,采用经典的三段式架构:
- 前端(Frontend):把源代码解析成抽象语法树(AST),再做语义分析,最终生成一种统一的中间表示(IR,Intermediate Representation)。Clang 就是 C/C++/Objective-C 的前端。
- 优化器(Optimizer):基于 IR 做各种独立于 CPU 架构的优化,比如循环展开、函数内联、死代码消除。核心工具是
opt,这一层完全不知道你最终要跑在 x86 还是 ARM 上。 - 后端(Backend):把优化后的 IR 转换成目标机器的汇编代码和机器码,这里才涉及指令选择、寄存器分配、指令调度这些底层的脏活累活。
这个设计精妙在什么地方?我用一句话概括:编译器变成了一个平台。前端和后端中间隔着一层稳定、设计良好的 IR,谁都可以往这条流水线上插一脚。
举个最直观的例子,Rust 编译器 rustc 早期用的是自己的中间表示,后来在版本迭代中引入了 LLVM 作为后端。写 Rust 的人只需要关心 Rust 语言的特性,生成机器码的事情全部交给 LLVM 后端。今天 Rust 能这么轻松地支持 Windows、macOS、Linux、WebAssembly、嵌入式 ARM,LLVM 后端的“多目标”能力功不可没。再比如 Swift,Apple 自己就是 LLVM 项目的核心贡献者,Swift 编译器前端负责把 Swift 变成 LLVM IR,然后后端直接白嫖 LLVM 对 Apple Silicon 的极致优化支持。
这就像装修房子:以前每个装修队都得自己烧砖烧水泥、自己接水电、自己刷墙。LLVM 把“水电基础设施”这块统一标准化了,于是新的装修队(新语言)只需要专注于自己擅长的软装(语法、语义、类型检查),剩下的硬装直接调用标准水电管线就行。
2.2 MLIR:让编译器变成“可编程的”
如果只是三段式架构,LLVM 还不足以成为今天的垄断级基础设施。真正让它在 AI 时代继续开疆拓土的,是 2020 年左右开始逐步成熟的 MLIR(Multi-Level Intermediate Representation,多层次中间表示)框架。
传统的 LLVM IR 是低层次的:它假设你已经把高级语言结构都“拍平”成了基本块和指令序列。但在 AI 框架这种场景里,从深度神经网络的“卷积”“矩阵乘法”这种高层的语义,到 CPU/GPU/专用加速器上真正运行的底层指令,中间隔着巨大的鸿沟。用人类做饭来类比:LLVM IR 相当于“把菜切好放在盘子里”的阶段,可是从“买什么菜”“怎么切”“先炒还是先炖”到“最终装盘”,中间还隔着整个烹饪过程,不能一步到位。
MLIR 做的事情就是允许你在同一个框架里定义多层次的 IR:你可以先建一个高层 IR 来表示“这是一个 Transformer 的注意力层”,然后通过一系列 pass(编译优化过程)把它逐步降到 LLVM 低层 IR,最后生成芯片指令。TensorFlow 和 PyTorch 社区都开发了基于 MLIR 的编译器项目(torch-mlir 就是典型案例),很多 AI 芯片厂商也基于 MLIR 搭建自己的编译器栈。
这意味着 llvm-project 已经不只属于“底层系统开发者”了。搞 AI 框架的、搞异构计算的、搞芯片软件栈的,现在全都得围着这个仓库转。
2.3 仓库里的主力子项目一目了然
llvm-project 仓库结构我用了很久才算真正摸清,这里把核心的子项目按分工简单列一下,方便你按图索骥:
| 子项目 | 定位 | 我的使用场景 |
|---|---|---|
| LLVM 核心库 | 提供 IR、优化 pass、后端代码生成等公共能力 | 写自定义 pass 做代码分析时最常碰 |
| Clang | C/C++/Objective-C 前端 | 日常编译 C/C++ 代码、生成 IR 做实验 |
| lld | 链接器 | 替代系统默认 ld,链接速度快一个量级 |
| libc++ | C++ 标准库实现 | 用较新的 C++ 标准特性时配合 Clang 使用 |
| compiler-rt | 运行时支持库 | 用 AddressSanitizer、UBSan 做内存检测时依赖它 |
| MLIR | 多级中间表示框架 | 研究 AI 编译器和自定义 DSL 时使用 |
| lldb | 调试器 | 比 gdb 更现代,配合 Clang 生成调试信息体验更好 |
| polly | 基于多面体模型的循环优化 | 做高性能计算时关注过 |
| flang | Fortran 前端 | 科学计算老代码迁移时了解过 |
另外还有libunwind、openmp、parallel-libs等更细分的子项目。说句实在话,绝大多数人真正接触到的只有几个:Clang、LLVM 核心库、lld,可能再加一个 MLIR。但知道整个全景,你在阅读构建脚本和 issues 的时候才不会一头雾水。
3. 如何从源码自己构建一套 llvm-project 工具链
源码构建 llvm-project 这件事,看起来就是跑一下 cmake 和 ninja,但实操起来每一步都有相当大的选择空间。选择不同,构建时长可能相差几个小时,生成的工具链行为也不同。我从环境准备、cmake 配置、常见构建目标三个层面说清楚。
3.1 环境准备和依赖选型
先说结论:Linux 上构建 llvm-project,我个人推荐 Ubuntu 22.04 或更新的发行版,gcc 版本 9 以上,或者直接用一个较新的 clang 也行。CMake 版本至少 3.20,Ninja 构建系统是必备的,另外强烈建议安装 ccache。
为什么强烈推荐 Ninja 而不是 Make?因为 llvm-project 的构建任务并行度非常高,Ninja 在增量构建和并行调度上的表现明显优于 Make。我第一次用 Make 构建,改了一行代码,重新构建居然要多等几分钟;换成 Ninja 之后,增量构建时间缩短了一半以上。这不是玄学,Ninja 对依赖关系的描述更精确,可以避免很多不必要的重复编译。
ccache 更是个神器。llvm-project 反复编译同一个文件的情况非常多,特别是在调试不同后端配置时。第一次全量构建可能要 20 分钟到一个小时(取决于机器核数),有了 ccache 缓存,第二次构建同一个目标可能只要两分钟。我见过太多人在这一步浪费时间,先用裸编译跑了一遍全程,然后换了一个配置又是完整编译一遍,平白烧掉一两个小时。
安装依赖的命令大致是:
sudo apt update sudo apt install build-essential cmake ninja-build ccache python3 git注意:如果你准备构建的 LLVM 版本比较新(比如 17 或 18),务必确认系统 gcc 版本不是太老。太老的 gcc 可能在某些 C++20 特性上编译不过,这和 llvm-project 本身的代码要求有关。实在不想升级系统 gcc,可以直接下载一个预编译的更高版本 clang 来引导构建。
3.2 cmake 配置的参数详解
这是整个实操过程中最关键也最容易劝退的一步。llvm-project 的 cmake 配置项多到令人发指,但真正决定构建成败的核心就几个。下面是我实测下来比较顺手的一组配置:
cmake -S llvm -B build -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_INSTALL_PREFIX=/opt/llvm-toolchain \ -DLLVM_ENABLE_PROJECTS="clang;lld;mlir" \ -DLLVM_ENABLE_RUNTIMES="libcxx;libcxxabi;compiler-rt" \ -DLLVM_TARGETS_TO_BUILD="X86;AArch64;RISCV" \ -DLLVM_CCACHE_BUILD=ON \ -DLLVM_ENABLE_ASSERTIONS=ON \ -DCMAKE_C_COMPILER=clang \ -DCMAKE_CXX_COMPILER=clang++我逐个解释为什么这样选,以及每项背后的坑。
-DCMAKE_BUILD_TYPE=Release:默认的 Debug 配置会生成大量断言代码和调试符号,构建速度慢、生成的工具链性能也差。我刚开始不明所以直接默认构建,结果生成的 clang 编译起我自己的代码比系统自带的慢很多。Release 模式下优化全部打开,这才是日常使用应该用的配置。-DLLVM_ENABLE_PROJECTS:这个列表是你要在 LLVM 仓库里一起构建的“前端工具”项目。clang是必选的,lld我强烈推荐,mlir如果你是做 AI 编译器或者自定义 DSL 那就加进来。注意 flang 和 polly 也属于这个列表。-DLLVM_ENABLE_RUNTIMES:这个列表和 PROJECTS 有微妙区别,它构建的是“运行时库”。最典型的是 libcxx/libcxxabi 和 compiler-rt。如果你只是日常写 C++ 代码,不打算用 LLVM 提供的 sanitizer 工具,这个列表甚至可以先不设,节省大量构建时间。但要用 ASan/UBSan,compiler-rt 就必须构建。-DLLVM_TARGETS_TO_BUILD:默认是全平台支持,构建时间直接翻倍。绝大多数人跑在 X86 上就够了,我自己实验用到了 AArch64 和 RISC-V 的交叉编译,所以加了这两个。目标越少,构建越快,生成的二进制定位越精确。-DLLVM_CCACHE_BUILD=ON:这个选项会帮你自动配置 ccache,不需要你自己设置CCACHE_环境变量,是这个版本才引入的贴心功能(旧版本需要手动设置)。-DLLVM_ENABLE_ASSERTIONS=ON:这个很多人纠结。开断言会让编译器在自己运行的时候做一堆内部检查,性能略受影响,但在开发调试期非常有用。如果你计划给 llvm-project 本身贡献代码或者写自定义 pass,就打开;只是作为终端用户使用工具链,可以关掉。- 指定
CC和CXX编译器:用 already installed 的 clang 来引导构建 llvm-project,比用 gcc 构建出来的工具链在某些场景下表现更好(例如能直接复用已有的 libc++)。但要注意,如果你的系统 gcc 版本太新而 clang 版本太旧,可能导致标准库头文件解析失败,这时候就别强行指定 clang 了。
我还想提醒一个细节:-DLLVM_ENABLE_RUNTIMES里的libcxx和libcxxabi默认是“作为运行时库”构建,这意味着你需要再跑一步ninja cxx cxxabi才会真正构建出 C++ 标准库。很多人在这个点上卡住半天,以为配置了就会自动构建,实际上它的构建根目标和 LLVM 主工具链是分开的。
3.3 正式构建和常见构建目标
配置完成之后,进入 build 目录开始编译。这一步我建议你算好机器核数,不要盲目-j拉满。用nproc看下核数,然后按核数减 2 左右来设置并行度,留出系统响应余量。我自己是 16 核机器,一般用-j14,构建约 30 分钟跑完 clang+lld。
cmake --build build -j14当然你也可以直接指定目标,避免编译一堆用不到的东西:
cmake --build build --target clang lld mlir-opt -j14这里mlir-opt是 MLIR 自带的一个工具,用来跑 MLIR 的优化 pass。如果没配 MLIR,就用不到这个目标。
构建完成后,可执行文件在build/bin目录下,先验证一下:
build/bin/clang --version然后写个最简单的 C 程序测试链路是否正常:
cat > hello.c << 'EOF' #include <stdio.h> int main() { printf("hello llvm-project\n"); return 0; } EOF build/bin/clang -O2 hello.c -o hello ./hello如果能正常打印,说明工具链核心流程已经跑通了。接下来可以进一步测试 lld:
build/bin/clang -fuse-ld=lld -O2 hello.c -o hello_lld ./hello_lld技巧提醒:如果你在
cmake时设置了CMAKE_INSTALL_PREFIX,构建完就可以直接cmake --install build把工具链安装到系统目录。但我个人的习惯是尽量不装到系统目录,直接用build/bin下的工具,把版本隔离干净,避免干扰系统自带工具链,需要切换版本时也方便。
3.4 自定义 IR 的快速实验
构建完成后的 llvm-project 给你打开了一扇新门:可以非常直观地看到自己的代码经过优化后的中间表示长什么样。这对理解编译器背后的逻辑非常有帮助。
build/bin/clang -S -emit-llvm -O2 hello.c -o hello.ll生成的hello.ll就是优化后的 LLVM IR 文本形式。你可能会看到一堆%1、%2这样的虚拟寄存器名,以及各种叫做add、mul、call的指令。如果加一行带-O0,再对比一下-O2,你就能非常直观地感受到优化器做了多少事情。我第一次看这个对比的时候,被惊讶到了:-O2的 IR 比-O0短了不止一半,很多函数直接内联成了常量表达式。
继续深入的话,还可以用opt工具单独跑某个优化 pass,比如:
build/bin/opt -passes='function(instcombine)' hello.ll -S -o hello_opt.ll你是“编译器”的主人,而不是只当它是黑盒。
4. 实操过程:用构建出的工具链解决一个实际问题
光编译一个 hello world 太无聊了,我来展示一个实际过程中的完整工作流:用刚构建好的 llvm-project 工具链,对一个 C++ 项目做非常规的代码优化和分析。这个流程会让你更全面理解 llvm-project 各组件之间的配合。
4.1 场景设定:一个性能敏感的数值计算模块
假设我们有一段代码,处理大量浮点矩阵运算,性能要求很苛刻:
// matmul.cpp #include <vector> #include <cstdio> const int N = 256; using Matrix = std::vector<std::vector<double>>; void matmul(const Matrix& A, const Matrix& B, Matrix& C) { for (int i = 0; i < N; ++i) { for (int j = 0; j < N; ++j) { double sum = 0.0; for (int k = 0; k < N; ++k) { sum += A[i][k] * B[k][j]; } C[i][j] = sum; } } } int main() { Matrix A(N, std::vector<double>(N, 1.0)); Matrix B(N, std::vector<double>(N, 2.0)); Matrix C(N, std::vector<double>(N, 0.0)); matmul(A, B, C); printf("%f\n", C[0][0]); return 0; }这段代码虽然简单,但它的三重循环在指令层面其实存在巨大的优化空间。系统自带的 gcc/g++ 也能优化,但我想看看 clang 在这个场景下能做到什么程度,尤其是结合 lld 之后,可执行文件的整体性能会有什么变化。
4.2 编译、压测和对比
我用三种组合编译,分别压测可执行文件的运行耗时:
- g++ -O2
- clang++ -O2
- clang++ -O2 + lld 链接
g++ -O2 matmul.cpp -o matmul_gcc build/bin/clang++ -O2 matmul.cpp -o matmul_clang build/bin/clang++ -fuse-ld=lld -O2 matmul.cpp -o matmul_lld然后用time或者更精细的perf stat运行多次,取稳定值。这里我直接说结论:在我的 Intel 12 代机器上,clang 和 gcc 的差距基本在 2%-5% 之间,clang 略占优势;使用 lld 链接相比系统默认的 GNU ld,链接时间从 0.8 秒缩短到 0.3 秒左右,运行性能没有明显差异。这是典型表现,不用为此纠结“谁更快”——成熟编译器在 O2 级别的优化能力已经高度接近,更大的差距往往来自代码本身的可优化空间。
真正有趣的不是这三者抢那 5% 的差距,而是下面这个角度。
4.3 从 IR 层面读懂优化器做了什么
我生成 clang++ -O2 的优化后 IR,来看编译器的视角:
build/bin/clang++ -S -emit-llvm -O2 matmul.cpp -o matmul.ll打开matmul.ll,你会看到它并没有做的那么极致——它甚至没有把最内层的乘法累加循环自动替换成 SIMD 指令。原因是std::vector<std::vector<double>>的结构导致内存访问模式是“两层间接寻址”,编译器很难在编译期完全推断出各个 vector 的基地址。这就是为什么很多高性能计算矩阵库要用一维数组加手写索引,而不是用vector<vector<double>>。
我用一维数组改写了矩阵存储方式之后,再次对比 IR,里面的循环结构就完全不同了,内层出现了一批和 SIMD 相关的向量指令序列。这说明编译器不是不能做,而是需要代码给它足够的“可见性”。这类认知,只有打开 IR 看到优化结果才真正进入内心深处。这也是 llvm-project 这个仓库带给开发者最大的礼物:它可以让你看到“编译器对你代码的真实评价”,这种反馈远比一个可执行文件的运行时间来得有信息含量。
4.4 sanitizer 加持下的错误捕获
llvm-project 的 compiler-rt 提供的 sanitizer 工具,对日常开发的帮助同样重要。我经常在调试 C++ 项目时用 AddressSanitizer(ASan)来抓内存越界和悬垂指针问题。配置好 ASan 的构建很简单,假如我已经安装了工具链,编译命令大致是:
build/bin/clang++ -fsanitize=address -g matmul.cpp -o matmul_asan ./matmul_asan如果代码里有内存越界,ASan 会直接打印出出错位置,调用栈信息非常详细。很多人只把 llvm-project 当编译器用,根本没意识到 compiler-rt 里这些运行时诊断工具才是宝藏。我第一次用 ASan 抓到一个隐藏了很久的 use-after-free 问题,那种感觉甚至有点感动。
5. 常见问题与排查技巧实录
构建和使用 llvm-project 的道路上,坑比想象中多。我踩过很多,这里挑有代表性的记录下来,希望能帮你节省几个小时的查找时间。
5.1 构建太慢?先看目标和缓存
最常见的问题是“为什么我构建了这么久还在跑”。我观察到的几种情况和对策如下:
- 没有用 Ninja,而是用了默认的 Unix Makefiles。解决办法是重新用
-G Ninja配置。 - 把所有目标一股脑都编译了。解决办法是精确指定目标,比如只编译 clang 和 lld。
- 没有开 ccache。解决办法是加
-DLLVM_CCACHE_BUILD=ON,已经有独立的 ccache 缓存的话还会自动复用。 LLVM_TARGETS_TO_BUILD没有限制,默认全平台。解决办法是只保留自己需要的。
另外,如果第一次构建时用的机器内存偏小,建议直接用 Ninja 并限制并行任务数到 4 以下。我看到过 8GB 内存的机器全并编辑直接 OOM 的情况,那是相当崩溃的现场。
我把常见问题整理成了一张排查表,方便临时查阅:
| 症状 | 可能原因 | 排查命令/手段 | 解决办法 |
|---|---|---|---|
| 编译过程中 OOM | 并行任务数过高或 Debug+全后端配置 | free -h观察剩余内存 | 降低 -j 并行数;换 Release;减少 Targets |
| 链接阶段找不到符号 | libc++ 和工具链版本不匹配 | grep "undefined" build.log | 检查LLVM_ENABLE_RUNTIMES,重新构建 libc++ |
| clang 编译自己失败 | 系统 gcc 库头文件版本太新 | 观察报错头文件路径 | 指定兼容的 C++ stdlib,或升级 clang 版本 |
| 增量构建不生效 | cmake 配置变更导致全量重建 | 观察 ninja 输出大量编译日志 | 尽量不开新配置,或用 ccache 缓解 |
opt -passes报未知 pass 名 | pass 名字拼写错误或版本差异 | opt -print-passes | 查询当前版本支持的 pass 列表 |
| 安装到 /usr/local 后和系统工具冲突 | 部分工具同名覆盖 | which clang、clang --version | 使用非系统目录DESTDIR安装或直接用 build/bin |
| lld 链接某些库报“重排”错误 | 编译器与链接器版本错配 | 查看 lld 警告信息 | 使用同一次构建的 clang 与 lld |
5.2 调试信息相关的坑
很多人用 clang 编译完代码后用 lldb 调试,结果发现断点位置错乱,或者查看变量值的时候显示<optimized out>。两个原因很典型:一是编译时没有加-g,这个比较基础;二是加-O2优化后,debug 信息与实际指令对应关系变得非常复杂。开发调式需要的合理搭配是-O0 -g,发布版本才开优化。
必要时可以试试-g -fno-omit-frame-pointer,这样即使开一定优化,栈回溯的出错信息也会更完整。
5.3 用 clang-tidy 做静态检查时的配置心得
clang-tidy 是 llvm-project 里一块非常实用的拼图。不少团队把它接入了 CI,用它做代码风格和常见 bug 检查。我配置它时的经验是:第一轮先启用clang-analyzer-*和bugprone-*这两组规则,这两组误报率相对低、发现真实问题的概率高;等团队适应之后再逐步扩展。如果一开始把所有规则全部打开,会得到几百上千条告警,团队基本就放弃使用了。接入大型项目时,通过.clang-tidy文件对特定目录开启特定规则,控制告警范围,好过一把抓。
6. 构建之外:llvm-project给你的三样底层能力
说完了实操层面的内容,我想换个视角,谈谈构建并使用 llvm-project 这件事,到底给你的技术能力带来什么长期价值。
首先是读懂编译器的能力。之前只把编译器看作黑盒,遇到报错就是上网搜。自己构建过 clang、看过 LLVM IR 之后,很多报错的本质原因一眼就能判断出来:链接错误、模板实例化错误、符号重定义,这些底层机制你都亲手操作过,不再是“玄学”。
其次是扩展编译器的能力。llvm-project 提供了完整的 API,允许你写自定义的优化 pass,也可以基于 Clang 的 LibTooling 写大型代码分析工具。我做过的项目中,有用它实现自动化大规模代码批量重构的,也有基于 AST 统计代码坏味道的。这种“编译器平台”的威力,只有真实上手才能体会。
最后是跨领域迁移的能力。llvm-project 涉及很多基本不区分领域的底层思想:数据流分析、SSA形式、指令调度、内联启发式、循环变换。这些思想在数据库优化器、游戏引擎、高性能计算、甚至芯片验证工具里都有相似的形态。一旦你在 llvm-project 的世界里建立起这些概念,后面学任何偏底层的技术,都会觉得顺滑许多。
如果让我给一个学习路径建议:先完整构建一遍,亲手体验;然后拿一个简单函数对比不同优化等级下的 IR;接着给 clang 写一个最简单的 AST 分析工具;最后再考虑涉足 MLIR。不要一上来就研究那些“编译器论文”,在 helloworld 级别的真实代码上反复观察,远比读几十篇论文来得快。
这套工具链我大概已经用了数年,踩过的坑远比我在这篇文章里写的多。里面还有很多好用的功能我没有完全展开,比如 lldb 的脚本化调试能力、libc++ 的 ABI 兼容政策、OpenMP 的 offload 支持等等。但无论如何,从理解到构建,再到深入 IR 和静态分析,这一条链路是大多数人循序渐进认识 llvm-project 最舒服的一条路。我最后想分享的小技巧是:给build/bin单独建一个软链接目录,比如~/tools/llvm/bin,把每次构建好的二进制入口放进去,然后配合别名命令,日常开发会非常顺手,也方便多个版本并存。希望你在自己的 llvm-project 旅程里,也能找到那种“原来编译器是这样看我代码的”的豁然开朗感。