免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

深入理解LLVM:从中间表示到编译器工具链的完整解析

深入理解LLVM:从中间表示到编译器工具链的完整解析 1. LLVM项目到底是个什么先聊点实际的。很多人第一次接触LLVM是在学编译原理的时候或者是在用Xcode写iOS程序时偶尔看到编译日志里蹦出来的Clang字样。但真正要弄懂llvm-project这个仓库的价值需要换一个视角——它不是一个“编译器”而是一整套“造编译器的工具”。LLVM的全称是Low Level Virtual Machine但今天这个名字已经名不副实了。它早已不是一台虚拟机而是从底层指令表示、优化框架、代码生成到上方各种语言前端、工具链、运行时库的完整生态。GitHub上那个llvm-project仓库就是这一切的源代码集合涵盖了Clang、LLDB、libc、compiler-rt、MLIR等十几个子项目数以百万行的C代码维护者遍布全球各大科技公司。为什么说它是“造编译器的工具”而不是编译器本身因为LLVM的核心设计理念是把编译器拆成三段前端负责把源代码变成中间表示中端在这个中间表示上做各种优化后端再把中间表示翻译成目标平台的机器码。这个中间表示就是LLVM IR而围绕IR做优化的那一整套pass框架才是LLVM真正值钱的部分。Clang只是套在前端位置上的一个具体实现你完全可以写一个自己的前端把这个IR喂给LLVM的优化器和后端产出一个完整可用的编译器。这套架构的价值类比过来就是别人卖你一台组装好的电脑LLVM卖你的是主板、电源标准、接口规范外加一堆现成的配件。你不用从零开始设计电路只需要做好自己的那块扩展卡插上去就能工作。这也是为什么现在几乎每一门新语言都在考虑基于LLVM——Rust用了它Swift用了它Julia的JIT也跟它有千丝万缕的关系。理解了llvm-project的定位后面所有细节都好聊了。2. 三段式架构与LLVM IR的设计智慧2.1 为什么非要中间表示早年的编译器都是“一条龙”结构比如GCC前端解析完语法、做完语义分析生成一个树状结构然后直接在这个树上做优化最后生成汇编。这个树状结构跟语言强相关跟目标机器也强相关导致整个编译器非常难拆分。你想给这门语言加一个新目标平台的支持几乎等于把优化器重写一遍。LLVM的思路完全不同。它规定了一个统一的中间表示层前端把源代码编译成IR后端只认IR。这个IR既是前端输出的目标也是后端读取的输入前后端之间不需要知道对方的存在。于是语言开发者只需要关心怎么把语法翻译成IR芯片厂商只需要关心怎么把IR映射到自己的指令集两边都可以独立演进。2.2 静态单赋值与无限寄存器LLVM IR有一个核心性质叫静态单赋值形式英文是Static Single Assignment简称SSA。这个性质要求每个变量只能被赋值一次代码里如果需要给一个变量赋多个值就必须用新名字来表示。看起来这是个很别扭的约束但它给优化器带来了极大的便利。传统编译器里一个变量可能在多个地方被修改数据流分析需要花大力气追踪变量的所有读写点。而在SSA形式下每个值的定义点是唯一的使用点清清楚楚很多优化算法可以直接在IR上做复杂度大幅降低。另一个隐藏设计是“无限寄存器”。LLVM IR里的变量是无限多的用%1、%2这样的编号区分。真正的物理寄存器是在后端做寄存器分配时由算法决定哪些虚拟寄存器可以共用同一个物理寄存器、哪些时候需要溢出到内存。这个设计让中端优化完全不用考虑目标机器的寄存器数量限制大大简化了优化器的编写。2.3 三种内存里的存在形态LLVM IR在编译过程中的不同阶段有不同形态。源码层面是人类可读的文本形式后缀是.ll经过汇编器处理后是二进制位码形式后缀是.bc而在编译器的内存中它是一系列C对象模块里放着函数函数里放着基本块基本块里放着指令。这三种形态各有用途。文本形式适合调试你能直接打开看一个源文件被前端翻译成了什么样子位码形式适合存储和传递编译器在做链接时处理的大多是这种格式内存对象形式则是优化器实际操作的对象每一条指令对应一个Instruction类的实例。这个设计给工具链带来了一个非常实用的特性你可以把C语言代码翻译成IR文件保存下来然后在另一个时间点对它做优化、做分析甚至把它链接到其他语言的IR上。跨语言优化在传统编译器里几乎不可能实现在LLVM这里就是文件合并而已。3. 工具链拆解Clang、LLDB与libc的协同3.1 Clang的模块化设计Clang是llvm-project里最广为人知的子项目负责C、C、Objective-C等语言的前端工作。它跟老牌编译器GCC的C前端相比最大的优势是模块化程度高有清晰的AST抽象语法树接口所以诞生了一大批基于Clang的工具。比如代码格式化工具clang-format静态分析工具clang-tidy重构工具clangd它们的底层都是Clang的库。Clang在编译速度上通常也优于GCC原因是它把语法分析的粒度控制得当并且很多处理是增量的。做iOS开发的同学应该感受过同样的项目用Clang编译比某些旧工具链要快不少。这里需要说明不同项目、不同机器上数据差异很大不能简单说Clang一定碾压GCC但它的架构给工具生态带来的价值是无可争议的。3.2 LLDB的调试体验LLDB是LLVM项目的调试器跟GDB相比它的架构更干净模块间的接口更明确而且原生支持LLVM的调试信息格式。在macOS和iOS开发中Xcode默认使用的就是LLDB你在Xcode里断点、看变量、优雅地表达表达式求值都是LLDB在做幕后工作。LLDB的一大亮点是通过Python脚本扩展你可以编写插件来自定义调试流程比如格式化输出复杂对象、自动定位崩溃现场等等。对写底层代码的人来说LLDB的表达式求值能力很强在调试时直接调用函数、修改变量体验比老牌调试器流畅不少。3.3 libc与compiler-rtlibc是LLVM项目的C标准库实现。这个库的设计目标是干净、性能好、严格的标准化而且license友好。很多项目选择libc而不是libstdc是因为它在一些边界行为上更符合标准也更容易定制。在内嵌场景和跨平台编译里libc的使用非常普遍。compiler-rt则提供了一些底层运行时支持。比如__asan相关的函数是AddressSanitizer地址消毒器运行时的一部分这个工具能检测内存越界、使用已释放内存等内存错误对C/C开发者的帮助是巨大的。平时说的ASan就是compiler-rt里实现的众多sanitizer之一。3.4 MLIRLLVM延伸出的编译基础设施MLIRMulti-Level Intermediate Representation多级中间表示是LLVM项目近几年最重要的衍生子项目之一。如果说LLVM IR是面向“代码优化和机器码生成”的通用表示那么MLIR更关注的是“在更高层次上表达计算图、算子、数据流”尤其适合机器学习和AI加速器场景。它的核心思想是多级IR不同抽象级别的表示可以共存于同一个编译流程中逐级lowering降级直到最终变成LLVM IR。像TensorFlow、PyTorch的高性能编译器都会在中间阶段用到MLIR。这意味着llvm-project不只是服务传统编译器它已经延伸到了深度学习编译和GPU编程领域。4. 用LLVM实际能做什么4.1 写一个领域专用语言的前端如果你要设计一门DSL领域专用语言比如一门用于配置路由的脚本语言或者一门用于描述数据处理管道的语言你不需要从零实现代码生成。只需要写一个词法分析器和语法分析器把源代码翻译成LLVM IR接下来的事情都交给LLVM优化、寄存器分配、指令选择、生成目标平台代码。实际动手时大多数DSL不会做成独立可执行文件而是嵌入宿主语言中通过JIT即时编译的方式在运行时执行。LLVM提供了一套ORC JIT框架可以在程序运行过程中把IR编译成机器码然后直接调用生成的函数指针。这意味着你的DSL代码可以享受接近原生代码的执行性能。4.2 做自定义编译优化passLLVM的优化器采用pass机制每个pass是一个独立的C类对IR进行一种特定变换或分析。内置的pass非常多比如死代码消除、循环展开、函数内联、向量化等等。它们以流水线的方式依次执行。如果你在开发某个领域应用发现生成的代码存在特定的模式可以优化可以写一个自定义pass。比如你的代码里大量出现对某个数学函数的调用参数是常量你就可以写一个pass在编译期把这些调用计算掉。再把pass加载到编译流程中用专用的工具opt命令跑一下就能把优化后的IR输出出来。4.3 做代码静态分析工具传统的静态分析工具大多基于语法树分析的是代码的表层模式。基于LLVM做的分析工具可以直接在IR上运行获得的信息更接近程序的真实语义。你可以写一个遍历IR的pass统计每个函数的指令数、识别未使用的全局变量或者实现一个自定义的警告规则。这也解释了为什么很多代码检查工具的底层都依赖Clang。基于Clang做分析可以拿到完整的语法树信息和控制流图再结合一些数据流分析框架能做出来的检查项非常丰富。llvm-project在这方面的积累是其他工具链很难超越的。4.4 反编译与二进制分析LLVM的后端可以把IR翻译成各平台的机器码反过来也可以加载二进制代码经过反汇编、指令识别后提升为IR。这一点是很多二进制分析工具的基础。比如你用LLVM的MC框架写一个工具可以把一段机器码反汇编成汇编也可以尝试把汇编提升为IR再通过优化器做一些简化方便理解程序的逻辑。这个领域的门槛比前面的高但如果你想做一些程序分析、兼容层或者安全研究LLVM的这套基础设施提供了相当强大的起点。5. 源码结构与构建实践5.1 仓库目录到底有什么先看llvm-project仓库的根目录第一层子目录包括clang、lld、lldb、mlir、flang、compiler-rt、libcxx、libcxxabi、libunwind等等。有一个容易让人疑惑的地方是LLVM自身的核心代码不在根目录而是在llvm这个子目录里。这个llvm目录里才是优化器、IR定义、后端代码、各种库的源码。如果你用的是GitHub镜像还需要注意分支切换。主分支是main但同时维护着多个发布分支比如release/17.x等。想稳定构建建议切到发布分支而不是使用最新主分支因为主分支每天都在变构建出问题的概率更高。5.2 硬件和依赖准备构建LLVM是一个资源密集型任务。我建议至少准备16GB内存、8个以上的逻辑核心、50GB以上的磁盘空间。磁盘空间主要被中间文件和编译缓存占用如果开启ccache可以减小重复构建的负担。依赖方面需要CMake、Ninja、Python以及宿主系统的C编译器。还有一个可选依赖是zlib主要用于压缩位码文件。zstd也建议装一下有些发行版对压缩支持有偏好装了能让llvm避免一些cmake配置时的告警。cmake和ninja的版本要够新太老的版本可能不支持LLVM用到的一些特性。在x86 Linux上用系统自带apt源通常足够。5.3 配置与编译命令详解以下是我常用的配置方式在llvm-project源代码根目录下操作cmake -G Ninja -S llvm -B build -DCMAKE_BUILD_TYPERelease -DLLVM_ENABLE_PROJECTSclang;lld -DLLVM_TARGETS_TO_BUILDX86;AArch64 -DLLVM_ENABLE_ASSERTIONSON逐个解释参数的含义-G Ninja指定使用Ninja作为构建工具。Ninja比make的并行调度更快增量构建也更准确。-S llvm指定LLVM核心源码目录为源码根目录。这个参数必须指向llvm子目录而不是仓库根目录。-B build指定构建输出目录为build。所有生成的中间文件都会放在这里。-DCMAKE_BUILD_TYPERelease构建release版本优化等级高适合实际使用。-DLLVM_ENABLE_PROJECTSclang;lld指定要构建的额外子项目。编译器工具链通常需要clang和lld。-DLLVM_TARGETS_TO_BUILDX86;AArch64只生成x86和ARM架构的后端。如果不指定默认会生成所有支持的后端构建时间会多好几倍。-DLLVM_ENABLE_ASSERTIONSON启用断言。虽然断言会轻微影响性能但在学习、调试工具链时非常有用能帮你提前发现问题。配置完成后直接执行cmake --build build如果只想构建一个特定的目标比如只想编出clang可以ninja -C build clang5.4 验证构建产物构建完成后产物在build/bin目录下。验证方式很简单echo int main(){return 0;} | build/bin/clang -x c - ./a.out echo build ok你应该能看到build ok的输出。这说明clang能把C代码编译成可执行文件并且执行成功。如果想验证C也没有问题把命令中的-x c改成-x c即可。6. 进阶实操用LLVM API遍历IR这个练手环节值得认真走一遍它能让你真正体会到LLVM IR的结构。6.1 准备输入IR先准备一个简单的C代码文件比如叫demo.cint add(int a, int b) { return a b; } int main() { return add(2, 3); }使用clang把它翻译成文本形式的IRbuild/bin/clang -S -emit-llvm demo.c -o demo.ll打开demo.ll你能看到类似这样的内容define i32 add(i32 %a, i32 %b) { entry: %add add nsw i32 %a, %b ret i32 %add }这里可以看到IR和汇编的相似之处但它更抽象。i32是32位整数类型%a、%b是虚拟寄存器add nsw是带无符号溢出语义的加法指令。6.2 编写遍历IR的程序下面这个示例程序的功能读取一个LLVM位码文件.bc遍历其中每个函数的每个基本块并打印所有指令的操作码。它演示的是LLVM C API的基本用法即llvm::parseIRFile函数、Module、Function、BasicBlock、Instruction这几种核心数据结构之间的组织关系。#include llvm/IR/Module.h #include llvm/IR/Function.h #include llvm/IR/BasicBlock.h #include llvm/IR/Instruction.h #include llvm/IR/LLVMContext.h #include llvm/IRReader/IRReader.h #include llvm/Support/SourceMgr.h #include llvm/Support/raw_ostream.h #include memory using namespace llvm; int main(int argc, char **argv) { if (argc 2) { errs() usage: argv[0] file.ll\n; return 1; } LLVMContext Context; SMDiagnostic Err; std::unique_ptrModule M parseIRFile(argv[1], Err, Context); if (!M) { Err.print(argv[0], errs()); return 1; } for (Function F : *M) { outs() Function: F.getName() \n; for (BasicBlock BB : F) { outs() Block: BB.getName() \n; for (Instruction I : BB) { outs() I.getOpcodeName() \n; } } } return 0; }代码逻辑很直白如果你是第一次接触LLVM API可以把Module理解成一个源代码文件Function是文件里的函数BasicBlock是函数内部的基本块Instruction是基本块里的每一条指令。嵌套的for循环就是逐层向下遍历。要注意parseIRFile既能读.ll文本文件也能读.bc位码文件它会自动识别格式。6.3 编译并运行这个分析工具把上面的代码保存为irdump.cpp然后编译它这一步需要链接LLVM的核心库build/bin/clang irdump.cpp -o irdump -stdc17 -I build/include $(build/bin/llvm-config --libs core irreader support --system-libs 2/dev/null)这里使用了llvm-config工具来获取库的链接参数这是LLVM开发中最常用的方式。它输出的--libs core irreader support参数会展开成一系列-lLLVMCore -lLLVMIRReader -lLLVMSupport等链接选项--system-libs会把需要用到的系统库比如curses、zlib等也加上。如果编译时提示找不到llvm-config命令确认一下它的完整路径build/bin/llvm-config。运行这个工具./irdump demo.ll输出应该类似Function: add Block: entry add ret Function: main Block: entry call ret这就对了。你的代码已经能够解析IR文件并遍历其中的每一条指令了。基于这个骨架你可以扩展出很多实用工具比如统计指令比例、分析函数调用关系、检测潜在问题模式等。7. 避坑指南与经验心得7.1 构建阶段的坑坑一根目录和llvm子目录混淆。很多人第一次克隆完仓库直接在根目录执行cmake结果飘红。必须明确cmake的-S参数指向的是llvm子目录。我在配置时踩过这个坑当时浪费了半小时排查最后发现是路径问题。坑二内存不足导致链接失败。LLVM的很多库文件很大链接器在链接阶段可能吃掉大量内存。建议给构建过程配置swap空间或减少并行度。ninja -C build -j 4 clang如果内存比较紧张可以把并行度调低虽然慢一些但至少能跑完。坑三断言不启用的隐性风险。我在写自定义pass时曾关闭断言调试结果运行时出现了奇怪的段错误追踪了一天也没有眉目。后来重新打开断言编译一跑就弹出了详细的失败位置和调用栈。7.2 开发阶段的坑坑一不要直接改LLVM核心库的API。LLVM的API在版本间变动很大如果你的代码基于某个版本开发升级仓库后大概率要调整。建议对外层代码做好封装减少对具体API版本的依赖。坑二优先使用IRBuilder。写IR的构建逻辑时手动创建指令对象太繁琐而且容易出错。IRBuilder提供了一套高级接口可以帮你自动处理很多指令创建的细节。比如创建加法指令的IR用Builder.CreateAdd一行就完成非常清晰。坑三irreader库里的parseIRFile跟parseIR不同。前者是顶级函数支持文件路径适合独立工具使用后者是ParseIR函数需要依赖于LLVMContext更适合嵌入到编译器的模块里使用。7.3 全链路验证技巧拿到一个修改过的LLVM版本想验证它是否真的改善了生成的代码质量建议做三件事先用opt跑一遍IR优化观察优化前后IR的变化再对比生成的目标机器代码看指令数量是否减少或关键循环是否被向量化最后一定要跑基准测试不能只看指令数量因为有时候为了省几条指令反而会造成流水线停顿。这个流程做下来你对LLVM的理解会深入很多不再只是表面上的工具用户。8. 给新手的实操路线图如果你是一个从未接触过LLVM源码的开发者想系统地入门llvm-project我的建议分四步走每一步都不长但能积少成多。第一步用Clang实践前端功能学会clang -S -emit-llvm、clang -O2 -S等常用命令能看懂IR文件的常见指令。这一步的目标是建立对IR的直觉。第二步学会用opt跑各种内置pass观察不同优化选项对IR的影响。比如把-mem2reg跑在一个简单函数上观察它是怎么把堆栈变量的存取转换成SSA寄存器的。第三步用LLVM API写一个简单的分析工具比如上面那个遍历指令的工具。这一步的目标是理解IR在内存中的表示形式体会到用C操作IR的流程。第四步尝试给LLVM添加一个新的优化pass哪怕它只是把某条恒定表达式折叠掉。你会经历pass注册、构建、加载、调试的完整流程之后再看LLVM的方法论就会觉得豁然开朗。我在学习Rust编译器、Swift编译器的实现原理时同样使用了这四步走的方法。LLVM就像一个编译器领域的“游戏引擎”学会操作它之后进入任何语言实现的大门都会顺畅得多。记住最好的学习材料永远是源码本身动手改一行代码的价值远大于读三篇博客。
返回列表