ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

LLVM项目实战指南:从IR到Pass,理解编译器的核心架构

LLVM项目实战指南:从IR到Pass,理解编译器的核心架构 搞编译器的人十有八九绕不开llvm-project这个项目。我最早接触它是因为想弄明白一个看起来很基础的问题一段 C 代码到底是怎么一步步变成能在 CPU 上跑的机器指令的顺着这个问题往下挖就撞上了 LLVM 这个庞然大物。说它庞大一点不夸张。llvm-project不是一个单独的软件而是一整套围绕“编译”这件事展开的开源项目集里面包含了编译器前端、优化器、后端代码生成、链接器、标准库、调试器组件、函数库甚至还有一堆给编译原理研究者用的基础设施。更直白地说如果把“编译”这件事比作一条流水线那么llvm-project就是这条流水线的整套设备从原材料源代码进厂到最终成品可执行文件或者目标码出厂所有环节它都能管。这篇文章我不会去逐行念源码而是想从一个实际使用者的角度把这套项目拆开揉碎讲清楚它到底是怎么组织的、核心机制是什么、普通人应该从哪里上手以及我在这条路上踩过哪些坑。不管是刚接触编译原理的学生还是工作中需要折腾工具链的工程师相信都能找到点用得上的东西。1. 整体设计与思路拆解为什么llvm-project能通吃这么多编程语言很多人第一次看到 LLVM 的架构图第一反应是“怎么还有这种好事”前端解析十几种语言后端生成十几种 CPU 架构的指令中间的优化器却只有一套。这不是运气而是从一开始就设计好的。1.1 三大组件拆开把“编译”这件事解耦了传统编译器通常按“前端 后端”两段式设计比如我们熟悉的 GCC 早期是例外但真正把这个思路贯彻到极致并形成成熟生态的是 LLVM。llvm-project的整体架构可以理解为三段前端Frontend负责把源代码解析成中间表示也就是 LLVM IR。Clang 是 C/C/Objective-C 的前端其他语言有不同的前端比如 Rust 用的是 rustc 自己但它可以直接生成 LLVM IR 交给你装入这个生态。优化中端Optimizer/Optimization Middle End把生成的 LLVM IR 做一遍又一遍的等价变换去掉多余计算、内联函数、循环优化等。这段是纯平台无关的。后端Backend把优化后的 LLVM IR 翻译成目标处理器的机器指令同时做寄存器分配、指令调度、汇编输出。这套解耦设计的厉害之处在于如果你想支持一门新语言只需要写前端把语法树变成 LLVM IR后面的优化和代码生成全是现成的。如果你想支持一种新 CPU只需要写后端把 LLVM IR 变成它的指令即可。两边互不干扰这也解释了为什么如今 Rust、Swift、Julia 这些语言都不约而同地选择 LLVM 作为基础设施。1.2 IR 是腰撑起整个生态聊 LLVM就不能不聊 LLVM IR。它是一套介于 C 语言和汇编之间的中间语言既有高级语言的抽象表达能力又贴近底层指令的形式。正因为有了这层统一的 IR前端的输出才能稳定地被后端消费优化器才能在上面做各种通用优化。打个比方如果编译是一个跨国翻译团队LLVM IR 就是团队通用的“国际音标”。无论源文件是英文、法文还是中文前端的工作都是先把它们音译成这套固定符号后端只需要认识这套符号再用目标 CPU 的“方言”念出来就行。不会出现“中文直译成英文”再“英文直译成日文”那种逐级失真。1.3 为什么整套项目用 C 写这是工程选择不是情怀有人可能会问编译器的核心基础设施为什么偏偏选 C说实话这个选择一直到今天都有讨论。C 的优势在于能直接管理内存、操作底层指针同时又有模板和抽象机制方便描述 AST 和 IR 这类复杂数据结构。没有垃圾回收也很关键。编译优化器是一个对性能极度敏感的系统频繁的 GC 停顿即便只是毫秒级也会在大型编译任务中被无限放大。今天 Rust 或许能承担类似任务但在 LLVM 启动的年代C 就是最合理的选择。我们现在接触到的llvm-project代码量超过数百万行C 的静态检查、调试工具链成熟度依然是这个规模下最稳妥的组合。2. 核心细节解析与实操要点LLVM IR、Pass 架构和工具链初体验理解了整体思路再往下走就需要动手接触实际的代码和工具。这段我会用最直接的例子告诉你LLVM IR 长什么样Pass 是怎么回事以及整个工具链里最常用的几个指令怎么配合。2.1 一段 C 代码变成 LLVM IR 的过程先看一段最简单的代码int add(int a, int b) { return a b; }我本机装好 LLVM 以后执行clang -S -emit-llvm add.c -o add.ll打开add.ll核心内容长这样define i32 add(i32 %a, i32 %b) { %1 add nsw i32 %a, %b ret i32 %1 }LLVM IR 有几个特点值得注意。它使用静态单赋值SSA形式也就是说每个变量只能被赋值一次这极大简化了数据流分析。它还有无限寄存器的概念变量名%1、%2只是记号真正映射到物理寄存器是后端的事。这种设计让优化器操作起来非常顺手不用像传统汇编那样整天担心寄存器不够用。我还见过刚接触 LLVM 的同事问既然 IR 是一层中间表示为什么要用文本格式保存呢其实 LLVM IR 有三种形态内存中的数据结构、文本表示.ll文件、二进制位码.bc文件。文本格式方便人读和调试二进制格式适合存储和传输。实际构建大型项目时更多用的是.bc文件但你自己学习和调式时.ll文件才是最好的朋友。2.2 优化 Pass 是什么用生活类比教你秒懂LLVM 优化器的工作方式是一遍遍跑“Pass”。所谓 Pass就是一次对 IR 的完整遍历和转换。它可以没头没尾地分析程序属性比如“这段代码有没有可能抛出空指针”也可以把一段代码变成另一段等价但更快的代码。我用洗衣服来类比代码是脏衣服Pass 就像洗衣机的一个程序。有只负责漂洗的 Pass有只管脱水的 Pass。你去洗衣服的时候需要按照一定顺序把多个程序组合起来运行顺序错了效果就差。LLVM 优化器本质上就是这样一个组合了上百个 Pass 的洗衣工厂。实际跑一个 Pass常用工具是opt。opt -passesinstcombine add.ll -S -o add.opt.llinstcombine是一个经典 Pass它会做计算合并和简化。你会发现经过优化后的 IR 可能不变这是正常的因为add函数本身已经够短了。可以试试更绕的写法比如int foo(int x) { return (x 1) 2; }再跑一遍opt -passesinstcombine优化后的 IR 大概率会变成add i32 %x, 3。这种编译器自动做算术合并的效果第一次看真的会觉得很爽。2.3 工具链全家桶clang、opt、llc、lli 怎么配合llvm-project里工具非常多但最常用的也就那几个工具作用常用场景clangC/C 编译器前端编译源码、生成 IRoptIR 优化器跑单个/多个 Pass做 IR 级实验llc后端代码生成把 LLVM IR 变为目标汇编/机器码lliIR 解释器/JIT 执行器直接解释执行.ll文件验证结果llvm-dis位码转文本把.bc文件转回.ll看内容llvm-as文本转位码把.ll文件压缩成.bcllvm-nm查看目标文件符号表查链接问题lldLLVM 的链接器链接目标文件生成可执行文件一个完整的链路是clang把.c变成.llopt对.ll做优化llc把优化后的.ll变成汇编.sgcc或者lld再汇编并链接成可执行文件。如果你想不生成机器码直接看看 IR 跑出来的结果对不对用lli是最快的。3. 实操过程与核心环节实现从源码编译到写一个自己的Pass前面的内容都是在用现成的工具。要真正理解llvm-project我建议你至少从源码编译一次并且亲手写一个最简单的 Pass。这个过程能帮你打通“工具使用”到“二次开发”的任督二脉。3.1 环境准备与源码构建的完整流程首先拉取源码。llvm-project在 GitHub 上是单仓库管理直接克隆整个仓库就行。需要注意仓库体积巨大如果只想要最近一次提交的历史用--depth1能省不少下载时间。git clone --depth1 https://github.com/llvm/llvm-project.git cd llvm-project然后创建构建目录使用 CMake 配置mkdir build cd build cmake -G Ninja ../llvm \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang \ -DLLVM_TARGETS_TO_BUILDX86这里面有几个参数我想特别解释一下-G Ninja指定用 Ninja 构建系统。Ninja 比 make 快很多而且能并行编译尤其是这种大工程的场景体验完全不同。-DCMAKE_BUILD_TYPERelease编译成 Release 版本。如果只是学习不追求速度用Debug也可以但磁盘占用和后续编译时间会成倍增加。-DLLVM_ENABLE_PROJECTSclang指定额外构建的项目。llvm核心是必须的clang几乎也必选因为我们要用它验证 IR。如果你想编译出来就跑更多语言前端这里还可以加clang;lld;libc等但首次编译建议克制不然时间等到怀疑人生。-DLLVM_TARGETS_TO_BUILDX86只生成 X86 后端的代码。默认会编译所有架构的后端包括 ARM、AArch64、MIPS 等纯属浪费时间。我把这个参数看作“只买了本机 CPU 的方言词典”。配置完成后开始编译ninja ninja install如果你不加ninja install构建产物就在build/bin目录下直接用就行。首次全量编译clang在我一台 8 核 16 线程、32GB 内存的机器上大约需要 30 到 40 分钟。如果配置了全部目标架构时间会飙到一两个小时而且磁盘占用动辄 20GB 以上。我建议先只装 X86跑通全流程再说。3.2 写一个最简单的自定义 Pass手把手全流程装好之后我们来干点进阶的事写一个自己的 Pass。这里我用的是 LLVM 17 之后推荐的新 Pass 管理器语法避免你用旧代码跑新版本报错。我们要做的 Pass 非常简单遍历所有函数在它的入口插入一个打印提示的调用。当然这一步需要链接运行时库才能跑所以我们退一步只检测函数有没有一个叫做foo的自定义 CallInst如果发现就打印一条信息。它的价值在于让你直观感受 Pass 遍历 IR 时看到的景象。新建一个目录my-pass在CMakeLists.txt里写cmake_minimum_required(VERSION 3.20) project(MyPass) find_package(LLVM REQUIRED CONFIG) include_directories(${LLVM_INCLUDE_DIRS}) add_definitions(${LLVM_DEFINITIONS}) add_library(MyPass MODULE MyPass.cpp) target_link_libraries(MyPass PRIVATE LLVM)接着写MyPass.cpp#include llvm/IR/Function.h #include llvm/IR/Instructions.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 { struct MyPass : public PassInfoMixinMyPass { PreservedAnalyses run(Function F, FunctionAnalysisManager FAM) { for (BasicBlock BB : F) { for (Instruction I : BB) { if (auto *Call dyn_castCallInst(I)) { if (Call-getCalledFunction() Call-getCalledFunction()-getName() foo) { errs() Found call to foo in function: F.getName() \n; } } } } return PreservedAnalyses::all(); } }; } // namespace extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, MyPass, 0.1, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name my-pass) { FPM.addPass(MyPass()); return true; } return false; }); }}; }这段代码有几个关键点PassInfoMixinMyPass是时下新 Pass 框架的接口形态dyn_castCallInst是在 IR 指令流里安全地类型转换llvmGetPassPluginInfo是插件入口让opt在运行时能动态识别并加载这个 Pass。编译插件mkdir my-pass-build cd my-pass-build cmake -G Ninja -DLLVM_DIR/path/to/llvm-project/build/lib/cmake/llvm ../my-pass ninja会在当前目录生成libMyPass.so。然后用一个测试文件验证。写一个test.cvoid foo(void) {} int main(void) { foo(); return 0; }生成 IR 并运行插件clang -S -emit-llvm test.c -o test.ll opt -load-pass-plugin ./libMyPass.so -passesmy-pass test.ll -S -o test.out.ll如果一切正常你的终端会打印出Found call to foo in function: main。虽然这个 Pass 本身没有任何实际优化作用但它完整展示了如何挂载到 LLVM 的 Pass 管理系统中这已经是很多语言前端、静态分析工具的雏形了。3.3 编译环境里常见的构建参数与优化技巧首次构建成功后后续开发会经常重新编译。有些参数能显著提升迭代效率用ccache做编译器缓存。第一次也装好后续改动源码重编时没改动的部分会直接命中缓存速度能快一个数量级。开发期用-DLLVM_PARALLEL_COMPILE_JOBS4 -DLLVM_PARALLEL_LINK_JOBS1限制并行度避免链接阶段把机器内存吃满导致 OOM。链接器有时能吃掉十几 GB 内存尤其当lld同时链接多个大模块时。只构建你需要的子集。比如只写 Pass那连 clang 都可以不装直接用仓库里第三方的opt或者预先下载的二进制版本也可以。想省时间就放下“必须全套自建”的执念。提示如果你是第一次在个人电脑上全量编译务必留意磁盘空间至少预留 40GB。构建中间的.o文件非常占空间全部完成之后可以用ninja clean或者手动删掉 build 目录里的中间产物只保留安装目录和源码。4. 常见问题与排查技巧实录避坑指南整个 LLVM 的学习和使用过程中我踩过的坑可以写一整篇。这里挑最常遇到的几个按问题、原因、对策整理成表格建议收藏。问题现象常见原因解决方案构建时c: fatal error: killed signal terminated program cc1plus编译过程中内存不足并行任务太多减少-j/--jobs至少留 4GB 内存给单个编译任务开启 zram/swap 或增加物理内存LLVM ERROR: Failure value returned from cantFail wrapped callAPI 版本不匹配用了旧示例写新版本所有第三方示例先检查与本地llvm-config --version的差异优先用仓库examples/目录中的代码opt: unknown pass name my-pass插件没有被正确加载或 Pass 注册名不存在检查-load-pass-plugin路径确认llvmGetPassPluginInfo中注册的字符串拼写一致链接时报错undefined reference to llvm::...target_link_libraries没有链接 LLVM 库在 CMake 中使用LLVM_LINK_LLVM_DYLIBON或显式链接LLVM新机器上重新编译特别慢没有启用 ccache或并行度过低安装 ccache设置CMAKE_C_COMPILER_LAUNCHERccache和CMAKE_CXX_COMPILER_LAUNCHERccacheFileCheck报错expected string not found测试用例与预期输出不匹配或修改了 Pass 行为但测试未同步更新打开 FileCheck 的-v模式对比实际输出与 CHECK 行确认 IR 顺序没有因优化改变用旧opt -foo语法报错新 Pass 管理器移除了旧命令行接口新版本统一使用-passes...语法传递逗号分隔的 Pass 名4.1 最重要的排查心得先定位是哪一层的问题我见过不少朋友调 LLVM 相关 bug 时毫无头绪直接去读源码效率很低。我的习惯是先问三个问题这个 bug 出现在前端、优化中端还是后端如果是前端检查 AST dumpclang -Xclang -ast-dump -fsyntax-only file.c。如果是优化器用opt -passes... -print-after-all打印每一步 IR 变化。如果是后端直接看llc -debug或输出汇编。这一步能帮你快速缩小排查范围避免在整头大象身上瞎摸。还有一个很实用的小技巧给 IR 文件起名时带上优化等级方便对比。比如test.O0.ll、test.O2.ll同一份代码不同等级生成的 IR 差异很大分析性能问题时这是必备材料。4.2 关于opt和clang优化级别不一致的坑如果有一次你在clang -O2下效果正常的优化 Pass搬到opt -passes...上却行为异常不要怀疑人生这很正常。clang -O2背后的 Pass 组合顺序和独立跑opt时并不完全一致因为 clang 会根据语言语义额外插入一些 Pass。要复现 clang 的完整优化序列可以用clang -O2 -S -emit-llvm test.c -o test.ll opt -passesdefaultO2 test.ll -S -o test.opt.lldefaultO2是模拟 clang 默认优化流水线的一种方式但它仍然不完全等价。做实验时一定要注明你用的是哪条路径否则对比结果毫无意义。5. 拓展视野在不同场景下如何利用llvm-project解决实际问题llvm-project不只是写编译器的人才关心。现在很多前端工程、安全研究、性能优化、代码分析工具底层都在借用 LLVM 的能力。5.1 静态分析用 Clang Static Analyzer 和自定义Checker 查 BugClang 自带一个静态分析器能发现空指针解引用、内存泄漏、逻辑错误等一系列问题。日常开发中即使你不写任何新代码直接运行clang --analyze yourfile.c就能得到一份不错的分析报告。想要更深入的话LLVM 提供了Checker接口你可以在 Clang 的分析框架内自定义规则检查代码中的特定模式。这是一种比正则表达式强百倍的代码扫描方式因为它能理解语法、类型、函数调用关系。我认识的一个团队就用自定义 Checker 在生产代码库里查“socket 文件描述符未关闭”的问题效果比人工评审和简单脚本扫描都好。如果你有这类需求非常值得研究一下 Clang Static Analyzer 的 Checker 编写文档。5.2 性能优化借助llvm-mca做指令级性能分析llvm-mca是 LLVM 内置的机器码分析器。它可以模拟给定的一段汇编代码在特定 CPU 流水线上执行多少个周期帮你在不用实际运行程序的情况下预判性能瓶颈。用法很直接把汇编代码喂给它llvm-mca -mcpuskylake add.s如果你在写一段需要极致性能的循环先用llvm-cxxmap或者直接手写汇编再用llvm-mca跑一遍能在上机之前就对指令选择有底。这也是我调试高吞吐代码时最常用的工具之一。5.3 异构计算与 GPU 编译LLVM 不只是 CPU 编译器很多人忽略了 LLVM 的后端早已延伸到 GPU 领域。AMD 的 ROCm 编译器、NVIDIA 的 NVVM IR以及苹果的 Metal 着色器编译底层都大量使用了 LLVM 基础设施。你是做图形学或者异构计算的研究 LLVM 的交叉编译后端会让你的能力边界宽很多。加上 MLIRMulti-Level IR以后LLVM 家族进一步覆盖了机器学习框架的编译器堆栈。TensorFlow、PyTorch 这些框架的 AOT 编译路径里都有 MLIR 的身影。可以说所有“需要把高层算子描述变成底层硬件执行”的场景现在都在向 LLVM 生态靠拢。6. 写在最后给入门者的几条实用建议学llvm-project和学普通开源项目不太一样它的抽象层次高代码量大学习曲线陡。但我个人的体会是只要抓住“IR 是核心Pass 是手段工具链是抓手”这条线就能少走很多弯路。一开始不建议从头到尾读源码。先熟练用clang、opt、llc这几个命令把示例代码反复生成、优化、反汇编形成对 IR 的直觉。然后再挑一个简单的 Pass 读源码比如instcombine或者loop-unroll看看它到底是怎么修改 IR 的。有了这个底子之后再去看新 Pass 怎么写、新后端怎么加一切都会顺理成章。编译环境的坑确实多我见过不少人卡在 CMake 配置上一下午最后发现只是路径写错了。先照着一份能跑通的配置模板来跑通之后再慢慢个性化能省掉大量的挫败感。最后再分享一个小技巧LLVM 的主干更新非常频繁API 变动也很大。你在网上查资料、抄示例一定要看对应的 LLVM 版本。为了减少版本带来的麻烦强烈建议你锁定一个稳定版本学习比如 LLVM 16 或 17不要追最新 main 分支否则很容易陷入“昨天能编译今天不能编译”的循环。等你真正理解了设计思路再跟着 main 分支走也不迟。反正只要你在软件行业混llvm-project迟早会出现在你的工具链里。与其等被环境折腾不如现在花点时间把它摸透这份投入的回报率比学任何一个小框架都高。
返回列表