简介LLVM IR生成演示项目面向C开发者与编译器初学者聚焦如何通过LLVM的C API从零构建中间表示。资源完整展示了从词法/语法分析、AST构建到基本块创建、指令生成、控制流连接与IR输出的全流程帮助读者理解LLVM IR的SSA形式及模块-函数-基本块-指令层级关系适合希望深入底层编译原理或尝试自研小型编译器的读者。压缩包共50个文件以cc和h源码为主覆盖代码生成、解析、AST等模块另含ll与yy格式的语法/IR示例、txt说明和md文档整体仅23KB体积小巧但结构清晰。已有463人学习浏览。读者可对照CMake构建配置亲手编译运行示例观察AST节点如何映射到LLVM模块、函数与基本块并尝试扩展指令类型这份紧凑的工程既适合课堂演示也能作为编译器工具链开发的出发模板。1. 项目到底在演示什么1.1 项目定位与使用场景第一次看到llvm-ir-dimostrazione这个名字时我愣了一下dimostrazione是意大利语里的“演示”连起来就是“LLVM IR 生成演示”。它并不是某个大型编译器项目而是一个定位很明确的教学型示例用尽可能少的代码把 LLVM IR 的生成过程完整展示出来。无论你是刚接触编译原理的学生还是正在给自定义语言写前端的开发者都可以从这个小项目里找到“IR 到底是怎么来的”这个问题的答案。这个项目解决的核心痛点是很多人学 LLVM 时都会撞上的一堵墙文档很厚API 很多但不知道从哪里下手。llvm-ir-dimostrazione把“源码到 IR”这条链路拆成最小可运行的片段让人先亲眼看到 IR 长什么样再理解为什么要这样设计。我强烈建议把它当作你进入 LLVM 世界的第一个动手实验而不是一上来就去啃官方 Kaleidoscope 教程。1.2 为什么新手学编译器要从 IR 开始很多初学者容易陷入一个误区学编译器就必须先写词法分析、语法分析然后直奔目标机器码。实际上现代编译器的核心设计是分层隔离的前端负责生成中间表示中端负责优化后端负责生成目标指令。LLVM IR 就是这个分层结构的枢纽。举个例子同样一段a b在 x86、ARM、RISC-V 上的汇编完全不同但在 LLVM IR 里就是一条add i32 %a, %b和架构无关。这意味着你只要掌握 IR就能理解所有后端共同面对的东西。llvm-ir-dimostrazione展示的正是这一层它不关心最终跑在什么 CPU 上而是聚焦在“如何把一段逻辑表达成 IR”这也是编译器领域最值得优先投入精力的模块。2. LLVM IR 的核心概念与生成路线2.1 三种表示形式必须分清楚LLVM IR 有三种形态我见过不少人在这一块栽跟头因为它们在磁盘上的后缀名不一样使用场景也完全不同。形态存储格式典型后缀适用场景文本形式可读的汇编风格字符串.ll调试、教学、手动编写 IR 示例二进制位码紧凑的序列化格式.bc编译产物、链接中间文件、跨版本传递内存表示C 对象结构无编译进程内操作、Pass 分析优化理解这三种形式不仅是为了看懂llvm-ir-dimostrazione的产物更是在实际工程中排查问题的基础。比如你从 Clang 得到一个.bc文件想查看里面的内容最直接的做法是用llvm-dis转回.ll文本而如果你的工具链报错说“bitcode 版本不兼容”多半是 LLVM 主版本号对不上需要升级或重新编译。2.2 手动编写 IR 与 Builder API怎么选生成 LLVM IR 有两条主路线直接手写.ll文本或者通过 LLVM 的 C API 用IRBuilder在内存中构建。llvm-ir-dimostrazione最有价值的一点是它把这两条路都打通了。手写.ll文件最大的优点是可读性极强每个指令写出来是什么样最终就是什么样非常适合理解 IR 的语法和语义。你可以把它当成“编译器领域的汇编语言”来学能看到操作数的类型、基本块的跳转关系、函数的调用约定。缺点也很明显一旦逻辑复杂手写 IR 的维护成本会爆炸而且很容易出现类型不匹配、缺少终结指令这类低级错误。用 C API 则是工程实践里的主流选择。IRBuilder封装了大量细节比如自动插入指令、处理类型、生成常量等你不需要关心每一条指令的语法只需要告诉 Builder 你想做什么。但它的学习曲线比较陡因为很多 API 的行为很隐晦一旦出错错误信息往往不在你预期的地方。我的建议是先用.ll文件把 IR 的形态摸清楚再切换到 Builder API这样即使 API 调用报错你也能大概猜出它生成的 IR 哪里出了问题。3. 从零生成一段可运行的 LLVM IR3.1 环境准备与工具链检查在开始跑llvm-ir-dimostrazione之前你需要一个可用的 LLVM 工具链。这里说的不是完整的源码编译只要拿到官方发布的预编译二进制就行。Windows 上可以直接从 LLVM 官网下载安装包或者用winget install LLVM.LLVMmacOS 上brew install llvm最省事Linux 各发行版也都有对应的llvm包。一个常见的坑是装完 LLVM 之后终端里llvm-as、lli这些命令找不到。这不是没装好而是可执行文件目录没加入 PATH。macOS 通过 Homebrew 安装时llvm 往往是 keg-only 的需要手动把/opt/homebrew/opt/llvm/bin加进环境变量。我习惯先跑一句llvm-as --version确认版本再继续后续操作。注意LLVM 的小版本升级通常不影响.ll基本语法但大版本之间偶尔会有指令集或语法调整如果你照着旧教程写出来的 IR 在当前版本报错优先检查版本差异而不是怀疑自己写错了。3.2 手写一个加法函数的 .ll 文件llvm-ir-dimostrazione的核心演示我猜是一个极简的加法函数因为这是最小但足以覆盖 IR 关键要素的例子。我们先手动创建一个add.ll; 模块声明module asm 可以理解为源文件名标识 source_filename add.ll ; 定义一个 i32 类型的函数 add两个参数都是 i32 define i32 add(i32 %a, i32 %b) { entry: %sum add i32 %a, %b ret i32 %sum }逐行拆开看define i32 add(i32 %a, i32 %b)声明了一个名为add的函数返回i32参数是%a和%b。entry是基本块的标签基本块是 IR 中控制流的基本单位它必须以终结指令结束这里的终结指令是ret。add i32 %a, %b是一条二元运算指令操作数类型和结果类型都是i32。这个例子虽然简单但已经把“模块、函数、基本块、指令”这四个层级都覆盖到了。拿到一份陌生的.ll文件时我建议也是按这个顺序去读先看有哪些全局声明再找define函数然后逐个分析基本块和指令。3.3 用工具链验证并运行写好add.ll之后第一件事是用llvm-as编译成 bitcodellvm-as add.ll -o add.bc如果没有任何输出说明语法检查通过。如果报了错误多半是类型写错或者缺少终结指令。接下来可以用lli直接解释执行不过我们的add没有入口运行会报 “target does not support main” 之类的错误所以更好的验证方式是用llc把它编译成当前平台的汇编llc add.ll -o add.s打开add.s你会发现针对不同 CPU 平台生成的汇编指令完全不同。这就是 LLVM IR “一次编写多处生成”的魅力所在。想更直观看到结果可以写一个带main的版本define i32 main() { entry: %result call i32 add(i32 2, i32 3) ret i32 %result }再跑lli add_with_main.ll echo $?如果终端输出 5说明整个 IR 从生成到执行的链路是通的。这个实验价值很大它帮你建立了“IR 文本 - bitcode - 本地汇编 - 运行结果”的完整直觉。3.4 用 C API 动态生成 IR理解了手写.ll之后就可以看llvm-ir-dimostrazione中更接近工程实践的部分用 LLVM 的 C API 生成同样的函数。核心代码大致如下#include llvm/IR/IRBuilder.h #include llvm/IR/LLVMContext.h #include llvm/IR/Module.h #include llvm/Support/raw_ostream.h using namespace llvm; int main() { LLVMContext context; Module *module new Module(add_module, context); // 构造函数类型返回 i32两个参数都是 i32 FunctionType *funcType FunctionType::get( Type::getInt32Ty(context), {Type::getInt32Ty(context), Type::getInt32Ty(context)}, false ); // 创建函数 Function *addFunc Function::Create( funcType, Function::ExternalLinkage, add, module ); // 创建基本块并用 IRBuilder 生成指令 BasicBlock *entry BasicBlock::Create(context, entry, addFunc); IRBuilder builder(entry); auto args addFunc-arg_begin(); Value *a args; Value *b args; a-setName(a); b-setName(b); Value *sum builder.CreateAdd(a, b, sum); builder.CreateRet(sum); // 打印生成的 IR 文本 module-print(outs(), nullptr); delete module; return 0; }这段代码和手写.ll是一一对应的Module对应模块Function对应函数BasicBlock对应基本块IRBuilder则替你处理了指令的插入语法。跑完这段程序控制台输出的 IR 和我前面手写的add.ll几乎一致。提示module-print(outs(), nullptr)是调试 API 生成结果时最常用的手段。任何时候不确定 API 行为就可以把当前模块打印出来看看这比空想有效得多。4. 实际操作中遇到的典型问题与排查技巧4.1 类型不匹配LLVM 的“类型洁癖”LLVM IR 是一个强类型系统所有的指令操作数类型必须完全一致否则 verify 阶段就会报错。常见错误是add i32 %a, %b中%a其实是个i64或者把两个指针类型直接相加。这在用 C API 时更容易发生因为你传入的Value*类型往往来自不同的构造路径。排查思路很简单先用Value::getType()-dump()或者打印模块查看每个值的类型。如果类型确实不同可以用builder.CreateIntCast、builder.CreatePtrToInt等转换指令先统一类型。在写手写.ll时我是靠“每一条指令都自己标注类型”来约束自己虽然啰嗦但能提前发现问题。4.2 基本块缺少终结指令基本块必须以ret、br、switch等终结指令结尾这是 LLVM IR 一个非常严格的约束。我见过不少新手在创建空基本块后忘记插入返回指令结果verifyModule直接报 “Basic Block does not end with a Terminator”。这个问题用 C API 编写时特别容易漏因为IRBuilder不会自动帮你补终结指令。一个非常实用的调试习惯是在生成完函数后调用verifyFunction(*func)或verifyModule(*module)它会把这类结构性问题提前暴露出来。如果报错说缺少终结指令回到代码里检查最后一个CreateRet或CreateBr是否真的被插入到了正确的基本块。4.3 用 opt 和 llvm-dis 观察优化对 IR 的影响学习和调试 LLVM IR 时我认为最有价值的工具不是调试器而是opt。给同一个.ll文件跑不同的 pass你会看到 IR 如何一步步变化。比如opt -passesinstcombine add.ll -S -o add_opt.llinstcombine会做很多模式匹配和常量折叠。我之前写过一个复杂的add表达式优化后直接被折叠成一条ret i32 5那一刻我才真正理解了“IR 是优化载体”是什么意思。-S参数表示输出文本形式如果你拿到的是.bc先用llvm-dis转成.ll再丢给opt这样整个过程都是可读的。常见报错可能原因处理方式Invalid operand type操作数类型不匹配使用CreateIntCast转换操作数Basic Block does not end with Terminator基本块缺少ret/br终结指令在代码中显式插入CreateRet/CreateBrExpected instruction opcode指令操作数不是Instruction类型检查是否是Constant或ArgumentSymbol not found函数名或全局变量名声明了但未定义确认声明与定义之间的链接类型一致4.4 Builder 插入位置与作用域的理解IRBuilder有一个“当前插入点”的概念它决定了新指令会被插到哪个基本块的哪个位置。在llvm-ir-dimostrazione这类演示项目里代码从头到尾都在一个基本块中执行所以插入位置不会出问题。但一旦你开始写循环或条件分支每个基本块都需要自己的 Builder或者用builder.SetInsertPoint(bb)来回切换。我遇到过最隐蔽的问题是在一个已经插入过指令的基本块里重复调用SetInsertPoint导致后续指令被插到了中间产生了不可预期的依赖顺序。解决办法很简单在生成每个基本块时都维护一个局部IRBuilder对象不要复用同一个 Builder 跨越多个基本块。这个习惯可以帮你省下大量排错时间。5. 我在反复实践后的几点经验5.1 版本差异是第一大坑LLVM 的 API 迭代速度非常快尤其是 pass 框架和 IRBuilder 的一些接口不同版本之间可能差别很大。以前写llvm-ir-dimostrazione时我用的还是旧版llvm::make_unique升级之后全部要改成std::make_unique。如果你查找资料时发现代码和本机版本对不上优先参考官方文档的 release notes而不是强行编译旧代码。版本差异还体现在命令行参数上比如老版本的opt用-mem2reg新版本用-passesmem2reg。我的建议是写任何脚本之前先跑llvm-as --version和opt --help确认当前环境的语法避免浪费时间。5.2 从小函数起步逐步扩展如果想真正吃透 IR 生成不要一上来就写复杂语言的前端。llvm-ir-dimostrazione里的加法函数虽然是玩具但它已经包含了模块、函数、基本块、指令、返回操作这几个最核心的元素。你可以在它的基础上按这个顺序扩展先加一个sub函数然后加一个调用函数接着试试 if-else用icmpbr指令最后再碰循环。每扩展一个功能就用opt -passesverify检查一遍再打印 IR 看看。这样每次只引入一个新概念问题定位会非常快。我经常跟朋友说学 LLVM IR 不是看书看会的是一遍遍跑lli和opt跑会的。5.3 最后分享一个排查小技巧调试 IR 生成时module-print和llvm-dis是两大法宝。但还有一个更细的技巧在 C API 里你可以临时用errs() *value;打印任意Value*的内容这会输出这个值的名字、类型和所在上下文。通过这种“埋点打印”你能很清楚地看到某个参数在进入指令之前到底变成了什么而不是靠猜测。这个小习惯几乎帮我解决过所有“IR 生成结果不符合预期”的问题。本文还有配套的精品资源点击获取