LLVM IR 入门实战:从 C 代码到自定义 Pass 的完整链路 简介这份资源是面向编译器初学者与工具链开发者的 LLVM IR 生成演示项目以 C 实现帮助读者理解如何通过 LLVM C API 从零构建中间表示。内容围绕模块、函数、基本块与指令的层级结构展开涵盖环境初始化、类型定义、函数与基本块创建、指令生成、控制流连接、优化及 .ll 文件输出等完整流程适合具备一定 C 基础、希望深入编译器后端与代码生成的学习者。资源包共 50 个文件以 25 个 cc 源文件与 16 个 h 头文件为主体另含 5 个 txt 说明、1 个 ll 示例、1 个 yy 词法文件及 md 文档压缩包约 23KB体量轻便。项目按 ast、parsing、codegen 等模块组织并配有 CMake 构建脚本便于对照源码理解语法分析、语义分析与 IR 生成的衔接。目前已有 463 人学习可作为编译器课程实验或自研工具链的参考范例。1. 从一行 C 代码到 LLVM IR为什么值得亲手跑一遍很多人第一次接触 LLVM IR是在调试编译器后端或者读某个优化 Pass 的源码时。屏幕上跳出一段%1 add nsw i32 %a, %b之类的东西看着像汇编又不像汇编想改又不敢改。其实 LLVM IR 就是编译器前端和后端之间的“中间语言合同”前端Clang、Rustc、Swift 前端等负责把源码翻译成它后端负责把它翻译成目标机器的机器码。你只要能把一份最小源码稳定地变成 IR再读懂 IR 里的类型、SSA 值、基本块和元数据后面做自定义 Pass、做静态分析、做代码插桩路就通了。这篇笔记围绕一个最小可复现的演示工程展开用 Clang 把 C 代码降成 LLVM IR用opt观察优化前后的差异再手写一段 IR 用lli直接执行。适合两类人一是刚学编译原理、想看到“真实编译器长什么样”的学生和转行者二是需要在 CI 里做 IR 级别检查、或者想给自家 DSL 接一个 LLVM 后端的工程师。全程不需要你懂后端指令选择只要会写几行 C、会敲命令行就行。2. 环境准备与最小 IR 生成链路从 clang -S -emit-llvm 开始2.1 工具链选型为什么用 clang opt lli 三件套做 LLVM IR 演示工具链的选择直接决定你后面能不能复现。常见做法是装一个发行版自带的 LLVM 包或者用官方预编译包。我一般会确认三件事clang --version能打印出 LLVM 版本号opt --version和lli --version存在且版本一致。版本不一致是新手最容易翻车的地方——用 17 生成的 IR 丢给 14 的opt报的错往往和语法无关而是属性或 intrinsic 不认识。选 Clang 而不是其他前端是因为它默认就带-emit-llvm一条命令直达 IR不需要你配置任何后端。opt负责跑优化 Passlli负责直接解释执行 IR这三个工具覆盖了“生成—变换—执行”的完整闭环足够撑起一个演示工程。如果你在 Linux 上包管理器里通常叫clang、llvm在 macOS 上装了 Xcode 命令行工具后clang自带但opt和lli可能需要额外装完整 LLVM。Windows 上建议用官方预编译包把bin目录加进 PATH。提示先跑clang -print-targets确认目标架构列表非空能排除掉大部分“装了个假 LLVM”的情况。2.2 把一份 C 文件降成可读 IR 的最小命令先准备一份最小源码我习惯叫它demo.c故意包含一个加法、一个分支和一个循环方便后面观察 SSA 和基本块// demo.c最小演示覆盖算术、分支、循环三类结构 int add(int a, int b) { return a b; // 最简单的算术观察 SSA 值 } int max_of(int a, int b) { if (a b) { // 分支观察基本块和 br 指令 return a; } return b; } int sum_to(int n) { int s 0; for (int i 0; i n; i) { // 循环观察 phi 节点 s i; } return s; }生成 IR 的命令是# -S 输出文本形式-emit-llvm 走 LLVM 路径-O0 关闭优化便于阅读 clang -S -emit-llvm -O0 demo.c -o demo_O0.ll # 再生成一份开了 O2 的用于对比 clang -S -emit-llvm -O2 demo.c -o demo_O2.ll参数说明-S表示输出汇编/IR 文本而不是目标文件-emit-llvm是关键告诉 Clang 输出 LLVM IR 而不是机器汇编-O0和-O2控制优化级别直接影响 IR 里有没有alloca、有没有被内联、循环有没有被展开。-o指定输出文件名不加的话会输出到标准输出长文件不好读。打开demo_O0.ll你会看到define i32 add(i32 %0, i32 %1)这样的函数定义函数体里是%3 add nsw i32 %0, %1。这里的%0、%1、%3就是 SSA 值每个值只被赋值一次。nsw是 “no signed wrap”表示这个加法假定不会溢出溢出就是未定义行为——这是 Clang 根据 C 语义加上的不是 LLVM 强制要求。理解这一点很重要因为你自己手写 IR 时如果乱加nsw优化器会基于“不会溢出”做激进变换结果可能和你想的不一样。2.3 用 opt 观察优化前后 IR 的差异有了两份 IR最直观的对比方式是用opt单独跑某个 Pass看它到底改了什么。比如循环相关的loop-unroll# 在 O0 的 IR 上单独跑循环展开输出到新文件 opt -passesloop-unroll -S demo_O0.ll -o demo_unroll.ll # 用 diff 看差异重点看 sum_to 函数 diff demo_O0.ll demo_unroll.ll-passes是较新版本 LLVM 的写法旧版本用-loop-unroll这种单 Pass 开关。如果你敲-passes报错说明版本偏老换成opt -loop-unroll -S demo_O0.ll -o demo_unroll.ll即可。跑完 diff你会看到sum_to里的循环被展开成若干次加法phi节点数量变化基本块结构也变了。这里有个血泪经验不要一上来就在 O2 的 IR 上跑单 Pass。O2 已经把能做的都做了你再跑loop-unroll往往看不到变化会误以为 Pass 没生效。正确做法是在 O0 或 O1 的 IR 上做单 Pass 实验变量少、差异明显。另外opt默认会做验证如果 IR 本身不合法比如 SSA 值用了没定义它会直接报错并指出行号这个报错信息比后端崩掉时的信息友好得多值得养成先跑opt -passesverify的习惯。3. 读懂 IR 的四个核心概念类型、SSA、基本块、元数据3.1 类型系统i32、指针和数组在 IR 里长什么样LLVM IR 是强类型的每个值都有明确类型。整数是iN比如i1、i8、i32、i64浮点是float、double指针在较新版本里写成ptr旧版本写成i32*这种带类型的指针。数组是[N x T]结构体是{T1, T2, ...}。函数类型写成i32 (i32, i32)表示接收两个 i32 返回一个 i32。看demo_O0.ll里max_of的 IR你会发现局部变量a、b在 O0 下通常先被alloca到栈上再用load/store读写。这是 Clang 在 O0 下的典型做法方便调试但 IR 看起来很啰嗦。到了 O2mem2regPass 会把这些alloca提升成 SSA 值IR 立刻清爽。理解这个差异你就明白为什么“O0 的 IR 不能直接拿来分析数据流”——栈上的值需要额外的内存分析才能确定。指针类型的变化是版本迁移里最容易踩的坑。LLVM 15 之后逐步推行 opaque pointeri32*变成ptrgetelementptr的写法也跟着变。如果你从网上抄了一段老 IR在新版本opt里跑报错大概率出在指针类型上。解决办法是看opt --version的主版本号15 及以上按ptr写14 及以下按带类型指针写。3.2 SSA 与 phi 节点循环里的值到底怎么合并SSA 的核心约束是每个值只被赋值一次。那循环里s i这种反复修改的变量怎么办答案是phi节点。看sum_to在 O2 下的 IR循环头部会有一个phi它根据来自不同前驱基本块的值选择当前值; sum_to 循环核心片段示意实际行号以你生成的文件为准 loop: %s.0 phi i32 [ 0, %entry ], [ %s.next, %loop ] %i.0 phi i32 [ 0, %entry ], [ %i.next, %loop ] %s.next add nsw i32 %s.0, %i.0 %i.next add nsw i32 %i.0, 1 %cond icmp sle i32 %i.next, %n br i1 %cond, label %loop, label %exitphi i32 [ 0, %entry ], [ %s.next, %loop ]的意思是如果从%entry进来取 0如果从%loop回来取上一轮的%s.next。这就是 SSA 里表达“变量随迭代变化”的标准方式。新手看 phi 容易晕诀窍是把它当成“在基本块入口处根据来路选值”而不是“赋值”。写自定义 Pass 时插入 phi 节点是最容易出错的操作之一因为必须保证每个前驱基本块都有对应的 incoming value少一个opt就会报验证失败。3.3 基本块与控制流br、switch 和不可达代码基本块是一段顺序执行、末尾必须有一条终结指令br、switch、ret、unreachable等的指令序列。br分两种br label %x是无条件跳转br i1 %cond, label %a, label %b是条件跳转。switch用于多路分支Clang 在编译switch语句时经常生成它。控制流图CFG就是基本块加边。你可以用opt -passesdot-cfg生成 dot 文件再用 Graphviz 渲染成图看。这个技巧在排查“为什么某个 Pass 没生效”时特别有用因为很多 Pass 的行为依赖 CFG 形状比如循环必须能被识别成自然循环loop-unroll才会动手。如果 CFG 里有不可达基本块某些分析会直接跳过导致你以为 Pass 坏了其实是输入不满足前提。注意手写 IR 时每个基本块最后必须有终结指令否则opt验证会报 “block does not end with a terminator”。这是最高频的手写错误没有之一。3.4 元数据与属性nsw、nuw、align 到底影响什么IR 里的nsw、nuw、align、!dbg这些不是装饰它们直接影响优化器的决策。nsw表示有符号加法不溢出nuw表示无符号不溢出优化器可以据此把add提升成更激进的变换。align告诉优化器指针对齐到多少字节影响向量化和内存访问合并。!dbg是调试信息-g编译时才有去掉它 IR 会小很多但调试时就没法映射回源码行。我一般建议做 IR 分析时如果不需要调试信息编译时不要加-gIR 体积能小一半以上读起来也清爽。但如果你要做源码级插桩!dbg必须保留否则插进去的指令没法关联到源码位置。另外自己手写 IR 做实验时不要随便加nsw/nuw除非你确定语义上真的不会溢出——加了之后优化器可能把你的测试用例优化成常量你就看不到想观察的指令了。4. 手写并执行一段 IRlli 跑通与常见报错定位4.1 从零写一个能跑的 hello IR光看 Clang 生成的 IR 还不够自己写一段才能理解每条指令的约束。下面这段 IR 定义了一个main调用printf打印一个整数; hello.ll手写 IR演示函数声明、调用和字符串常量 fmt private constant [4 x i8] c%d\0A\00 ; 格式串 %d\n declare i32 printf(ptr, ...) ; 声明外部函数 define i32 main() { entry: %r call i32 printf(ptr fmt, i32 42) ; 调用 printf传 42 ret i32 0 }执行命令# lli 直接解释执行 IR不需要编译成机器码 lli hello.ll # 预期输出42逻辑说明fmt是全局常量类型是[4 x i8]内容是%d\n\0四个字节。declare只声明不定义告诉 LLVM 有个外部printf。call指令的第一个参数是函数指针后面是实参。ptr fmt把全局数组的地址传进去printf按 C 约定读取。ret i32 0是main的返回值。参数说明private表示这个全局只在本模块可见constant表示不可修改。如果你的 LLVM 版本低于 15ptr要写成i8*declare也要相应改成declare i32 printf(i8*, ...)。这是版本差异不是语法错误。4.2 用 opt 验证 IR 合法性并定位报错手写 IR 十有八九第一次跑不过。最有效的排查手段是先跑验证# 只做验证不跑任何优化 opt -passesverify -S hello.ll -o /dev/null如果 IR 有问题opt会打印类似error: use of undefined value %x或error: expected instruction opcode的信息并给出行号。常见报错分三类一是 SSA 值未定义就使用检查%名字拼写和定义顺序二是基本块缺终结指令检查每个 label 末尾三是类型不匹配比如add i32却传了个i64。定位到行号后对照 IR 语法逐条改比盲目猜快得多。另一个技巧是用llvm-as把文本 IR 转成二进制 bitcode再llvm-dis转回来。这一去一回能暴露很多隐藏问题因为llvm-as的解析比opt更严格llvm-as hello.ll -o hello.bc # 文本转 bitcode llvm-dis hello.bc -o hello2.ll # bitcode 转回文本 diff hello.ll hello2.ll # 看规范化后的差异4.3 用 lli 执行带参数的 IR 并观察行为lli支持传命令行参数这对测试带argc/argv的 IR 很有用# 假设 IR 里的 main 接收 argc/argv lli prog.ll arg1 arg2lli默认用 JIT 解释执行启动快适合小段 IR 验证。如果 IR 里用了某些 intrinsic 或平台相关特性lli可能报 “unsupported intrinsic”。这时可以改用llc编译成目标文件再链接llc prog.ll -o prog.s # 生成汇编 clang prog.s -o prog # 链接成可执行文件 ./progllc走的是完整后端支持的特性比lli多但编译慢。我的习惯是逻辑验证用lli涉及 intrinsic 或需要真实运行环境时用llcclang。两条路都走一遍能帮你分清“IR 本身有问题”和“执行引擎不支持”这两类完全不同的故障。5. 避坑与排查IR 演示工程里最容易翻车的五件事5.1 版本不一致导致 IR 语法报错现象从文档或旧项目里抄来的 IRopt报error: expected type或error: ptr type is not supported。原因LLVM 15 前后指针语法从i32*变成ptrgetelementptr的参数形式也变了。解决先opt --version看主版本号15 及以上统一用ptr14 及以下用带类型指针。如果必须跨版本用llvm-as/llvm-dis做一次规范化转换但注意不是所有语法都能自动降级。5.2 O0 的 IR 里全是 alloca误以为优化没生效现象在 O0 的 IR 上跑mem2reg发现alloca没被全部消除。原因mem2reg只处理“可提升”的alloca如果变量地址被取过比如传给了函数它就不会动。解决确认alloca是否只被load/store直接使用没有被getelementptr或函数调用取地址。如果确实需要提升先跑opt -passesmem2reg看剩余alloca再决定是否手动改写 IR。5.3 phi 节点 incoming value 数量对不上现象opt报error: PHINode should have one entry for each predecessor。原因某个前驱基本块没有对应的 incoming value或者多写了一个。解决用opt -passesdot-cfg画出 CFG数清楚每个基本块的前驱数量逐个核对 phi 的[value, block]对。手写 IR 时建议先写基本块和br最后再补 phi避免边写边改导致遗漏。5.4 lli 报 unsupported intrinsic 但 IR 看起来没问题现象lli执行时报unsupported intrinsic但opt -passesverify通过。原因lli的解释器不支持某些 intrinsic尤其是平台相关的或较新的。解决改用llc编译成目标文件再链接执行。如果llc也报错说明 intrinsic 本身用错了检查参数类型和数量。另一个办法是查 LLVM 文档确认该 intrinsic 的最低版本要求。5.5 优化级别不同导致测试用例行为不一致现象同一份 IRO0 下跑出预期结果O2 下结果变了。原因IR 里加了nsw/nuw或undef优化器基于这些假设做了变换而你的测试用例恰好触发了溢出或未定义行为。解决检查所有算术指令的属性去掉不必要的nsw/nuw检查有没有undef值被使用。做 IR 实验时建议固定优化级别并在测试用例里显式覆盖边界值避免“优化器帮你做了决定”。6. 进阶技巧用 Pass 插件在 IR 上做自定义检查走到这里你已经能生成、阅读、手写、执行 IR 了。下一步最有价值的进阶方向是写一个自己的 Pass在 IR 上做自动化检查。比如你想统计每个函数里add指令的数量或者找出所有可能溢出的nsw加法。LLVM 的新 Pass 管理器New PM支持插件式加载不需要重新编译整个 LLVM。先写一个最小 Pass 插件统计函数里的add指令数// AddCounter.cpp统计每个函数里 add 指令的数量 #include llvm/IR/PassManager.h #include llvm/IR/Function.h #include llvm/IR/Instructions.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Support/raw_ostream.h using namespace llvm; struct AddCounter : public PassInfoMixinAddCounter { PreservedAnalyses run(Function F, FunctionAnalysisManager ) { unsigned count 0; for (BasicBlock BB : F) { for (Instruction I : BB) { if (isaBinaryOperator(I) I.getOpcode() Instruction::Add) { count; // 只统计 add不统计 fadd } } } errs() Function F.getName() has count add(s)\n; return PreservedAnalyses::all(); // 没改 IR保留所有分析结果 } }; // 注册插件入口New PM 通过这个符号加载 extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, AddCounter, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name add-counter) { FPM.addPass(AddCounter()); return true; } return false; }); }}; }编译成动态库并加载# 编译插件路径按你本机 LLVM 安装位置调整 clang -shared -fPIC -o AddCounter.so AddCounter.cpp \ $(llvm-config --cxxflags --ldflags --libs core passes) # 用 opt 加载插件并跑自定义 Pass opt -load-pass-plugin./AddCounter.so -passesadd-counter -S demo_O0.ll -o /dev/null参数说明-load-pass-plugin指定动态库路径-passesadd-counter触发注册的回调。llvm-config负责吐出编译所需的头文件路径和链接库不同发行版可能叫llvm-config-17这种带版本后缀的名字。如果链接时报未定义符号多半是llvm-config --libs给的库不全补上--system-libs再试。这个 Pass 本身很简单但它打开了一扇门你可以把任何 IR 级别的检查逻辑塞进去在 CI 里对每次提交的 IR 跑一遍自动拦截“新增了未预期的nsw加法”或“某个函数膨胀超过阈值”。我自己的习惯是任何涉及 IR 变换的改动都先写一个只读的检查 Pass 跑通确认能正确识别目标模式再动手写变换逻辑。这样能把“分析错了”和“变换写错了”两类问题分开排查成本低很多。最后说一个我踩过的坑Pass 插件和 LLVM 主版本的 ABI 必须严格匹配用 17 的头文件编译的插件加载到 16 的opt里轻则报符号找不到重则直接段错误。所以插件工程最好和你的 LLVM 安装用同一套llvm-config别混用系统包和手动编译的版本。希望帮到你。本文还有配套的精品资源点击获取