编译器中端

编译器中端主要包括:中间代码生成和中间代码优化。

1. 中间代码生成

1.1 为什么需要中间代码?

1.2 中间代码分类

按照与源代码接近的程度可以分为:

  • 高级IR:更接近源代码,保留了更多的源代码层面的信息,如数据类型、控制结构等。通常用于编译器前端的语义分析和初步优化阶段。例如:AST等;

  • 中级IR:开始脱离源代码的具体语法,引入更多与机器无关的优化。通常用于进行编译器的主要优化,如循环优化、常量传播、死代码消除等。例如:TAC等;

  • 低级IR:接近目标机器代码,更多地反映了目标平台的具体特性,如寄存器、指令集等。用于编译器后端的优化,特别是那些与目标机器密切相关的优化,如寄存器分配、指令选择和调度。

根据其结构和表现形式可以分为:

  • 图IR: 将编译器生成的信息保存在图中,通过图结构来表示程序的各种属性。图IR非常适合表示并分析程序的复杂结构,如循环、分支等。例如:AST、CFG和DFG等;

  • 线性IR: 以线性的方式表示程序,通常是一系列的指令或语句。不像图IR那样直观地表示控制流或数据流,但对于某些分析和转换来说更简单、直接,常用于编译器的早期阶段,进行简化的分析和转换。例如:TAC、SSA等;

  • 混合IR: 结合了图IR和线性IR的特点,尝试兼顾两者的优势。一种常见的混合表示为使用线性IR来表示无循环代码的块,使用图来表示这些块之间的控制流。混合IR旨在提供足够的灵活性来支持各种编译阶段的需要,从而使编译器能够有效地执行复杂的优化和分析。

了解了基本的分类,下面介绍几种常见的中间表示方式,并针对表达式A进行不同形式的呈现:

//表达式A
a=(-b+c*d)+c*d

③ 三地址代码(Three-Address Code, TAC) 三地址代码是中级IR和线性IR,它是一种接近于汇编语言但又保持了高级语言特征的代码形式。这种形式的IR特别适合于执行和表示各种编译时优化。在三地址代码中,每条指令通常涉及到两个操作数(y、z)和一个结果(x),指令的一般形式可以是: x = y op z 其中,op 是一个二元运算符,y 和 z 是操作数,可以是常量、变量或者临时变量,x 是存放结果的变量或临时变量。三地址代码也支持一元运算符,这种情况下指令的形式为: x = op y 常用的三地址代码有:

指令类型指令形式备注

赋值指令

x = y op z、x = op y

op为运算符

复制指令

x = y

条件跳转

if x relop y goto n

relop为关系运算符

非条件跳转

goto n

跳转到地址n的指令

参数传递

param x

将x设置为参数

过程调用

call p,n

p为过程的名字,n为过程的参数的个数

过程返回

return x

数组引用

x=y[i]

i为数组的偏移地址,而不是下标

数组赋值

x[i]=y

地址及指针操作

x=&y、x=yx=y

另外,表达式A生成的TAC和SSA是一样的,大家不妨验证一下是否符合SSA的特性😁。

1.3 中间代码生成实践

LLVM IR是一种基于静态单赋值(SSA)的表示法,提供了类型安全、底层操作、灵活性以及简洁地表示 高级语言的能力。它实际上有三种不同的表示方式,它们在功能上是等价的,但用途和表现形式各有不同。这三种表示分别是:

  • LLVM汇编语言(LLVM Assembly Language):这是一种文本格式的表示,提供了可读性较好的代码形式。它允许开发者和工具以文本形式查看和编辑IR,非常适合调试和教学。这种格式通常保存为以.ll为扩展名的文件;

  • LLVM字节码(LLVM Bitcode):这是一种二进制格式的表示,它将IR编码为二进制文件,通常用于在不同编译阶段之间传递IR,或者作为发布应用程序的一部分以支持延迟编译(JIT编译)或跨平台移植。字节码格式更加紧凑,加载和处理速度也更快。这种格式的文件扩展名通常是.bc。

  • 内存中的IR(In-memory IR):这是LLVM库在内存中操作的数据结构形式。当- LLVM的前端(如Clang)解析源代码时,它会构建出一个内存中的IR表示,后续的优化和转换操作都是在这种形式上进行的。这种表示不直接面向用户,而是由LLVM的API操作和修改。

下面实操使用LLVM工具生成这.bc和.ll这两种中间代码: ① 安装LLVM 可以参考官方下载地址安装,Mac可以使用HomeBrew安装。

brew install llvm

由于LLVM 带了一个版本的 Clang 和 C++ 的标准库,与本机原来的工具链可能会有冲突,所以 brew 安装的时候并没有在 /usr/local 下建立符号链接。使用 LLVM 工具的时,要配置好相关的环境变量。

# 可执行文件的路径
export PATH="/usr/local/opt/llvm/bin:$PATH"
# 让编译器能够找到LLVM
export LDFLAGS="-L/usr/local/opt/llvm/lib"
export CPPFLAGS="-I/usr/local/opt/llvm/include"

② 准备源码文件fun1.c

int fun1(int a, int b){
    int c = 10;
    return a+b+c;
}

③ 生成IR

# 生成LLVM汇编语言
clang -emit-llvm -S fun1.c -o fun1.ll
# 生成LLVM字节码
clang -emit-llvm -c fun1.c -o fun1.bc
# 转换命令
llvm-as fun1.ll -o fun1.bc
# 查看结果
hexdump -C fun1.bc

2. 中间代码优化

2.1 常见优化方式

IR存在的意义是使得代码优化变得容易且可复用,不同的中间表达方式也是为了方便不同优化手段的执行。对代码做优化的方法有很多,如果把它们分类一下的话,可以按照下面两个维度:

  • 按是否机器相关分类:

    • 机器无关优化(Machine-Independent Optimization):这类优化不依赖于目标机器的具体硬件特性,主要关注于高级语言构造的改进和通用的算法改进。例如:常量折叠、死代码消除、循环不变代码外提等;

    • 机器相关优化(Machine-Dependent Optimization):这类优化依赖于目标机器具体硬件特性,如寄存器的数量、指令集特点等。例如:寄存器分配、指令选择、指令级并行优化等。

  • 按优化的范围分类:

    • 本地优化(Local Optimization):关注于程序中小的片段,通常是一个基本块内的优化。这类优化不考虑跨基本块的控制流和数据流。例如:公共子表达式消除、死代码消除等;

    • 全局优化(Global Optimization):考虑整个程序或函数的控制流和数据流,跨多个基本块进行优化。例如:全局常量传播、全局死代码消除等;

    • 过程间优化(Interprocedural Optimization, IPO):涉及多个函数或过程,优化的范围超越单个函数的边界。例如:内联展开、过程间常量传播等。

中间代码优化属于机器无关,针对本地、全局和过程间都可以进行优化。下面介绍一些常见的优化方式,之后再使用LLVM进行优化实操。

① 常量折叠(Constant Folding) 预先计算常量表达式的结果,而不是在运行时计算。编译器会分析代码中的表达式,如果发现表达式完全由常量组成,编译器就会预先计算这个表达式的结果,并在生成的代码中用这个计算结果替换原有的表达式。这个过程不改变程序的语义,因为替换后的表达式和原表达式在逻辑上是等价的。例如:

int main() {
    int a = 2 + 3;
    int b = a * 10;
    return b;
}

上述代码表达式 2 + 3 完全由常量组成,因此编译器在编译时就可以计算出它的结果为5。因此替换后生成:

int main() {
    int a = 5;
    int b = a * 10;
    return b;
}

接着,虽然 a 不是一个字面常量,但是在上下文中它的值是已知的,因此表达式 a * 10 也可以在编译时被计算出结果为50。最终,代码可以被优化为:

int main() {
    return 50;
}

② 死代码消除(Dead Code Elimination) 移除那些不会影响程序最终结果的代码。这种优化不仅可以减少程序的大小,还能提高执行效率,因为它减少了执行时的计算量和内存占用。死代码的类型有:

  • 不可达代码:这类代码在任何情况下都不会被执行到。例如,在条件判断后的某个分支中,如果条件永远不会满足,则该分支中的代码就是不可达代码;

int func(int x) {
    return x * 2;
    printf("This line will never be executed.\n"); // 此行是死代码
}
  • 无效果表达式:这类代码虽然会被执行,但是不会对程序的状态或输出产生任何影响。比如,对局部变量的赋值,如果这个局部变量之后没有被读取,那么这个赋值操作就是无效果的。

void example() {
    int a = 10;
    a = 20; // 这个赋值是死代码,因为a的值没有被使用
    // 函数结束,没有任何对a的引用
}

③ 公共子表达式消除(Common Subexpression Elimination, CSE) 识别并消除在程序中多次计算的相同表达式,从而减少重复计算,提高程序的运行效率。这种技术识别在一段代码中多次出现的相同表达式,然后将这个表达式的计算结果保存在一个临时变量中,后续使用这个结果而不是重新计算表达式。举例:

int a = b * c + g;
int d = b * c * e;

上述代码b * c 被计算了两次。通过公共子表达式消除,我们可以将b * c的结果存储在一个临时变量中,然后复用这个结果:

int temp = b * c;
int a = temp + g;
int d = temp * e;

④ 循环展开(Loop Unrolling) 减少循环迭代次数,从而提高程序的执行效率。循环展开通过减少循环迭代次数来实现,它将循环体中的操作复制多份,每次迭代执行更多的工作,从而减少了循环控制逻辑(比如增量和条件测试)的开销。举例:

int sum = 0;
for (int i = 0; i < 100; i++) {
    sum += array[i];
}

展开这个循环,每次迭代处理两个元素:

int sum = 0;
for (int i = 0; i < 100; i += 2) {
    sum += array[i];
    sum += array[i + 1];
}

⑤ 循环不变代码外提(Loop Invariant Code Motion) 将循环内部那些在每次迭代中都不会改变的计算移动到循环外部执行,从而减少了循环内部的计算量,提高了程序的效率。举例:

for (int i = 0; i < n; i++) {
    if (b > 0) {
        array[i] = b * array[i];
    }
}

如果b的值在循环过程中不改变,那么if (b > 0)的判断结果是不变的。将这个判断移到循环外部,这样如果b小于等于0可以避免整个循环的执行:

if (b > 0) {
    for (int i = 0; i < n; i++) {
        array[i] = b * array[i];
    }
}

⑥ 内联展开(Inline Expansion) 将函数调用替换为函数体本身的代码,以减少函数调用开销。这种技术的目的是减少函数调用时产生的额外成本,比如参数传递、栈帧创建和销毁等,从而提高程序的执行效率,也能提高局部性有助于缓存利用。举例:

int square(int x) {
    return x * x;
}

int main() {
    int sum = 0;
    for (int i = 0; i < 10; i++) {
        sum += square(i);
    }
    return sum;
}

内联展开后的代码如下,每次循环调用的square函数被替换成了它的函数体:

int main() {
    int sum = 0;
    for (int i = 0; i < 10; i++) {
        sum += i * i; // 直接使用内联后的代码
    }
    return sum;
}

2.2 中间代码优化实践

生成IR的命令可以直接带上优化参数生成优化后的结果:

# 带上 O2 参数来生成优化的 IR
clang -emit-llvm -S -O2 fun1.c -o fun1-O2.ll

另外还有一个单独的命令opt用来做代码优化。缺省情况下,它的输入和输出都是.bc 文件,带上 -S 参数则可以直接对.ll 文件进行优化。用 opt --help 命令,可以查看 opt 命令所支持的所有优化算法。

opt -O2 fun1.bc -o fun1-O2.bc
opt -S -O2 fun1.ll -o fun1-O2.ll

在 LLVM 内部,优化工作是通过一个个的 Pass(遍)来实现的,它支持三种类型的 Pass:

  • 分析型的 Pass(Analysis Passes),只是做分析,产生一些分析结果用于后序操作;

  • 代码转换的Pass(Transform Passes),比如做公共子表达式删除;

  • 工具型的Pass,比如对模块做正确性验证。

Last updated