LLVM 项目完全拆解:从 IR 架构到 llvmpipe 与 SIMD 实践
2026/9/20 20:04:46 网站建设 项目流程

我这两年跟 LLVM 打交道的时间,比跟家里人都熟。年初为了给一个自研的脚本语言做 JIT 编译后端,把 llvm-project 从 GitHub 拉下来从头编了一遍,中间踩坑无数,但也算把这套庞然大物的骨架摸了个七七八八。最近看到不少朋友在讨论 llvmpipe(Mesa 里那个基于 LLVM 的软件渲染器,还带 llvm 15.0.7 版本标识和 256 bits 向量宽度),正好借着这个由头,把整个 LLVM 项目从架构设计到实际操作写一篇尽量完整的拆解。如果你是刚接触编译原理、想用 LLVM 做点东西,或者只是好奇这玩意为什么能撑起整个现代编译器生态,这篇文章应该能给你一个相对清晰的全局视图。

1. 先搞清楚 LLVM 到底是什么:不只是“一个编译器”

我第一次接触 LLVM 的时候,也被它的名字误导了很久。Low Level Virtual Machine,听起来像是个虚拟机,但实际它和你理解的 JVM、V8 完全是两码事。它不是一个“跑字节码的运行时环境”,而是一整套用来构建编译器的“乐高积木”。你可以用它的库和基础设施,拼出自己的语言前端、优化器、后端,甚至是一个像 llvmpipe 这样把 GPU 着色器翻译成 CPU SIMD 指令的软件渲染器。

真正让 LLVM 成为今天这个生态霸主的原因,是它把一个编译器按“前端-中间层-后端”切成了三段,并且把中间层(IR)做成了统一、稳定、可读性极高的“通用语言”。GCC 也做类似的事,但 LLVM 的模块化程度更高、代码库更干净、API 更现代化,这让它成了学术界和工业界共同的选择。

1.1 三层架构里的“通用语”:LLVM IR 为什么是灵魂所在

要理解 LLVM,必须先理解 IR。IR 是 LLVM 的一种中间表示,它长得很像简化的 C 语言,或者说是“经过类型标注的三地址码”。比如a + b * c这样一句 C 代码,在经过 clang 翻译成 LLVM IR 后,会变成类似这样的形式:

%1 = load i32, i32* %b %2 = load i32, i32* %c %3 = mul nsw i32 %1, %2 %4 = load i32, i32* %a %5 = add nsw i32 %4, %3 store i32 %5, i32* %a

每一个操作都被拆成了一条独立的指令,每条指令都显式地标注了数据类型(i32 表示 32 位整数)、操作数来源和结果去向。这种设计带来的最大好处是:任何前端只要能把源代码翻译成 IR,优化器和后端就都可以直接复用。你写一门新语言,不需要重新发明一套优化框架,也不用管 x86、ARM、RISC-V 之间的寄存器差异,只要把 IR 丢给 LLVM 的优化 pass,它就能自动帮你完成死代码消除、常量折叠、循环展开等几十轮优化,最后再交给后端生成对应平台的机器码。

这个设计出来的时间其实非常早——2000 年左右 Chris Lattner 在 UIUC 做博士论文时就是这个思路,之后苹果把它的作者和团队一起收了进去。现在 LLVM 已经迭代到了 15.x、16.x、17.x 的版本,但核心架构一点没变,只是 IR 的特性越来越丰富,优化 pass 越来越多,后端架构越来越完整。

1.2 不只是编译器:LLVM 项目的家族成员

很多人以为 LLVM 就是“一个编译器”,实际 llvm-project 这个仓库里装的是一个完整的生态。我现在用的 15.0.7 版本的仓库里,主要包含这些子项目:

  • llvm:核心编译器基础设施,就是 IR 定义、优化 pass、代码生成、汇编器、链接器等底层组件。这是整个项目的心脏。
  • clang:C/C++/Objective-C 的前端。这是所有人接触 LLVM 的第一道门,把 C/C++ 源码翻译成 IR。
  • lld:一个高性能的链接器,目标是把链接速度做到极致。比系统自带的 GNU ld 快好几倍,Android 和 Chrome 团队都在用。
  • libc++ / libc++abi:C++ 标准库的 LLVM 实现,是 clang 的配套标准库。
  • compiler-rt:提供一些底层运行时支持,比如地址消毒器(AddressSanitizer)、未定义行为消毒器(UBSan)等工具的运行时库。
  • mlir:MLIR 项目,一个用于构建编译器的编译器框架,可以理解为“IR 的 IR”。它吸收了 LLVM IR 的设计理念,但更灵活,专门用来做编译器工具链的硬件/软件协同设计、AI 模型编译(如 TensorFlow、PyTorch 的一些编译器后端就是基于 MLIR)等。
  • polly:一个基于多面体模型的循环优化器,可以对循环进行非常激进的重组和并行化,主要给高性能计算场景用。
  • flang、openmp、pstl:Fortran 前端、OpenMP 实现、并行 STL 实现等较专业的分支。
  • llvmpipe:虽然 llvmpipe 的源码实际托管在 Mesa 项目里,但它属于 LLVM 生态的一部分。它利用 LLVM 的 JIT(Just-In-Time)编译能力,把 OpenGL/Vulkan 的着色器代码在运行时编译成 CPU 的 SIMD 指令,用纯软件的方式执行渲染管线。为什么要这么干?因为这能解决一个很实际的问题:当你在一台没有合适 GPU 驱动的机器上跑图形程序时,llvmpipe 能给你一个兜底的软件渲染方案,让程序不至于直接崩溃。这就要靠 LLVM 的模块化和 JIT 能力撑起来。

llvm-project 现在有几千个活跃贡献者,横跨苹果、谷歌、ARM、索尼、高通等一堆巨头。这个生态的影响力已经远远超出“编译器”的范畴,任何需要做代码生成、静态分析、语言开发的场景,几乎都能从中找到现有方案。

2. 自己动手构建 LLVM:从源码拉取到可用的 clang

如果你只是想用 clang 编译 C 语言,那没必要从源码构建,直接去官网下载预编译包就行——Linux 发行版也有现成的。但如果你想做开发,比如想改 clang 的行为、想给 LLVM 新增一个目标后端、想写一个自定义的优化 pass,或者想调试验证某个版本的 bug,那你就需要从源码构建一套完整的工具链了。

2.1 准备阶段的几个关键取舍:版本、磁盘、内存

首先说版本,我踩过最大的一个坑是版本匹配问题。llvm-project 是一个大仓库,相对于 git tag 你会看到 release/15.x 这样的分支。如果你同时还要用 Mesa 的 llvmpipe,那要注意 Mesa 对 LLVM 版本有要求,选 15.0.7 是因为 Mesa 某个版本要求最低 LLVM 15。理论上如果你用官方的 release 分支就不会有太大的兼容性问题,但如果你混用 dev 分支和 release 分支,很容易遇到奇奇怪怪的 API 不匹配。

磁盘空间这块,我强烈建议你准备30GB 以上的可用空间,内存建议至少 16GB。如果你只有 8GB 内存,也不是不能编,但要做好系统卡死的心理准备。编译时间上,一个完整的 Release 构建在 12 核 24 线程的机器上大概需要 30 到 60 分钟,16G 内存的情况下并行线程不要拉满,-j4可能更稳。

依赖方面,Linux 下需要 gcc 或 clang(引导编译器)、cmake(3.20 以上)、ninja、python3、zlib 等。另一点很多人不知道:构建 LLVM 需要比较新的 CMake 版本,旧版 CMake 可能在配置阶段就报错。如果你用的是 Ubuntu 20.04 那种老系统,建议用 pip 装个新版本 colcon 或用源码构建的 cmake。

2.2 cmake 配置的几种经典组合:官方版、Debug版、Toolchain版

CMake 配置是整个构建过程中最需要细抠的一步。这里给出一个我实际用过的、比较合理的组合:

git clone --depth 1 --branch llvmorg-15.0.7 https://github.com/llvm/llvm-project.git cd llvm-project mkdir build && cd build cmake -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DLLVM_ENABLE_PROJECTS="clang;lld;libcxx;compiler-rt" \ -DLLVM_TARGETS_TO_BUILD="X86;AArch64;RISCV" \ -DLLVM_ENABLE_ASSERTIONS=ON \ -DCMAKE_C_COMPILER=gcc \ -DCMAKE_CXX_COMPILER=g++ \ ../llvm ninja

逐个解释一下这些参数:

  • -DLLVM_ENABLE_PROJECTS:指定要构建 llvm-project 仓库里的哪些子项目。clang 是必然要的,lld 强烈建议要,如果是做 iOS/Android 开发,加上 libcxx 和 libcxxabi 会方便很多。但注意不能一开始就加太多,比如 mlir 和 flang 如果你不会用到,就最好不要加,每一个额外项目都会把编译时间往上拉一大截。
  • -DLLVM_TARGETS_TO_BUILD:指定后端要支持的目标架构。如果你只有 X86 的工作负载,那构建 X86 就够了。如果做交叉编译,需要额外加 ARM、AArch64、RISCV 等。target 数量越多,代码生成器的代码就越多,编译越慢。
  • -DLLVM_ENABLE_ASSERTIONS=ON:打开断言。开发调试模式建议开启,能帮你尽早发现 IR 或 pass 中的问题,但会损失一部分运行时性能。发布版本建议关闭,以获得更快的代码生成速度。
  • -DCMAKE_C_COMPILER/CXX_COMPILER:引导编译器一般用系统自带的 gcc 或 clang 都行。没有指定的话 CMake 会自动探测,但不同系统默认值不同,容易踩坑,所以还是显式指定比较好。

2.3 构建过程中最常遇到的三类故障及解决记录

这类大型项目的编译过程从来不会一帆风顺。我遇到的第一个问题是系统内存不足导致的编译进程崩溃:ninja默认会拉满所有核心,编译个别巨型文件(比如X86ISelDAGToDAG.cpp这种单文件几千行的)时内存占用飙到两三个 GB,然后 OOM 直接被 kill。解决方法是限制并发度,ninja -j4或加-DLLVM_PARALLEL_LINK_JOBS=2单独限制链接阶段的并发数。

第二个问题是 CMake 版本太老。Ubuntu 18.04 自带的 cmake 3.10 根本跑不起来,报错直接说需要 CMake 3.20 以上。这种情况只能用官方脚本装新版本,或者用 pip 安装。

第三个问题是系统语言环境。我遇到过因为 LANG 不是 en_US.UTF-8 导致测试脚本报错的情况,很莫名其妙,但把 locale 调成标准 UTF-8 之后就正常了。

提示:如果你只是想知道构建有没有成功,不需要跑完测试套件。测试套件ninja check-all会额外消耗大量时间和资源,一般开发场景下没有必要全量跑。

构建完成之后,build/bin目录下会出现clangclang++lldllvm-config等一批可执行文件。llvm-config这个工具很重要,它可以帮助你获取 LLVM 的编译参数和链接参数,例如llvm-config --cxxflags --ldflags --libs的输出就是后续开发自定义 LLVM 工具时的编译指示。

3. 实操:把一段 C 代码变成目标的整个过程

理解了怎么构建,接下来应该亲手走一遍“从源码到机器码”的完整流程。这一步非常重要,它能把前面讲的 IR、pass、后端这几个概念串成一条线。其实 LLVM 整个编译过程可以用一条命令拆开来看:

# 先写个测试文件 test.c # int main() { int a = 1; int b = 2; return a + b; } # 第一步:前端 clang 把 C 代码编译成 LLVM IR(.ll 文件) clang -S -emit-llvm test.c -o test.ll # 第二步:对 IR 执行优化(也可以直接用 -O2 一步到位) opt -O2 test.ll -o test.opt.bc # 第三步:后端将 IR 生成目标平台的汇编代码 llc test.opt.bc -o test.s # 第四步:汇编器转成目标文件 llvm-mc -filetype=obj test.s -o test.o # 第五步:链接器生成可执行文件 ld.lld test.o -o test # 也可以用 clang 一步完成所有步骤 clang test.c -o test

3.1 从 C 源码到 LLVM IR:用 clang 生成可读的中间表示

这是最直观理解 IR 的一步。运行clang -S -emit-llvm test.c -o test.ll后,你会看到整个 IR 结构。它包含模块信息、全局变量声明、函数定义和基本块。LLVM IR 也是 SSA(Static Single Assignment,静态单赋值)形式,即每个变量只能被赋值一次,所以你会看到大量以%1%2开头的临时变量,这是后续做数据流分析和优化的基础。

IR 有三种形式:人类可读的.ll文本文件、二进制不可读的.bc位码文件,以及内存中的表示。三种形式之间可以互相转换,这是调试时特别方便的一点——你可以随时把内存中的 IR dump 成文本来看,比如在你自己的 pass 里加一句llvm::errs() << F;就能把函数的 IR 打印出来。

3.2 中间优化层的一个典型 pass 演示:自定义一个函数内联观察点

LLVM 的优化能力全部体现在 pass 上。自 15 版本开始,新 PM(New Pass Manager)已经成为了默认方式。编写一个自定义 pass 是了解优化流程最好的方法,下面是一个最简单的打印函数名的 pass:

#include "llvm/IR/Function.h" #include "llvm/IR/LegacyPassManager.h" #include "llvm/Pass.h" #include "llvm/IR/Instructions.h" #include "llvm/IR/Module.h" #include "llvm/Support/raw_ostream.h" namespace { struct MyPass : public llvm::FunctionPass { static char ID; MyPass() : llvm::FunctionPass(ID) {} bool runOnFunction(llvm::Function &F) override { llvm::errs() << "Function: " << F.getName() << "\n"; for (auto &BB : F) { for (auto &I : BB) { llvm::errs() << " Inst: " << I.getOpcodeName() << "\n"; } } return false; } }; } // namespace char MyPass::ID = 0; static llvm::RegisterPass<MyPass> X("mypass", "My Pass");

然后用llvm-config编译成一个动态库或静态链接的工具,试试对前面生成的test.ll跑一遍:

clang++ -fPIC -shared mypass.cpp `llvm-config --cxxflags` -o libmypass.so opt -load-pass-plugin=./libmypass.so -passes=mypass test.ll

这个虽然简单,但你已经可以在这个框架里做任何你想做的变换了——比如统计某个函数的循环数量、插桩记录调用次数、做死代码消除等。理解了如何编写和使用 pass,你就掌握了修改编译器行为最核心的手段。

3.3 机器码生成:看 llc 如何做指令选择、寄存器分配和指令调度

IR 优化完之后,最后一步就是交给后端变成真正的机器指令。以 x86 平台为例,输入的 IR 会经过层层转换才变成movladdl这样的指令。这个过程中有三个关键步骤,非常值得一提:

  • 指令选择(Instruction Selection):把 IR 上的模式匹配成目标平台的指令模式。比如add nsw i32 %1, %2这个 IR,会被 x86 后端匹配成addl %r1, %r2指令。
  • 寄存器分配(Register Allocation):把 IR 里无限的虚拟寄存器映射到有限的物理寄存器上。这是编译领域最经典的 NP 问题之一,LLVM 用的是一种叫 Greedy 的启发式算法,在实际中效果非常好。
  • 指令调度(Instruction Scheduling):重新排列指令顺序,使得 CPU 流水线能够更高效地执行,减少停顿。

你可以用llc -debug看到整个后端的调试输出(注意要打开-DLLVM_ENABLE_ASSERTIONS并有 debug 符号),也能用llc -show-mc-inst查看生成的机器码。这个层面在工作日常中可能不常用到,但理解它有助于你分析后端的性能调优。

4. llvmpipe 和 256 bits:LLVM JIT 与 SIMD 的亲密关系

接着前面提到 llvmpipe 这个话题。Mesa 是 Linux 图形栈里的一个开源实现库,它实现 OpenGL、Vulkan 等图形 API。正常流程下,GPU 驱动程序收到渲染指令后,会把着色器编译成 GPU 能识别的微码或硬件指令去执行。但如果你没有 GPU(或者 GPU 驱动未加载),程序不就应该没法跑了吗?llvmpipe 的路子是:我不跑在 GPU 上,我直接在 CPU 上把着色器“翻译”成高效的机器码来跑,用 CPU 的速度来模拟 GPU 的渲染管线

4.1 llvmpipe 为什么不直接用 C 写渲染器,而是要引入 LLVM

如果你只是像一个普通的软件渲染器那样,把每个像素点的颜色计算用 C 语言循环一遍,性能会非常差。因为 GPU 着色器里大量用到向量运算(vec4 这样的四分量浮点向量),而 CPU 恰好也有对应的向量指令——SSE 能一次计算 128 位(即 4 个 float),AVX2 能一次算 256 位(即 8 个 float),AVX-512 甚至能一次处理 512 位。问题是,你不可能预先知道用户会提交什么样的着色器,所以没法在编译 libgallium 时就把着色器逻辑写死。这时候 LLVM 的 JIT 特性就派上用场了:在运行时,把着色器 IR 动态编译成当前 CPU 支持的最合适的 SIMD 指令

这也是为什么你会在日志里看到llvmpipe (llvm 15.0.7, 256 bits)这样的标识——它告诉你,当前这套 llvmpipe 是绑定 LLVM 15.0.7 构建的,它现在使用 256 位的向量宽度,也就是 AVX2 指令集。如果你的 CPU 支持 AVX-512,llvmpipe 可以尝试用 512 位宽度(取决于编译时的配置和运行时检测)。向量宽度越大,一次循环同时处理的像素或顶点数据就越多,渲染性能越好。

4.2 在 llvmpipe 中 LLVM 的 JIT 发挥了什么作用:一条完整路径

用更技术的方式描述这个过程:llvmpipe 里的一个组件会收到来自 Gallium3D 状态的 TGSI 或 NIR(两种中间表示语言),然后通过 LLVM 的 IR builder 接口,把着色器翻译成对应的 LLVM IR。之后调用 LLVM 的优化 pass(例如VerifierInstructionCombining等),再交给某个 TargetMachine(如 X86 的X86TargetMachine)做代码生成。最终通过ExecutionEngine执行代码生成的函数的调用指针,把对着色器代码的调用转化成对一块 CPU 指令区段的调用。

这里面有一个非常关键的设计点:因为 CPU 的 SIMD 指令是定宽的(AVX2 是 256 位),而着色器的向量类型可能是任意的(vec2、vec3、vec4),llvmpipe 需要高效地做“向量的 fill 和 extract”。这部分逻辑完全由 LLVM 优化器帮忙处理。写软件渲染器的人不用操心指令选择,只要描述“这里有四个 float 需要相加”,LLVM 后端就会自动帮你选好vaddps ymm0, ymm1, ymm2(AVX2 下的 256 位浮点向量加指令)还是拆成两个 128 位的vaddps xmm0, xmm1, xmm2,看目标 CPU 的支援情况来定。

4.3 向量宽度 256 bits 意味着什么:从实际帧率角度看

有些用户可能会纠结:为什么是 256 bits 而不是 128 bits?这个直接决定了每周期能处理多少数据。在当前消费级 CPU 上,AVX2 是最普及的向量指令集,基本上 Ryzen 和 4 代以后的 Core 都支持。llvmpipe 把向量宽度设为 256 bits,代表它一次可以并行执行 8 个单精度浮点数的运算,比 128 bits 翻了一倍。如果你的机器有 AVX-512(比如服务器级至强、部分 11 代以后至强),理论上 llvmpipe 还能启用 512 位模式,但这种场景在真机上并不常见,也受功耗限制。

我实际用 llvmpipe 跑过一些测试,说实话,在室内分辨率较低的窗口环境下、跑简单的图形程序,llvmpipe 是能扛得住的。但到重度 3D 场景(比如用 Godot 开启软件渲染)帧率就会比较难看了。“256 bits”加快了每像素的吞吐,但毕竟 CPU 并行度和 GPU 无法相提并论——LLVM 已经把 CPU 的能力榨到接近极限,物理天花板在那里。

5. 学会用 LLVM 能做哪些实际事情:一些值得落地的应用方向

如果你对编译技术本身不感兴趣,可能不知道 LLVM 到底能帮你做什么实际项目。这一节我举几个真实场景,它们都没有超出 LLVM 的能力圈,但却能帮你看到“这玩意不是只有理论价值”。

5.1 为自研语言写一个快速编译器前端和后端

现在很多语言开发者在设计新语言时,一开始就定了“使用 LLVM 作后端”。Rust 用的是自研前端 + LLVM 后端;Julia 用 LLVM 做 JIT;Swift 也用 LLVM。你写一门新语言时,最吃力的其实是后端——要生成高质量的、支持各种平台的机器码,需要数年时间。用 LLVM,前端把源代码翻译成 IR,后端所有优化和平台支持都已经给你准备好了。

前端的工作量也大多集中在“语义分析”和“IR 生成”上。你用LLVMContext创建模块,用IRBuilder创建指令,几乎不需要考虑寄存器和指令选择。一个简单支持整数的语言的编译器,几千行 C++ 代码就能搞定——这个投入产出比,在非 LLVM 时代是不可想象的。

5.2 实现一个静态分析工具

LLVM 提供了一套完整的 C++ API 来遍历 IR、分析数据流和控制流。市面上很多静态分析工具(如 Clang Static Analyzer、Infer 的一部分)都基于它。如果你想扫描自己项目的潜在崩溃点,比如空指针解引用、未初始化变量,完全可以基于 clang 的前端 + LLVM 的 IR 做定制化分析。在opt里加一个自定义 pass,设置某个分析策略,再跑一遍目标源代码,就可以在 IR 层做检查。这也顺便解释了为什么 LLVM 在 DevSecOps 赛道如此重要。

5.3 做高性能计算相关的优化

MLIR 和多面体优化器 Polly 在科学研究中大放异彩,比如针对矩阵运算、卷积、FFT 等计算核的自动向量化和缓存分块优化。这些优化在普通编译器里很难做(因为需要分析循环嵌套的数学结构),但 Polly 在 LLVM IR 层做了一个抽象,可以将循环变换成多维数据流模型,然后做极致的并行化和数据局部性优化。此时 256 bits 的 SIMD 宽度不再是 GPU 专用词汇——它在 CPU 的高性能计算里也非常重要。

6. 问题排查速查表:那些“我也遇到过的”典型问题

最后总结我实际碰过的几个常见坑,直接整理成一张表格,供你对照排查。

现象可能的原因解决方法
cmake 报错:CMake 3.20+ required系统 cmake 版本过老用 pip 安装新版本,或从 cmake.org 下载
编译时提示fatal error: 'llvm/IR/Function.h' file not found未正确使用 llvm-config 的 cxxflags检查llvm-config --includedir路径是否加入编译参数
链接时大量 undefined reference漏掉 LLVM 的库参数使用llvm-config --libs --system-libs补充链接参数
opt报错Pass plugin not found插件编译为动态库时缺少位置无关代码编译时加-fPIC
跑测试check-all卡死并发过高导致资源耗尽或死锁降低并行数,或单独跑特定测试目录
使用 llvmpipe 画面花屏或崩溃llvmpipe 版本与 LLVM 版本不匹配确认 Mesa 官方对该 LLVM 版本的支持情况;不要混用 dev 分支
构建时间过长打开了太多 target 和 project-DLLVM_TARGETS_TO_BUILD精简目标平台,只保留需要的

6.2 一个提升调试效率的小技巧:学会 dump 和看 IR

最后再分享一个我使用频率最高的调试技巧:碰到任何编译或优化相关的问题,第一步永远是“把 IR 文件 dump 出来看一遍”。无论是你想验证 clang 有没有按预期往前走,还是想观察 optimizer 有没有做某些优化,都建议看一下 IR 的实际形态。

你可以用clang -S -emit-llvm -O0看未优化的原始 IR,用clang -S -emit-llvm -O2看优化后的形态,对两个文件跑一下diff,很多问题就一目了然了。如果你在写自己的 pass,一定要在 pass 前和 pass 后分别 dump 一次再对比。这种“前后对照”的调试习惯,比单纯看代码高效十倍。

我个人在实际操作中的体会是:LLVM 的学习曲线不低,但它的回报非常高。你不需要一开始就把 IR 规范、pass 管理、目标后端这些全部啃完,可以先从"把 C 编译成 IR"开始,然后再"改一个 pass,加一条指令",最后再考虑深入后端或 JIT。这套项目的架构很优雅,但正因为功能太多,你反而要学着“克制”地使用它——抓住一条自己需要的链路,吃透它,比试图理解全部细节要有效得多。希望这篇基于 llvm-project 的拆解能帮你少走一些我当年走过的弯路。

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

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

立即咨询