深入LLVM核心:从IR、Pass到源码构建与软件渲染实践
2026/9/20 10:21:06 网站建设 项目流程

如果你用过clang编译过东西,那你其实已经和 LLVM 打过交道了。我第一次真正打开llvm-project这个仓库,不是因为好奇,而是在一次给图形栈做软件渲染调试时,发现llvmpipe打出的渲染器字符串里带着LLVM 15.0.7, 256 bits。不搞清楚这套工程是怎么把着色器变成机器码的,后面根本没法继续。后来我把整个项目源码拉下来,从构建、IR、优化 Pass 到后端生成,完整跑了一遍,才感觉“LLVM”这三个字母终于变成了一个可以操作的系统。

这篇东西我不想写成官方文档的复述,而是按我实际摸过的路线,把llvm-project里最值得理解的部分拆开:项目结构、IR 与 Pass 体系、15.0.7 的源码构建、如何写一个自带插件 Pass,以及 llvmpipe 里那个 256 bit 向量宽度到底是怎么回事。适合两类人:一类是刚接触编译器、想找个真实项目入门的同学;另一类是在工作中被 LLVM 树里的目录和各种 Pass 名字劝退的开发者。

1. 为什么说LLVM项目值得每一个工具链开发者仔细读一遍

1.1 它已经不是一个“虚拟机”

llvm-project这个名字很容易误导人。早年 LLVM 确实是 "Low Level Virtual Machine" 的缩写,但现在的项目早就不是传统意义上的虚拟机,而是一套编译基础设施全家桶。仓库顶层通常能看到这些子目录:llvm是核心库和工具链优化器、clang是 C/C++/Objective-C 前端、lld是链接器、compiler-rt是运行时库、libcxx/libcxxabi是 C++ 标准库实现,还有mlirpollyflang这些更垂直的组件。很多人第一次 clone 完会懵:我到底该读哪块?

我的建议是,先分清“前端、中端、后端”这条主链。前端负责把源代码变成中间表示,中端就是 LLVM 的优化器,后端负责把优化后的中间表示变成目标机器汇编或 object 文件。你在终端敲clang的时候,实际操作的是“前端 + 中端 + 后端”这条流水线,所以 LLVM 项目里真正最核心的代码,反而不是某个工具目录,而是llvm/lib/IRllvm/lib/Transformsllvm/lib/CodeGen这几个库目录。

1.2 从一段C语言代码进入llvm-project的完整旅程

用一段最简单的代码体会整条链路:

int add(int a, int b) { return a + b; }

保存为add.c,然后依次执行:

clang -O1 -S -emit-llvm add.c -o add.ll opt -passes='default<O2>' add.ll -S -o add.o2.ll llc add.o2.ll -o add.s

第一步得到的是 LLVM IR 文本,第二步把 IR 放进优化管线跑一遍,第三步由后端生成汇编。很多同学一上来就盯着llvm/lib/CodeGen里的汇编指令表,其实容易迷路。更聪明的做法是先读 IR 和 Pass 的 Test 文件,比如llvm/test/Transforms下面一堆.ll文件,用opt一跑就能看到变换前后差异。我当时就是这么入门的:把测试文件里的RUN:指令复制到命令行,改参数,观察输出,比直接读源码快得多。

1.3 仓库目录的阅读顺序建议

如果你是从零开始读llvm-project,建议按llvm/docs/GettingStarted.rst之外的个人经验来排顺序:

  1. 先看llvm/lib/IR,理解ModuleFunctionBasicBlockInstruction这几个核心类的关系。
  2. 再看llvm/lib/Transforms/Utilsllvm/lib/Transforms/Scalar,感受一个简单的优化是如何遍历 IR 并修改指令的。
  3. 接着看llvm/lib/CodeGen/SelectionDAGllvm/lib/CodeGen/GlobalISel,了解指令选择的大思路。
  4. 最后再看clang/lib/CodeGen,你会惊讶地发现,Clang 的 CodeGen 和 LLVM 核心的关系,其实没有想象中那么神秘:它把一个 C 的 AST 翻译成对应的 IR 指令。

这个顺序的核心逻辑是“先学会读 IR,再学改 IR,最后才关心如何把 IR 变成机器码”。直接从后端看容易陷入 TableGen 的海洋,怀疑人生。

2. LLVM IR与Pass体系:读懂优化器的工作原理

2.1 IR的三种形态与文本阅读

LLVM IR 有三种表示方式:内存中的对象结构、bitcode(.bc)、可读文本(.ll)。三者的关系很像代码的“内存对象、编译产物、源代码”。日常调试用文本形态最多:

clang -S -emit-llvm add.c -o add.ll llvm-as add.ll -o add.bc llvm-dis add.bc -o add.back.ll

llvm-as把文本变成 bitcode,llvm-dis是逆向过程。用clang -O1生成的add.ll大概是这样的:

define i32 @add(i32 %a, i32 %b) { %add = add nsw i32 %b, %a ret i32 %add }

注意到%a%b是虚拟寄存器,%add是一次性赋值的结果。这就是 SSA(静态单赋值)形式:每个变量只被赋值一次,后续所有使用都直接引用这次定义。这种形式的好处是 use-def 链非常清晰,一个值从哪里来、被谁消费,分析时不用像看命令式代码那样追踪到变量被覆盖的每一处。

2.2 SSA和phi:需要跨过的第一道门槛

理解 SSA 之后,大部分人的下一个坎是phi指令。想象一个if-else合并后的值,同一个变量在不同分支里被赋了不同的值,但返回时只有一次使用。SSA 要求每个变量只能定义一次,于是合并点需要一个特殊节点来处理分支来源。

实际生成的 IR 里会有phi

entry: %cond = icmp slt i32 %a, 0 br i1 %cond, label %then, label %else then: %x.0 = add i32 %a, 1 br label %merge else: %x.1 = sub i32 %a, 1 br label %merge merge: %x = phi i32 [ %x.0, %then ], [ %x.1, %else ] ret i32 %x

phi的意思是:如果控制流是从%then块进来的,当前值取%x.0;如果是从%else块进来的,取%x.1。不要被这个名字吓到,它只是 SSA 规则下的“必经之路”。写 Pass 的时候经常要处理 phi,尤其是做指令删除或下一条指令插入时,必须注意phi节点的位置只能在基本块开头。

2.3 新的Pass管理器:优化管线是怎么串起来的

LLVM 15 已经默认使用 New Pass Manager。Pass 大体分两种:分析 Pass 和变换 Pass。分析 Pass 只负责收集信息,比如LoopAnalysis会告诉你一个循环有哪些基本块;变换 Pass 会修改 IR,比如InstCombineGVNLoopUnroll。变换 Pass 内部通常也会调用分析 Pass,拿到信息后决定改还是不改。

命令行里最常用的就是:

opt -passes='default<O2>' add.ll -S -o add.o2.ll

这里的default<O2>是一个预先定义好的 Pass 流水线。它内部包括函数内联、循环展开、公共子表达式消除、死代码删除等一长串优化,顺序不是随便排的。比如InstCombine通常放在循环变换之前,因为先做局部简化能减少后续 Pass 的工作量。如果自定义 Pass,一个很常见的坑是把手写 Pass 插到了错误位置,导致和后续优化产生冲突。更稳妥的做法是先用opt -print-after-all观察管线里每个 Pass 前后的 IR,再把自定义 Pass 插入到合适位置。

3. 在真实机器上从源码构建LLVM 15.0.7:完整过程与踩坑

3.1 构建前必须想清楚的三件事

第一次从源码构建 LLVM,不要像无头苍蝇一样直接敲cmake。先想清楚三件事:

第一,构建目标是什么。如果只是为了搞懂 IR 和写 Pass,只需要optllcclang这几个二进制;如果需要玩链接器,再在LLVM_ENABLE_PROJECTS里加lld。全量构建llvm-project需要非常长的时间,尤其在没有足够内存的机器上,链接阶段很容易被 OOM 杀掉。

第二,要不要启用 Assertions。我建议第一次构建打开-DLLVM_ENABLE_ASSERTIONS=ON,因为编译器代码里的断言能帮你提前发现很多“看起来能跑但实际不对”的问题,尤其写自定义 Pass 时,断言能在 IR 损坏时及时报警。

第三,哪些后端目标要包含进去。如果只是本机实验,-DLLVM_TARGETS_TO_BUILD="X86"就够了。如果想交叉编译玩 RISC-V 或 WebAssembly,再加对应 target。这里的坑在于:目标太多会导致构建时间翻倍,而且大多数初学阶段根本用不到。

3.2 CMake参数说明与构建命令

我当时的构建命令是这样的:

git clone --depth 1 --branch llvmorg-15.0.7 https://github.com/llvm/llvm-project.git cmake -S llvm-project/llvm -B build -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DLLVM_ENABLE_PROJECTS="clang;lld" \ -DLLVM_TARGETS_TO_BUILD="X86;AArch64;ARM;RISCV;WebAssembly" \ -DLLVM_ENABLE_ASSERTIONS=ON \ -DLLVM_INCLUDE_TESTS=OFF \ -DLLVM_PARALLEL_LINK_JOBS=2 ninja -C build clang lld opt llc

几个参数的作用:

  • -G Ninja:用 Ninja 构建系统,比 Makefile 快,而且默认支持并行。
  • -DCMAKE_BUILD_TYPE=Release:编译优化后的发布版。如果想调试 Pass,可以改成Debug,但编译产物会大很多、链接更慢。折中是RelWithDebInfo
  • -DLLVM_PARALLEL_LINK_JOBS=2:限制并行链接任务数。这个我强烈建议加上,否则ninja默认按核数并行链接,内存不够时直接被杀。
  • -DLLVM_INCLUDE_TESTS=OFF:第一次构建不需要跑测试,能省不少时间。

构建完后,工具都在build/bin下。用build/bin/clang --version应该能看到clang version 15.0.7build/bin/llvm-config --version会输出15.0.7

3.3 我踩过的三个构建坑

第一个坑是内存不足导致链接失败。早期我图省事,直接ninja -C build没加LLVM_PARALLEL_LINK_JOBS,机器 16G 内存,跑了一会儿clang链接阶段就被 Linux OOM Killer 杀掉。解决办法就是限并行链接任务数,或者先编译optllc这种小工具,不急着编全量。

第二个坑是LLVM_ENABLE_PROJECTS里的项目相互依赖。比如想在 15.x 里启用flang,需要先启用mlir并注意版本匹配。如果只是做工具链,老老实实只放clang;lld,能少踩很多依赖坑。

第三个坑是 CMake 缓存。第一次配参数时漏了某个 target,第二次想补上,直接在 build 目录里重新跑cmake,有时候因为缓存导致新参数没生效。我的经验是:大版本间换参数,宁可删掉 build 目录重新配置,也不要贪图省事。毕竟编译基础工具链本来就是一次性的成本,不值得在缓存问题上反复折腾。

3.4 构建产物的正确打开方式

很多人构建完只记得clang,其实build/bin/下的optllcllvm-configllvm-dis每一个都是实验好帮手。在构建目录外,需要用到库路径时,可以用build/bin/llvm-config --cxxflags --ldflags --libs core拿到编译参数。这个命令在写 Pass 插件时尤其有用,后面会频繁用到。

一个小建议:把build/bin加入PATH时,最好使用绝对路径或显式前缀,不要和系统自带的旧版本 LLVM 工具混在一起。有一次我在 shell 里直接把 build/bin 排到最前,结果系统里某个依赖旧 LLVM ABI 的软件莫名其妙崩了,后来一查,是LD_LIBRARY_PATH被我污染了。环境变量的坑比代码本身的坑更难排查。

4. 自定义优化Pass:用llvm-project扩展自己的编译器

4.1 从零写一个function pass

仅仅读 Pass 和跑opt还不够,真正让我对llvm-project有掌控感的,是写了一个自定义 Pass 并通过插件的方式加载。这个流程能验证你对 IR、PassManager 和工具链构建的理解是不是真的到位。

先准备一个最简单的 Function Pass,目的是统计每个函数里的指令数量,这里不修改 IR,所以分析后返回PreservedAnalyses::all()是合理的:

#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 InstructionCounterPass : public PassInfoMixin<InstructionCounterPass> { PreservedAnalyses run(Function &F, FunctionAnalysisManager &AM) { unsigned Count = 0; for (auto &BB : F) for (auto &I : BB) ++Count; errs() << "Function " << F.getName() << " has " << Count << " instructions\n"; return PreservedAnalyses::all(); } }; } // namespace PassPluginLibraryInfo getPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, "InstructionCounterPass", LLVM_VERSION_STRING, [](PassBuilder &PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager &FPM, ArrayRef<PassBuilder::PipelineElement>) { if (Name == "count-instructions") { FPM.addPass(InstructionCounterPass()); return true; } return false; }); }}; } extern "C" LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return getPassPluginInfo(); }

这段代码的核心是两件事:一个实现 Pass 逻辑的run方法,以及一个导出llvmGetPassPluginInfo符号的入口函数。opt在加载插件的时候会找到这个入口,拿到注册信息,然后把count-instructions这个命令行名称和我们的 Pass 关联起来。

4.2 接入录制Pass管线的两种方式

写完后,用llvm-config提供的参数编译成动态库:

clang++ -shared -fPIC -fno-rtti \ $(llvm-config --cxxflags) \ InstructionCounterPass.cpp -o InstructionCounterPass.so \ $(llvm-config --ldflags --libs core)

这里-fno-rtti是因为 LLVM 默认关闭 RTTI,插件也要保持一致,否则类型信息可能对不上。接着用opt加载:

opt -load-pass-plugin=./InstructionCounterPass.so \ -passes="count-instructions" \ add.ll -S -o add.count.ll

如果LLVM_LIBRARY_DIR没有加到动态库搜索路径,可能会出现 “libLLVM-15.so 找不到” 的错误,用export LD_LIBRARY_PATH=$(llvm-config --libdir)可以解决。

另一种接入方式是直接把源文件放到llvm/lib/Transforms/Utils目录里,改对应 CMakeLists,重新编译opt。这种静态集成适合做深度改造,因为你的 Pass 能访问内部更多接口;缺点是每次改动都要重新编译整个opt,实验成本较高。我个人的习惯是:实验阶段用插件,确定逻辑要长期保留时再迁到源码树里。

4.3 验证优化结果与调试技巧

自定义 Pass 最大的敌人不是编译错误,而是“看起来跑通了,但 IR 被改坏”。LLVM 有一系列的调试工具:

  • opt -print-before-all -print-after-all:打印每个 Pass 运行前后的 IR,适合观察自定义 Pass 本身和后续 Pass 的真实效果。
  • opt -verify-each:在每个 Pass 运行后调用verifyModule,IR 一旦非法会立刻报错。
  • llvm-diff:比较两个.ll文件的差异,适合验证一个 Pass 是否只是做了预期的修改。

还有一点需要特别注意:如果你在 Pass 里修改了 IR,但错误地返回了PreservedAnalyses::all(),分析 Pass 的结果可能被当成仍然有效,后续优化拿到过期信息后产生错误代码。判断准则很简单:完全没有动任何 IR,才能返回all();只要修改了指令或基本块,保守起见返回PreservedAnalyses::none(),让 PassManager 重新计算分析结果。

写 Pass 时我也建议用最终会出现在 Test 里的场景来验证,而不是只用一个手写的add.ll。因为真实 IR 里会有 phi、多条边、未合并的基本块,Pass 很容易在这些边界上翻车。我早期写过一个小试穿的“把函数名打印出来”的实验,看起来没问题,但放到真实项目里一跑,遇到空函数、declare 声明、弱符号等情况就乱了。逐步用FileCheck写回归测试,能帮你把这类问题提前暴露。

5. llvmpipe与256 bit向量:LLVM在软件渲染里的实际落地

5.1 软件渲染器为什么会把LLVM版本打在脸上

如果你在虚拟机、云服务器或者没有独立显卡的机器上运行glxinfo,大概率会看到类似这样的输出:

OpenGL renderer string: llvmpipe (LLVM 15.0.7, 256 bits)

我第一次看到LLVM 15.0.7, 256 bits这串东西时,第一反应是某个驱动把版本信息写在了渲染器名称里。实际上,这是 Mesa 的llvmpipe软件渲染器在初始化时打印的识别字符串。llvmpipe是 Gallium 架构里的一个软件渲染驱动,它不做硬件的光栅化,而是把 GLSL/Vulkan 着色器编译成宿主机的 CPU 指令,靠多线程和 SIMD 指令来模拟 GPU 管线。这个编译动作,底层就是 LLVM 的 JIT。

括号里的LLVM 15.0.7是 Mesa 在运行时链接到的 LLVM 版本,256 bits是 llvmpipe 当时选择的 SIMD 向量宽度。为什么是 256 而不是 128 或 512?因为这取决于运行机器的 CPU 特性。如果 CPU 支持 AVX2,寄存器宽度是 256 bit,llvmpipe 就会选择这个宽度来生成向量化代码;如果只支持 SSE2,宽度就会退到 128 bit。这串字符串等于把 LLVM 后端的能力直接暴露在了用户面前。

5.2 LLVM如何生成256 bit的SIMD代码

llvmpipe 的工作方式,简单说就是把一段高级着色语言翻译到 LLVM IR,再让 LLVM 选择目标机器上的最佳向量指令。你不用真的去读 Mesa 源码,用llc就能感受这个过程。

比如一个简单函数,输入四个float,返回它们的和:

define float @sum4(<4 x float> %v) { %v1 = extractelement <4 x float> %v, i32 0 %v2 = extractelement <4 x float> %v, i32 1 %v3 = extractelement <4 x float> %v, i32 2 %v4 = extractelement <4 x float> %v, i32 3 %s1 = fadd float %v1, %v2 %s2 = fadd float %s1, %v3 %s3 = fadd float %s2, %v4 ret float %s3 }

llc -mattr=+avx2生成 X86 汇编,你会看到它把<4 x float>放进了xmmymm寄存器。这里的-mattr=+avx2相当于告诉后端“目标 CPU 支持 AVX2 特性”。如果去掉这个参数,后端只能用 SSE 指令,生成的向量宽度就是 128 bit。

实际项目中,CPU 特性不是靠手写参数,而是由TargetMachine初始化时传入的MCSubtargetInfo决定。llvmpipe 检测到本机支持 AVX2,就在创建 LLVM TargetMachine 时把这个特性加进去,LLVM 后端在指令选择阶段就会倾向使用 256 bit 的 YMM 指令。所以那个256 bits不是 LLVM 的固定值,而是“LLVM + CPU 特性 + 目标代码生成策略”共同作用的结果。

5.3 从后端视角看TargetDescription的几份关键文件

如果你对“LLVM 怎么知道 AVX2 是 256 bit”这个问题好奇,可以去看llvm/lib/Target/X86目录。里面最劝退的是.td文件,它们是 TableGen 写的目标描述。但不要慌,核心只需要抓住几条线:

  • X86.td:定义 X86 这一整个 target 的总体结构,包括有哪些子目标特性。
  • X86InstrInfo.td:描述指令的语义和机器编码。
  • X86Subtarget.cpp:根据 CPU 或-mattr参数,决定哪些特性开关被打开。

-mattr=+avx2里的avx2,在 X86 后端里会映射到一块对应的SubtargetFeature。指令选择器看到 IR 里的向量类型和avx2特性开启,就会匹配到 AVX2 指令而不是 SSE 指令。这个过程听起来玄,实际就是查表匹配:IR 的<4 x float>加一个fadd,在X86InstrInfo.td里可以匹配到VADDPS这类指令。

想深入了解的读者,不妨自己跑一句:

llc -mattr=help

运行后,LLVM 会列出该 target 支持的所有特性开关。这是理解后端能力边界最快的方式,比直接读.td文件轻松得多。

5.4 对想做JIT/软渲染的团队的建议

llvmpipe 这种用 LLVM JIT 做软件渲染的思路,对其他需要“运行时生成高性能代码”的团队很有参考价值。我自己在类似场景里总结过几个经验:

第一,不要把 LLVM Context 当成便宜资源。每次创建 Context 和 Module 都有不小开销,JIT 场景下要尽量复用。llvm::orc::LLJIT这类高层 API 是为了这种场景设计的。

第二,向量宽度选择和 CPU 特性检测要提前做好。不要在运行到热点路径时才临时去查 CPU 特性,应该在 JIT 编译前就确定TargetOptionsSubtargetFeatures,否则同一份着色器代码在不同机器上会生成差异很大的机器码。

第三,调试 JIT 生成的代码时,打开-debug-only=...或者保留 bitcode/汇编输出,往往比在生成的机器码里打断点更高效。LLVM 有llvm-objdumpllc -filetype=asm这些工具,配合-print-after-all能看到每一层优化做了什么。

如果你当初也是因为llvmpipe (LLVM 15.0.7, 256 bits)这行字对 LLVM 产生了好奇,我建议不要停在glxinfo这一步。花一个周末把llvm-project构建出来,亲手写一个 Pass,再用llc看看向量代码是怎么生成的。读源码和跑实验是完全两回事,尤其是你一边看 IR 一边刷后端.td文件的时候,很多概念根本不用背,自然就进脑子了。最后再分享一个小技巧:在实验目录里固定好llvm-config的路径,别真的依赖系统 PATH 里的老版本工具,否则你辛苦写的插件在 ABI 不兼容问题上折腾到崩溃。那是另一个很长的故事了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询