☰
LLVM IR 入门实战:从 C 代码到自定义 Pass 的完整链路
2026/10/13 4:41:58 网站建设 项目流程

简介:这份资源是面向编译器初学者与工具链开发者的 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负责跑优化 Pass,lli负责直接解释执行 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 -passes='loop-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 -passes='verify'的习惯。

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 看起来很啰嗦。到了 O2,mem2regPass 会把这些alloca提升成 SSA 值,IR 立刻清爽。理解这个差异,你就明白为什么“O0 的 IR 不能直接拿来分析数据流”——栈上的值需要额外的内存分析才能确定。

指针类型的变化是版本迁移里最容易踩的坑。LLVM 15 之后逐步推行 opaque pointer,i32*变成ptr,getelementptr的写法也跟着变。如果你从网上抄了一段老 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 %exit

phi 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 -passes='dot-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 分析时,如果不需要调试信息,编译时不要加-g,IR 体积能小一半以上,读起来也清爽。但如果你要做源码级插桩,!dbg必须保留,否则插进去的指令没法关联到源码位置。另外,自己手写 IR 做实验时,不要随便加nsw/nuw,除非你确定语义上真的不会溢出——加了之后优化器可能把你的测试用例优化成常量,你就看不到想观察的指令了。

4. 手写并执行一段 IR:lli 跑通与常见报错定位

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 版本低于 15,ptr要写成i8*,declare也要相应改成declare i32 @printf(i8*, ...)。这是版本差异,不是语法错误。

4.2 用 opt 验证 IR 合法性并定位报错

手写 IR 十有八九第一次跑不过。最有效的排查手段是先跑验证:

# 只做验证,不跑任何优化 opt -passes='verify' -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 arg2

lli默认用 JIT 解释执行,启动快,适合小段 IR 验证。如果 IR 里用了某些 intrinsic 或平台相关特性,lli可能报 “unsupported intrinsic”。这时可以改用llc编译成目标文件再链接:

llc prog.ll -o prog.s # 生成汇编 clang prog.s -o prog # 链接成可执行文件 ./prog

llc走的是完整后端,支持的特性比lli多,但编译慢。我的习惯是:逻辑验证用lli,涉及 intrinsic 或需要真实运行环境时用llc+clang。两条路都走一遍,能帮你分清“IR 本身有问题”和“执行引擎不支持”这两类完全不同的故障。

5. 避坑与排查:IR 演示工程里最容易翻车的五件事

5.1 版本不一致导致 IR 语法报错

现象:从文档或旧项目里抄来的 IR,opt报error: expected type或error: ptr type is not supported。原因:LLVM 15 前后指针语法从i32*变成ptr,getelementptr的参数形式也变了。解决:先opt --version看主版本号,15 及以上统一用ptr,14 及以下用带类型指针。如果必须跨版本,用llvm-as/llvm-dis做一次规范化转换,但注意不是所有语法都能自动降级。

5.2 O0 的 IR 里全是 alloca,误以为优化没生效

现象:在 O0 的 IR 上跑mem2reg,发现alloca没被全部消除。原因:mem2reg只处理“可提升”的alloca,如果变量地址被取过(比如传给了函数),它就不会动。解决:确认alloca是否只被load/store直接使用,没有被getelementptr或函数调用取地址。如果确实需要提升,先跑opt -passes='mem2reg'看剩余alloca,再决定是否手动改写 IR。

5.3 phi 节点 incoming value 数量对不上

现象:opt报error: PHINode should have one entry for each predecessor。原因:某个前驱基本块没有对应的 incoming value,或者多写了一个。解决:用opt -passes='dot-cfg'画出 CFG,数清楚每个基本块的前驱数量,逐个核对 phi 的[value, block]对。手写 IR 时建议先写基本块和br,最后再补 phi,避免边写边改导致遗漏。

5.4 lli 报 unsupported intrinsic 但 IR 看起来没问题

现象:lli执行时报unsupported intrinsic,但opt -passes='verify'通过。原因:lli的解释器不支持某些 intrinsic,尤其是平台相关的或较新的。解决:改用llc编译成目标文件再链接执行。如果llc也报错,说明 intrinsic 本身用错了,检查参数类型和数量。另一个办法是查 LLVM 文档确认该 intrinsic 的最低版本要求。

5.5 优化级别不同导致测试用例行为不一致

现象:同一份 IR,O0 下跑出预期结果,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 PassInfoMixin<AddCounter> { PreservedAnalyses run(Function &F, FunctionAnalysisManager &) { unsigned count = 0; for (BasicBlock &BB : F) { for (Instruction &I : BB) { if (isa<BinaryOperator>(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, ArrayRef<PassBuilder::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 -passes='add-counter' -S demo_O0.ll -o /dev/null

参数说明:-load-pass-plugin指定动态库路径,-passes='add-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,别混用系统包和手动编译的版本。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询