深入LLVM:从源码构建工具链到MLIR编译器扩展
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再做语义分析最终生成一种统一的中间表示IRIntermediate 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、嵌入式 ARMLLVM 后端的“多目标”能力功不可没。再比如 SwiftApple 自己就是 LLVM 项目的核心贡献者Swift 编译器前端负责把 Swift 变成 LLVM IR然后后端直接白嫖 LLVM 对 Apple Silicon 的极致优化支持。这就像装修房子以前每个装修队都得自己烧砖烧水泥、自己接水电、自己刷墙。LLVM 把“水电基础设施”这块统一标准化了于是新的装修队新语言只需要专注于自己擅长的软装语法、语义、类型检查剩下的硬装直接调用标准水电管线就行。2.2 MLIR让编译器变成“可编程的”如果只是三段式架构LLVM 还不足以成为今天的垄断级基础设施。真正让它在 AI 时代继续开疆拓土的是 2020 年左右开始逐步成熟的 MLIRMulti-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 做代码分析时最常碰ClangC/C/Objective-C 前端日常编译 C/C 代码、生成 IR 做实验lld链接器替代系统默认 ld链接速度快一个量级libcC 标准库实现用较新的 C 标准特性时配合 Clang 使用compiler-rt运行时支持库用 AddressSanitizer、UBSan 做内存检测时依赖它MLIR多级中间表示框架研究 AI 编译器和自定义 DSL 时使用lldb调试器比 gdb 更现代配合 Clang 生成调试信息体验更好polly基于多面体模型的循环优化做高性能计算时关注过flangFortran 前端科学计算老代码迁移时了解过另外还有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.20Ninja 构建系统是必备的另外强烈建议安装 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 可能在某些 C20 特性上编译不过这和 llvm-project 本身的代码要求有关。实在不想升级系统 gcc可以直接下载一个预编译的更高版本 clang 来引导构建。3.2 cmake 配置的参数详解这是整个实操过程中最关键也最容易劝退的一步。llvm-project 的 cmake 配置项多到令人发指但真正决定构建成败的核心就几个。下面是我实测下来比较顺手的一组配置cmake -S llvm -B build -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DCMAKE_INSTALL_PREFIX/opt/llvm-toolchain \ -DLLVM_ENABLE_PROJECTSclang;lld;mlir \ -DLLVM_ENABLE_RUNTIMESlibcxx;libcxxabi;compiler-rt \ -DLLVM_TARGETS_TO_BUILDX86;AArch64;RISCV \ -DLLVM_CCACHE_BUILDON \ -DLLVM_ENABLE_ASSERTIONSON \ -DCMAKE_C_COMPILERclang \ -DCMAKE_CXX_COMPILERclang我逐个解释为什么这样选以及每项背后的坑。-DCMAKE_BUILD_TYPERelease默认的 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/UBSancompiler-rt 就必须构建。-DLLVM_TARGETS_TO_BUILD默认是全平台支持构建时间直接翻倍。绝大多数人跑在 X86 上就够了我自己实验用到了 AArch64 和 RISC-V 的交叉编译所以加了这两个。目标越少构建越快生成的二进制定位越精确。-DLLVM_CCACHE_BUILDON这个选项会帮你自动配置 ccache不需要你自己设置CCACHE_环境变量是这个版本才引入的贴心功能旧版本需要手动设置。-DLLVM_ENABLE_ASSERTIONSON这个很多人纠结。开断言会让编译器在自己运行的时候做一堆内部检查性能略受影响但在开发调试期非常有用。如果你计划给 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 分钟跑完 clanglld。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如果能正常打印说明工具链核心流程已经跑通了。接下来可以进一步测试 lldbuild/bin/clang -fuse-ldlld -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 -passesfunction(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::vectorstd::vectordouble; 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::vectordouble(N, 1.0)); Matrix B(N, std::vectordouble(N, 2.0)); Matrix C(N, std::vectordouble(N, 0.0)); matmul(A, B, C); printf(%f\n, C[0][0]); return 0; }这段代码虽然简单但它的三重循环在指令层面其实存在巨大的优化空间。系统自带的 gcc/g 也能优化但我想看看 clang 在这个场景下能做到什么程度尤其是结合 lld 之后可执行文件的整体性能会有什么变化。4.2 编译、压测和对比我用三种组合编译分别压测可执行文件的运行耗时g -O2clang -O2clang -O2 lld 链接g -O2 matmul.cpp -o matmul_gcc build/bin/clang -O2 matmul.cpp -o matmul_clang build/bin/clang -fuse-ldlld -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::vectorstd::vectordouble的结构导致内存访问模式是“两层间接寻址”编译器很难在编译期完全推断出各个 vector 的基地址。这就是为什么很多高性能计算矩阵库要用一维数组加手写索引而不是用vectorvectordouble。我用一维数组改写了矩阵存储方式之后再次对比 IR里面的循环结构就完全不同了内层出现了一批和 SIMD 相关的向量指令序列。这说明编译器不是不能做而是需要代码给它足够的“可见性”。这类认知只有打开 IR 看到优化结果才真正进入内心深处。这也是 llvm-project 这个仓库带给开发者最大的礼物它可以让你看到“编译器对你代码的真实评价”这种反馈远比一个可执行文件的运行时间来得有信息含量。4.4 sanitizer 加持下的错误捕获llvm-project 的 compiler-rt 提供的 sanitizer 工具对日常开发的帮助同样重要。我经常在调试 C 项目时用 AddressSanitizerASan来抓内存越界和悬垂指针问题。配置好 ASan 的构建很简单假如我已经安装了工具链编译命令大致是build/bin/clang -fsanitizeaddress -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_BUILDON已经有独立的 ccache 缓存的话还会自动复用。LLVM_TARGETS_TO_BUILD没有限制默认全平台。解决办法是只保留自己需要的。另外如果第一次构建时用的机器内存偏小建议直接用 Ninja 并限制并行任务数到 4 以下。我看到过 8GB 内存的机器全并编辑直接 OOM 的情况那是相当崩溃的现场。我把常见问题整理成了一张排查表方便临时查阅症状可能原因排查命令/手段解决办法编译过程中 OOM并行任务数过高或 Debug全后端配置free -h观察剩余内存降低 -j 并行数换 Release减少 Targets链接阶段找不到符号libc 和工具链版本不匹配grep undefined build.log检查LLVM_ENABLE_RUNTIMES重新构建 libcclang 编译自己失败系统 gcc 库头文件版本太新观察报错头文件路径指定兼容的 C stdlib或升级 clang 版本增量构建不生效cmake 配置变更导致全量重建观察 ninja 输出大量编译日志尽量不开新配置或用 ccache 缓解opt -passes报未知 pass 名pass 名字拼写错误或版本差异opt -print-passes查询当前版本支持的 pass 列表安装到 /usr/local 后和系统工具冲突部分工具同名覆盖which clang、clang --version使用非系统目录DESTDIR安装或直接用 build/binlld 链接某些库报“重排”错误编译器与链接器版本错配查看 lld 警告信息使用同一次构建的 clang 与 lld5.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 旅程里也能找到那种“原来编译器是这样看我代码的”的豁然开朗感。