ARTICLE DETAIL

资讯详情

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

LLVM IR生成从原理到实践:用IRBuilder搭建自定义编译器后端

LLVM IR生成从原理到实践:用IRBuilder搭建自定义编译器后端 简介面向C开发者与编译器初学者的LLVM IR生成演示项目以清晰的工程源码展示如何通过LLVM C API从零构建中间表示涵盖模块、函数、基本块与指令的创建流程适合作为课程设计或自学编译器后端的参考资料。压缩包共50个文件以25个cc源文件、16个头文件为主体并包含1个ll格式IR示例、1个yy语法文件及CMake构建配置说明文件则帮助快速了解项目结构整包仅23KB结构紧凑适合快速研读。已有463人学习使用。工程源码按AST解析、错误处理、代码生成等模块组织系统演示了从环境初始化、类型定义、指令生成到函数与基本块连接、最终输出IR的完整路径可帮助读者理解SSA形式、控制流构建、优化入口等核心概念通过项目内ll示例还能直观对比生成结果无论是学习编译器后端还是进行工具链开发都能从中获得可运行的参考实现与排错思路。 我在整理自己的工具链时突然被一个叫llvm-ir-dimostrazione的仓库吸引了。dimostrazione在意大利语里就是“演示、示例”的意思翻译过来就是“LLVM IR 生成演示”。这类仓库看起来很小但它把 LLVM 中间表示IR的生成过程从头到尾拆了个干净。我电脑里藏了不少同类项目但这个是少数让我觉得“可以直接拿去讲课”的。这篇文章我打算用它当引子把 LLVM IR 生成这件事从原理讲到操作再讲到我实际踩过的坑。不管你是刚接触编译器后端还是已经在写自定义 Pass应该都能从中找到点实在的东西。1. 为什么值得花时间研究 LLVM IR 的生成1.1 三层抽象的定位AST、IR、机器码之间到底发生了什么很多初学者一开始不理解既然已经有了抽象语法树AST为什么还要再折腾一层 IR直接遍历 AST 生成汇编不是更简单吗这里的关键在于“中间表示”的价值。AST 是编译前端对源码结构的直接映射它保留了很多语法糖比如for循环、while循环、if-else、短路求值等。但机器码和汇编只关心操作指令、寄存器、内存地址它不关心你写的是for还是while。如果把前端 AST 直接对接后端汇编那么每支持一种新语言就要重新写一套汇编生成逻辑。更麻烦的是你无法在那条路径上做跨语言的优化C 语言做的优化Java 没法复用。LLVM IR 夹在中间就相当于一个“已经拆掉语法糖、但还没分配真实寄存器的抽象汇编”。它使用无限数量的虚拟寄存器%1、%2、%tmp这种每条指令都遵守静态单赋值SSA形式所以非常适合做优化。你从 C 生成的 IR和从 Rust 生成的 IR如果语义一致优化逻辑完全可以共用一套。我见过不少写编译器的人前端辛辛苦苦解析完源码结果后端硬从头写一个寄存器分配器。不是说不行但如果你想要的是“支持几十种语言、共享优化器、还能在 ARM/X86/RISC-V 上跑”LLVM 这套从源语言到 IR 再到目标代码的路径是目前最成熟的方案。llvm-ir-dimostrazione演示的正是这条路径中最关键的一段怎么把“后端想要的东西”给到 LLVM。1.2 演示项目的核心切口自己生成 IR而不是直接编译 C我最初看到这个名字时以为仓库里是写一个完整的编译器前端。但深入看了一下它更像一个“IR 生成教学原语”。它不是用clang -S -emit-llvm把 C 代码转成 IR然后对着看。它直接调用 LLVM API 或者手写 IR 文本从零构造出一个个函数、基本块和指令。这个切口很有意思。为什么要绕开 clang因为 clang 帮你干了很多事类型检查、语法糖展开、隐式转换、ABI 处理最后生成的 IR 已经非常复杂。初学者拿到这份 IR根本不知道哪条指令对应源码里的哪个动作。而自己生成 IR你完全清楚每一步的意图。你可以先创建一个空模块然后在模块里创建函数在函数里创建基本块在基本块末尾插入一条返回值指令。这整个过程的每一步都是显式的IR 和源码之间的关系一目了然。从教学来讲这才是理解 LLVM 的正确方式。从工程上讲这份代码也足够简单适合作为嵌入其他语言前端时的参考骨架。2. 搭建 IR 生成项目时的版本选择与环境陷阱2.1 LLVM 库版本别小看这个选择LLVM 每年的版本迭代都会调整 API特别是LLVMContext、IRBuilder、Module这些核心类接口变化很频繁。写llvm-ir-dimostrazione这种演示项目时最怕的就是拿旧教程配新版库编译报错一堆。我自己常用的组合是 LLVM 16/17 配合 CMake。你在项目里通常不是直接链接llvm-config --libs所有库那样会把成千上万个符号全链进来编译慢、可执行文件也巨大。建议最少依赖原则只需要core、irreader、support、analysis这几个核心库就够了。CMake 里找 LLVM 的典型写法大概是find_package(LLVM REQUIRED CONFIG) message(STATUS Found LLVM ${LLVM_PACKAGE_VERSION}) include_directories(${LLVM_INCLUDE_DIRS}) add_definitions(${LLVM_DEFINITIONS})然后链接target_link_libraries(ir_demo PRIVATE LLVMCore LLVMSupport LLVMIRReader )如果你是新装的 LLVM还要确认llvm-config在 PATH 里并且LLVM_DIR环境变量指向了lib/cmake/llvm。我试过在 Ubuntu 上用 apt 装llvm-17-dev结果是头文件放在/usr/lib/llvm-17/includeCMake 默认找不到必须手动设置export LLVM_DIR/usr/lib/llvm-17/lib/cmake/llvm这个问题几乎每隔一段时间就会遇到一次写在 README 里能救不少人。2.2 生成 IR 的三种途径文本手写、C API、IRBuilderLLVM 官方和社区生态里生成 IR 有几种不同做法。第一种直接写 .ll 文本。这种方式直观语法简单适合做教学演示和测试用例。比如下面这段就是一个完整函数define i32 add(i32 %a, i32 %b) { entry: %sum add i32 %a, %b ret i32 %sum }没有任何多余信息。你可以直接存成add.ll然后用lli add.ll执行或者llc add.ll生成汇编。第二种用 C API 里的IRBuilder。这是最常用的编程式生成方式。IRBuilder帮你维护插入点自动处理指令顺序。你要做的就是创建一个Module然后在Function的基本块里不断调用CreateAdd、CreateRet等方法。第三种直接用裸的 Instruction API。比如BinaryOperator::Create然后手动设置插入点。这种方式比IRBuilder更底层但提供了更多控制力一般写优化 Pass 时会用到。我给你的建议是学习和演示阶段先从手写.ll文本开始搞清楚每条指令的含义然后切换到IRBuilder因为日常生成 IR 时你几乎不会去手动 new 指令对象。llvm-ir-dimostrazione这类项目通常会把两种方式混合起来先用IRBuilder生成再用verifyModule验证最后打印出文本 IR 做对照。这也是我觉得最舒服的路径。3. 手把手写 IR 生成器从空模块到一个完整函数3.1 核心数据结构Module、Function、BasicBlockLLVM IR 的组织结构非常像文件系统。最顶层是Module可以理解成一个编译单元它包含全局变量、函数定义和外部声明。Function对应一个函数里面有参数列表和一个或多个BasicBlock。BasicBlock是直线代码序列除了最后一个终止指令ret、br、switch等之外中间不允许有跳转目标或者分支指令。很多刚接触的人会被 BasicBlock 的英语名字误导以为它是“基本代码块”其实它是“只有一个入口、一个出口的连续指令序列”。整个 SSA 形式的 IR 围绕着 BasicBlock 组成控制流图CFG。你生成 IR 时每一个if、每一个loop最终都表现为若干 BasicBlock 之间的跳转。看一下用 C API 生成前述add函数的最小代码#include llvm/IR/LLVMContext.h #include llvm/IR/Module.h #include llvm/IR/IRBuilder.h #include llvm/IR/Verifier.h #include llvm/Support/raw_ostream.h using namespace llvm; int main() { LLVMContext context; Module module(demo, context); FunctionType *funcType FunctionType::get( Type::getInt32Ty(context), {Type::getInt32Ty(context), Type::getInt32Ty(context)}, false ); Function *addFunc Function::Create( funcType, Function::ExternalLinkage, add, module ); BasicBlock *entry BasicBlock::Create(context, entry, addFunc); IRBuilder builder(entry); Value *a addFunc-arg_begin(); Value *b addFunc-arg_begin() 1; Value *sum builder.CreateAdd(a, b, sum); builder.CreateRet(sum); verifyFunction(*addFunc, errs()); module.print(outs(), nullptr); return 0; }这段代码跑完打印出的 IR 和手写版几乎一样。整个过程非常直观先确定函数签名再创建函数再创建 basic block用 IRBuilder 插入指令。你看生成 IR 并不神秘本质上就是把这些对象组织起来。3.2 生成带控制流的函数不只是加法上面那个例子实在太简单。为了演示出 IR 的生成能力我建议你试试写一个绝对值函数。它包含条件分支能让你看到 BasicBlock 的跳转是如何创建的。C 源码大概是int abs(int x) { if (x 0) return -x; return x; }对应的 IR 结构是三个 BasicBlockentry里做条件比较并跳转then块里返回取负结果merge块里返回原始值。用IRBuilder生成时代码大体如下Function *absFunc ...; BasicBlock *entry BasicBlock::Create(context, entry, absFunc); BasicBlock *thenBlock BasicBlock::Create(context, then, absFunc); BasicBlock *retBlock BasicBlock::Create(context, ret, absFunc); IRBuilder builder(entry); Value *cond builder.CreateICmpSLT(absFunc-arg_begin(), ConstantInt::get(ctx, 0), cond); builder.CreateCondBr(cond, thenBlock, retBlock); builder.SetInsertPoint(thenBlock); Value *neg builder.CreateNeg(absFunc-arg_begin(), neg); builder.CreateRet(neg); builder.SetInsertPoint(retBlock); builder.CreateRet(absFunc-arg_begin());这里有个容易踩的细节CreateCondBr之后当前的插入点还在entry但你已经不能再继续往entry插入普通指令了因为终止指令是基本块的最后一条指令。你需要SetInsertPoint把插入点移到新的基本块。忘记切换插入点是新手最容易犯的错。3.3 调用一个外部函数让 IR 真正能跑起来如果只生成内部函数lli执行时只能做纯计算无法输入输出。为了让它有点实际价值可以生成一个调用printf或puts的 IR。以调用printf为例你需要先在 Module 里声明外部函数FunctionType *printfType FunctionType::get( Type::getInt32Ty(context), {Type::getInt8PtrTy(context)}, true ); Function *printfFunc Function::Create( printfType, Function::ExternalLinkage, printf, module );然后在调用处创建一个全局字符串常量并通过CreateGlobalStringPtr拿到它的指针作为printf的参数。最后的调用是这样的Value *formatStr builder.CreateGlobalStringPtr(hello from llvm: %d\n); Value *arg builder.CreateLoad(...); // 需要加载变量 builder.CreateCall(printfFunc, {formatStr, arg});演示项目如果能做到这一步就已经不是“纸上谈兵”了IR 是真正可以被 JIT 执行或者通过lli跑出结果的。4. 生成 IR 时最隐蔽的几个坑4.1 类型不匹配i1 和 i32 不是一回事LLVM IR 是强类型语言。条件比较icmp的结果类型永远是i1也就是单比特整数。你在后面如果直接把它当成i32使用比如参与算术运算LLVM 的 verifier 会立刻报错。正确做法是用zext或sext扩展成合适的整数宽度。我见过不少人写条件表达式生成时把icmp eq的结果直接塞进select或者br这没问题但一旦想把这个布尔结果和一个i32相加就得先zext。这种事情在文本 IR 里一眼就能看出来但在 C API 里类型是隐式传递的编译器不会拦截这种逻辑错误只有verifyModule会在运行时抛断言。所以我的建议是生成完 IR 后务必调用verifyModule或verifyFunction它比你自己用眼睛看靠谱得多。4.2 插入点停在了终止指令之后IRBuilder 的SetInsertPoint可以设置到任意指令或基本块的末尾。问题是如果你设置的插入点是一个已经以ret结尾的基本块然后再调用CreateAddLLVM 会抛出一个断言基本块末尾已经有终止指令你不能再往中间插指令。这个错误几乎人人都会遇到一次。典型的场景是你在生成if的两个分支时先创建了entry和then、else两个块然后在entry里创建条件跳转。接着你继续用同一个 builder 生成then块里指令却忘记调用SetInsertPoint(thenBlock)。于是新的指令被插到了entry的ret后面自然就爆炸了。排查这个问题的方法很简单看报错信息里提到的 BasicBlock 名字。例如断言报在entry就去检查你是不是之前把插入点留在了那里。4.3 SSA 形式的约束一个值不能在不同路径上分别定义写普通代码时你可以说“如果条件成立x 1否则 x 2”然后在后面继续使用 x。但 LLVM IR 是 SSA 形式一个虚拟寄存器只能被赋值一次。你不能在then块里定义%x1在else块里定义%x2然后直接在一个merge块里使用%x。解决办法是使用phi指令。比如merge: %x phi i32 [ 1, %then ], [ 2, %else ]对于生成器来说这意味着你在构建控制流时要事先记录各个分支提供的 value然后在汇合点创建PHINode。IRBuilder有CreatePHI方法但不是自动的需要你自己管理 incoming values。很多人写自定义前端时到了这一步才开始理解为什么 LLVM 没办法像 AST 解释器那样“顺着人类思维”去执行。这就是 SSA 的代价也是它适合做数据流分析的原因。4.4 全局变量初始化不是你想填啥就填啥全局变量的初始化在 LLVM 里必须是常量表达式不能是任意指令。比如你不能在模块加载时调用函数并把结果赋值给全局变量。如果你确实需要有运行时初始化的东西应该改成函数内部通过alloca加store的方式模拟。这个限制在你生成静态数据时会很不舒服。解决方案通常是定义一个未初始化的全局变量然后在某个初始化函数里写入。这和 C 语言的构造函数思路基本一致。llvm-ir-dimostrazione这种教学演示项目如果遇到全局变量我建议先用常量初始化等把流程跑通再考虑复杂场景。5. IR 生成后的验证、运行与优化观察5.1 使用 lli 直接执行你的 IR生成 IR 后最想做的事情就是看到它跑起来。如果机器上装了 LLVM 工具链可以直接用llilli demo.lllli本质上是 LLVM 的 JIT 执行引擎它会把 IR 快速编译成机器码运行。调用printf这种系统库函数时lli也能正常解析符号只要系统里找得到对应动态库。这对演示项目来说非常方便不需要写独立的可执行文件。如果 IR 里的函数需要命令行参数lli也能把参数传给main。不过要提醒一句你的模块里必须有定义main函数并且签名符合预期lli才会当作入口执行。5.2 用 opt 和 llc 看优化效果IR 生成出来只是起点LLVM 的真正的本事体现在优化和代码生成上。你可以用opt把生成的 IR 跑一遍 passopt -passesinstcombine,mem2reg -S demo.ll -o demo.opt.ll其中-S表示输出文本 IR。这样你就能看到未优化的 IR 到优化后的 IR 有多少变化。比如你在生成时为了统一路径使用了alloca加载和存储变量mem2reg会把它们全部转成 SSA 虚拟寄存器。生成阶段你不必手动做复杂的寄存器提升这是 pass 的活。到了这一步llvm-ir-dimostrazione教学演示项目才算完成闭环生成 IR、验证正确性、跑 JIT、优化、最后用llc生成汇编。llc demo.opt.ll -o demo.s然后你就能看到你的函数变成 X86 汇编指令的样子甚至可以拿diff对比优化前后汇编的差异体会opt到底改了什么。5.3 动手扩展方向如果你想把这类项目做得更深入我建议按以下几个方向扩展加一个简单的前端语法比如只支持加减乘除和括号的表达式解析器从字符串表达式生成 IR形成完整的“表达式编译器”。这是最有成就感的一步。支持自定义结构体用StructType表示结构体配合alloca、getelementptr实现字段访问。getelementptr是新手最容易懵的指令强烈建议单独学习。添加函数调用和参数传递生成多个函数在 IR 内部互相调用并观察调用约定是怎么体现的。集成简单的 JIT利用 LLVM ORC JIT 在运行时编译并执行生成的函数把一个表达式计算器做成 REPL。每一个方向都能把 IR 生成的知识往外推进一步。实际做下来你会对寄存器分配、指令选择、ABI 这些后端细节有更直观的感觉而不是只停留在“看懂 .ll 文件”的阶段。6. 最后再分享一个少有人提的调式技巧很多人生成 IR 出错后第一反应是盯着代码看。我的建议是先让程序停在出错前把当时已经生成的那段 Module 打印成文本。在 C 里很容易加一行module.print(dbgs(), nullptr);打印出来的文本 IR 是排查一切问题的基础。你看一眼基本块前后顺序、终止指令位置、每个 value 的百分比编号很多问题就水落石出了。另一个技巧是给每个指令都取一个可读的名字比如CreateAdd(a, b, sum)而不是默认的空名字。这样打印 IR 时%sum add i32 %a, %b一眼就能知道你程序在干什么。别小看这个习惯它在后面优化阶段会帮你省掉大量心智负担。LLVM IR 生成说到底是把一个语言前端和一个通用后端连接起来的桥。llvm-ir-dimostrazione这种演示项目就是教你亲手搭这段桥。先把一个函数跑通再扩展到控制流、函数调用、全局变量后面的事就顺理成章了。愿你也能享受到第一次把自定义语言编译成机器码的快乐。本文还有配套的精品资源点击获取
返回列表