news 2026/9/20 14:18:55

LLVM编译器框架入门:从构建到Pass开发与llvmpipe实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LLVM编译器框架入门:从构建到Pass开发与llvmpipe实践

打开编译器的黑盒之前,我一直觉得 LLVM 是个离我很远的东西。后来自己动手下载 llvm-project、跑 CMake、看 IR、写 Pass,才发现它其实没那么高冷——它像一个编译器领域的“积木工厂”,Rust、Swift、Clang 这些天天见面的工具,底层都有它的影子。如果你写代码、做性能优化,或者只是好奇“源代码到底怎么变成机器码”的,这篇内容应该能帮你把这套庞然大物拆出一个清晰的轮廓。

1. 先看整体:llvm-project 里到底藏着什么

1.1 一个仓库,N个独立项目

很多人第一次打开 llvm-project 仓库时都会被吓到,目录太多、命名太抽象。其实官方早就把整套代码拆成了若干子项目,每个都是相对独立的组件,组合在一起才构成完整的 LLVM 生态。

核心的几个我列在下面,方便你建立第一印象:

子项目作用
llvm整个生态的核心库,包括 IR 定义、优化器、后端代码生成、汇编器、链接器等基础能力
clangC/C++/Objective-C 前端,把源码解析成 AST,再翻译成 LLVM IR
lld高性能链接器,支持 ELF、Mach-O、COFF 等格式,速度通常比系统自带链接器快不少
libc++ / libc++abiC++ 标准库和 ABI 兼容层,很多新特性会先在这里落地
compiler-rt编译器运行时库,包含 sanitizer(ASan、UBSan 等)和各类底层辅助函数
mlir面向编译器基础设施的多级中间表示框架,现在 AI 编译器领域特别火
flangFortran 前端
lldb基于 LLVM 的调试器
llvmpipe纯软件实现的图形光栅化器,GPU 不可用时的渲染兜底方案

我在实际使用中其实只盯住其中几个:日常构建 C/C++ 代码用 clang,链接提速靠 lld,分析优化逻辑时直接在 llvm 目录里翻。其他子项目各有各的玩法,但对大多数使用者来说,先跑通 clang + lld + llvm 这条主线就够了。

1.2 IR:LLVM 安身立命的核心

所有 LLVM 组件都围绕一个东西转,就是中间表示(Intermediate Representation,简称 IR)。它长得像一种精简的汇编语言,但抽象层次比汇编更高,变量用无限编号的虚拟寄存器,类型系统明确区分整数、浮点数、指针、向量等,还支持undefpoison这类在高级语言和汇编里都不存在的特殊值。

IR 最大的特点是没有平台绑定。同一个.ll文件,可以翻译成 x86、ARM、RISC-V 甚至 GPU 指令。这意味着什么?我举个例子你就明白了:Rust 编译器把 MIR 降到 LLVM IR,Swift 也把 SIL 降到 LLVM IR,Clang 把 C/C++ 代码也降到 LLVM IR。三种完全不同语言的前端,最终都汇入同一条优化和代码生成流水线。这也是 LLVM 生态最迷人的地方——你只需要把自己的语言翻译成 IR,后面的优化、寄存器分配、指令选择、平台适配,整套设施直接复用,不用从零造轮子。

有一类为llvm配置的交叉编译场景非常能体现 IR 的价值:在 x86 机器上构建 ARM 程序,前端照样生成目标无关的 IR;后端看到目标架构是 ARM,就会选择对应的指令集模式和 ABI 规则。整个过程编译器核心逻辑不用重写,只是换了目标描述文件而已。

1.3 前后端解耦带来的连锁反应

把“前端语言解析”和“后端代码生成”解耦之后,好处不只是复用这么简单。它让整个工具链变得像一条生产线:前端工人只负责“把原料翻译成统一格式”,后端工人只负责“把统一格式翻译成目标机器语言”,优化器在中间做提炼。

这带来一个很实际的好处:新语言想快速获得成熟的优化和代码生成能力,不需要跑到 GCC 那边去适配一套 tightly coupled 的架构。Swift 当年选 LLVM 路径,Rust 也明确使用 LLVM 做后端,都看中了“你只需要解决前端,剩下的我来”这种模式。后来很多新兴语言也沿着这条路走,写完词法分析和语法分析,再输出 LLVM IR,几天就能得到一个能跑的原型,这在二十年前几乎不敢想。

对编译器学习者来说,这种解耦也让学习曲线平滑了不少。你可以绕过 AST 和语法分析的繁琐,直接从 IR 开始研究优化算法和寄存器分配,然后再回头补前端。说实话,如果当初 GCC 那套整体式架构是唯一的教材,我可能早就放弃了。

2. 为什么 LLVM 能一统编译器的半壁江山

2.1 优化流水线:把“改代码”变成“搭积木”

传统编译器的优化逻辑是一大坨内部程序互相调用的黑盒,很难单独调试某个步骤。LLVM 的优化器被设计成一条流水线,里面每个优化都是一个独立的 Pass,负责一项明确的变换。

比如-mem2reg专门提升内存访问到寄存器,-instcombine做指令级代数化简,-loop-unroll展开循环,-inline做函数内联。你可以自由组合它们,像拼乐高一样定制自己的优化流程。调优时最常用的一套组合是:先-mem2reg,再-instcombine,然后-simplifycfg,最后再跑几轮-loop相关优化。这些 Pass 的名字听起来很抽象,实际在命令行一试就能看到效果:输入一小段 C 代码,clang -S -emit-llvm生成 IR 后,手动跑几个 Pass,就能看到冗余指令消失、分支结构变清爽。

Pass 机制带了一个额外好处:调试方便。某个优化把程序改错了,你可以逐个 Pass 排查,找到罪魁祸首,而不是面对一整锅糊掉的代码。开发自己语言的编译后端时,这个特性简直是救命稻草。

2.2 目标无关与目标相关的分界线

LLVM 对“目标无关”和“目标相关”的划分,是我见过最清晰的编译器设计之一。目标无关的部分处理 IR 层面的优化,比如公共子表达式消除、死代码删除,这些逻辑无论跑到什么 CPU 上都成立。目标相关的部分则负责处理指令选择、寄存器分配、指令调度等,这些必须知道芯片的具体能力。

这种分工的具体体现是 TableGen 这套描述语言。它允许后端开发者用声明式的方式描述指令集,LLVM 工具链自动生成匹配器、编码器、反汇编器的代码。比如你想给一种新的 RISC 芯片做后端,大部分工作变成“定义寄存器组、定义指令格式、描述指令选择规则”,而不是手写一堆模式匹配的 C++ 代码。

我自己试过稍微浏览 RISC-V 后端的文件,只用了几百行描述就能看懂整体指令映射逻辑,这比 GCC 那堆机器描述文件容易理解得多。

2.3 风格与体制的胜利

除了技术本身,LLVM 流行还有一个重要原因:它的社区协作方式更现代。代码用 CMake 管理、模块划分清晰、大量单元测试和回归测试,这些工程化实践让外部开发者很容易上手参与。而 GCC 内部结构非常复杂,外围工具链不统一(binutils 是一个独立项目),改造侵入性大,导致很多厂商最后选择 LLVM。

C++ 代码质量也是一个因素。LLVM 的代码风格极其统一,头文件守卫前缀、命名空间规范、注释风格都有明确约定。我最初读 LLVM 源码时有个感受:看别人的 C++ 代码有时很痛苦,但 LLVM 的代码读起来像一篇结构清晰的说明书。长年累月的代码审查文化带来的积累,让这套框架不只是工具,还是一套编译器开发的“最佳实践模板”。

3. 从零开始构建 llvm-project 的实操记录

3.1 克隆源码:注意分支和体积

构建 llvm-project 第一步是获取源码。如果你只是想编译并日常使用,千万不要git clone整个仓库默认分支的完整历史,那会下很久很久。建议用浅克隆加版本标签:

git clone --depth=1 --branch llvmorg-15.0.7 https://github.com/llvm/llvm-project.git cd llvm-project

为什么我推荐固定版本而不是跟踪 main?因为 LLVM 的主干分支变动极快,API 可能隔几周就调整一次,外部工具链很容易失配。15.0.7 是一个非常稳定的版本,热词里提到的 llvmpipe 相关版本也是 15.0.7,这个版本号对应的是 LLVM 15 时代的维护性发布,修了不少 bug,比较适合用来学习或者给生产环境做依赖。

3.2 CMake 配置:参数选型决定后续体验

构建 LLVM 的经典套路是新建一个 build 目录,在里面跑 CMake。目录一定不要放在源码树里,否则后续清理非常麻烦。第一次构建我建议直接用 Ninja 这个构建系统,比 Make 快不少,还支持并行任务控制得更精细。

基础配置命令:

cmake -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DLLVM_ENABLE_PROJECTS="clang;lld" \ -DLLVM_TARGETS_TO_BUILD="X86;AArch64;RISCV" \ ../llvm

这里几个参数我说一下。LLVM_ENABLE_PROJECTS控制要额外构建哪些子项目,clanglld是日常编译链接最常用的。如果对 MLIR 感兴趣,也可以把mlir加进去,但首次构建不要贪多,每个额外项目都会显著增加编译时间。LLVM_TARGETS_TO_BUILD决定后端要支持哪些 CPU 架构,如果只在本机调试,填X86就够了。把 RISC-V 加进去纯粹是为了交叉编译实验,代价是编译时间长不少。

有一点容易踩坑:如果内存不够大(少于 8GB),链接阶段很容易 OOM。可以用-DLLVM_PARALLEL_LINK_JOBS=1限制并行链接任务数,虽然速度会变慢,但至少不会莫名崩溃。我第一次构建时就因为没限制链接任务,内存直接被打满,系统卡死了半分钟才缓过来。

3.3 Ninja 构建:漫长的等待和它的意义

配置完成之后,构建命令很简单:

ninja -j$(nproc)

不过这里有一个经验之谈:-j$(nproc)并不总是最优解。如果你的 CPU 核心很多但内存有限,全部核心一起干活链接阶段照样会爆内存。稳妥起见,我一般用-j8或者-j$(($(nproc)/2)),牺牲一点时间换稳定。

首次 Release 构建带 clang 和 lld,大约需要二十分钟到一小时,取决于机器性能。这段时间适合顺手看看 LLVM 的文档,或者准备好后面实验要用的 C 代码。构建结束后,所有二进制都会集中在 build/bin 目录里。你把它加进 PATH,就可以直接体验 LLVM 全家桶了:

export PATH=$PWD/build/bin:$PATH clang --version

看到 clang version 15.0.7 输出的那一刻,感觉之前等待都值了。

3.4 构建完成后的第一组实验

装好之后别着急关终端,先用一段最小的 C 代码验证工具链是否正常。写个hello.c

#include <stdio.h> int main(void) { printf("Hello, LLVM!\n"); return 0; }

然后分别用你系统的 gcc 和新构建的 clang 编译,对比一下生成的汇编:

clang -O2 -S hello.c -o hello_clang.s gcc -O2 -S hello.c -o hello_gcc.s

这组对比很有意思。你会发现两个编译器生成汇编在某些细节上风格明显不同(指令选择顺序、常量加载方式、栈布局差异),但整体逻辑都是正确的。这也是 LLVM 和 GCC 性能之争的微观缩影——它们多次在各类 Benchmark 上互有胜负,实际差异往往不超过几个百分点。

如果想进一步体验 lld 的速度提升,可以编译一个大一点的项目,把链接器替换成 lld:

clang -O2 hello.c -fuse-ld=lld -o hello_lld

链接一个简单程序看不出什么,但在大型 C++ 项目里,替换 lld 之后链接时间从分钟级降到秒级是常有的体验。

4. 亲手写一个 IR Pass:理解优化器的最短路径

4.1 Pass 到底是什么

前面说过优化器由若干 Pass 组成,现在我们来实际写一个最简单的 Pass,跑在opt工具里面。写之前先澄清一个概念:Pass 就是一个遍历 LLVM IR 的 C++ 类,在 IR 模块中查找或修改需要变换的内容。传统 Pass 分好几种模块级别,最常见的是ModulePass(遍历整个模块)、FunctionPass(遍历每个函数)、LoopPass(遍历每个循环)。

日常学习中写一个FunctionPass打印函数名,是最快的上手方式。它能让你体会到“编译器优化就是在 IR 上做程序变换”这句话的含义。

4.2 编写最小 FunctionPass

打开 LLVM 源码树,在llvm/lib/Transforms/Utils下新建一个文件,比如MyFirstPass.cpp。内容如下(基于 LLVM 15 的接口):

#include "llvm/IR/Function.h" #include "llvm/IR/LegacyPassManager.h" #include "llvm/Pass.h" #include "llvm/Passes/PassBuilder.h" #include "llvm/Passes/PassPlugin.h" #include "llvm/Support/raw_ostream.h" using namespace llvm; namespace { class MyFirstPass : public FunctionPass { public: static char ID; MyFirstPass() : FunctionPass(ID) {} bool runOnFunction(Function &F) override { errs() << "Visiting function: " << F.getName() << "\n"; return false; // 没有修改任何内容,返回 false } }; char MyFirstPass::ID = 0; static RegisterPass<MyFirstPass> X("my-first-pass", "My First Pass"); } // namespace

代码逻辑非常简单:继承FunctionPass,在runOnFunction里打印函数名。RegisterPass把 Pass 注册到opt的 legacy Pass 框架,这样命令行就能通过-my-first-pass调用它。

如果要做成动态加载的插件,还需要实现一个llvmGetPassPluginInfo函数。这里为了简洁,我先用静态接入编译的方式演示。

4.3 编译并跑起来

把文件加进 CMake 会麻烦一点,最简单的方式是直接把它放进llvm/lib/Transforms/Utils/CMakeLists.txtadd_llvm_component_library列表里,比如在Utils.cpp后面追加一项MyFirstPass.cpp,然后再重新构建 opt:

ninja opt

构建完成后,找一段 C 代码生成 IR:

cat > test.c << 'EOF' int add(int a, int b) { return a + b; } int main(void) { return add(2, 3); } EOF clang -S -emit-llvm test.c -o test.ll opt -load build/lib/libMyFirstPass.so -my-first-pass test.ll

这里有个新手很困惑的点:-load参数针对动态库方式,如果你的 Pass 是静态编进 opt 的,就不需要-load,直接opt -my-first-pass test.ll就能跑。看到输出里依次打印Visiting function: addVisiting function: main,说明 Pass 真的被调用了。

4.4 版本差异的坑

LLVM 不同版本之间 Pass 接口变化很大。LLVM 14 之后主推新 Pass Manager,也就是基于PassBuilderAnalysisManager的框架,legacy 的FunctionPass却仍然保留着,造成了很多教程和代码的混乱。

如果以后你在网上看到一篇教程用了PM.addPass(FunctionPass())这类写法,注意它可能是针对新 Pass Manager。学习阶段我的建议是先拥抱 legacy 接口,因为它更容易理解,调试也直白。理解了 Pass 的核心概念之后,再切换到新 Pass Manager 就顺理成章了。

5. llvmpipe:LLVM 生态里最不务正业的惊喜

5.1 llvmpipe 做了什么

说到 llvmpipe,它是 Mesa 3D 图形库中的一个软件渲染器,核心思想是用 LLVM 来 JIT 编译图形渲染的 shader 代码,让它们在 CPU 上高效执行。通俗一点说:当你的机器没有 GPU 或者 GPU 驱动不可用时,图形程序也不会直接罢工,而是退回到纯 CPU 软件渲染模式,llvmpipe 负责把那些为 GPU 设计的渲染指令翻译成 CPU 指令来执行。

这个词条之所以和 LLVM 关联紧密,是因为它完全建立在 LLVM 的 IR 和代码生成能力之上。没有 LLVM,llvmpipe 不可能达到可用的速度;没有 llvmpipe,很多无 GPU 的服务器环境和虚拟机会在图形初始化阶段直接失败。

热词里的 “llvm 15.0.7” 和 “256 bits” 其实是 llvmpipe 在桌面环境渲染时输出的一行调试信息,大意是渲染管线将某些数据打包成 256 位的 SIMD 向量进行处理。这直接关系到软件渲染的性能。

5.2 256 bits 与 SIMD 的关系

现代 x86 CPU 都支持 AVX2 指令集,寄存器宽度是 256 位,也就是一次可以处理 8 个 32 位浮点数或 32 个 8 位整数。llvmpipe 利用 LLVM 的自动向量化能力,把图形片段着色器里的计算打包成这种宽度,让 CPU 尽可能像 GPU 那样并行处理数据。

我对比过 llvmpipe 在不同 SIMD 模式下的表现。同样的桌面渲染场景,纯标量执行几乎卡到不可用,而启用 AVX2 后能跑到基本流畅的帧率(当然和真 GPU 没法比)。这说明 LLVM 后端的向量化质量直接决定了 llvmpipe 的上限。256 这个数字不是随便选的,它正好对应 AVX2 的寄存器宽度,如果 CPU 支持 AVX-512,llvmpipe 还可以用上 512 位向量。

5.3 在无 GPU 环境下使用 llvmpipe

实际场景中最常见的是跑 CI(持续集成)测试。构建一个需要 OpenGL 上下文的应用,机器上却没有 GPU,这时候把环境变量LIBGL_ALWAYS_SOFTWARE=true设上,强制 Mesa 走软件渲染路径,llvmpipe 就能让测试在 CPU 上跑完整个渲染流程。

还有个用途是离屏渲染。在容器里做渲染任务,不需要真实显示桌面,只需要帧缓冲。llvmpipe 配合 EGL 的 surfaceless platform,可以完全不碰 X11 或 Wayland,直接在内存中渲染出图像。这在生成缩略图、做测试快照、跑图形算法验证时非常实用。

有一点要注意:llvmpipe 对计算密集型 shader 的渲染速度远不如真实 GPU,如果测试代码对帧率有硬性要求,软件渲染模式很可能会因为超时被判断为失败。遇到这种问题,建议把渲染超时阈值调大,或者在 CI 配置里单独标记这些用例。

6. 实际踩坑与排查技巧

6.1 构建阶段的经典问题

我整理了几个高频问题,用表格记录在这里,都是自己踩过或者看别人反复问过的:

现象原因解决办法
链接阶段 OOM 崩掉并行链接任务太多,内存不够设置-DLLVM_PARALLEL_LINK_JOBS=1
cmake 找不到 Ninja没安装 ninja-buildUbuntu/Debian 上用apt install ninja-build,macOS 上用brew install ninja
clang命令不存在忘记加 PATH 或没构建 clang确认LLVM_ENABLE_PROJECTS包含 clang,重新构建
构建很慢且反复失败源码目录有临时文件残留重新开一个干净 build 目录,不要增量续用坏掉的构建树
opt加载自定义 Pass 报版本不匹配编译 Pass 用的 LLVM 版本和运行 opt 的版本不一致保持两者的 LLVM 源码版本和构建选项一致

6.2 IR 调试技巧

写优化 Pass 时最常干的事就是看 IR。一个实用的命令是把单个函数的 IR 打印出来:

opt -passes='print<function>' test.ll

不同版本 pass 名称会有变化,LLVM 15 下面print<function>打印函数级别分析结果。如果想看优化前后对比:

opt -S -passes='mem2reg' test.ll -o test_opt.ll diff test.ll test_opt.ll

diff 出来你会很直观地看到allocaload/store如何被消除,替换成 SSA 形式的虚拟寄存器。我觉得理解这一步比读十篇原理文章都有效。

还有个小技巧:clang -O0 -S -emit-llvmclang -O2 -S -emit-llvm生成的 IR 对比,能直接展示优化器的工作量。我经常拿这两份 IR 对比去解释“编译器优化到底做了什么”。

6.3 新 Pass Manager 的初次接触

LLVM 15 里 legacy Pass Manager 还在,但新 Pass Manager 日益成为主流。如果你想写新风格 Pass,最简单的方式是参考llvm/lib/Passes/PassBuilder.cpp里的parseAnalysisPassparseOptimizerPass的注册逻辑。新接口的特点是所有 pass 都是类模板,通过FunctionAnalysisManager获取依赖分析结果,不再有char ID那一套东西。

新 Pass Manager 的动态加载方式也变了,需要实现llvm::PassPluginLibraryInfo。如果刚开始接触,我建议先跑通 legacy 版本,有了基础再迁移。不要一上来就追新,否则会被各种抽象概念搞乱。

6.4 我个人的一些固化习惯

用 LLVM 一年多,我总结出几个能显著提高效率的小习惯:

第一,永远把 build 目录放在高速磁盘上。LLVM 编译过程产生海量小文件,机械硬盘上构建时间能慢到怀疑人生,换成 NVMe SSD 之后体验完全不同。

第二,配置一个字符精简的别名。我日常最常用的是:

alias llc='build/bin/llc' alias opt='build/bin/opt' alias clang='build/bin/clang'

以及把 build/bin 放进 PATH,这样所有工具直接可用,不用每次写一长串路径。

第三,写 Pass 之前一定先看llvm/examples目录。里面有几个标准示例,包括如何编写 FunctionPass、如何接入 PassBuilder,代码风格非常规范。照着改,比自己盲写靠谱得多。

最后,处理问题时善用llvm-reduce工具。当编译器或者 Pass 出 bug 时,它能把大型 IR 文件自动裁减到最小可复现场景。这个工具在 LLVM 开发调试里几乎是神级存在,能省下大量手工裁剪时间。


从第一次跑通 clang 编译 C 程序,到写出自己的第一个 Pass,再到看 llvmpipe 用 LLVM 在 CPU 上“模拟”GPU 渲染,我对这套项目最深的体会是:它不是一个单一工具,而是一整套关于“如何构建编译器”的思考方式和工程实现。上手的时候可能会被它的规模和版本波动劝退,但只要建好一个稳定的构建环境,从 IR 和 Pass 入手慢慢玩,你会发现自己对程序执行和性能优化的理解,会跨上一个完全不同的台阶。下次再看到clang编译你的代码,你就知道这背后是怎样一个精密又优雅的世界了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/20 14:17:51

Claude-Code高级编程实践与性能优化指南

1. 项目概述"10-Claude-Code高级应用与最佳实践"这个标题指向的是一个关于代码开发与优化技术的深度指南。作为一名有十年全栈开发经验的工程师&#xff0c;我理解这类内容的核心价值在于将抽象的技术概念转化为可落地的实操方案。Claude-Code在这里代表着一套系统化…

作者头像 李华
网站建设 2026/9/20 14:17:43

IDA + MCP + AI 保姆级配置教程:让大模型直接操作逆向工具

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 14:15:56

Ghidra逆向工程实战:从安装配置到高效分析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 14:15:16

LiveCodeBench 题单:TaoToken 给 Kimi K2.7 Code 做逐题调用记录

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 14:11:43

vn.py源码解析:事件驱动与模块化设计原理

1. 为什么读懂 vn.py 的源码&#xff0c;比学会写一个策略更重要&#xff1f;在量化交易这个行当里&#xff0c;我见过太多人把时间花在调参、回测、优化指标上&#xff0c;却从没打开过 vn.py 的event_engine.py文件看一眼。他们用着CtaStrategy类&#xff0c;却不知道on_tick…

作者头像 李华