拓冰建站拓冰建站
首页 / 资讯中心 / 正文

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 IRlld高性能链接器支持 ELF、Mach-O、COFF 等格式速度通常比系统自带链接器快不少libc / libcabiC 标准库和 ABI 兼容层很多新特性会先在这里落地compiler-rt编译器运行时库包含 sanitizerASan、UBSan 等和各类底层辅助函数mlir面向编译器基础设施的多级中间表示框架现在 AI 编译器领域特别火flangFortran 前端lldb基于 LLVM 的调试器llvmpipe纯软件实现的图形光栅化器GPU 不可用时的渲染兜底方案我在实际使用中其实只盯住其中几个日常构建 C/C 代码用 clang链接提速靠 lld分析优化逻辑时直接在 llvm 目录里翻。其他子项目各有各的玩法但对大多数使用者来说先跑通 clang lld llvm 这条主线就够了。1.2 IRLLVM 安身立命的核心所有 LLVM 组件都围绕一个东西转就是中间表示Intermediate Representation简称 IR。它长得像一种精简的汇编语言但抽象层次比汇编更高变量用无限编号的虚拟寄存器类型系统明确区分整数、浮点数、指针、向量等还支持undef、poison这类在高级语言和汇编里都不存在的特殊值。IR 最大的特点是没有平台绑定。同一个.ll文件可以翻译成 x86、ARM、RISC-V 甚至 GPU 指令。这意味着什么我举个例子你就明白了Rust 编译器把 MIR 降到 LLVM IRSwift 也把 SIL 降到 LLVM IRClang 把 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 --depth1 --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_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86;AArch64;RISCV \ ../llvm这里几个参数我说一下。LLVM_ENABLE_PROJECTS控制要额外构建哪些子项目clang和lld是日常编译链接最常用的。如果对 MLIR 感兴趣也可以把mlir加进去但首次构建不要贪多每个额外项目都会显著增加编译时间。LLVM_TARGETS_TO_BUILD决定后端要支持哪些 CPU 架构如果只在本机调试填X86就够了。把 RISC-V 加进去纯粹是为了交叉编译实验代价是编译时间长不少。有一点容易踩坑如果内存不够大少于 8GB链接阶段很容易 OOM。可以用-DLLVM_PARALLEL_LINK_JOBS1限制并行链接任务数虽然速度会变慢但至少不会莫名崩溃。我第一次构建时就因为没限制链接任务内存直接被打满系统卡死了半分钟才缓过来。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 的速度提升可以编译一个大一点的项目把链接器替换成 lldclang -O2 hello.c -fuse-ldlld -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 RegisterPassMyFirstPass 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.txt的add_llvm_component_library列表里比如在Utils.cpp后面追加一项MyFirstPass.cpp然后再重新构建 optninja opt构建完成后找一段 C 代码生成 IRcat 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: add和Visiting function: main说明 Pass 真的被调用了。4.4 版本差异的坑LLVM 不同版本之间 Pass 接口变化很大。LLVM 14 之后主推新 Pass Manager也就是基于PassBuilder和AnalysisManager的框架legacy 的FunctionPass却仍然保留着造成了很多教程和代码的混乱。如果以后你在网上看到一篇教程用了PM.addPass(FunctionPass())这类写法注意它可能是针对新 Pass Manager。学习阶段我的建议是先拥抱 legacy 接口因为它更容易理解调试也直白。理解了 Pass 的核心概念之后再切换到新 Pass Manager 就顺理成章了。5. llvmpipeLLVM 生态里最不务正业的惊喜5.1 llvmpipe 做了什么说到 llvmpipe它是 Mesa 3D 图形库中的一个软件渲染器核心思想是用 LLVM 来 JIT 编译图形渲染的 shader 代码让它们在 CPU 上高效执行。通俗一点说当你的机器没有 GPU 或者 GPU 驱动不可用时图形程序也不会直接罢工而是退回到纯 CPU 软件渲染模式llvmpipe 负责把那些为 GPU 设计的渲染指令翻译成 CPU 指令来执行。这个词条之所以和 LLVM 关联紧密是因为它完全建立在 LLVM 的 IR 和代码生成能力之上。没有 LLVMllvmpipe 不可能达到可用的速度没有 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-512llvmpipe 还可以用上 512 位向量。5.3 在无 GPU 环境下使用 llvmpipe实际场景中最常见的是跑 CI持续集成测试。构建一个需要 OpenGL 上下文的应用机器上却没有 GPU这时候把环境变量LIBGL_ALWAYS_SOFTWAREtrue设上强制 Mesa 走软件渲染路径llvmpipe 就能让测试在 CPU 上跑完整个渲染流程。还有个用途是离屏渲染。在容器里做渲染任务不需要真实显示桌面只需要帧缓冲。llvmpipe 配合 EGL 的 surfaceless platform可以完全不碰 X11 或 Wayland直接在内存中渲染出图像。这在生成缩略图、做测试快照、跑图形算法验证时非常实用。有一点要注意llvmpipe 对计算密集型 shader 的渲染速度远不如真实 GPU如果测试代码对帧率有硬性要求软件渲染模式很可能会因为超时被判断为失败。遇到这种问题建议把渲染超时阈值调大或者在 CI 配置里单独标记这些用例。6. 实际踩坑与排查技巧6.1 构建阶段的经典问题我整理了几个高频问题用表格记录在这里都是自己踩过或者看别人反复问过的现象原因解决办法链接阶段 OOM 崩掉并行链接任务太多内存不够设置-DLLVM_PARALLEL_LINK_JOBS1cmake 找不到 Ninja没安装 ninja-buildUbuntu/Debian 上用apt install ninja-buildmacOS 上用brew install ninjaclang命令不存在忘记加 PATH 或没构建 clang确认LLVM_ENABLE_PROJECTS包含 clang重新构建构建很慢且反复失败源码目录有临时文件残留重新开一个干净 build 目录不要增量续用坏掉的构建树opt加载自定义 Pass 报版本不匹配编译 Pass 用的 LLVM 版本和运行 opt 的版本不一致保持两者的 LLVM 源码版本和构建选项一致6.2 IR 调试技巧写优化 Pass 时最常干的事就是看 IR。一个实用的命令是把单个函数的 IR 打印出来opt -passesprintfunction test.ll不同版本 pass 名称会有变化LLVM 15 下面printfunction打印函数级别分析结果。如果想看优化前后对比opt -S -passesmem2reg test.ll -o test_opt.ll diff test.ll test_opt.lldiff 出来你会很直观地看到alloca和load/store如何被消除替换成 SSA 形式的虚拟寄存器。我觉得理解这一步比读十篇原理文章都有效。还有个小技巧clang -O0 -S -emit-llvm和clang -O2 -S -emit-llvm生成的 IR 对比能直接展示优化器的工作量。我经常拿这两份 IR 对比去解释“编译器优化到底做了什么”。6.3 新 Pass Manager 的初次接触LLVM 15 里 legacy Pass Manager 还在但新 Pass Manager 日益成为主流。如果你想写新风格 Pass最简单的方式是参考llvm/lib/Passes/PassBuilder.cpp里的parseAnalysisPass或parseOptimizerPass的注册逻辑。新接口的特点是所有 pass 都是类模板通过FunctionAnalysisManager获取依赖分析结果不再有char ID那一套东西。新 Pass Manager 的动态加载方式也变了需要实现llvm::PassPluginLibraryInfo。如果刚开始接触我建议先跑通 legacy 版本有了基础再迁移。不要一上来就追新否则会被各种抽象概念搞乱。6.4 我个人的一些固化习惯用 LLVM 一年多我总结出几个能显著提高效率的小习惯第一永远把 build 目录放在高速磁盘上。LLVM 编译过程产生海量小文件机械硬盘上构建时间能慢到怀疑人生换成 NVMe SSD 之后体验完全不同。第二配置一个字符精简的别名。我日常最常用的是alias llcbuild/bin/llc alias optbuild/bin/opt alias clangbuild/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编译你的代码你就知道这背后是怎样一个精密又优雅的世界了。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门