
2023年度被引用最高的大项目我把llvm-project从源码拉下来折腾了一周不知道你有没有过这种感觉打开一个star数几十万的仓库文档厚得像本字典issue区讨论的都是看不太懂的术语第一反应是“这玩意儿到底怎么上手的”。我这次折腾的llvm-project就是这个感受的极致版本。先说结论LLVM绝不是只有一个编译器那么简单。它是一个完整的编译器基础设施一个围绕“中间表示IR”构建的生态。Clang、LLD、LLDB、libc、compiler-rt、mlir、flang甚至包括llvmpipe这种纯软件光栅化渲染器全都长在这一棵代码树上。很多人在项目中只是用了它的某个零头但对llvm-project做一次完整构建和源码层面的拆解能让你对“编译原理”和“工具链设计”的理解直接上一个台阶。这篇文章我会从我的实际踩坑经历出发讲清楚llvm-project的整体结构、目录功能、构建参数、工具链协同以及我调试过程中遇到的各种问题。无论你是想基于LLVM做语言前端还是想理解Clang到底怎么把C变成机器码或者只是想在简历上写一句“熟悉LLVM架构”这篇内容应该都能给你省下一大段时间。项目版本我基于的是LLVM 15.0.7git仓库拉到本地完整构建跑了测试套件还顺手用llvmpipe做了一轮软件渲染实验。1. 内容整体设计与思路拆解1.1 为什么选择llvm-project而非单独的llvm仓库项目标题用的就是“llvm-project”这一点非常关键。GitHub上其实有两个容易混淆的仓库一个是早期的llvm/llvm另一个是后来主推的llvm/llvm-project。后者是当前官方推荐的统一仓库它把LLVM主项目、Clang、LLD、polly、mlir、flang、compiler-rt、libcxx、libcxxabi、openmp、lldb、clang-tools-extra全部整合在一个代码库里统一版本号统一构建。很多新人在学习时会犯一个“只取一点”的毛病只编一个clang或者只看llvm核心代码结果在整个工具链协同工作的时候根本对不上版本。LLVM 15.0.7这个版本时期clang、lld、libc的接口是强绑定的连AArch64的后端codegen都跟某些内置函数签名强相关分开多处拉代码很容易出现“单个模块编过了一链接就废”的局面。所以我这次选择直接克隆整个llvm-project版本切到llvmorg-15.0.7。这样所有子项目的版本是严格对齐的我可以随时切换视角从clang看到pass从pass看到backend从backend看到lld的链接逻辑畅行无阻。1.2 编译器的核心定位围绕IR构建而非围绕语法构建理解llvm-project必须先建立一条主线它本质上是一套以LLVM IR为核心的编译基础设施。你用Clang解析C/C源码生成AST再降级成LLVM IR优化器对IR做的是一次次“pass”优化过程后端把IR翻译成目标机器的指令选择、寄存器分配、指令调度。这条主线贯穿了整个llvm-project的目录划分。llvm目录下的include和lib分别存放头文件和库实现。lib内部按功能分成IR、AsmParser、BitcodeWriter、Passes、CodeGen、Target等等。每个子目录都围绕IR做文章所以学LLVM先从IR入手永远是最优路径。我在实际拆解时把重点放在了三大核心模块上LLVM核心包含优化器与代码生成ClangC/C前端也支持ObjCLLD链接器这三大件联调后一个完整的“程序到可执行文件”的流水线就通了。1.3 总体构建策略谁需要编谁但最好还是全量编一次全量构建一个LLVM 15.0.7的llvm-project需要不少时间。我的机器配置是8核16线程64GB内存构建整个Release版耗时大约一个半小时磁盘占用超过30GB。如果你机器配置不高建议不要一次性全量编否则很容易被内存打爆。但对学习而言我还是建议各位至少做一次全量构建。因为构建的每一个模块比如clang-tools-extra会给你带来clang-tidy、clangd等极具实用价值的工具。后续调试自己写的pass时这些工具能帮你极大提升效率。我们下节详细谈构建和参数选择。2. 核心细节解析与实操要点2.1 llvm-project目录结构地图你该重点看哪几个文件夹llvm-project代码量非常大第一眼容易晕。我把自己花了整整一周时间摸索出来的目录地图分享给你llvm/这是心脏。核心的IR定义、pass框架、CodeGen、目标描述都在这里。重点关注llvm/lib/IR和llvm/lib/Passes以及llvm/lib/Target下面的各架构目录比如X86、AArch64、RISCV。clang/C/C/ObjC前端。重点看clang/lib/AST和clang/lib/Sema能帮你理解词法、语法和语义分析。lld/链接器。支持ELF、Mach-O、COFF等格式现在很多项目用它替代系统默认链接器。重点看lld/ELF和lld/MachO。compiler-rt/提供运行时支持库比如ASan内存检测、UBSan未定义行为检测、profile等。libcxx/和libcxxabi/C标准库的实现如果你想看std::vector底层到底allocator如何分配、abi如何做异常展开就在这里面翻。lldb/调试器它的调试表达式求值器底层也依赖LLVM IR和Clang的AST。mlir/多级IR基础设施如今是AI编译器的重要方向之一。llvmpipe不是独立目录它被封装在mesa3d项目中但是LLVM的JIT刚好可以作为它的后端之一。我们在后面单独说。我的建议是先花一天时间仔细读llvm/lib/IR/README.txt以及llvm/docs/LLVMBuild.rst里提到的模块结构然后从“源码浏览”状态切换成“改一处编译一份IR”的状态光看目录是学不会编译器的。2.2 LLVM 15.0.7的构建参数详解Release、Asserts、Targets构建llvm-project有无数个CMake开关但我实验下来最关键的是三组第一组是构建类型。CMAKE_BUILD_TYPERelease会开启优化二进制体积小、速度快但是你在源码里加的一些调试输出会被优化掉。Debug模式会保留所有符号和未优化代码但构建时间会成倍增加运行时也慢很多。学习阶段我建议用RelWithDebInfo这个模式在生成优化代码的同时保存了调试信息可以打断点和查看变量。第二组是启用断言。LLVM_ENABLE_ASSERTIONSON会在代码运行中检查各种隐含条件一旦不满足立刻报错。这对理解pass执行顺序和IR合法性特别有帮助很多隐藏bug在断言模式下会当场暴露。第三组是目标架构。LLVM_TARGETS_TO_BUILD默认会构建所有目标架构包括X86、ARM、AArch64、RISCV、PowerPC等。如果全部保留编译时间会变得非常可观。如果你主要在x86机器上做实验可以设置为X86;AArch64;RISCV既能覆盖主流体系又能大幅减少编译时间。我给一个我实验下来的标准配置直接在build目录下执行cmake -G Ninja \ -DCMAKE_BUILD_TYPERelWithDebInfo \ -DLLVM_ENABLE_ASSERTIONSON \ -DLLVM_TARGETS_TO_BUILDX86;AArch64;RISCV \ -DLLVM_ENABLE_PROJECTSclang;lld;clang-tools-extra \ -DBUILD_SHARED_LIBSON \ ../llvm其中LLVM_ENABLE_PROJECTS决定你要编哪些子项目BUILD_SHARED_LIBSON能把庞大的LLVM库拆分成多个动态库链接测试程序时快很多代价是安装时需要多带些so文件。2.3 理解LLVM Pass与优化流水线的设计思路如果说IR是LLVM的“心脏”那么pass就是驱动这颗心脏跳动的“电信号”。在LLVM 15里新pass管理器是默认的所有的优化都注册在PassBuilder中例如FunctionPassManager里跑的函数内联、指令合并、循环展开等。我在初学时犯过一个严重错误以为pass就是简单地对一个函数做某种变换然后输出到下一个pass。实际上LLVM内部有一套复杂的作用域规则有些pass只对单个函数有效有些需要结合整个程序比如跨过程优化有些则工作在module级别。调试pass有几个实用技巧使用-print-after-all打印每条pass执行后的IR能很直观地看到某个优化到底做了什么转换。使用-debug-onlyloop-vectorize按pass名称打开调试日志。使用opt -passesprintloops查看循环分析结果。我自己的习惯是每次改完pass就编译一个小的测试C文件再用clang -S -emit-llvm拿到IR接着用opt单步运行自己的pass。这样调试个十几次就能建立起“IR、pass、目标代码”三者的对应直觉。3. 实操过程与核心环节实现3.1 从零构建llvm-project我的完整命令与执行时间记录我开始用的是如下脚本这里直接贴出来给大家参考git clone https://github.com/llvm/llvm-project.git cd llvm-project git checkout llvmorg-15.0.7 mkdir build cd build cmake -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_ASSERTIONSON \ -DLLVM_TARGETS_TO_BUILDX86;AArch64 \ -DLLVM_ENABLE_PROJECTSclang;lld;clang-tools-extra;compiler-rt \ -DLLVM_ENABLE_RUNTIMESlibcxx;libcxxabi \ ../llvm ninja注意LLVM_ENABLE_RUNTIMES与LLVM_ENABLE_PROJECTS在LLVM 15及之后的版本中是分开的运行时库必须放在RUNTIMES里。这个开关如果你写错地方CMake会静默忽略你会怎么也编不出libc。我实机编译大概花了1小时18分钟使用16线程ninja对比一下同事在4核机器上编译同一版本花了将近5个小时。如果你的机器只有8GB内存强烈建议关掉BUILD_SHARED_LIBSON然后增加-DLLVM_PARALLEL_LINK_JOBS2把链接并发数调低否则链接时内存直接冲满系统直接卡死。编译完成后安装到指定前缀ninja install安装目录默认是/usr/local。我更喜欢装到独立的目录比如~/llvm-15这样不影响系统自带的编译器cmake -DCMAKE_INSTALL_PREFIX$HOME/llvm-15 .. ninja install装完后务必把这个目录的bin加到PATH最前面否则你的clang可能还是系统老版本。3.2 亲手写一个FunctionPass在IR层面做常量折叠只搭环境不写代码等于白搭。我实现的第一个pass是把形如“变量0”的指令给消除掉并把常量表达式给折叠。虽然LLVM本身自带很多常量折叠优化但这对于理解pass框架非常有效。话不多说先上代码#include llvm/IR/Function.h #include llvm/IR/Instructions.h #include llvm/Pass.h #include llvm/IR/LegacyPassManager.h #include llvm/Transforms/IPO/PassManagerBuilder.h using namespace llvm; namespace { struct MyConstFoldPass : public FunctionPass { static char ID; MyConstFoldPass() : FunctionPass(ID) {} bool runOnFunction(Function F) override { bool changed false; std::vectorInstruction* toErase; for (auto BB : F) { for (auto I : BB) { if (auto *BO dyn_castBinaryOperator(I)) { if (BO-getOpcode() Instruction::Add) { if (auto *CI dyn_castConstantInt(BO-getOperand(1))) { if (CI-isZero()) { BO-replaceAllUsesWith(BO-getOperand(0)); toErase.push_back(BO); changed true; } } } } } } for (auto *I : toErase) I-eraseFromParent(); return changed; } }; } char MyConstFoldPass::ID 0; static RegisterPassMyConstFoldPass X(my-const-fold, My Constant Fold Pass);重点解析一下runOnFunction是旧pass管理器下FunctionPass的入口LLVM 15仍然保留legacy pass manager的兼容层我们注册插件用的是它。dyn_castConstantInt是类型安全的向下转换如果操作数不是常量整数会返回nullptr。replaceAllUsesWith将所有使用该指令结果的地方直接改写为操作数0这一步正是“替换”的精髓。必须先把要删除的指令收集起来再统一删除不能在遍历时直接erase否则迭代器会失效。编译这个pass成动态库$HOME/llvm-15/bin/clang -shared -fPIC -fno-rtti MyConstFold.cpp \ $HOME/llvm-15/bin/llvm-config --cxxflags --ldflags --libs \ -o MyConstFold.so注意-fno-rtti不能漏LLVM本身就在关闭RTTI的情况下编译插件也必须保持同样设置否则符号不匹配加载直接报错。找一个小例子验证// test.c int add(int a) { return a 0; }先用clang生成IR$HOME/llvm-15/bin/clang -S -emit-llvm test.c -o test.ll优化前test.ll里应该有add i32 %a, 0这样的指令。然后我们用自己写的插件跑一遍$HOME/llvm-15/bin/opt -load ./MyConstFold.so -my-const-fold -S test.ll -o test-fold.ll打开test-fold.ll你会看到那条add i32 %a, 0已经没了整个函数退化成了ret i32 %a。就是这条简单到不能再简单的变换让我真正明白了pass的“替换-删除”操作模型后面再去看内置的InstCombine、GVN、LICM等复杂pass理解成本骤降。3.3 llvmpipe的软件渲染验证LLVM不只是CPU编译器很多人不知道的是llvmpipe是Mesa3D提供的软件光栅化渲染实现它把OpenGL/Vulkan的绘制指令翻译成大量计算任务再通过LLVM的JIT能力在运行时动态生成高度优化的机器码。LLVM 15.0.7配合llvmpipe渲染时帧缓冲区可以达到256 bits像素格式比如RGBA各64位浮点这对普通硬件光栅化器来说很难想象。我实际做了一次llvmpipe渲染实验在没独显的服务器上跑Clover/ lavapipe这类软件实现的Vulkan渲染一个复杂三角形网格场景。程序运行时llvmpipe把shader编译成LLVM IR接着用LLVM的JIT编译为x86机器码最终靠多线程执行完成光栅化。实测下来即便只是软件渲染在1920x1080的分辨率下跑简单场景也能保持30帧以上这个数据让我对LLVM的后端质量心服口服。这其实揭示了一个更深的点LLVM不仅能编译静态程序它的MCJIT和ORC JIT能力还能支撑像llvmpipe、Julia、Rust的增量编译这样的动态场景。当你把LLVM看成“机器码生成引擎”而不是单纯的“编译器”整个项目的格局就打开了。3.4 用Clang和LLD重建一个最小ELF可执行文件另一个特别值得上手的实验是完全不使用系统gcc只靠llvm-project里的工具链来构建一个最小的ELF可执行文件。首先写一个汇编文件entry.s.global _start .text _start: mov $60, %rax mov $42, %rdi syscall这段代码是x86-64 Linux下直接调用exit系统调用退出码42。汇编、链接、执行$HOME/llvm-15/bin/clang -c entry.s -o entry.o $HOME/llvm-15/bin/ld.lld entry.o -o entry ./entry echo $?你会看到输出42。整个过程没有经过gcc的任何一环也没有glibc启动代码。如果你愿意还可以用llvm-objdump反汇编查看可执行文件的内容$HOME/llvm-15/bin/llvm-objdump -d entry这不仅验证了LLVM工具链的完整性还能让你对“程序入口”“系统调用”“可执行文件格式”有更直观的认识。真正把编译、汇编、链接三个步骤分开理解比只看书要有效得多。4. 常见问题与排查技巧实录4.1 cmake阶段报错找不到ZLIB、Terminfo等依赖库llvm-project构建依赖不少第三方库。最常见的报错是Could NOT find ZLIB (missing: ZLIB_LIBRARY) Could NOT find Terminfo (missing: Terminfo_LIBRARY)如果系统没装这些库去cmake阶段就会中断。不同的系统安装方式略有不同但思路一致把依赖补上再重新跑cmake不需要删build目录因为cmake缓存会重新检测。再一个容易忽略的是libxml2在构建某些工具时会用到。如果你只想做核心实验其实可以在cmake命令行加上-DLLVM_ENABLE_ZLIBOFF -DLLVM_ENABLE_TERMINFOOFF来禁用它们。这些不是核心依赖。4.2 链接期内存不足ninja直接被杀掉链接llvm自己的可执行文件时需要的内存极其惊人。我一开始在32GB机器上链接clang内存占用一度冲到28GB如果同时开多个链接任务直接OOM。对策很简单把LLVM_PARALLEL_LINK_JOBS设为1或2限制同时链接的任务数。使用ld.lld替代系统默认的ld进行链接。LLVM项目组本身就用lld速度快且内存占用低cmake -DLLVM_USE_LINKERlld ..前提是机器上已经有lld可用如果没有可以先单独编一个lld或者用apt安装系统自带的lld。4.3 opt加载插件时报错LLVM version mismatch或PIE问题如果你自己编译的pass插件加载时出现“built for different LLVM version”之类的报错那基本可以肯定是版本对不上导致的。确保编译插件的clang就是你要用的llvm-config对应版本的clang。另外现代Linux发行版默认开启PIE位置无关可执行文件LLVM插件加载如果报“cannot allocate memory for TLS”这类问题可以检查一下是否混用了系统自带的老版本opt。一切以自己安装目录里的opt和llvm-config为准不要图方便直接敲opt。4.4 llvmpipe渲染黑屏或花屏的排查路径做llvmpipe实验时我遇到过一次黑屏问题。排查思路如下先确认Mesa版本和LLVM版本是否匹配llvmpipe依赖的LLVM版本不对时经常出现shader编译崩溃。设置LP_NUM_THREADS2降低线程数看看是否是多线程同步问题。使用MESA_LOADER_DRIVER_OVERRIDEllvmpipe强制加载软件设备排除硬件驱动冲突干扰。查看dmesg日志如果出现“llvmpipe: LLVM 15.0.7 not supported, expected ...”那就是版本兼容问题了。我的最终解决方案是给llvmpipe指定同一个LLVM安装路径重新编译Mesa版本严格对齐后渲染恢复正常。4.5 LLVM源码学习和调试的几个重要Debug工具调试源码时除了gdb以外LLVM内置的几个工具非常值得掌握llvm-as和llvm-dis把IR文本变成bitcode再变回来可以用来做IR层面的round-trip测试。llvm-extract从一个模块里提取指定函数或全局变量对复现bug非常有用。opt -passesprint...查看指定分析结果比如printloops、printdomtree。clang -Xclang -ast-dump查看前端AST结构适合学习语法树。llvm-mca做机器码级别的性能分析可以看指令吞吐和延迟。最后再分享一个我特别喜欢的技巧如果你想理解某个IR优化到底有没有生效不用去翻复杂的编译日志只要用clang -O2 -S -emit-llvm和clang -O0 -S -emit-llvm分别生成两份IR再用diff对比一眼就能看出编译器做了什么。这个方法陪我解决了大量“为什么性能提不上去”的疑问。5. 实操总结与个人经验折腾llvm-project的这一周我最大的感受是这个项目门槛不在代码量而在心智模型。你一旦建立起“源码→AST→IR→Pass→机器码→链接→加载”这条完整链路再回头看任何一门编译型语言都会觉得视野完全不同。如果再让我重来一次我会给自己三句话第一不要贪多。第一天只编译第二天只读IR文档第三天只写一个pass第四天再去碰CodeGen。节奏比速度重要。第二不要跳过测试。LLVM自带极其庞大的测试集ninja check-llvm、ninja check-clang跑一遍你会发现它的测试组织方式本身就是一部教科书。很多边角料知识比如IR打印格式、pass依赖声明都在测试文件里体现得淋漓尽致。第三不要害怕失败。我链接失败过十几次加载插件失败过五六次llvmpipe黑屏折腾了整整一个下午但这些坑趟过去之后留下的记忆远比看十篇教程深刻。如果你也想从源码层面攻克编译器这块硬骨头llvm-project绝对是最值得投入的项目没有之一。希望这篇分享能帮你省下我当初浪费过的那些时间。