LLVM 与 LLVM IR
前置实操指南
本文所有示例均可通过以下Clang命令直接复现。
clang -S -emit-llvm -O0 test.c -o test.ll
LLVM
LLVM 是一套现代编译器框架,用于把高级语言翻译成各种 CPU 能运行的机器码。
Clang is an "LLVM native" C/C++/Objective-C compiler, which aims to deliver amazingly fast compiles, extremely useful error and warning messages and to provide a platform for building great source level tools. The Clang Static Analyzer and clang-tidy are tools that automatically find bugs in your code, and are great examples of the sort of tools that can be built using the Clang frontend as a library to parse C/C++ code.
Clang 是LLVM生态的官方C/C++/Objective-C前端,也是本文生成LLVM IR的核心工具。
LLVM IR (Intermediate Representation) is the core language of the LLVM Compiler Infrastructure. It serves as a universal bridge between high-level programming languages (like C++, Rust, or Swift) and low-level machine code (like x86, ARM, or RISC-V).
LLVM IR 是 LLVM 架构在编译过程中产生的一种中间语言,前端将高级语言翻译为LLVM IR后,优化器可基于IR做跨平台的通用优化,最终由后端翻译为目标平台的汇编与机器码。
LLVM 架构的完整编译流程:
LLVM IR 基础标识符规则
在学习IR核心特征前,先明确IR最基础的标识符命名规则,这是读懂所有IR代码的前提:
全局标识符:以
@开头,对应全局变量、函数名,整个程序范围内可见,例如@main、@printf局部标识符:以
%开头,对应函数内的虚拟寄存器、局部类型,仅在当前函数内可见,例如%1、%a标识符分为两种格式:
命名标识符:自定义可读名称,例如
%max_val、@global_var未命名标识符:以数字编号表示,由编译器自动生成,例如
%1、%2,按函数内出现顺序编号
LLVM IR 的特征
Static Single Assignment (SSA): Every variable is assigned exactly once, which simplifies complex compiler optimizations like constant propagation and dead code elimination.
SSA(静态单赋值)是LLVM IR最核心的特征,核心规则是:每个虚拟寄存器只能被赋值一次,永远不会被重复修改。
以如下极简C语言程序为例:
#include <stdio.h>
int main()
{
int a = 0;
a = a + 1;
}
使用前文的Clang命令将其翻译为IR,取主函数完整片段做分析:
; Function Attrs: 函数属性标记,noinline=禁止内联,optnone=无优化,uwtable=支持栈回溯
define dso_local i32 @main() #0 {
%1 = alloca i32, align 4 ; 分配4字节int类型栈内存,栈地址存入虚拟寄存器%1(对应变量a的内存地址)
store i32 0, ptr %1, align 4 ; 将常量0写入%1指向的内存,对应C代码:int a = 0;
%2 = load i32, ptr %1, align 4 ; 从%1指向的内存读取值,存入虚拟寄存器%2,此时%2=0
%3 = add nsw i32 %2, 1 ; 执行%2 + 1,结果存入虚拟寄存器%3,nsw=有符号溢出时为未定义行为
store i32 %3, ptr %1, align 4 ; 将%3的值写入%1指向的内存,对应C代码:a = a + 1;
ret i32 0 ; 函数返回0,程序退出
}
可以清晰看到,虚拟寄存器%1、%2、%3都只被赋值一次,不会被重复赋值,这就是SSA的核心表现。
在SSA规则下,变量与其值一一对应,使得“一个变量在某点的取值是什么”这个问题变得极其简单,从而大幅简化了常量传播、公共子表达式消除、死代码消除等编译器优化。
我们可以通过开启优化直观看到SSA的价值,使用-O2优化等级编译上述C代码,生成的IR会被极致简化:
define dso_local i32 @main() #0 {
ret i32 0
}
优化器基于SSA规则,快速识别出代码中的所有操作都不会影响最终返回值,直接消除了所有死代码,这就是SSA为优化带来的核心优势。
Target-Agnostic but Flexible: While designed to be portable, it can contain target-specific details (like pointer sizes) depending on the frontend that generated it.
尽管LLVM IR被设计为一种通用的跨平台中间表示,核心指令逻辑可在不同CPU架构间复用,但它仍可以携带特定目标平台的细节信息,用于后端生成适配目标平台的机器码。
如下文程序的IR开头片段:
; ModuleID = 'test_00.c'
source_filename = "test_00.c"
; 目标数据布局:定义目标平台的内存对齐、指针大小、类型宽度等规则
target datalayout = "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-f80:128-n8:16:32:64-S128"
; 目标三元组:指定目标平台为 x86_64架构-PC端-Linux-GNU系统
target triple = "x86_64-pc-linux-gnu"
IR的核心运算指令(alloca/load/add/store等)不依赖x86、ARM或RISC-V架构,逻辑本身可以跨平台复用、跨平台优化,但target triple和target datalayout会携带目标平台的专属信息,确保后端生成正确的机器码。
Three Equivalent Forms: It exists in three formats that represent the exact same code:
In-Memory: C++ classes used during the compilation process.
Bitcode (.bc): A compact binary format for efficient storage and transmission.
Assembly (.ll): A human-readable text format used for debugging and manual inspection.
LLVM IR有三种完全等价的表现形式,仅存储和使用场景不同,语义完全一致:
内存表示:编译过程中,LLVM用C++类在内存中维护的IR结构,是编译期优化、代码生成的直接操作对象
Bitcode(.bc格式):紧凑的二进制格式,体积小、加载快,适合IR的分发、链接与持久化存储
汇编文本(.ll格式):人类可读的纯文本格式,也是本文主要使用的格式,适合调试、学习、手动修改IR
三种格式可通过LLVM工具命令互相转换:
# .ll文本 → .bc二进制
llvm-as test.ll -o test.bc
# .bc二进制 → .ll文本
llvm-dis test.bc -o test.ll
LLVM IR 的基本块
LLVM IR 的代码被组织成一系列的基本块(Basic Block, BB),基本块是控制流的最小单元,一个基本块是满足以下4个核心条件的指令序列:
单入口: 代码只能从基本块的第一条指令进入,不能从基本块中间跳入
单出口: 代码只能从基本块的最后一条指令离开
无内部跳转: 基本块内部没有跳转、分支指令,指令总是按顺序从头到尾执行
终结指令: 每个基本块的最后一条指令必须是终结指令(如
br分支、ret返回等),用于决定控制流的走向
以如下带if-else分支的C语言代码为例:
#include <stdio.h>
int main()
{
int a = 10;
int b = 20;
if(a > b)
{
printf("%d", a);
}
else
{
printf("%d", b);
}
}
经过转换的IR中,选取主函数体完整片段:
@.str = private unnamed_addr constant [3 x i8] c"%d\00", align 1 ; 全局字符串常量,对应printf的格式化字符串"%d"
; 主函数定义
define dso_local i32 @main() #0 {
; 入口基本块(无显式标签,函数的第一个基本块,也可称为%0块)
%1 = alloca i32, align 4 ; 分配栈内存,对应main函数的返回值存储
%2 = alloca i32, align 4 ; 分配栈内存,对应变量a
%3 = alloca i32, align 4 ; 分配栈内存,对应变量b
store i32 0, ptr %1, align 4 ; 初始化返回值为0
store i32 10, ptr %2, align 4 ; 给变量a赋值10
store i32 20, ptr %3, align 4 ; 给变量b赋值20
%4 = load i32, ptr %2, align 4 ; 读取a的值到%4
%5 = load i32, ptr %3, align 4 ; 读取b的值到%5
%6 = icmp sgt i32 %4, %5 ; 有符号整数比较,判断%4 > %5,结果为布尔值存入%6(sgt = signed greater than)
br i1 %6, label %7, label %10 ; 终结指令:条件分支,%6为真跳转到%7块,为假跳转到%10块
; 基本块%7:if(a > b)为真的分支
7: ; preds = %0 (前驱块是入口块)
%8 = load i32, ptr %2, align 4 ; 读取a的值到%8
%9 = call i32 (ptr, ...) @printf(ptr noundef @.str, i32 noundef %8) ; 调用printf输出a的值
br label %13 ; 终结指令:无条件跳转到%13块(分支汇合点)
; 基本块%10:if(a > b)为假的else分支
10: ; preds = %0 (前驱块是入口块)
%11 = load i32, ptr %3, align 4 ; 读取b的值到%11
%12 = call i32 (ptr, ...) @printf(ptr noundef @.str, i32 noundef %11) ; 调用printf输出b的值
br label %13 ; 终结指令:无条件跳转到%13块(分支汇合点)
; 基本块%13:if-else分支的汇合块,函数收尾
13: ; preds = %7, %10 (前驱块是%7和%10)
%14 = load i32, ptr %1, align 4 ; 读取函数返回值
ret i32 %14 ; 终结指令:函数返回,结束执行
}
; printf函数声明,告诉编译器该函数的签名
declare i32 @printf(ptr noundef, ...) #1
不难发现,程序被分为 4 个完整的基本块:入口块、%7、%10、%13,每个基本块都严格遵守单入口、单出口、内部无跳转、以终结指令结尾的规则。
分支控制流可视化:
LLVM IR 的 Phi 指令
In LLVM Intermediate Representation (IR) ...
phinodes act as "selectors" that choose a value for a variable based on which path the program took to reach the current basic block ... Resolves variable values at the merge point of multiple control flow paths (e.g., after anif-elsestatement or at the start of a loop).
Phi指令(也叫Phi节点)是为了适配SSA规则而设计的核心指令,核心作用是:在多个控制流路径的汇合点,根据程序进入当前块的前驱基本块,选择对应的变量值。
Phi指令标准语法
%result = phi <类型> [ <来自前驱块1的值>, <前驱块1的标签> ], [ <来自前驱块2的值>, <前驱块2的标签> ], ...
执行逻辑:
如果当前基本块是从 <前驱块1的标签> 跳转过来的 →
%result取值为 <来自前驱块1的值>如果当前基本块是从 <前驱块2的标签> 跳转过来的 →
%result取值为 <来自前驱块2的值>
Phi指令核心约束
Phi指令必须放在当前基本块的最开头,前面不能有任何非Phi指令
必须为当前基本块的所有前驱块都提供对应的取值分支,不能遗漏
所有取值的类型必须完全一致,与Phi指令定义的类型匹配
完整可运行示例
以上文的if-else代码为例,我们修改需求:不直接输出最大值,而是将最大值存入变量max,再输出max。
在C语言中,我们可以这样写:
#include <stdio.h>
int main()
{
int a = 10;
int b = 20;
int max;
if(a > b)
{
max = a;
}
else
{
max = b;
}
printf("%d", max);
}
由于LLVM IR严格遵守SSA规则,max对应的虚拟寄存器不能被赋值两次,因此必须通过Phi指令在分支汇合点完成条件赋值,生成的完整IR如下(核心关注%13块的Phi指令):
@.str = private unnamed_addr constant [3 x i8] c"%d\00", align 1
define dso_local i32 @main() #0 {
; 入口基本块
%1 = alloca i32, align 4
%2 = alloca i32, align 4
%3 = alloca i32, align 4
store i32 0, ptr %1, align 4
store i32 10, ptr %2, align 4
store i32 20, ptr %3, align 4
%4 = load i32, ptr %2, align 4
%5 = load i32, ptr %3, align 4
%6 = icmp sgt i32 %4, %5
br i1 %6, label %7, label %10
; if为真的分支块
7:
%8 = load i32, ptr %2, align 4 ; 读取a的值,作为Phi的第一个候选值
br label %13
; else分支块
10:
%11 = load i32, ptr %3, align 4 ; 读取b的值,作为Phi的第二个候选值
br label %13
; 分支汇合块,核心Phi指令
13:
; Phi指令:根据前驱块选择max的值,类型为i32
; 从%7块跳转过来 → 取值%8(a的值);从%10块跳转过来 → 取值%11(b的值)
%max = phi i32 [ %8, %7 ], [ %11, %10 ]
; 调用printf输出max的值
%14 = call i32 (ptr, ...) @printf(ptr noundef @.str, i32 noundef %max)
%15 = load i32, ptr %1, align 4
ret i32 %15
}
declare i32 @printf(ptr noundef, ...) #1
通过Phi指令,我们在一行语句中完成了多分支的条件赋值,同时完全遵守了SSA的单赋值规则,这也是Phi指令在分支、循环场景中最核心的用途。
LLVM IR 的调用约定
调用约定定义了函数调用时参数如何传递、返回值如何返回,以及调用者和被调用者谁负责清理栈内存等核心规则。LLVM IR允许为每个函数单独指定调用约定,不同的目标平台、架构有不同的默认调用约定。
以上文的printf函数调用为例,核心IR片段如下:
; 函数声明:定义printf函数的签名、返回值类型、参数类型
; i32:函数返回值类型为int;ptr:第一个参数为字符串指针;...:可变参数
declare i32 @printf(ptr noundef, ...) #1
; 函数调用:调用printf函数,传入两个参数
; 第一个参数:格式化字符串@.str的指针;第二个参数:要输出的数值%max
%14 = call i32 (ptr, ...) @printf(ptr noundef @.str, i32 noundef %max)
本文示例中使用的参数传递规则仅适用于LLVM中默认的C调用约定
ccc,不同平台的调用约定完全不同。