很多人第一次接触 llvm-project是在编译器课程或者看 Clang 相关新闻的时候。这个项目确实庞大一个仓库里塞了几十个子项目代码量以百万行计说实话第一次 git clone 的时候我心里也发怵。但真正花时间把链路理清楚后会发现LLVM 的设计思路其实相当清晰而且现代编译领域的很多基础设施——从 IDE 的代码补全到新语言的编译器实现再到芯片厂商的工具链适配——都绕不开这套东西。这篇博文我想从一个实际的开发者视角拆一拆 llvm-project 到底在解决什么问题它的核心模块是怎么配合的以及如果你想把整个项目跑起来、甚至在里面加点自己的东西应该从哪里下手。无论你是对编译器感兴趣的本科生还是工作中需要给某个 DSL 写编译器、需要深度定制工具链的工程师这篇内容都会对你有用。1. llvm-project 是什么为什么值得折腾1.1 先搞清楚这张项目地图llvm-project 是围绕 LLVM 编译器基础设施建立起来的一整套开源项目集合。你可以把它理解成“造编译器所需的乐高积木”。传统编译器通常是铁板一块——GCC 就是一个典型的整体式编译器前后端绑定得很死。而 LLVM 的思路是把编译过程拆成三段前端负责把源代码变成一种统一的中间表示IR中端在这个 IR 上做平台无关的优化后端再把优化后的 IR 翻译成不同 CPU 架构的机器码。所以 llvm-project 这个仓库里不只有“LLVM”这一个项目它还包括了 C/C 编译器 Clang、调试器 LLDB、链接器 LLD、C 标准库 libc以及后面加入的 MLIR、Flang 等等。任何一个子项目单独拎出来都足够深入研究但它们是共享同一套 IR 设计和基础设施的。这个项目厉害的地方在于IR 是整个生态的“通用货币”。只要你的语言前端能产出 LLVM IR你就能立刻享受到后面几十个架构的后端支持以及十几年来社区积累的大量优化 pass。这就是为什么 Rust、Swift、Julia 这些语言都选择把 LLVM 作为自己的编译后端——它们只需要专注写好前端剩下的代码生成问题交给 LLVM。1.2 什么人适合阅读这篇内容如果你属于下面任何一类这篇博文都适合你正在学编译原理想做点实验但不知道从哪下手的学生工作中要给公司内部 DSL 写编译器想调研 LLVM 方案的工程师要用 LLVM 做静态分析、代码插桩、混淆混淆、性能分析方向的工具开发者搞芯片工具链、想为自家新架构写 LLVM 后端的从业者或者只是单纯好奇“天天听人说 LLVM它到底是啥”的非编译器方向的程序员。不管哪种情况你都会发现 llvm-project 的门槛主要不在代码难度而在对整体架构的理解上。只要把架构看懂了后面上手是非常顺的。2. 整体设计背后的大逻辑2.1 三段式架构前端、中端、后端LLVM 的设计核心就是我上面提到的前端—中端—后端三段式。这里我用一个具体的例子说明假设有一行 C 代码int add(int a, int b) { return a b; }前端 Clang 会先把这行代码解析成抽象语法树AST然后做语义分析、类型检查最后翻译成 LLVM IRdefine i32 add(i32 %a, i32 %b) { entry: %sum add i32 %a, %b ret i32 %sum }这段 IR 是平台无关的既不代表 x86 也不代表 ARM。你可以把它想成一种“伪汇编”但它不是给真实机器读的而是给 LLVM 自身的优化和后端框架消费的。中端优化器会对这段 IR 做各种等价变换比如常量折叠、死代码消除、循环优化。后端拿到优化后的 IR才根据目标机器生成真正的汇编指令。这个三段式结构最直接的好处是组合自由。如果你想支持一门新语言只需要写那个语言的前端输出 LLVM IR后面全都有了。反过来如果你想支持一种新的 CPU 架构只需要把后端写好所有接入 LLVM 的语言就自动获得了对这套架构的编译支持。理论上一劳永逸实际工程里当然还是有不少细节要打磨但整体框架给了大家一个共同协作的基础。2.2 和 GCC 的整体式架构相比优势在哪里GCC 是这个领域绕不开的老前辈到现在仍然是 Linux 内核的默认编译器之一。但它的架构决定了做实验、做工具链定制会麻烦不少。GCC 的中间表示 GIMPLE 是 C 语言风格的和具体语言绑定较紧前后端都揉在一个进程里想要单独复用某一段非常困难。如果你想给一门新语言写编译器而后端还想用 GCC那基本意味着你得把 GCC 的整个插件机制啃透难度很高。LLVM 从一开始就把“可作为基础设施复用”作为目标。op、analyzer、lld、llvm-cov 这些工具都是独立可执行文件有清晰的输入输出边界。这意味着学术界、产业界可以很方便地在 LLVM IR 这一层做文章而不需要把整个编译器改得面目全非。比如做代码混淆的团队他们通常就是在 IR 层写一个优化 pass想插什么逻辑都行想对程序做控制流分析的更是把 IR 当宝贝。另外一点很实际LLVM 的 IR 设计比 GIMPLE 更规整它是严格的静态单赋值SSA形式变量只赋值一次。虽然 SSA 的概念有点术语范儿但本质上是给优化提供了一种很好操作的约束条件——每个值的定义点和使用关系一目了然分析起来非常干净。2.3 源码树里藏着哪些子项目clone 完 llvm-project 后首层目录会看到一堆子目录很多人第一反应是“眼晕”。我给新手整理一个速览表子项目作用谁在重度使用llvm核心框架IR 定义、优化 pass、后端、各种命令行工具所有人clangC/C/Objective-C 前端日常编译、IDE、静态分析clang-tools-extraclangd、clang-tidy 等辅助工具开发环境、代码检查lldb基于 LLVM 的调试器调试场景lld高性能链接器现在很多项目用它替代系统 ld构建系统、发布工具链compiler-rt提供一些编译器运行时支持函数sanitizer、profile、原子操作等libc / libcabiC 标准库实现C 项目mlir多级 IR 基础设施用于机器学习、编译器自定义AI 编译器领域flangFortran 前端科学计算不要急着每个都看先从 llvm 和 clang 这两个目录入手就够了。尤其 llvm 目录里 lib/ 和 tools/ 子目录你看懂这两个地方后面就顺了。3. 核心细节拆解IR、Pass 与后端代码生成3.1 LLVM IR 是所有中间环节的通用语言IR 是 LLVM 的灵魂。它有三种表示形式本质内容完全一致内存中的 C 对象、磁盘上的 bitcode.bc、可读的文本形式.ll。日常调试和分析主要用文本形式的.ll文件你可以手动写也可以用工具生成。看一段 .ll 代码define i32 factorial(i32 %n) { entry: %cmp icmp sgt i32 %n, 0 br i1 %cmp, label %then, label %done then: %dec sub i32 %n, 1 %rec call i32 factorial(i32 %dec) %mul mul i32 %n, %rec ret i32 %mul done: ret i32 1 }这段代码表达了递归计算阶乘的逻辑。可以看到每个基本块basic block以 label 开头内部是一串指令最后以一条终结指令br 或 ret结束。变量名以%开头而且遵循 SSA 规则每个变量只被赋值一次。这会给写优化 pass 带来极大的方便。IR 的关键作用在于“随时随地可以被读取、分析和修改”。比如你想给程序加一个统计函数调用次数的功能不必改源代码直接在 IR 层写一个 pass遍历所有 call 指令插入一个全局计数器然后修改返回值逻辑就行。这种玩法在传统整体式编译器里几乎不敢想象。要生成 IR 文件最常用的是 Clang 命令clang -S -emit-llvm foo.c -o foo.ll如果想生成可读性更好的 IR可以加-O0并配合-fno-discard-value-names保留变量名调试的时候非常有用。3.2 Pass 与优化管线LLVM 中所有优化和分析的逻辑都封装在“pass”里。一个 pass 就是一段访问 IR 并对它做变换或分析的代码。比如instcombine做指令化简gvn做全局值编号消除冗余计算licm做循环不变代码外提等等。早期 LLVM 的 pass 管理是用一个 legacy PassManager后来社区发现它并行性差、依赖管理混乱所以 LLVM 14 开始默认切换到了新 PassManager。新 PM 的一个明显变化是你在命令行用opt工具优化 IR 的时候参数从老的-instcombine变成了-passesinstcombine并且支持通过-passesdefaultO2这样的写法直接引用内置优化流水线。如果你想自己写一个新 pass最快捷的方法是用 LLVM 提供的NewPM框架。一个简单的函数级 pass 看起来像这样#include llvm/IR/Function.h #include llvm/IR/InstIterator.h #include llvm/Pass.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h using namespace llvm; namespace { struct MyPass : public PassInfoMixinMyPass { PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { outs() Processing function: F.getName() \n; return PreservedAnalyses::all(); } }; } extern C ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, MyPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name my-pass) { FPM.addPass(MyPass()); return true; } return false; }); }}; }写完后用-fPIC -shared编译成.so动态库就能通过opt -load-pass-plugin./mypass.so -passesmy-pass加载使用。看似魔法其实本质就是把你的代码挂在 PassBuilder 的回调里等 opt 收到对应的 pass 名字时调用它。对初学而言不用一上来就深入优化算法的数学原理而是先跑通“写一个 pass — 编译 — 加载 — 看效果”的循环然后再逐步去研究某个特定优化为什么这样设计。这个过程能让你对编译器内部运作建立直觉。3.3 后端是怎么把 IR 变成机器码的很多人对 LLVM 后端的印象是“黑魔法”其实后端也是分层处理的。从 IR 到目标机器指令主要经历这么几个阶段指令选择Instruction Selection、指令调度Scheduling、寄存器分配Register Allocation、代码布局Layout然后生成目标汇编或目标文件。指令选择阶段LLVM IR 指令会被映射到目标架构的指令。比如一条 IR 加法%sum add i32 %a, %b在 x86 后端很可能选择addl指令。这个阶段在远古版本用的是 SelectionDAG先把 IR 翻译成一个 DAG 图做模式匹配匹配到对应的指令模式就完成了选择。这种方式功能强但代码复杂编译速度不尽如人意。所以 LLVM 现在大力推广基于 GISelGlobal Instruction Selection的框架它在 AArch64、RISC-V 等架构上已经比较成熟。如果你要写一个全新的后端强烈建议从 GISel 入手而不是再去碰 SelectionDAG。寄存器分配是把无限数量的虚拟寄存器放到物理寄存器上的过程。x86 的寄存器就那几个通用寄存器程序里的变量可能成百上千寄存器不够用就往内存栈里溢栈spill。LLVM 默认用的是贪心寄存器分配算法它在寄存器压力不高时质量很高在压力高时也能妥善处理 spill 代码的插入。这一块如果想深入优化可做的工作非常多比如针对特定 CPU 微架构重排 spill 位置以提高缓存命中率。最后生成目标文件时LLVM 会通过 MCMachine Code层处理汇编解析、目标文件格式ELF、Mach-O、COFF等再交给汇编器变成.o文件最后由 lld 链接成可执行文件。4. 实操从零构建 llvm-project4.1 构建准备与参数选型下载 llvm-project 有两种推荐方式。第一种是 git clone 官方仓库这样方便切换分支和做二次开发git clone https://github.com/llvm/llvm-project.git --depth 1第二种是下载官方发布的源码压缩包代码体积更小适合只想稳定使用的场景。我平时调代码就用 git clone因为你会频繁git log和git blame。构建工具方面CMake 加 Ninja 是标配。Ninja 比 make 快很多而且增量编译极其可靠。configure 之前你需要评估自己打算构建哪些子项目。全部构建会非常慢而且需要大量磁盘空间。常见的最小集合是cmake -G Ninja ../llvm-project/llvm \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86;AArch64 \ -DLLVM_CCACHE_BUILDON \ -DLLVM_USE_LINKERlld需要逐项解释一下CMAKE_BUILD_TYPERelease是最重要的参数。如果你忘了设默认是空字符串结果是 Debug 版本编译速度慢、生成的二进制巨大、运行也慢。开发工具链请尽量用 Release除非你真的需要调试 LLVM 自身才考虑 RelWithDebInfo。LLVM_ENABLE_PROJECTS控制要构建哪些“外部”项目。官方文档里所有项目都写在声明里你只写你需要的。LLVM_TARGETS_TO_BUILD控制要生成哪些后端。默认是构建所有后端构建时间和内存都会大幅上升。作为入门者先构建 X86 就够了后续需要 ARM/RISC-V 再添加。LLVM_CCACHE_BUILDON会启用 ccache能大幅加速重复构建。如果你还没装 ccache可以先apt install ccache或brew install ccache。LLVM_USE_LINKERlld启用 lld 作为链接器因为 LLVM 自身的代码量很大用系统默认的 ld 链接会比较慢lld 能显著缩短链接时间。内存方面LLVM 的 C 模板实例化非常疯狂链接器内存占用也很高。实测最少需要 8GB RAM如果并行开太多构建任务16GB 内存也不算富裕。保守方案先ninja -j4如果机器内存大可以ninja -j$(nproc)。4.2 构建过程实录与分析配置完之后进入项目根目录执行ninja clang lld opt llc这几样是日常最常用的工具clang 是编译器前端lld 是链接器opt 是优化工具llc 是 IR 到汇编代码生成器。先构建这几个就够用了。第一次构建十有八九会花比较长的时间Release 模式下我手头这台 8 核机器大概需要半小时到一小时。看到进度条停留在 100% 的时候你会发现自己收获了一整套可以用bin/里直接调用的工具链例如./bin/clang -S -emit-llvm test.c -o test.ll ./bin/opt -passesinstcombine test.ll -S -o test.opt.ll ./bin/llc test.opt.ll -o test.s这一步如果出现错误不要慌后面我会专门列常见问题。构建成功以后建议顺手跑一遍 C 标准测试集可选但这个比较耗时初学阶段跳过也没关系。4.3 用构建产物做一个最小验证实验为了验证你的工具链真的能用我习惯做一个非常小的例子。建一个文件hello.cint add(int a, int b) { int c a b; int d c * 2; return d; }执行# 1. C 源码 - LLVM IR ./bin/clang -S -emit-llvm hello.c -o hello.ll # 2. 查看 IR 内容 cat hello.ll # 3. 用优化器做一次 O2 级别优化 ./bin/opt -passesdefaultO2 hello.ll -S -o hello.opt.ll # 4. 生成目标汇编 ./bin/llc hello.opt.ll -o hello.s # 5. 用 clang 做最终链接输出可执行文件 ./bin/clang hello.s -o hello如果中间任何一步报错说明你的工具链某个环节有问题。这个流水线式验证能帮你快速定位问题出在前端、中端还是后端。我用这个 demo 给同事做新手引导他们普遍反馈“看到一整套流水线跑通之后LLVM 突然就没那么神秘了”。5. 常见问题与排查技巧实录5.1 构建期的高频坑实际构建过程中我踩过的坑比想象的还要多有些问题社区里反复出现这里做个速查表症状原因解决办法配置时报缺少 zlib 或 libxml2缺少系统依赖安装对应开发包比如apt install zlib1g-dev libxml2-dev编译 OOM 或链接 OOM并行任务过多内存不足降低-j的并发数配置时额外加-DLLVM_PARALLEL_LINK_JOBS1限制链接并发ld: cannot find -lstdc一类错误缺少 running C runtime 开发环境安装 gcc/g 编译器和标准库开发包LLVM 用 system compiler 构建自身很常见打开 nmake 或 Visual Studio 报错Windows 上用了不合适的构建器Windows 推荐用 Visual Studio 的 CMake 生成器或者 LLVM 官方文档推荐的 clang-cl Ninja 方案构建完成后运行 clang 提示error: unable to execute command: Segmentation fault代码与 LLVM 版本不匹配或环境变量 LD_LIBRARY_PATH 问题检查你是否设置了奇怪的环境变量或用lldb调试定位问题CMake 报TableGen相关错误TableGen 生成了错误的表格文件先重新构建 TableGen 目标ninja llvm-tblgen clang-tblgen再编译后续目标其中链接 OOM 是最常见的。LLVM 代码库非常大Release 模式下链接器需要大量内存。即使编译器编译成功了链接阶段也可能因为内存不足被杀掉。我当时用的办法是配置时显式加上-DLLVM_PARALLEL_LINK_JOBS1这会把链接任务串行化速度慢一点但内存占用大幅下降稳妥很多。还有一个容易忽略的坑LLVM_ENABLE_PROJECTS写错了项目名字。比如把lld拼成lddCMake 配置阶段不会报错只是它自动忽略掉不认识的子项目等你发现的时候可能已经浪费了很多构建时间。配置完建议检查一下CMakeCache.txt里的LLVM_ENABLE_PROJECTS字段。5.2 日常开发中的调试技巧一旦你开始写自己的 pass 或者改 LLVM 代码下面这些技巧能为你省下大量时间优先用-print-after-all观察每次 pass 改变 IR 的效果。这是学习优化 pass 最好的方式。命令行./bin/opt -passesdefaultO2 -print-after-all hello.ll -S -o /dev/null它会把每一个 pass 执行后的 IR 全部打印出来能直观看到哪一步做了什么变换。想看更具体的某个 pass用-print-afterinstcombine。善用-debug和-debug-only。很多 pass 内部带有调试输出例如指令选择阶段可以用-debug-onlyisel查看详细过程。注意opt、llc的-debug输出可能十分庞大先用-debug-only缩小范围。写 pass 时用 LLVM_DEBUG 宏。在代码里加LLVM_DEBUG(dbgs() my debug message\n);然后在运行时带-debug-onlymypass这样调试默认不显示需要时再开。这比自己 print 到 stdout 好用得多。学会看 IR 验证错误。新 PM 默认会在每个 pass 之后做 IR 验证如果你的 pass 破坏了 SSA 结构或产生了非法指令LLVM 的 verifier 会立刻报错并指出出错位置。早期看到Broken module found别慌那说明你这个 pass 写错了仔细看它给出的文件位置和指令基本一查一个准。跑测试集来验证改动。LLVM 自带一个基于lit的测试框架。写完一个 pass 后在test/目录下建一个.ll测试文件用RUN指令写出你的预期输出然后执行ninja check或者单独跑llvm-lit。它能帮你防止回归。5.3 顺手分享的一个小经验最后分享一个我在实际改代码时才学会的经验多留意 LLVM 的代码生成日志和注释它们是最真实的技术文档。很多人遇到函数不懂第一反应去查文档。但 LLVM 更新极快很多表达式、命令行参数已经变了网上旧教程根本对不上。这时候你去看源码里对应的注释尤其是llvm/lib/Transforms下的 pass 实现注释往往能把一个奇怪选项的来龙去脉讲得清清楚楚。我记得第一次接触llvm/lib/Passes/PassBuilder.cpp时看到那个庞大的buildPipeline函数真的有点打退堂鼓。但静下心读它的注释按功能往下推发现它不过是用一系列条件判断把一个个 pass 串联起来。当你养成“读代码、调参数、看日志、再验证”的习惯后LLVM 就不再是让人望而生畏的黑箱子而是一个可以随你拆装改造的积木工具盒。如果你也想做点实验就去 clone 一个 llvm-project先把hello world的 IR、优化、汇编、链接完整跑一遍然后试着写一个什么也不做、只是打印函数名的 MyPass。不要怕慢也不要怕编译报错这些过程本身就是最好的学习材料。
