
很多搞底层开发的朋友第一次看到llvm-project这个仓库时多少会有点懵。它不是一个“编译器”而是一整套编译器基础设施从前端语法分析、中间表示优化到后端指令生成、链接器、标准库甚至调试器和JIT引擎全都塞在一个项目里。过去十年里我们熟悉的Clang、Rust编译器、Swift工具链甚至还有跑在CPU上的软件渲染管线llvmpipe底层用的都是这套东西。这篇文章我想以实际使用者的视角把llvm-project拆开揉碎讲一遍。先带你认识它到底包含了什么、为什么这么设计再实战一把从源码构建整个项目顺便聊聊LLVM IR、优化pass以及llvmpipe在15.0.7版本下启用256位向量单元这个细节。无论你是想入门编译器开发还是日常写C想用Clang换换口味又或者纯粹好奇GPU渲染背后那部分CPU兜底逻辑这篇文章都值得你花十分钟读完。所有的过程和踩坑我都用实际跑过的例子来讲尽量不写空话。1. 内容整体设计与思路拆解1.1 拆开“llvm-project”这个大仓库先把这个仓库的结构说清楚。很长一段时间里LLVM源码是拆成llvm、clang、lld、libcxx等多个独立仓库管理的后来为了简化版本对齐和构建流程官方把它们统一收进了llvm-project。所以当你git clone这个仓库时看到llvm/、clang/、lld/、libc/这些目录并列在一起是很正常的。每个目录本质上是一个拥有自己构建体系的子项目统一由顶层的CMake把它们组织起来。其中最重要的几个组成部分LLVM Core包含LLVM IR的定义、优化器、目标后端X86、ARM、RISC-V等以及纯库形式提供的编译器基础设施。ClangC/C/Objective-C前端传统gcc的替代者也是普通人最容易接触到的LLVM产品。日常敲clang main.c就是在用它。LLD一个高性能链接器和GNU ld兼容性很好链接速度通常比传统ld快数倍。libc 与 libcabiC标准库实现和libstdc形成竞争关系。compiler-rt提供各种运行时支持库比如__divti3这类编译器辅助函数、sanitizer运行时AddressSanitizer等。MLIR、Polly、Flang、OpenMP这些属于扩展项目分别对应多层级IR、循环优化、Fortran前端和并行运行时。你还会经常听到llvmpipe它其实是Mesa3D项目的一部分并不是LLVM官方仓库的子项目。它做的事情很纯粹当系统没有可用GPU驱动时用CPU来跑OpenGL管线其中所有shader的JIT编译工作就是借助LLVM完成的。后面我会用llvmpipe (llvm 15.0.7, 256 bits)这个现象来展开说明LLVM后端如何影响到CPU渲染性能。1.2 为什么我觉得LLVM“模块化”才是真灵魂很多人以为LLVM的核心卖点是“开源编译器”我一开始也这么觉得但用了一段时间后发现真正让它解锁各种可能性的是那套高度模块化的库设计理念。传统编译器比如早期GCC把前端、优化、后端深度耦合你给一种新语言写前端时几乎要了解整个编译器的内部流程。而LLVM把编译过程抽象成一条流水线前端把源代码翻译成 LLVM IR优化器对IR做各种语义保留的变换后端再把IR映射成目标机器的汇编或机器码。三层之间通过明确定义的IR解耦每个环节都能被单独抽取出来复用。可以理解为一家餐厅LLVM Core是中央厨房前端是各个菜系的门店Clang做C菜系Rust前端做Rust菜系后端是配送车队。只要你的门店按统一的菜单格式出菜中央厨房和配送车队都不需要关心这道菜是中餐还是西餐。这样一来想为一种新语言构建编译器你只需要写好“前端”立刻就能免费继承LLVM成熟的优化器和几十种CPU、GPU后端。Rust早期直接复用LLVM后端Swift也在用它这比从零写一套跨平台代码生成器的成本低了几个量级。实际上我在本地做实验时就经常只链接LLVMCore、LLVMSupport、LLVMX86CodeGen这几个库写一个几百行的自定义pass来处理IR根本不需要启动完整Clang。如果你还在为“编译器是个黑盒”而发愁LLVM这套模块化设计恰好给你开了一扇随时可以把手伸进去的门。2. 核心细节解析与实操要点2.1 LLVM IR读懂三种表示形式才算入了门LLVM IR是整个框架的心脏。我常跟朋友说搞懂LLVM IR你就搞懂了LLVM一半。它有三种等价的表示形式内存格式编译器在内存中维护的对象图由Module、Function、BasicBlock、Instruction等C类构成。文本格式带.ll后缀、人类可读的IR也是你分析编译器行为时最常用的载体。字节码格式带.bc后缀通常用来快速加载到后续工具比如LTO链接时优化时嵌入到目标文件里用的就是这种。想快速看一段代码生成了什么样的IR最简单的方式是clang -S -emit-llvm foo.c -o foo.ll我用一个超短例子演示。假设源码是int add(int a, int b) { return a b; }生成的IR大致长这样define i32 add(i32 %a, i32 %b) { entry: %add add nsw i32 %a, %b ret i32 %add }在这个例子里i32就是32位整数类型add是函数名%a、%b是虚拟寄存器。LLVM IR最反直觉也最核心的一点是SSA形式每个变量只允许赋值一次。这样设计并非拍脑袋而是因为SSA能天然暴露数据依赖关系优化器做死代码删除、公共子表达式消除、重写循环时都依赖这张依赖图。初学时最痛苦的也是这里你会发现在IR里没法像普通命令式语言那样随便改某个变量的值想保留新值得新建一个虚拟寄存器。理解这一点看pass就顺畅多了。很多朋友问我要不要深入研究汇编级指令选择我的建议是不搞后端开发的话看懂IR到优化pass这一层完全够用。但IR本身的花样非常多比如phi节点、landingpad异常处理、metadata调试信息等属于深入LLVM优化和调试信息生成时绕不开的题目。2.2 优化pass弄清楚 -O2 到底干了些啥优化是LLVM最体现工程价值的部分。平时你用Clang、Rust甚至Swift无一例外都享受过LLVM优化器带来的性能提升。在默认的 -O2 管道里几十个pass按固定顺序执行常见的有ADCEAggressive Dead Code Elimination删除不可能被外部观察到的死代码有时甚至能顺着某些已固定的条件把整个分支都剪掉。InstCombine模式匹配驱动的算术化简比如x 0直接变成xx*2可能被重写成左移。GVNGlobal Value Numbering跨基本块消除值冗余避免重复计算相同表达式。LoopVectorize识别循环中对数组或向量的操作把它向量化让CPU的SIMD单元真正派上用场。举一个实际例子我写过一个求数组和的循环int sum(int *arr, int n) { int s 0; for (int i 0; i n; i) { s arr[i]; } return s; }如果不优化生成的IR里能看到分支指令和加载、加法、跳转这样的基本结构。但开了-O2你会看到循环被展开还会用向量指令一次加载多个元素再累加甚至尾递归部分被重构。说白了-O2不是某一条优化命令而是几十个pass精心编排的结果。想查看某一轮pass作用后的中间产物可以用clang -O2 -S -emit-llvm foo.c -o foo.ll opt -passesdefaultO2 -S foo.ll -o foo.o2.ll这在你调性能、分析代码生成是否符合预期时帮助非常大。2.3 Clang与GCC之争改用什么前端其实有讲究Clang在相当多场景下已经可以完全替代GCC但两者依然有自己的侧重点。我这里从实际体验角度逐条对比对比维度Clang/LLVMGCC编译速度通常更快前端解析和IR生成占优大项目有时编译较慢诊断信息报错和警告可读性强带高亮和修复提示近年来改进很大但风格偏传统模块化扩展极好LTO、插件、自定义pass非常顺手相对封闭内部API变动大架构覆盖ARM、X86、RISC-V等普遍覆盖老架构支持有时更全默认ABI同一平台默认基本一致可能与Clang在异常处理等ABI细节上略有差异我真正从GCC迁移到Clang的契机不是编译速度而是诊断信息。有一次我写了一行很复杂的模板元编程代码GCC报出来的错误信息有上万行夹在一片no matching function里完全找不到重点。换成Clang后它直接把冲突的模板实例位置标出来了旁边附上来自哪个文件、哪个展开层级省下半天调试时间。但GCC也没到被淘汰的地步。一些特定平台或嵌入式工具链仍然以GCC为默认编译器某些不寻常的内联汇编和BCI二进制兼容行为GCC反而更“宽容”。我的建议是主力开发用Clang但交叉编译或对接老项目时保留GCC作为对照编译器的能力会省很多心。3. 实操过程与核心环节实现3.1 从源码构建 llvm-project一次完整的体力活构建llvm-project本身是个偏“体力”的流程但每个环节都有讲究值得细致走一遍。我以LLVM 15.0.7版本为例系统是Ubuntu 22.04构建Release版。第一步克隆仓库。这个仓库很大包含全部历史的话拉下来要好几个GB。我建议用--depth1只拉取最近一次提交git clone --depth1 --branch llvmorg-15.0.7 https://github.com/llvm/llvm-project.git cd llvm-project第二步创建构建目录。LLVM官方支持直接在源码目录下构建但我强烈建议另建目录比如build-release这样不会污染源码树也方便同时维护Debug和Release多个构建。然后执行cmake配置cmake -G Ninja -S llvm -B build-release \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86;AArch64 \ -DLLVM_ENABLE_ASSERTIONSON这里每个参数都有意义-G Ninja用Ninja作为构建系统比make快不少并行度也控制得更好。-DLLVM_ENABLE_PROJECTSclang;lld决定除了LLVM核心之外还要额外构建哪些子项目。只写clang;lld能把时间至少砍掉三分之一。如果你以后还想用libc、compiler-rt再往里追加即可。-DLLVM_TARGETS_TO_BUILDX86;AArch64限制后端目标。很多人默认不设置结果把几十种CPU后端全编了耗时和磁盘占用飙升。所以我强烈建议按需裁剪。-DLLVM_ENABLE_ASSERTIONSONRelease模式下默认关闭断言但对于开发调试和排查问题断言保留能暴露不少未定义行为。折中方案是开。第三步正式编译。先看机器核数nproc然后起Ninja并行任务。我习惯按核数/1.5左右开线程避免把机器卡死cd build-release ninja -j 8Release构建在我的机器上大约耗时40到60分钟具体取决于CPU、内存和磁盘。如果内存紧张建议-j 4或更低否则容易把16GB内存直接吃满。构建完成后检查一下可执行文件./bin/clang --version能正确看到clang version 15.0.7这类输出就说明构建成功。3.2 快速验证用你自己的Clang跑一个Hello World构建完只是第一步我还习惯用一道“三明治测试”来确认整套工具链能正常工作从源码到IR再到目标文件逐层打通。先写一个简单的C程序#include stdio.h int main(void) { printf(hello llvm-project\n); return 0; }用自建Clang编译运行cd build-release ./bin/clang hello.c -o hello ./hello正常输出hello llvm-project。接着把中间产物dump出来看看./bin/clang -S -emit-llvm hello.c -o hello.ll这个.ll文件就是前端生成的LLVM IR。你可以在里面看到main函数、printf的声明以及调用指令。这是理解Clang前端行为的绝佳入口。如果你还想进一步压榨性能可以加-O2看IR变化或者用./bin/llc hello.ll -o hello.s把LLVM IR转成X86汇编观察每条IR指令最终映射成了什么指令。我每次给新手演示时让他们看这条链路结束后再回去对比最初的C代码基本都能秒懂“编译器不是魔法只是一层一层做翻译”。3.3 自定义Pass实验给优化器“插个队”Clang认可用-fpass-plugin加载自定义pass这是不少人玩LLVM的第一个进阶实验。我们写一个最简单的不动信号的pass在每次函数被处理时往标准错误打一行日志。新建LogPass.cpp#include llvm/IR/Function.h #include llvm/IR/IRBuilder.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h using namespace llvm; namespace { class LogFunctionPass : public PassInfoMixinLogFunctionPass { public: PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { errs() [LogPass] visiting function: F.getName() \n; return PreservedAnalyses::all(); } }; } // namespace extern C ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, LogPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name log-function) { FPM.addPass(LogFunctionPass()); return true; } return false; }); }}; }编译成动态库clang -shared -fPIC LogPass.cpp -o LogPass.so $(llvm-config --cxxflags --ldflags --libs)然后调用clang -O2 -fpass-plugin./LogPass.so -c hello.c -o hello.o如果一切正常你会看到每个函数名都被打印出来。这个方法在分析优化器到底处理了多少函数、某个pass是否生效时特别有效。做深入学习时还可以在pass里直接修改IR实现函数内联、循环展开等这就是写编译器插件的雏形。3.4 剖析“llvmpipe (llvm 15.0.7, 256 bits)”这一行输出背后如果你在无独显或驱动未加载的Linux环境跑过OpenGL应用大概率见过类似llvmpipe (LLVM 15.0.7, 256 bits)的渲染器字符串。这一行老玩家调侃叫“显卡战未来”实际意思很直白GPU硬件光栅化路径不可用渲染完全靠CPU上的llvmpipe软件实现。llvmpipe作为Mesa的软件渲染器管线流程依然按照OpenGL标准走顶点着色器、片段着色器等都需要执行。但CPU没有GPU那种大规模并行执行单元怎么把shader跑快答案就是LLVM的JIT能力。llvmpipe在运行时把GLSL对应的IR丢给LLVMLLVM针对当前CPU特性生成优化后的机器码然后立刻执行。256 bits指的是LLVM后端检测到当前CPU支持AVX2/AVX-512的一部分256位向量指令于是在生成shader机器码时向量的宽度按256位处理一次能处理8个32位浮点数。这里的关键不是频率多高而是SIMD利用率多高。我之前在一台没装独立显卡的老服务器上跑过一个初级的OpenGL渲染测试对比了强制禁用SIMD和启用256位路径的差异。在默认能吃到AVX2的路径下一个简单的全屏渐变片段着色器能跑到30fps左右一旦用MESA_GLSL_VISUAL或环境变量强制向量宽度降下来帧率会明显下滑。这背后确实是LLVM的向量化循环和指令选择在起作用它让现代CPU的向量单元被真正用了起来。这也解释了为什么一个“软件渲染器”在特定环境下并不是完全不可接受至少用来做无GPU环境下的验证、CI测试是完全够用的。4. 常见问题与排查技巧实录4.1 编译期和链接期的坑十个人九个人遇到过构建LLVM核心本身其实相对成熟但如果你做二次开发、链接自己的pass或集成LLVM库时问题就会开始冒头。以下是我踩过的几个高概率问题问题一undefined reference to llvm::...出现这类错误绝大多数情况是链接时根本没把必要的LLVM库加全或者顺序错了。用llvm-config --libs可以获得完整的库列表注意一定要把它放到源文件之后。如果你只用了少数几个模块也可以手动只链接LLVMCore、LLVMSupport、LLVMX86CodeGen不要一把梭全库链接速度和符号冲突都会好一些。问题二版本不匹配导致ABI崩溃LLVM的C API没有严格稳定的ABI我见过有人把系统里预装的libLLVM-14和自编译的15.0.7头文件混用结果程序运行到一半直接Segmentation fault。排查时第一件事就是确认ldd ./your_binary | grep llvm llvm-config --version确保编译期头文件和运行期动态库来自同一次构建。凡是长期做LLVM集成的人都应该把“版本对齐”当铁律。问题三ninja过程中内存爆掉默认并行度太高或者Debug模式元数据膨胀很容易触发OOM。我一般先设置-j 4或-j 8且优先考虑减少LLVM_TARGETS_TO_BUILD毕竟X86和AArch64对我来说已经够用。如果内存实在紧张也可以考虑-DLLVM_USE_SPLIT_DWARFON把调试信息拆开链接期负担会小很多。4.2 运行期警告与性能排查经验速查表现象可能原因建议操作报错Could not create thread线程池/并行任务数过多降低-j或检查llvm::ThreadPool配置开启自定义pass后IR没变化pass没被注册或返回了PreservedAnalyses::all()用opt -print-after-all看pass执行顺序llvmpipe渲染特别慢向量宽度过低或shader太复杂检查环境是否真正启用了AVX2优化shader循环尽量用向量运算链接时PLT offset too large通常出现在大二进制/大库场景尝试-fPIC统一编译或改用-Wl,-z,now看是否缓解编译C Rich Code时内存爆炸模板展开或IR过重开-g0或-gline-tables-only减少调试信息或升级内存4.3 避开隐坑调试符号、PIC、断言选择Debug版LLVM构建极吃磁盘和内存我见过不少人为了加一行errs()就全量编Debug版编译完再运行慢如蜗牛。更优雅的方式是用Release Assertions保留LLVM_ENABLE_ASSERTIONSON这个组合在绝大多数情况下既能看断言又不至于让构建慢一倍以上。调试具体行为时可以用-gline-tables-only替代完整调试信息把符号表体的体量降下来但保留行号映射。另外如果你要开发.so给其他程序动态加载记住所有涉及LLVM头文件的编译单元都要加-fPIC。我最初写LogPass.so时把这个忘了加载时报了一串重定位错误排查半天才发现是PIC问题。llvm-config --cxxflags通常会帮你带上但手动编译时很容易漏掉。5. 从构建者到使用者给新手的几个建议如果看完上面这些你对llvm-project产生了兴趣我建议按一条“性价比最高”的路去走先用官方发布的二进制版本把常见工具链跑熟再逐步尝试源码构建、跑自定义pass最后再去翻IR生成和指令选择这类深水区。不要一上来就啃编译原理大部头那会快速消耗耐心。有几个我实操下来觉得很有用的习惯分享给你建立环境变量别名把构建目录下的bin加入PATH之前先用绝对路径调用避免和系统自带Clang混淆。我踩过“明明构建好了跑出来的还是系统老版本”这种蠢坑。多构建目录并存一个build-release、一个build-debug面向不同任务。Release版用于跑性能和日常编译Debug版用于断点、验证pass行为。二者井水不犯河水。善用opt和llc这俩是分析IR、验证后端行为的一对利器。遇到优化结果不对把.ll导出来用opt -passes...一步步缩小范围比盲目调-O2更快找到问题。关注llvmpipe这类实战场景想让LLVM落地于真实项目Mesa、Polly、Flang、MLIR这些大型实践都是极好的学习样本。比如前面提到的llvmpipe (LLVM 15.0.7, 256 bits)一行渲染器字符串就能把IR、JIT、后端指令宽度、SIMD优化全部串起来学习价值极高。技术选型上我也体会到一个朴素道理像LLVM这种开源基础设施最大的价值往往不只是“免费”而是它把编译、优化、生成代码这件事彻底透明化给了每个开发者按需定制的能力。想想看你能在编译器中插一段自己的代码观察它在优化管道中的行为这种掌控感是闭源工具链给不了的。聊到这里llvm-project能走多远取决于你愿不愿意花一个周末把它编译一遍、写个pass、看看IR。等你真正把第一段自定义优化跑通再回头看那些高高在上的“编译器”概念会发现它不过是一套按规矩办事的乐高积木。根据我自己的经验完整从源码构建一次LLVM再亲手用自建Clang编译一个带AVX2向量化的C程序你对“工具链如何工作”的理解会上一个台阶。这个项目后续还能玩出很多花样比如接入MLIR做神经网络编译、通过Rust的rustc_codegen_llvm看看Rust后端怎么复用LLVM、又或者给Mesa中的llvmpipe换一个JIT轮子。总之先把源码拉下来跑通一次属于自己的LLVM你已经比大多数只看文档的人更靠近底层了。