赞
踩
1. 编译器的前端技术,重点是让编译器能够读懂程序,无结构的代码文本经过前端的处理以后,就变成了Token、AST和语义属性、符号表等结构化的信息,基于这些信息,可以实现简单的脚本解释器。但很多情况下,需要继续把程序编译成机器能读懂的代码,并高效运行。这时就面临了三个问题:
(1)必须了解计算机运行一个程序的原理(也就是运行期机制),只有这样才知道如何生成这样的程序。
(2)要能利用前端生成的AST和属性信息,将其正确翻译成目标代码。
(3)需要对程序做尽可能多的优化,比如让程序执行效率更高,占空间更少等等。
弄清这三个问题,是顺利完成编译器后端工作的关键。总的来说,编译器后端要解决的问题是:一台计算机怎么生成一个可以运行的程序,然后还能让这个程序在计算机上正确和高效地运行?下图是一个模型:
基本上需要面对的是两个硬件:
(1)CPU。它能接受机器指令和数据,并进行计算。它里面有寄存器、高速缓存和运算单元,充分利用寄存器和高速缓存会让系统的性能大大提升。
(2)内存。要在内存里保存编译好的代码和数据,还要设计一套机制,让程序最高效地利用这些内存。
可以看出,编译器后端技术跟计算机体系结构的关系很密切,例如运行期的机制里内存空间如何划分和组织;程序是如何启动、跳转和退出的;执行过程中指令和数据如何传递到CPU;整个过程中需要如何跟操作系统配合,等等。对运行期机制有了一定的了解之后,就可以进行下一步即生成符合运行期机制的代码。
2. 编译器后端的最终结果就是生成目标代码。如果目标是在计算机上直接运行,就像C语言程序那样,那这个目标代码指的是汇编代码。而如果运行目标是Java虚拟机,那这个目标代码就是指JVM的字节码。写汇编跟使用高级语言有很多不同,其中一点就是要关心CPU和内存这样具体的硬件,比如需要了解不同的CPU指令集的差别,还需要知道CPU是64位的还是32位的,有几个寄存器,每个寄存器可以用于什么指令等等。但这样导致的问题是,每种语言针对每种不同的硬件,都要生成不同的汇编代码。
所以为了降低后端工作量,提高软件复用度,就需要引入中间代码(Intermediate Representation,IR)的机制,它是独立于具体硬件的一种代码格式。各个语言的前端可以先翻译成IR,然后再从IR翻译成不同硬件架构的汇编代码。如果有n个前端语言和m个后端架构,本来需要做m*n个翻译程序,现在只需要m+n个了,大大降低了总体的工作量,如下图所示:
甚至很多语言主要做好前端就行了,后端可以尽量重用已有的库和工具,这也是现在推出新语言越来越快的原因之一。像Rust就充分利用了LLVM;GCC的各种语言如C、C++、Object C等也是充分共享了后端技术。IR可以有多种格式,比如“x + y * z”翻译成三地址代码是下面的样子,每行代码最多涉及三个地址,其中t1和t2是临时变量:
- t1 := y * z
-
- t2 := x + t1
Java语言生成的字节码也是一种IR,IR是一种中间表达方式,不一定非是像汇编代码那样的一条条的指令。所以AST其实也可以看做一种IR,在前端部分实现的脚本语言,就是基于AST这个IR来运行的。每种IR的目的和用途是不一样的:
(1)AST主要用于前端的工作。
(2)Java的字节码,是设计用来在虚拟机上运行的。
(3)LLVM的中间代码,主要是用于做代码翻译和编译优化的。
3. 总的来说,可以把各种语言翻译成中间代码,再针对每一种目标架构,通过一个程序将中间代码翻译成相应的汇编代码就可以了。生成正确的、能够执行的代码比较简单,可这样的代码执行效率很低,因为直接翻译生成的代码往往不够简洁,比如会生成大量的临时变量,指令数量也较多。因为翻译程序首先照顾的是正确性,很难同时兼顾是否足够优化。另一方面,由于高级语言本身的限制和程序员的编程习惯,也会导致代码不够优化,不能充分发挥计算机的性能。所以一定要对代码做优化。
优化工作又分为“独立于机器的优化”和“依赖于机器的优化”两种。独立于机器的优化是基于IR进行的,它可以通过对代码的分析,用更加高效的代码代替原来的代码。比如下面这段代码中的foo()函数,里面有多个地方可以优化,甚至连整个对foo()函数的调用也可以省略,因为foo()的值一定是101,这些优化工作在编译期都可以去做:
- int foo(){
- int a = 10*10; //这里在编译时可以直接计算出100这个值
- int b = 20; //这个变量没有用到,可以在代码中删除
-
- if (a>0){ //因为a一定大于0,所以判断条件和else语句都可以去掉
- return a+1; //这里可以在编译器就计算出是101
- }
- else{
- return a-1;
- }
- }
- int a = foo(); //这里可以直接地换成 a=101;
依赖于机器的优化,则是依赖于硬件的特征。现代的计算机硬件设计了很多特性,以便提供更高的处理能力,比如并行计算能力、多层次内存结构(使用多个级别的高速缓存)等等。编译器要能够充分利用硬件提供的性能,比如:
(1)寄存器优化。对于频繁访问的变量,最好放在寄存器中,并且尽量最大限度地利用寄存器,不让其中一些空着,有不少算法是解决这个问题的,教材上一般提到的是染色算法;
(2)充分利用高速缓存。高速缓存的访问速度可以比内存快几十倍上百倍,所以要尽量利用高速缓存。比如,某段代码操作的数据,在内存里尽量放在一起,这样CPU读入数据时,会一起都放到高速缓存中,不用一遍一遍地重新到内存取。
(3)并行性。现代计算机都有多个内核可以并行计算,编译器要尽可能充分利用多个内核的计算能力。
(4)流水线。CPU在处理不同指令的时候,需要等待的时间周期是不一样的,在等待某些指令做完的过程中其实还可以执行其他指令。
(5)指令选择。有时候CPU完成一个功能,有多个指令可供选择。而针对某个特定的需求,采用A指令可能比B指令效率高百倍。比如X86架构的CPU提供SIMD功能,也就是一条指令可以处理多条数据,而不是像传统指令那样一条指令只能处理一条数据。在内存计算领域,SIMD也可以大大提升性能。
(6)其他优化。比如可以针对专用的AI芯片和GPU做优化,提供AI计算能力等等。
可以看出,做好依赖于机器的优化要对目标机器的体系结构有清晰的理解,这样开发一些系统级的软件也会更加得心应手。实际上,数据库系统、大数据系统等等都是要融合编译技术的。在编译器中需要对代码进行的优化非常多。因此,优化工作也是编译过程中耗时最长、最体现某个编译器功力的一类工作。
编译器的后端,是要把高级语言翻译成计算机能够理解的目标语言。它跟前端相比关注点是不同的。前端关注的是正确反映了代码含义的静态结构,而后端关注的是让代码良好运行的动态结构,例如作用域是前端的概念,而生存期是后端的概念。
4. 程序运行的过程中,主要是跟两个硬件(CPU和内存)以及一个软件(操作系统)打交道,如下图所示:
大部分程序本质上只关心CPU和内存这两个硬件。对于CPU重点关注的是寄存器以及高速缓存,它们跟程序的执行机制和优化密切相关。寄存器是CPU指令在进行计算时临时数据存储的地方。CPU指令一般都会用到寄存器,比如加法计算(c=a+b)的过程是这样的:
(1)指令1(mov):从内存取a的值放到寄存器中;
(2)指令2(add):再把内存中b的值取出来与这个寄存器中的值相加,仍然保存在寄存器中;
(3)指令3(mov):最后再把寄存器中的数据写回内存中c的地址。
寄存器的速度也很快,所以能用寄存器就别用内存。尽量充分利用寄存器,是编译器做优化的内容之一。而高速缓存可以弥补CPU的处理速度和内存访问速度之间的差距,所以指令在内存读一个数据时,它不是只读进当前指令所需要的数据,而是把跟这个数据相邻的一组数据都读进高速缓存了。因此写程序时,应尽量把某个操作所需的数据都放在内存中的连续区域中,不要零零散散地到处放,这样有利于充分利用高速缓存。这种优化思路,叫做数据的局部性。
程序在运行时,操作系统会给它分配一块虚拟的内存空间,让它在运行期可以使用。目前使用的都是64位的机器,可以用一个64位的长整型来表示内存地址,它能够表示的所有地址叫做寻址空间。64位机器的寻址空间就有2的64次方那么大,达到TB级别。在存在操作系统的情况下,程序逻辑上可使用的内存一般大于实际的物理内存。程序在使用内存时,操作系统会把程序使用的逻辑地址映射到真实的物理内存地址,有的物理内存区域会映射进多个进程的地址空间,如下图所示:
对于不太常用的内存数据,操作系统会写到磁盘上,以便腾出更多可用的物理内存。当然也存在没有操作系统的情况,这个时候程序所使用的内存就是物理内存,必须自己做好内存的管理。
5. C语言和Java虚拟机对内存的管理和使用策略就是不同的。尽管如此,大多数语言还是会采用一些通用的内存管理模式。以C语言为例,会把内存划分为代码区、静态数据区、栈和堆,如下图所示:
一般来讲,代码区是在最低的地址区域,然后是静态数据区、堆。而栈传统上是从高地址向低地址延伸,栈的最顶部有一块区域,用来保存环境变量。
(1)代码区(也叫文本段)存放编译完成以后的机器码。这个内存区域是只读的不会再修改,但也不绝对。现代语言的运行时已经越来越动态化,除了保存机器码还可以存放中间代码,并且还可以在运行时把中间代码编译成机器码,写入代码区。
(2)静态数据区保存程序中全局的变量和常量。它的地址在编译期就是确定的,在生成的代码里直接使用这个地址就可以访问它们,它们的生存期是从程序启动一直到程序结束。它又可以细分为Data和BSS两个段。Data段中的变量是在编译期就初始化好的,直接从程序装载进内存。BSS段中是那些没有声明初始化值的变量,都会被初始化成0。
(3)堆适合管理生存期较长的一些数据,这些数据在退出作用域以后也不会消失。比如在某个方法里创建了一个对象并返回,并希望代表这个对象的数据在退出函数后仍然可以访问。
(4)栈适合保存生存期比较短的数据,比如函数和方法里的本地变量。它们在进入某个作用域的时候申请内存,退出这个作用域的时候就可以释放掉。
再来看看跟程序打交道的操作系统。一方面程序可以编译成不需要操作系统也能运行,就像一些物联网应用完全跑在裸设备上;另一方面,有了操作系统的帮助可以为程序提供便利,比如可以使用超过物理内存的存储空间,操作系统负责进行虚拟内存的管理。程序在操作系统中需要遵守的约定包括:程序文件的二进制格式约定,这样操作系统才能将程序正确地加载进来,并为同一个程序的多个进程共享代码区。在使用寄存器和栈时也要遵守一些约定,便于操作系统在不同进程之间切换、做系统调用时,做好上下文的保护。
6. 接下来看看程序运行的过程。首先,可运行的程序一般是由操作系统加载到内存的,并且定位到代码区里程序的入口开始执行,比如C语言的main函数的第一行代码。每次加载一条代码程序都会顺序执行,碰到跳转语句才会跳到另一个地址执行。CPU里有一个指令寄存器,里面保存了下一条指令的地址,如下图所示:
假设运行这样一段代码编译后形成的程序:
- int main(){
- int a = 1;
- foo(3);
- bar();
- }
-
- int foo(int c){
- int b = 2;
- return b+c;
- }
-
- int bar(){
- return foo(4) + 1;
- }
上面代码首先激活(Activate)了main()函数,main()函数又激活foo()函数,然后又激活bar()函数,bar()函数还会激活foo()函数,其中foo()函数被两次以不同的路径激活:
把每次调用一个函数的过程,叫做一次活动(Activation)。每个活动都对应一个活动记录(Activation Record),这个活动记录里有这个函数运行所需要的信息,比如参数、返回值、本地变量等。目前用栈来管理内存,所以可以把活动记录等价于栈桢。栈桢是活动记录的实现方式,可以自由设计活动记录或栈桢的结构,下图是一个常见的设计:
(1)返回值:一般放在最顶上,这样它的地址是固定的。foo()函数返回以后,它的调用者可以到这里来取到返回值。在实际情况中会优先通过寄存器来传递返回值,比通过内存传递性能更高。
(2)参数:在调用foo函数时,把参数写到这个地址里。同样也可以通过寄存器来传递,而不是内存。
(3)控制链接:就是上一级栈桢的地址。如果用到了上一级作用域中的变量,就可以顺着这个链接找到上一级栈桢,并找到变量的值。
(4)返回地址:foo函数执行完毕以后,继续执行哪条指令。同样,可以用寄存器来保存这个信息。
(5)本地变量:foo函数的本地变量b的存储空间。
(6)寄存器信息:函数还经常在栈桢里保存寄存器的数据。如果在foo函数里要使用某个寄存器,可能需要先把它的值保存下来,防止破坏了别的代码保存在这里的数据。这种约定叫做被调用者责任,也就是使用寄存器的人要保护好寄存器里原有的信息。某个函数如果使用了某个寄存器,但它又要调用别的函数,为了防止别的函数把自己放在寄存器中的数据覆盖掉,要自己保存在栈桢中。栈的结构如下图所示:
可以看到,栈桢就是一块确定的内存,变量就是这块内存里的地址。每个栈桢的长度是不一样的。用到的参数和本地变量多,栈桢就要长一点。但是栈桢的长度和结构是在编译期就能完全确定的,这样就便于计算地址的偏移量,获取栈桢里某个数据。总的来说栈桢的设计很自由,但是要考虑不同语言编译形成的模块要能够链接在一起,所以还是要遵守一些公共的约定,否则自己写的函数别人就没办法调用了。
7. 了解了栈桢的实现之后,再来看一个更大的场景,从全局的角度看看整个运行过程中都发生了什么,如下图所示:
代码区里存储了一些代码,main函数、bar函数和foo函数各自有一段连续的区域来存储代码,上图中用了一些汇编指令来表示这些代码,不过实际运行时这里其实是机器码。假设执行到foo函数中的一段指令,来计算“b+c”的值并返回,这里用到了mov、add、jmp这三个指令,mov是把某个值从一个地方拷贝到另一个地方,add是往某个地方加一个值,jmp是改变代码执行的顺序,跳转到另一个地方去执行,如下所示:
- mov b的地址 寄存器1
- add c的地址 寄存器1
- mov 寄存器1 foo的返回值地址
- jmp 返回地址 //或ret指令
执行完这几个指令以后,foo的返回值位置就写入了6,并跳转到bar函数中执行foo之后的代码。这时foo的栈桢就没用了,新的栈顶是bar的栈桢的顶部。理论上讲操作系统这时可以把foo的栈桢所占的内存收回了,比如可以映射到另一个程序的寻址空间,让另一个程序使用。但是在这个例子中会看到,即使返回了bar函数仍要访问栈顶之外的一个内存地址,也就是返回值的地址。
所以,目前的调用约定都规定,程序的栈顶之外仍然会有一小块内存(比如128K)是可以由程序访问的,比如可以拿来存储返回值,这一小段内存操作系统并不会回收。堆的使用也类似,只不过是要手工进行申请和释放,比栈要多一些维护工作。
8. 对于静态编译型语言,比如C和Go语言,编译器后端的任务就是生成汇编代码,然后再由汇编器生成机器码,生成的文件叫目标文件,最后再使用链接器就能生成可执行文件或库文件了,如下图所示:
就算像JavaScript这样的解释执行语言,也要在运行时利用类似的机制生成机器码,以便提高执行的速度。Java的字节码,在运行时通常也会通过JIT机制编译成机器码,而汇编语言是完成这些工作的基础。机器语言都是0101的二进制数据,不适合人类阅读,而汇编语言是可读性更好的机器语言,基本上每条指令都可以直接翻译成一条机器码。跟日常使用的高级语言相比,汇编语言的语法特别简单,但它要跟硬件(CPU和内存)打交道。
计算机的处理器有很多不同的架构,比如x86-64、ARM、Power等,每种处理器的指令集都不相同,那也就意味着汇编语言不同。下面用C语言的例子来生成一下汇编代码:
- #include <stdio.h>
- int main(int argc, char* argv[]){
- printf("Hello %s!\n", "Richard");
- return 0;
- }
在macOS中输入下面的命令,其中的-S参数就是告诉编译器把源代码编译成汇编代码,而-O2参数告诉编译器进行2级优化,这样生成的汇编代码会短一些:
clang -S -O2 hello.c -o hello.s
或者:
gcc -S -O2 hello.c -o hello.s
生成的汇编代码是下面的样子:
- .section __TEXT,__text,regular,pure_instructions
- .build_version macos, 10, 14 sdk_version 10, 14
- .globl _main ## -- Begin function main
- .p2align 4, 0x90
- _main: ## @main
- .cfi_startproc
- ## %bb.0:
- pushq %rbp
- .cfi_def_cfa_offset 16
- .cfi_offset %rbp, -16
- movq %rsp, %rbp
- .cfi_def_cfa_register %rbp
- leaq L_.str(%rip), %rdi
- leaq L_.str.1(%rip), %rsi
- xorl %eax, %eax
- callq _printf
- xorl %eax, %eax
- popq %rbp
- retq
- .cfi_endproc
- ## -- End function
- .section __TEXT,__cstring,cstring_literals
- L_.str: ## @.str
- .asciz "Hello %s!\n"
-
- L_.str.1: ## @.str.1
- .asciz "Richard"
-
- .subsections_via_symbols
如果再打下面的命令,就会把这段汇编代码编译成可执行文件(在macOS或Linux执行as命令,就是调用了GNU的汇编器):
- as hello.s -o hello.o //用汇编器编译成目标文件
- gcc hello.o -o hello //链接成可执行文件
- ./hello //运行程序
以上面的代码为例,来看一下汇编语言的组成元素。这段代码里有指令、伪指令、标签和注释四种元素,每个元素单独占一行。指令(instruction)是直接由CPU进行处理的命令,例如:
- pushq %rbp
- movq %rsp, %rbp
其中,开头的一个单词是助记符(mnemonic),后面跟着的是操作数(operand),有多个操作数时以逗号分隔。第二行代码的意思是把数据从这里(源)拷贝到那里(目的)。而伪指令以“.”开头,末尾没有冒号“:”,如下所示:
- .section __TEXT,__text,regular,pure_instructions
- .globl _main
- .asciz "Hello %s!\n"
伪指令是是辅助性的,汇编器在生成目标文件时会用到这些信息,但伪指令不是真正的CPU指令,只是写给汇编器的,每种汇编器的伪指令也不同。
标签以冒号“:”结尾,用于对伪指令生成的数据或指令做标记,例如L_.str:标签是对一个字符串做了标记。其他代码可以访问标签,例如跳转到这个标签所标记的指令,如下所示:
- L_.str: ## @.str
- .asciz "Hello %s!\n"
标签很有用,它可以代表一段代码或者常量的地址(即在代码区或静态数据区中的位置)。可一开始没法知道这个地址的具体值,必须生成目标文件后才能算出来,所以标签会简化汇编代码的编写。第四种元素是注释,以“#”号开头,这跟C语言中以//表示注释语句是一样的。
9. 指令是汇编代码的主要部分,在代码中助记符“movq”、“xorl”中的“mov”和“xor”是指令,而“q”和“l”叫作后缀表示操作数的位数。后缀一共有b, w, l, q四种,分别代表8、16、32和64位,如下图所示:
比如movq中的q代表操作数是8个字节也就是64位的,也就是把8字节从一个地方拷贝到另一个地方,而movl则是拷贝4个字节。而在指令中使用操作数,可以使用四种格式,分别是:立即数、寄存器、直接内存访问和间接内存访问。
(1)立即数以$开头,比如$40。下面这行代码是把40这个数字拷贝到%eax寄存器:
movl $40, %eax
(2)在指令中最常见到的就是对寄存器的访问,GNU的汇编器规定寄存器一定要以%开头。
(3)直接内存访问。当在代码中看到操作数是一个数字时,它其实指的是内存地址,因为数字立即数必须以$开头。另外汇编代码里的标签,也会被翻译成直接内存访问的地址,比如“callq _printf”中的“_printf”是一个函数入口的地址。汇编器会计算出程序装载在内存时,每个字面量和过程的地址。
(4)间接内存访问。带有括号,比如(%rbp)是指%rbp寄存器指向的值所在的地址,即栈基指针指向的栈底中的值所在地址。间接内存访问的完整形式是:“偏移量(基址,索引值,字节数)”这样的格式,其地址是:基址+索引值*字节数+偏移量,例如:8(%rbp),是比%rbp寄存器的值加8;-8(%rbp),是比%rbp寄存器的值减8;(%rbp, %eax, 4)的值,等于%rbp + %eax*4,这个地址格式相当于访问C语言的数组中的元素,数组元素是32位的整数,其索引值是%eax,而数组的起始位置是%rbp,其中字节数只能取1,2,4,8四个值。
对指令的格式有所了解后,接下来再看几个常用的指令:
(1)mov指令。格式为“mov 寄存器|内存|立即数, 寄存器|内存”。这个指令最常用到,用于在寄存器或内存之间传递数据,或者把立即数加载到内存或寄存器。mov指令的第一个参数是源,可以是寄存器、内存或立即数。第二个参数是目的地,可以是寄存器或内存。
(2)lea指令(load effective address)。装载有效地址,格式为:“lea 源,目的”。比如前面例子代码中的leaq指令,是把字符串的地址加载到%rdi寄存器,如下所示:
leaq L_.str(%rip), %rdi
(3)add指令是做加法运算,比如典型的c=a+b这样一个算术运算可能是这样的:
- movl -4(%rbp), %eax #把%rbp-4的值拷贝到%eax
- addl -8(%rbp), %eax #把%rbp-8地址的值加到%eax上
- movl %eax, -12(%rbp) #把%eax的值写到内存地址%rbp-12
这三行代码,分别是操作a、b、c三个变量的地址。它们的地址分别比%rbp的值减4、减8、减12,因此a、b、c三个变量每个都是4个字节长也就是32位,它们是紧挨着存放的,并且是从高地址向低地址延伸的,这是栈的特征。除了add以外,其他算术运算的指令如下所示:
与栈有关的操作如下所示:
过程调用操作如下所示:
10. 在汇编代码中,经常要使用各种以%开头的寄存器的符号。x86-64架构的CPU里有很多寄存器,在代码里最常用的是16个64位的通用寄存器,分别是:
- %rax,%rbx,%rcx,%rdx,%rsi,%rdi,%rbp,%rsp,
- %r8,%r9,%r10,%r11,%r12,%r13,%r14,%r15。
这些寄存器在历史上有各自的用途,比如rax中的“a”是Accumulator(累加器)的意思,这个寄存器是累加寄存器。但随着技术的发展,这些寄存器基本上都成为了通用的寄存器,不限于某种特定的用途。但是为了方便软件的编写,这里还是做了一些约定,给这些寄存器划分了用途。针对x86-64架构有多个调用约定(Calling Convention),包括微软的x64调用约定(Windows)、System V AMD64 ABI(Unix和Linux)等,下面的内容属于后者:
(1)%rax除了其他用途外,通常在函数返回时,把返回值放在这里。
(2)%rsp作为栈指针寄存器,指向栈顶。
(3)%rdi,%rsi,%rdx,%rcx,%r8,%r9给函数传整型参数,依次对应第1参数到第6参数。超过6个参数就放在栈桢里。
(4)如果程序要使用%rbx,%rbp,%r12,%r13,%r14,%r15这几个寄存器,是由被调用者(Callee)负责保护的,也就是写到栈里,在返回的时候要恢复这些寄存器中原来的内容。其他寄存器的内容则是由调用者(Caller)负责保护,如果不想这些寄存器中的内容被破坏,那么要自己保护起来。
上面这些寄存器的名字都是64位的名字,对于每个寄存器还可以只使用它的一部分,并且另起一个名字。比如对于%rax如果使用它的前32位,就叫做%eax,前16位叫%ax,前8位(0到7位)叫%al,8到15位叫%ah,如下图所示:
其他的寄存器也有这样的使用方式,当在汇编代码中看到如下名称时,就知道其实它们有可能在物理上是同一个寄存器:
除了通用寄存器以外,有可能的话还要了解下面的寄存器和用途,写汇编代码时也经常跟它们发生关联:
(1)8个80位的x87寄存器,用于做浮点计算;
(2)8个64位的MMX寄存器,用于MMX指令(即多媒体指令),这8个跟x87寄存器在物理上是相同的寄存器。在传递浮点数参数时要用mmx寄存器。
(3)16个128位的SSE寄存器,用于SSE指令,它涉及到SIMD的概念。
(4)指令寄存器rip,保存指令地址。CPU总是根据这个寄存器来读取指令。
(5)flags(64位:rflags,32位:eflags)寄存器:每个位用来标识一个状态。比如它们会用于比较和跳转的指令,例如if语句翻译成的汇编代码,就会用它们来保存if条件的计算结果。
因此,汇编代码处处要跟寄存器打交道,正确和高效使用寄存器,是编译器后端的重要任务之一。
11. 编译器后端可以基于AST把之前自定义的playscript翻译成正确的汇编代码,并将汇编代码编译成可执行程序。先来看看如何从playscript生成汇编代码,例如支持函数调用和传参、整数的加法运算(要充分利用寄存器提高性能)、变量声明和初始化。具体来说,要能够把下面的示例程序正确生成汇编代码:
- //asm.play
- int fun1(int x1, int x2, int x3, int x4, int x5, int x6, int x7, int x8){
- int c = 10;
- return x1 + x2 + x3 + x4 + x5 + x6 + x7 + x8 + c;
- }
-
- println("fun1:" + fun1(1,2,3,4,5,6,7,8));
功能等价于这段playscript代码的手写汇编代码如下,涉及到如何在多于6个参数的情况下传参,和栈帧的变化过程:
- # function-call2-craft.s 函数调用和参数传递
- # 文本段,纯代码
- .section __TEXT,__text,regular,pure_instructions
-
- _fun1:
- # 函数调用的序曲,设置栈指针
- pushq %rbp # 把调用者的栈帧底部地址保存起来
- movq %rsp, %rbp # 把调用者的栈帧顶部地址,设置为本栈帧的底部
-
- movl $10, -4(%rbp) # 变量c赋值为10,也可以写成 movl $10, (%rsp)
-
- # 做加法
- movl %edi, %eax # 第一个参数放进%eax
- addl %esi, %eax # 加参数2
- addl %edx, %eax # 加参数3
- addl %ecx, %eax # 加参数4
- addl %r8d, %eax # 加参数5
- addl %r9d, %eax # 加参数6
- addl 16(%rbp), %eax # 加参数7
- addl 24(%rbp), %eax # 加参数8
-
- addl -4(%rbp), %eax # 加上c的值
-
- # 函数调用的尾声,恢复栈指针为原来的值
- popq %rbp # 恢复调用者栈帧的底部数值
- retq # 返回
-
- .globl _main # .global伪指令让_main函数外部可见
- _main: ## @main
-
- # 函数调用的序曲,设置栈指针
- pushq %rbp # 把调用者的栈帧底部地址保存起来
- movq %rsp, %rbp # 把调用者的栈帧顶部地址,设置为本栈帧的底部
-
- subq $16, %rsp # 这里是为了让栈帧16字节对齐,实际使用可以更少
-
- # 设置参数
- movl $1, %edi # 参数1
- movl $2, %esi # 参数2
- movl $3, %edx # 参数3
- movl $4, %ecx # 参数4
- movl $5, %r8d # 参数5
- movl $6, %r9d # 参数6
- movl $7, (%rsp) # 参数7
- movl $8, 8(%rsp) # 参数8
-
- callq _fun1 # 调用函数
-
- # 为printf设置参数
- leaq L_.str(%rip), %rdi # 第一个参数是字符串的地址
- movl %eax, %esi # 第二个参数是前一个参数的返回值
-
- callq _printf # 调用函数
-
- # 设置返回值。这句也常用 xorl %esi, %esi 这样的指令,都是置为零
- movl $0, %eax
-
- addq $16, %rsp # 缩小栈
-
- # 函数调用的尾声,恢复栈指针为原来的值
- popq %rbp # 恢复调用者栈帧的底部数值
- retq # 返回
-
- # 文本段,保存字符串字面量
- .section __TEXT,__cstring,cstring_literals
- L_.str: ## @.str
- .asciz "fun1 :%d \n"
12. 接下来动手写程序,从AST翻译成汇编代码,如AsmGen.java中实现加法运算的翻译过程如下:
- case PlayScriptParser.ADD:
- //为加法运算申请一个临时的存储位置,可以是寄存器和栈
- address = allocForExpression(ctx);
- bodyAsm.append("\tmovl\t").append(left).append(", ").append(address).append("\n"); //把左边节点拷贝到存储空间
- bodyAsm.append("\taddl\t").append(right).append(", ").append(address).append("\n"); //把右边节点加上去
- break;
这段代码的含义是:通过allocForExpression()方法,为每次加法运算申请一个临时空间(可以是寄存器,或栈里的一个地址),用来存放加法操作的结果。接着用mov指令把加号左边的值拷贝到这个临时空间,再用add指令加上右边的值。生成汇编代码的过程,基本上就是基于AST拼接字符串,其中bodyAsm变量是一个StringBuffer对象,可以用StringBuffer的toString()方法获得最后的汇编代码。按照上面的逻辑,针对“x1 + x2 + x3 + x4 + x5 + x6 + x7 + x8 + c”这个表达式,形成的汇编代码如下:
- # 过程体
- movl $10, -4(%rbp)
- movl %edi, %eax //x1
- addl %esi, %eax //+x2
- movl %eax, %ebx
- addl %edx, %ebx //+x3
- movl %ebx, %r10d
- addl %ecx, %r10d //+x4
- movl %r10d, %r11d
- addl %r8d, %r11d //+x5
- movl %r11d, %r12d
- addl %r9d, %r12d //+x6
- movl %r12d, %r13d
- addl 16(%rbp), %r13d //+x7
- movl %r13d, %r14d
- addl 24(%rbp), %r14d //+x8
- movl %r14d, %r15d
- addl -4(%rbp), %r15d //+c,本地变量
可以看到上面那段Java解析代码的问题,在于每次执行加法运算时都要占用一个新的寄存器。比如x1+x2使用了%eax,再加x3时使用了%ebx,按照这样的速度寄存器很快就用完了,使用效率显然不高。所以必须要做代码优化。如果只是简单机械地翻译代码,相当于产生了大量的临时变量,每个临时变量都占用了空间,如下所示:
- t1 := x1 + x2;
- t2 := t1 + x3;
- t3 := t2 + x4;
- ...
进行代码优化可以让不再使用的存储位置(t1,t2,t3…)能够复用,从而减少临时变量也减少代码行数,优化后的申请临时存储空间的方法如下:
- //复用前序表达式的存储位置
- if (ctx.bop != null && ctx.expression().size() >= 2) {
- ExpressionContext left = ctx.expression(0);
- String leftAddress = tempVars.get(left);
- if (leftAddress!= null){
- tempVars.put(ctx, leftAddress); //当前节点也跟这个地址关联起来
- return leftAddress;
- }
- }
这段代码的意思是:对于每次加法运算原本都要申请一个寄存器,如果加号左边的节点已经在某个寄存器中,那就直接复用这个寄存器,就不要用新的了。调整以后,生成的汇编代码就跟手写的一样了,而且至始至终只用了%eax一个寄存器,代码数量也减少了一半,优化效果明显,如下所示:
- # 过程体
- movl $10, -4(%rbp)
- movl %edi, %eax
- addl %esi, %eax
- addl %edx, %eax
- addl %ecx, %eax
- addl %r8d, %eax
- addl %r9d, %eax
- addl 16(%rbp), %eax
- addl 24(%rbp), %eax
- addl -4(%rbp), %eax
-
- # 返回值
- # 返回值在之前的计算中,已经存入%eax
对代码如何使用寄存器进行充分优化,是编译器后端一项必须要做的工作。这里只用了很粗糙的方法,不具备实用价值,后面可以看到更好的优化算法。
13. 弄清楚了加法运算的代码翻译逻辑,再看看AsmGen.java中generate()和generateProcedure()方法,看看汇编代码完整的生成逻辑是怎样的。这样可以帮助弄清楚整体脉络和所有的细节,比如函数的标签是怎么生成的,序曲和尾声是怎么加上去的,本地变量的地址是如何计算的,等等,如下所示:
- public String generate() {
- StringBuffer sb = new StringBuffer();
-
- // 1.代码段的头
- sb.append("\t.section __TEXT,__text,regular,pure_instructions\n");
-
- // 2.生成函数的代码
- for (Type type : at.types) {
- if (type instanceof Function) {
- Function function = (Function) type;
- FunctionDeclarationContext fdc = (FunctionDeclarationContext) function.ctx;
- visitFunctionDeclaration(fdc); // 遍历,代码生成到bodyAsm中了
- generateProcedure(function.name, sb);
- }
- }
-
- // 3.对主程序生成_main函数
- visitProg((ProgContext) at.ast);
- generateProcedure("main", sb);
-
- // 4.文本字面量
- sb.append("\n# 字符串字面量\n");
- sb.append("\t.section __TEXT,__cstring,cstring_literals\n");
- for(int i = 0; i< stringLiterals.size(); i++){
- sb.append("L.str." + i + ":\n");
- sb.append("\t.asciz\t\"").append(stringLiterals.get(i)).append("\"\n");
- }
-
- // 5.重置全局的一些临时变量
- stringLiterals.clear();
-
- return sb.toString();
- }
generate()方法是整个翻译程序的入口,它做了几项工作:
(1)生成一个.section伪指令,表明这是一个放文本的代码段。
(2)遍历AST中的所有函数,调用generateProcedure()方法为每个函数生成一段汇编代码。
(3)再接着生成一个主程序的入口。
(4)在一个新的section中,声明一些全局的常量(字面量)。
上面整个程序的结构跟最后生成的汇编代码的结构是一致的。generateProcedure()方法把函数转换成汇编代码,如下所示:
- private void generateProcedure(String name, StringBuffer sb) {
- // 1.函数标签
- sb.append("\n## 过程:").append(name).append("\n");
- sb.append("\t.globl _").append(name).append("\n");
- sb.append("_").append(name).append(":\n");
-
- // 2.序曲
- sb.append("\n\t# 序曲\n");
- sb.append("\tpushq\t%rbp\n");
- sb.append("\tmovq\t%rsp, %rbp\n");
-
- // 3.设置栈顶
- // 16字节对齐
- if ((rspOffset % 16) != 0) {
- rspOffset = (rspOffset / 16 + 1) * 16;
- }
- sb.append("\n\t# 设置栈顶\n");
- sb.append("\tsubq\t$").append(rspOffset).append(", %rsp\n");
-
- // 4.保存用到的寄存器的值
- saveRegisters();
-
- // 5.函数体
- sb.append("\n\t# 过程体\n");
- sb.append(bodyAsm);
-
- // 6.恢复受保护的寄存器的值
- restoreRegisters();
-
- // 7.恢复栈顶
- sb.append("\n\t# 恢复栈顶\n");
- sb.append("\taddq\t$").append(rspOffset).append(", %rsp\n");
-
- // 8.如果是main函数,设置返回值为0
- if (name.equals("main")) {
- sb.append("\n\t# 返回值\n");
- sb.append("\txorl\t%eax, %eax\n");
- }
-
- // 9.尾声
- sb.append("\n\t# 尾声\n");
- sb.append("\tpopq\t%rbp\n");
- sb.append("\tretq\n");
-
- // 10.重置临时变量
- rspOffset = 0;
- localVars.clear();
- tempVars.clear();
- bodyAsm = new StringBuffer();
- }
它的工作包括:
(1)生成函数标签、序曲部分的代码、设置栈顶指针、保护寄存器原有的值等。
(2)接着是函数体,比如本地变量初始化、做加法运算等。
(3)最后是一系列收尾工作,包括恢复被保护的寄存器的值、恢复栈顶指针,以及尾声部分的代码。
最后可以通过-S参数运行playscript.java,将asm.play文件生成汇编代码文件asm.s,再生成和运行可执行文件:
- java play.PlayScript -S asm.play -o asm.s //生成汇编代码
- gcc asm.s -o asm //生成可执行文件
- ./asm //运行可执行文件
14. 到目前为止,已经成功地编译playscript程序,并生成了可执行文件。为了加深对生成可执行文件的理解,再用 playscript生成目标文件,让C语言来调用。这样可以证明playscript生成汇编代码的逻辑是靠谱的,以至于可以用playscript代替C语言来写一个共用模块。
在编程时,经常调用一些公共的库实现一些功能,这些库可能是别的语言写的,但仍然可以调用。这里也可以实现playscript与其他语言的功能共享,在示例程序中实现很简单,微调一下生成的汇编代码,使用“.global _fun1”伪指令让_fun1过程变成全局的,这样其他语言写的程序就可以调用这个_fun1过程,实现功能的重用,如下所示:
- # convention-fun1.s 测试调用约定,_fun1将在外部被调用
- # 文本段,纯代码
- .section __TEXT,__text,regular,pure_instructions
-
- .globl _fun1 # .global伪指令让_fun1函数外部可见
- _fun1:
- # 函数调用的序曲,设置栈指针
- pushq %rbp # 把调用者的栈帧底部地址保存起来
- movq %rsp, %rbp # 把调用者的栈帧顶部地址,设置为本栈帧的底部
-
- movl $10, -4(%rbp) # 变量c赋值为10,也可以写成 movl $10, (%rsp)
-
- # 做加法
- movl %edi, %eax # 第一个参数放进%eax
- addl %esi, %eax # 加参数2
- addl %edx, %eax # 加参数3
- addl %ecx, %eax # 加参数4
- addl %r8d, %eax # 加参数5
- addl %r9d, %eax # 加参数6
- addl 16(%rbp), %eax # 加参数7
- addl 24(%rbp), %eax # 加参数8
-
- addl -4(%rbp), %eax # 加上c的值
-
- # 函数调用的尾声,恢复栈指针为原来的值
- popq %rbp # 恢复调用者栈帧的底部数值
- retq # 返回
接下来再写一个C语言的函数来调用fun1(),其中的extern关键字说明有一个fun1()函数是在另一个模块里实现的:
- /**
- * convention-main.c 测试调用约定。调用一个外部函数fun1
- */
- #include <stdio.h>
-
- //声明一个外部函数,在链接时会在其他模块中找到
- extern int fun1(int x1, int x2, int x3, int x4, int x5, int x6, int x7, int x8);
-
- int main(int argc, char *argv[])
- {
- printf("fun1: %d \n", fun1(1,2,3,4,5,6,7,8));
- return 0;
- }
然后在命令行敲下面两个命令:
- # 编译汇编程序
- as convention-fun1.s -o convention-fun1.o
-
- # 编译C程序
- gcc convention-main.c convention-fun1.o -o convention
(1)第一个命令,把playscript 生成的汇编代码编译成一个二进制目标文件。
(2)第二个命令在编译C程序时,同时也带上这个二进制文件,那么编译器就会找到 un1()函数的定义,并链接到一起。最后生成的可执行文件能够顺利运行。
这里面需要解释一下链接过程,其实高级语言和汇编语言都容易阅读。而二进制文件则是对计算机友好的,便于运行。汇编器可以把每一个汇编文件都编译生成一个二进制的目标文件,或者叫做一个模块,而链接器则把这些模块组装成一个整体。但在C语言生成的那个模块中,调用fun1()函数时它没有办法知道fun1()函数的准确地址,因为这个地址必须是整个文件都组装完毕以后才能计算出来。所以汇编器把这个任务推迟,交给链接器去解决,如下图所示:
编译一个程序,最后的结果是生成可运行的二进制文件。其实生成汇编代码以后,就可以认为编译器的任务完成了,后面的工作其实是由汇编器和链接器完成的。但也可以把整个过程都看做编译过程,了解二进制文件的结构,也为完整地了解整个编译过程划上了句号。当然对二进制文件格式的理解,也是做大型项目编译管理、二进制代码分析等工作的基础。
对于每个操作系统,对于可执行程序的格式要求是不一样的。比如在Linux下,目标文件、共享对象文件、二进制文件都是采用ELF格式。实际上,这些二进制文件的格式跟加载到内存中的程序格式是很相似的,这样可以迅速被操作系统读取,并加载到内存中去,加载速度越快,也就相当于程序的启动速度越快。同内存中的布局一样,在ELF格式中,代码和数据也是分开的。这样做的好处是,程序的代码部分可以在多个进程中共享,不需要在内存里放多份,放一份然后映射到每个进程的代码区就行了。而数据部分则是每个进程都不一样的,所以要为每个进程加载一份。
15. IR的作用是能基于它对接不同语言的前端,也能对接不同的硬件架构,还能做很多的优化。首先来看看IR的特征。IR的意思是中间表达方式,它在高级语言和汇编语言的中间,这意味着它的特征也是处于二者之间的。与高级语言相比,IR丢弃了大部分高级语言的语法特征和语义特征,比如循环语句、if语句、作用域、面向对象等等,它更像高层次的汇编语言;而相比真正的汇编语言,它又不会有那么多琐碎、与具体硬件相关的细节。
IR有很多种类(AST也是一种IR),每种IR都有不同的特点和用途,有的编译器甚至要用到几种不同的IR。这里后端部分所讲的IR,目的是方便执行各种优化算法,并有利于生成汇编,这种IR可以看做是一种高层次的汇编语言,主要体现在:
(1)它可以使用寄存器,但寄存器的数量没有限制;
(2)控制结构也跟汇编语言比较像,比如有跳转语句,分成多个程序块,用标签来标识程序块等;
(3)使用相当于汇编指令的操作码。这些操作码可以一对一地翻译成汇编代码,但有时一个操作码会对应多个汇编指令。
下面来看看一个典型IR:三地址代码(Three Address Code, TAC)。它的优点是很简洁,所以适合用来讨论算法,如下所示:
- x := y op z //二元操作
- x := op y //一元操作
每条三地址代码最多有三个地址,其中两个是源地址(如第一行代码的y和z),一个是目的地址(也就是x),每条代码最多有一个操作(op),例如下面的算术运算代码:
- int a, b, c, d;
- a = b + c * d;
写成TAC格式如下所示:
- t1 := c * d
- a := b + t1
t1是新产生的临时变量。当源代码的表达式中包含一个以上的操作符时,就需要引入临时变量,并把原来的一条代码拆成多条代码。再比如下面的条件语句代码,如下所示:
- int a, b c;
- if (a < b )
- c = b;
- else
- c = a;
- c = c * 2;
转换成TAC如下所示:
- t1 := a < b;
- IfZ t1 Goto L1;
- c := a;
- Goto L2;
- L1:
- c := b;
- L2:
- c := c * 2;
IfZ是检查后面的操作数是否是0,“Z”就是“Zero”的意思。这里使用了标签和Goto语句来进行指令的跳转(Goto 相当于x86-64的汇编指令jmp)。循环语句的例子如下所示:
- int a, b;
- while (a < b){
- a = a + 1;
- }
- a = a + b;
转换成TAC的循环语句如下所示:
- L1:
- t1 := a < b;
- IfZ t1 Goto L2;
- a := a + 1;
- Goto L1;
- L2:
- a := a + b;
所以三地址代码的规则相当简单,可以通过比较简单的转换规则,就能从AST生成TAC。这里三地址代码主要用来描述优化算法,因为它比较简洁易读,操作(指令)的类型很少,书写方式也符合日常习惯。不过这里并不用它来生成汇编代码,是因为它含有的细节信息还是比较少,比如整数是16、32还是64位的?目标机器的架构和操作系统是什么?生成二进制文件的布局是怎样的等等。
这里会用LLVM的IR来承担生成汇编的任务,因为它有能力描述与目标机器(CPU、操作系统)相关的更加具体的信息,准确地生成目标代码,从而真正能够用于生产环境。在正式开始LLVM的介绍前,再讲讲另外几种IR的格式:
(1)四元式。它是与三地址代码等价的另一种表达方式,格式是:(OP,arg1,arg2,result)所以,“a := b + c”就等价于(+,b,c,a)。
(2)逆波兰表达式。它把操作符放到后面,所以也叫做后缀表达式。“b + c”对应的逆波兰表达式是“b c +”;而“a = b + c”对应的逆波兰表达式是“a b c + =”。逆波兰表达式特别适合用栈来做计算。比如计算“b c +”,先从栈里弹出加号,知道要做加法操作,然后从栈里弹出两个操作数,执行加法运算即可。这个计算过程跟深度优先的遍历AST是等价的。所以采用逆波兰表达式,有可能用一个很简单的方式就能实现公式计算功能,如果编写带有公式功能的软件时可以考虑使用它。而且从AST生成逆波兰表达式也非常容易。
16. 三地址代码主要是学习算法的工具,或者用于实现比较简单的后端,要实现工业级的后端,充分发挥硬件的性能,还要学习LLVM的IR。LLVM汇编码(LLVM Assembly)是LLVM的IR。有时就简单地称呼它为LLVM语言,因此可以把用LLVM汇编码书写的一个程序文件叫做LLVM程序。后面会详细提到LLVM,这里只是先了解它的核心即IR。
首先,LLVM汇编码是采用静态单赋值代码形式的。在三地址代码上再加一些限制,就能得到另一种重要的代码,即静态单赋值代码(Static Single Assignment,SSA),在静态单赋值代码中一个变量只能被赋值一次,例如“y = x1 + x2 + x3 + x4”的普通三地址代码如下:
- y := x1 + x2;
- y := y + x3;
- y := y + x4;
其中y被赋值了三次,如果写成SSA的形式,就只能写成下面的样子:
- t1 := x1 + x2;
- t2 := t1 + x3;
- y := t2 + x4;
为什么要费力写成这种形式,还要为此多添加t1和t2临时变量?原因是SSA的形式体现了精确的“使用 - 定义”关系。每个变量很确定地只会被定义一次,然后可以多次使用,这种特点使得基于SSA更容易做数据流分析,而数据流分析又是很多代码优化技术的基础,所以几乎所有语言的编译器、解释器或虚拟机中都使用了SSA,因为有利于做代码优化。而LLVM的IR也采用SSA的形式,也是因为SSA方便做代码优化。
其次,LLVM IR比三地址代码有更多的细节信息。比如整型变量的字长、内存对齐方式等等,所以使用LLVM IR能够更准确地翻译成汇编码。例如下面这段C语言代码:
- int fun1(int a, int b){
- int c = 10;
- return a + b + c;
- }
对应的LLLM汇编码如下:
- ; ModuleID = 'fun1.c'
- source_filename = "fun1.c"
- target datalayout = "e-m:o-i64:64-f80:128-n8:16:32:64-S128"
- target triple = "x86_64-apple-macosx10.14.0"
- ; Function Attrs: noinline nounwind optnone ssp uwtable
- define i32 @fun1(i32, i32) #0 {
- %3 = alloca i32, align 4 //为3个变量申请空间
- %4 = alloca i32, align 4
- %5 = alloca i32, align 4
- store i32 %0, i32* %3, align 4 //参数1赋值给变量1
- store i32 %1, i32* %4, align 4 //参数2赋值给变量2
- store i32 10, i32* %5, align 4 //常量10赋值给变量3
- %6 = load i32, i32* %3, align 4 //
- %7 = load i32, i32* %4, align 4
- %8 = add nsw i32 %6, %7
- %9 = load i32, i32* %5, align 4
- %10 = add nsw i32 %8, %9
- ret i32 %10
- }
- attributes #0 = { noinline nounwind optnone ssp uwtable "correctly-rounded-divide-sqrt-fp-math"="false" "disable-tail-calls"="false" "less-precise-fpmad"="false" "no-frame-pointer-elim"="true" "no-frame-pointer-elim-non-leaf" "no-infs-fp-math"="false" "no-jump-tables"="false" "no-nans-fp-math"="false" "no-signed-zeros-fp-math"="false" "no-trapping-math"="false" "stack-protector-buffer-size"="8" "target-cpu"="penryn" "target-features"="+cx16,+fxsr,+mmx,+sahf,+sse,+sse2,+sse3,+sse4.1,+ssse3,+x87" "unsafe-fp-math"="false" "use-soft-float"="false" }
-
- !llvm.module.flags = !{!0, !1, !2}
- !llvm.ident = !{!3}
-
- !0 = !{i32 2, !"SDK Version", [2 x i32] [i32 10, i32 14]}
- !1 = !{i32 1, !"wchar_size", i32 4}
- !2 = !{i32 7, !"PIC Level", i32 2}
- !3 = !{!"Apple LLVM version 10.0.1 (clang-1001.0.46.4)"}
这些代码看上去确实比三地址代码复杂,但还是比汇编精简多了,比如LLVM IR的指令数量连x86-64汇编的十分之一都不到。来熟悉一下里面的元素:
(1)模块。LLVM程序是由模块构成的,这个文件就是一个模块。模块里可以包括函数、全局变量和符号表中的条目。链接时会把各个模块拼接到一起,形成可执行文件或库文件。在模块中,可以定义目标数据布局(target data layout),例如上面汇编代码开头的小写“e”是低字节序(Little Endian)的意思,对于超过一个字节的数据来说,低位字节排在内存的低地址端,高位字节排放在内存的高地址端:
target datalayout = "e-m:o-i64:64-f80:128-n8:16:32:64-S128"
上面汇编的第二行“target triple”用来定义模块的目标主机,它包括架构、厂商、操作系统三个部分。
(2)函数。在上面汇编中有一个以define开头的函数的声明,还带着花括号,这有点像C语言的写法,比汇编用标签来表示一个函数的可读性更好。函数声明时可以带很多修饰成分,比如链接类型、调用约定等。如果不写,缺省的链接类型是external的,也就是可以暴露出来被其他模块链接。调用约定也有很多种选择,缺省是“ccc”,也就是C语言的调用约定(C Calling Convention),而“swiftcc”则是 swift 语言的调用约定。这些信息都是生成汇编时所需要的。上面汇编中函数fun1还带有“#0”的属性值,里面定义了许多属性,这些也是生成汇编时所需要的。
(3)标识符。分为全局(Glocal)和本地的(Local):全局标识符以@开头,包括函数和全局变量,上面汇编中的@fun1就是;本地标识符以%开头。有的标识符是有名字的比如@fun1或%a,有的是没名字的用数字表示就可以,例如%1。
(4)操作码。alloca、store、load、add、ret这些都是操作码。它们的含义如下图所示:
它们跟之前看到的汇编相似,但是似乎函数体中的代码有点长,一个简单的“a+b+c”就翻译成了10多行代码,还用到了那么多临时变量。不过这只是没经过优化的格式,带上优化参数稍加优化以后,就会被精简成下面的样子:
- define i32 @fun1(i32, i32) local_unnamed_addr #0 {
- %3 = add i32 %0, 10
- %4 = add i32 %3, %1
- ret i32 %4
- }
(5)类型系统。汇编是无类型的。如果用add指令它就认为操作的是整数。而用fadd(或addss)指令,就认为操作的是浮点数。这样会有类型不安全的风险,如果把整型当浮点数用了,造成的后果是计算结果完全错误。LLVM汇编则带有一个类型系统。它能避免不安全的数据操作,并且有助于优化算法。这个类型系统包括基础数据类型、函数类型和void类型。如下图所示:
函数类型是包括对返回值和参数的定义,比如i32 (i32);void类型不代表任何值,也没有长度。
(6)全局变量和常量。在LLVM汇编中可以声明全局变量。全局变量所定义的内存是在编译时就分配好了的,而不是在运行时,例如下面这句定义了一个全局变量C:
@c = global i32 100, align 4
也可以声明常量,它的值在运行时不会被修改,如下所示:
@c = constant i32 100, align 4
(7)元数据。在上面汇编中还看到以“!”开头的一些句子,这些是元数据,它定义了一些额外的信息,提供给优化器和代码生成器使用。
(8)基本块。函数中的代码会分成一个个的基本块,可以用标签(Label)来标记一个基本块。下面这段代码有4个基本块,其中第一个块有一个缺省的名字“entry”,也就是作为入口的基本块,这个基本块不给它标签也可以:
- define i32 @bb(i32) #0 {
- %2 = alloca i32, align 4
- %3 = alloca i32, align 4
- store i32 %0, i32* %3, align 4
- %4 = load i32, i32* %3, align 4
- %5 = icmp sgt i32 %4, 0
- br i1 %5, label %6, label %9
-
- ; <label>:6: ; preds = %1
- %7 = load i32, i32* %3, align 4
- %8 = mul nsw i32 %7, 2
- store i32 %8, i32* %2, align 4
- br label %12
-
- ; <label>:9: ; preds = %1
- %10 = load i32, i32* %3, align 4
- %11 = add nsw i32 %10, 3
- store i32 %11, i32* %2, align 4
- br label %12
-
- ; <label>:12: ; preds = %9, %6
- %13 = load i32, i32* %2, align 4
- ret i32 %13
- }
这段代码实际上相当于下面这段C语言的代码:
- int bb(int b){
- if (b > 0)
- return b * 2;
- else
- return b + 3;
- }
每个基本块是一系列的指令,分析一下标签为9的基本块,从而熟悉一下基本块和LLVM指令的特点:
(1)第一行(%10 = load i32, i32* %3, align 4)的含义是:把3号变量(32位整型)从内存加载到寄存器,叫做10号变量,其中内存对齐是4字节。这里延伸一下,在内存里存放数据时,有时会从2、4、8个字节的整数倍地址开始存。有些汇编指令要求必须从这样对齐的地址来取数据。另一些指令没做要求,但如果是不对齐的,比如从0x03地址取数据,就要花费更多的时钟周期。但内存对齐的缺点是会浪费内存空间。第一行是整个基本块的唯一入口,从其他基本块跳转过来时,只能跳转到这个入口行,不能跳转到基本块中的其他行。
(2)第二行(%11 = add nsw i32 %10, 3)的含义是:把10号变量(32位整型)加上3,保存到11号变量,其中nsw是加法计算时没有符号环绕(No Signed Wrap)的意思。
(3)第三行(store i32 %11, i32* %2, align 4)的含义是:把11号变量(32位整型)存入内存中的2号变量,内存对齐4字节。
(4)第四行(br label %12)的含义是:跳转到标签为12的代码块。其中br指令是一条终结指令。终结指令要么是跳转到另一个基本块,要么是从函数中返回(ret指令),基本块的最后一行必须是一条终结指令。
最后要强调,从其他基本块不可以跳转到入口基本块,即函数中的第一个基本块。这个规定也是有利于做数据优化。
17. 在编译器后端,做代码优化和为每个目标平台生成汇编代码,工作量是很大的,需要利用现成的工具。在前端部分,可以使用Antlr生成词法分析器和语法分析器。在后端部分,也可以利用LLVM和GCC这两个后端框架。LLVM是一个开源编译器基础设施项目,主要聚焦于编译器的后端功能(代码生成、代码优化、JIT等)。在AI领域的TensorFlow框架,在后端也是用LLVM来编译,它把机器学习的IR翻译成LLVM的IR,然后再翻译成支持CPU、GPU和TPU的程序。
与LLVM起到类似作用的后端编译框架是GCC(GNU Compiler Collection,GNU编译器套件)。它支持了GNU Linux上的很多语言,例如C、C++、Objective-C、Fortran、Go和Java语言等。其实,它最初只是一个C语言的编译器,后来把公共的后端功能也提炼出来形成了框架,支持多种前端语言和后端平台。
接下来先来看看LLVM的构成和特点,LLVM能够支持多种语言的前端、多种后端CPU架构。在LLVM内部,使用类型化的和SSA特点的IR进行各种分析、优化和转换,如下图所示:
LLVM项目包含很多组成部分:
(1)LLVM核心(core)。就是上图中的优化和分析工具,还包括了为各种CPU生成目标代码的功能;这些库采用的是LLVM IR,一个良好定义的中间语言,前面部分已经初步了解它了。
(2)Clang前端(是基于 LLVM 的 C、C++、Objective-C 编译器)。
(3)LLDB(一个调试工具)。
(4)LLVM版本的C++标准类库,和其他子项目。
LLVM有良好的模块化设计和接口。以前的编译器后端技术很难复用,而LLVM具备定义了良好接口的库,方便使用者选择在什么时候,复用哪些后端功能。比如针对代码优化,LLVM提供了很多算法,语言的设计者可以自己选择合适的算法,或者实现自己特殊的算法,具有很好的灵活性。LLVM同时支持JIT(即时编译)和AOT(提前编译)两种模式。过去的语言要么是解释型的,要么编译后运行。习惯了使用解释型语言的程序员,很难习惯必须等待一段编译时间才能看到运行效果,习惯在一个REPL界面中一边写脚本,一边实时看到反馈。LLVM既可以通过JIT技术支持解释执行,又可以完全编译后才执行,这对于语言的设计者很有吸引力。
而且,LLVM在设计上支持全过程的优化,包括编译时、链接时、装载时,甚至是运行时。以运行时优化为例,基于LLVM能够在运行时,收集一些性能相关的数据对代码编译优化,可以是实时优化的、动态修改内存中的机器码;也可以收集这些性能数据,然后做离线的优化,重新生成可执行文件,然后再加载执行,在现代计算环境下,每种功能的计算特点都不相同,确实需要针对不同的场景做不同的优化。下图展现了这个过程:
18. 可以使用LLVM自带的命令行工具,分几步体验一下LLVM的功能:
(1)从C语言代码生成IR;
(2)优化IR;
(3)从文本格式的IR生成二进制的字节码;
(4)把IR编译成汇编代码和可执行文件。
从C语言代码生成IR代码比较简单,之前已经用到过一个C语言的示例代码,如下所示:
- //fun1.c
- int fun1(int a, int b){
- int c = 10;
- return a+b+c;
- }
用前端工具Clang就可以把它编译成IR代码,命令如下:
clang -emit-llvm -S fun1.c -o fun1.ll
其中,-emit-llvm参数告诉Clang生成LLVM的汇编码,也就是IR代码(如果不带这个参数,就会生成针对目标机器的汇编码),生成的LLVM IR如下:
- ; ModuleID = 'function-call1.c'
- source_filename = "function-call1.c"
- target datalayout = "e-m:o-i64:64-f80:128-n8:16:32:64-S128"
- target triple = "x86_64-apple-macosx10.14.0"
-
- ; Function Attrs: noinline nounwind optnone ssp uwtable
- define i32 @fun1(i32, i32) #0 {
- %3 = alloca i32, align 4
- %4 = alloca i32, align 4
- %5 = alloca i32, align 4
- store i32 %0, i32* %3, align 4
- store i32 %1, i32* %4, align 4
- store i32 10, i32* %5, align 4
- %6 = load i32, i32* %3, align 4
- %7 = load i32, i32* %4, align 4
- %8 = add nsw i32 %6, %7
- %9 = load i32, i32* %5, align 4
- %10 = add nsw i32 %8, %9
- ret i32 %10
- }
-
- attributes #0 = { noinline nounwind optnone ssp uwtable "correctly-rounded-divide-sqrt-fp-math"="false" "disable-tail-calls"="false" "less-precise-fpmad"="false" "min-legal-vector-width"="0" "no-frame-pointer-elim"="true" "no-frame-pointer-elim-non-leaf" "no-infs-fp-math"="false" "no-jump-tables"="false" "no-nans-fp-math"="false" "no-signed-zeros-fp-math"="false" "no-trapping-math"="false" "stack-protector-buffer-size"="8" "target-cpu"="penryn" "target-features"="+cx16,+fxsr,+mmx,+sahf,+sse,+sse2,+sse3,+sse4.1,+ssse3,+x87" "unsafe-fp-math"="false" "use-soft-float"="false" }
-
- !llvm.module.flags = !{!0, !1}
- !llvm.ident = !{!2}
-
- !0 = !{i32 1, !"wchar_size", i32 4}
- !1 = !{i32 7, !"PIC Level", i32 2}
- !2 = !{!"clang version 8.0.0 (tags/RELEASE_800/final)"}
前面提到过可以对生成的IR做优化,让代码更短,只要在上面的命令中加上-O2参数就可以了(这时完成了上面功能步骤的第二步):
clang -emit-llvm -S -O2 fun1.c -o fun1.ll
这个时候函数体的核心代码就变短了很多。这里面最重要的优化动作,是从原来使用内存(alloca指令是在栈中分配空间,store指令是往内存里写入值),优化到只使用寄存器(%0、%1是参数,%3、%4也是寄存器),如下所示:
- define i32 @fun1(i32, i32) #0 {
- %3 = add nsw i32 %0, %1
- %4 = add nsw i32 %3, 10
- ret i32 %4
- }
还可以用opt命令来完成上面的优化,具体在后面讲优化算法的部分再细化。另外,LLVM的IR有两种格式,在示例代码中显示的是它的文本格式,文件名一般以.ll结尾;第二种格式是字节码(bitcode)格式,文件名以.bc结尾。为什么要用两种格式呢?因为文本格式的文件便于程序员阅读,而字节码格式的是二进制文件,便于机器处理,比如即时编译和执行。生成字节码格式之后,所占空间会小很多,所以可以快速加载进内存,并转换为内存中的对象格式。而如果加载文本文件,则还需要一个解析的过程,才能变成内存中的格式,效率比较慢。调用llvm-as命令,可以把文本格式转换成字节码格式:
llvm-as fun1.ll -o fun1.bc
也可以用clang直接生成字节码,这时不需要带-S参数而是要用-c参数,如下所示:
clang -emit-llvm -c fun1.c -o fun1.bc
因为.bc文件是二进制文件,不能直接用文本编辑器查看,而要用hexdump命令查看(这时完成了上面功能步骤的第三步):
hexdump -C fun1.bc
LLVM的一个优点,就是可以即时编译运行字节码,不一定非要编译生成汇编码和可执行文件才能运行(这一点有点像Java语言),这也让LLVM具有极高的灵活性,比如可以在运行时根据收集的性能信息,改变优化策略,生成更高效的机器码。再进一步,可以把字节码编译成目标平台的汇编代码,使用的是llc命令,如下所示:
llc fun1.bc -o fun1.s
用clang命令也能从字节码生成汇编代码,要注意带上-S参数就行了,如下所示:
clang -S fun1.bc -o fun1.s
到了这一步已经得到了汇编代码,接着就可以进一步生成目标文件和可执行文件了。实际上,使用LLVM从源代码到生成可执行文件有两条可能的路径:
(1)第一条路径,是把每个源文件分别编译成字节码文件,然后再编译成目标文件,最后链接成可执行文件。
(2)第二条路径,是先把编译好的字节码文件链接在一起,形成一个更大的字节码文件,然后对这个字节码文件进行进一步的优化,之后再生成目标文件和可执行文件。
第二条路径比第一条路径多了一个优化的步骤,第一条路径只对每个模块做了优化,没有做整体的优化。所以,如有可能尽量采用第二条路径,这样能够生成更加优化的代码。现在完成了上面功能步骤的第四步,总结一下,用到的命令行工具包括:clang前端、llvm-as、llc,其他命令还有opt(代码优化)、llvm-dis(将字节码再反编译回ll文件)、llvm-link(链接)等。
19. 上面已经初步了解了LLVM和它的IR,也能够使用它的命令行工具。不过还是要通过写程序来生成LLVM的 IR,这样才能复用LLVM的功能,从而实现一门完整的语言。不过,如果要像前面生成汇编语言那样,通过字符串拼接来生成LLVM的IR,除了要了解LLVM IR的很多细节之外,代码一定比较啰嗦和复杂,因为字符串拼接不是结构化的方法,所以最好用一个定义良好的数据结构来表示IR。
好在LLVM项目已经考虑到了这一点,它提供了代表LLVM IR的一组对象模型,只要生成这些对象就相当于生成了IR,这个难度就低多了。而且LLVM还提供了一个工具类IRBuilder,可以利用它进一步提升创建LLVM IR的对象模型的效率。
接下来,就先来了解LLVM IR的对象模型。LLVM在内部有用C++实现的对象模型能够完整表示LLVM IR,当把字节码读入内存时,LLVM就会在内存中构建出这个模型。只有基于这个对象模型才可以做进一步的工作,包括代码优化、实现即时编译和运行、以及静态编译生成目标文件。所以说,这个对象模型是LLVM运行时的核心,如下图所示:
IR对象模型的头文件在include/llvm/IR目录下,其中最重要的类包括:
(1)Module(模块)。Module类聚合了一个模块中的所有数据,它可以包含多个函数。可以通过Model::iterator来遍历模块中所有的函数,它也包含了一个模块的全局变量。
(2)Function(函数)。Function包含了与函数定义(definition)或声明(declaration)有关的所有对象。函数定义包含了函数体,而函数声明则仅仅包含了函数的原型,它是在其他模块中定义的,在本模块中使用。可以通过getArgumentList()方法来获得函数参数的列表,也可以遍历函数体中的所有基本块,这些基本块会形成一个CFG(控制流图),如下所示:
- //函数声明,没有函数体。这个函数是在其他模块中定义的,在本模块中使用
- declare void @foo(i32)
-
- //函数定义,包含函数体
- define i32 @fun3(i32 %a) {
- %calltmp1 = call void @foo(i32 %a) //调用外部函数
- ret i32 10
- }
(3)BasicBlock(基本块)。BasicBlock封装了一系列的LLVM指令,可以借助bigin()/end()模式遍历这些指令,还可以通过getTerminator()方法获得最后一条指令(即终结指令)。还可以用到几个辅助方法在CFG中导航,比如获得某个基本块的前序基本块。
(4)Instruction(指令)。Instruction类代表了LLVM IR的原子操作(即一条指令),可以通过getOpcode()来获得它代表的操作码,它是一个llvm::Instruction枚举值,可以通过op_begin()和op_end()方法对获得这个指令的操作数。
(5)Value(值)。Value类代表一个值。在LLVM的内存IR中,如果一个类是从Value继承的,意味着它定义了一个值,其他方可以去使用。函数、基本块和指令都继承了Value。
(6)LLVMContext(上下文)。这个类代表了LLVM做编译工作时的一个上下文,包含了编译工作中的一些全局数据,比如各个模块用到的常量和类型。
这些内容是LLVM IR对象模型的主要部分,生成IR的过程,就是跟这些类打交道。
20. 接下来,就用程序来生成LLVM的IR。刚刚提到的每个LLVM IR类都可以通过程序来构建。那么为下面这个fun1()函数生成IR,应该怎么办呢:
- int fun1(int a, int b){
- return a+b;
- }
(1)第一步,可以来生成一个LLVM模块,也就是顶层的IR对象。如下所示:
Module *mod = new Module("fun1.ll", TheModule);
(2)第二步,继续在模块中定义函数fun1,因为模块最主要的构成要素就是各个函数。不过在定义函数之前,要先定义函数的原型(或者叫函数的类型)。函数的类型在前端部分讲过:如果两个函数的返回值相同,并且参数也相同,这两个函数的类型是相同的,这样就可以做函数指针或函数型变量的赋值。上面示例代码的函数原型是:返回值是32位整数,参数是两个32位整数。有了函数原型以后,就可以使用这个函数原型定义一个函数。还可以为每个参数设置一个名称,便于后面引用这个参数,如下所示:
- //函数原型
- vector<Type *> argTypes(2, Type::getInt32Ty(TheContext));
- FunctionType *fun1Type = FunctionType::get(Type::getInt32Ty(TheContext), //返回值是整数
- argTypes, //两个整型参数
- false); //不是变长参数
-
- //函数对象
- Function *fun = Function::Create(fun1Type,
- Function::ExternalLinkage, //链接类型
- "fun2", //函数名称
- TheModule.get()); //所在模块
-
- //设置参数名称
- string argNames[2] = {"a", "b"};
- unsigned i = 0;
- for (auto &arg : fun->args()){
- arg.setName(argNames[i++]);
- }
这里需要注意,代码中是如何使用变量类型的。所有的基础类型都是提前定义好的,可以通过Type类的getXXXTy()方法获得。
(3)第三步,创建一个基本块。这个函数只有一个基本块,可以把它命名为“entry”,也可以不给它命名。在创建了基本块之后,用了一个辅助类IRBuilder设置了一个插入点,后序生成的指令会插入到这个基本块中(IRBuilder是LLVM为了简化IR生成过程所提供的一个辅助类),如下所示:
- //创建一个基本块
- BasicBlock *BB = BasicBlock::Create(TheContext,//上下文
- "", //基本块名称
- fun); //所在函数
- Builder.SetInsertPoint(BB); //设置指令的插入点
(4)第四步,生成"a+b"表达式所对应的IR,插入到基本块中。a和b都是函数fun的参数,把它取出来分别赋值给L和R(L和R是Value)。然后用IRBuilder的CreateAdd()方法,生成一条add指令。这个指令的计算结果存放在addtemp中,如下所示:
- //把参数变量存到NamedValues里面备用
- NamedValues.clear();
- for (auto &Arg : fun->args())
- NamedValues[Arg.getName()] = &Arg;
-
- //做加法
- Value *L = NamedValues["a"];
- Value *R = NamedValues["b"];
- Value *addtmp = Builder.CreateAdd(L, R);
(5)第五步,利用刚才获得的addtmp创建一个返回值,如下所示:
- //返回值
- Builder.CreateRet(addtmp);
(6)最后一步,检查这个函数的正确性。这相当于是做语义检查,比如,基本块的最后一个语句就必须是一个正确的返回指令:
- //验证函数的正确性
- verifyFunction(*fun);
上述步骤的代码形成codegen_fun1()方法,可以调用这个方法然后打印输出生成的IR:
- Function *fun1 = codegen_fun1(); //在模块中生成Function对象
- TheModule->print(errs(), nullptr); //在终端输出IR
生成的IR如下:
- ; ModuleID = 'llvmdemo'
- source_filename = "llvmdemo"
- define i32 @fun1(i32 %a, i32 %b) {
- %1 = add i32 %a, %b
- ret i32 %1
- }
21. 为了熟悉更多的API,接下来再生成一个带有if语句的IR,然后看一看函数中包含多个基本块的情况。例如为下面的一个函数生成IR:
- int fun_ifstmt(int a)
- if (a > 2)
- return 2;
- else
- return 3;
- }
这样的一个函数,需要包含4个基本块:入口基本块、Then基本块、Else基本块和Merge基本块。控制流图(CFG)是先分开再合并,像下面这样:
在入口基本块中要计算“a>2”的值,并根据这个值分别跳转到ThenBB和ElseBB。这里用到了IRBuilder的CreateICmpUGE()方法(UGE是”不大于等于“,也就是小于)。这个指令的返回值是一个1位的整型,也就是int1,如下所示:
- //计算a>2
- Value * L = NamedValues["a"];
- Value * R = ConstantInt::get(TheContext, APInt(32, 2, true));
- Value * cond = Builder.CreateICmpUGE(L, R, "cmptmp");
接下来创建另外3个基本块,并用IRBuilder的CreateCondBr()方法创建条件跳转指令:当cond是1时跳转到ThenBB,0时跳转到ElseBB,如下所示:
- BasicBlock *ThenBB =BasicBlock::Create(TheContext, "then", fun);
- BasicBlock *ElseBB = BasicBlock::Create(TheContext, "else");
- BasicBlock *MergeBB = BasicBlock::Create(TheContext, "ifcont");
- Builder.CreateCondBr(cond, ThenBB, ElseBB);
细心的话可能会发现,在创建ThenBB时指定了其所在函数是fun,而其他两个基本块没有指定。这是因为接下来就要为ThenBB生成指令,所以先加到fun中。之后再顺序添加ElseBB和MergeBB到fun中,如下所示:
- //ThenBB
- Builder.SetInsertPoint(ThenBB);
- Value *ThenV = ConstantInt::get(TheContext, APInt(32, 2, true));
- Builder.CreateBr(MergeBB);
-
- //ElseBB
- fun->getBasicBlockList().push_back(ElseBB); //把基本块加入到函数中
- Builder.SetInsertPoint(ElseBB);
- Value *ElseV = ConstantInt::get(TheContext, APInt(32, 3, true));
- Builder.CreateBr(MergeBB);
在ThenBB和ElseBB这两个基本块的代码中,分别计算出了两个值:ThenV和ElseV。它们都可能是最后的返回值,但具体采用哪个还要看实际运行时,控制流走的是ThenBB还是ElseBB。这就需要用到phi指令,它完成了根据控制流来选择合适的值的任务,如下所示:
- //MergeBB
- fun->getBasicBlockList().push_back(MergeBB);
- Builder.SetInsertPoint(MergeBB);
- //PHI节点:整型,两个候选值
- PHINode *PN = Builder.CreatePHI(Type::getInt32Ty(TheContext), 2);
- PN->addIncoming(ThenV, ThenBB); //前序基本块是ThenBB时,采用ThenV
- PN->addIncoming(ElseV, ElseBB); //前序基本块是ElseBB时,采用ElseV
-
- //返回值
- Builder.CreateRet(PN);
从上面这段代码中能看出,在if语句中phi指令是关键。因为当程序的控制流经过多个基本块,每个基本块都可能改变某个值时,通过phi指令可以知道运行时实际走的是哪条路径,从而获得正确的值。最后生成的IR如下,其中的phi指令指出,如果前序基本块是then取值为2,是else时取值为3:
- define i32 @fun_ifstmt(i32 %a) {
- %cmptmp = icmp uge i32 %a, 2
- br i1 %cmptmp, label %then, label %else
-
- then: ; preds = %0
- br label %ifcont
-
- else: ; preds = %0
- br label %ifcont
-
- ifcont: ; preds = %else, %then
- %1 = phi i32 [ 2, %then ], [ 3, %else ]
- ret i32 %1
- }
循环语句也跟if语句差不多,因为它们都是要涉及到多个基本块,都要用到phi指令。
22. 在写程序时,本地变量是必不可少的一个元素,所以把刚才的示例程序变化一下,用本地变量b保存ThenBB和ElseBB中计算的值,借此学习一下LLVM IR是如何支持本地变量的。改变后的示例程序如下:
- int fun_localvar(int a)
- int b = 0;
- if (a > 2)
- b = 2;
- else
- b = 3;
- return b;
- }
现在挑战来了,在这段代码中b被声明了一次,赋值了3次。前面知道LLVM IR采用的是SSA形式,也就是每个变量只允许被赋值一次,那么对于多次赋值的情况该如何生成IR呢?其实,LLVM规定了对寄存器只能做单次赋值,而对内存中的变量是可以多次赋值的。对于“int b = 0;”,用下面几条语句生成IR:
- //本地变量b
- AllocaInst *b = Builder.CreateAlloca(Type::getInt32Ty(TheContext), nullptr, "b");
- Value* initValue = ConstantInt::get(TheContext, APInt(32, 0, true));
-
- Builder.CreateStore(initValue, b);
上面这段代码的含义是:首先用CreateAlloca()方法在栈中申请一块内存,用于保存一个32位的整型,接着用CreateStore()方法生成一条store指令,给b赋予初始值。上面几句生成的IR如下:
- %b = alloca i32
- store i32 0, i32* %b
接着,可以在ThenBB和ElseBB中,分别对内存中的b赋值,如下所示:
- //ThenBB
- Builder.SetInsertPoint(ThenBB);
- Value *ThenV = ConstantInt::get(TheContext, APInt(32, 2, true));
- Builder.CreateStore(ThenV, b);
- Builder.CreateBr(MergeBB);
-
- //ElseBB
- fun->getBasicBlockList().push_back(ElseBB);
- Builder.SetInsertPoint(ElseBB);
- Value *ElseV = ConstantInt::get(TheContext, APInt(32, 3, true));
- Builder.CreateStore(ElseV, b);
- Builder.CreateBr(MergeBB);
最后在MergeBB中,只需要返回b就可以了,如下所示:
- //MergeBB
- fun->getBasicBlockList().push_back(MergeBB);
- Builder.SetInsertPoint(MergeBB);
-
- //返回值
- Builder.CreateRet(b);
最后生成的IR如下:
- define i32 @fun_ifstmt.1(i32 %a) {
- %b = alloca i32
- store i32 0, i32* %b
- %cmptmp = icmp uge i32 %a, 2
- br i1 %cmptmp, label %then, label %else
-
- then: ; preds = %0
- store i32 2, i32* %b
- br label %ifcont
-
- else: ; preds = %0
- store i32 3, i32* %b
- br label %ifcont
-
- ifcont: ; preds = %else, %then
- ret i32* %b
- }
当然,使用内存保存临时变量的性能比较低,但可以很容易通过优化算法,把上述代码从使用内存的版本,优化成使用寄存器的版本。
23. 现在已经能够在内存中建立LLVM的IR对象了,包括模块、函数、基本块和各种指令。LLVM可以即时编译并执行这个IR模型。接下来先创建一个不带参数的__main()函数作为入口,同时会借这个例子延伸一下函数的调用。在前面声明了函数fun1,现在在__main()函数中演示如何调用它,如下所示:
- Function * codegen_main(){
- //创建main函数
- FunctionType *mainType = FunctionType::get(Type::getInt32Ty(TheContext), false);
- Function *main = Function::Create(mainType, Function::ExternalLinkage, "__main", TheModule.get());
-
- //创建一个基本块
- BasicBlock *BB = BasicBlock::Create(TheContext, "", main);
- Builder.SetInsertPoint(BB);
-
- //设置参数的值
- int argValues[2] = {2, 3};
- std::vector<Value *> ArgsV;
- for (unsigned i = 0; i<2; ++i) {
- Value * value = ConstantInt::get(TheContext, APInt(32,argValues[i],true));
- ArgsV.push_back(value);
- if (!ArgsV.back())
- return nullptr;
- }
-
- //调用函数fun1
- Function *callee = TheModule->getFunction("fun1");
- Value * rtn = Builder.CreateCall(callee, ArgsV, "calltmp");
-
- //返回值
- Builder.CreateRet(rtn);
- return main;
- }
调用函数时,首先从模块中查找出名称为fun1的函数,准备好参数值,然后通过IRBuilder的CreateCall()方法来生成函数调用指令。最后生成的IR如下:
- define i32 @__main() {
- %calltmp = call i32 @fun1(i32 2, i32 3)
- ret i32 %calltmp3
- }
接下来,调用即时编译的引擎来运行__main函数。使用这个JIT引擎,需要做几件事情:
(1)初始化与目标硬件平台有关的设置,如下所示:
- InitializeNativeTarget();
- InitializeNativeTargetAsmPrinter();
- InitializeNativeTargetAsmParser();
(2)把创建的模型加入到JIT引擎中,找到__main()函数的地址(整个过程跟C语言中使用函数指针来执行一个函数没有太大区别),如下所示:
- auto H = TheJIT->addModule(std::move(TheModule));
-
- //查找__main函数
- auto main = TheJIT->findSymbol("__main");
-
- //获得函数指针
- int32_t (*FP)() = (int32_t (*)())(intptr_t)cantFail(main.getAddress());
-
- //执行函数
- int rtn = FP();
-
- //打印执行结果
- fprintf(stderr, "__main: %d\n", rtn);
(3)程序可以成功执行,并打印__main函数的返回值。
既然已经演示了如何调用函数,这里揭示LLVM的一个惊人特性:可以在LLVM IR里调用本地编写的函数,比如编写一个foo()函数,用来打印输出一些信息:
- void foo(int a){
- printf("in foo: %d\n",a);
- }
然后就可以在__main里直接调用这个foo函数,就像调用fun1函数一样,如下所示:
- //调用一个外部函数foo
- vector<Type *> argTypes(1, Type::getInt32Ty(TheContext));
- FunctionType *fooType = FunctionType::get(Type::getVoidTy(TheContext), argTypes, false);
-
- Function *foo = Function::Create(fooType, Function::ExternalLinkage, "foo", TheModule.get());
-
- std::vector<Value *> ArgsV2;
- ArgsV2.push_back(rtn);
- if (!ArgsV2.back())
- return nullptr;
-
- Builder.CreateCall(foo, ArgsV2, "calltmp2");
注意,在这里只对foo函数做了声明,并没有定义它的函数体,这时LLVM会在外部寻找foo的定义,它会找到用C++编写的foo函数,然后调用并执行;如果foo函数在另一个目标文件中,它也可以找到。
24. 代码优化是编译器后端的两大工作之一(另一个是代码生成)。代码优化的目标,是优化程序对计算机资源的使用。平常最关心的就是CPU资源,最大效率地利用CPU资源可以提高程序的性能。代码优化有时候还会有其他目标,比如代码大小、内存占用大小、磁盘访问次数、网络通讯次数等等。从代码优化的对象看,大多数的代码优化都是在IR上做的,而不是在前一阶段的AST和后一阶段汇编代码上进行的,为什么呢?
其实,在AST上也能做一些优化,比如在编译器前端,会把一些不必要的AST层次削减掉(例如add->mul->pri->Int,每个父节点只有一个子节点,可以直接简化为一个Int节点),但它抽象层次太高,含有的硬件架构信息太少,难以执行很多优化算法。 在汇编代码上进行优化会让算法跟机器相关,当换一个目标机器时还要重新编写优化代码。所以,在IR上是最合适的,它能尽量做到机器独立,同时又暴露出很多的优化机会。
从优化的范围看,分为本地优化、全局优化和过程间优化。优化通常针对一组指令,最常用也最重要的指令组就是基本块。基本块的特点是:每个基本块只能从入口进入,从最后一条指令退出,每条指令都会被顺序执行。因这个特点,在做某些优化时会比较方便。比如针对下面的基本块,可以很安全地把第3行的“y:=t+x”改成“y:= 3 * x”,因为t的赋值一定是在y的前面:
- BB1:
- t:=2 * x
- y:=t + x
- Goto BB2
这种针对基本块的优化,叫做本地优化(Local Optimization)。那么另一个问题是能否把第二行的“t:=2 * x”也优化删掉呢?这取决于是否有别的代码会引用t。所以需要进行更大范围的分析,才能决定是否把第二行优化掉。超越基本块的范围进行分析,需要用到控制流图(Control Flow Graph,CFG)。CFG是一种有向图,它体现了基本块之间的指令流转关系。如果从BB1的最后一条指令是跳转到BB2,那么从BB1到BB2就有一条边。一个函数(或过程)里如果包含多个基本块,可以表达为一个CFG,如下图所示:
如果通过分析CFG,发现t在其他地方没有被使用,就可以把第二行删掉。这种针对一个函数、基于CFG的优化,叫做全局优化(Global Optimization)。比全局优化更大范围的优化,叫做过程间优化(Inter-procedural Optimization),它能跨越函数的边界,对多个函数之间的关系进行优化,而不是仅针对一个函数做优化。
但不需要每次都把代码优化做彻底,因为做代码优化本身也需要消耗计算机的资源。所以,需要权衡代码优化带来的好处和优化本身的开销这两个方面,然后确定做多少优化。比如,在浏览器里加载JavaScript时,JavaScript引擎一定会对语法做优化,但如果优化消耗的时间太长界面的响应会变慢,反倒影响用户使用页面的体验,所以JavaScript引擎做优化时要掌握合适的度或调整优化时机。
接下来认识一些常见的代码优化场景:
(1)代数优化(Algebraic Optimazation)。它是最简单的一种优化,当操作符是代数运算时,可以根据数学知识进行优化。比如“x:=x+0”这行代码,操作前后x没有任何变化,所以这样的代码可以删掉;又比如“x:=x*0”可以简化成“x:=0”;对某些机器来说移位运算的速度比乘法快,那么“x:=x*8”可以优化成“x:=x<<3”。
(2)常数折叠(Constant Folding)。它是指对常数的运算可以在编译时计算,比如 “x:= 20 * 3”可以优化成“x:=60”。另外在if条件中,如果条件是一个常量,那就可以确定地取某个分支。比如:“If 2>0 Goto BB2”可以简化成“Goto BB2”就好了。
(3)删除不可达的基本块。有些代码永远不可能被激活,比如在条件编译的场景中会写这样的程序:“if(DEBUG) {…}”。如果编译时DEBUG是一个常量false,那这个代码块就没必要编译了。
(4)删除公共子表达式(Common Subexpression Elimination)。下面这两行代码,x和 y右边的形式是一样的,如果这两行代码之间,a和b的值没有发生变化(比如采用SSA形式),那么x和y的值一定是一样的:
- x := a + b
- y := a + b
那就可以让y等于x,从而减少了一次“a+b”的计算,如下所示:
- x := a + b
- y := x
(5)拷贝传播(Copy Propagation)和常数传播(Constant Propagation)。下面的代码中,第三行可以被替换成“z:= 2 * x”, 因为y的值就等于x,这叫做拷贝传播:
- x := a + b
- y := x
- z := 2 * y
如果y := 10,常数10也可以传播下去,把最后一行替换成z:= 2 * 10,这叫做常数传播。再做一次常数折叠,就变成z:=20了。
(6)死代码删除(Ded code elimination)。在上面的拷贝传播中,如果没有其他地方使用y变量了,那么第二行就是死代码,就可以删除掉。
一个优化可能导致另一个优化,比如拷贝传播导致 y不再被使用,又可以进行死代码删除的优化。所以一般进行多次优化、多次扫描。
25. 上面这些优化场景,可以用于本地优化、全局优化和过程间优化。这里先看如何做本地优化,后面再接着讨论全局优化。假设下面的代码是一个基本块(省略最后的终结指令):
- a := b
- c := a + b
- c := b
- d := a + b
- e := a + b
为了优化它们,方法是计算一个“可用表达式(available expression)”的集合。可用表达式是指存在一个变量,保存着某个表达式的值。从上到下顺序计算这个集合:
(1)一开始是空集。
(2)经过第一行代码后,集合里增加了“a:=b”;
(3)经过第二行代码后,增加了“c:=a+b”。
(4)在经过第三行代码以后,由于变量c的定义变了,所以“c:=a+b”不再可用,而是换成了“c:=b”,如下图所示:
(5)能看到代码“e:=a+b”,和集合中的“d:=a+b”等号右边部分是相同的,所以首先可以删除公共子表达式,优化成“e:=d”。变成下面这样:
(6)然后可以做一下拷贝传播,利用“a:=b”,把表达式中的多个a都替换成b,如下图所示:
到目前为止,a都被替换成了b,对e的计算也简化了,优化后的代码变成了下面这样:
- a := b
- c := b + b
- c := b
- d := b + b
- e := d
观察一下这段代码,它似乎还存在可优化的空间,比如会存在死代码,可以将其删除。假设在后序的基本块中,b和c仍然会被使用,但其他变量就不会再被用到了。那么上面这5行代码哪行能被删除呢?这时要做活跃性分析(Liveness Analysis)。一个变量是活的,意思是它的值在改变前会被其他代码读取(对于SSA格式的IR,变量定义出来之后就不会再改变,所以只要看后面的代码有没有使用这个变量就可以了)。这里会分析每个变量的活跃性,把死的变量删掉。
这次还是要借助一个集合,不过这个集合是从后向前、倒序计算的,如下图所示:
一开始集合里的元素是{b, c},这是初始值表示b和c会被后面的代码使用,所以它们是活的。
(1)扫描过“e := d”后,因为用到了d所以d是活的,结果是{b, c, d}。
(2)再扫描“d := b + b”用到了b,但集合里已经有b了;这里给d赋值了,已经满足了后面代码对d的要求,所以可以从集合里去掉d了,结果是{b,c}。
(3)再扫描“c := b”,从集合里去掉c结果是{b}。
(4)继续扫描一直到第一行,最后的集合仍然是{b}。
现在基于这个集合,就可以做死代码删除了。当给一个变量赋值时,它后面跟着的集合没有这个变量,说明它不被需要,就可以删掉了。下图中标红色的三行都是死代码,都可以删掉:
删掉以后只剩下了两行代码。注意由于“e := d”被删掉了,导致d也不再被需要,变成了死变量,如下图所示:
把变量d删掉以后,就剩下了一行代码“c := b”了:
到此为止完成了整个的优化过程,5行代码优化成了1行代码。总结一下这个优化过程:
(1)首先做一个正向扫描,进行可用表达式分析,建立可用表达式的集合,然后参照这个集合替换公共子表达式,以及做拷贝传播。
(2)接着做一个反向扫描,进行活跃性分析,建立活变量的集合识别出死变量,并依据它删除给死变量赋值的代码。
(3)上述优化可能需要做不止一遍,才能得到最后的结果。
上面做的优化是基于一段顺序执行的代码,没有跳转,都是属于一个基本块的,属于本地优化。本地优化中,可用表达式分析和活跃性分析都可以看做是由下面4个元素构成的:
(1)D(方向)。是朝前还是朝后遍历。
(2)V(值)。代码的每一个地方都要计算出一个值。可用表达式分析和活跃性分析的值是一个集合,也有些分析的值并不是集合。
(3)F(转换函数,对V进行转换)。比如在做可用表达式分析时,遇到“c := b”时,可用表达式的集合从{a := b, c := a + b}转换成了{a := b, c := b}。这里遵守的转换规则是:因为变量c被重新赋值了,那么就从集合里把变量c原来的定义去掉,并把带有c的表达式都去掉,因为过去的c已经失效了,然后把变量c新的定义加进去。
(4)I(初始值,是算法开始时V的取值)。做可用表达式分析时初始值是空集。在做活跃性分析时初始值是后面代码中还会访问的变量,也就是活变量。
26. 上面这样形式化以后,就可以按照这个模型来统一理解各种本地优化算法。接下来熟悉一下LLVM的优化功能。前面曾经用Clang命令带上O2参数来生成优化的IR:
clang -emit-llvm -S -O2 fun1.c -o fun1-O2.ll
实际上,LLVM还有一个单独的命令opt来做代码优化。缺省情况下它的输入和输出都是.bc文件,所以还要在.bc和.ll两种格式之间做转换,如下所示:
- clang -emit-llvm -S fun1.c -o fun1.ll //生成LLVM IR
- llc fun1.ll -o fun1.bc //编译成字节码
- opt -O2 fun1.bc -o fun1-O2.bc //做O2级的优化
- llvm-dis fun1-O2.bc -o fun1-O2.ll //将字节码反编译成文本格式
其中要注意的一点,是要把第一行命令生成的fun1.ll文件中的“optone”这个属性去掉,因为这个它的意思是不要代码优化。还可以简化上述操作,给opt命令带上-S参数直接对.ll文件进行优化:
opt -S -O2 fun1.ll -o fun1-O2.ll
-O2代表的是二级优化,LLVM中定义了多个优化级别,基本上数字越大所做的优化就越多。可以不使用笼统的优化级别,而是指定采用某个特别的优化算法,比如mem2reg算法会把对内存的访问优化成尽量访问寄存器,如下所示:
opt -S -mem2reg fun1.ll -o fun1-O2.ll
对于常数折叠,在调用API生成IR时LLVM缺省就会去做这个优化。比如下面这段代码是返回2+3的值,但生成IR时直接变成了5,因为这种优化比较简单不需要做复杂的分析:
- Function * codegen_const_folding(){
- //创建函数
- FunctionType *funType = FunctionType::get(Type::getInt32Ty(TheContext), false);
- Function *fun = Function::Create(funType, Function::ExternalLinkage, "const_folding", TheModule.get());
-
- //创建一个基本块
- BasicBlock *BB = BasicBlock::Create(TheContext, "", fun);
- Builder.SetInsertPoint(BB);
-
- Value * tmp1 = ConstantInt::get(TheContext, APInt(32, 2, true));
- Value * tmp2 = ConstantInt::get(TheContext, APInt(32, 3, true));
- Value * tmp3 = Builder.CreateAdd(tmp1, tmp2);
-
- Builder.CreateRet(tmp3);
- return fun;
- }
生成的IR如下:
- define i32 @const_folding() {
- ret i32 5
- }
需要注意,很多优化算法都是要基于寄存器变量来做,所以通常都会先做一下-mem2reg优化。在LLVM中做优化算法很方便,因为它采用的是SSA格式。具体来说,LLVM中定义了Value和User两个接口,它们体现了LLVM IR最强大的特性,即静态单赋值中的定义-使用链,这种定义-使用关系会被用到优化算法中。前面已经讲过了Value类。如果一个类是从Value继承的意味着它定义了一个值。另一个类是User类,函数和指令也是User类的子类,也就是说在函数和指令中,可以使用别的地方定义的值,如下图所示:
这两个类是怎么帮助到优化算法中的呢?在User中可以访问所有它用到的Value,比如一个加法指令(%c = add nsw i32 %a, %b)用到了a和b这两个变量。而在Value中可以访问所有使用这个值的User,比如给c赋值的这条指令。所以可以遍历一个Value的所有User把它替换成另一个Value,这就是拷贝传播。
接下来,看看如何用程序实现IR的优化。在LLVM内部,优化工作是通过一个个的Pass(遍)来实现的,它支持三种类型的Pass:
(1)一种是分析型的Pass(Analysis Passes),只是做分析产生一些分析结果用于后序操作。
(2)一些是做代码转换的(Transform Passes),比如做公共子表达式删除。
(3)还有一类pass是工具型的,比如对模块做正确性验证。
下面的代码创建了一个PassManager,并添加了两个优化Pass:
- // 创建一个PassManager
- TheFPM = std::make_unique<legacy::FunctionPassManager>(TheModule.get());
-
- // 窥孔优化和一些位计算优化
- TheFPM->add(createInstructionCombiningPass());
-
- // 表达式重关联
- TheFPM->add(createReassociatePass());
-
- TheFPM->doInitialization();
之后,再简单地调用PassManager的run()方法就可以对代码进行优化:
TheFPM->run(*fun);
27. 上面提到了删除公共子表达式、拷贝传播等本地优化能做的工作,其实这几个工作也可以在全局优化中进行。只不过全局优化中的算法,不会像在本地优化中一样只针对一个基本块,而是更复杂一些,因为要覆盖多个基本块。这些基本块构成了一个CFG,代码在运行时有多种可能的执行路径,这会造成多路径下值的计算问题,比如活跃变量集合的计算。当然还有些优化只能在全局优化中做,在本地优化中做不了,比如:
(1)代码移动(code motion)能够将代码从一个基本块挪到另一个基本块,比如从循环内部挪到循环外部,来减少不必要的计算。
(2)部分冗余删除(Partial Redundancy Elimination),它能把一个基本块都删掉。
总之,全局优化比本地优化能做的工作更多,分析算法也更复杂,因为CFG中可能存在多条执行路径。不过,可以在上面提到的本地优化的算法思路上,解决掉多路径情况下V值的计算问题。而这种基于CFG做优化分析的方法框架,就叫做数据流分析。前面讲了本地优化时的活跃性分析,那时情况比较简单,不需要考虑多路径问题。而在做全局优化时,情况就要复杂一些:代码不是在一个基本块里简单地顺序执行,而可能经过控制流图(CFG)中的多条路径。来看一个例子(由if语句形成了两条分支语句):
基于这个CFG可以做全局的活跃性分析,从最底下的基本块开始,倒着向前计算活跃变量的集合(即从基本块5倒着向基本块1计算)。这里需要注意,对基本块1进行计算时,它的输入是基本块2的输出也就是{a, b, c},和基本块3的输出也就是{a, c},计算结果是这两个集合的并集{a, b, c}。也就是说基本块1的后序基本块,有可能用到这三个变量。这里就是与本地优化不同的地方,要基于多条路径来计算,如下图所示:
基于这个分析图,马上发现y变量可以被删掉(因为它前面的活变量集合{x}不包括y,也就是不被后面的代码所使用),并且影响到了活跃变量的集合,如下图所示:
删掉y变量以后再继续优化一轮,会发现d也可以删掉。如下图所示:
d删掉以后,2号基本块里面已经没有代码了,也可以被删掉,最后的CFG是下面这样:
到目前为止发现:全局优化总体来说跟本地优化很相似,唯一的不同就是要基于多个分支计算集合的内容(也就是V值)。在进入基本块1时,2和3两个分支相遇(meet),取了2和3的V值的并集,这就是数据流分析的基本特征。但是,上面这个CFG还是比较简单的,因为它没有循环属于有向无环图。这种图的特点是:针对图中的每一个节点,总能找到它的前序节点和后序节点,所以只需要按照顺序计算就好了。但是如果加上了环路,就不那么简单了,来看一看下面这张图:
基本块4有两个后序节点,分别是5和1,所以要计算4的活跃变量,就需要知道5和1的输出是什么。5的输出好说,但1的还没计算出来,因为要计算1就要依赖2和3,从而间接地又依赖了4。这样一来,1和4是循环依赖的。再进一步探究发现,其实1、2、3、4四个节点之间都是循环依赖的。所以说,一旦在CFG中引入循环回路,严格的前后计算顺序就不存在了。
在前端部分计算First和Follow集合时,就会遇到循环依赖的情况,只不过那里没有像这样展开细细地分析。不过,那个时候是用不动点法来破解僵局的,在这里还是要运用不动点法,具体操作是:给每个基本块的V值都分配初始值,也就是空集合,如下图所示:
然后对所有节点进行多次计算,直到所有集合都稳定为止。第一遍时按照5-4-3-2-1的顺序计算(实际上采取任何顺序都可以),计算结果如下:
如果现在计算就结束,实际上可以把基本块2中的d变量删掉。但如果再按照5-4-3-2-1的顺序计算一遍,就会往集合里增加一些新的元素(图中标的是红色)。这是因为在计算基本块4时,基本块1的输出{b, c, d}也会变成4的输入。这时发现进入基本块2时,活变量集合里是含有d的,所以d是不能删除的,如下图所示:
再仔细看,这个d是是基本块3需要的:它会跟1去要,1会跟4要,4跟2要。所以再次证明,1、2、3、4四个节点是互相依赖的。再来看一下,对于活变量集合的计算,当两个分支相遇的情况下,最终的结果取了两个分支的并集。前面提到过一个本地优化分析包含四个元素:方向(D)、值(V)、转换函数(F)和初始值(I)。在做全局优化时需要再多加一个元素,就是两个分支相遇时要做一个运算,计算他们相交的值,这个运算可以用大写希腊字母Λ(lambda)表示。包含了D、V、F、I和Λ的分析框架,就叫做数据流分析。
28. 那么Λ怎么计算呢?需要用到一个数学工具,叫做“半格”(Semilattice)。首先,半格是一种偏序集(Partially Ordered Set)。偏序集就是集合中只有部分成员能够互相比较大小,例如在做全局活跃性分析时,{a, b, c}和{a, c}相遇产生的新值是{a, b, c},形式化地写成{a, b, c}Λ{a, c} = {a, b, c}。这时候说{a, b, c}是可以跟{a, c}比较大小的。那么哪个大哪个小呢?如果 XΛY=X,就说X<=Y。所以{a, b, c}是比较小的,{a, c}是比较大的。
当然,{a, b, c}也可以跟{a, b}比较大小,但它没办法跟{c, d}比较大小。所以把包含了{{a, b, c}、{a, c}、{a, b}、{c, d}…}这样的一个集合,叫做偏序集,它们中只有部分成员之间可以比较大小。哪些成员可以比较呢?就是下面的半格图中,可以通过有方向的线连起来。半格可以画成图形,假设程序只有a, b, c三个变量,那么这个半格画成图形是这样的:
沿着上面图中的线,两个值是可以比较大小的,按箭头的方向依次减少:{}>{a}>{a, b}> {a, b, c}。如果两个值之间没有一条路径,那么它们之间就是不能比较大小的,就像{a}和{b}就不能比较大小。对于这个半格,把{}(空集)叫做Top,Top大于所有其他的值。而{a, b, c}叫做Bottom,它是最小的值。在做活跃性分析时,Λ运算是计算两个值的最大下界(Greatest Lower Bound)。就是比两个原始值都小的值中,取最大的那个。{a}和{b}的最大下界是{a, b},{a, b, c}和{a, c}的最大下界就是{a, b, c},规则如下:
(1)如果一个偏序集中,任意两个元素都有最大下界,那么这个偏序集就叫做交半格(Meet Semilattice)。
(2)与此相对应的,如果集合中的每个元素都有最小上界(Least Upper Bound),那么这个偏序集叫做并半格(Join Semilattice)。
(3)如果一个偏序集既是交半格,又是并半格,就说这个偏序集是一个格,上面示例的这个偏序集就是一个格(Lattice)。
那为什么要引入这么复杂的一套数学工具呢?两个分支相遇就计算它们的并集,不就可以了吗?事情没那么简单。因为并不是所有的分析,其V值都是一个集合,就算是集合,相交时的运算也不一定是求并集,而有可能是求交集。这里通过另一个案例来分析一下非集合的半格运算:常数传播。
常数传播,就是如果知道某个变量的值是个常数,那么就把用到这个变量的表达式,都用常数去替换。看看下面的例子,在基本块4中a的值能否用一个常数替代?
答案是不能。到达基本块4的两条路径,一条a=3另一条a=4,不知道在实际运行时会从哪条路径过来,所以这个时候a的取值是不确定的,基本块4中的a无法用常数替换。在这种情况下,V不再是一个集合,而是a可能取的常数值,但a有可能不是一个常数,所以再定义一个特殊的值:Top(T)。除了T之外,再引入一个与T对应的特殊值:Bottom(含义是某个语句永远不会被执行)。总结起来,常数传播时V的取值可能是3个:
(1)常数c。
(2)Top:意思是a的值不是一个常数。
(3)Bottom:某个语句不会被执行。
这些值是怎么排序的呢?最大的是Top,中间各个常数之间是无法比较的,Bottom是最小的。接下来,看看如何计算多个V值相交的值。再把计算过程形式化一下,在这个分析中当经过每个语句时,V值都可能发生变化,用下面两个函数来代表不同地方的V值:
(1)C(a, s, in)。表示在语句s之前a的取值,比如C(a, b:=a+2, in) = 3。
(2)C(a, s, out)。表示在语句s之后a的取值,比如C(a, a:=4, out) = 4。
如果s的前序有i条可能的路径,那么多个输出和一个输入“C(a, si, out)和C(a, s, in)”的关系,可以制定一系列规则:
(1)如果有一条输入路径是Top,或者说C(a, si, out)是Top,那么结果C(a, s, in)就是Top。
(2)如果输入中有两个不同的常数比如3和4,那么结果也是Top。
(3)如果所有的输入都是相同的常数或Bottom,那么结果就是该常数。例如如果所有路径a的值都是3,那么这里就可以安全地认为a的值是3。那些Bottom路径不影响,因为整条路径不会执行。
(4)如果所有的输入都是Bottom,那么结果也是Bottom。
上面的这4个规则,就是一套半格的计算规则。在这里也可以总结一下它的转换规则也就是F,考虑一下某个Statement的in值和out值的关系,也就是经过该Statement以后,V值会有啥变化:
(1)如果输入是Bottom,那么输出也是Bottom。也就是这条路径不会经过。
(2)如果该Statement就是“a := 常数”,那么输出就是该常数。
(3)如果该Statement是a赋予的一个比较复杂的表达式而不是常数,那么输出就是Top。
(4)如果该Statement不是对a赋值的,那么V值保持不变。
这样转换函数F也搞清楚了。初始值I其实就是Top,因为一开始时a还没有赋值,所以不会是常数;方向D是向下的,这个时候D、V、F、I 和Λ这5个元素都清楚了,就可以写算法实现了。
29. 一个正式的编译器后端,代码生成部分需要考虑得更加严密才行,其实主要有三点:
(1)指令的选择。同样一个功能可以用不同的指令或指令序列来完成,而需要选择比较优化的方案。
(2)寄存器分配。每款CPU的寄存器都是有限的,要有效地利用它。
(3)指令重排序。计算执行的次序会影响所生成的代码的效率。在不影响运行结果的情况下,要通过代码重排序获得更高的效率。
接下来针对第一个问题,聊聊为什么需要选择指令,以及如何选择指令。如果不考虑目标代码的性能,可以按照非常机械的方式翻译代码。比如可以制定一个代码翻译的模板,把形如“a := b + c”的代码都翻译成下面的汇编代码:
- mov b, r0 //把b装入寄存器r0
- add c, r0 //把c加到r0上
- mov r0, a //把r0存入a
那么,下面两句代码:
- a := b + c
- d := a + e
将被机械地翻译成:
- mov b, r0
- add c, r0
- mov r0, a
- mov a, r0
- add e, r0
- mov r0, d
可以从上面这段代码中看到,第4行其实是多余的,因为r0的值就是a不用再装载一遍了。另外,如果后面的代码不会用到a(即a只是个临时变量),那么第3行也是多余的。这种算法正确性没有问题,但代码量太大代价太高。所以最好用聪明一点的算法来生成更加优化的代码。这是要做指令选择的原因之一。
做指令选择的第二个原因是,实现同一种功能可以使用多种指令,特别是CISC指令集(可替代选择很多,但各自有适用的场景)。对某个CPU来说,完成同样的任务可以采用不同的指令。比如实现“a := a + 1”可以生成三条代码,如下所示:
- mov a, r0
- add $1, r0
- mov r0, a
也可以直接用一行代码即采用inc指令,而要看看用哪种方法总体代价最低:
inc a
第二个例子,把r0寄存器置为0,也可以有多个方法,如下图所示:
- mov $0, r0 //赋值为立即数0
- xor r0, r0 //异或操作
- sub r0, r0 //用自身的值去减
- ...
再比如,a * 7可以用a<<3 - a 实现:首先移位3位相当于乘8,然后再减去一次a就相当于乘以7。虽然用了两条指令,但是可能消耗的总时钟周期更少。那么做指令选择的思路是什么呢?目前最成熟的算法都是基于树覆盖的方法,下面通过一个例子了解一下什么是树覆盖算法,a[i] = b这个表达式中,假设a和b都是栈里的本地变量,i是放在寄存器ri中。这个表达式可以用一个AST表示,如下图所示:
可能觉得这棵树看着像AST但又不大像,那是因为里面有mem节点(意思是存入内存)、mov节点、栈指针 (fp)。它可以算作低级(low-level)AST,是一种IR的表达方式,有时被称为结构化IR。这个AST里包含了丰富的运行时细节信息,相当于把LLVM的IR用树结构来表示了,可以把一个基本块的指令都画成这样的树状结构。基于这棵树,可以翻译成如下的汇编代码:
- load M[fp+a], r1 //取出数组开头的地址,放入r1,fp是栈顶的指针,a是地址的偏移量
- addi 4, r2 //把4加载到r2
- mul ri, r2 //把ri的值乘到r2上,即i*4,即数组元素的偏移量,每个元素4字节
- add r2, r1 //把r2加到r1上,也就是算出a[i]的地址
- load M[fp+b], r2 //把b的值加载到r2寄存器
- store r2, M[r1] //把r2写入地址为r1的内存
这里用了一种假想的汇编代码,跟LLVM IR有点像,但更简化、易读,如下图所示:
用树覆盖的方法可以大大减少代码量,下图中用红色线包围的部分被叫做一个瓦片 (tiling),那些包含了操作符的瓦片,就可以转化成一条指令。每个瓦片可以覆盖多个节点,所以生成的指令比较少:
那用什么来做瓦片呢?每条机器指令都会对应IR的一些模式(Pattern),可以表示成一些小的树,而这些小树就可以当作瓦片,如下图所示:
该算法可以遍历AST,遇到上面的模式就可以生成对应的指令。以load指令为例,它有几个模式:任意一个节点加上一个常量,这相当于汇编语言中的间接地址访问;或者mem下直接就是一个常量,这相当于是直接地址访问。最后,地址值还可以由下级子节点计算出来。
所以,从一棵AST生成代码的过程,就是用上面这些小树去匹配一棵大树,并把整个大树覆盖的过程,所以叫做树覆盖算法。上面图中2、4、5、6、8、9这几个节点依次生成汇编代码。要注意的是,覆盖方式可能会有多个,比如下面这个覆盖方式,相比之前的结果,它在8和9两个瓦片上是有区别的:
这样生成的汇编代码最后两句也不同,如下所示:
- load M[fp+a], r1 //取出数组开头的地址,放入r1,fp是栈顶的指针,a是地址的偏移量
- addi 4, r2 //把4加载到r2
- mul ri, r2 //把ri的值乘到r2上,即i*4,即数组元素的偏移量,每个元素4字节
- add r2, r1 //把r2加到r1上,也就是算出a[i]的地址
- addi fp+b, r2 //把fp+b的值加载到r2寄存器
- movm M[r2], M[r1] //把地址为r2到值拷贝到地址为r1内存里
可以体会一下,这两个覆盖方式的差别:
(1)对于瓦片8中的加法运算,一个当做了间接地址的计算,一个就是当成加法;
(2)对于根节点的操作,一个翻译成store,把寄存器中的b的值写入到内存。一个翻译成movm指令,直接在内存之间拷贝值。至于这两种翻译方法哪种更好,比较总体的性能哪个更高就行了。
到目前为止,已经直观地了解了为什么要进行指令选择,以及最常用的树覆盖方法了。当然树覆盖算法有很多,比如Maximal Munch算法、动态规划算法、树文法等,LLVM也有自己的算法。简单说下Maximal Munch算法,直译成中文是每次尽量咬一大口的意思,也就是从树根开始,每次挑一个能覆盖最多节点的瓦片,这样就形成几棵子树。对每棵子树也都用相同的策略,这样会使得生成的指令是最少的。注意指令顺序要反过来,按照深度优先的策略,先是叶子再是树根。这个算法是Optimal的算法,它指在局部,相邻的两个瓦片不可能连接成代价更低的瓦片。覆盖算法除了Optimal的还有Optimum的,Optimum是全局最优化的状态,就是代码总体的代价是最低的。
30. 接下来继续探讨前面提到的第二个问题:寄存器分配。寄存器优化的任务是:最大程度地利用寄存器,但不要超过寄存器总数量的限制。因为生成IR时是不知道目标机器的信息的,也就不知道目标机器有几个寄存器可以用,所以在IR中可以使用无限个临时变量,每个临时变量都代表一个寄存器。现在既然要生成针对目标机器的代码,也就知道这些信息了,那就要把原来的IR改写一下,以便使用寄存器时不超标。
寄存器优化的原理是什么呢?例如下图左边的IR中,a、d、f这三个临时变量不会同时出现。假设a和d在这个代码块之后成了死变量,那么这三个变量可以共用同一个寄存器,就像右边显示的那样:
实际上,这三行代码是对“b + c + e + 10”这个表达式的翻译,所以a和d都是在转换为IR时引入的中间变量,用完就不用了。所以通过这个例子,可以直观地理解寄存器共享的原则:如果存在两个临时变量a和b,它们在整个程序执行过程中最多只有一个变量是活跃的,那么这两个变量可以共享同一个寄存器。
前面已经提过了如何做变量的活跃性分析,所以可以分析出,在任何一个程序点活跃变量的集合。然后再看一下,哪些变量从来没有出现在同一个集合中就行。看看下面这个图:
上图中,凡是出现在同一个花括号里的变量都不能共享寄存器,因为它们在某个时刻是同时活跃的。那a到f哪些变量从来没碰到过呢?再画一个图来寻找一下,下图中每个临时变量作为一个节点,如果两个变量同时存在过,就画一条边。这样形成的图叫做寄存器干扰图 (Register Interference Graph, RIG)。在这张图里,凡是没有连线的两个变量就可以分配到同一个寄存器,例如a和b,b和d,a和d,b和e,a和e:
那针对这个程序,一共需要几个寄存器,怎么分配呢?比较常用的算法是图染色算法:只要两个节点之间有连线,节点就染成不同的颜色。最后所需要的最少颜色就是所需要的寄存器的数量。下图画了两个染色方案,都是需要4 种颜色:
那么如何用算法来染色呢?如何用算法知道寄存器是否够用?染色算法其实是,如果想知道k个寄存器够不够用,只需要找到一个少于k条边的节点,把它从图中去掉。接着再找下一个少于k条边的节点,再去掉。如果最后整个图都被删掉了,那么这个图一定可以用k种颜色来染色。如下图所示:
因为如果一个图(蓝色椭圆)是能用k种颜色染色的,那么再加上一个节点,它的边的数量少于k个比如是n,那么这个大一点的图(红色椭圆)还是可以用 k 种颜色染色。因为加进来的节点的边数少于k个,所以一定能找到一个颜色,与这个点的n个邻居都不相同。所以,把刚才一个个去掉节点的顺序反过来,把一个个节点依次加到图上,每加上一个,就找一个它的邻居没有用的颜色来染色就行了。
但是,如果所需要寄存器比实际寄存器的数量多,那就要用栈了。这个问题就是寄存器溢出(Register Spilling),溢出到栈里去,像本地变量、参数、返回值等都尽量用寄存器,如果寄存器不够用就放到栈里。另外无论放在寄存器里还是栈里,都是活动记录的组成部分,所以活动记录这个概念比栈桢更广义。依然是上面的例子,如果只有3个寄存器,那么要计算一下3个寄存器够不够用,先把a和b从图中去掉,如下所示:
这时发现,剩下的4个节点,每个节点都有3个邻居,所以3个寄存器肯定不够用,必须要溢出一个去。可以选择让f保存在栈里,把f去掉以后,剩下的c,d,e可以用3种颜色成功染色。f虽然被保存到了栈里,但每次使用它时都要load到一个临时变量,也就是寄存器中。每次保存f也都要用一个临时变量写入到内存。所以要把原来的代码修改一下,把每个使用f的地方都加上一条load或save指令,以便在使用f时把f放到寄存器,用完后再写回内存。修改后的CFG如下:
因为原来有4个地方用到了f,所以引入了f1到f4四个临时变量。这样总的临时变量反而变多了,从6个到了9个。不过虽然临时变量更多了,但这几个临时变量的生存期都很短,图里带有f的活跃变量集合比之前少多了。所以,即使有9个临时变量,也能用三种颜色染色,如下图所示:
最后,在选择把哪个变量溢出时,最好选择使用次数最少的变量。在程序内循环中的变量就最好不要溢出,因为每次循环都会用到它们,还是放在寄存器里性能更高。
31. 前面了解了指令选择和寄存器分配,这里继续探讨目标代码生成的第三个需要考虑的因素:指令重排序(Instruction Scheduling)。人们通常会把CPU看做一个整体,把CPU执行指令的过程想象成依此检票进站的过程,改变不同乘客的次序并不会加快检票的速度。所以会自然而然地认为改变顺序并不会改变总时间。但当进入CPU内部,会看到CPU是由多个功能部件构成的。下图是Ice Lake微架构的CPU内部构成:
在这个结构中,一条指令执行时要依次用到多个功能部件,分成多个阶段,虽然每条指令是顺序执行的,但每个部件的工作完成以后,就可以服务于下一条指令,从而达到并行执行的效果。这种结构叫做流水线(pipeline)结构。比如典型的RISC指令在执行过程会分成前后共5个阶段:
(1)IF:获取指令;
(2)ID(或RF):指令解码和获取寄存器的值;
(3)EX:执行指令;
(4)ME(或MEM):内存访问(如果指令不涉及内存访问,该阶段可以省略);
(5)WB:写回寄存器。
对于CISC指令,CPU的流水线会根据指令的不同分成更多个阶段。在执行指令的阶段,不同的指令也会由不同的单元负责,可以把这些单元叫做执行单元,比如Intel的Ice Lake架构CPU有下面这些执行单元:
其他执行单元还有:BM、Vec ALU、Vec SHFT、Vec Add、Vec Mul、Shuffle等。因为CPU内部存在着多个功能单元,所以在同一时刻,不同的功能单元其实可以服务于不同的指令,如下图所示;
这样多条指令实质上是并行执行的,从而减少了总的执行时间,这种并行叫做指令级并行。如果没有这种并行结构,或者由于指令之间存在依赖关系,无法并行,那么执行周期就会大大加长,如下图所示:
来看一个实际的例子。假设load和store指令需要3个时钟周期来读写数据,add指令需要1个时钟周期,mul指令需要2个时钟周期。图中红色的编号是原来的指令顺序,绿色的数字是每条指令开始时的时钟周期,把每条指令的时钟周期累计一下就能算出来。最后一条指令开始的时钟周期是20,该条指令运行需要3个时钟周期,所以在第22个时钟周期执行完所有的指令。右边是重新排序后的指令,一共花了13个时钟周期。优化力度还是很大的:
看下左边前两条指令,这两条指令的意思是:先加载数据到寄存器,然后做一个加法。但加载需要3个时钟周期,所以add指令无法执行只能干等着。右列的前三条都是load指令,它们之间没有数据依赖关系,可以每个时钟周期启动一个,到了第四个时钟周期,每一条指令的数据已经加载完毕,所以就可以执行加法运算了。可以把右边的内容画成下面的样子,能看到很多指令在时钟周期上是重叠的,这就是指令级并行的特点:
32. 当然不是所有的指令都可以并行,最后的3条指令就是顺序执行的,导致无法并行的原因有几个:
(1)数据依赖约束。如果后一条指令要用到前一条指令的结果,那必须排在它后面,比如下面两条指令add和mul,对于第二条指令来说,除了获取指令的阶段(IF)可以和第一条指令并行以外,其他阶段需要等第一条指令的结果写入r1,第二条指令才可以使用r1的值继续运行:
- add r2, r1
- mul r3, r1
(2)功能部件约束。如果只有一个乘法计算器,那么一次只能执行一条乘法运算,如下图所示:
(3)指令流出约束。指令流出部件一次流出n条指令。
(4)寄存器约束。寄存器数量有限,指令并行时使用的寄存器不可以超标。
后三者也可以合并成为一类,称作资源约束。在数据依赖约束中,如果有因为使用同一个存储位置,而导致不能并行的,可以用重命名变量的方式消除,这类约束被叫做伪约束。而先写再写,以及先读后写是伪约束的两种呈现方式:
(1)先写再写:如果指令A写一个寄存器或内存位置,B也写同一个位置,就不能改变A和B的执行顺序,不过可以修改程序让A和B写不同的位置。
(2)先读后写:如果A必须在B写某个位置之前读某个位置,那么不能改变A和B的执行顺序。除非能够通过重命名让它们使用不同的位置。
用算法对指令进行重排序的关键点,是要找出代码之间的数据依赖关系。下图展现了示例中各行代码之间的数据依赖,可以叫做数据的依赖图(dependence graph)。它的边代表了值的流动,比如a行加载了一个数据到r1,b行利用r1来做计算,所以b行依赖a行,这个图也可以叫做优先图(precedence graph),因为a比b优先,b比d优先:
可以给图中的每个节点再加上两个属性,利用这两个属性就可以对指令进行排序了:
(1)操作类型,因为这涉及它所需要的功能单元。
(2)时延属性,也就是每条指令所需的时钟周期。
图中的a、c、e、g是叶子,它们没有依赖任何其他的节点,所以尽量排在前面。b、d、f、h必须出现在各自所依赖的节点后面。而根节点i总是要排在最后面。根据时延属性计算出每个节点的累计时延(每个节点的累计时延等于父节点的累计时延加上本节点的时延),其中a-b-d-f-h-i路径是关键路径,代码执行的最少时间就是这条路径所花的时钟周期之和,如下图所示:
因为a在关键路径上,所以首先考虑把a节点排在第1行。剩下的树中,c-d-f-h-i变成了关键路径,因为c的累计时延最大,c节点可以排在第2行,如下图所示:
b和e的累计时延都是最长的,但由于b必须在a执行完毕后,才会开始执行,所以最好等够a的3个时钟周期,否则还是会空等,所以先不考虑b,而是把e放到第3行,如下图所示:
继续按照这个方式排,最后可以获得a-c-e-b-d-g-f-h-i的指令序列。不过这个代码还可以继续优化:也就是发现并消除其中的伪约束。c和e都向r2里写了值,而d使用的是c写入的值。如果修改变量名称,比如让e使用r3寄存器,就可以去掉e跟d,以及e与c之间伪约束,让e就可以排在c和d之前。同理也可以让g使用r4寄存器,使得g可以排在e和f的前面。当然在这个示例中,这种改变并没有减少总的时间消耗,因为关键路径上的依赖没有变化,它们都使用了r1寄存器。但在别的情况下,就有可能带来更大的优化。如下图所示:
刚才其实是采用了List Scheduling算法,大致分为 4 步:
(1)把变量重命名来消除伪约束(可选步骤)。
(2)创建依赖图。
(3)为每行代码计算优先值(计算方法有很多,比如示例中基于最长时延的方法)。
(4)迭代处理代码并排序。
不过算法都需要考虑到复杂度,关于指令选择、寄存器分配和指令重排序的算法,其难度(时间复杂度)都是“NP-完全”的,意思是这类问题找不到一个随规模(代码行数)增大而计算量增长较慢的算法(多项式时间算法),从而找到最优解。反之,计算量可能会随着代码行数呈指数级上升。因此,编译原理中的一些难度高的算法,都在代码生成这一环。
33. 到目前为止,了解了目标代码生成的三大考虑因素:指令选择、寄存器分配和指令重排序。现在来看看目标代码生成,在LLVM中是如何实现的。LLVM的后端需要多个处理步骤来生成目标代码,如下图所示:
图中红色的部分是重要步骤,它本身包含了多个Pass,所以也叫做超级Pass。图中蓝框的Pass,是用来做一些额外的优化处理。接下来讲LLVM生成目标代码的关键步骤:
(1)指令选择。LLVM的指令选择算法是基于DAG(有向无环图)的树模式匹配,与前一讲基于AST的算法有一些区别,但总思路是一致的。这个算法是Near-Optimal(接近Optimal)的,能够在线性时间内完成指令的选择,并且它关注产生代码的尺寸,要求尺寸足够小。DAG是融合了公共子表达式的AST,也是一种结构化的IR。下面两行代码对应的AST和DAG如下图所示,能看到DAG把a=5这棵子树给融合了:
- a = 5
- b = (2 + a)+ (a * 3)
LLVM把内存中的IR模型,转化成了一个体现某个目标平台特征的SelectionDAG,用于做指令选择。每个基本块转化成一个DAG,DAG中的节点通常代表指令,边代表指令之间的数据流动。在这个阶段之后,LLVM会把DAG中的LLVM IR节点,全部转换成目标机器的节点,代表目标机器的指令,而不是LLVM的指令。
(2)指令排序(寄存器分配之前)。基于前一步的处理结果,要对指令进行排序。但因为DAG不能反映没有依赖关系的节点间的排序,所以LLVM要先把DAG转换成一种三地址模式,这个格式叫做MachineInstr。这个阶段会把指令排序,并尽量发挥指令级并行的能力。
(3)寄存器分配。接下来做寄存器的分配。LLVM的IR支持无限多的寄存器,在这个环节要分配到实际的寄存器上,分配不下的就溢出到内存。
(4)指令排序(寄存器分配之后)。分配完寄存器之后,LLVM会再做一次指令排序。因为寄存器分配会指定确定的寄存器,而访问不同寄存器的时钟周期可能是不同的。对于溢出到内存中的变量,也增加了一些指令在内存和寄存器之间传输数据。利用这些信息,LLVM可以进一步优化指令的排序。
(5)代码输出。做完上面所有工作后,就可以输出目标代码了。LLVM在这一步把MachineInstr格式转换为MCInst格式,因为后者更有利于汇编器和链接器输出汇编代码或二进制目标代码。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。