代码生成过程-编译最后一步

编译的最后一步,也就是生成目标代码,则必须是跟特定 CPU 架构相关的。

 

这就是编译器的后端。不过,后端不只是简单地生成目标代码,它还要完成与机器相关的一些优化工作,确保生成的目标代码的性能最高。

 

看看编译器是如何通过指令选择、寄存器分配、指令排序和基于机器代码的优化等步骤,完成整个代码生成的任务的

 

编译器后端的任务:生成针对不同架构的目标代码。

生成针对不同 CPU 的目标代码


int foo(int a, int b){
    return a + b + 10;
}

 

执行“clang -S foo.c -o foo.x86.s”命令,你可以得到对应的 x86 架构下的汇编代码


#序曲
pushq  %rbp
movq  %rsp, %rbp     #%rbp是栈底指针

#函数体
movl  %edi, -4(%rbp) #把第1个参数写到栈里第一个位置(偏移量为4)
movl  %esi, -8(%rbp) #把第2个参数写到栈里第二个位置(偏移量为8)
movl  -4(%rbp), %eax #把第1个参数写到%eax寄存器
addl  -8(%rbp), %eax #把第2个参数加到%eax
addl  $10, %eax      #把立即数10加到%eax,%eax同时是放返回值的地方

#尾声
popq  %rbp
retq

 

那为什么我说汇编代码不难学呢?你可以去查阅下各种不同 CPU 的指令。然后,你就会发现这些指令其实主要就那么几种,一类是做加减乘除的(如 add 指令),一类是做内存访问的(如 mov、lea 指令),一类是控制流程的(如 jmp、ret 指令),等等。说得夸张一点,这就是个复杂的计算器。

 

让编译器输出高级语言的汇编代码,多看一些各种情况下汇编代码的写法,自然就会对汇编语言越来越熟悉了。

 

虽然针对某一种 CPU 的汇编并不难,但问题是不同架构的 CPU,其指令是不同的。编译器的后端每支持一种新的架构,就要有一套新的代码。

 

x86 的汇编,mov 指令的功能很强大,可以从内存加载到寄存器,也可以从寄存器保存回内存,还可以从内存的一个地方拷贝到另一个地方、从一个寄存器拷贝到另一个寄存器。add 指令的操作数也可以使用内存地址。而在 ARM 的汇编中,从寄存器到内存要使用 str(也就是 Store)指令,而从内存到寄存器要使用 ldr(也就是 Load)指令。对于加法指令 add 而言,两个操作数及计算结果都必须使用寄存器。

ARM 的这种指令风格叫做 Load-Store 架构。在这种架构下,指令被分为内存访问(Load 和 Store)和 ALU 操作两大类,而后者只能在寄存器上操作。各种 RISC 指令集都是 Load-Store 架构的,比如 PowerPC、RISC-V、ARM 和 MIPS 等。而像 x86 这种 CISC 指令,叫做 Register-Memory 架构,在指令里可以混合使用内存地址和寄存器。

 

另一种方法,是编写“代码生成器的生成器”。也就是说,你可以把 CPU 架构的各种信息(比如有哪些指令、指令的特点、有哪些寄存器等)描述出来,然后基于这些信息生成目标代码的生成器,就像根据语法规则,用 ANTLR、bison 这样的工具来生成语法解析器一样。

 

经过这样的处理,虽然我们生成的目标代码是架构相关的,但中间的处理算法却可以尽量做成与架构无关的。

 

生成目标代码时的优化工作

包括指令选择、寄存器分配、指令排序、基于机器代码的优化等步骤。充分发挥硬件的性能。

 

 

 

 

指令选择

它的作用是在完成相同功能的情况下,选择代价更低的指令组合。

 

对于相同的源代码和 IR,编译器可以生成不同的指令,而我们要选择代价最低的那个。

 

数组寻址的原理,a[i]的地址就是从数组 a 的起始地址往后偏移 i 个单位。对于整型数组来说,a[i]的地址就是 a+i*4。

 

数组操作是很常见的现象,于是 x86 芯片专门提供了一种寻址方式,简化了数组的寻址,这就是间接内存访问。间接内存访问的完整形式是:偏移量(基址,索引值,字节数),其地址是:基址 + 索引值 * 字节数 + 偏移量。

 

我们天天在用的 x86 家族的芯片,它支持很多不同的指令集,比如 SSE、AVX、FMA 等,每个指令集里都有能完成加减乘除运算的指令。当然,每个指令集适合使用的场景也不同,我们要根据情况选择最合适的指令。

 

 

指令选择的作用了,它在具体实现上有很多算法,比如树覆盖算法,以及 BURS(自底向上的重写系统)等

 

 

寄存器分配

 

内存访问比寄存器访问大约慢 100 倍

因为不需要访问内存,所以连栈顶指针都不需要挪动,进一步减少了代码量。

特别是像函数中用到的本地变量和参数,它们在退出作用域以后就没用了,所以能放到寄存器里,就放寄存器里吧。

 

但实际 CPU 中的寄存器是有限的。所以,我们就要用一定的算法,把寄存器分配给使用最频繁的变量,比如循环中的变量。而对于超出物理寄存器数量的变量,则“溢出”到栈里,通过内存来保存。

寄存器分配的算法有很多种。一个使用比较广泛的算法是寄存器染色算法,它的特点是计算结果比较优化,但缺点是计算量比较大。

另一个常见的算法是线性扫描算法,它的优点是计算速度快,但缺点是有可能不够优化,适合需要编译速度比较快的场景,比如即时编译。在解析 Graal 编译器的时候,你会看到这种算法的实现。

 

指令排序

寄存器分配算法对性能的提升是非常显著的。接下来我要介绍的指令排序,对性能的提升同样非常显著。

CPU 执行指令的一种内部机制:流水线(Pipeline)。

原来,CPU 内部是分成多个功能单元的。对于一条指令,每个功能单元处理完毕以后,交给下一个功能单元,然后它就可以接着再处理下一条指令。所以,在同一时刻,不同的功能单元实际上是在处理不同的指令。这样的话,多条指令实质上是并行执行的,从而减少了总的执行时间,这种并行叫做指令级并行。

 

但是有的时候,指令之间会存在依赖关系,后一条指令必须等到前一条指令执行完毕才能运行,这样的话,指令就不能并行了,执行时间就会大大延长。

 

循环做优化的一种技术,叫做循环展开(Loop Unroll),它会把循环体中的代码重复多次,与之对应的是减少循环次数。这样一个基本块中就会有更多条指令,增加了通过指令排序做优化的机会。

 

指令排序的算法也有很多种,比如基于数据依赖图的 List Scheduling 算法

 

 

窥孔优化(Peephole Optimization)

前面做了常数折叠以后,后面的处理步骤修改了代码或生成新的代码以后,可能还会产生出新的常数折叠的机会。另外,有些优化也只有在目标代码的基础上才方便做

 

假设相邻两条指令,一条指令从寄存器保存数据到栈里,下一条指令又从栈里原封不动地把数据加载到原来的寄存器,那么这条加载指令就是冗余的,可以去掉。

 


str r0, [sp, #4]  //把r0的值保存到栈顶+4的位置
ldr r0, [sp, #4]  //把栈顶+4位置的值加载到r0寄存器

 

基于目标代码的优化,最常用的方法是窥孔优化(Peephole Optimization)。窥孔优化的思路,是提供一个固定大小的窗口,比如能够容纳 20 条指令,并检查窗口内的指令,看看是否可以优化。然后再往下滑动窗口,再次检查优化机会。

调用约定的影响

 

其中的 %edi 寄存器用来传递第一个参数,%esi 寄存器用来传递第二个参数,这就是遵守了一种广泛用于 Unix 和 Linux 系统的调用约定“System V AMD64 ABI”。这个调用约定规定,对于整型参数,前 6 个参数可以用寄存器传递,6 个之后的参数就要基于栈来传递。

 

ABI 是 Application Binary Interface 的缩写,也就是应用程序的二进制接口。通常,ABI 里面除了规定调用约定外,还要包括二进制文件的格式、进程初始化的方式等更多内容。

 

在看 ARM 的汇编代码时,我们会发现,它超过了 4 个参数就要通过栈来传递。实际上,它遵循的是一种不同 ABI,叫做 EABI(嵌入式应用程序二进制接口)。在调用 Clang 做编译的时候,-target 参数“armv7a-none-eabi”的最后一部分,就是指定了 EABI。

 

 

在实现编译器的时候,你可以发明自己的调用约定,比如哪些寄存器用来放参数、哪些用来放返回值,等等。但是,如果你要使用别的语言编译好的目标文件,或者你想让自己的编译器生成的目标文件被别人使用,那你就要遵守某种特定的 ABI 标准。

 

 在分配完寄存器以后,还要再做一次指令排序,因为寄存器分配算法会产生新的指令排序优化的机会。比如,一些变量会溢出到栈里,从而增加了一些内存访问指令。

 

这个处理过程,其实也是 IR 不断 lower 的过程。一开始是 MIR,在做了指令选择以后,就变成了具体架构相关的 LIR 了。在没做寄存器分配之前,我们在 LIR 中用到寄存器还是虚拟的,数量是无限的,做完分配以后,就变成具体的物理寄存器的名称了。

 

与机器相关的优化(如窥孔优化)也会穿插在整个过程中。最后一个步骤,是通过一个 Emit 目标代码的程序生成目标代码。因为 IR 已经被 lower 得很接近目标代码了,所以这个翻译程序是比较简单的。

 

 

 

 

 

 

 

 

 

 

 

 

 

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值