ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LLVM IR生成实战:从IRBuilder到JIT的完整指南

LLVM IR生成实战:从IRBuilder到JIT的完整指南 简介项目演示了如何借助C API手动生成LLVM IR面向编译器、工具链开发者以及想弄清中间表示原理的进阶学习者可用来快速搭建自定义代码生成流程。压缩包共50个文件核心为25个cc实现与16个h声明另有5个txt说明、1个ll示例输出和1个yy词法语法输入总计仅23KB目录按AST、解析、错误处理、代码生成等模块清晰组织。示例从LLVMContext初始化开始依次演示Module与类型创建、Function定义、BasicBlock构建并生成算术运算、条件跳转、函数调用等指令同时展示如何通过优化通道处理IR精简冗余指令最终输出可读的.ll格式文件。全过程配有对应代码片段步骤衔接清楚方便对照学习。已有463人学习下载适合作为编译原理课程设计或自研小型编译器的参考能够帮助理解SSA形式与LLVM底层API的配合方式。1. LLVM IR生成演示为什么说IR是现代编译器的接缝层大多数人接触LLVM IR是从clang -S -emit-llvm输出的一堆%1 add i32开始的那是编译器喂给你的结果而不是你自己按需求造出来的IR。真正的工程场景里你会碰到的是写一个DSL前端想把自定义语法直接落成IR或者要给模拟器、自制CPU做后端编译器需要按自己的指令集去消费IR。这时候需要的就不是读懂IR而是主动生成IR。这个标题下的演示方向讲的就是从零构造Module、Function、BasicBlock、Instruction再打印、验证、JIT执行把IR当成一个可以直接调用的目标去组装。项目的后半段“dimostrazione”是意大利语的“演示”内容其实就一句话教会你如何用LLVM的API把IR捏出来而不是等着编译器给你吐出来。它适合三类人正在啃LLVM文档但不知道从哪下手的新手要写自定义语言前端、不想在AST胶水上浪费时间的人以及想在LLVM之上做指令选择实验的研究者。这里需要先立住一个反直觉的结论生成IR最稳的方式不是拼字符串而是调用IRBuilder让它在内存里维护好use-def链和SSA约束再把它打印出来当面看。2. 先把LLVM IR的骨架摸清类型、指令与module/function/bb三层结构2.1 跑通最小演示链路文本IR不用编译器也能直接执行做IR生成之前我建议先建立一个最小可运行的感知。LLVM IR的第一种形态是文本格式通常以.ll结尾它可以直接被lli解析并执行也可以被llvm-as编译成二进制.bc再用llc变成目标平台汇编。我通常会先写一个几十行的.ll文本把要生成的IR在脑子里过一遍然后用LLVM自带工具把它跑起来。这个习惯比直接写C API代码更重要因为IR文本没有隐式行为任何一条指令都能找到对应的文本字符它让黑匣子变成可审阅的源码。; demo_min.ll define i32 main() { ret i32 42 }这是能完整走通“生成-验证-执行”链路的最小IR。运行lli -O0 -jit-kindorc demo_min.ll后进程退出码就是42Linux下用echo $?能拿到这个返回值Windows下看%ERRORLEVEL%。别看这5行简单它验证了整条链路define声明模块级函数、i32确定返回类型、ret终止基本块最关键的是入口函数main按C ABI约定暴露给运行期。lli有几个参数值得记住。-O0和默认的-O2会影响JIT端做不做优化做演示时如果希望执行的IR和写的IR行为一致就显式加-O0。-jit-kind切换底层JIT引擎老版本默认MCJIT新版本默认ORC遇到版本相关的段错误或符号解析错误时先试试-jit-kindmcjit很多老工程都是在换引擎之后修好的这属于典型的版本迁移踩坑。2.2 类型系统决定IR的表现形态LLVM IR的类型系统是生成一切指令的前提因为指令的操作数类型一旦不匹配verifyModule直接在内存里报断言。最常用的基础类型是整数型iNN可以是任意位宽比如i8、i32、i64浮点型有float和double分别对应C的float和double。指针类型在LLVM 15之后统一为opaque ptr不再携带被指向类型这个改动影响深远GEP指令的第一个索引不再像typed pointer时代那样写“指向的指针本身那一层”我身边好几个同事都是从老教程里学的旧语法升级LLVM后代码不能编译找了一整天原因。聚合类型里struct用%struct.Foo type { i32, ptr }表达数组用[4 x i32]表达。生成IR时对应到LLVM C API就是StructType::create(ctx, elements, Foo)和ArrayType::get(i32Ty, 4)。一个常见的误区是试图用i64去存储一个ptr再取回来虽然大多数平台上这两个类型的位宽相同但IR的类型系统不允许把指针当整数直接运算必须先ptrtoint。这个限制保证IR的语义在跨平台时一致也是演示代码里最先让人翻车的地方。类型文本形态C API典型用途整数i32Type::getInt32Ty(ctx)字面量、索引、返回值浮点doubleType::getDoubleTy(ctx)数值计算指针ptrPointerType::getUnqual(ctx)全局变量、内存访问结构体%T type { i32, ptr }StructType::create(...)复合数据数组[4 x i32]ArrayType::get(...)定长序列2.3 SSA形式与use-def链手写IR时为什么会有“先定义后使用”的规则LLVM IR是SSA形式它的含义是每个变量只能被定义一次使用的每一条指令都直接引用定义它的那条指令中间不经过可变存储。这个设计让数据依赖变成显式的边优化器可以一眼看出某条use依赖哪个def。手动构造IR时最容易犯的错误是试图“重新赋值”一个值比如想在同一个基本块里修改%v。这在SSA里不合法正确做法要么用alloca加store/load把它变成内存对象要么在不同路径里定义不同的值再用phi节点在汇聚点选一条。; 错误%x 被定义两次不是合法SSA define i32 bad() { %x add i32 1, 2 %x add i32 %x, 3 ret i32 %x }; 正确用 %x2 承接第二次计算 define i32 good() { %x add i32 1, 2 %x2 add i32 %x, 3 ret i32 %x2 }第二段代码能通过llvm-as和lli第一段会在解析期直接报“instruction expected to be a value but is a different type”之类的错误。这里要记一个基本规则在IR文本里%x只是值的命名不是可写变量同一个名字出现多次在IR语法中会被视为重定义。真正实现变量的方式是alloca栈上分配一个位置然后通过load读取、store写入。后面第3章会看到IRBuilder如何自动帮你规避这些文本层的约束但理解SSA是读IR打印结果的基础否则一看到phi就懵。3. 用IRBuilder生成第一个IR模块配置、builder模式与代码落盘3.1 初始化LLVM上下文并创建module/function用C API生成IR第一步是创建LLVMContext和Module。LLVMContext是线程隔离的核心状态对象所有类型、常量、指令都由它管理一个进程里通常只创建一个但多线程编译时需要每个线程持有自己的Context否则并发操作同一组类型的指针会触达底层竞态。#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 ctx; auto mod std::make_uniqueModule(demo, ctx); // 创建一个 i32 main() 函数无参数返回 i32 auto i32Ty Type::getInt32Ty(ctx); auto fnTy FunctionType::get(i32Ty, false); auto mainFn Function::Create(fnTy, Function::ExternalLinkage, main, mod.get()); mod-print(errs(), nullptr); return 0; }FunctionType::get(i32Ty, false)的第一个参数是返回类型第二个参数是参数类型数组这里传false表示不是可变参数函数。Function::Create的第三参数是函数名第四个是函数所属的模块链接类型用ExternalLinkage这样lli和JIT才能通过符号名找到它如果你声明为InternalLinkage链接器会把它当作模块私有符号外部lookup会失败。mod-print(errs(), nullptr)把当前模块打印到标准错误流这是调试IR生成时最基础、也最常用的手段。3.2 IRBuilder与BasicBlock插入点的概念决定指令顺序有了函数还需要给它一个入口基本块并让IRBuilder把指令插到块尾。IRBuilder是生成IR的核心工具它内部维护一个插入点InsertPt每次调用CreateXxx时新指令会被插到插入点之前或之后具体由构造模式决定。默认模式是InsertAtEnd也就是追加到基本块尾部。auto *entry BasicBlock::Create(ctx, entry, mainFn); IRBuilder builder(entry); // 构造常量 42 并插入 ret 指令 Value *retVal ConstantInt::get(i32Ty, 42); builder.CreateRet(retVal); if (verifyModule(*mod, errs())) { errs() verify failed!\n; return 1; } mod-print(errs(), nullptr);BasicBlock::Create(ctx, entry, mainFn)第三个参数把它挂到mainFn上函数的基本块顺序就是它在函数里创建的顺序。IRBuilder builder(entry)把新建的builder插入点设为entry块的末尾此时块里还没有指令插入点自然在块的最开头CreateRet把ret指令放到块末尾同时它也是terminator标记该基本块结束。这里需要注意如果忘了在某个基本块末尾加terminator比如ret或brverifyModule必然报错“Instruction does not dominate all its uses”或“Basic Block does not have a terminator”。这是新手第一次跑IR生成最常见的回收站。3.3 生成带变量的函数alloca/store/load与GEP再进一步生成一个真正做计算的函数。比如希望生成i32 add(i32 %a, i32 %b)先创建带参数的函数类型然后在函数体内用alloca给参数安排位置再用load取出来相加。这种做法虽然看起来绕但它和前端管线里“变量可能被重新赋值”的语义对齐之后交给mem2reg这个pass来提升为SSA值。// 参数列表用数组传入 std::vectorType* paramTys {i32Ty, i32Ty}; auto fnTy2 FunctionType::get(i32Ty, paramTys, false); auto fn2 Function::Create(fnTy2, Function::ExternalLinkage, add, mod.get()); // 给参数起名字方便阅读IR fn2-arg_begin()-setName(a); (fn2-arg_begin())-setName(b); auto *body BasicBlock::Create(ctx, entry, fn2); IRBuilder b(body); AllocaInst *aPtr b.CreateAlloca(i32Ty, nullptr, a.ptr); AllocaInst *bPtr b.CreateAlloca(i32Ty, nullptr, b.ptr); b.CreateStore(fn2-arg_begin(), aPtr); b.CreateStore(fn2-arg_begin(), bPtr); Value *aVal b.CreateLoad(i32Ty, aPtr, a.val); Value *bVal b.CreateLoad(i32Ty, bPtr, b.val); Value *sum b.CreateAdd(aVal, bVal, sum); b.CreateRet(sum);CreateAlloca的第一个参数是分配的元素类型第二个参数是数组元素数量通常传nullptr表示单个元素。CreateStore把参数值写入内存CreateLoad读回来此时IR里出现的是alloca i32和load i32指令。这段代码刻意没做mem2reg提升是为了让生成的IR输出更容易理解如果要真正用于生产管线会在生成完IR后用opt -passesmem2reg把它变成纯SSA形式中间的alloca全部消失值依赖变成phi。4. 从演示走向可用JIT、opt与gcc/msvc前端差异对齐4.1 用ExecutionEngine跑JIT与LLI说再见命令行lli适合测试整段IR但真实演示工程需要在同一个进程里生成IR并立即执行。LLVM官方方案是ORC JIT老工程师更熟悉的是EngineBuilder加ExecutionEngine的MCJIT组合。这里给出一个可编译的ORC用法注意LLVM版本在14到18之间API有差异以下写法以较新的LLJITBuilder为准。#include llvm/ExecutionEngine/Orc/LLJIT.h #include llvm/Support/TargetSelect.h using namespace llvm::orc; int main() { InitializeNativeTarget(); InitializeNativeTargetAsmPrinter(); auto jit LLJITBuilder().create(); if (!jit) { errs() JIT create failed\n; return 1; } // 把当前module移交给JIT if (auto err jit-get()-addIRModule(ThreadSafeModule(std::move(mod), std::make_uniqueLLVMContext()))) { errs() addIRModule failed\n; return 1; } auto sym jit-get()-lookup(add); if (!sym) { errs() lookup failed\n; return 1; } auto addAddr sym-getAddress(); using AddFn int (*)(int, int); auto addFn reinterpret_castAddFn(addAddr); out addFn(3, 5) \n; return 0; }LLJITBuilder().create()返回的是Expectedstd::unique_ptrLLJIT直接赋值再用*jit解引用是错误写法必须先检查错误再取值。addIRModule接受ThreadSafeModule所以代码里要把Module用std::make_unique包一层同时把这个线程上下文移交进去JIT执行阶段不会再碰原模块这点和直接运行时解析IR不同。最后lookup(add)拿到的符号地址通过reinterpret_cast转成函数指针参数和返回类型要和IR函数签名严格一致否则调用约定不匹配轻则返回值错乱重则栈崩溃。这是从演示走向可用时必须跨过的一道门槛LLI帮你做符号查找和调用封装自己用JIT时这些细节全部暴露。4.2 gcc/msvc/clang前端的IR差异语义对齐比文本一致更重要为什么同一个源码用不同前端编译出的IR常对不上因为IR文本只是语义载体不同前端对ABI的处理不同。拿clang编译C和用gcc编译再到LLVM工具链处理即便目标平台一致生成的IR也可能有差异。演示工程里如果要让手写IR和C代码互相调用必须按C ABI对齐而不是对着某一款编译器的输出照抄。典型差异集中在四处返回值扩展规则i8和i16返回值在x86-64上会被caller扩展到i32结构体返回大于16字节的结构体通过内存返回小于等于的按整数寄存器返回wchar_t类型在Linux上是i32在Windows上是i16异常处理Clang有personality函数和landingpad指令手写IR时通常直接绕过这部分别硬接。差异点clang (Linux)MSVC手写IR建议返回i8值i8扩展为i32类似但调用约定有差异用i32返回后由调用端截断结构体返回大于16字节用sret参数类似避免直接返回大结构体wchar_ti32i16跨平台代码传入固定宽度整数内存布局按目标 ABI按 MSVC ABI演示代码全部用显式类型不与ABI隐含内容耦合这个表格看起来很枯燥但它是“让IR生成器具备实用价值”的分水岭。如果你生成的函数只是内部自己调用自己的指令序列不跨模块边界ABI差异可以忽略一旦JIT暴露给C函数调用或者.ll文件被clang链接到C对象里就一定要对齐。4.3 用opt做IR级算子自发现pass组合如何帮你找出可折叠的指令序列标题里的热词“算子自发现”落到这个场景里就是说你想知道手写IR里哪些指令序列是冗余的与其肉眼一行行看不如让优化器自动折叠一遍然后对比折叠前后的IR。opt是LLVM的IR优化工具开发中常用来验证某段IR在不同pass组合下的行为变化。这一节用一个实例说明怎么用它做“自发现”生成一堆中间计算然后跑instcombine看它被压成几条指令。; pre_opt.ll define i32 fold(i32 %x) { %t1 mul i32 %x, 2 %t2 add i32 %t1, %x ret i32 %t2 }opt -passesinstcombine -S pre_opt.ll -o post_opt.ll cat post_opt.llinstcombine会把mul %x, 2变成shl %x, 1再把add折叠成mul %x, 3或对应的移位加组合具体看目标平台。-S表示输出文本IR没有-S则输出二进制bitcode。这里推荐一个更细的观察手段opt -passesinstcombine -print-after-all会把每个pass执行后的IR全量打印出来用opt -print-after-all配合-filter-print-funcsfold只打印目标函数能少看很多噪音。“自发现”的意义在于你不用预先知道哪段指令是冗余的。比如%t1 mul和%t2 add这两句人眼看不出毛病但instcombine过一遍就发现它可以合并成一次乘法。对做编译器后端的人来说这等于拿到了一个工具生成一大段模板IR之后跑一遍pass再对比哪些指令被改写就知道你的IR生成模板里藏了多少多余的中间值。我一般会在CI流程里加一条job专门跑opt -O1 -S并检查输出是否存在phi以外的意外指令防止未来改动生成器时引入了被优化器“打补丁”的错误代码。5. 手写IR生成避坑指南从崩溃到默默算错的关键5条5.1 verifyModule一跑就断言terminator缺失或phi位置错现象C里调用verifyModule(*mod, errs())返回错误输出的第一行是Basic Block does not have a terminator或者PHINode should be first in basic block。原因IRBuilder创建指令没有语义约束允许在基本块末尾留一条add就把函数结束写phi节点时手动插到了块中间某条普通指令之后。解决给每个基本块创建后在块的末尾确保有ret或br指令手动插入phi时要么在BasicBlock::Create后立刻创建要么用PHINode::Create(type, 0, phi, block-begin())把phi插到块首。5.2 JIT跑出来的数字不对DataLayout与目标机器不一致现象IR逻辑正确lli和opt都没报错但用LLJIT执行后返回值差一个偏移量比如期望48实际得到32。原因Module没有设置目标机器的DataLayout默认情况下JIT按编译LLVM时的默认布局做内存地址计算只要代码里出现alloca、GEP这类依赖元素size的指令布局一旦不一致就计算出错。解决生成Module后立刻构造目标机器再回填布局常见做法是module-setDataLayout(TargetMachine::createDataLayout())如果只做演示不涉及跨端至少用mod-setDataLayout(e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64)这类明确字符串兜底不对就不要硬编。5.3 GEP索引层级用错结果错乱还不报错现象加载一个[4 x i32]数组的第2个元素写CreateGEP(arrTy, ptr, {0, 2})得到的是第0个元素往后偏移了8个字实际拿到的是空数据。原因opaque pointer之后GetElementPtrInst的类型推导依赖第一个参数指定的源类型索引数量必须和源类型的嵌套层级对应数组[4 x i32]需要两层索引第一层是“指向数组的指针那一层”第二层才是数组内部的下标多写或少写一个索引都会静默算错地址不会报错。解决先打印IR确认GEP后面的类型序列是否正确代码里显式用ArrayRefValue*把索引包好避免漏参数。5.4 pass一跑结果就变instcombine把你的显式逻辑折叠了现象生成的IR单独验证正常但传入opt -O2或LLJIT执行后结果与预期不符输出的IR里一片phi、select自己的add全没了。原因优化器在语义等价前提下会做激进改写如果你的生成器里存在未被识别的模板逻辑比如把mul %x, 0留在IR里期望后续继续叠加指令优化器在第一次pass就会把它折叠成0后续指令被常量传播一路冲掉。解决需要保留“未优化形态”时用-O0运行想观察折叠行为用opt -passesinstcombine,printloops分级跑并在关键pass之间输出IR对比不要一次加完所有优化参数。5.5 符号名重复导致JIT链接期崩溃现象连续生成两次同名函数第二次addIRModule成功但lookup返回符号地址错误或者JIT进程直接段错误。原因IR里函数名就是全局符号同一个Module里不允许两个外部链接函数同名但不同Module加入同一个LLJIT实例时符号表会冲突。解决生成器里给Module设置一个全局前缀命名空间比如mod-setModuleIdentifier(my_dsl_module)函数名用mydsl_ 用户函数名的方式做mangling或者每次JIT前后重新创建LLJIT实例避免跨Module污染。6. 把演示工程串成流水线从.ll到JIT的一键复现6.1 一个最小工程的目录与构建脚本把上面的思路收束成一个可复现的最小工程目录结构建议定为src/main.cpp放生成器src/generate.cpp放IR构造逻辑cmake里找LLVM的包scripts/run_demo.sh一键执行。CMakeLists这一节直接给你抄的版本cmake_minimum_required(VERSION 3.20) project(llvm_ir_demo CXX) find_package(LLVM REQUIRED CONFIG) message(STATUS LLVM version: ${LLVM_PACKAGE_VERSION}) add_executable(ir_gen src/main.cpp src/generate.cpp) target_include_directories(ir_gen PRIVATE ${LLVM_INCLUDE_DIRS}) target_link_libraries(ir_gen PRIVATE LLVM)find_package(LLVM REQUIRED CONFIG)会找到LLVM的CMake导出文件把LLVM_INCLUDE_DIRS和LLVM库一并带入。编译时注意LLVM_ENABLE_ASSERTIONS默认打开release版的LLVM也自带断言IR生成器里不要用assert去验证IR必须用verifyModule两者触发条件不同。配套的run_demo.sh只做三件事#!/usr/bin/env bash set -euo pipefail ./ir_gen demo.ll lli -O0 demo.ll opt -passesmem2reg -S demo.ll -o demo_opt.ll./ir_gen demo.ll生成IR文本lli -O0解释执行并打印结果opt做一次常规提升。set -euo pipefail保证任何一个环节失败都会中止不会带着坏IR继续往下跑。6.2 三个必跑验证verifyModule、-O0 JIT、mem2reg后对比我自己做这个方向时给自己定过一条铁规矩凡是生成器改了逻辑发布前必须跑三件事。第一verifyModule返回成功第二用-O0跑一遍JIT确认结果符合算术预期注意一定不要用默认优化级别否则跳过了暴露问题的机会第三把生成的IR用mem2reg提升后打印diff如果提升后出现了自己没预期到的phi合并点说明参数的生命周期边界画得不对。这三件事跑完才敢说这段IR生成逻辑是可信的。最后说一个个人习惯每次生成完IR我会第一时间把mod-print(errs(), nullptr)的输出贴到一个临时文件里而不是直接丢给JIT。这一步能省下大量debug时间因为IR文本是你能看到的唯一真相JIT的崩溃日志往往只给你一个函数名和地址回推成本远高于多打印一次。LLVM IR生成的调试本质上是“让IR按预期出现在你面前、能被执行、能被验证”保持良好的打印习惯比任何高级工具都实在。希望这篇笔记帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进