
不知道你有没有过这种经历明明只是想搞清楚一个编译报错结果翻着翻着就撞进了“llvm-project”这个仓库里再一看光顶层目录就有几十个整个人瞬间有点懵。我刚入行的时候也犯过同样的晕一直以为 LLVM 就是那个把 C/C 变成汇编的编译器后来才搞清楚严格说LLVM 只是这个大仓库里的一个核心子项目而 llvm-project 是一个把编译器前端、优化器、后端、链接器、调试器、运行时库全部打包在一起的 monorepo。这篇文章我打算从实际工程视角出发把 llvm-project 的组成、构建方式、LLVM IR 基础、以及它和 llvmpipe 软件渲染之间的关系讲清楚再分享一个从零写 LLVM pass 的完整过程。内容偏实操适合刚接触编译原理、准备用 LLVM 做课程设计或工具链二次开发的读者。1. 别再只把 LLVM 当作“编译器”它是一个工具链全家桶1.1 项目仓库的真实构成不止 llvm 目录llvm-project是官方 monorepo 的仓库名你 clone 下来之后顶层并不是只有一个llvm目录而是密密麻麻一堆并列的子项目llvm核心基础设施包括 LLVM IR、优化器、指令选择、代码生成以及各种后端。clangC/C/Objective-C 编译器前端。clang-tools-extraclang-tidy、clangd 等基于 Clang 的辅助工具。lld链接器。lldb调试器。compiler-rt运行时库包括 sanitizer、builtins 等。libcxx、libcxxabi、libunwindC 标准库、ABI 兼容层、栈展开库。mlirMLIR 子项目用于构建可复用、可扩展的编译器基础设施。polly多面体优化器。flang、openmp、bolt、libclc等等。这个结构对新手最直接的误导在于很多人想“下载 LLVM 编译器”结果直接 clone 了llvm-project然后发现不知道编哪个。其实真正编译 C/C 时你用的是clang前端而clang是作为llvm-project的一员参与整体构建的。如果你从源码构建 LLVM 时只启用clang其他子项目不编译也完全没问题。1.2 为什么 monorepo 的构建方式决定了你的使用体验LLVM 各子项目之间的耦合度非常高。clang用 LLVM 的库lldb也用mlir同样用。如果你把它们拆成多个仓库每次 API 变更都要同步改版本号这对一个每年两次大版本发布的编译器项目来说成本太高。所以 LLVM 从 8.0 开始采用 monorepo所有子项目共用同一个llvm子目录下的 CMake 配置。这也意味着你在构建 clang 工具链时实际上是在把整个 LLVM 核心库一起编译出来。很多人第一次跑cmake时会觉得“我只是想编译个 clang为什么配置了这么多选项”——这是正常的因为 clang 本身就是挂在 LLVM 库上的一棵大树。1.3 版本差异LLVM 15 在你上手时需要注意的变化本文假定你使用的是 LLVM 15.0.7这也是 Mesa 的 llvmpipe 软件渲染器里很常见的一个 LLVM 版本号。为什么单提 15因为这个版本对普通使用者有几个影响LLVM 15 中不透明指针opaque pointer默认开启IR 里不再区分i32*、i8*统一写成ptr。优化管线已经全面转向新的 Pass Manager老式opt命令行参数很多地方不再兼容。后端对 RISC-V、AArch64 的成熟度已经很高做交叉编译体验比以前好很多。如果你从最新源码开始学版本差异会更多但既然你大概率会碰到带llvm 15.0.7字样的环境从这里入手最稳妥。2. llvm-project 首选构建方式从 CMake 配置到三件套产物2.1 Ninja clang 的黄金组合与构建参数设定我个人的建议是不要用默认的 Unix Makefiles直接用 Ninja。Ninja 并行度更好增量构建也快很多。如果你机器上已经有 clang/gcc先就别折腾用系统编译器去 bootstrap直接用系统 clang 或 GCC 来构建即可。先说一个最常用的配置模板git clone --branch llvmorg-15.0.7 https://github.com/llvm/llvm-project.git cd llvm-project mkdir build cd build cmake -G Ninja ../llvm \ -DCMAKE_BUILD_TYPERelease \ -DCMAKE_INSTALL_PREFIX$HOME/llvm-15 \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86;AArch64;RISCV \ -DLLVM_ENABLE_ASSERTIONSOFF注意这里的../llvm不是笔误。llvm-project仓库里的llvm目录才是 CMake 的源根目录。LLVM_ENABLE_PROJECTS指定要一起编译的子项目我用的是clang和lld这样后续做实验够用了。如果想把 libc 也编进去更推荐用LLVM_ENABLE_RUNTIMEScmake -G Ninja ../llvm \ -DLLVM_ENABLE_RUNTIMESlibcxx;libcxxabi;libunwind;compiler-rt \ ...这里面的差异解释起来有点长projects 和 runtimes 的构建阶段不同runtimes 是在工具链本身可用之后再用刚编出来的 clang 去构建的。LLVM 15 仍兼容两种写法但如果你照抄网上老教程把 libcxx 放进LLVM_ENABLE_PROJECTS新版 CMake 会提示你应该移到 runtimes。2.2 按需裁剪LLVM_TARGETS_TO_BUILD 等关键变量新手最容易忽略LLVM_TARGETS_TO_BUILD。它控制要生成的各个 CPU 后端。LLVM 默认会生成所有后端包括像 BPF、Hexagon 这类你可能一辈子都用不到的 target构建时间会明显变长。只保留你需要的那几个后端构建速度会有可感知的提升变量作用建议值LLVM_TARGETS_TO_BUILD选择生成哪些后端本机架构做实验时再加 AArch64/RISCVLLVM_ENABLE_PROJECTS启用同仓库编译的前端/工具clang;lld比较常用CMAKE_BUILD_TYPE优化/调试配置Release或RelWithDebInfoLLVM_PARALLEL_LINK_JOBS限制并行链接任务数2避免链接 OOM还有一个容易被忽略的选项LLVM_ENABLE_ASSERTIONSON。建议如果你要写 pass、调试优化器本身就打开如果只是想拿 clang 当普通编译器用建议关闭性能更好。一个折中是CMAKE_BUILD_TYPERelWithDebInfo加上LLVM_ENABLE_ASSERTIONSON可调试性和性能都还能接受。2.3 构建时长和资源占用我的实测数据与建议在很多教程里构建 LLVM 被描述成一件极其耗时的事有人会说“一编就是一下午”。以我个人经验只要配置合理它其实没那么可怕。比如我用一台 8 核 16 线程、32GB 内存的机器构建目标后端X86、AArch64、RISCV子项目clang、lld构建类型Release首次全量构建大约需要 40 到 60 分钟主要卡在最终链接阶段。如果你不限制并行链接任务数lld或clang这类大二进制在链接时可能同时拉起四五个进程每个吃好几 GB 内存32GB 机器也会卡死。所以我在构建命令里加了-DLLVM_PARALLEL_LINK_JOBS2编译阶段你可以让 Ninja 尽量并行链接阶段保守一点这样整体反而更稳。构建完成后真正的产物在build/bin下。你可能会关心“三件套”我一般会重点确认这几个文件./bin/clang --version ./bin/llc --version ./bin/opt --versionclang负责前端和总驱动llc把 IR 变成汇编opt负责跑优化 pass。做编译原理相关开发时opt和llc几乎每天都要用。提示如果你只是想临时用一下 LLVM不想花时间全量编译可以直接安装发行版提供的llvm-15-dev、clang-15、lld-15这类二进制包。但如果你准备自己改 IR、写 pass还是建议从源码构建否则头文件和库版本对不上后面有你哭的。3. LLVM IR 快速上手理解 llvm 15 的中间表示设计3.1 IR 的基本模块Module、Function、BasicBlock 与 InstructionLLVM 的核心资产是它的中间表示也就是 IR。它既不是源码也不是机器码而是一种带类型、基于静态单赋值SSA形式的指令集。好多人第一次看.ll文件会发怵其实拆开来看非常规律。先看一个最简单的 C 函数int add(int a, int b) { return a b; }把它变成 LLVM IRclang -S -emit-llvm add.c -o add.ll生成的 IR 核心部分长这样define i32 add(i32 %a, i32 %b) { entry: %add add nsw i32 %a, %b ret i32 %add }你可以把这里面对应到 LLVM 的几个基本概念Module一个 IR 文件就是一个 Module里面可以放若干个函数、全局变量、元数据。Function也就是add这个函数。函数名前面的i32是返回类型。BasicBlockentry:这样的标签一个函数被划分为一个或多个基本块。基本块内没有跳转只有一个入口一个出口。Instructionadd、ret等指令是构成 IR 的最小操作单位。SSA 形式意味着每个变量只赋值一次。上面%add是加法指令的结果后面直接用就行不能重复给它赋值。这种形式让很多优化变得很简单因为数据流分析本质上就是沿着变量名追踪即可。3.2 内存中的 IR 与文本 IR 的关系.ll文件是给人看的文本格式。但在内存里LLVM 的 IR 是一堆 C 对象Module对象持有Function对象的列表Function持有BasicBlockBasicBlock持有Instruction。它们是严谨的 C 类层次结构。除了文本格式和内存格式还有一种紧凑的二进制格式叫 bitcode.bc。三者的关系可以这样理解.ll可读文本方便调试和 diff。.bc序列化后的二进制方便大量存储和快速加载。内存对象优化器和后端真正操作的数据结构。你用clang -c add.c -emit-llvm -o add.bc拿到的就是 bitcode。用llvm-dis add.bc可以把它还原成.ll。很多 pass 的测试都直接写.ll因为可以精确控制 IR 的形态不依赖 C 源码的编译结果。3.3 新版 LLVM 对 IR 的一些调整LLVM 15 有个特别显眼的变化默认开启不透明指针。以前你写 IR 时会看到define i32 add(i32* %p, i32* %q)现在指针类型统一为ptr不再区分整型指针还是单精度浮点指针。比如define void test(ptr %p) { %v load i32, ptr %p, align 4 ... }这样做的原因是历史上有太多针对“指针类型是否参与别名分析”的 bug。去掉指针元素类型之后load指令必须显式写清楚加载的类型getelementptr也需要带元素类型。举个例子%gep getelementptr i32, ptr %p, i64 4这里i32是元素类型%p是基址i64 4表示偏移 4 个元素。它不直接翻译成“在%p上加 16 字节”而是按i32大小来计算地址这样能避免在i8*和i32*之间来回转换带来的混乱。对于写过老 LLVM pass 的人而言这个改动会让你很多涉及getPointerElementType()的代码编译不过。不过如果你是新手直接按新风格学反而没有这些历史包袱。4. llvmpipe 到底在做什么软件渲染里的 LLVM 后端4.1 llvmpipe 如何让 CPU 也能跑起 OpenGLllvmpipe是 Mesa 里的软件光栅化器。简单说它是一个完全用 CPU 模拟 GPU 渲染管线的实现。正常情况下图形程序会把 GLSL 着色器编译成 GPU 专用的机器指令然后把三角形数据丢给 GPU。但如果机器没有独立显卡或者驱动坏了Mesa 就能拉起 llvmpipe让一切在 CPU 完成。这里的关键点是llvmpipe 内部用 LLVM 作为 JIT 引擎。它会把自己的中间表示转换成 LLVM IR再用 LLVM 的即时编译功能生成当前机器能跑的本地代码。这就是为什么 llvmpipe 的版本号里直接带着 LLVM 的版本号。用类似下面的命令可以查看当前环境的渲染器信息glxinfo -B LIBGL_ALWAYS_SOFTWARE1 glxinfo -B如果输出里出现llvmpipe就说明你其实是在用 CPU 跑 OpenGL 渲染。4.2 “llvmpipe (LLVM 15.0.7, 256 bits)” 这行渲染器信息意味着什么在不少 Linux 环境里你会看到这样一行OpenGL renderer string: llvmpipe (LLVM 15.0.7, 256 bits)这句话可以拆成三部分llvmpipe渲染器是 Mesa 的软件光栅化实现。LLVM 15.0.7当前使用的 LLVM JIT 版本。256 bitsllvmpipe 在构建或运行时检测到的向量宽度上限是 256 位也就是能使用 AVX2 的 YMM 寄存器来跑 SIMD 指令。这个256 bits直接影响渲染性能。llvmpipe 在编译像素着色器时会把一组像素打包成向量一起运算。如果 CPU 支持 256 位向量一次能处理的像素分量就更多如果只支持 128 位 SSE那就是 “128 bits”。这行信息对图形调试很有用因为它直接告诉你软件渲染器是否发挥了当前 CPU 的全部 SIMD 能力。如果你在虚拟机里看到它基本可以确认显卡没有被直通给虚拟机图形走的是 CPU 模拟。4.3 把 llvmpipe 当作调试工具一次图形问题排查经历有一回我给客户写一个 OpenGL 渲染工具客户的机器是 ARM 小主机外接了简单的 GPU但驱动一直不稳定。程序一跑起来画面偶尔花屏。我让客户跑了glxinfo发现渲染器即便是正常路径也偶尔回退到llvmpipe而我的 CI 机器没有 GPU测试时强制走LIBGL_ALWAYS_SOFTWARE1反而跑出来的结果像素与客户报错时的像素对不上。后来定位下来问题出在客户机器回退 llvmpipe 时GLSL 着色器用的mediump精度被 llvmpipe 以较高精度计算而真实 GPU 驱动按低精度处理导致两者颜色细节出现肉眼可见的差异。这个案例想说明的是llvmpipe 不只是“没显卡时的备胎”它还是排查驱动 bug 的利器。它和 GPU 的真正行为存在一定偏差但这个偏差恰恰可以用来区分“算法错了”还是“驱动实现错了”。另外llvmpipe 也支持 Vulkan对应的驱动叫lavapipe。通过设置VK_ICD_FILENAMES指向对应的 JSON 文件就能用 SwiftShader 之外的方案做 Vulkan 软渲染验证。5. 动手写一个 LLVM pass从零到接入 pipeline5.1 pass 的基础骨架与注册方式讲完项目结构和 IR就该做点实际的事了。好多人学 LLVM 卡在 pass 这一步因为不知道从哪里下手。这里我给出一个最精简的 function pass作用是在每个函数里打印所有被调用的函数名。#include llvm/IR/Function.h #include llvm/IR/Instructions.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Support/raw_ostream.h using namespace llvm; namespace { struct DemoPass : public PassInfoMixinDemoPass { PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { for (BasicBlock BB : F) { for (Instruction I : BB) { if (auto *Call dyn_castCallInst(I)) { if (Function *Callee Call-getCalledFunction()) { errs() call: Callee-getName() \n; } } } } return PreservedAnalyses::all(); } }; } // namespace extern C ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, DemoPass, 0.1, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name demo-pass) { FPM.addPass(DemoPass()); return true; } return false; }); }}; }这段代码里最核心的是两点。第一pass 必须继承PassInfoMixinDemoPass并实现run方法返回PreservedAnalyses。第二入口函数必须是llvmGetPassPluginInfo它告诉 LLVM 这个插件的 API 版本号、名称、注册回调。把它编译成动态库clang -stdc17 -fPIC -shared demo.cpp \ $(llvm-config --cxxflags --ldflags --libs) \ -o demo.so然后对任一.ll文件执行opt -load-pass-plugin./demo.so -passesdemo-pass -S input.ll你会发现input.ll里的每个函数内部的调用信息都被打印出来了。5.2 遇到的实际坑New Pass Manager 与旧接口的差异网上搜 LLVM pass 教程很容易搜到老代码。老代码一般是这样的写法struct DemoPass : public FunctionPass { static char ID; DemoPass() : FunctionPass(ID) {} bool runOnFunction(Function F) override { ... } }; char DemoPass::ID 0; static RegisterPassDemoPass X(demo-pass, ...);这是 legacy pass manager 的写法。LLVM 15 里新 pass manager 已经是默认opt命令行的-passes语法只认新 PM 的插件注册方式。你在网上看到的老代码直接用opt -demo-pass跑大概率会提示找不到 pass。新 PM 的关键差异可以总结为三点pass 是函数对象不需要静态 ID。run方法返回PreservedAnalyses告诉后续 pass“哪些分析还可以继续用”没法明确判断时直接返回PreservedAnalyses::none()。通过PassBuilder的 callback 注册进 pipeline。这里特别提醒如果你的 pass 真的修改了 IR就不要返回PreservedAnalyses::all()。这个返回值的意思是“我什么都没改所有分析结果都能复用”。如果你改了 IR 却说没改后面 pass 可能会基于过期数据做错误的优化。做个保守的 pass直接返回return PreservedAnalyses::none();5.3 用 lit 和 FileCheck 做回归测试LLVM 官方测试框架是 lit配合 FileCheck 工具来校验 pass 输出。拿上面的 DemoPass 举例你可以写一个测试文件; RUN: opt -load-pass-plugin%t/demo.so -passesdemo-pass -S %s | FileCheck %s ; CHECK: call: bar define void foo() { call void bar() ret void }RUN行就是测试命令。%s是当前文件路径FileCheck会检查后续输出里是否存在与CHECK匹配的行。如果你把 pass 加入到 LLVM 源码树还可以放到llvm/test/Transforms/下并用llvm-lit执行。我自己更常用的方式是单独建一个目录不编进 LLVM 代码树直接用opt加-load-pass-plugin跑配合一小段 shell 脚本做断言。这样迭代快也不污染主工程。6. llvm-project 工程实践中的隐性成本与建议6.1 版本回溯与依赖匹配不要把 clang 和 LLVM 混着用最常见的错误是系统里装了clang-15但你自己从源码编译 LLVM 时用的却是另一套配置。接着你用源码里的opt去加载某个插件结果插件依赖的 LLVM 库版本跟opt编译时不一致一运行就报“unknown symbol”或“version mismatch”。我踩过最深的坑是把发行版自带的libLLVM-15.so和源码构建的clang混在一起用。那时候我天真地以为“反正都是 LLVM 15应该兼容”。结果就是无休止的undefined symbol。如果要在同一台机器上维护多个 LLVM 版本我的建议是所有工具链组件都尽量从同一个llvm-projecttag 编译。使用CMAKE_INSTALL_PREFIX区分安装目录比如~/llvm-15、~/llvm-16。使用llvm-config时把它所在的bin目录放在 PATH 最前面避免找到系统内置版本。opt -load-pass-plugin引用的动态库编译时用哪个llvm-config运行时就用哪个opt。6.2 常见编译错误和排查思路LLVM 代码库庞大编译错误有时让人很绝望。根据我这些年积累的经验大部分错误逃不出下面几类现象常见原因处理方式找不到llvm-config.h没有先构建llvm-config或没有安装 dev 包先执行ninja llvm-config或安装对应版本 dev 包undefined reference tollvm::...链接库顺序不对或缺少某个组件尽量用llvm-config --libs动态库放后面uses undefined class llvm::Function缺少对应头文件检查 include一般同名.h文件即可编译 pass 时 RTTI 报错LLVM 构建时关闭了 RTTI而你的插件开了编译插件时加上-fno-rtti并保持异常选项一致expected top-level entityIR 文件格式不对或 bitcode 和文本混淆确认文件是.ll文本或先llvm-dis另一个隐蔽问题是 new pass manager 下pass 插件运行时崩溃但错误信息不明显。这种情况优先用llvm-symbolizer配合地址回溯或者在编译插件时保留调试符号clang -g -O0 -fno-rtti -stdc17 ...6.3 适合个人的工作流参考如果你只是一个人维护一个小的 LLVM 实验项目不必追求编全量的clang和lld。有两个更轻量的开发流只构建opt和llccmake时-DLLVM_ENABLE_PROJECTS然后ninja opt llc。这个流程足够跑 pass 和看汇编。需要 clang 前端时再补先ninja clang通常增量编译几十秒就能出来。我自己的习惯是建两个构建目录build-release/ # Release ASSERTIONSON用于日常跑测试 build-debug/ # Debug用于调试 pass 崩溃日常修改一个 pass我会在build-release里跑ninja demo.so然后快速跑一遍 lit 测试。只有在定位到具体崩溃点时才去build-debug里用 debugger 跑。对于调试优化问题我还会在命令行上加opt -passesprintloops,demo-pass ...或者在 clang 编译时加clang -mllvm -print-after-all ...这些参数能帮你看到每个 pass 前后 IR 的变化非常利于理解优化器在干什么。总之llvm-project 的深度可以让你从编译器前端一路钻研到后端机器码但它真正难的地方不是某一处的算法而是整套工程之间的衔接。把这些工程细节先理顺后续看源码、写 pass、调后端都会顺畅很多。