LLVM项目实战:从源码构建到自定义Pass与llvmpipe协作解析 近些年在做编译器相关工具链的选型和落地时llvm-project 几乎是绕不开的一个名字。不管是做静态分析、代码插桩、GPU 渲染软件栈还是单纯想搞懂 Clang 和 GCC 的差异最终都会落到这个庞大的项目上。我最初接触 LLVM 是从 llvmpipe 这个软件渲染器开始的当时为了搞清楚某个图形栈在无 GPU 环境下的渲染路径一路从 Mesa 追踪到了 LLVM 的 IR 层才发现这套基础设施比想象中要复杂得多也强大得多。这篇博客就按照我实际摸索的路径把 llvm-project 的项目结构、核心模块、源码构建和常见坑点拆开梳理一遍希望能给正在入门或在选型中犹豫的朋友一些参考。1. 项目全景llvm-project 到底是什么LLVM 这个名字现在基本等同于一个庞大的编译器工具链集合而并非单一工具。你在 GitHub 上看到的 llvm-project 仓库是官方统一维护的 monorepo包含 LLVM 核心库、Clang 前端、LLD 链接器、libc 标准库实现、compiler-rt 运行时库、OpenMP 运行时、Polly 循环优化器以及一大批辅助工具。以 15.0.7 这个版本为例它已经是相当成熟的稳定版被大量下游项目包括 Mesa、Blender、各种国产编译器分支用作基线版本。很多初学者会把 LLVM 简单理解为“Clang 背后的库”这个理解其实不够准确。LLVM 的真正价值在于它的中间表示IR和围绕 IR 设计的整套优化框架。Clang 只是把 C/C 代码翻译成 IR 的一个前端而 llvm-project 里同时还有 FlangFortran 前端、MLIR多层级 IR 框架、LLVM 的 JIT 引擎、跨平台的后端代码生成器这些才是它区别于 GCC 等传统编译器项目的核心优势。引用一个比较直白的对比GCC 是“一个完整的编译器”而 LLVM 是“乐高积木”你可以用它的库自由组合出新的编译器、新的代码分析工具、新的语言前端。llvmpipe 就是这种模块化优势的典型案例它用 LLVM 作为 JIT 后端把图形渲染管线中的顶点着色器、片段着色器编译成当前 CPU 架构的原生 SIMD 指令从而实现纯软件渲染。这条路子在 GCC 体系下几乎走不通但在 LLVM 体系下就成了常规操作。1.1 核心子项目与各自定位llvm-project 仓库里目录很多但实际高频使用的主要就是下面这几个llvm/这是核心目录包含 IR 定义、优化 pass 实现、目标后端x86、ARM、RISC-V 等、MC 层机器码生成、表驱动指令选择器TableGen。你日常写优化 pass 或者做自定义后端的入口基本都在这里。clang/C/C/Objective-C 的前端负责词法分析、语法分析、语义分析然后生成 LLVM IR。Clang 的静态分析器clang-tidy、clang-check 等也在这个目录下。lld/官方链接器支持 ELF、Mach-O、COFF、WebAssembly 等格式速度比 GNU ld 快很多而且命令行参数兼容性做得很好。libcxx/和libcxxabi/C 标准库实现和 ABI 层。如果你想在非 Linux 平台跑新的 C 标准特性或者想摆脱 libstdc 的包袱这两个子项目就是替代方案。compiler-rt/提供各种运行时支持包括 AddressSanitizerASan、UndefinedBehaviorSanitizerUBSan等动态分析工具的实现。polly/基于多面体模型的循环优化器能对嵌套循环做自动并行化、向量化和数据局部性优化。这个项目有点“听起来厉害但实际接入成本高”的意思适合有高性能计算背景的人研究。mlir/多层级 IR 基础设施最初是 LLVM 的副产品现在已经成为 AI 编译器、硬件设计验证、量子计算等领域的关键组件。它允许你定义自己的中间表示层然后逐步降到 LLVM IR。这里需要注意一个版本匹配的问题。llvm-project 虽然是一个仓库但各个子项目之间是强关联的比如 Clang 15 通常匹配的 libc 是 15.xcompiler-rt 也是配套的。如果你想单独升级某一个组件很容易遇到 ABI 不兼容或接口对不上的问题。所以在做工具链选型时建议直接用 release 分支的完整快照而不是自己任意混搭版本。1.2 为什么 llvmpipe 要用 LLVMllvmpipe 是 Mesa 3D 图形库中的一个软件渲染器它的目标是“在完全没有 GPU 的环境下也能运行 OpenGL 4.5 级别的程序”。这个目标的难点在于性能纯软件逐像素计算的开销极大必须充分利用 CPU 的 SIMD 指令集SSE、AVX 等来加速。llvmpipe 的思路是通过 LLVM 的 IR 来描述 GPU 着色器的逻辑然后利用 LLVM 的后端把 IR 编译成当前 CPU 支持的最优 SIMD 指令序列。你可能会问为什么不直接用 C 写死一套渲染内核而是费劲绕到 LLVM答案是灵活性和动态编译能力。着色器语言 GLSL 的语法和逻辑是运行时才能知道的llvmpipe 在加载一个着色器时会先把它翻译成 TGSIMesa 的着色器中间表示再翻译成 LLVM IR最后 JIT 编译成机器码。举个例子你在 Blender 里用一个自定义 GLSL 着色器它里面有大量的浮点运算和纹理采样操作。llvmpipe 拿到这个着色器后会针对你当前 CPU 的 AVX2 指令集动态生成对应的 SIMD 代码。这样在同一段渲染逻辑上AVX2 的 CPU 能比只有 SSE2 的旧 CPU 快出好几倍。而这一切对上层应用是透明的应用只看到 OpenGL API完全不知道背后发生了什么。256 bits 这个关键词在这个语境下非常关键它指的是 AVX2 的向量寄存器宽度是 256 位。在 LLVM 15.0.7 中llvmpipe 可以借助这个宽度一次性处理 8 个 32 位浮点数的运算从而实现真正的并行计算。这也是为什么 llvmpipe 的渲染性能在近年有显著提升的原因之一LLVM 的向量化能力和目标指令集的匹配程度直接决定了软件渲染器的天花板。2. 源码结构解析从目录名到作用范围如果你是第一次接触 llvm-project看到顶层目录列表可能会有点懵。这里会有很多“看起来名字差不多其实分工完全不同”的目录。我花了不少时间才把这些目录对应到具体功能上这里整理一份比较实用的速查表帮你快速建立映射关系。目录路径核心作用常见入口工具llvm/lib/IR/IR 的核心数据结构和基础操作optllvm/lib/Passes/优化管线和 pass 注册机制optllvm/lib/Transforms/各类优化 pass 的实现向量化、内联、循环展开等optllvm/lib/Target/各个 CPU 架构的后端实现llcllvm/tools/各种可执行工具opt、llc、llvm-config 等命令行工具clang/lib/Driver/Clang 驱动逻辑负责解析命令行参数并调用各阶段工具clangclang/lib/Sema/语义分析检查类型和名称解析clangclang/lib/CodeGen/从 AST 生成 LLVM IR 的代码生成逻辑clang -emit-llvmlld/ELF/ELF 格式的链接逻辑ld.lldcompiler-rt/lib/asan/AddressSanitizer 的实现配合clang -fsanitizeaddress这里重点说下llvm/lib/Target/这个目录。当你用llc命令把一个.ll文件编译成机器码时就是通过 Target 目录下对应的后端完成的。每一个后端的结构都是独立的子目录比如X86/、ARM/、AArch64/、RISCV/。启动一个自定义 CPU 架构的编译器工作本质就是在这个目录下新增一个 Target 子目录然后定义好指令选择、寄存器分配、指令调度等回调。TableGen.td文件是 LLVM 里一个很独特的机制它用领域特定语言描述指令集架构信息然后 LLVM 的构建系统会在编译时自动生成 C 代码。比如你打开llvm/lib/Target/X86/X86InstrInfo.td里面定义了几百上千条 x86 指令的信息包括操作码、操作数、语义属性构建时 TableGen 会把这堆声明转换成可查询的数据表。这个设计让添加一条新指令变得相对简单只需要改.td文件然后重新构建那一个 Target 子目录就行。另外要提一下llvm/include/llvm/这个头文件目录。这里的头文件是 LLVM 公共 API 的必经之路分为llvm/IR/IR 核心类型、llvm/CodeGen/代码生成接口、llvm/Analysis/各种分析 pass 的头文件等。做二次开发时绝大多数情况都是先在这个 include 目录里找到接口声明再去对应的lib子目录看实现。3. 从源码构建 LLVM 15.0.7 的完整流程官网和一些博客会直接提供一个已经编译好的二进制包但如果你想用 LLVM 做深度开发写自定义 pass、调试后端、改前端行为从源码构建是必须经历的过程。而且源码构建能让你对 LLVM 的组成结构有更深的感知毕竟直接用二进制包的话你永远不会体会到一个完整 LLVM 工具链的构建过程到底多耗时、多依赖磁盘 IO。3.1 版本与依赖选择在构建 LLVM 15.0.7 之前有几个关键的版本约束需要确认。LLVM 15 对 CMake 的最低要求是 3.20同时需要有支持 C17 的编译器GCC 7.1 以上或 Clang 5.0 以上均可以。实际构建时我更建议用比较高版本的 GCC 或 Clang比如 GCC 11 以上因为新版编译器对 C17 的特性支持更完整构建速度和生成的代码质量都会更好。构建 LLVM 还需要两个关键依赖zlib 和 libxml2可选的取决于是否开启相关功能。在 Debian/Ubuntu 系统上可以用下面的命令把常见依赖装齐sudo apt update sudo apt install -y build-essential cmake ninja-build python3 \ zlib1g-dev libxml2-dev libncurses-dev注意这里的ninja-build它比make构建速度快很多而且支持增量构建更高效。LLVM 项目体量巨大用 Makefile 构建的体验非常糟糕非常不推荐。使用 Ninja 的话全量构建 LLVMClanglld 在主流配置下可能需要 30~60 分钟而用 Make 可能要翻倍。3.2 CMake 配置参数详解接下来是配置构建。LLVM 官方推荐使用 out-of-source 方式也就是在源码目录之外单独建立一个 build 目录。我一般这么做git clone --branch llvmorg-15.0.7 --depth 1 \ https://github.com/llvm/llvm-project.git cd llvm-project mkdir build cd build cmake \ -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DCMAKE_INSTALL_PREFIX/opt/llvm-15 \ -DLLVM_ENABLE_PROJECTSclang;lld;compiler-rt \ -DLLVM_TARGETS_TO_BUILDX86;AArch64;RISCV \ -DLLVM_ENABLE_ASSERTIONSON \ -DLLVM_OPTIMIZED_TABLEGENON \ ../llvm这里有几个参数要认真解释。LLVM_ENABLE_PROJECTS是枚举你需要的子项目多个项目用分号分隔。需要注意libcxx和libcxxabi不能直接放在这个列表里它们需要额外的配置流程或者作为 runtimes 编译。LLVM_TARGETS_TO_BUILD控制后端架构的种类默认是全部但编译时间会特别长。你如果只在 x86 环境使用只写X86就够了。加上AArch64和RISCV的目的是为了做交叉编译或者验证多种架构的代码生成不强制。LLVM_ENABLE_ASSERTIONSON这个选项建议在开发调试时打开。它会在代码里启用大量的运行时断言一旦出现不变量被破坏会直接崩溃并打印详细诊断信息。这虽然会降低 LLVM 自身的性能但能帮你尽早发现问题。LLVM_OPTIMIZED_TABLEGENON这参数容易忽略但它对构建速度影响巨大。它的作用是用优化编译方式构建一次 TableGen 工具之后生成代码时不会再重复编译 TableGen 自身的工具。3.3 构建与安装配置完成后开始正式编译ninja -j$(nproc) ninja install-j$(nproc)是让 Ninja 使用所有 CPU 核心并行编译。第一个ninja命令执行完后产物都在build/bin/目录下可以不用 install 直接试用。如果你希望通过 PATH 全局访问就把/opt/llvm-15/bin追加到.bashrc的 PATH 环境变量里。ninja install这一步是可选的好处是安装目录干净、无构建产物混杂后续通过环境变量管理起来方便。提示如果你内存比较小比如只有 8GB建议加一个-DLLVM_PARALLEL_LINK_JOBS4参数限制并行链接任务数否则链接阶段可能瞬间吃掉 16GB 以上的内存导致 OOM。3.4 验证工具链是否可用构建完成后可以用下面的命令验证基本功能是否正常echo int main() { return 0; } | /opt/llvm-15/bin/clang -x c - -o /tmp/hello /opt/llvm-15/bin/llvm-as --version /opt/llvm-15/bin/llc --version第一个命令是直接用 Clang 编译一个最小的 C 程序能通过说明 Clang 前端和系统头文件的集成没问题。llvm-as是 IR 汇编器llc是静态编译器后端。运行--version时你会看到类似LLVM version 15.0.7的输出同时能看到这套构建支持的 target 列表。我踩过的一个坑是编译时没有把 Clang 的默认头文件路径和库路径设置正确。如果你使用clang /tmp/test.cpp时出现找不到stddef.h或iostream这类头文件的报错大概率是因为 Clang 链接的是系统默认的 libstdc 头文件目录而系统又没装 GCC 相关的头文件包。解决办法就是apt install libstdc-XX-dev把完整的 C 头文件同步环境装上。4. llvmpipe 与 LLVM 15 的协作机制现在回到本文最开始提到的 llvmpipe。不少人只是在 Chromium 或 Blender 的日志里看到过 llvmpipe 这个名词但并不知道它内部和 LLVM 的协作关系。理解了这一层你对 llvm-project 整体架构的把握会明显上一个台阶。4.1 llvmpipe 的编译管线llvmpipe 在整个 Mesa 图形栈中的位置大致是OpenGL 应用 - Mesa 状态追踪器 - 着色器编译器GLSL 到 TGSI - LLVM JIT - 软件光栅化器。当应用上传一个着色器程序时llvmpipe 会触发一个编译过程把 GLSL 代码经过一系列降级最终变成 LLVM IR然后交给 MCJIT 或 ORC JIT 编译成机器码。在编译过程中有几个关键点值得关注。首先llvmpipe 会把每个渲染像素看作一个 SIMD 向量元素所以它会求 LLVM 后端能把 IR 向量化到多宽。LLVM 15 中 x86 后端默认支持的向量宽度是 128 位SSE但通过 AVX2 可以获得 256 位。llvmpipe 在启动时检测 CPU 特性如果发现 AVX2 可用就会在 IR 生成阶段显式采用宽度为 88 个 float的向量类型。其次llvmpipe 不只是把着色器 JIT 编译一次。它还会根据不同的绘制状态生成不同版本的渲染管线代码。比如混合模式变了、深度测试开关变了都会触发重新编译。这种动态方式的性能开销在初始化时会比较明显但换来的运行时效率远比解释执行高得多。4.2 256 bits 的实际价值“256 bits”在 llvmpipe 的上下文里基本等同于 AVX2 指令集的代名词。当你看到软件渲染器的日志中出现llvmpipe (LLVM 15.0.7, 256 bits)时就说明当前进程的 llvmpipe 正在使用 256 位向量路径渲染。为什么 256 位这么重要因为图形像素计算天然是大规模并行任务一个 4x4 的像素块如果一次只处理 4 个像素128 位 SIMD比一次处理 8 个像素256 位 SIMD在吞吐量上就差了一倍。而且现代 CPU 的 AVX2 单元功耗和延迟都已经相当优化用它做渲染密集运算的性价比很高。当然用更宽的向量也意味着寄存器压力更大指令编码更复杂llvmpipe 内部需要精心设计数据处理布局。从上层视角看llvmpipe (LLVM 15.0.7, 256 bits)这个字符串出现在你的 OpenGL 扩展信息里是一个很明显的性能暗示。说明当前环境没有可用的硬件加速但软件渲染器用 AVX2 做了均衡性能补偿。如果你在无 GPU 的 CI 机器上跑图形测试看到这个字符串就不用太惊慌至少说明渲染路径是完整且可用的。4.3 如何让 llvmpipe 深入使用 LLVM 的新版特性Mesa 项目在 llvmpipe 中对 LLVM 的版本有比较严格的绑定关系。以 Mesa 23.x 为例它官方支持的 LLVM 版本是 15 到 17。也就是说如果你自己编译了一份 LLVM 18 想给 llvmpipe 用很可能在构建 Mesa 时就会因为版本检查不通过而失败。在构建 Mesa 时启用 llvmpipe 的关键 CMake 参数是-Dllvmenabled -Dshared-llvmenabled。动态链接 LLVM 更推荐因为 Mesa 多个模块比如 radv、lavapipe可能同时依赖 LLVM动态链接可以共享同一份 LLVM 运行时内存占用也更低。调试时你还可以通过环境变量LP_NUM_THREADS控制 llvmpipe 的线程数GALLIUM_DEBUGsoftpipe或者直接看MESA_LOADER_DRIVER_OVERRIDEllvmpipe来强制使用 llvmpipe。如果你想清除 llvmpipe 的 JIT 缓存可以用rm -rf ~/.cache/mesa清掉 Mesa 的磁盘缓存这样每次调试都能走完整的编译路径。5. 基于 LLVM 做二次开发的第一站自定义 Passllvm-project 的核心价值在于它的框架可扩展性。很多人的第一堂 LLVM 开发课就是写一个自定义的优化 pass这也能让你把前面章节学到的 IR 知识和实际开发串起来。下面用一个非常简单的例子讲一下完整的流程假设我们的目标是写一个 pass统计每个函数里的指令数量并打印出来。5.1 准备一个 New Pass Manager 的 passLLVM 15 已经全面启用 New Pass Manager旧版legacy::FunctionPass的写法还兼容但已经不推荐。核心代码写在llvm/lib/Transforms/Utils/下编译时用 LLVM 提供的构建系统自动加载插件比较方便。更轻量的方式是直接使用命令行工具opt加载一个动态库插件。#include llvm/IR/Function.h #include llvm/IR/Instructions.h #include llvm/Pass.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h using namespace llvm; namespace { struct CountInstructionsPass : public PassInfoMixinCountInstructionsPass { PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { unsigned Count 0; for (auto BB : F) { Count BB.size(); } errs() Function F.getName() has Count instructions\n; return PreservedAnalyses::all(); } }; } // namespace llvm::PassPluginLibraryInfo getLLVMPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, CountInstructionsPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name count-instructions) { FPM.addPass(CountInstructionsPass()); return true; } return false; }); }}; } extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return getLLVMPassPluginInfo(); }这个 pass 的逻辑不复杂遍历每个基本块累加基本块内的指令数量然后打印到errs()。这里的run函数返回PreservedAnalyses::all()表示这个 pass 不会改变 IR不需要强制重跑其他分析。如果你的 pass 确实修改了 IR比如做了内联或者删除指令就需要返回对应的 preserved 集合。5.2 用 opt 加载并运行把以上代码保存为CountInstructions.cpp然后编译成动态库$LLVM_DIR/bin/clang -shared -fPIC -fno-rtti \ CountInstructions.cpp -o libCountInstructions.so \ $(llvm-config --cxxflags)这里关键的是-fno-rttiLLVM 编译时默认关闭 RTTI编译插件需要保持同样开关否则会有符号不匹配问题。$(llvm-config --cxxflags)会自动填充头文件路径和编译选项。准备一个测试用的 IR 文件test.lldefine i32 add(i32 %a, i32 %b) { %sum add i32 %a, %b ret i32 %sum } define i32 main() { %r call i32 add(i32 1, i32 2) ret i32 %r }执行$LLVM_DIR/bin/opt -load-pass-plugin./libCountInstructions.so \ -passescount-instructions test.ll正常输出会有两条类似Function add has 2 instructions和Function main has 2 instructions的文本。这说明你的 pass 已经成功接入到 New Pass Manager 的管线里后续就能在-passes参数里和各种内置 pass 组合使用了。5.3 常见编译链接错误与排查写 pass 时最常见的坑是链接时找不到llvm-config或者版本不匹配。如果你同时装了多个版本的 LLVM最好在编译脚本里把绝对路径写死比如/opt/llvm-15/bin/llvm-config --prefix。还有一个容易踩的坑是opt的版本和你编译插件时用的头文件版本不一致比如用 LLVM 16 的头文件编译出来的插件往 LLVM 15 的 opt 里加载会直接报错。另外如果你使用-fno-rtti却用到了 LLVM 里依赖 RTTI 的代码会出现 undefined reference。解决办法是在编译指令里明确添加-DLLVM_ENABLE_RTTIOFF相关的宏定义。这个宏能让 LLVM 头文件配置和你的编译选项保持一致。6. 常见问题与故障排查速查表最后一部分把我在实际操作中遇到的几个高频问题和排查思路整理成表格方便你直接对照处理。这些问题横跨构建、调试和运行时三类场景遇到类似情况时可以快速定位方向。现象可能原因排查方法解决方案CMake 配置时提示找不到 Ninja未安装 ninja-build执行ninja --versionsudo apt install ninja-build构建内存不足导致 OOMLLVM 并行链接任务过多查看系统剩余内存加-DLLVM_PARALLEL_LINK_JOBS2或换成更小的-jclang 找不到标准头文件缺少 libstdc dev 包echoclang -E -x c -v -opt 加载插件报版本不匹配插件和 opt 的 LLVM 版本不一致opt --version和llvm-config --version对比统一使用同一套构建产物重新编译插件llvmpipe 日志显示 128 bits 而非 256CPU 不支持 AVX2lscpu查看 flags更换支持 AVX2 的 CPU或确认没有禁用 SIMD 指令自定义 pass 修改 IR 后结果异常没有正确返回 PreservedAnalyses检查你的 pass 修改了哪些 IR返回PreservedAnalyses::none()或按需标记保留链接时报大量 undefined reference缺少依赖库或 RTTI 配置不匹配查看完整链接错误信息检查-fno-rtti是否对齐添加缺失的-lLLVM-15Mesa 构建时 llvmpipe 启用失败llvmpipe 依赖的 LLVM 版本超范围查看 Mesa 的 meson configure 日志换成项目支持的 LLVM 版本如 15/16/17关于内存压力的问题我想多说一句。很多人第一次构建 LLVM 时会被内存占用吓到链接几个大工具时瞬间占用十几个 GB 内存是常见情况。如果你只有 8GB 内存的虚拟机建议还是构建裁剪版比如只构建llvm和clang跳过compiler-rt和lld。后续需要时再往环境变量里添加对应项目重新构建即可不要一口气全编译。交流群里经常有人问“为什么 CMake 都配置成功了构建时却报 TableGen 文件找不到”这种情况通常是因为你没有执行git submodule update --init --recursive或者 clone 时用了--depth 1的参数但仓库里部分子模块缺失。在拉取 llvm-project 时建议直接用带完整子模块的 release tag不要随意裁剪这能省掉不少麻烦。llvmpipe 那边如果你遇到软件渲染画面花屏或者崩溃的情况建议先关闭 Mesa 的 shader cache通过环境变量MESA_SHADER_CACHE_DISABLEtrue来验证是不是缓存过期导致的问题再尝试用GALLIUM_PRINT_ALWAYS1打开 LLVM JIT 调试输出查看编译后的 IR 是否符合预期。这种问题多半发生在 llvmpipe 和 LLVM 版本不匹配时大胆更新或回退 LLVM 版本通常能解决。说到底llvm-project 值得静下心来花时间研究。不管是做编译器工具链、图形渲染、代码安全分析还是 AI 编译器它的 IR 和 pass 基础设施都提供了非常可靠的基石。比起死记硬背那些命令行参数我更推荐用“写一个小工具跑一遍全流程”的方式来学习。当你能够熟练地写出一个 pass 并用 opt 跑通你对 LLVM 的整个项目结构才算真正入门了。