用 clang 生成 LLVM IR:从命令到读懂 SSA 与验证
发布时间:2026/10/2 13:17:43 作者:尧图编辑部 阅读量:1,286

简介面向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-disllvm-as sum.ll -o sum.bc # 文本 - 比特码 llvm-dis sum.bc -o sum.ll # 比特码 - 文本参数说明llvm-as是 LLVM 的汇编器把可读的.ll变成二进制的.bcllvm-dis是反汇编器方向反过来。两者都只验证语法合法性不做优化。生产上常见做法是在 CI 里先生成.bc做产物需要对比时临时用llvm-dis转回文本避免文本文件过大占仓库空间。2.3 用 opt 验证并变换 IR-passes 参数的新旧写法生成 IR 之后的第一件事不是读而是验证它有没有语法错误。这一步用opt工具完成。opt是 LLVM 的 IR 级优化器也承担 IR 校验职责。opt -passesverify -S sum.ll -o /dev/null参数说明-passesverify只跑一个校验 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 -passesmem2reg,instcombine,simplifycfg -S sum.ll -o sum-opt.ll参数说明mem2reg把allocaload/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 到 InstructionIR 的层级和第一行注释打开任何一份.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 时一个基本块末尾没有 terminatoropt -passesverify会立即报错。3.3 看懂控制流switch、br 和 phi 在 IR 里怎么配合条件分支用br已经够用但做编译器演示时经常遇到两类控制流容易让人懵多路分支和汇合点。多路分支对应 C 的switchIR 里有专门的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 函数 IRdefine 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 -passesmem2reg把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 -passesmem2reg把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 -passesverify -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 -passesverify这是检验手写 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 -passespass1,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已被提升为 SSAopt -passesmem2reg -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 大版本升级带来的隐性破坏。希望帮到你。本文还有配套的精品资源点击获取