尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

深入理解LLVM项目:从IR设计到Pass开发实战

深入理解LLVM项目:从IR设计到Pass开发实战 在编译器圈子里摸爬滚打这些年如果要我选一个“最值得花时间吃透的基础设施项目”我会毫不犹豫地说llvm-project。这个项目几乎成了现代编译器、静态分析工具、代码优化框架的事实标准从苹果的 Clang 到 Rust 的官方后端再到各种 DSP、GPU 的专用编译器底层都在用 LLVM 那套东西。这篇文章不打算做那种“名词解释式”的科普我想把 llvm-project 当成一个真实项目来拆讲清楚它内部是怎么组织的、为什么这样设计、你拿到源码之后从哪里下手、以及我在实际使用中踩过哪些坑。如果你正准备入门编译器开发、想在 LLVM 上做二次开发或者只是好奇“一个能支撑几十种编程语言和上百种后端芯片的编译器项目到底长什么样”那这篇文章应该能帮你省不少时间。1. 项目整体设计与思路拆解1.1 从“虚拟指令集”说起LLVM 最核心的设计决策llvm-project 之所以能成为今天的样子根源在于它早期定下的一个设计原则把编译器的前后端彻底拆开中间用一套独立于具体 CPU 和具体语言的目标指令集IRIntermediate Representation来衔接。举个直观的例子。你用 C 写了一段排序代码用 Clang 编译成 x86 的可执行文件同时你用 Rust 写了一个排序逻辑用 rustc 编译成 ARM 平台的可执行文件。从源代码到最终机器码这两条路径的前半段完全不同但进入 LLVM 后端之后它们面对的是同一种“中间语言”。也就是说Clang 负责把 C 语法树翻译成 LLVM IRRust 前端也在做类似的事之后所有优化、寄存器分配、指令选择、汇编生成都由 LLVM 后端统一完成。这种“前端和架构解耦”的思路让 llvm-project 具备了两个其他编译器很难做到的能力新增一门语言时你只需要写一个新的前端把语法翻译成 LLVM IR就能立刻获得全套优化和后端支持。新增一种芯片架构时你只需要实现一个后端把 LLVM IR 翻译成目标指令集就能立刻让所有接入 LLVM 的语言都支持这个新架构。我在实际项目中见过一个团队只用了几个月就给一个自研的 RISC-V 扩展指令集加上了完整的 Clang 支持这在没有 LLVM 的年代几乎是不可想象的。所以理解 llvm-project第一个要建立的心智模型就是“IR 是枢纽”前端和后端都围着它转。1.2 monorepo 结构为什么所有东西都在一个仓库里很多人第一次接触 llvm-project 会被它的仓库体积吓到码农圈子有个梗叫“clone 一下 LLVM磁盘空间就没了三分之一”。这其实是 LLVM 团队有意为之的 monorepo单仓库多项目策略仓库里不只包含 LLVM 核心库还包含了 Clang、LLD、libc、compiler-rt、polly、flang、mlir 等一大堆子项目。用 monorepo 的好处很实在版本天然同步。Clang 和 LLVM 核心库的 API 经常一起变动如果分开仓库每次都要处理“Clang r12345 必须搭配 LLVM r67890”这类依赖地狱monorepo 直接从物理上消灭了这些问题。跨项目重构方便。比如你改动了 IR 里的一条指令表示需要同步修改调试器、反汇编器、优化 pass在一个仓库里可以一次提交全改完。统一构建系统。整个项目用一套 CMake 逻辑组织你可以按需裁剪只编你想要的那几个子项目。我在给团队分享 LLVM 源码结构时喜欢打一个比方llvm-project 就像一个大型百货商场LLVM 核心库是水电燃气这类基础设施Clang 是一楼生意最好的餐饮区MLIR 是刚开业的新商场而那些 target 后端则是各个出租的铺位。你想研究哪个区域就直奔哪个楼层不用从一楼开始把所有铺位逛完。2. 核心组件解析与关键技术点2.1 LLVM 核心库pass 架构与优化管线LLVM 核心库是整个项目的发动机它提供的核心能力是对 LLVM IR 做分析和优化。这些优化动作被封装成一个个“pass”每个 pass 只做一件很具体的事比如删除永远不会执行到的代码Dead Code Elimination把常量表达式在编译期就算出来Constant Folding把循环里不变的表达式移到循环外面Loop Invariant Code Motion根据目标机器的缓存特性重新排列指令Instruction Scheduling编译时这些 pass 会按顺序组成一条管线像流水线一样IR 从一端进去经过一道道工序从另一端出来的时候已经变得更高效、更精简。LLVM 的优化管线分为几个层次比如-O0几乎不跑优化-O2跑常规优化-O3会额外开启向量化和循环展开这类激进优化。我第一次深入阅读 LLVM 的 pass 源码时最大的感受是这里的代码质量极高每个 pass 都带有详尽的注释说明它在什么条件下生效、有什么副作用。如果你对“如何设计一套可插拔的优化框架”感兴趣LLVM 的 pass 管理器是绝佳的教材。后来我们团队做自研静态分析工具几乎就是照抄了 LLVM 的 pass 机制定义分析任务、声明依赖、按拓扑序执行这套模式成熟可靠省了我们很多设计上的反复。2.2 Clang不只是“苹果的 C 语言编译器”很多人把 Clang 理解成“又一个 C 编译器”这种理解太浅了。Clang 在 llvm-project 里的定位是提供完整的 C/C/Objective-C 前端它做的事情包括词法分析、语法分析、语义分析、生成抽象语法树AST最后把 AST 翻译成 LLVM IR。Clang 最让我喜欢的一点是它的库化设计。传统编译器通常是一个庞大的可执行文件你没法轻易地把“语法分析”这个功能单独拿出来用。Clang 不同它把词法分析器、解析器、AST 构建器都做成了库外部代码可以#include clang/Parse/...直接调用。这带来了一个巨大的生态基于 Clang 做的代码补全工具、自动重构工具、代码风格检查工具一抓一大把。一个日常开发中很常见的例子你想统计一个大型 C 项目里所有函数的定义位置不用去解析文本正则表达式处理 C 的模板和宏会让人崩溃直接写一个 Clang 的 AST 遍历工具几十行代码搞定。这种体验让我个人对 Clang 的定位是“编译器加 IDE 基础设施”远远超出了传统编译器的范畴。另外Clang 的诊断信息diagnostics质量也是它比 GCC 更受欢迎的原因之一。它会给出带颜色高亮的错误定位还会提示 “did you mean ...”。LLVM 项目里甚至有专门的代码负责把错误信息里的关键词做模糊匹配用来推荐最可能的修正方案。这个思路后来被大量现代编译器借鉴。2.3 LLVM IR 详解SSA 形式与指令表示既然 IR 是核心枢纽就值得花点篇幅讲清楚它到底长什么样。LLVM IR 是一种基于静态单赋值SSAStatic Single Assignment形式的指令集所谓 SSA简单说就是每个变量只被赋值一次。比如define i32 add(i32 %a, i32 %b) { %sum add i32 %a, %b ret i32 %sum }这段 IR 定义了一个add函数接收两个i32类型参数返回它们的和。%sum这个名字在整个函数里只会被赋值一次这就是 SSA 的含义。SSA 形式给优化带来了巨大的方便因为数据的依赖关系可以直接从名字上读出来不需要去做复杂的“定义-使用链”分析。LLVM IR 还有三种表示形态内存中的表示编译过程中 pass 操作的就是这种形态以 C 对象的方式存在。文本表示.ll 文件人可以阅读和修改的文本格式用来调试非常方便。二进制位码表示.bc 文件紧凑、加载快适合存储在磁盘上。我在实际调试时最常用的是文本表示编译时加一个-S参数就能把 IR dump 出来。很多初学者会被 IR 里繁多的指令类型吓到其实 LLVM 的指令集被刻意设计得比较精简。核心就几大类算术运算、内存访问load/store、控制流br/switch/ret、函数调用、比较与转换。真正复杂的是类型系统比如i32、float、指针、数组、结构体还有带地址空间的指针。理解了类型再看 IR思路就会清晰很多。2.4 后端体系TableGen、指令选择与寄存器分配LLVM 能支撑几十种硬件架构靠的是一套高度工程化的后端实现方式。后端开发里有三个关键词我建议想深入的人记住TableGen这不是一个库而是一种领域特定语言用来描述目标机器的指令集、寄存器、调用约定等信息。它有点像一个“超级宏”你用声明式的语法写目标描述文件.tdTableGen 工具会生成大量的 C 代码。一开始很多人不理解为什么非要多一层抽象等你真的处理过上千条指令的架构比如 x86就会发现手写这些代码会要命TableGen 生成的代码虽然多但一致性和准确性远超人肉拷贝。指令选择Instruction Selection把 LLVM IR 里的操作映射到目标机器的具体指令。LLVM 默认使用 SelectionDAG 框架更现代的选择还有 GlobalISel。这个过程要处理指令模式的匹配、合法化把目标不支持的指令拆成多条简单指令等复杂问题。寄存器分配Register Allocation把无限多的虚拟寄存器映射到有限的物理寄存器上。寄存器不够的时候就要“溢出”到内存这个决策的好坏直接决定了生成代码的质量。LLVM 默认的寄存器分配器是 Greedy 算法它的实现思路很值得学习先处理冲突图再按权重贪心分配实在不行就 spill。如果你想快速理解 LLVM 后端的工作流程我建议选一个简单的架构比如 RISC-V 或 AArch64去读它的指令选择代码不要一上来就啃 x86x86 的历史包袱太多。3. 实操过程从源码构建 LLVM 到跑通第一个 pass3.1 环境准备与源码获取llvm-project 对操作系统没有太多挑剔Linux、macOS、Windows 都能构建但如果你是第一次尝试我强烈建议在 Linux 上做依赖问题最少。获取源码的第一步是 clone我这里加一个提醒llvm-project 仓库体积很大而且历史提交极多做一个浅克隆往往更明智git clone --depth1 --branch llvmorg-18.1.8 https://github.com/llvm/llvm-project.git这里选了 18.1.8 这个 release tag原因是发行版相对稳定API 已经冻结。如果你 clone 最新的 main 分支可能在构建时碰到编译器版本不兼容或者 API 刚变动的幺蛾子。做技术研究建议始终从一个 release 版本开始之后想体验新特性再切 main。依赖方面最核心的就是 CMake 和一套可用的 C 编译器。注意LLVM 的构建是一个“自举”过程你的系统编译器会把 LLVM 编译出来然后这个新编出来的 Clang 又可以用来编译其他程序。建议事先准备 Ninja 作为构建工具它的并行加速效果比 make 好很多sudo apt-get update sudo apt-get install -y cmake ninja-build gcc g python33.2 构建配置与参数选择LLVM 的 CMake 配置项非常多大约一两百个但真正核心的没几个。一个比较稳的构建配置如下cmake -G Ninja \ -B build \ -S llvm-project/llvm \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_ENABLE_ASSERTIONSON逐项解释一下CMAKE_BUILD_TYPERelease编译优化后的发布版本。有些人图省事直接用默认的 Debug结果构建时间翻一倍运行时还慢得离谱。除非你要调试 LLVM 自身否则用 Release。LLVM_ENABLE_PROJECTSclang指定除了 LLVM 核心库之外还要构建哪些子项目。如果只需要跑 IR 优化只写clang就够了。如果还要链接器可以加上lld用分号分隔。LLVM_TARGETS_TO_BUILDX86限制后端的 target 数量。全部 target 都编的话构建时间会多出好几倍但你平时根本用不到 PowerPC、SystemZ 这些后端。按需裁剪是 LLVM 构建里最见效的一招。LLVM_ENABLE_ASSERTIONSON开启断言。发布版本可以关掉它但开发阶段强烈建议打开很多潜在内存问题都是靠断言在早期暴露的。配置文件完成后执行ninja -C build以 18.1.8 为例在主流 8 核 16G 内存的机器上全量构建大概需要 15 到 30 分钟。如果你只编clang这一个 target会快不少ninja -C build clang构建完成后可执行文件在build/bin/目录下clang、llvm-as、opt、llc这些工具都在里面。3.3 写出并运行自己的第一个 LLVM pass工具链跑起来之后我们就可以做一件很有仪式感的事写一个自定义的优化 pass。我们先从一个最基础的“函数级打印 pass”开始它的作用是遍历模块里的每个函数打印出函数名和基本块数量。这个 pass 不改变 IR只是观察但能帮你走通整个开发流程。先在llvm/lib/Transforms/下建一个目录例如HelloPass#include llvm/IR/Function.h #include llvm/IR/Module.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 HelloPass : public PassInfoMixinHelloPass { PreservedAnalyses run(Module M, ModuleAnalysisManager AM) { for (auto F : M) { errs() Function: F.getName() , blocks: F.size() \n; } return PreservedAnalyses::all(); } }; } // namespace llvm::PassPluginLibraryInfo getHelloPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, HelloPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, ModulePassManager MPM, ArrayRefPassBuilder::PipelineElement) { if (Name hello-pass) { MPM.addPass(HelloPass()); return true; } return false; }); }}; } extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return getHelloPassPluginInfo(); }这段代码用到了新的new pass manager接口这是 LLVM 目前主推的框架。PassInfoMixin是 pass 的基类run方法里拿到整个模块的引用遍历每个函数并打印信息。编译这个插件的 CMake 配置如下add_library(HelloPass MODULE HelloPass.cpp ) target_link_libraries(HelloPass PRIVATE LLVMCore LLVMPasses LLVMSupport )然后在 llvm-project 的源码根目录里重新跑一次 CMake把包含这个子目录的路径加到LLVM_OPTIONAL_SOURCES或直接作为add_subdirectory引入再ninja HelloPass就会生成一个.so插件文件。测试一下这个 pass先写一段测试代码// test.c int add(int a, int b) { return a b; } int main() { return add(2, 3); }用刚构建的 clang 编译成 IR然后用opt加载插件build/bin/clang -S -emit-llvm test.c -o test.ll build/bin/opt -load-pass-pluginbuild/lib/HelloPass.so -passeshello-pass test.ll -o /dev/null如果一切正常你会看到终端打印出两个函数名add和main以及各自的基本块数量。恭喜到这里你已经可以算半个 LLVM 开发者了因为插件的加载机制、pass 的注册方式、工具链的使用方法这些核心套路你都走通了。3.4 实操中的构建细节与参数计算很多人第一次构建 LLVM 会遇到内存不足或者磁盘不够的问题。这里有两个硬指标可以预先估算磁盘空间一个包含 Clang 的 Release 构建大概需要 30GB 到 50GB 的磁盘空间。其中源码占一半构建产物占一半。如果你想全部 target 都编可能奔着 100GB 去。内存编译过程中最耗内存的是链接步骤尤其是链接clang这个巨大的可执行文件多个并行链接任务同时跑的时候内存很容易吃满。我自己的经验是 8GB 内存的机器建议把ninja -j2降到 2 个并行任务16GB 内存可以放心-j832GB 以上基本随便跑。如果你机器配置一般我建议在 cmake 时设置一个更小的并行度ninja -C build -j 4 clang有时候源码构建会因为 GCC 版本太老而失败报找不到span头文件或者类似 C17 特性的错误。LLVM 18 要求编译器支持 C17gcc 版本至少要 7.1 以上。遇到这类问题优先考虑升级编译器或者用 Clang 来编译 LLVM。4. 常见问题与排查技巧实录4.1 “undefined symbol: llvmGetPassPluginInfo” 错误这个错误几乎是所有 LLVM 插件开发者的第一道坎。出现这个错误的时候opt会报无法加载插件。原因通常是插件符号导出问题。LLVM 的插件机制要求插件必须导出一个名为llvmGetPassPluginInfo的 C 接口符号而且这个符号的可见性必须是默认的。我在代码里写了LLVM_ATTRIBUTE_WEAK这个宏会设置合适的可见性和弱符号属性。如果你自己手写导出符号可以这样手动声明extern C __attribute__((visibility(default))) ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { ... }另外一个坑是链接顺序。某些版本的 LLVM 要求你的插件源码里引用的 LLVM 库在链接时出现在正确位置否则会出现undefined reference。最简单的方案是直接用我们前面写的 CMake 方式让它继承 LLVM 的链接配置不要自己手动写g -shared命令。4.2 link 阶段 OOM内存耗尽链接clang这个可执行文件时内存消耗特别凶。一个常见的缓解手段是使用lld作为链接器代替系统默认的ldcmake -G Ninja \ ... -DLLVM_USE_LINKERlldlld 的内存占用和链接速度都明显优于 GNU ld尤其是大型可执行文件的链接体验差距非常明显。如果你在构建 LLVM 时还没装 lld可以先用系统包管理器装好然后重新跑 cmake。另一个经验是控制链接并行度ninja -C build -j 2构建慢一点没关系至少不会让机器卡死。4.3 IR 调试时不知道该看什么你写了一个 pass跑起来了但优化效果不符合预期。这种时候不要瞎猜建议按以下顺序排查先用opt -print-after-all把所有 pass 处理后的 IR 打印出来看你的 pass 是否按预期修改了 IR。如果你做的是分析类 pass检查输入 IR 是否真的包含你想处理的 pattern。比如你想匹配循环结构但测试代码在-O2下已经被完全展开或者矢量化了那你看到的内容可能和预期相差很远。使用小规模测试用例来复现问题避免在大型项目上盲目输出海量 IR 日志。4.4 常见问题速查表现象可能原因解决方案编译报错undefined reference to llvm::...CMake 链接库配置缺失在target_link_libraries里补上对应的 LLVM 组件如LLVMCore、LLVMSupport点击opt -passesmy-pass没有输出pass 注册名写错或没被加载成功检查插件加载日志用opt -load-pass-plugin... -passesmy-pass手动确认加载构建时磁盘爆满Release 构建产物体积大使用LLVM_TARGETS_TO_BUILD裁剪后端清理无用 build 目录系统编译器太老LLVM 18 需要 C17升级 GCC/Clang或切换到较旧的 LLVM 版本无法运行clang可执行文件动态库路径不对设置LD_LIBRARY_PATH指向build/lib或者把build/bin加到PATH5. 周边生态与进阶学习路径5.1 MLIR多级 IR 框架llvm-project 里还有一颗冉冉升起的新星MLIRMulti-Level Intermediate Representation。如果说 LLVM IR 是“一种中间表示”MLIR 就是“中间表示的框架”它允许你在不同抽象层级定义自己的 IR然后逐级lower到LLVM IR。我第一次接触 MLIR 是在做 AI 编译器项目的调研阶段当时需要把深度学习模型的计算图转换成能在 GPU 上高效执行的代码。传统方式要么用 TVM 这种重框架要么直接生成 CUDA 代码灵活性和可维护性都不够好。MLIR 的方案完全不同你可以为模型定义高层 IR比如表示矩阵乘、卷积再通过 pass 逐级下降到 LLVM 能理解的程度。这种“不是把所有问题都放到同一个层级解决”的思路非常优雅。如果你已经熟悉 LLVM 的 pass 机制MLIR 的学习成本会低很多它们的设计哲学一脉相承。不过MLIR 还处于快速演进期接口变化频繁学习和使用时尽量跟着官方 tutorial 走不要依赖网上过时的博客。5.2 学习路线建议给不同目标的人几条可执行的学习路线目标是日常开发想理解编译原理只研究 LLVM IR 和 opt不必深入后端。学会看clang -S -emit-llvm的输出用opt跑几个标准优化 pass理解 SSA 和基本块就够了。目标是做编译器前端或语言实现重点研究 Clang 的 AST 和 API写一些小工具遍历和分析 C/C 代码。LLVM 官方有 Clang Tutorial 和 Clang AST 的示例代码。目标是做后端或芯片支持从 TableGen 和 SelectionDAG 开始选一个简单 target 深入代码阅读。建议先读 RISC-V 后端的.td文件理解指令描述的基本语法。还有一个我特别推荐的学习方法是“读 IR不读源码”。先把大量 C/C 代码用 clang 转成 LLVM IR体会哪些代码写法会产生什么样的 IR 模式。这个习惯会极大加速你对编译器优化的直觉判断。比如一个for循环在-O2下会变成一堆复杂的控制流你多读几次 IR以后写代码的时候就会有意无意地写出更易被优化的代码。5.3 从 llvm-project 中能学到什么抛开工具本身llvm-project 的源码是一座难得的学习矿藏。它里面至少有这几类值得精读的内容大型 C 项目的工程组织超过百万行代码的规模它如何管理头文件、命名空间、目录结构、cmake 依赖这套实践对任何一个大型 C 项目都有参考意义。算法与数据结构在真实世界中的应用IR 的支配树分析、循环检测、图着色寄存器分配、数据流分析教科书上的算法在这里都有工业级实现。API 设计的边界思考LLVM 是一个被几千个项目依赖的库它如何在性能和易用性之间权衡如何保证 API 演进不破坏既有用户这些经验远远超出编译器领域。每次深入 llvm-project 的某个模块我都觉得不只是学会了一个工具更像在跟一群世界级的工程师做一次无声的交流。6. 写在最后的实操体会最后分享一点我自己的心得体会。llvm-project 的入门曲线确实陡峭最开始 clone 仓库、配置 CMake、看那些密密麻麻的.td文件时很容易产生“这玩意儿太复杂了我可能学不会”的挫败感。但一旦你跑通了第一个 pass、第一次成功修改了 IR、第一次看到自己的优化让生成的汇编变短了那种正反馈会非常强烈。我个人的建议是不要一开始就想着读懂所有代码挑一条最窄的路径走通比如“源码构建 Clang - 写插件 pass - 修改 IR”走通之后再往两侧扩展。整个过程不是线性阅读而是像一个搜索算法先找到一个可行的路径再逐步扩大搜索范围。如果你在某个环节卡住了优先怀疑构建配置其次是 API 版本最后才是你的算法或逻辑。llvm-project 还在持续演进MLIR 生态、Orc JIT、Sanitizer 工具链都在快速迭代今天学到的东西明天可能就变了但那一套“前端解耦、IR 中枢、pass 管线、后端可插拔”的核心思想会在整个职业生涯里持续发挥作用。希望这篇文章能让你少走一些我当年走过的弯路。
返回列表