ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

LLVM编译器基础设施入门:从IR构建到Pass开发与后端扩展

LLVM编译器基础设施入门:从IR构建到Pass开发与后端扩展 我刚开始接触LLVM的时候最直观的困惑就是这个东西到底是个编译器还是一个库还是一个工具链后来在llvm-project仓库里泡得越久越发现它是全都占一点。它本质上是一个围绕中间表示IR构建的编译器基础设施项目Clang只是它的一个前端真正值钱的是背后那一整套可以自由组合、二次开发的编译技术栈。这篇文章我想结合我长期在llvm-project上做开发、构建、写Pass、改后端的经验把这个项目的底层逻辑、目录结构、构建流程和上手路线讲透。不管你是想做编译器、想给Clang加语法还是想在LLVM上做程序分析这篇文章都会比官方文档更贴近实际操作。1. 项目整体设计与架构拆解1.1 三层架构为什么LLVM能通吃各种语言和芯片LLVM最核心的设计思想可以概括为三明治结构前端负责把源代码变成IR中端负责对IR做优化后端负责把IR变成目标机器的汇编或机器码。这三层之间的接口就是LLVM IR它像编译器世界的通用语言只要你把源代码翻译成IR后续的优化和代码生成全部复用。你写C语言Clang这个前端会把它翻译成IR你用Rustrustc也走上了LLVM这条高速路Swift、Julia、Kotlin/Native底层都是同一套优化器和后端。我第一次意识到这个设计的厉害之处是在我尝试给一个玩具语言写编译器的时候我只写了词法分析、语法分析和IR生成三个模块优化和机器码生成直接白嫖了整个LLVM这几乎是把编译器后端的最小成本降到了零。这种架构的另一个聪明之处在于IR可以在文本形式、内存对象和二进制位码bitcode之间自由转换。这意味着你可以把IR序列化到磁盘上做增量编译、跨进程优化LTO甚至把一个前端生成的IR交给另一台机器去优化。很多做静态分析、符号执行、模糊测试的工具也是直接从IR这一层切入绕过了前端和后端极大地降低了工程成本。1.2 单一仓库的协作模式llvm-project如何管理庞大生态llvm-project并不是单一代码库它采用了monorepo模式把LLVM核心、Clang、LLD、libc、compiler-rt等一系列子项目全部放进同一个Git仓库。我刚入坑的时候对这种仓库规模很头疼clone下来接近1GB构建一次要好几个小时。但实际开发一段时间后我理解了这个选择的必要性编译器项目内部的接口变更极其频繁如果把子项目拆开各自独立仓库一个IR数据结构改了字段所有下游仓库都得同步发版本版本错位的痛苦会比磁盘空间保守得多。在monorepo模式下你可以随时把Clang和LLVM核心的代码同时checkout到同一个提交点想要做跨项目改动时不会遇到编译器用的是老IR前端是新IR之类的诡异错误。这个仓库还提供了统一的测试基础设施和统一的代码格式化工具clang-format所有子项目共用一套工程规范这是大规模协作项目里非常稀缺的隐形价值。2. 源码仓库布局详解2.1 顶层目录导读不要在llvm目录里迷路第一次打开llvm-project仓库时建议先看顶层目录而不是直接扎进llvm子目录。这里有十几个项目名称初看容易发懵但你只需要抓住几个关键角色。llvm目录是核心库和核心工具集的所在地。clang是C/C/Objective-C前端。lld是链接器polly是面向多面体模型的循环优化器libcxx和libcxxabi是C标准库实现compiler-rt提供运行时支持和sanitizer工具。还有一个容易被忽略的mlir目录它是LLVM团队布局编译器基础设施的另一条隐线专门用来做多层级中间表示对神经网络编译器和异构计算框架特别重要。如果你在学习阶段想快速跑通一个实验不必把全部项目都加入构建只构架llvm项目本身写个简单的IR分析工具完全够用。那些声称必须跑全量构建的教程其实是在浪费时间。2.2 核心代码目录的职能划分在llvm/include和llvm/lib里面你才能感受到LLVM的宏大。lib/IR存放基础指令定义和IR读写lib/Transforms是各类优化Passlib/Target存放所有架构后端比如X86、AArch64、RISCVlib/CodeGen负责指令选择、寄存器分配、指令调度、MIR等流程。读这部分代码时建议不要按字母顺序去读而是按数据流来读。我自己的阅读顺序是先看IR模块里的Instruction和BasicBlock定义再看一个具体Pass比如mem2reg接着看SelectionDAG是怎么进行指令选择的最后才去看寄存器分配。顺着这条线把整个编译器流水线串起来各个模块之间的关系会比孤立阅读清晰得多。include目录里是公共头文件td后缀的文件是TableGen描述文件它们定义了指令集架构描述、寄存器文件、指令格式、Pass参数等。LLVM的指令集是高度数据驱动的新增一个后端指令通常只改.td文件和少量C文件TableGen负责把这些描述展开成C代码这是LLVM后端扩展性特别强的原因。3. 从源码构建LLVM环境选择与参数详解3.1 构建前的准备磁盘、内存和编译器的硬指标构建LLVM最常被低估的瓶颈其实是内存和磁盘。我用一台16核32GB的机器构建Release版全量构建包含Clang和LLD峰值内存接近12GB磁盘占用超过30GB。如果加了调试符号磁盘占用能再到50GB以上。所以建议使用至少8GB内存、50GB可用磁盘的机器否则可能中途死在链接阶段。编译器也需要注意。LLVM对构建编译器有一定要求建议用较新的Clang或GCC。曾经有人用旧版GCC编译LLVM时报出各种奇怪的模板错误其实多半是编译器不符合最低版本要求。CMake在配置阶段会检测编译器版本和能力出现版本不足时会有醒目的warning。3.2 CMake参数速查用这份配置少踩一半坑构建LLVM的标准流程是mkdir build cd build然后运行cmake配置。下面是我经常使用的一组参数按稳定可靠的优先级排列cmake -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DCMAKE_C_COMPILERclang \ -DCMAKE_CXX_COMPILERclang \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_ENABLE_ASSERTIONSON \ ../llvm这里有几个参数的作用值得解释清楚。LLVM_ENABLE_PROJECTS决定要构建哪些子项目如果你想做C语言工具链就加clang和lld不需要的东西不要加进来。LLVM_TARGETS_TO_BUILD决定要生成哪些后端的代码跟我们做实验只保留X86就够了几十个后端全部开启会让构建时间成倍增长。LLVM_ENABLE_ASSERTIONS建议开启它会打开内部断言很多IR不合法、指针错误都能在运行时立刻暴露出来发布版默认关闭。配置完成以后直接运行ninja即可。多核机器可以用ninja -jN指定并行编译任务数但这有个很容易踩的坑-j数值超过内存承受能力编译到一半直接OOM。我经过多次测试的结论是按总内存除以单任务约2GB来计算16GB内存建议不超过8。如果内存吃紧甚至不建议开高并发慢一点总比整个构建崩溃好。3.3 构建完成后的工具分布构建完成后可执行文件在build/bin目录下。最常用的几个编译器工具链已经整整齐齐地放在一起clang、clang、llvm-as、llvm-dis、opt、llc、lli。clang是编译器前端opt是优化器llc是后端代码生成器lli是IR的解释执行器。我习惯在~/.bashrc里把build/bin加入PATH这样就能直接使用clang、opt这些命令。另一个实用技巧是导出LLVM_HOME环境变量指向build目录很多自动化脚本和集成开发环境配置都能用上省去到处写绝对路径的痛苦。4. 上手LLVM开发的三个切入点4.1 从IR开始认识中间表示的三地址码形态LLVM IR是学习LLVM开发最好的启蒙教材。写一个简单的C代码然后用clang -S -emit-llvm观察IR输出int add(int a, int b) { return a b; }对应的IR大致长这样define i32 add(i32 %a, i32 %b) { entry: %add add nsw i32 %a, %b ret i32 %add }这里可以看到几个关键特征函数签名明确标注了返回值和参数的LLVM类型每一条指令都对应一个SSA形式的虚拟寄存器add指令带有nsw标志说明这里不会发生有符号溢出优化器可以据此做更激进的变换。这种三地址码的形式让每条指令最多接受两个操作数并产生一个结果非常便于分析和变换。如果你首次接触SSA建议完全不要跳过这是理解LLVM后续一切优化行为的基础。在实际开发中还有个小技巧IR文本很难直接手写但可以用llvm-as把文本IR编译成bitcode再用工具链继续操作。调试一个Pass的时候我会手写一段极简IR作为测试输入比总用Clang生成IR要快得多尤其是处理一个几万行C代码项目时手写精准测试用例是必需的。4.2 写一个Pass如何给优化器添加自定义变换LLVM Pass是扩展优化器的标准手段。写Pass的难度不在于API调用而在于要遵守LLVM的框架约束。以新PassManager风格的函数Pass为例#include llvm/IR/Function.h #include llvm/IR/Instructions.h #include llvm/Pass.h #include llvm/IR/LegacyPassManager.h #include llvm/Transforms/IPO/PassManagerBuilder.h using namespace llvm; namespace { class MyFunctionPass : public FunctionPass { public: static char ID; MyFunctionPass() : FunctionPass(ID) {} bool runOnFunction(Function F) override { bool Changed false; for (auto BB : F) { for (auto I : BB) { if (auto *Call dyn_castCallInst(I)) { if (Call-getCalledFunction() Call-getCalledFunction()-getName() printf) { // 在这里对 printf 调用做点自定义处理 Changed true; } } } } return Changed; } }; } // namespace char MyFunctionPass::ID 0; static RegisterPassMyFunctionPass X(my-function-pass, My Custom Function Pass);这个Pass遍历每个函数的每条指令查找对printf的调用。关键点有两个一是runOnFunction返回一个布尔值表示是否修改了IR这个值不能被随便填否则会干扰LLVM对Pass是否修改了结果的分析二是Pass需要注册一个命令行名称这样opt工具才能根据名称调起它。实际开发中需要留意PassManager的API使用场景差异。传统的legacy PassManager在新代码里逐渐被淘汰新PassManager用PassBuilder作为入口两者的编写风格差异很大。LLVM社区正在迁移到新PassManager建议新项目直接以新风格为准但你在读老代码时又会经常见到legacy注册宏两者都要能读懂缺一不可。4.3 进入后端TableGen与指令选择的工作流如果你对修改芯片后端感兴趣LLVM的做法和一个普通编译器差别很大。LLVM后端的指令集架构描述大量使用TableGen语言比如要查看X86架构的ADD指令描述可以搜X86InstrArithmetic.td文件里面有类似这样的内容def ADD32rr : I0x01, MRMDestReg, (outs GR32:$dst), (ins GR32:$src1, GR32:$src2), add{l} {$src2, $src1}, [(set GR32:$dst, (add GR32:$src1, GR32:$src2))], IIC_ALU_RR, Sched[WriteALU];这个td描述在提供什么信息0x01是机器码MRMDestReg是寻址模式后面的指令字符串和SelectionDAG模式共同决定了优化器匹配到ADD32rr这条指令的含义。读懂TableGen描述是打开LLVM后端大门的第一块敲门砖。改后端最常见的工作流是先改.td文件给新指令或新寄存器堆写描述再运行构建让TableGen工具生成对应的C头文件最后在SelectionDAG或GlobalISel的匹配逻辑里加入新模式。改完后编译一个测试用例再用llvm-objdump验证机器码每一步都有对应的工具可以检查中间产物不会像改传统编译器那样完全一头扎进黑盒调试。5. 常见问题与排查技巧实录5.1 TableGen报错别急着改C代码在LLVM后端开发里TableGen相关的报错经常让新手抓狂。错误信息可能直接指向td文件的某一行但真正的根源往往在几层include和class继承关系里的属性设置。排查这类问题的建议顺序是先用llvm-tblgen的-print-records选项查看展开后的记录看属性是否都符合预期再检查是不是引用了不存在的类或字段最后看是不是有重复定义或继承冲突。多数情况下展开后结果能很清楚地暴露问题根源而不是一上来就去翻C文件。5.2 IR不合法或断言失败开头没开LLVM_ENABLE_ASSERTIONS的构建IR不合法和内存错误的表现会非常隐晦。开了断言之后如果你生成了一个类型不匹配的指令运行opt会直接定位到出错的那一行输出Instruction does not dominate all its uses!之类信息。遇到这类问题建议使用opt -passesverify验证IR合法性。还有一个非常高效的办法就是用-print-after-all导出每个Pass运行完之后的IR快照对比一下就知道是哪个Pass把IR搞坏的。5.3 链接阶段报错和符号找不到写Pass时最常见的链接问题是忘加loadable modules或者在CMake里没引入对应库。如果是在llvm-project源码内写Pass要在对应目录的CMakeLists.txt里把新Pass的源文件加进去。如果是外部项目不能用LLVM_VERSION_MAJOR这些宏找不到或者链接不到LLVM的库多半是CMake的LLVM_DIR路径设置不对。最稳妥的做法是直接调用llvm-config --cxxflags --ldflags把它的输出拼到你的编译命令行里。5.4 调试优化效果时不要盲改想验证某个Pass有没有效果我的固定操作是先用clang生成一份IR基准再用opt做一次不包含该Pass的优化作为对照组最后在实验组中开启该Pass做对比。通过llvm-dis把优化后的bitcode转成文本IRdiff一下三次结果。这种方法能快速定位Pass对哪一段IR产生了变化也方便在优化性能和代码大小之间做权衡分析。盲改参数而不做三组对比等于在不知道黑盒里的变化就赌运气这在编译器开发里是效率最低的路径。6. 学习路径与方法建议如果你决定认真啃LLVM我建议按这个路径来先会用Clang的命令行选项再会读IR和写IR文本接着写一个透过IR做静态分析的小工具再写一个简单Pass最后再进入后端。不要一上来就研究指令选择或寄存器分配那会直接被庞大概念淹没。一个提高效率的方法是把官方文档当工具书而不是按顺序阅读。我每次写Pass前会先打开llvm/include/llvm/IR目录下的头文件快速查看Instruction类的继承关系和关键方法签名比在网页文档里翻半天快得多。想了解某个Pass实现时直接在include或lib目录搜索Pass名称就能定位到源码许多英文文档反而没有源码直观。学习过程中建议给自己设一个明确的小目标。不要是学习LLVM而是给函数调用次数加一个统计并用opt输出报告这类可验证、有产出的目标。我在给一个开源静态分析工具贡献代码时就从这样一个极小的统计Pass开始那次的完整实践让我对PassManager的生命周期、IR遍历、命令行参数注册、测试框架的认知比看十篇教程都牢固。LLVM的学习曲线确实陡但它的设计有高度的自洽性一旦跨过IR和PassManager这两道坎后面再接触后端、MLIR都会有水到渠成感。如果看到这里你正准备动手构建一个llvm-project我希望你能跳过文档里那些过时的示例代码直接用上面这套心得去尝试真正把IR玩弄于股掌之间才是理解这个项目的起点。
返回列表