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

LLVM编译器核心概念与工程实践:从IR到Pass开发全解析

在编译器这个圈子里摸爬滚打这些年llvm-project可以说是我电脑里最常打交道的一堆代码了。不管是给某个芯片架构做指令集支持还是想把一门语言编译成高效的机器码绕来绕去最后都会落到这个项目上。如果你正准备进入编译器领域或者工作中突然被指派去调一个莫名其妙的编译报错那么搞清楚 llvm-project 的结构和用法是值得先做的事。这篇文章我会用偏工程实践的角度讲清楚 LLVM 的核心设计、怎么把源码跑起来、怎么写第一个 Pass还会分享一些我自己踩过的坑和排查思路。内容不追求教科书式的面面俱到但求每个技术点都落到实处你看完能直接动手试。1. LLVM 到底是什么一个工具链而不只是一个编译器先把这个概念掰开。很多人听到 LLVM 第一反应是“哦一个 C/C 编译器”这个说法对了一半。Clang 确实是个编译器而且是非常优秀的 C/C/Objective-C 编译器但 Clang 只是 llvm-project 这个巨大工程里的一个前端。LLVM 真正厉害的地方是一套设计精良的编译器基础设施尤其是那个处于中间层的中间表示IRIntermediate Representation。1.1 先搞明白编译器的三段式架构一个现代编译器如果你想让它支持多种语言、多种目标架构最经典的架构就是拆成三段。前端负责把源代码解析成某种统一的中间表示中端在这份中间表示上做与机器无关的优化比如循环展开、常量传播、死代码消除后端则把优化后的中间表示翻译成目标机器的汇编指令。这个三段式架构的好处是你要支持一门新语言只需要新写一个前端你要支持一种新 CPU只需要新写一个后端。前端和中端之间靠 IR 解耦中端和后端之间也靠 IR 解耦。llvm-project 里打包的就是这套完整的东西。Clang 是 C 系语言的前端LLVM core 是中端和后端LLD 是链接器compiler-rt 提供运行时库libc 是标准库实现MLIR 则是面向机器学习和其他领域的一层更灵活的多级 IR 框架。1.2 LLVM 能做什么几个最常见的应用场景从我实际接触到的案例来看LLVM 的价值体现在几个非常具体的方向上。第一类是编程语言开发。如果你在折腾一门新的静态编译语言不用从零写代码生成器直接把自己的 AST 降级成 LLVM IR就能凭空获得几十个目标架构的支持以及各种成熟的优化功能。这个红利非常实在市面上不少新兴编程语言就是这么干的。第二类是针对特定硬件做代码生成。比如公司设计了一颗新的处理器核心想让 GCC 和 LLVM 都支持这颗核心LLVM 通常比 GCC 更友好模块化更好新后端开发的周期短得多。第三类是静态分析与代码插桩。LLVM IR 是结构化的、类型化的而且保留了大量控制流信息基于 IR 做程序分析比在汇编层面做要轻松太多。很多代码安全扫描工具、溯源工具、覆盖率工具底层实际就是跑在 LLVM Pass 之上。第四类是 GPU 生态与异构计算。NVIDIA 的 NVVM、AMD 的 ROCm、苹果的 Metal 编译器底层都吸收或借鉴了 LLVM 的技术体系。想深入高性能计算领域LLVM 是绕不开的核心。2. 为什么我劝你先别急着写代码先搞懂 LLVM 的设计哲学我刚接触 llvm-project 的时候第一反应是直接拉代码然后 make结果把环境整得乱七八糟跑了半天还在编依赖。后来回头看先用一个周末把设计哲学搞清楚收益远大于盲目动手。2.1 IR 是整个项目的灵魂LLVM IR 是一种静态单赋值形式SSAStatic Single Assignment的强类型中间表示。它有三个互相可转换的形态内存中的数据结构、可读的文本形式以及序列化的 bitcode 形式。文本形式一般以.ll为扩展名bitcode 一般是.bc。你可以随时用llvm-as把文本转成 bitcode用llvm-dis把 bitcode 还原成文本。这个设计对调试极其友好因为任何优化流程中间状态都可以 dump 成文本拿来看问题出在哪一个 Pass 一目了然。一个简单的 LLVM IR 长这样define i32 add_one(i32 %x) { entry: %add add i32 %x, 1 ret i32 %add }define用来定义函数i32是整数类型add_one是函数名entry是基本块标签。你可以看到每个中间值都有类型每条指令都体现 SSA 思想每个变量最多被赋值一次之后只被引用。为什么要设计成 SSA这是 LLVM 能高效做优化的大前提。SSA 形式下变量只有唯一一个定义点数据流信息直接体现在名字的依赖关系上很多优化算法都能实现得更优雅。比如常量传播在 SSA 里只需要沿着use-def链往前看定义指令是否为常量非常直观。为了维护 SSA 的约束当变量的值来自多个控制流路径时需要引入phi节点。这个概念对新手来说略抽象但非常重要。你可以把 phi 理解成一条合流指令它根据当前到达基本块的路径从几个候选值里选中一个作为结果。理解了 phi你才算真正理解了 IR 的数据流。define i32 max(i32 %a, i32 %b) { entry: %cmp icmp sgt i32 %a, %b br i1 %cmp, label %then, label %else then: br label %merge else: br label %merge merge: %result phi i32 [ %a, %then ], [ %b, %else ] ret i32 %result }2.2 为什么选择三种形态可互换的设计我自己在开发 Pass 的时候最常用的调试方法是这样的写一个.c文件用clang -emit-llvm -S编译成.ll然后用opt -passname跑某个单独的 Pass再用llc看生成的目标汇编。整个过程每走一步都能看到中间产物。这个工作流说明了大工程的工程哲学每一步都设计成可观测、可替换的。你在里面做研究也好做工程也好都不会有“一黑到底”的无力感。2.3 TableGen定义一切描述的领域专用语言LLVM 的后端描述文件里充满了.td文件这些文件用 TableGen 语言编写。如果你要开发新后端TableGen 是你每天都要打交道的 DSL。它的作用一句话说清楚用声明式的方式写出指令集架构的特征然后由 TableGen 工具在构建时生成大量 C 代码。例如target description、指令选择匹配器、寄存器信息等都可以从.td文件里自动生成。很多人第一次被.td文件搞崩溃是因为不知道它不是在写普通的代码而是在写“元描述”。理解这一点之后再上手就顺多了。3. 从源码把 LLVM 跑起来第一份交到手上的资产说完了设计来点动手的。llvm-project 本身是个超大的 CMake 工程全量构建需要很长时间但掌握正确的姿势之后整个过程并不复杂。3.1 依赖准备与版本选择先强调一点LLVM 的 main 分支更新非常激进几乎每天都有大量提交如果对稳定性有要求不要用 main 分支而是用 release 分支。目前我推荐 17.x 或 18.x 版本功能完善且社区资料多。编译 LLVM 需要 GCC 或 Clang 编译器、CMake 3.20 以上、Ninja 或 Make以及 Python 3.6 以上用于运行测试脚本。在装依赖时不要贪多缺什么再补什么就行。3.2 配置构建的常用参数拉取代码并进入目录之后我的推荐配置是git clone --depth 1 --branch llvmorg-18.1.8 https://github.com/llvm/llvm-project.git cd llvm-project mkdir build cd build cmake -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86;AArch64 \ -DLLVM_INSTALL_UTILSON \ ../llvmLLVM_ENABLE_PROJECTS决定额外构建哪些子项目。如果不需要 Clang可以只留空或单独调 core但我基本每次都带 clang 和 lld因为日常调试离不开它们。LLVM_TARGETS_TO_BUILD是一个非常值得谨慎设置的参数。默认情况下会把所有后端的代码都编译一遍包括 SystemZ、MIPS、PowerPC 等可能永远用不到的架构。只保留自己需要的架构能显著减少编译时间我实测大概能省 20% 到 40% 的时间。LLVM_INSTALL_UTILSON会把FileCheck、llvm-lit这些测试工具装好后面做测试时没它不行。设置完执行ninja如果你机器配置一般这一跑可能要等很久。建议打开ninja -j4或者根据自己的 CPU 核数适当调低并行度别把内存打满导致系统卡死。我试过在 16 核 32G 内存的机器上全量构建大约 30 到 50 分钟具体取决于项目和机器。构建完毕之后所有二进制都在build/bin下。你可以先验证一下./bin/llc --version能看到版本信息基本就说明构建成功。3.3 手动写一个最简单的 LLVM IR 并运行有了二进制第一时间体验 IR 执行链路。保存上面那个add_one函数为add.ll然后执行./bin/lli add.lllli是 LLVM 的解释器可以直接运行 IR。不过这个函数只定义没有入口跑不出结果。可以改造成一个可执行程序define i32 main() { entry: %a call i32 add_one(i32 41) ret i32 %a } define i32 add_one(i32 %x) { entry: %add add i32 %x, 1 ret i32 %add }然后用./bin/lli add.ll echo $?输出结果为 42说明 IR 解释执行链路是通的。再试一下编译到目标汇编./bin/llc add.ll -o add.s打开add.s就能看到为你本机架构生成的汇编代码。这个过程虽然简单但你是亲手让一个现代编译器从 IR 走到了汇编这对理解整个编译链路特别有帮助。4. 亲手写第一个 Pass入门与进阶的分水岭很多人在 LLVM 里真正想干的第一件事不是跑通工具链而是写一个属于自己的优化 Pass。这件事也是初学者最容易卡住的地方。我自己也在这上面折腾了很久所以这里把心得写透。4.1 Pass 的类型与新旧 Pass Manager先解释一下 Pass 是什么。Pass 是 LLVM 中进行分析与转换的基本单元。你写了一个 Pass把它注册进 Pass 管道LLVM 就会按你指定的顺序在 IR 上执行它的逻辑。从使用方式上看Pass 分为分析 Pass 和转换 Pass。分析 Pass 只计算并缓存信息比如统计函数调用次数、识别循环结构并不修改 IR转换 Pass 则会对 IR 进行改写比如删除死代码、内联函数。从实现接口上看早期大量 Pass 继承自FunctionPass而新的 Pass Manager 框架里推荐实现llvm::PassInfoMixinYourPass配合llvm::FunctionAnalysisManager。新 PM 解决了旧 PM 的很多问题比如更清晰的依赖管理、更好的并发能力、更严格的分析结果缓存。19 版本里旧 PM 基本已经退出历史舞台所以新同学直接从新 PM 开始学就好不要再被网上老教程带偏。4.2 一个能跑的 Function Pass我们实现一个很小的功能统计一个函数里add指令的数量并且对每个函数打印出来。纯粹为了熟悉框架。新建文件FunctionInfo.cpp#include llvm/IR/Function.h #include llvm/IR/Instructions.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 FunctionInfoPass : public PassInfoMixinFunctionInfoPass { public: PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { int addCount 0; for (BasicBlock BB : F) { for (Instruction I : BB) { if (auto *BinOp dyn_castBinaryOperator(I)) { if (BinOp-getOpcode() Instruction::Add) { addCount; } } } } errs() Function F.getName() has addCount add instructions\n; return PreservedAnalyses::all(); } }; } // namespace llvm::PassPluginLibraryInfo getLLVMFunctionInfoPluginInfo() { return {LLVM_PLUGIN_API_VERSION, FunctionInfo, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name function-info) { FPM.addPass(FunctionInfoPass()); return true; } return false; }); }}; } extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return getLLVMFunctionInfoPluginInfo(); }这段代码里最关键的两个机制是PassInfoMixin是新 PM 的 min 风格接口你只需要实现一个run方法返回PreservedAnalyses表示当前 Pass 保留了哪些分析结果。如果你的 Pass 没改任何 IR只做分析就应该返回PreservedAnalyses::all()这样后续 Pass 就不用重新计算分析信息。如果你改了 IR需要准确声明破坏了哪些分析否则会有难查的错误。pass-plugin机制允许你把 Pass 编译成.so文件运行时动态加载不污染主程序。这在开发调试阶段非常方便。用opt编译并加载这个插件clang -shared -fPIC -fno-rtti FunctionInfo.cpp -o libFunctionInfo.so \ llvm-config --cxxflags --ldflags --libs ./bin/opt -load-pass-plugin./libFunctionInfo.so \ -passesfunction-info add.ll -disable-output如果看到类似Function add_one has 1 add instructions说明你的 Pass 已经生效。4.3 调试技巧与常见错误写 Pass 最容易栽的坑我用惨痛经验帮你列一下。第一忘了返回正确的PreservedAnalyses。如果你改了指令却返回all()等于告诉后续分析“我啥都没动”后续 Pass 拿了过期的分析信息可能会导致错误的优化结果。这个问题极其阴险因为不一定立刻崩溃而是某些优化行为变得奇怪。调试时可以在 Pass 里把assert打开或对比开不开你这个 Pass 的输出差异。第二opt的-passes语法写错。新版 Pass 管道语法和旧版差距很大比如函数内 Pass 的管道要用双引号包起并且可能需要在前面加function()。我给你的建议是先用-print-after-all配合-passes测试再看opt --help-hidden里对 pipeline 的说明。第三插件 API 版本不匹配。llvmGetPassPluginInfo里返回的LLVM_PLUGIN_API_VERSION必须和你编译时的 LLVM 头文件一致。如果动态库是在旧版本 LLVM 上编的load 进新版 opt 会直接报 plugin API 版本不匹配。解决办法只有一个用同一份源码编译你的插件和 opt。5. 自己动手折腾一个精简后端结构理解指令选择与寄存器分配写完 Pass很多人下一个目标就是“给 LLVM 加一个新后端”。这一步难度不小但它恰恰是 llvm-project 被大规模用于芯片生态的核心原因。为了让你有个正确的心理模型我把一个后端的主要组成部分拆开讲讲。5.1 理解后端的主要组成部分一个 LLVM 后端核心要做的事情包括代码从 LLVM IR 到 SelectionDAG有向无环图是 LLVM 做指令选择时采用的中间结构的转化然后做指令选择把 IR 指令映射成目标机器的指令接着做指令调度让指令顺序更符合硬件流水线然后做寄存器分配把无限的虚拟寄存器映射到有限的物理寄存器最后做指令输出生成目标架构的汇编文本。在 LLVM 后端里一个常见的文件组织方式包括X86.td、RISCV.td这类 target description 文件用 TableGen 定义寄存器、指令、调用约定等ISelLowering.cpp负责处理目标相关的操作合法化比如某些架构没有 64 位整数除法就得在这里拆成多次 32 位运算InstrInfo.td定义每条指令的形式、编码、语义RegisterInfo.td定义寄存器类和寄存器别名AsmParser和MC层负责处理汇编的解析与生成。这个图景很庞大但好消息是 LLVM 的模块化设计允许你一步一步来。你完全可以先写一个只有少数几条指令的极简后端编译 tiny C 程序看到汇编输出再逐步补充指令。5.2 从 RISC-V 后端开始学如果你想练手我建议不要直接裸写一个全新的目标而是复制或参考 RISC-V 的后端因为 RISC-V 的指令集设计精简、规范寄存器数量适中是 LLVM 后端的“hello world”。我自己当初是这么做的在llvm/lib/Target下复制一份 RISCV全局改名成 MYRISCV然后从最小子集开始删指令。改 TableGen 文件里的指令定义、寄存器定义再加上编译系统里的 CMake 依赖最后用 clang 把一段简单的return 42编译到我的“新后端”。这个过程非常容易出幺蛾子每个细节都可能让你卡住半天。但每次通过llc看到自己定义的指令出现在汇编输出里那种成就感是别的项目给不了的。5.3 后端常见的编译错误与定位思路给后端做开发最常遇到的错误有两类。一类是 TableGen 报错比如error: No match for intrinsic一般是你的Intrinsics.td写出的 intrinsic 签名和调用处不一致。可以运行llvm-tblgen -gen-instr-info来看看生成的指令信息表快速排查定义问题。另一类是SelectionDAG类型合法化崩溃类似于Cannot select。遇到这种情况通常需要看llvm-mc或者llc -debug-onlyisel对某个具体指令的 ISel 流程跟踪输出。把-debug-onlyisel开起来你就能看到 IR 节点的 legalize 过程在哪里断了。不要害怕这些报错后端开发的本质就是在这些报错里反复定位、修改、验证。一定要熟练使用llc -debug-onlyxxx系列的调试开关这是 LLVM 开发者最重要的武器之一。6. 学习资源与进阶路线建议如果你想在 llvm-project 这条路线上走得更深光靠我这篇文章肯定不够所以我把自己亲测有效的学习路线整理出来。6.1 官方文档怎么读LLVM 的官方文档整体质量很高但组织比较分散新手容易迷茫。我的建议是不要按顺序通读而是按任务去查。写 Pass 之前重点看Writing an LLVM Pass这是最核心的一篇理解 IR 结构翻LangRef这是 LLVM IR 的完整参考想通设计和哲学看LLVM Programmer’s Manual能帮你了解各种实用 API 和数据结构。6.2 通过读代码来学架构读 llvm-project 的源码比看任何文档都有用。我推荐几个入门的阅读路径阅读lib/IR/Instructions.cpp和lib/IR/BasicBlock.cpp了解 IR 基本对象如何管理阅读lib/Transforms/InstCombine/InstCombineAddSub.cpp看官方如何做指令级别的化简阅读lib/Target/RISCV/RISCVISelLowering.cpp了解后端如何将目标无关 IR 转换成目标语义。阅读时要配合调试器比如用lldb或gdb在InstCombine设置断点亲眼看着一条 IR 指令如何被改写。这在学习早期比任何抽象理解都管用。6.3 社区与交流建议LLVM 社区活跃度很高邮件列表和 Discourse 论坛上经常有大牛回答但提问前一定要遵循基本礼仪先跑bugpoint或opt -print-after-all获取最小复现用例把环境和版本信息写清楚。提问质量越高获得有效回答的概率越大。还有一点llvm-project 的贡献指南写得非常清楚如果你想提交代码先在 Discourse 上发 RFC尤其是指令集扩展、Pass 新功能这种涉及面较大的改动提前沟通能避免做无用功。7. 常见问题速查表我在实践中踩过的坑最后把我在实操中遇到的高频问题整理成一张速查表方便你以后排查。现象可能原因解决措施构建时 CMake 报找不到ninja未安装或未加入 PATH安装 ninja-build或改用-G Unix Makefilesllc大量输出指令选择错误TableGen 中指令模式不完整用-debug-onlyisel跟踪选择流程补全模式加载插件报 no such plugin插件编译时的 LLVM 版本与 opt 不一致用同一份 build 产物下的头文件和库重新编译插件-passes命令行解析失败新旧 Pass 语法混用先查看opt --help-hidden确认 Pass 注册名Pass 改了 IR 但输出不变未正确修改到 IR或 Pass 被跳过在 run 函数打印指令数量确认是否执行无法跑通 RISC-V 后端目标未加入LLVM_TARGETS_TO_BUILD重新 CMake 时加入 RISC-VRelease 模式断点不生效优化级别较高导致指令重排用-DCMAKE_BUILD_TYPEDebug重新构建写 Pass 时dyn_cast总是返回空类型不匹配先确认Instruction::getOpcode()的枚举值链接时出现重复符号插件和主程序都编译了同一份代码编译插件时避免链接整个 LLVM 库用-fvisibilityhidden修改.td文件后构建不报错但行为不变TableGen 没有重新生成删除 build 目录里相关.inc文件后重新构建这张表不可能覆盖所有问题但绝大多数新手期的困惑都能在里面找到方向。8. 关于后续扩展的一些想法写完第一个 Pass、跑通一次后端修改之后你会发现自己对“编译器”这个黑盒的掌控感完全不同了。很多人学到这个阶段会问接下来往哪走我觉得有几个方向可以看兴趣选。一是深入了解优化算法的设计。从 InstCombine 的指令化简到 GVN 的全局值编号再到 LoopUnroll 的循环优化每一块拿出来都够研究很久。这里的经验是用 LLVM 的opt -print-after-all配合具体例子亲手对比开和关某个 Pass 前后的 IR理解速度特别快。二是往 MLIR 方向走。MLIR 是 llvm-project 里发展非常快的子项目它允许你定义多级 IR在高层做领域专属优化再逐步 lower 到 LLVM IR。做机器学习编译器、做硬件综合都会遇到它。三是往工具链周边走比如学习 LLD 的链接原理、学习 compiler-rt 里各种运行时库的实现、甚至研究 libc 的实现细节。编译器的世界非常深但每一层都和 LLVM 这一套基础设施紧密相关。根据我个人的经验学习 LLVM 最大的收获往往还不只是“会写 Pass”和“会调后端”而是一种理解程序的新视角。当你开始习惯用 IR 的粒度去思考程序的语义很多东西会变得清晰很多。
分享:

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

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