十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

深入理解LLVM:从IR Pass到llvmpipe的编译器技术解析

深入理解LLVM:从IR Pass到llvmpipe的编译器技术解析 一个看起来平平无奇的仓库名“llvm-project”其实牵动的是整个现代编译技术、编程语言工具链甚至GPU图形渲染的底层神经。做编译器、做嵌入式、做高性能计算还有搞图形渲染的人估计都绕不开这五个字母。每次看到发行版更新日志里出现“LLVM 15.0.7”这种版本号很多人的第一反应是“又升级了什么”我的第一反应是“这轮又改了多少API旧代码又要修几个坑”。作为在这个坑里摸爬滚打了好些年的人今天打算把这套东西从头到脚拆开聊聊——它到底解决了什么问题这些组件各自负责什么llvmpipe里的那个256 bits又意味着什么以及最实际的怎么在LLVM框架里真正动手做一个自己的IR Pass。这篇文章不会只停留在“LLVM是一个编译器框架”这种正确但没用的结论上更多会讲原理、讲为什么这样设计顺带把实操里踩过的坑一起倒出来。1. LLVM项目到底解决什么问题——先理解它存在的意义1.1 从“卖不出去的虚拟机”到“编译器基础设施”LLVM这个缩写最早的意思叫“Low Level Virtual Machine”听起来像是一个底层虚拟机项目。但发展到现在它本质上已经和“虚拟机”没什么关系了更准确的说法是“模块化、可复用的编译器与工具链基础设施”。为什么会发生这种转变因为LLVM最初的设计者Chris Lattner在博士论文里设想的目标其实是做一个能够长期存在的、适用于任意编程语言的静态编译和动态编译平台。传统的编译器比如早期的GCC架构上是“一个前端对一个后端”改一个语言的支持就要动整个工具链改一个目标平台的支持也要动整个优化逻辑牵一发动全身。而LLVM从一开始就切开了这层结构前端负责把源代码变成统一的中间表示也就是IR优化器只对这个IR做优化后端负责把优化后的IR变成目标机器码。这三层之间的接口是稳定的IR这就让“新语言接入”和“新平台支持”变成了一件插拔式的工作。比这个架构更重要的是LLVM采用了“库式”的组织形态。你在文件系统里看到的是llvm-project这个超大规模仓库里面包含的不仅仅是一个可执行的编译器二进制而是几十个可以独立调用的库组件比如MC、CodeGen、Analysis、Transforms、Target等。用户也可以不启动整个编译流程而是像拼接积木一样把某个优化库、某个分析库捞出来嵌入自己的程序。很多实际的工具链项目就是这么干的——比如安卓的ART运行时、苹果的Swift编译器、各种研究用的程序分析框架它们底层都是LLVM库的某种组合。理解了这一点你才能明白为什么这个仓库那么庞大、目录那么复杂因为它的设计目标本身就要求把每一层都拆成可复用组件。1.2 三段式架构前端、优化器、后端的解耦逻辑LLVM的核心设计是三层解耦我尽量用一句话说清楚前端把源代码翻译成IR优化器在IR上做各种等价变换后端把IR翻译成目标机器的汇编和机器码。每一层只跟IR打交道不关心其他层的实现细节。这带来的直接好处非常明显。第一一门新语言接入LLVM生态只需要写一个前端把语法树转成IR就能立刻享受到所有优化器能力、链接器支持、调试器支持。Rust、Swift、Julia甚至Go早期都评估过或实际用过LLVM后端就是因为这条路非常省事。第二一个新CPU架构要进入生态只需要写一个后端就能让所有走LLVM前端的语言全部跑起来。RISC-V这些年崛起得这么快和LLVM社区对其后端的成熟支持有很大关系。第三对整个工具链而言IR就是统一的“交流语言”Clang给出的IR可以被优化器、静态分析器、调试器、JIT执行引擎分别消费不用为每个消费场景单独写编译代码。这里有个容易忽略但很关键的点IR不是一次性设计的。LLVM的IR早期只有一种“定常IR”后来为了支持JIT场景又加了“显式SSA形式的IR”为了支持高性能局部优化又搞了SelectionDAG这种有向无环图表示为了支持指令选择引入了GlobalISel。不同的编译阶段其实会使用不同粒度的中间表示只是外面那层最广为人知的IR是天蓝色的文本形态。整个过程好比盖房子结构图一套、施工图一套、竣工图又一套表达目的不同颗粒度也不同但它们各自服务于一个阶段。1.3 案例拆解一行C代码在LLVM里走完的完整流程光讲概念没有体感举一个最简单的例子int add(int a, int b) { return a b; }。用Clang把它转成IR文本会得到类似下面这样的内容define i32 add(i32 %a, i32 %b) { entry: %tmp add i32 %a, %b ret i32 %tmp }i32就是32位整数%a、%b是SSA形式的虚拟寄存器。所谓SSA形式简单说是每个变量只能被赋值一次这样数据流分析会非常方便。然后优化器开始干活比如在这一段里可以做常量传播、公共子表达式消除如果调用点传入的是常数还能提前在编译期就算出结果。优化后的IR继续往后走后端会先根据目标指令集做指令选择——在x86-64上add指令可能被映射成一条lea或者add同时还要处理调用约定决定参数放寄存器还是栈里在ARM64上则是走它自己的寄存器组合。最终生成的目标文件再经过汇编、链接变成可执行文件。这个过程中有一个非常让我佩服的设计点只要IR是稳定的前端和后端就可以各改各的只要保证IR语义不变。很多真实项目里的实践也验证了这一点——比如苹果在后端做巨大的性能和代码大小优化时Clang端并不需要同步改动而Clang新版本一旦改进了诊断信息或前端语法树处理逻辑IR输出更规范的时候所有后端平台都能立刻受益。这种解耦是LLVM能在十几年里保持生命力的核心原因。2. 核心工具链详解Clang、LLD、LLDB各自的分工2.1 Clang不只是C/C编译器提到LLVM很多人第一个想到的就是Clang。Clang原本是“C Language Family Frontend”也就是C系列语言的前端但今天它已经是完整的编译器驱动可以把C、C、Objective-C、OpenMP、CUDA、HIP这些语言都编译到目标平台。Clang和GCC相比最直观的优势有两个错误诊断精确、编译速度快。诊断信息不只是告诉你“这一行有错”而是能从源码分析和注释里定位到真正的疑点配合-fdiagnostics-absolute-paths、-fdiagnostics-formatjson这些选项工程集成能力非常强。Clang不只是“编译”它还衍生出了一套代码质量工具链。clang-tidy做静态检查和风格修正clang-format统一代码风格clangd为IDE提供语言服务。很多人没有意识到的是这些工具都构建在Clang的LibTooling框架之上也就是把Clang的解析、AST、语义分析能力做成库形式开放出来。所以做自定义代码分析很多时候不用自己从头写词法器、语法器直接用LibTooling加一个AST Matcher就能搞定。Clang还有一个很实用的能力静态分析器clang --analyze。它不是简单的正则扫描而是基于真实的控制流和数据流做路径敏感分析能查出空指针解引用、内存泄漏、逻辑错误等问题。在CI阶段跑一次静态分析比代码评审人肉找缺陷效率高很多。我见过不少项目把Clang静态分析集成到了GitLab CI或者GitHub Actions里每次提交自动出报告效果很不错。2.2 LLD与LLDB链接和调试环节的价值编译器只是工具链里的一环链接器和调试器同样重要。LLD是LLVM生态的链接器速度优势非常明显尤其是链接大型C项目的时候比传统的GNU ld要快好几倍。原因是LLD在设计上用了并行符号解析、更紧凑的内存布局还针对重定位和合并做了大量算法优化。用上LLD之后链接时间从几分钟压到几十秒是很常见的事。很多人会问链接时间重要吗非常之重要——开发迭代时每次改一行代码都要重新链接几秒的差距累积下来一天能省下不少时间。这也是为什么Chromium这类工程很早就切到了LLD。LLDB则是LLVM生态的调试器。它和Clang共享了太多底层代码所以对Clang编译出来的程序调试体验好得有点“过分”。表达式求值可以直接用Clang的前端来解析和编译支持复杂的C表达式、函数调用甚至支持调试时用expression构建复杂对象。相比GDBLLDB的命令风格更现代化、更接近面向对象的逻辑脚本接口也是Python生态的方便做定制。习惯GDB的人可能一开始不适应但在LLVM生态里LLDB和Clang的配合确实更自然。2.3 LLVM 15.0.7这个版本里值得注意的变化用户反馈里出现“llvmpipe (llvm 15.0.7, 256 bits)”说明系统里用的是LLVM 15.0.7。这个版本在2022年底到2023年初属于比较新的稳定版本在很多主流Linux发行版里被选为了默认工具链。相比14它在C标准支持上做了一大批更新——默认的-std已经到C17对C20和C2b的许多特性也有了实验性支持。此外LLD在链接行为上做了若干修正CodeGen层面对不同CPU调优的flag也有了调整。从实践角度看LLVM 15恰好是NewPM新Pass管理器全面转正、旧Pass管理器开始被边缘化的时期。如果你在这个版本上跑opt会发现默认执行的已经是新Pass管理器体系命令行参数和插件加载方式都和老版本有差异。这一点如果不注意拿旧教程里的例子直接跑很容易报错。我后面会详细讲这部分实操尽可能把新老差异讲清楚。3. llvmpipe到底是什么——软件渲染管线里的JIT艺术3.1 软件渲染管线的原理当你在一个没有GPU或者驱动异常的Linux环境里跑图形程序屏幕上还能出现窗口内容很大概率就是llvmpipe在后面撑着。llvmpipe是Mesa项目里的一个软件光栅化驱动它不依赖任何独立显卡只用CPU完成所有的图元装配、光栅化、片段着色、深度测试这些工作。为什么叫“llvmpipe”因为它依赖LLVM的JIT能力——先把图形渲染所需的着色器和光栅化计算逻辑描述成LLVM IR然后在运行时动态编译成当前CPU的原生机器码来执行。软件渲染听起来像是“性能差”的代名词但在没有GPU的环境、虚拟机里、云桌面场景下这是唯一的兜底方案。llvmpipe的能力并不低级它支持较新版本的OpenGL稳定的Vulkan软件实现也有Lavapipe跑基础3D应用、桌面合成器完全够用。关键是它的代码不是解释执行的而是每次启动针对当前机器的CPU特性重新编译能利用上SSE、AVX这些SIMD指令集。判断当前环境是否跑在llvmpipe上常用一个命令glxinfo | grep OpenGL renderer输出类似“llvmpipe (LLVM 15.0.7, 256 bits)”就说明当前是软件渲染。EU前面的版本号是Mesa编译时链接的LLVM版本后面的“256 bits”代表当前JIT生成代码时采用了256位SIMD向量也就是AVX/AVX2指令集的向量宽度如果是“128 bits”那基本就是SSE/SSE2的水平如果是“512 bits”则表示编译时有AVX-512支持。3.2 256 bits意味着什么——从SIMD和代码生成看优化对于图形渲染来说很多运算天然是并行的。拿最简单的一个片段着色器来说几百个像素的RGB值要做同样的乘法加法这些操作完全可以打包进SIMD寄存器一次算多个。AVX的256位寄存器可以一次处理8个32位浮点数相当于一条指令干八条指令的活。因此“256 bits”不只是一个数字它说明llvmpipe在运行时生成的代码已经尽量把计算流水线向量化了。LLVM的向量化能力在这里体现得淋漓尽致。llvmpipe把后端渲染的每个“着色器变换”都表述成IR然后用LLVM的优化器自动做循环向量化、SLP向量化等处理最后利用当前CPU的指令集特性生成机器码。这个过程等价于CPU作为一块“可重编译的图形硬件”运行时按需生成最佳指令组合。对比传统的解释型软件渲染比如老式的Mesa softpipe性能差距可以达到数倍甚至一个数量级。顺带提一句浏览器里跑WebGL也有类似的软件实现路径Chrome的SwiftShader、Firefox的软件WebGL实现核心思路都是JIT动态生成SIMD代码。所以“无GPU环境下还能跑图形应用”这个体验靠的就是LLVM这类JIT基础设施在底层撑腰。真要说这句“llvmpipe (LLVM 15.0.7, 256 bits)”哪里值得注意我觉得它其实是一台机器“当前没有硬件加速”的明确信号。如果跑在虚拟机里那可能意味着宿主机没把GPU透传进来如果跑在实体机上可能要检查一下显卡驱动是否正常。反过来如果你在做无头服务器上的离屏渲染测试看到它反而是好消息——说明只要CPU够强照样能完成渲染任务。4. 实操在LLVM框架下开发一个自定义IR Pass4.1 环境准备纸上谈兵没意思真正在LLVM里动手写一个Pass才能理解这套架构的精髓。我以最经典的方式演示编写一个函数级IR Pass把函数里的加法指令全部替换成乘法指令——这个实践本身没什么工程价值但能完整展示Pass的开发流程、加载机制和调试方法。先准备环境。你不需要从源码构建整个LLVM直接用系统包管理器安装开发版即可。Ubuntu/Debian上sudo apt install llvm-15-dev clang-15 lld-15这里的关键是llvm-15-dev它提供了头文件和llvm-config工具。很多新手在这步卡住只装了llvm或clang的可执行文件结果头文件找不到、llvm-config不存在后面的编译全靠抄网上的命令自然走不通。装完后用llvm-config-15 --includedir查一下头文件路径用llvm-config-15 --libdir查一下库路径确认环境OK再往下走。还要注意Pass插件二进制是强绑定LLVM版本的。你在LLVM 15下编译的.so不能拿去给LLVM 14或16的opt加载。这是新手最容易踩的坑也是打包发布Pass时最大的“平台差异”来源。项目的CI里最好明确指定LLVM版本否则本地编好了服务器上一跑就报Version mismatch。4.2 实现一个函数级Pass我以LLVM 15时代的新Pass管理器NewPM写法为例因为这是当前的主流也符合15.0.7这个版本的行为。代码如下#include llvm/IR/Function.h #include llvm/IR/Instructions.h #include llvm/IR/InstrTypes.h #include llvm/IR/IRBuilder.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h using namespace llvm; namespace { struct MulInsteadOfAddPass : public PassInfoMixinMulInsteadOfAddPass { PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { bool changed false; for (auto BB : F) { for (auto I : BB) { if (auto *BO dyn_castBinaryOperator(I)) { if (BO-getOpcode() Instruction::Add) { IRBuilder Builder(BO); Value *lhs BO-getOperand(0); Value *rhs BO-getOperand(1); Value *mul Builder.CreateMul(lhs, rhs); BO-replaceAllUsesWith(mul); BO-eraseFromParent(); changed true; } } } } return changed ? PreservedAnalyses::none() : PreservedAnalyses::all(); } }; } // namespace extern C ::llvm::PassPluginLibraryInfo LLVM_ATTRIBUTE_WEAK llvmGetPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, MulInsteadOfAdd, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name mul-instead-of-add) { FPM.addPass(MulInsteadOfAddPass()); return true; } return false; }); } }; }这个代码的意图非常明确遍历函数里每个基本块再遍历基本块里的每条指令。如果发现一条二元运算指令且操作码是Add则用IRBuilder在相同位置构造一条乘法指令把原来加法的结果替换成乘法的结果然后删掉原加法指令。有几个地方需要解释清楚dyn_castBinaryOperator是LLVM里典型的安全向下转型。如果dyn_cast失败返回nullptr说明这条指令不是二元运算直接跳过。IRBuilder是构造IR最顺手的工具它会自动把新指令插入到指定位置并帮你处理常量折叠。replaceAllUsesWithRAUW是LLVM里最常见的“换结果”操作把所有使用旧值的地方改成新值十分高效但要注意别把迭代器搞失效了所以先RAUW再erase是安全顺序。PreservedAnalyses是NewPM里的“分析结果保鲜”机制。如果这个Pass没有修改IR就返回PreservedAnalyses::all()告诉优化管线所有分析结果都可以继续用如果改了必须返回none()否则后续Pass可能基于过时的分析结果做出错误决策。很多初写Pass的人在这里写错导致难以排查的诡异行为。4.3 编译、加载与运行验证有了代码接下来编译成插件。先拿到llvm-config的相关参数再编译llvm-config-15 --cxxflags --ldflags --libs core实际操作时最省事的方式是用llvm-config输出拼接编译命令clang-15 -fPIC -shared $(llvm-config-15 --cxxflags --ldflags) mul_pass.cpp -o mul_pass.so如果你是用CMake管理项目推荐把llvm-config的路径传给find_package(LLVM REQUIRED CONFIG)然后链接LLVM目标。手动编译一次作为验证没问题正式开发建议还是走CMake。编译出mul_pass.so后准备一个简单的测试文件int f(int a, int b) { return a b; }先用Clang生成未优化的IR文本clang-15 -O0 -emit-llvm -S test.c -o test.ll然后加载插件并运行Passopt-15 -load-pass-plugin./mul_pass.so -passesmul-instead-of-add -S test.ll -o out.ll查看out.ll原来的add应该已经被替换成mul并且函数体里出现了一个乘法指令。整个过程最让我觉得舒服的地方在于不需要为了验证一个Pass去重新编译整个LLVM一个插件so加一个小测试文件就够了迭代速度非常快。这也是LLVM模块化设计在日常开发里的最大红利。5. 掉坑记录与排查技巧5.1 常见问题速查表我把真实使用LLVM过程中遇到频率最高的几个问题整理成了表格按症状、原因、解决办法来列方便直接查。症状常见原因解决办法opt加载插件报“Plugin was built for a different LLVM version”插件编译时的LLVM版本和opt运行时的版本不一致统一用同一套llvm-config-15/clang-15编译不要混用发行版自带的头文件和官方二进制头文件找不到llvm/IR/Function.h只装了llvm/clang可执行包没装开发包安装llvm-15-dev用llvm-config-15 --includedir确认头文件位置opt运行后IR没有任何变化Pass没有被正确注册到pipeline或RegisterPipelineParsingCallback里的名称写错或新旧Pass管理器混用检查-passes参数是否与注册名完全一致确认NewPM下没有走Legacy的-load加载方式编译报链接错误undefined reference to llvm::...缺少必要的LLVM库或链接顺序不对用llvm-config-15 --libs core完整展开链接库静态库对顺序敏感把LLVM库放到源文件后面手工写IR会得到大量类型不匹配错误对LLVM类型的约束理解不到位SSA形式被破坏多用IRBuilder来构造指令它会在适当位置插入指令以遵守支配关系少用裸构造函数程序崩溃在Pass运行期迭代器失效或修改IR导致内部一致性破坏RAUW之后立刻检查是否需要删除当前迭代的指令需要收集后统一修改时先用容器保存待删指令5.2 排查思路与心得遇到问题先缩小范围。如果是编译期错误先确认“是不是LLVM版本差异”把opt --version和clang --version对一下。如果是运行期崩溃先开带-debug的构建或者多编译几次减小优化级别定位到具体指令。如果怀疑NewPM和Legacy PM的差异直接看这两个版本下opt的帮助输出插件注册API完全不同。还有一个很实在的经验是写Pass不要一上来就追求通用和优化。先把最简单的遍历、打印、修改做出来跑通再做复杂的控制流分析。LLVM IR的调试信息其实非常丰富-debug-onlyxxx可以从几十个调试类里选一个过滤日志-print-after-all可以看到每个Pass执行后的IR变化这对确认“自己的Pass到底动了什么”帮助极大。我每次写新Pass第一步永远是打印指令列表确认遍历逻辑正确再往上叠加分析逻辑。6. 最后说两句经验这套LLVM项目体系初次接触确实有点劝退——源码庞大、API变动频繁、概念密度高。但一旦迈过“IR怎么看懂”“Pass怎么跑起来”这两道坎后面就是一片开阔地。llvmpipe那个软件渲染背后依赖的是LLVM的JIT和向量化各语言工具链后面的IR优化依赖的是LLVM的Pass体系嵌入式开发里用的交叉编译工具链底层也是LLVM的后端支持。这些年我用LLVM做过静态分析插件、写过优化Pass、也调过软件渲染问题最大的感受是这个项目最值得学的并不是某一条具体的API怎么调用而是那种“一切皆组件、组件皆可复用”的工程哲学。最后再分享一个小技巧凡是LLVM相关的问题搜索引擎里查出报错信息后先看这个报错属于哪个组件Clang、opt、lld、llvmpipe再去对应组件的文档和版本迁移说明里找答案。LLVM每个大版本都会发布一整套“Release Notes”里面会明确列出API变更和弃用项比看一堆第三方博客要靠谱得多。祝你们在llvm-project这个深坑里挖得开心。
返回列表