简介:面向C++开发者与编译器初学者的LLVM IR生成演示项目,聚焦如何用C++ API从零构造中间表示,适用于正在学习编译原理、希望掌握LLVM底层机制的技术人群,通过可运行的示例降低入门门槛。资源共50个文件,以25个cc源文件和16个h头文件为主干,配合txt说明文档、yy语法解析文件以及ll格式IR示例,并包含构建配置与项目说明文件;压缩包整体仅23KB,结构紧凑、模块清晰。项目覆盖LLVM环境初始化、模块与类型定义、函数和基本块创建、算术与控制流指令生成、IR优化及.ll文件输出等完整环节,同时包含AST语法树与解析相关实现,能够直观呈现编译器前端到IR生成的实际衔接,便于对照源码动手实践。已有463人学习下载,适合通过源码实例快速上手LLVM IR、进而开展自定义编译器或工具链开发的进阶读者。
1. LLVM IR 生成演示到底演示什么:从一段 C 代码到可读中间表示
很多第一次接触 LLVM 的人,都是栽在同一个地方:照着教程敲了一下午,发现 clang 确实能编译,但输出的一长串%开头临时变量和教科书上画的控制流图对不上。这个标题llvm-ir-dimostrazione本身就是一个演示性质的项目名,把「生成 LLVM IR」这件事拆成可复现的步骤,让读者看清 clang 在编译过程中间到底做了什么、IR 长什么样、哪些指令是可读的、哪些只是给优化器看的中间产物。它不是讲编译器原理的科普,而是带着你把命令跑通、把文件打开、把每一段 IR 读明白。
这篇笔记适合三类人:做静态分析或程序插桩的工程师,想验证自己写的优化 pass 的编译器学习者,以及需要在 CI 里对 IR 做快照比对的工具链开发者。文章不涉及具体开源包的内部代码,只按「生成 IR → 读懂 IR → 验证 IR」这条主线来讲一个从业者最常见的落地方案。你不需要看过任何源码包,只需要一台装了 LLVM 工具的机器,就能跟完全程。
2. 用 clang 生成 LLVM IR:四条命令和三组参数怎么选
LLVM IR 是 clang 把 C/C++ 源码翻译成后端指令之前的中间表示。生成这一层东西最可靠的工具就是 clang 本身,不用额外装别的。常见的做法是写一个最小的 C 文件,用-emit-llvm让 clang 输出 IR 而不是目标平台的机器码。下面从这个最小流程开始,把命令、参数和输出格式逐一说清楚。
2.1 最小命令:clang -S -emit-llvm 把 C 文件变成可读 IR
先准备一个足够简单、但能看出控制流和运算的 C 文件,这里用累加求和,因为它既能展示循环,又能在优化级别不同时看出 IR 的巨大差异。
// sum.c int sum(int n) { int s = 0; for (int i = 0; i < n; i++) { s += i; } return s; }生成可读 IR 的命令是:
clang -S -emit-llvm -O0 sum.c -o sum.ll这条命令拆开看:-S表示只做预处理、编译和汇编之前的步骤,输出汇编级文本;-emit-llvm把输出格式从目标平台汇编换成 LLVM IR 文本;-O0是关闭优化,保留和源码结构最接近的指令序列;-o sum.ll指定输出文件名。生出来的sum.ll以.ll结尾,内容是纯文本,可以直接用编辑器打开。
跑完会看到一段长得像汇编但不是汇编的内容。开头有ModuleID = 'sum.c',接下来是函数定义define i32 @sum(i32 %n)。i32是 32 位整数类型,@sum是全局符号名,%n是函数的第一个参数。函数体里不会直接看到s += i对应的单条指令,而是先用alloca在栈上给变量分配位置,再用load和store来回搬运。
这里有一个关键认知:-O0下的 IR 是为了调试信息可回溯,故意把变量都放进内存里,让每条 store/load 都能对应回源码行号。这也就是说,你第一次打开sum.ll看到的代码量会是源码的十几倍,这是正常现象,不是 clang 坏了。
2.2 三种输出格式:.ll 文本、.bc 比特码和目标汇编之间的差别
生成 IR 不只是能输出.ll文本这一种形式。不同场景需要不同格式,我这里按使用频率整理一张对照表,方便你按需取用:
| 输出格式 | 命令关键参数 | 文件特征 | 典型使用场景 |
|---|---|---|---|
| 可读 IR 文本 | -S -emit-llvm | .ll,纯文本,可 diff | 阅读、调试、手工修改、入库做快照 |
| IR 比特码 | -emit-llvm -c | .bc,二进制,体积小 | 传给 opt/llc 做后续处理,节省 IO |
| 目标平台汇编 | -S(不带 emit-llvm) | .s,机器汇编 | 确认指令选择结果,查后端问题 |
| 目标平台对象 | -c(不带 emit-llvm) | .o,可链接 | 正常编译产物,和 IR 无直接关系 |
命令分别对应:
clang -S -emit-llvm -O1 sum.c -o sum.ll # 文本 IR clang -emit-llvm -c -O1 sum.c -o sum.bc # 比特码 IR clang -S -O1 sum.c -o sum.s # 目标汇编文本 IR 适合人读,比特码适合机器读。如果要在两个格式之间互转,LLVM 工具链提供了llvm-as和llvm-dis:
llvm-as sum.ll -o sum.bc # 文本 -> 比特码 llvm-dis sum.bc -o sum.ll # 比特码 -> 文本参数说明:llvm-as是 LLVM 的汇编器,把可读的.ll变成二进制的.bc;llvm-dis是反汇编器,方向反过来。两者都只验证语法合法性,不做优化。生产上常见做法是在 CI 里先生成.bc做产物,需要对比时临时用llvm-dis转回文本,避免文本文件过大占仓库空间。
2.3 用 opt 验证并变换 IR:-passes 参数的新旧写法
生成 IR 之后的第一件事不是读,而是验证它有没有语法错误。这一步用opt工具完成。opt是 LLVM 的 IR 级优化器,也承担 IR 校验职责。
opt -passes=verify -S sum.ll -o /dev/null参数说明:-passes=verify只跑一个校验 pass,检查 IR 是否满足 SSA 和基本块终结等约束;-S让输出保持文本格式;-o /dev/null表示我们只关心退出码,不保留输出。如果 IR 有问题,opt 会打印错误位置和原因并返回非零退出码;如果没问题,这条命令静默通过。
-passes=是 LLVM 14 之后新 Pass 管理器的标准写法。如果你在网上搜到老教程里写opt -verify sum.ll或opt -instcombine sum.ll,那是旧 Pass 管理器的参数风格,在较新版本的 LLVM 上会直接报unknown pass name。这也是新手最常见的翻车点之一。
实际想对 IR 做变换时,passes参数可以串联多个 pass,用逗号分隔:
opt -passes=mem2reg,instcombine,simplifycfg -S sum.ll -o sum-opt.ll参数说明:mem2reg把alloca+load/store模式提升成 SSA 寄存器值,是让 IR 从「可调试形态」变为「可优化形态」最关键的一步;instcombine做指令层面的合并简化;simplifycfg清理尾部的无用基本块。跑完再打开sum-opt.ll,你会发现循环里的load/store消失了,变量变成了真正的 SSA 值。这个变换过程正是整个 LLVM IR 生成演示中「从源码到中间表示再到可优化形式」的完整弧线。
3. 读懂 LLVM IR 的四层结构:Module、Function、BasicBlock、Instruction
把 IR 生成出来只是第一步,能读懂它才算真正掌握。LLVM IR 的逻辑结构是严格分层的:一个模块(Module)包含若干全局变量和函数(Function),每个函数由若干基本块(BasicBlock)组成,每个基本块包含一串指令(Instruction)。这四层结构和源码、汇编之间是一一对应的映射,下面逐个展开。
3.1 从 Module 到 Instruction:IR 的层级和第一行注释
打开任何一份.ll文件,第一行通常是; ModuleID = 'xxx.c',分号开头的是注释,这句标明了这个模块来自哪个源文件。接着会看到全局变量和函数声明。以带printf的演示代码为例:
@.str = private unnamed_addr constant [4 x i8] c"%d\0A\00", align 1 declare i32 @printf(ptr noundef, ...) define i32 @main() { %1 = call i32 (ptr, ...) @printf(ptr @.str, i32 42) ret i32 0 }这里必须先解释一个容易混淆的点:@.str是字符串常量,类型是[4 x i8],内容c"%d\0A\00"对应%d、换行符和字符串结束符。private unnamed_addr表示这个全局变量仅当前模块可见,不需要固定地址。declare声明了一个外部函数@printf,它不在本模块内定义,链接时由 libc 提供。
define i32 @main()则是真正的函数定义,参数在括号里,返回类型在define后的i32。函数体内一行call指令调用了@printf,第一个参数是字符串地址,第二个是整数 42。这条 call 的返回值用临时变量%1接收,尽管这里用不上。函数末尾必须有ret i32 0,返回 0 表示进程正常退出。
参数说明里还有ptr这个类型。从 LLVM 15 开始,指针类型统一简化成不透明的ptr,不再区分i32*、i8*,这是为了简化类型系统。所以你在新版 IR 里看到的全是ptr,只有在load/store/getelementptr时才会通过被指向值的类型来推断实际类型。这个变化直接影响了手写 IR 的方式,后面避坑章节会专门说。
3.2 SSA 形式与 alloca:为什么生成的 IR 里全是 load 和 store
上一节说过,-O0生成的 IR 里变量全在栈上。要真正理解这个现象,必须搞懂 SSA(静态单赋值)形式。SSA 要求每个变量只能被赋值一次,编译器才能高效做数据流分析。但 C 源码里的变量天生可以被多次赋值,比如循环里的s += i每次迭代都在改s。
LLVM 的解决方案是:先用alloca在栈上给变量分配内存槽位,之后所有读写都变成对内存的load和store。这样变量就不是「寄存器值」,而是「内存地址」,不违反 SSA 约束。看-O0下sum函数的真实结构:
define i32 @sum(i32 %n) { entry: %s = alloca i32, align 4 %i = alloca i32, align 4 store i32 0, ptr %s, align 4 store i32 0, ptr %i, align 4 br label %for.cond for.cond: %1 = load i32, ptr %i, align 4 %2 = load i32, ptr %n, align 4 %cmp = icmp slt i32 %1, %2 br i1 %cmp, label %for.body, label %for.end for.body: %3 = load i32, ptr %i, align 4 %4 = load i32, ptr %s, align 4 %add = add nsw i32 %4, %3 store i32 %add, ptr %s, align 4 br label %for.inc for.inc: %5 = load i32, ptr %i, align 4 %inc = add nsw i32 %5, 1 store i32 %inc, ptr %i, align 4 br label %for.cond for.end: %6 = load i32, ptr %s, align 4 ret i32 %6 }注意看entry基本块里的alloca:%s和%i分别对应 C 代码里的变量 s 和 i,后面的每次读写都通过load/store操作内存地址。这也是为什么-O0的 IR 代码量膨胀得厉害——一个变量一次赋值在 IR 里要拆成 load、计算、store 三步。
这些load/store之间的基本块跳转由br完成。br label %for.cond是无条件跳转;br i1 %cmp, label %for.body, label %for.end是条件跳转,第一操作数%cmp是 i1 类型的比较结果。整份 IR 里,每个基本块都以跳转或返回指令结尾,这条末尾指令叫终止指令(terminator),它是基本块划分的边界。如果你手写 IR 时一个基本块末尾没有 terminator,opt -passes=verify会立即报错。
3.3 看懂控制流:switch、br 和 phi 在 IR 里怎么配合
条件分支用br已经够用,但做编译器演示时经常遇到两类控制流容易让人懵:多路分支和汇合点。多路分支对应 C 的switch,IR 里有专门的switch指令;汇合点则要引入 SSA 里最重要的phi指令。
先看用switch表示多路分支的典型形态:
switch i32 %x, label %default [ i32 0, label %case0 i32 1, label %case1 ]这段代码表示:%x等于 0 跳case0,等于 1 跳case1,其他值跳default。switch的优点是跳转表比一串icmp + br更高效,也更容易生成高效后端代码。
更值得花时间理解的是phi指令。它解决的是 SSA 下的汇合问题:当两条控制流路径汇聚到同一个基本块,且每条路径对同一个变量赋了不同值时,这个变量在汇合点该取谁的值?看一个经典的 max 函数 IR:
define i32 @max(i32 %a, i32 %b) { entry: %cmp = icmp sgt i32 %a, %b br i1 %cmp, label %then, label %else then: br label %merge else: br label %merge merge: %result = phi i32 [ %a, %then ], [ %b, %else ] ret i32 %result }merge基本块里的phi i32 [ %a, %then ], [ %b, %else ]读法是:如果前一个执行的基本块是then,那么%result取%a的值;如果前一个基本块是else,取%b的值。phi 本身不是一条可执行的运算指令,它只是给优化器看的「数据来源说明」。
实际使用中的坑在于:phi 必须放在基本块的开头,且括号里列出的前驱块必须和基本块的实际前驱一一对应,一个都不能漏,也不能多。一旦 phi 写错,IR 验证不会报语法错误,但结果会运行时错乱,这是最阴险的 IR bug 之一。用opt -passes=mem2reg把alloca提升为 SSA 后,生成的正是大量 phi 指令,这也是你就能确认它是否写规范了的最快路径。
4. LLVM IR 生成避坑:五个最常见翻车点与排查思路
这个章节不写理论,只写我在实际拿 IR 做生成、校验、对比时踩过的坑。每一条都按「现象 → 原因 → 解决」拆开,你在复现时遇到同类问题可以直接对号入座。
4.1 现象:-O0 生成出来的 IR 代码量爆炸,根本没法读懂
第一次用clang -S -emit-llvm -O0生成 IR 的人,几乎都会被几十行的输出吓住。一个三行循环的函数,IR 能拉到三十多行,而且里面全是alloca、load、store,看不到任何「高级」的结构。
原因是-O0优化级别下,clang 刻意让变量以栈内存形式存在,以便调试器能随时查看和修改变量值。这意味着每个源变量在 IR 里都对应一段alloca + load/store序列,代码量自然爆炸。不是 IR 生成坏了,是优化没开。
解决方式是分场景处理:如果你要读的是算法的逻辑结构,用-O1生成,变量会被提升为 SSA 值,控制流也干净很多;如果你要看优化器能做到什么程度,用-O2甚至-O3,循环会被展开或向量化;如果必须用-O0但又想读,可以后接opt -passes=mem2reg把alloca提升掉。三者并不冲突,可以按需都看一眼再决定用哪个版本作为基线。
4.2 现象:手写 IR 时 opt 报 malformed IR,但怎么看都找不出错
手写 IR 做演示是常有的事,最常见的报错是Instruction does not dominate all its uses和BasicBlock ended with a non-terminator instruction,报错信息指向的行号却总是看不太懂。我遇到过最典型的情况是函数末尾漏了ret,或者条件分支写成了br label %block却忘了给另一个分支单独写基本块。
原因一般出在三条:基本块末尾缺少终止指令;phi 节点的前驱列表和实际控制流不一致;或者引用了未定义的临时寄存器名。LLVM 的验证器对这两类错误零容忍,因为它们会直接破坏 SSA 结构和控制流图。
解决方式是别用肉眼硬看,把错误交给工具定位。先跑opt -passes=verify -S file.ll -o /dev/null,它会精确打印出错的指令和基本块名;然后检查每个基本块末尾是不是只有br、switch、ret、unreachable之一;最后检查所有%xx是否在引用之前定义过。这三步能解决九成的手写 IR 校验问题。
4.3 现象:手写 IR 时用了旧版指针写法,opt 直接拒绝处理
如果你按三年前的教程写过 IR,可能见过define i32* @foo(i32* %p)这种写法。但在 LLVM 15 之后,这条代码会直接报类型错误。因为指针类型已统一为不透明指针ptr,不再允许在函数签名和局部变量上写i32*、i8**这类带元素类型的指针。
原因很简单:opaque pointer 是 LLVM 为了简化类型系统和减少冗余类型信息而做的长期演进,最终把所有指针类型收敛成单一的ptr。负载的实际类型由load指令的结果类型和store指令的值类型决定,不再由指针自身携带。
解决方式是写任何新 IR 时直接使用ptr类型,函数参数、返回值、全局变量、alloca的地方统一写ptr。如果某些老工具比如老的llvm-as不认ptr,说明 LLVM 版本太旧,升级到 LLVM 15 及以上即可。写完后仍然跑一遍opt -passes=verify,这是检验手写 IR 是否跟上版本最直接的手段。
4.4 现象:跑 opt 时报 unknown pass name,命令行明明是对的
在网上随便搜一个 pass 教程,复制下来的命令大概率长这样:opt -instcombine -S sum.ll -o sum-opt.ll。在老版本 LLVM 上是能跑的,但换到新版本就报unknown pass name 'instcombine',需要加-passes=。这是因为 LLVM 在 14 版本切换到新 Pass 管理器,旧的命令行风格被废弃了。
原因不复杂:旧 Pass 管理器的 pass 名在命令行直接作为参数传给 opt,而新 Pass 管理器要求所有 pass 统一写进-passes=参数,并且名字本身也做了归并,比如旧名字instcombine在新体系下也是instcombine,但有些 pass 改名了,比如dce在新体系下对应dce,而mem2reg这个名字两代通用。
解决方式是先确认版本再查 pass 名:llvm-config --version查看版本号;新版本统一用opt -passes=pass1,pass2这种逗号分隔形式;如果不确定某个 pass 在新体系下叫什么,用opt -print-passes列出全部可用名字。把这条命令记在笔记里,能节省大量排查时间。
4.5 现象:加了 -g 生成调试版 IR,结果里面全是 llvm.dbg 指令
生成 IR 做演示的时候,很容易顺手带上-g参数保留调试信息。结果打开.ll文件,发现函数体里夹杂了大量call void @llvm.dbg.declare(metadata ...)和call void @llvm.dbg.value(metadata ...)这类 intrinsic 调用,真正的业务逻辑被淹没其中。
原因:-g让 clang 在 IR 里嵌入 DWARF 调试信息,这些llvm.dbg.*是 LLVM 的调试 intrinsic,后端会依据它们生成调试符号表。它们和普通函数调用长得一模一样,但实际不产生任何运行时代码。
解决方式是生成演示用 IR 时关掉调试信息,用-g0显式关闭。如果你确实需要对比带调试和不带调试的 IR 差异,可以在生成后手动 grep 过滤掉 dbg 行,比如grep -v 'llvm.dbg' sum.ll查看干净的指令流。这条经验在写 IR 快照测试时尤其重要,否则每次 CI 对比都会被调试指令的插入位置差异干扰。
5. 进阶技巧:把 IR 生成变成可回归的验证闭环
生成 IR 只是起点。真正让这个方向值得投入的,是把 IR 生成行为固定下来,让每次改动都能被自动验证。我现在的做法是三条链一起用:lli做行为验证,FileCheck做输出断言,最后把样例和 check 文件一起提交进仓库。
先看行为验证。生成一份带main的 IR,用解释器直接跑,确认逻辑是对的:
clang -S -emit-llvm -O1 demo.c -o demo.ll lli demo.lldemo.c里放一个打印sum(10)结果的 main 函数,终端输出 45。这样每次手动改动过 IR,先用lli跑一遍,比只看语法校验多一层行为保障。
再看输出断言。用 FileCheck 可以在 CI 里固定 IR 的结构特征,比如确认所有alloca已被提升为 SSA:
opt -passes=mem2reg -S sum.ll | FileCheck check-sum.txtcheck-sum.txt里写:
CHECK-LABEL: define i32 @sum CHECK-NOT: alloca CHECK: ret i32这段代码的含义是:遇到define i32 @sum后开始检查,确认后续内容里没有alloca指令,并且最终有ret i32返回。如果 mem2reg 没有生效,CHECK-NOT 就会触发失败,CI 直接红掉。这块逻辑能抓住「IR 生成形状变了」这一类最隐蔽的回归。
我个人的习惯是:每次改 IR 生成逻辑或 clang 版本,都顺手把一个最小样例的期望输出和 check 文件一起提交。这比任何文档都有说服力,下次升级 LLVM 工具链时,跑一遍整套 check 就能知道哪些变化是有意的、哪些是意外的。这个习惯已经帮我挡过好几次 LLVM 大版本升级带来的隐性破坏。希望帮到你。
本文还有配套的精品资源,点击获取