LLVM-01 核心概念梳理和实操

本文围绕 LLVM 编译器框架及其核心 LLVM IR 展开,从基础概念、语法规则、核心特性到控制流结构,系统讲解了 LLVM IR 的设计逻辑与实际作用。

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 架构的完整编译流程:

graph TD subgraph 输入 A1[C/C++] A2[Rust] A3[Swift] end subgraph 前端 B[Clang 等编译器<br/>生成 LLVM IR] end subgraph LLVM IR C1[.ll 文本格式] C2[.bc 二进制格式] C3[内存表示] end subgraph 优化器 D[平台无关优化] end subgraph 后端 E1[x86] E2[ARM] E3[RISC-V] end F[最终机器码] A1 --> B A2 --> B A3 --> B B --> C1 B --> C2 B --> C3 C1 --> D C2 --> D C3 --> D D --> E1 D --> E2 D --> E3 E1 --> F E2 --> F E3 --> F

LLVM IR 基础标识符规则

在学习IR核心特征前,先明确IR最基础的标识符命名规则,这是读懂所有IR代码的前提:

  1. 全局标识符:以@开头,对应全局变量、函数名,整个程序范围内可见,例如@main@printf

  2. 局部标识符:以%开头,对应函数内的虚拟寄存器、局部类型,仅在当前函数内可见,例如%1%a

  3. 标识符分为两种格式:

    • 命名标识符:自定义可读名称,例如%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 tripletarget datalayout会携带目标平台的专属信息,确保后端生成正确的机器码。

Three Equivalent Forms: It exists in three formats that represent the exact same code:

  1. In-Memory: C++ classes used during the compilation process.

  2. Bitcode (.bc): A compact binary format for efficient storage and transmission.

  3. Assembly (.ll): A human-readable text format used for debugging and manual inspection.

LLVM IR有三种完全等价的表现形式,仅存储和使用场景不同,语义完全一致:

  1. 内存表示:编译过程中,LLVM用C++类在内存中维护的IR结构,是编译期优化、代码生成的直接操作对象

  2. Bitcode(.bc格式):紧凑的二进制格式,体积小、加载快,适合IR的分发、链接与持久化存储

  3. 汇编文本(.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个核心条件的指令序列:

  1. 单入口: 代码只能从基本块的第一条指令进入,不能从基本块中间跳入

  2. 单出口: 代码只能从基本块的最后一条指令离开

  3. 无内部跳转: 基本块内部没有跳转、分支指令,指令总是按顺序从头到尾执行

  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,每个基本块都严格遵守单入口、单出口、内部无跳转、以终结指令结尾的规则。

分支控制流可视化:

graph TD BB0["入口块<br/>比较 10 > 20 ?<br/>br %7, %10"] BB7["块 %7<br/>printf(10)<br/>br %13"] BB10["块 %10<br/>printf(20)<br/>br %13"] BB13["块 %13<br/>ret 0"] BB0 -->|是| BB7 BB0 -->|否| BB10 BB7 --> BB13 BB10 --> BB13

LLVM IR 的 Phi 指令

In LLVM Intermediate Representation (IR) ... phi nodes 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 an if-else statement or at the start of a loop).

Phi指令(也叫Phi节点)是为了适配SSA规则而设计的核心指令,核心作用是:在多个控制流路径的汇合点,根据程序进入当前块的前驱基本块,选择对应的变量值

Phi指令标准语法

%result = phi <类型> [ <来自前驱块1的值>, <前驱块1的标签> ], [ <来自前驱块2的值>, <前驱块2的标签> ], ...
  • 执行逻辑:

    • 如果当前基本块是从 <前驱块1的标签> 跳转过来的 → %result 取值为 <来自前驱块1的值>

    • 如果当前基本块是从 <前驱块2的标签> 跳转过来的 → %result 取值为 <来自前驱块2的值>

Phi指令核心约束

  1. Phi指令必须放在当前基本块的最开头,前面不能有任何非Phi指令

  2. 必须为当前基本块的所有前驱块都提供对应的取值分支,不能遗漏

  3. 所有取值的类型必须完全一致,与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,不同平台的调用约定完全不同。

LICENSED UNDER CC BY-NC-SA 4.0
评论