mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-10-20 08:53:50 +08:00
mod
This commit is contained in:
158
极客时间专栏/编译原理之美/实现一门编译型语言 · 原理篇/20 | 高效运行:编译器的后端技术.md
Normal file
158
极客时间专栏/编译原理之美/实现一门编译型语言 · 原理篇/20 | 高效运行:编译器的后端技术.md
Normal file
@@ -0,0 +1,158 @@
|
||||
<audio id="audio" title="20 | 高效运行:编译器的后端技术" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/37/4a/375e318f6491529a3a406400581d504a.mp3"></audio>
|
||||
|
||||
前18节课,我们主要探讨了编译器的前端技术,它的重点,是让编译器能够读懂程序。无结构的代码文本,经过前端的处理以后,就变成了Token、AST和语义属性、符号表等结构化的信息。基于这些信息,我们可以实现简单的脚本解释器,这也从另一个角度证明了我们的前端处理工作确实理解了程序代码,否则程序不可能正确执行嘛。
|
||||
|
||||
实际上,学完前端技术以后,我们已经能做很多事情了,比如让软件有自定义功能,就像我们在[15讲](https://time.geekbang.org/column/article/136557)中提到的报表系统,这时,不需要涉及编译器后端技术。
|
||||
|
||||
但很多情况下,我们需要继续把程序编译成机器能读懂的代码,并高效运行。**这时,我们就面临了三个问题:**
|
||||
|
||||
1.我们必须了解计算机运行一个程序的原理(也就是运行期机制),只有这样,才知道如何生成这样的程序。<br>
|
||||
2.要能利用前端生成的AST和属性信息,将其正确翻译成目标代码。<br>
|
||||
3.需要对程序做尽可能多的优化,比如让程序执行效率更高,占空间更少等等。
|
||||
|
||||
弄清这三个问题,是顺利完成编译器后端工作的关键,本节课,我会让你对程序运行机制、生成代码和优化代码有个直观的了解,然后再在接下来的课程中,将这些问题逐一击破。
|
||||
|
||||
## 弄清程序的运行机制
|
||||
|
||||
总的来说,编译器后端要解决的问题是:现在给你一台计算机,你怎么生成一个可以运行的程序,然后还能让这个程序在计算机上正确和高效地运行?
|
||||
|
||||
我画了一个模型:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0a/40/0ab606233923bd3904950acf39f6a440.jpg" alt="">
|
||||
|
||||
基本上,我们需要面对的是两个硬件:
|
||||
|
||||
<li>
|
||||
**一个是CPU,它能接受机器指令和数据,并进行计算。**它里面有寄存器、高速缓存和运算单元,充分利用寄存器和高速缓存会让系统的性能大大提升。
|
||||
</li>
|
||||
<li>
|
||||
**另一个是内存。**我们要在内存里保存编译好的代码和数据,还要设计一套机制,让程序最高效地利用这些内存。
|
||||
</li>
|
||||
|
||||
通常情况下,我们的程序要受某个操作系统的管理,所以也要符合操作系统的一些约定。但有时候我们的程序也可能直接跑在硬件上,单片机和很多物联网设备采用这样的结构,甚至一些服务端系统,也可以不跑在操作系统上。
|
||||
|
||||
你可以看出,编译器后端技术跟计算机体系结构的关系很密切。我们必须清楚地理解计算机程序是怎么运行的,有了这个基础,才能探讨如何编译生成这样的程序。
|
||||
|
||||
所以,我会在下一节课,也就是**21讲**,将运行期的机制讲清楚,比如内存空间如何划分和组织;程序是如何启动、跳转和退出的;执行过程中指令和数据如何传递到CPU;整个过程中需要如何跟操作系统配合,等等。
|
||||
|
||||
也有的时候,我们的面对的机器是虚拟机,Java的运行环境就是一个虚拟机(JVM),那我们需要就了解这个虚拟机的特点,以便生成可以在这个虚拟机上运行的代码,比如Java的字节码。同时,字节码有时仍然需要编译成机器码。
|
||||
|
||||
在对运行期机制有了一定的了解之后,我们就有底气来进行下一步了,生成符合运行期机制的代码。
|
||||
|
||||
## 生成代码
|
||||
|
||||
编译器后端的最终结果,就是生成目标代码。如果目标是在计算机上直接运行,就像C语言程序那样,那这个目标代码指的是汇编代码。而如果运行目标是Java虚拟机,那这个目标代码就是指JVM的字节码。
|
||||
|
||||
基于我们在编译器前端所生成的成果,我们其实可以直接生成汇编代码,在后面的课程中,我会带你做一个这样的尝试。
|
||||
|
||||
你可能惧怕汇编代码,觉得它肯定很难,能写汇编的人一定很牛。在我看来,这是一个偏见,因为汇编代码并不难写,为什么呢?
|
||||
|
||||
其实汇编没有类型,也没有那么多的语法结构,它要做的通常就是把数据拷贝到寄存器,处理一下,再保存回内存。所以,从汇编语言的特性看,就决定了它不可能复杂到哪儿去。
|
||||
|
||||
你如果问问硬件工程师就知道了,因为他们经常拿汇编语言操作寄存器、调用中断,也没多难。但另一方面,正是因为汇编的基础机制太简单,而且不太安全,用它编写程序的效率太低,所以现在直接用汇编写的程序,都是处理很小、很单一的问题,我们不会再像阿波罗登月计划那样,用汇编写整个系统,这个项目的代码最近已经开源了,如果现在用高级语言去做这项工作,会容易得多,还可以像现在的汽车自动驾驶系统一样实现更多的功能。
|
||||
|
||||
所以,**在22和23讲**,我会带你从AST直接翻译成汇编代码,并编译成可执行文件,这样你就会看到这个过程没有你想象的那么困难,你对汇编代码的恐惧感,也会就此消失了。
|
||||
|
||||
当然,写汇编跟使用高级语言有很多不同,**其中一点就是要关心CPU和内存这样具体的硬件。**比如,你需要了解不同的CPU指令集的差别,你还需要知道CPU是64位的还是32位的,有几个寄存器,每个寄存器可以用于什么指令,等等。但这样导致的问题是,每种语言,针对每种不同的硬件,都要生成不同的汇编代码。你想想看,一般我们设计一门语言要支持尽可能多的硬件平台,这样的工作量是不是很庞大?
|
||||
|
||||
所以,为了降低后端工作量,提高软件复用度,就需要引入**中间代码(Intermediate Representation,IR)的机制**,它是独立于具体硬件的一种代码格式。各个语言的前端可以先翻译成IR,然后再从IR翻译成不同硬件架构的汇编代码。如果有n个前端语言,m个后端架构,本来需要做m*n个翻译程序,现在只需要m+n个了。这就大大降低了总体的工作量。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/23/ea/23578fc6e348e79876bdeb90f0ee30ea.jpg" alt="">
|
||||
|
||||
甚至,很多语言主要做好前端就行了,后端可以尽量重用已有的库和工具,这也是现在推出新语言越来越快的原因之一。像Rust就充分利用了LLVM,GCC的各种语言,如C、C++、Object C等,也是充分共享了后端技术。
|
||||
|
||||
IR可以有多种格式,在第24讲,我们会介绍三地址代码、静态单赋值码等不同的IR。比如,“x + y * z”翻译成三地址代码是下面的样子,每行代码最多涉及三个地址,其中t1和t2是临时变量:
|
||||
|
||||
```
|
||||
t1 := y * z
|
||||
t2 := x + t1
|
||||
|
||||
```
|
||||
|
||||
Java语言生成的字节码也是一种IR,我们还会介绍LLVM的IR,并且基于LLVM这个工具来加速我们后端的开发。
|
||||
|
||||
其实,IR这个词直译成中文,是“中间表示方式”的意思,不一定非是像汇编代码那样的一条条的指令。所以,AST其实也可以看做一种IR。我们在前端部分实现的脚本语言,就是基于AST这个IR来运行的。
|
||||
|
||||
每种IR的目的和用途是不一样的:
|
||||
|
||||
- AST主要用于前端的工作。
|
||||
- Java的字节码,是设计用来在虚拟机上运行的。
|
||||
- LLVM的中间代码,主要是用于做代码翻译和编译优化的。
|
||||
- ……
|
||||
|
||||
总的来说,我们可以把各种语言翻译成中间代码,再针对每一种目标架构,通过一个程序将中间代码翻译成相应的汇编代码就可以了。然而事情真的这么简单吗?答案是否定的,因为我们还必须对代码进行优化。
|
||||
|
||||
## 代码分析和优化
|
||||
|
||||
生成正确的、能够执行的代码比较简单,可这样的代码执行效率很低,因为直接翻译生成的代码往往不够简洁,比如会生成大量的临时变量,指令数量也较多。因为翻译程序首先照顾的是正确性,很难同时兼顾是否足够优化,这是一方面。另一方面,由于高级语言本身的限制和程序员的编程习惯,也会导致代码不够优化,不能充分发挥计算机的性能。所以我们一定要对代码做优化。程序员在比较各种语言的时候,一定会比较它们的性能差异。一个语言的性能太差,就会影响它的使用和普及。
|
||||
|
||||
实际上,就算是现在常见的脚本语言,如Python和JavaScript,也做了很多后端优化的工作,包括编译成字节码、支持即时编译等,这些都是为了进一步提高性能。从谷歌支持的开源项目V8开始,JavaScript的性能获得了巨大的提高,这才导致了JavaScript再一次的繁荣,包括支持体验更好的前端应用和基于Node.js的后端应用。
|
||||
|
||||
优化工作又分为**“独立于机器的优化”和“依赖于机器的优化”**两种。
|
||||
|
||||
独立于机器的优化,是基于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;
|
||||
|
||||
```
|
||||
|
||||
上面的代码,通过优化,可以消除很多冗余的逻辑。这就好比你正在旅行,先从北京飞到了上海,然后又飞到厦门,最后飞回北京。然后你朋友问你现在在哪时,你告诉他在北京。那么他虽然知道你在北京,但并没有意识到你已经在几个城市折腾了一圈,因为他只关心你现在在哪儿,并不关心你的中间过程。 我们在给a赋值的时候,只需要知道这个值是101就行了。完全不需要在运行时去兜一大圈来计算。
|
||||
|
||||
计算机代码里有很多这种需要优化的情形。我们在27和28讲会介绍多种优化技术,比如局部优化和全局优化,常数折叠、拷贝传播、删除公共子表达式等,其中数据流分析方法比较重要,会重点介绍。
|
||||
|
||||
**依赖于机器的优化,则是依赖于硬件的特征。**现代的计算机硬件设计了很多特性,以便提供更高的处理能力,比如并行计算能力,多层次内存结构(使用多个级别的高速缓存)等等。编译器要能够充分利用硬件提供的性能,比如 :
|
||||
|
||||
<li>
|
||||
**寄存器优化。**对于频繁访问的变量,最好放在寄存器中,并且尽量最大限度地利用寄存器,不让其中一些空着,有不少算法是解决这个问题的,教材上一般提到的是染色算法;
|
||||
</li>
|
||||
<li>
|
||||
**充分利用高速缓存。**高速缓存的访问速度可以比内存快几十倍上百倍,所以我们要尽量利用高速缓存。比如,某段代码操作的数据,在内存里尽量放在一起,这样CPU读入数据时,会一起都放到高速缓存中,不用一遍一遍地重新到内存取。
|
||||
</li>
|
||||
<li>
|
||||
**并行性。**现代计算机都有多个内核,可以并行计算。我们的编译器要尽可能把充分利用多个内核的计算能力。 这在编译技术中是一个专门的领域。
|
||||
</li>
|
||||
<li>
|
||||
**流水线。**CPU在处理不同的指令的时候,需要等待的时间周期是不一样的,在等待某些指令做完的过程中其实还可以执行其他指令。就比如在星巴克买咖啡,交了钱就可以去等了,收银员可以先去处理下一个顾客,而不是要等到前一个顾客拿到咖啡才开始处理下一个顾客。
|
||||
</li>
|
||||
<li>
|
||||
**指令选择。**有的时候,CPU完成一个功能,有多个指令可供选择。而针对某个特定的需求,采用A指令可能比B指令效率高百倍。比如X86架构的CPU提供SIMD功能,也就是一条指令可以处理多条数据,而不是像传统指令那样一条指令只能处理一条数据。在内存计算领域,SIMD也可以大大提升性能,我们在第30讲的应用篇,会针对SIMD做一个实验。
|
||||
</li>
|
||||
<li>
|
||||
**其他优化。**比如可以针对专用的AI芯片和GPU做优化,提供AI计算能力,等等。
|
||||
</li>
|
||||
|
||||
可以看出来,做好依赖于机器的优化要对目标机器的体系结构有清晰的理解,如果能做好这些工作,那么开发一些系统级的软件也会更加得心应手。实际上,数据库系统、大数据系统等等,都是要融合编译技术的。
|
||||
|
||||
总结起来,在编译器中需要对代码进行的优化非常多。因此,这部分工作也是编译过程中耗时最长、最体现某个编译器的功力的一类工作,所以更值得引起你的重视。
|
||||
|
||||
## 课程小结
|
||||
|
||||
本节课,我们对编译器的后端技术做了概述。你了解到要做好后端工作,必须熟悉计算机体系结构和程序的运行时机制;还要从前端生成中间代码,然后基于中间代码生成针对不同平台的目标代码;最后,需要对代码做各种优化工作,包括独立于机器的优化和依赖于机器的优化。
|
||||
|
||||
刚接触编译技术的时候,你可能会把视线停留在前端技术上,以为能做Lexer、Parser就是懂编译了。实际上,词法分析和语法分析比较成熟,有成熟的工具来支撑。**相对来说,后端的工作量更大,挑战更多,研究的热点也更多。**比如,人工智能领域又出现了一些专用的AI芯片和指令集,就需要去适配。
|
||||
|
||||
编译器的后端,要把高级语言翻译成计算机能够理解的目标语言。它跟前端相比,关注点是不同的。前端关注的是正确反映了代码含义的静态结构,而后端关注的是让代码良好运行的动态结构。它们之间的差别,从我讲解“作用域”和“生存期”两个概念时就能看出来。作用域是前端的概念,而生存期是后端的概念。
|
||||
|
||||
其实在前面的课程中,我们已经涉及了少量的后端技术的概念,比如生存期、栈桢,因为我们要让脚本语言运行起来。但这个运行环境比较简单,脚本的执行也是简单的基于AST,所以性能是比较低的。但在后端部分,我们会实现一门静态编译型的语言,因此会对对运行期机制做更加深入的解读和实现。
|
||||
|
||||
如果能把后端技术学好,你对计算机底层运行机制的理解会更上一层楼,也会成为一名底子更加扎实的软件工程师。
|
||||
|
||||
## 一课一思
|
||||
|
||||
我们说编译器后端的任务是让程序适配硬件、高效运行。对于你所熟悉的程序语言,它的后端技术有什么特点呢?比如它采用了哪些技术使得性能更高,或者代码尺寸更小,或者能更好地兼容硬件?欢迎在留言区分享你的经验和观点。
|
||||
|
||||
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
|
||||
|
||||
|
218
极客时间专栏/编译原理之美/实现一门编译型语言 · 原理篇/21 | 运行时机制:突破现象看本质,透过语法看运行时.md
Normal file
218
极客时间专栏/编译原理之美/实现一门编译型语言 · 原理篇/21 | 运行时机制:突破现象看本质,透过语法看运行时.md
Normal file
@@ -0,0 +1,218 @@
|
||||
<audio id="audio" title="21 | 运行时机制:突破现象看本质,透过语法看运行时" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/62/fc/624f041baa6ae6709497ea01849211fc.mp3"></audio>
|
||||
|
||||
编译器的任务,是要生成能够在计算机上运行的代码,但要生成代码,我们必须对程序的运行环境和运行机制有比较透彻的了解。
|
||||
|
||||
你要知道,大型的、复杂一点儿的系统,比如像淘宝一样的电商系统、搜索引擎系统等等,都存在一些技术任务,是需要你深入了解底层机制才能解决的。比如淘宝的基础技术团队就曾经贡献过,Java虚拟机即时编译功能中的一个补丁。
|
||||
|
||||
这反映出掌握底层技术能力的重要性,所以,如果你想进阶成为这个层次的工程师,不能只学学上层的语法,而是要把计算机语言从上层的语法到底层的运行机制都了解透彻。
|
||||
|
||||
本节课,我会对计算机程序如何运行,做一个解密,话题分成两个部分:
|
||||
|
||||
1.了解程序运行的环境,包括CPU、内存和操作系统,探知它们跟程序到底有什么关系。<br>
|
||||
2.了解程序运行的过程。比如,一个程序是怎么跑起来的,代码是怎样执行和跳转的,又是如何管理内存的。
|
||||
|
||||
首先,我们先来了解一下程序运行的环境。
|
||||
|
||||
## 程序运行的环境
|
||||
|
||||
程序运行的过程中,主要是跟两个硬件(CPU和内存)以及一个软件(操作系统)打交道。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/eb/cd/eba17e1195eae228fd9dceea3b06efcd.jpg" alt="">
|
||||
|
||||
本质上,我们的程序只关心CPU和内存这两个硬件。你可能说:“不对啊,计算机还有其他硬件,比如显示器和硬盘啊。”但对我们的程序来说,操作这些硬件,也只是执行某些特定的驱动代码,跟执行其他代码并没有什么差异。
|
||||
|
||||
#### 1.关注CPU和内存
|
||||
|
||||
CPU的内部有很多组成部分,对于本课程来说,我们重点关注的是**寄存器以及高速缓存,**它们跟程序的执行机制和优化密切相关。
|
||||
|
||||
**寄存器**是CPU指令在进行计算的时候,临时数据存储的地方。CPU指令一般都会用到寄存器,比如,典型的一个加法计算(c=a+b)的过程是这样的:
|
||||
|
||||
>
|
||||
<p>指令1(mov):从内存取a的值放到寄存器中;<br>
|
||||
指令2(add):再把内存中b的值取出来与这个寄存器中的值相加,仍然保存在寄存器中;<br>
|
||||
指令3(mov):最后再把寄存器中的数据写回内存中c的地址。</p>
|
||||
|
||||
|
||||
寄存器的速度也很快,所以能用寄存器就别用内存。尽量充分利用寄存器,是编译器做优化的内容之一。
|
||||
|
||||
**而高速缓存**可以弥补CPU的处理速度和内存访问速度之间的差距。所以,我们的指令在内存读一个数据的时候,它不是老老实实地只读进当前指令所需要的数据,而是把跟这个数据相邻的一组数据都读进高速缓存了。这就相当于外卖小哥送餐的时候,不会为每一单来回跑一趟,而是一次取一批,如果这一批外卖恰好都是同一个写字楼里的,那小哥的送餐效率就会很高。
|
||||
|
||||
内存和高速缓存的速度差异差不多是两个数量级,也就是一百倍。比如,高速缓存的读取时间可能是0.5ns,而内存的访问时间可能是50ns。不同硬件的参数可能有差异,但总体来说是几十倍到上百倍的差异。
|
||||
|
||||
你写程序时,尽量把某个操作所需的数据都放在内存中的连续区域中,不要零零散散地到处放,这样有利于充分利用高速缓存。**这种优化思路,叫做数据的局部性。**
|
||||
|
||||
**这里提一句,**在写系统级的程序时,你要对各种IO的时间有基本的概念,比如高速缓存、内存、磁盘、网络的IO大致都是什么数量级的。因为这都影响到系统的整体性能,也影响到你如何做程序优化。如果你需要对程序做更多的优化,还需要了解更多的CPU运行机制,包括流水线机制、并行机制等等,这里就不展开了。
|
||||
|
||||
讲完CPU之后,还有内存这个硬件。
|
||||
|
||||
程序在运行时,操作系统会给它分配一块虚拟的内存空间,让它在运行期可以使用。我们目前使用的都是64位的机器,你可以用一个64位的长整型来表示内存地址,它能够表示的所有地址,我们叫做寻址空间。
|
||||
|
||||
64位机器的寻址空间就有2的64次方那么大,也就是有很多很多个TB(Terabyte),大到你的程序根本用不完。不过,操作系统一般会给予一定的限制,不会给你这么大的寻址空间,比如给到100来个G,这对一般的程序,也足够用了。
|
||||
|
||||
在存在操作系统的情况下,程序逻辑上可使用的内存一般大于实际的物理内存。程序在使用内存的时候,操作系统会把程序使用的逻辑地址映射到真实的物理内存地址。有的物理内存区域会映射进多个进程的地址空间。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e1/48/e17dc76e20cfb194dac757f2e10e4b48.jpg" alt="">
|
||||
|
||||
对于不太常用的内存数据,操作系统会写到磁盘上,以便腾出更多可用的物理内存。
|
||||
|
||||
当然,也存在没有操作系统的情况,这个时候你的程序所使用的内存就是物理内存,我们必须自己做好内存的管理。
|
||||
|
||||
**对于这个内存,该怎么用呢?**
|
||||
|
||||
本质上来说,你想怎么用就怎么用,并没有什么特别的限制。一个编译器的作者,可以决定在哪儿放代码,在哪儿放数据,当然了,别的作者也可能采用其他的策略。实际上,C语言和Java虚拟机对内存的管理和使用策略就是不同的。
|
||||
|
||||
尽管如此,大多数语言还是会采用一些通用的内存管理模式。以C语言为例,会把内存划分为代码区、静态数据区、栈和堆。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/45/6a/452137a61a7b051ffceb40ae45199f6a.jpg" alt="">
|
||||
|
||||
一般来讲,代码区是在最低的地址区域,然后是静态数据区,然后是堆。而栈传统上是从高地址向低地址延伸,栈的最顶部有一块区域,用来保存环境变量。
|
||||
|
||||
**代码区(也叫文本段)存放编译完成以后的机器码。**这个内存区域是只读的,不会再修改,但也不绝对。现代语言的运行时已经越来越动态化,除了保存机器码,还可以存放中间代码,并且还可以在运行时把中间代码编译成机器码,写入代码区。
|
||||
|
||||
**静态数据区保存程序中全局的变量和常量。**它的地址在编译期就是确定的,在生成的代码里直接使用这个地址就可以访问它们,它们的生存期是从程序启动一直到程序结束。它又可以细分为Data和BSS两个段。Data段中的变量是在编译期就初始化好的,直接从程序装在进内存。BSS段中是那些没有声明初始化值的变量,都会被初始化成0。
|
||||
|
||||
**堆适合管理生存期较长的一些数据,这些数据在退出作用域以后也不会消失。**比如,我们在某个方法里创建了一个对象并返回,并希望代表这个对象的数据在退出函数后仍然可以访问。
|
||||
|
||||
**而栈适合保存生存期比较短的数据,比如函数和方法里的本地变量。**它们在进入某个作用域的时候申请内存,退出这个作用域的时候就可以释放掉。
|
||||
|
||||
讲完了CPU和内存之后,我们再来看看跟程序打交道的操作系统。
|
||||
|
||||
#### 2.程序和操作系统的关系
|
||||
|
||||
程序跟操作系统的关系比较微妙:
|
||||
|
||||
<li>
|
||||
一方面我们的程序可以编译成不需要操作系统也能运行,就像一些物联网应用那样,完全跑在裸设备上。
|
||||
</li>
|
||||
<li>
|
||||
另一方面,有了操作系统的帮助,可以为程序提供便利,比如可以使用超过物理内存的存储空间,操作系统负责进行虚拟内存的管理。
|
||||
</li>
|
||||
|
||||
在存在操作系统的情况下,因为很多进程共享计算机资源,所以就要遵循一些约定。这就仿佛办公室是所有同事共享的,那么大家就都要遵守一些约定,如果一个人大声喧哗,就会影响到其他人。
|
||||
|
||||
**程序需要遵守的约定包括:**程序文件的二进制格式约定,这样操作系统才能程序正确地加载进来,并为同一个程序的多个进程共享代码区。在使用寄存器和栈的时候也要遵守一些约定,便于操作系统在不同的进程之间切换的时候、在做系统调用的时候,做好上下文的保护。
|
||||
|
||||
所以,我们编译程序的时候,要知道需要遵守哪些约定。因为就算是使用同样的CPU,针对不同的操作系统,编译的结果也是非常不同的。
|
||||
|
||||
好了,我们了解了程序运行时的硬件和操作系统环境。接下来,我们看看程序运行时,是怎么跟它们互动的。
|
||||
|
||||
## 程序运行的过程
|
||||
|
||||
你天天运行程序,可对于程序运行的细节,真的清楚吗?
|
||||
|
||||
#### 1.程序运行的细节
|
||||
|
||||
首先,可运行的程序一般是由操作系统加载到内存的,并且定位到代码区里程序的入口开始执行。比如,C语言的main函数的第一行代码。
|
||||
|
||||
每次加载一条代码,程序都会顺序执行,碰到跳转语句,才会跳到另一个地址执行。CPU里有一个指令寄存器,里面保存了下一条指令的地址。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3b/f5/3bd535433e4aad9140bc0e114498def5.jpg" alt="">
|
||||
|
||||
假设我们运行这样一段代码编译后形成的程序:
|
||||
|
||||
```
|
||||
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()函数被两次以不同的路径激活。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/42/fa/4281fe310ee37428f91acb31d3a733fa.jpg" alt="">
|
||||
|
||||
我们把每次调用一个函数的过程,叫做一次活动(Activation)。每个活动都对应一个活动记录(Activation Record),这个活动记录里有这个函数运行所需要的信息,比如参数、返回值、本地变量等。
|
||||
|
||||
目前我们用栈来管理内存,所以可以把活动记录等价于栈桢。栈桢是活动记录的实现方式,我们可以自由设计活动记录或栈桢的结构,下图是一个常见的设计:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a2/c3/a2ecc1e47c00e015558bacc83d3dd0c3.jpg" alt="">
|
||||
|
||||
<li>
|
||||
返回值:一般放在最顶上,这样它的地址是固定的。foo()函数返回以后,它的调用者可以到这里来取到返回值。在实际情况中,我们会优先通过寄存器来传递返回值,比通过内存传递性能更高。
|
||||
</li>
|
||||
<li>
|
||||
参数:在调用foo函数时,把参数写到这个地址里。同样,我们也可以通过寄存器来传递,而不是内存。
|
||||
</li>
|
||||
<li>
|
||||
控制链接:就是上一级栈桢的地址。如果用到了上一级作用域中的变量,就可以顺着这个链接找到上一级栈桢,并找到变量的值。
|
||||
</li>
|
||||
<li>
|
||||
返回地址:foo函数执行完毕以后,继续执行哪条指令。同样,我们可以用寄存器来保存这个信息。
|
||||
</li>
|
||||
<li>
|
||||
本地变量:foo函数的本地变量b的存储空间。
|
||||
</li>
|
||||
<li>
|
||||
寄存器信息:我们还经常在栈桢里保存寄存器的数据。如果在foo函数里要使用某个寄存器,可能需要先把它的值保存下来,防止破坏了别的代码保存在这里的数据。**这种约定叫做被调用者责任,**也就是使用寄存器的人要保护好寄存器里原有的信息。某个函数如果使用了某个寄存器,但它又要调用别的函数,为了防止别的函数把自己放在寄存器中的数据覆盖掉,要自己保存在栈桢中。**这种约定叫做调用者责任。**
|
||||
</li>
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d9/41/d95e2987786756a6ecb2d1f47df37841.jpg" alt="">
|
||||
|
||||
你可以看到,每个栈桢的长度是不一样的。
|
||||
|
||||
用到的参数和本地变量多,栈桢就要长一点。但是,栈桢的长度和结构是在编译期就能完全确定的。这样就便于我们计算地址的偏移量,获取栈桢里某个数据。
|
||||
|
||||
总的来说,栈桢的设计很自由。但是,你要考虑不同语言编译形成的模块要能够链接在一起,所以还是要遵守一些公共的约定的,否则,你写的函数,别人就没办法调用了。
|
||||
|
||||
在[08讲](https://time.geekbang.org/column/article/128623),我提到过栈桢,这次我们用了更加贴近具体实现的描述:栈桢就是一块确定的内存,变量就是这块内存里的地址。在下一讲,我会带你动手实现我们的栈桢。
|
||||
|
||||
#### 2.从全局角度看整个运行过程
|
||||
|
||||
了解了栈桢的实现之后,我们再来看一个更大的场景,从全局的角度看看整个运行过程中都发生了什么。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/31/2f/31ec7430fcb8bb2752151a38ed65672f.jpg" alt="">
|
||||
|
||||
代码区里存储了一些代码,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)是可以由程序访问的,比如我们可以拿来存储返回值。这一小段内存操作系统并不会回收。
|
||||
|
||||
我们目前只讲了栈,堆的使用也类似,只不过是要手工进行申请和释放,比栈要多一些维护工作。
|
||||
|
||||
## 课程小结
|
||||
|
||||
本节课,我带你了解了程序运行的环境和过程,我们的程序主要跟CPU、内存,以及操作系统打交道。你需要了解的重点如下:
|
||||
|
||||
<li>
|
||||
CPU上运行程序的指令,运行过程中要用到寄存器、高速缓存来提高指令和数据的存取效率。
|
||||
</li>
|
||||
<li>
|
||||
内存可以划分成不同的区域保存代码、静态数据,并用栈和堆来存放运行时产生的动态数据。
|
||||
</li>
|
||||
<li>
|
||||
操作系统会把物理的内存映射成进程的寻址空间,同一份代码会被映射进多个进程的内存空间,操作系统的公共库也会被映射进进程的内存空间,操作系统还会自动维护栈。
|
||||
</li>
|
||||
|
||||
程序在运行时顺序执行代码,可以根据跳转指令来跳转;栈被划分成栈桢,栈桢的设计有一定的自由度,但通常也要遵守一些约定;栈桢的大小和结构在编译时就能决定;在运行时,栈桢作为活动记录,不停地被动态创建和释放。
|
||||
|
||||
以上这些内容就是一个程序运行时的秘密。你再面对代码时,脑海里就会想象出它是怎样跟CPU、内存和操作系统打交道的了。而且有了这些背景知识,你也可以让编译器生成代码,按照本节课所说的模式运行了!
|
||||
|
||||
## 一课一思
|
||||
|
||||
本节课,我概要地介绍了程序运行的环境和运行过程。常见的静态编译型的语言,比如C语言、Go语言,差不多都是这个模式。那么你是否了解你所采用的计算机语言的运行环境和运行过程?跟本文描述的哪些地方相同,哪些地方不同?欢迎在留言区分享你的经验。
|
||||
|
||||
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
|
321
极客时间专栏/编译原理之美/实现一门编译型语言 · 原理篇/22 | 生成汇编代码(一):汇编语言其实不难学.md
Normal file
321
极客时间专栏/编译原理之美/实现一门编译型语言 · 原理篇/22 | 生成汇编代码(一):汇编语言其实不难学.md
Normal file
@@ -0,0 +1,321 @@
|
||||
<audio id="audio" title="22 | 生成汇编代码(一):汇编语言其实不难学" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bc/ca/bccec8d844433a8b64e1ea9896e52fca.mp3"></audio>
|
||||
|
||||
>
|
||||
敲黑板:课程用的是GNU汇编器,macOS和Linux已内置,本文的汇编语言的写法是GNU汇编器规定的写法。Windows系统可安装MinGW或Linux虚拟机。
|
||||
|
||||
|
||||
对于静态编译型语言,比如C语言和Go语言,编译器后端的任务就是生成汇编代码,然后再由汇编器生成机器码,生成的文件叫目标文件,最后再使用链接器就能生成可执行文件或库文件了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fe/76/feadbf7a473c420d0693c249b48e0e76.jpg" alt="">
|
||||
|
||||
就算像JavaScript这样的解释执行的语言,也要在运行时利用类似的机制生成机器码,以便调高执行的速度。Java的字节码,在运行时通常也会通过JIT机制编译成机器码。**而汇编语言是完成这些工作的基础。**
|
||||
|
||||
对你来说,掌握汇编语言是十分有益的,因为哪怕掌握一小点儿汇编技能,就能应用到某项工作中,比如,在C语言里嵌入汇编,实现某个特殊功能;或者读懂某些底层类库或驱动程序的代码,因为它可能是用汇编写的。
|
||||
|
||||
本节课,我先带你了解一下汇编语言,来个破冰之旅。然后在接下来的课程中再带你基于AST手工生成汇编代码,破除你对汇编代码的恐惧,了解编译期后端生成汇编代码的原理。
|
||||
|
||||
以后,当你看到高级语言的代码,以及IR时,就可以想象出来它对应的汇编代码是什么样子,实现从上层到底层认知的贯通。
|
||||
|
||||
## 了解汇编语言
|
||||
|
||||
机器语言都是0101的二进制的数据,不适合我们阅读。而汇编语言,简单来说,是可读性更好的机器语言,基本上每条指令都可以直接翻译成一条机器码。
|
||||
|
||||
跟你日常使用的高级语言相比,汇编语言的语法特别简单,但它要跟硬件(CPU和内存)打交道,我们来体会一下。
|
||||
|
||||
计算机的处理器有很多不同的架构,比如x86-64、ARM、Power等,每种处理器的指令集都不相同,那也就意味着汇编语言不同。我们目前用的电脑,CPU一般是x86-64架构,是64位机。(如不做特别说明,本课程都是以x86-64架构作为例子的)。
|
||||
|
||||
说了半天,汇编代码长什么样子呢?我用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 //运行程序
|
||||
|
||||
```
|
||||
|
||||
以上面的代码为例,来看一下汇编语言的组成元素。**这是汇编语言入门的基础,也是重点内容,在阅读时,你不需要死记硬背,而是要灵活掌握,**比如CPU的指令特别多,我们记住常用的就行了,不太常用的可以去查手册。
|
||||
|
||||
#### 1.汇编语言的组成元素
|
||||
|
||||
这段代码里有**指令、伪指令、标签和注释**四种元素,每个元素单独占一行。
|
||||
|
||||
**指令(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语言中以//表示注释语句是一样的。**
|
||||
|
||||
因为指令是汇编代码的主要部分,所以我们再把与指令有关的知识点展开讲解一下。
|
||||
|
||||
#### 2.详细了解指令这个元素
|
||||
|
||||
在代码中,助记符“movq”“xorl”中的“mov”和“xor”是指令,而“q”和“l”叫做后缀,表示操作数的位数。后缀一共有b, w, l, q四种,分别代表8位、16位、32位和64位。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/83/4b/83e27f35ac31ae773e52e8826e6e534b.jpg" alt="">
|
||||
|
||||
比如,movq中的q代表操作数是8个字节,也就是64位的。movq就是把8字节从一个地方拷贝到另一个地方,而movl则是拷贝4个字节。
|
||||
|
||||
而在指令中使用操作数,可以使用四种格式,它们分别是:**立即数、寄存器、直接内存访问和间接内存访问。**
|
||||
|
||||
**立即数以$开头,** **比如$40**。(下面这行代码是把40这个数字拷贝到%eax寄存器)。
|
||||
|
||||
```
|
||||
movl $40, %eax
|
||||
|
||||
```
|
||||
|
||||
除此之外,我们在指令中最常见到的就是对寄存器的访问,GNU的汇编器规定寄存器一定要以%开头。
|
||||
|
||||
**直接内存访问:**当我们在代码中看到操作数是一个数字时,它其实指的是内存地址。不要误以为它是一个数字,因为数字立即数必须以$开头。另外,汇编代码里的标签,也会被翻译成直接内存访问的地址。比如“callq _printf”中的“_printf”是一个函数入口的地址。汇编器帮我们计算出程序装载在内存时,每个字面量和过程的地址。
|
||||
|
||||
**间接内存访问:**带有括号,比如(%rbp),它是指%rbp寄存器的值所指向的地址。
|
||||
|
||||
间接内存访问的完整形式是:
|
||||
|
||||
>
|
||||
偏移量(基址,索引值,字节数)这样的格式。
|
||||
|
||||
|
||||
其地址是:
|
||||
|
||||
>
|
||||
基址 + 索引值*字节数 + 偏移量
|
||||
|
||||
|
||||
举例来说:
|
||||
|
||||
>
|
||||
<p>8(%rbp),是比%rbp寄存器的值加8。<br>
|
||||
-8(%rbp),是比%rbp寄存器的值减8。<br>
|
||||
(%rbp, %eax, 4)的值,等于%rbp + %eax*4。这个地址格式相当于访问C语言中的数组中的元素,数组元素是32位的整数,其索引值是%eax,而数组的起始位置是%rbp。其中字节数只能取1,2,4,8四个值。</p>
|
||||
|
||||
|
||||
你现在应该对指令的格式有所了解了,接下来,我们再学几个常用的指令:
|
||||
|
||||
**mov指令**
|
||||
|
||||
```
|
||||
mov 寄存器|内存|立即数, 寄存器|内存
|
||||
|
||||
```
|
||||
|
||||
这个指令最常用到,用于在寄存器或内存之间传递数据,或者把立即数加载到内存或寄存器。mov指令的第一个参数是源,可以是寄存器、内存或立即数。第二个参数是目的地,可以是寄存器或内存。
|
||||
|
||||
**lea指令,lea是“load effective address”的意思,装载有效地址。**
|
||||
|
||||
```
|
||||
lea 源,目的
|
||||
|
||||
```
|
||||
|
||||
比如前面例子代码中的leaq指令,是把字符串的地址加载到%rdi寄存器。
|
||||
|
||||
```
|
||||
leaq L_.str(%rip), %rdi
|
||||
|
||||
```
|
||||
|
||||
**add指令是做加法运算,它可以采取下面的格式:**
|
||||
|
||||
```
|
||||
add 立即数, 寄存器
|
||||
add 寄存器, 寄存器
|
||||
add 内存, 寄存器
|
||||
add 立即数, 内存
|
||||
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以外,其他算术运算的指令:**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5b/5e/5b945cfd9287417e801819a22f5a8b5e.jpg" alt="">
|
||||
|
||||
**与栈有关的操作:**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/72/df/72dd44d44e416cf59bc3bb40efdb99df.jpg" alt="">
|
||||
|
||||
**跳转类:**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/81/58/814115093a062cfcde9054d4bd957858.jpg" alt="">
|
||||
|
||||
**过程调用:**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/71/15/71014fa3d6f218ba4cd0d65ae8966615.jpg" alt="">
|
||||
|
||||
**比较操作:**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/48/f0/48ed198159b91b09a317493870faecf0.jpg" alt="">
|
||||
|
||||
以上我列举的指令,是你在编写汇编代码时,经常会用到的,比较重要,会满足你编写简单汇编程序的需求,所以你需要重点关注。
|
||||
|
||||
x86-64是复杂指令集的处理器,有非常多的指令,差不多有上千条,全部记住是比较难的。更好的办法,是记住主要的指令,其他指令在使用时去查[Intel公司的手册](https://software.intel.com/en-us/download/intel-64-and-ia-32-architectures-sdm-combined-volumes-1-2a-2b-2c-2d-3a-3b-3c-3d-and-4),在这里我就不举例了。
|
||||
|
||||
## x86-64架构的寄存器
|
||||
|
||||
在汇编代码中,我们经常要使用各种以%开头的寄存器的符号。初学者阅读这些代码时,通常会有一些疑问:有几个寄存器可以用?我可以随便用它们吗?使用不当会不会造成错误?等等。所以,有必要让你熟悉一下这些寄存器,了解它们的使用方法。
|
||||
|
||||
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系统)等,下面的内容属于后者:
|
||||
|
||||
<li>
|
||||
%rax 除了其他用途外,通常在函数返回的时候,把返回值放在这里。
|
||||
</li>
|
||||
<li>
|
||||
%rsp 作为栈指针寄存器,指向栈顶。
|
||||
</li>
|
||||
<li>
|
||||
%rdi,%rsi,%rdx,%rcx,%r8,%r9 给函数传整型参数,依次对应第1参数到第6参数。超过6个参数怎么办?放在栈桢里,我们[21讲](https://time.geekbang.org/column/article/146635)已经讲过了。
|
||||
</li>
|
||||
<li>
|
||||
如果程序要使用%rbx,%rbp,%r12,%r13,%r14,%r15 这几个寄存器,是由被调用者(Callee)负责保护的,也就是写到栈里,在返回的时候要恢复这些寄存器中原来的内容。其他寄存器的内容,则是由调用者(Caller)负责保护,如果不想这些寄存器中的内容被破坏,那么要自己保护起来。
|
||||
</li>
|
||||
|
||||
上面这些寄存器的名字都是64位的名字,对于每个寄存器,我们还可以只使用它的一部分,并且另起一个名字。比如对于%rax,如果使用它的前32位,就叫做%eax,前16位叫%ax,前8位(0到7位)叫%al,8到15位叫%ah。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/db/1a/dbde233c28b9f92b38286abb49c1411a.jpg" alt="">
|
||||
|
||||
其他的寄存器也有这样的使用方式,当你在汇编代码中看到这些名称的时候,你就知道其实它们有可能在物理上是同一个寄存器。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b9/3d/b9bec8ec5536a5d1fc346e79b0357a3d.jpg" alt="">
|
||||
|
||||
除了通用寄存器以外,有可能的话,还要了解下面的寄存器和它们的用途,我们写汇编代码时也经常跟它们发生关联:
|
||||
|
||||
<li>
|
||||
8个80位的x87寄存器,用于做浮点计算;
|
||||
</li>
|
||||
<li>
|
||||
8个64位的MMX寄存器,用于MMX指令(即多媒体指令),这8个跟x87寄存器在物理上是相同的寄存器。在传递浮点数参数的时候,要用mmx寄存器。
|
||||
</li>
|
||||
<li>
|
||||
16个128位的SSE寄存器,用于SSE指令。我们将在应用篇里使用SSE指令,讲解SIMD的概念。
|
||||
</li>
|
||||
<li>
|
||||
指令寄存器,rip,保存指令地址。CPU总是根据这个寄存器来读取指令。
|
||||
</li>
|
||||
<li>
|
||||
flags(64位:rflags, 32位:eflags)寄存器:每个位用来标识一个状态。比如,它们会用于比较和跳转的指令,比如if语句翻译成的汇编代码,就会用它们来保存if条件的计算结果。
|
||||
</li>
|
||||
|
||||
总的来说,我们的汇编代码处处要跟寄存器打交道,正确和高效使用寄存器,是编译期后端的重要任务之一。
|
||||
|
||||
## 课程小结
|
||||
|
||||
本节课,我讲解了汇编语言的一些基础知识,由于汇编语言的特点,涉及的知识点和细节比较多,在这个过程中,你无需死记硬背,只需要掌握几个重点内容:
|
||||
|
||||
1.汇编语言是由指令、标签、伪指令和注释构成的。其中主要内容都是指令。指令包含一个该指令的助记符和操作数。操作数可以使用直接数、寄存器,以及用两种方式访问内存地址。
|
||||
|
||||
2.汇编指令中会用到一些通用寄存器。这些寄存器除了用于计算以外,还可以根据调用约定帮助传递参数和返回值。使用寄存器时,要区分由调用者还是被调用者负责保护寄存器中原来的内容。
|
||||
|
||||
另外,我们还要注意按照一定的规则维护和使用栈桢,**这个知识点会在后面的加餐中展开来讲一个例子。**
|
||||
|
||||
鉴于你可能是第一次使用汇编语言,所以我**提供两个建议,让你快速上手汇编语言:**
|
||||
|
||||
1.你可以用C语言写一些示例代码,然后用编译器生成汇编代码,看看能否看懂。
|
||||
|
||||
2.模仿文稿中的例子,自己改写并运行你自己的汇编程序,这个过程中,你会发现真的没那么难。
|
||||
|
||||
## 一课一思
|
||||
|
||||
你之前学习过或者在项目中使用过汇编语言吗?感受是什么呢?有什么经验和体会呢?欢迎在留言区分享你的经验与感受。
|
||||
|
||||
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
|
450
极客时间专栏/编译原理之美/实现一门编译型语言 · 原理篇/23 | 生成汇编代码(二):把脚本编译成可执行文件.md
Normal file
450
极客时间专栏/编译原理之美/实现一门编译型语言 · 原理篇/23 | 生成汇编代码(二):把脚本编译成可执行文件.md
Normal file
@@ -0,0 +1,450 @@
|
||||
<audio id="audio" title="23 | 生成汇编代码(二):把脚本编译成可执行文件" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3f/07/3fbda6bc711a84d6785530dd81f18207.mp3"></audio>
|
||||
|
||||
学完两节课之后,对于后端编译过程,你可能还会产生一些疑问,比如:
|
||||
|
||||
1.大致知道汇编程序怎么写,却不知道如何从AST生成汇编代码,中间有什么挑战。
|
||||
|
||||
2.编译成汇编代码之后需要做什么,才能生成可执行文件。
|
||||
|
||||
本节课,我会带你真正动手,基于AST把playscript翻译成正确的汇编代码,并将汇编代码编译成可执行程序。
|
||||
|
||||
通过这样一个过程,可以实现从编译器前端到后端的完整贯通,帮你对编译器后端工作建立比较清晰的认识。这样一来,你在日常工作中进行大型项目的编译管理的时候,或者需要重用别人的类库的时候,思路会更加清晰。
|
||||
|
||||
## 从playscript生成汇编代码
|
||||
|
||||
**先来看看如何从playscript生成汇编代码。**
|
||||
|
||||
我会带你把playscript的几个有代表性的功能,而不是全部的功能翻译成汇编代码,一来工作量少一些,二来方便做代码优化。这几个有代表性的功能如下:
|
||||
|
||||
1.支持函数调用和传参(这个功能可以回顾加餐)。
|
||||
|
||||
2.支持整数的加法运算(在这个过程中要充分利用寄存器提高性能)。
|
||||
|
||||
3.支持变量声明和初始化。
|
||||
|
||||
具体来说,要能够把下面的示例程序正确生成汇编代码:
|
||||
|
||||
```
|
||||
//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个参数的情况下传参,观察栈帧的变化过程,你可以看看下面的图片和代码,回忆一下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/45/89/45587ab64c83ea52f9d1fd3fedc6b189.jpg" alt="">
|
||||
|
||||
```
|
||||
# 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 # 调用函数
|
||||
|
||||
# 为pritf设置参数
|
||||
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"
|
||||
|
||||
```
|
||||
|
||||
接下来,我们动手写程序,从AST翻译成汇编代码(相关代码在playscript-java项目的[AsmGen.java](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/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,本地变量
|
||||
|
||||
```
|
||||
|
||||
**看出这个代码有什么问题了吗?**我们每次执行加法运算的时候,都要占用一个新的寄存器。比如,x1+x2使用了%eax,再加x3时使用了%ebx,按照这样的速度,寄存器很快就用完了,使用效率显然不高。所以必须要做代码优化。
|
||||
|
||||
如果只是简单机械地翻译代码,相当于产生了大量的临时变量,每个临时变量都占用了空间:
|
||||
|
||||
```
|
||||
t1 := x1 + x2;
|
||||
t2 := t1 + x3;
|
||||
t3 := t2 + x4;
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
**进行代码优化**可以让不再使用的存储位置(t1,t2,t3…)能够复用,从而减少临时变量,也减少代码行数,[优化后的申请临时存储空间的方法](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/AsmGen.java#L164)如下:
|
||||
|
||||
```
|
||||
//复用前序表达式的存储位置
|
||||
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
|
||||
|
||||
```
|
||||
|
||||
**对代码如何使用寄存器进行充分优化,是编译器后端一项必须要做的工作。**这里只用了很粗糙的方法,不具备实用价值,后面可以学习更好的优化算法。
|
||||
|
||||
弄清楚了加法运算的代码翻译逻辑,我们再看看AsmGen.java中的[generate()](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/AsmGen.java#L71)方法和[generateProcedure()](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/AsmGen.java#L107)方法,看看汇编代码完整的生成逻辑是怎样的。这样可以帮助你弄清楚整体脉络和所有的细节,比如函数的标签是怎么生成的,序曲和尾声是怎么加上去的,本地变量的地址是如何计算的,等等。
|
||||
|
||||
```
|
||||
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.在一个新的section中,声明一些全局的常量(字面量)。整个程序的结构跟最后生成的汇编代码的结构是一致的,所以很容易看懂。
|
||||
|
||||
**generateProcedure()方法把函数转换成汇编代码,里面的注释也很清晰,开头的工作包括:**
|
||||
|
||||
1.生成函数标签、序曲部分的代码、设置栈顶指针、保护寄存器原有的值等。
|
||||
|
||||
2.接着是函数体,比如本地变量初始化、做加法运算等。
|
||||
|
||||
3.最后是一系列收尾工作,包括恢复被保护的寄存器的值、恢复栈顶指针,以及尾声部分的代码。
|
||||
|
||||
我们之前已经理解了一个函数体中的汇编代码的结构,所以看这段翻译代码肯定不费事儿。
|
||||
|
||||
```
|
||||
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();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
最后,你可以通过-S参数运行playscript-java,将asm.play文件生成汇编代码文件asm.s,再生成和运行可执行文件:
|
||||
|
||||
```
|
||||
java play.PlayScript -S asm.play -o asm.s //生成汇编代码
|
||||
gcc asm.s -o asm //生成可执行文件
|
||||
./asm //运行可执行文件
|
||||
|
||||
```
|
||||
|
||||
另外,我们的翻译程序只实现了少量的特性(加法运算、本地变量、函数……)。我建议基于这个代码框架做修改,增加其他特性,比如减法、乘法和除法,支持浮点数,支持if语句和循环语句等。学过加餐之后,你应该清楚如何生成这样的汇编代码了。
|
||||
|
||||
到目前为止,我们已经成功地编译playscript程序,并生成了可执行文件!为了加深你对生成可执行文件的理解,我们再做个挑战,用playscript生成目标文件,让C语言来调用。这样可以证明playscript生成汇编代码的逻辑是靠谱的,以至于可以用playscript代替C语言来写一个共用模块。
|
||||
|
||||
## 通过C语言调用playscript模块
|
||||
|
||||
我们在编程的时候,经常调用一些公共的库实现一些功能,这些库可能是别的语言写的,但我们仍然可以调用。我们也可以实现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
|
||||
|
||||
```
|
||||
|
||||
<li>
|
||||
第一个命令,把playscript生成的汇编代码编译成一个二进制目标文件。
|
||||
</li>
|
||||
<li>
|
||||
<p>第二个命令在编译C程序的时候,同时也带上这个二进制文件,那么编译器就会找到fun1()函数的定义,并链接到一起。<br>
|
||||
最后生成的可执行文件能够顺利运行。</p>
|
||||
</li>
|
||||
|
||||
**这里面,我需要解释一下链接过程,**它有助于你在二进制文件层面上加深对编译过程的理解。
|
||||
|
||||
其实,高级语言和汇编语言都容易阅读。而二进制文件,则是对计算机友好的,便于运行。汇编器可以把每一个汇编文件都编译生成一个二进制的目标文件,或者叫做一个模块。而链接器则把这些模块组装成一个整体。
|
||||
|
||||
但在C语言生成的那个模块中,调用fun1()函数时,它没有办法知道fun1()函数的准确地址,因为这个地址必须是整个文件都组装完毕以后才能计算出来。所以,汇编器把这个任务推迟,交给链接器去解决。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/71/3b/71d5ff8c02eb1f0c98fc55862e4ca63b.jpg" alt="">
|
||||
|
||||
**这就好比你去饭店排队吃饭,首先要拿个号(函数的标签),但不知道具体坐哪桌。等叫到你的号的时候(链接过程),服务员才会给你安排一个确定的桌子(函数的地址)。**
|
||||
|
||||
既然我们已经从文本世界进入了二进制的世界,那么我们可以再加深一下对可执行文件结构的理解。
|
||||
|
||||
## 理解可执行文件
|
||||
|
||||
我们编译一个程序,最后的结果是生成可运行的二进制文件。其实,生成汇编代码以后,我们就可以认为编译器的任务完成了。后面的工作,其实是由汇编器和链接器完成的。但我们也可以把整个过程都看做编译过程,了解二进制文件的结构,也为我们完整地了解整个编译过程划上了句号。
|
||||
|
||||
当然了,对二进制文件格式的理解,也是做**大型项目编译管理、二进制代码分析等工作的基础,**很有意义。
|
||||
|
||||
对于每个操作系统,我们对于可执行程序的格式要求是不一样的。比如,在Linux下,目标文件、共享对象文件、二进制文件,都是采用ELF格式。
|
||||
|
||||
实际上,这些二进制文件的格式跟加载到内存中的程序的格式是很相似的。这样有什么好处呢?它可以迅速被操作系统读取,并加载到内存中去,加载速度越快,也就相当于程序的启动速度越快。
|
||||
|
||||
同内存中的布局一样,在ELF格式中,代码和数据也是分开的。这样做的好处是,程序的代码部分,可以在多个进程中共享,不需要在内存里放多份。放一份,然后映射到每个进程的代码区就行了。而数据部分,则是每个进程都不一样的,所以要为每个进程加载一份。
|
||||
|
||||
这样讲的话,**你就理解了可执行文件、目标文件等二进制文件的原理了,**具体的细节,可以查阅相关的文档和手册。
|
||||
|
||||
## 课程小结
|
||||
|
||||
这节课,我们实现了从AST到汇编代码,汇编代码到可执行文件的完整过程。现在,你应该对后端工作的实质建立起了直接的认识。我建议你抓住几个关键点:
|
||||
|
||||
首先,从AST生成汇编代码,可以通过比较机械的翻译来完成,我们举了加法运算的例子。阅读示例程序,你也可以看看函数调用、参数传递等等的实现过程。总体来说,这个过程并不难。
|
||||
|
||||
第二,这种机械地翻译生成的代码,一定是不够优化的。我们已经看到了加法运算不够优化的情况,所以一定要增加一个优化的过程。
|
||||
|
||||
第三,在生成汇编的过程中,最需要注意的就是要遵守调用约定。这就需要了解调用约定的很多细节。只要遵守调用约定,不同语言生成的二进制目标文件也可以链接在一起,形成最后的可执行文件。
|
||||
|
||||
现在我已经带你完成了编译器后端的第一轮认知迭代,并且直接操刀汇编代码,破除你对汇编的恐惧心。在之后的课程中,我们会进入第二轮迭代:中间代码和代码优化。
|
||||
|
||||
## 一课一思
|
||||
|
||||
我们针对加法计算、函数调用等语法生成了汇编代码。你能否思考一下,如果要支持其他运算和语法,比如乘法运算、条件判断、循环语句等,大概会怎样实现?如果要支持面向对象编程,又该怎样实现呢?欢迎你打开思路,在留言区分享自己的想法。
|
||||
|
||||
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
|
||||
|
||||
示例代码我放在文末,供你参考。
|
||||
|
||||
- AsmGen.java(将AST翻译成汇编代码) [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/AsmGen.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/AsmGen.java)
|
||||
- asm.play(用于生成汇编码的playscript脚本) [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/examples/asm.play) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/examples/asm.play)
|
410
极客时间专栏/编译原理之美/实现一门编译型语言 · 原理篇/24 | 中间代码:兼容不同的语言和硬件.md
Normal file
410
极客时间专栏/编译原理之美/实现一门编译型语言 · 原理篇/24 | 中间代码:兼容不同的语言和硬件.md
Normal file
@@ -0,0 +1,410 @@
|
||||
<audio id="audio" title="24 | 中间代码:兼容不同的语言和硬件" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9f/09/9f3b7c6de4ad431ce4e85c1119f35e09.mp3"></audio>
|
||||
|
||||
前几节课,我带你尝试不通过IR,直接生成汇编代码,这是为了帮你快速破冰,建立直觉。在这个过程中,你也遇到了一些挑战,比如:
|
||||
|
||||
<li>
|
||||
你要对生成的代码进行优化,才有可能更好地使用寄存器和内存,同时也能减少代码量;
|
||||
</li>
|
||||
<li>
|
||||
另外,针对不同的CPU和操作系统,你需要调整生成汇编代码的逻辑。
|
||||
</li>
|
||||
|
||||
这些实际体验,都进一步验证了[20讲](https://time.geekbang.org/column/article/145472)中,IR的作用:我们能基于IR对接不同语言的前端,也能对接不同的硬件架构,还能做很多的优化。
|
||||
|
||||
既然IR有这些作用,那你可能会问,**IR都是什么样子的呢?有什么特点?如何生成IR呢?**
|
||||
|
||||
本节课,我就带你了解IR的特点,认识常见的三地址代码,学会如何把高级语言的代码翻译成IR。然后,我还会特别介绍LLVM的IR,以便后面使用LLVM这个工具。
|
||||
|
||||
首先,来看看IR的特征。
|
||||
|
||||
## 介于中间的语言
|
||||
|
||||
IR的意思是中间表达方式,它在高级语言和汇编语言的中间,这意味着,它的特征也是处于二者之间的。
|
||||
|
||||
与高级语言相比,IR丢弃了大部分高级语言的语法特征和语义特征,比如循环语句、if语句、作用域、面向对象等等,它更像高层次的汇编语言;而相比真正的汇编语言,它又不会有那么多琐碎的、与具体硬件相关的细节。
|
||||
|
||||
相信你在学习汇编语言的时候,会发现汇编语言的细节特别多。比如,你要知道很多指令的名字和用法,还要记住很多不同的寄存器。[在22讲](https://time.geekbang.org/column/article/147854),我提到,如果你想完整地掌握x86-64架构,还需要接触很多指令集,以及调用约定的细节、内存使用的细节等等([参见Intel的手册](https://software.intel.com/en-us/download/intel-64-and-ia-32-architectures-sdm-combined-volumes-1-2a-2b-2c-2d-3a-3b-3c-3d-and-4))。
|
||||
|
||||
仅仅拿指令的数量来说,据有人统计,Intel指令的助记符有981个之多!都记住怎么可能啊。**所以说,汇编语言并不难,而是麻烦。**
|
||||
|
||||
IR不会像x86-64汇编语言那么繁琐,但它却包含了足够的细节信息,能方便我们实现优化算法,以及生成针对目标机器的汇编代码。
|
||||
|
||||
另外,我在20讲提到,IR有很多种类(AST也是一种IR),每种IR都有不同的特点和用途,有的编译器,甚至要用到几种不同的IR。
|
||||
|
||||
我们在后端部分所讲的IR,目的是方便执行各种优化算法,并有利于生成汇编。**这种IR,可以看做是一种高层次的汇编语言,主要体现在:**
|
||||
|
||||
- 它可以使用寄存器,但寄存器的数量没有限制;
|
||||
- 控制结构也跟汇编语言比较像,比如有跳转语句,分成多个程序块,用标签来标识程序块等;
|
||||
- 使用相当于汇编指令的操作码。这些操作码可以一对一地翻译成汇编代码,但有时一个操作码会对应多个汇编指令。
|
||||
|
||||
下面来看看一个典型IR:三地址代码,简称TAC。
|
||||
|
||||
## 认识典型的IR:三地址代码(TAC)
|
||||
|
||||
下面是一种常见的IR的格式,它叫做三地址代码(Three Address Code, TAC),它的优点是很简洁,所以适合用来讨论算法:
|
||||
|
||||
```
|
||||
x := y op z //二元操作
|
||||
x := op y //一元操作
|
||||
|
||||
```
|
||||
|
||||
每条三地址代码最多有三个地址,其中两个是源地址(比如第一行代码的y和z),一个是目的地址(也就是x),每条代码最多有一个操作(op)。
|
||||
|
||||
我来举几个例子,带你熟悉一下三地址代码,**这样,你能掌握三地址代码的特点,从高级语言的代码转换生成三地址代码。**
|
||||
|
||||
**1.基本的算术运算:**
|
||||
|
||||
```
|
||||
int a, b, c, d;
|
||||
a = b + c * d;
|
||||
|
||||
```
|
||||
|
||||
TAC:
|
||||
|
||||
```
|
||||
t1 := c * d
|
||||
a := b + t1
|
||||
|
||||
```
|
||||
|
||||
t1是新产生的临时变量。当源代码的表达式中包含一个以上的操作符时,就需要引入临时变量,并把原来的一条代码拆成多条代码。
|
||||
|
||||
**2.布尔值的计算:**
|
||||
|
||||
```
|
||||
int a, b;
|
||||
bool x, y;
|
||||
x = a * 2 < b;
|
||||
y = a + 3 == b;
|
||||
|
||||
```
|
||||
|
||||
TAC:
|
||||
|
||||
```
|
||||
t1 := a * 2;
|
||||
x := t1 < b;
|
||||
t2 := a + 3;
|
||||
y := t2 == b;
|
||||
|
||||
```
|
||||
|
||||
布尔值实际上是用整数表示的,0代表false,非0值代表true。
|
||||
|
||||
**3.条件语句:**
|
||||
|
||||
```
|
||||
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)。
|
||||
|
||||
**4.循环语句:**
|
||||
|
||||
```
|
||||
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、操作系统)相关的更加具体的信息,准确地生成目标代码,从而真正能够用于生产环境。
|
||||
|
||||
**在讲这个问题之前,我想先延伸一下,讲讲另外几种IR的格式,**主要想帮你开拓思维,如果你的项目需求,恰好能用这种IR实现,到时不妨拿来用一下:
|
||||
|
||||
<li>
|
||||
首先是四元式。它是与三地址代码等价的另一种表达方式,格式是:(OP,arg1,arg2,result)所以,“a := b + c” 就等价于(+,b,c,a)。
|
||||
</li>
|
||||
<li>
|
||||
另一种常用的格式是逆波兰表达式。它把操作符放到后面,所以也叫做后缀表达式。“b + c”对应的逆波兰表达式是“b c +”;而“a = b + c”对应的逆波兰表达式是“a b c + =”。
|
||||
</li>
|
||||
|
||||
**逆波兰表达式特别适合用栈来做计算。**比如计算“b c +”,先从栈里弹出加号,知道要做加法操作,然后从栈里弹出两个操作数,执行加法运算即可。这个计算过程,跟深度优先的遍历AST是等价的。所以,采用逆波兰表达式,有可能让你用一个很简单的方式就实现公式计算功能,**如果你编写带有公式功能的软件时可以考虑使用它。**而且,从AST生成逆波兰表达式也非常容易。
|
||||
|
||||
三地址代码主要是学习算法的工具,或者用于实现比较简单的后端,要实现工业级的后端,充分发挥硬件的性能,你还要学习LLVM的IR。
|
||||
|
||||
## 认识LLVM汇编码
|
||||
|
||||
**LLVM汇编码(LLVM Assembly),是LLVM的IR。**有的时候,我们就简单地称呼它为LLVM语言,因此我们可以把用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汇编码如下(这是我在macOS上生成的):
|
||||
|
||||
```
|
||||
; 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汇编的十分之一都不到。
|
||||
|
||||
**我们来熟悉一下里面的元素:**
|
||||
|
||||
- 模块
|
||||
|
||||
LLVM程序是由模块构成的,这个文件就是一个模块。模块里可以包括函数、全局变量和符号表中的条目。链接的时候,会把各个模块拼接到一起,形成可执行文件或库文件。
|
||||
|
||||
在模块中,你可以定义目标数据布局(target datalayout)。例如,开头的小写“e”是低字节序(Little Endian)的意思,对于超过一个字节的数据来说,低位字节排放在内存的低地址端,高位字节排放在内存的高地址端。
|
||||
|
||||
```
|
||||
target datalayout = "e-m:o-i64:64-f80:128-n8:16:32:64-S128"
|
||||
|
||||
```
|
||||
|
||||
“target triple”用来定义模块的目标主机,它包括架构、厂商、操作系统三个部分。
|
||||
|
||||
```
|
||||
target triple = "x86_64-apple-macosx10.14.0"
|
||||
|
||||
```
|
||||
|
||||
- 函数
|
||||
|
||||
在示例代码中有一个以define开头的函数的声明,还带着花括号。这有点儿像C语言的写法,比汇编用采取标签来表示一个函数的可读性更好。
|
||||
|
||||
函数声明时可以带很多修饰成分,比如链接类型、调用约定等。如果不写,缺省的链接类型是external的,也就是可以像[23讲](https://time.geekbang.org/column/article/150798)中做链接练习的那样,暴露出来被其他模块链接。调用约定也有很多种选择,缺省是“ccc”,也就是C语言的调用约定(C Calling Convention),而“swiftcc”则是swift语言的调用约定。**这些信息都是生成汇编时所需要的。**
|
||||
|
||||
示例中函数fun1还带有“#0”的属性值,定义了许多属性。这些也是生成汇编时所需要的。
|
||||
|
||||
- 标识符
|
||||
|
||||
分为全局的(Glocal)和本地的(Local):全局标识符以@开头,包括函数和全局变量,前面代码中的@fun1就是;本地标识符以%开头。
|
||||
|
||||
有的标识符是有名字的,比如@fun1或%a,有的是没有名字的,用数字表示就可以了,如%1。
|
||||
|
||||
- 操作码
|
||||
|
||||
alloca、store、load、add、ret这些,都是操作码。它们的含义是:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b6/3e/b60c17cd8aa27160003884a2e1e4fd3e.jpg" alt="">
|
||||
|
||||
它们跟我们之前学到的汇编很相似。但是似乎函数体中的代码有点儿长。怎么一个简单的“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
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
- 类型系统
|
||||
|
||||
汇编是无类型的。如果你用add指令,它就认为你操作的是整数。而用fadd(或addss)指令,就认为你操作的是浮点数。这样会有类型不安全的风险,把整型当浮点数用了,造成的后果是计算结果完全错误。
|
||||
|
||||
LLVM汇编则带有一个类型系统。它能避免不安全的数据操作,并且有助于优化算法。这个类型系统包括**基础数据类型、函数类型和void类型。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/09/2e/090b2841d969debe803346460764242e.jpg" alt="">
|
||||
|
||||
**函数类型**是包括对返回值和参数的定义,比如:i32 (i32);
|
||||
|
||||
**void类型**不代表任何值,也没有长度。
|
||||
|
||||
- 全局变量和常量
|
||||
|
||||
在LLVM汇编中可以声明全局变量。全局变量所定义的内存,是在编译时就分配好了的,而不是在运行时,例如下面这句定义了一个全局变量C:
|
||||
|
||||
```
|
||||
@c = global i32 100, align 4
|
||||
|
||||
```
|
||||
|
||||
你也可以声明常量,它的值在运行时不会被修改:
|
||||
|
||||
```
|
||||
@c = constant i32 100, align 4
|
||||
|
||||
```
|
||||
|
||||
- 元数据
|
||||
|
||||
在代码中你还看到以“!”开头的一些句子,这些是元数据。这些元数据定义了一些额外的信息,提供给优化器和代码生成器使用。
|
||||
|
||||
- 基本块
|
||||
|
||||
函数中的代码会分成一个个的基本块,可以用标签(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指令的特点:**
|
||||
|
||||
第一行(%10 = load i32, i32* %3, align 4)的含义是:把3号变量(32位整型)从内存加载到寄存器,叫做10号变量,其中,内存对齐是4字节。
|
||||
|
||||
**我在这里延伸一下,**我们在内存里存放数据的时候,有时会从2、4、8个字节的整数倍地址开始存。有些汇编指令要求必须从这样对齐的地址来取数据。另一些指令没做要求,但如果是不对齐的,比如是从0x03地址取数据,就要花费更多的时钟周期。但缺点是,内存对齐会浪费内存空间。
|
||||
|
||||
第一行是整个基本块的唯一入口,从其他基本块跳转过来的时候,只能跳转到这个入口行,不能跳转到基本块中的其他行。
|
||||
|
||||
第二行(%11 = add nsw i32 %10, 3)的含义是:把10号变量(32位整型)加上3,保存到11号变量,其中nsw是加法计算时没有符号环绕(No Signed Wrap)的意思。它的细节你可以查阅“[LLVM语言参考手册](http://llvm.org/docs/LangRef.html)”。
|
||||
|
||||
第三行(store i32 %11, i32* %2, align 4)的含义是:把11号变量(32位整型)存入内存中的2号变量,内存对齐4字节。
|
||||
|
||||
第四行(br label %12)的含义是:跳转到标签为12的代码块。其中,br指令是一条终结指令。终结指令要么是跳转到另一个基本块,要么是从函数中返回(ret指令),基本块的最后一行必须是一条终结指令。
|
||||
|
||||
最后我要强调,从其他基本块不可以跳转到入口基本块,也就是函数中的第一个基本块。这个规定也是有利于做数据优化。
|
||||
|
||||
以上就是对LLVM汇编码的概要介绍(更详细的信息了解可以参见“LLVM语言参考手册”)。
|
||||
|
||||
这样,你实际上就可以用LLVM汇编码来编写程序了,或者将AST翻译成LLVM汇编码。听上去有点让人犯怵,因为LLVM汇编码的细节也相当不少,好在,LLVM提供了一个IR生成的API(应用编程接口),可以让我们更高效、更准确地生成IR。
|
||||
|
||||
## 课程小结
|
||||
|
||||
IR是我们后续做代码优化、汇编代码生成的基础,在本节课中,我想让你明确的要点如下:
|
||||
|
||||
1.三地址代码是很常见的一种IR,包含一个目的地址、一个操作符和至多两个源地址。它等价于四元式。我们在27讲和28讲中的优化算法,会用三地址代码来讲解,这样比较易于阅读。
|
||||
|
||||
2.LLVM IR的第一个特点是静态单赋值(SSA),也就是每个变量(地址)最多被赋值一次,它这种特性有利于运行代码优化算法;第二个特点是带有比较多的细节,方便我们做优化和生成高质量的汇编代码。
|
||||
|
||||
通过本节课,你应该对于编译器后端中常常提到的IR建立了直观的认识,相信通过接下来的练习,你一定会消除对IR的陌生感,让它成为你得心应手的好工具!
|
||||
|
||||
## 一课一思
|
||||
|
||||
我们介绍了IR的特点和几种基本的IR,在你的领域,比如人工智能领域,你了解其他的IR吗?它带来了什么好处?欢迎分享你的经验和观点。
|
||||
|
||||
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的人。
|
||||
|
||||
|
285
极客时间专栏/编译原理之美/实现一门编译型语言 · 原理篇/25 | 后端技术的重用:LLVM不仅仅让你高效.md
Normal file
285
极客时间专栏/编译原理之美/实现一门编译型语言 · 原理篇/25 | 后端技术的重用:LLVM不仅仅让你高效.md
Normal file
@@ -0,0 +1,285 @@
|
||||
<audio id="audio" title="25 | 后端技术的重用:LLVM不仅仅让你高效" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6a/1c/6aa5ff3e6128c29a09685c86a83d701c.mp3"></audio>
|
||||
|
||||
在编译器后端,做代码优化和为每个目标平台生成汇编代码,工作量是很大的。那么,有什么办法能降低这方面的工作量,提高我们的工作效率呢?**答案就是利用现成的工具。**
|
||||
|
||||
在前端部分,我就带你使用Antlr生成了词法分析器和语法分析器。那么在后端部分,我们也可以获得类似的帮助,比如利用LLVM和GCC这两个后端框架。
|
||||
|
||||
相比前端的编译器工具,如Lex(Flex)、Yacc(Bison)和Antlr等,对于后端工具,了解的人比较少,资料也更稀缺,如果你是初学者,那么上手的确有一些难度。不过我们已经用20~24讲,铺垫了必要的基础知识,也尝试了手写汇编代码,这些知识足够你学习和掌握后端工具了。
|
||||
|
||||
本节课,我想先让你了解一些背景信息,所以会先概要地介绍一下LLVM和GCC这两个有代表性的框架的情况,这样,当我再更加详细地讲解LLVM,带你实际使用一下它的时候,你接受起来就会更加容易了。
|
||||
|
||||
## 两个编译器后端框架:LLVM和GCC
|
||||
|
||||
LLVM是一个开源的编译器基础设施项目,主要聚焦于编译器的后端功能(代码生成、代码优化、JIT……)。它最早是美国伊利诺伊大学的一个研究性项目,核心主持人员是Chris Lattner(克里斯·拉特纳)。
|
||||
|
||||
LLVM的出名是由于苹果公司全面采用了这个框架。苹果系统上的C语言、C++、Objective-C的编译器Clang就是基于LLVM的,最新的Swift编程语言也是基于LLVM,支撑了无数的移动应用和桌面应用。无独有偶,在Android平台上最新的开发语言Kotlin,也支持基于LLVM编译成本地代码。
|
||||
|
||||
另外,由Mozilla公司(Firefox就是这个公司的产品)开发的系统级编程语言RUST,也是基于LLVM开发的。还有一门相对小众的科学计算领域的语言,叫做Julia,它既能像脚本语言一样灵活易用,又可以具有C语言一样的速度,在数据计算方面又有特别的优化,它的背后也有LLVM的支撑。
|
||||
|
||||
OpenGL和一些图像处理领域也在用LLVM,我还看到一个资料,**说阿里云的工程师实现了一个Cava脚本语言,用于配合其搜索引擎系统HA3。**
|
||||
|
||||
[LLVM的logo,一只漂亮的龙:](https://en.wikipedia.org/wiki/File:LLVM_Logo.svg)
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d2/ac/d212b52e14007278e8ee417e20e94bac.png" alt="">
|
||||
|
||||
还有,在人工智能领域炙手可热的TensorFlow框架,在后端也是用LLVM来编译。它把机器学习的IR翻译成LLVM的IR,然后再翻译成支持CPU、GPU和TPU的程序。
|
||||
|
||||
所以这样看起来,你所使用的很多语言和工具,背后都有LLVM的影子,只不过你可能没有留意罢了。所以在我看来,要了解编译器的后端技术,就不能不了解LLVM。
|
||||
|
||||
与LLVM起到类似作用的后端编译框架是GCC(GNU Compiler Collection,GNU编译器套件)。它支持了GNU Linux上的很多语言,例如C、C++、Objective-C、Fortran、Go语言和Java语言等。其实,它最初只是一个C语言的编译器,后来把公共的后端功能也提炼了出来,形成了框架,支持多种前端语言和后端平台。最近华为发布的方舟编译器,据说也是建立在GCC基础上的。
|
||||
|
||||
LLVM和GCC很难比较优劣,因为这两个项目都取得了很大的成功。
|
||||
|
||||
在本课程中,我们主要采用LLVM,但其中学到的一些知识,比如IR的设计、代码优化算法、适配不同硬件的策略,在学习GCC或其他编译器后端的时候,也是有用的,从而大大提升学习效率。
|
||||
|
||||
接下来,我们先来看看LLVM的构成和特点,让你对它有个宏观的认识。
|
||||
|
||||
## 了解LLVM的特点
|
||||
|
||||
LLVM能够支持多种语言的前端、多种后端CPU架构。在LLVM内部,使用类型化的和SSA特点的IR进行各种分析、优化和转换:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/07/1c/079aa0c78325b3a4420d78523b5aa51c.png" alt="">
|
||||
|
||||
LLVM项目包含了很多组成部分:
|
||||
|
||||
<li>
|
||||
LLVM核心(core)。就是上图中的优化和分析工具,还包括了为各种CPU生成目标代码的功能;这些库采用的是LLVM IR,一个良好定义的中间语言,在上一讲,我们已经初步了解它了。
|
||||
</li>
|
||||
<li>
|
||||
Clang前端(是基于LLVM的C、C++、Objective-C编译器)。
|
||||
</li>
|
||||
<li>
|
||||
LLDB(一个调试工具)。
|
||||
</li>
|
||||
<li>
|
||||
LLVM版本的C++标准类库。
|
||||
</li>
|
||||
<li>
|
||||
其他一些子项目。
|
||||
</li>
|
||||
|
||||
**我个人很喜欢LLVM,想了想,主要有几点原因: **
|
||||
|
||||
首先,LLVM有良好的模块化设计和接口。以前的编译器后端技术很难复用,而LLVM具备定义了良好接口的库,方便使用者选择在什么时候,复用哪些后端功能。比如,针对代码优化,LLVM提供了很多算法,语言的设计者可以自己选择合适的算法,或者实现自己特殊的算法,具有很好的灵活性。
|
||||
|
||||
第二,LLVM同时支持JIT(即时编译)和AOT(提前编译)两种模式。过去的语言要么是解释型的,要么编译后运行。习惯了使用解释型语言的程序员,很难习惯必须等待一段编译时间才能看到运行效果。很多科学工作者,习惯在一个REPL界面中一边写脚本,一边实时看到反馈。LLVM既可以通过JIT技术支持解释执行,又可以完全编译后才执行,这对于语言的设计者很有吸引力。
|
||||
|
||||
第三,有很多可以学习借鉴的项目。Swift、Rust、Julia这些新生代的语言,实现了很多吸引人的特性,还有很多其他的开源项目,而我们可以研究、借鉴它们是如何充分利用LLVM的。
|
||||
|
||||
第四,全过程优化的设计思想。LLVM在设计上支持全过程的优化。Lattner和Adve最早关于LLVM设计思想的文章[《LLVM: 一个全生命周期分析和转换的编译框架》,](https://llvm.org/pubs/2003-09-30-LifelongOptimizationTR.pdf)就提出计算机语言可以在各个阶段进行优化,包括编译时、链接时、安装时,甚至是运行时。
|
||||
|
||||
以运行时优化为例,基于LLVM我们能够在运行时,收集一些性能相关的数据对代码编译优化,可以是实时优化的、动态修改内存中的机器码;也可以收集这些性能数据,然后做离线的优化,重新生成可执行文件,然后再加载执行,**这一点非常吸引我,**因为在现代计算环境下,每种功能的计算特点都不相同,确实需要针对不同的场景做不同的优化。下图展现了这个过程(图片来源《 LLVM: A Compilation Framework for Lifelong Program Analysis & Transformation》):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/07/6e/071b0421588472cda2033c75124ee96e.png" alt="">
|
||||
|
||||
**我建议你读一读Lattner和Adve的这篇论文**(另外强调一下,当你深入学习编译技术的时候,阅读领域内的论文就是必不可少的一项功课了)。
|
||||
|
||||
第五,LLVM的授权更友好。GNU的很多软件都是采用GPL协议的,所以如果用GCC的后端工具来编写你的语言,你可能必须要按照GPL协议开源。而LLVM则更友好一些,你基于LLVM所做的工作,完全可以是闭源的软件产品。
|
||||
|
||||
而我之所以说:“LLVM不仅仅让你更高效”,就是因为上面它的这些特点。
|
||||
|
||||
现在,你已经对LLVM的构成和特点有一定的了解了,接下来,我带你亲自动手操作和体验一下LLVM的功能,这样你就可以迅速消除对它的陌生感,快速上手了。
|
||||
|
||||
## 体验一下LLVM的功能
|
||||
|
||||
首先你需要安装一下LLVM(参照[官方网站](http://releases.llvm.org/)上的相关介绍下载安装)。因为我使用的是macOS,所以用brew就可以安装。
|
||||
|
||||
```
|
||||
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”
|
||||
|
||||
```
|
||||
|
||||
安装完毕之后,我们使用一下LLVM自带的命令行工具,分几步体验一下LLVM的功能:
|
||||
|
||||
1.从C语言代码生成IR;<br>
|
||||
2.优化IR;<br>
|
||||
3.从文本格式的IR生成二进制的字节码;<br>
|
||||
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代码(如果不带这个参数,就会生成针对目标机器的汇编码)所生成的IR我们上一讲也见过,你现在应该能够读懂它了。你可以多写几个不同的程序,看看生成的IR是什么样的,比如if语句、循环语句等等(这时你完成了第一步):
|
||||
|
||||
```
|
||||
; 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命令来完成上面的优化,具体我们在27、28讲中讲优化算法的时候再细化。
|
||||
|
||||
**另外,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
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/74/b1/7466ca0d3d8beb0c4d570091512da1b1.png" alt="">
|
||||
|
||||
LLVM的一个优点,就是可以即时编译运行字节码,不一定非要编译生成汇编码和可执行文件才能运行(这一点有点儿像Java语言),这也让LLVM具有极高的灵活性,比如,可以在运行时根据收集的性能信息,改变优化策略,生成更高效的机器码。
|
||||
|
||||
再进一步,我们可以把字节码编译成目标平台的汇编代码。我们使用的是llc命令,命令如下:
|
||||
|
||||
```
|
||||
llc fun1.bc -o fun1.s
|
||||
|
||||
```
|
||||
|
||||
用clang命令也能从字节码生成汇编代码,要注意带上-S参数就行了:
|
||||
|
||||
```
|
||||
clang -S fun1.bc -o fun1.s
|
||||
|
||||
```
|
||||
|
||||
**到了这一步,我们已经得到了汇编代码,**接着就可以进一步生成目标文件和可执行文件了。
|
||||
|
||||
实际上,使用LLVM从源代码到生成可执行文件有两条可能的路径:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5a/d4/5ad8793ffba445c8f95d417f4ae9e6d4.jpg" alt="">
|
||||
|
||||
<li>
|
||||
第一条路径,是把每个源文件分别编译成字节码文件,然后再编译成目标文件,最后链接成可执行文件。
|
||||
</li>
|
||||
<li>
|
||||
第二条路径,是先把编译好的字节码文件链接在一起,形成一个更大的字节码文件,然后对这个字节码文件进行进一步的优化,之后再生成目标文件和可执行文件。
|
||||
</li>
|
||||
|
||||
第二条路径比第一条路径多了一个优化的步骤,第一条路径只对每个模块做了优化,没有做整体的优化。所以,如有可能,尽量采用第二条路径,这样能够生成更加优化的代码。
|
||||
|
||||
现在你完成了第四步,对LLVM的命令行工具有了一定的了解。总结一下,我们用到的命令行工具包括:clang前端、llvm-as、llc,其他命令还有opt(代码优化)、llvm-dis(将字节码再反编译回ll文件)、llvm-link(链接)等,你可以看它们的help信息,并练习使用。
|
||||
|
||||
在熟悉了命令行工具之后,我们就可以进一步在编程环境中使用LLVM了,不过在此之前,需要搭建一个开发环境。
|
||||
|
||||
## 建立C++开发环境来使用LLVM
|
||||
|
||||
LLVM本身是用C++开发的,所以最好采用C++调用它的功能。当然,采用其他语言也有办法调用LLVM:
|
||||
|
||||
- C语言可以调用专门的C接口;
|
||||
- 像Go、Rust、Python、Ocaml、甚至Node.js都有对LLVM API的绑定;
|
||||
- 如果使用Java,也可以通过JavaCPP(类似JNI)技术调用LLVM。
|
||||
|
||||
在课程中,我用C++来做实现,因为这样能够最近距离地跟LLVM打交道。与此同时,我们前端工具采用的Antlr,也能够支持C++开发环境。**所以,我为playscript建立了一个C++的开发环境。**
|
||||
|
||||
**开发工具方面:**原则上只要一个编辑器加上工具链就行,但为了提高效率,有IDE的支持会更好(我用的是JetBrains的Clion)。
|
||||
|
||||
**构建工具方面:**目前LLVM本身用的是CMake,而Clion刚好也采用CMake,所以很方便。
|
||||
|
||||
**这里我想针对CMake多解释几句,**因为越来越多的C++项目都是用CMake来管理的,LLVM以及Antlr的C++版本也采用了CMake,**你最好对它有一定了解。**
|
||||
|
||||
CMake是一款优秀的工程构建工具,它类似于Java程序员们习惯使用的Maven工具。对于只包含少量文件或模块的C或C++程序,你可以仅仅通过命令行带上一些参数就能编译。
|
||||
|
||||
不过,实际的项目都会比较复杂,往往会包含比较多的模块,存在比较复杂的依赖关系,编译过程也不是一步能完成的,要分成多步。这时候我们一般用make管理项目的构建过程,这就要学会写make文件。但手工写make文件工作量会比较大,而CMake就是在make的基础上再封装了一层,它能通过更简单的配置文件,帮我们生成make文件,帮助程序员提升效率。
|
||||
|
||||
整个开发环境的搭建我在课程里就不多写了,你可以参见示例代码所附带的文档。文档里有比较清晰的说明,可以帮助你把环境搭建起来,并运行示例程序。
|
||||
|
||||
另外,我知道你可能对C++并不那么熟悉。但你应该学过C语言,所以示例代码还是能看懂的。
|
||||
|
||||
## 课程小结
|
||||
|
||||
本节课,为了帮助你理解后端工具,我先概要介绍了后端工具的情况,接着着重介绍了LLVM的构成和特点,然后又带你熟悉了它的命令行工具,让你能够生成文本和字节码两种格式的IR,并生成可执行文件,最后带你了解了LLVM的开发环境。
|
||||
|
||||
本节课的内容比较好理解,因为侧重让你建立跟LLVM的熟悉感,没有什么复杂的算法和原理,而我想强调的是以下几点:
|
||||
|
||||
1.后端工具对于语言设计者很重要,我们必须学会善加利用;<br>
|
||||
2.LLVM有很好的模块化设计,支持即时编译(JIT)和提前编译(AOT),支持全过程的优化,并且具备友好的授权,值得我们好好掌握;<br>
|
||||
3.你要熟悉LLVM的命令行工具,这样可以上手做很多实验,加深对LLVM的了解。
|
||||
|
||||
最后,我想给你的建议是:一定要动手安装和使用LLVM,写点代码测试它的功能。比如,写点儿C、C++等语言的程序,并翻译成IR,进一步熟悉LLVM的IR。下一讲,我们就要进入它的内部,调用它的API来生成IR和运行了!
|
||||
|
||||
## 一课一思
|
||||
|
||||
很多语言都获得了后端工具的帮助,比如可以把Android应用直接编译成机器码,提升运行效率。你所经常使用的计算机语言采用了什么后端工具?有什么特点?欢迎在留言区分享。
|
||||
|
||||
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你分享给更多的朋友。
|
||||
|
||||
|
508
极客时间专栏/编译原理之美/实现一门编译型语言 · 原理篇/26 | 生成IR:实现静态编译的语言.md
Normal file
508
极客时间专栏/编译原理之美/实现一门编译型语言 · 原理篇/26 | 生成IR:实现静态编译的语言.md
Normal file
@@ -0,0 +1,508 @@
|
||||
<audio id="audio" title="26 | 生成IR:实现静态编译的语言" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c2/ed/c2d12d8e9b24543ca2e3f5b30d90b7ed.mp3"></audio>
|
||||
|
||||
目前来讲,你已经初步了解了LLVM和它的IR,也能够使用它的命令行工具。**不过,我们还是要通过程序生成LLVM的IR,**这样才能复用LLVM的功能,从而实现一门完整的语言。
|
||||
|
||||
不过,如果我们要像前面生成汇编语言那样,通过字符串拼接来生成LLVM的IR,除了要了解LLVM IR的很多细节之外,代码一定比较啰嗦和复杂,因为字符串拼接不是结构化的方法,所以,最好用一个定义良好的数据结构来表示IR。
|
||||
|
||||
好在LLVM项目已经帮我们考虑到了这一点,它提供了代表LLVM IR的一组对象模型,我们只要生成这些对象,就相当于生成了IR,这个难度就低多了。而且,LLVM还提供了一个工具类,IRBuilder,我们可以利用它,进一步提升创建LLVM IR的对象模型的效率,让生成IR的过程变得更加简单!
|
||||
|
||||
接下来,就让我们先来了解LLVM IR的对象模型。
|
||||
|
||||
## LLVM IR的对象模型
|
||||
|
||||
LLVM在内部有用C++实现的对象模型,能够完整表示LLVM IR,当我们把字节码读入内存时,LLVM就会在内存中构建出这个模型。只有基于这个对象模型,我们才可以做进一步的工作,包括代码优化,实现即时编译和运行,以及静态编译生成目标文件。**所以说,这个对象模型是LLVM运行时的核心。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ce/9f/ced8f09e66d4bbd60eb524456d165e9f.jpg" alt="">
|
||||
|
||||
IR对象模型的头文件在[include/llvm/IR](https://github.com/llvm/llvm-project/tree/master/llvm/include/llvm/IR)目录下,其中最重要的类包括:
|
||||
|
||||
- Module(模块)
|
||||
|
||||
Module类聚合了一个模块中的所有数据,它可以包含多个函数。你可以通过Model::iterator来遍历模块中所有的函数。它也包含了一个模块的全局变量。
|
||||
|
||||
- Function(函数)
|
||||
|
||||
Function包含了与函数定义(definition)或声明(declaration)有关的所有对象。函数定义包含了函数体,而函数声明,则仅仅包含了函数的原型,它是在其他模块中定义的,在本模块中使用。
|
||||
|
||||
你可以通过getArgumentList()方法来获得函数参数的列表,也可以遍历函数体中的所有基本块,这些基本块会形成一个CFG(控制流图)。
|
||||
|
||||
```
|
||||
//函数声明,没有函数体。这个函数是在其他模块中定义的,在本模块中使用
|
||||
declare void @foo(i32)
|
||||
|
||||
//函数定义,包含函数体
|
||||
define i32 @fun3(i32 %a) {
|
||||
%calltmp1 = call void @foo(i32 %a) //调用外部函数
|
||||
ret i32 10
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
- BasicBlock(基本块)
|
||||
|
||||
BasicBlock封装了一系列的LLVM指令,你可以借助bigin()/end()模式遍历这些指令,还可以通过getTerminator()方法获得最后一条指令(也就是终结指令)。你还可以用到几个辅助方法在CFG中导航,比如获得某个基本块的前序基本块。
|
||||
|
||||
- Instruction(指令)
|
||||
|
||||
Instruction类代表了LLVM IR的原子操作(也就是一条指令),你可以通过getOpcode()来获得它代表的操作码,它是一个llvm::Instruction枚举值,你可以通过op_begin()和op_end()方法对获得这个指令的操作数。
|
||||
|
||||
- Value(值)
|
||||
|
||||
Value类代表一个值。在LLVM的内存IR中,如果一个类是从Value继承的,意味着它定义了一个值,其他方可以去使用。函数、基本块和指令都继承了Value。
|
||||
|
||||
- LLVMContext(上下文)
|
||||
|
||||
这个类代表了LLVM做编译工作时的一个上下文,包含了编译工作中的一些全局数据,比如各个模块用到的常量和类型。
|
||||
|
||||
这些内容是LLVM IR对象模型的主要部分,我们生成IR的过程,就是跟这些类打交道,其他一些次要的类,你可以在阅读和编写代码的过程中逐渐熟悉起来。
|
||||
|
||||
接下来,就让我们用程序来生成LLVM的IR。
|
||||
|
||||
## 尝试生成LLVM IR
|
||||
|
||||
我刚刚提到的每个LLVM IR类,都可以通过程序来构建。那么,为下面这个fun1()函数生成IR,应该怎么办呢?
|
||||
|
||||
```
|
||||
int fun1(int a, int b){
|
||||
return a+b;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**第一步,**我们可以来生成一个LLVM模块,也就是顶层的IR对象。
|
||||
|
||||
```
|
||||
Module *mod = new Module("fun1.ll", TheModule);
|
||||
|
||||
```
|
||||
|
||||
**第二步,**我们继续在模块中定义函数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()方法获得(我们使用的是Int32类型,你还可以获得其他类型)。
|
||||
|
||||
**第三步,**创建一个基本块。
|
||||
|
||||
这个函数只有一个基本块,你可以把它命名为“entry”,也可以不给它命名。在创建了基本块之后,我们用了一个辅助类IRBuilder,设置了一个插入点,后序生成的指令会插入到这个基本块中(IRBuilder是LLVM为了简化IR生成过程所提供的一个辅助类)。
|
||||
|
||||
```
|
||||
//创建一个基本块
|
||||
BasicBlock *BB = BasicBlock::Create(TheContext,//上下文
|
||||
"", //基本块名称
|
||||
fun); //所在函数
|
||||
Builder.SetInsertPoint(BB); //设置指令的插入点
|
||||
|
||||
```
|
||||
|
||||
**第四步,**生成"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);
|
||||
|
||||
```
|
||||
|
||||
**第五步,**利用刚才获得的addtmp创建一个返回值。
|
||||
|
||||
```
|
||||
//返回值
|
||||
Builder.CreateRet(addtmp);
|
||||
|
||||
```
|
||||
|
||||
**最后一步,**检查这个函数的正确性。这相当于是做语义检查,比如,基本块的最后一个语句就必须是一个正确的返回指令。
|
||||
|
||||
```
|
||||
//验证函数的正确性
|
||||
verifyFunction(*fun);
|
||||
|
||||
```
|
||||
|
||||
完整的代码我也提供给你,放在[codegen_fun1()](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/26-llvmdemo/main.cpp#L49)里了,你可以看一下。我们可以调用这个方法,然后打印输出生成的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
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个例子简单,过程直观,只有一个加法运算,而我建议你在这个过程中注意每个IR对象都是怎样被创建的,在大脑中想象出整个对象结构。
|
||||
|
||||
为了熟悉更多的API,接下来,我再带你生成一个稍微复杂一点儿的,带有if语句的IR。然后来看一看,函数中包含多个基本块的情况。
|
||||
|
||||
## 支持if语句
|
||||
|
||||
具体说,我们要为下面的一个函数生成IR(函数有一个参数a,当a大于2的时候,返回2;否则返回3)。
|
||||
|
||||
```
|
||||
int fun_ifstmt(int a)
|
||||
if (a > 2)
|
||||
return 2;
|
||||
else
|
||||
return 3;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样的一个函数,需要包含4个基本块:**入口基本块、Then基本块、Else基本块和Merge基本块。**控制流图(CFG)是先分开,再合并,像下面这样:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ce/2a/ce96ecd42b4b4e095d4671e1b658582a.jpg" alt="">
|
||||
|
||||
**在入口基本块中,**我们要计算“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指令,**所以一旦你会写if语句,肯定就会写循环语句的。**
|
||||
|
||||
## 支持本地变量
|
||||
|
||||
在写程序的时候,本地变量是必不可少的一个元素,所以,我们趁热打铁,把刚才的示例程序变化一下,用本地变量b保存ThenBB和ElseBB中计算的值,借此学习一下LLVM IR是如何支持本地变量的。
|
||||
|
||||
改变后的示例程序如下:
|
||||
|
||||
```
|
||||
int fun_localvar(int a)
|
||||
int b = 0;
|
||||
if (a > 2)
|
||||
b = 2;
|
||||
else
|
||||
b = 3;
|
||||
return b;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
其中,函数有一个参数a,一个本地变量b:如果a大于2,那么给b赋值2;否则,给b赋值3。最后的返回值是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
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当然,使用内存保存临时变量的性能比较低,但我们可以很容易通过优化算法,把上述代码从使用内存的版本,优化成使用寄存器的版本。
|
||||
|
||||
通过上面几个示例,现在你已经学会了生成基本的IR,包括能够支持本地变量、加法运算、if语句。那么这样生成的IR能否正常工作呢?我们需要把这些IR编译和运行一下才知道。
|
||||
|
||||
## 编译并运行程序
|
||||
|
||||
现在已经能够在内存中建立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引擎有关的代码,放到了DemoJIT.h中,你现在可以暂时不关心它的细节,留到以后再去了解)。使用这个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函数在另一个目标文件中,它也可以找到。
|
||||
|
||||
刚才讲的是即时编译和运行,你也可以生成目标文件,然后再去链接和执行。生成目标文件的代码参见[emitObject()](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/26-llvmdemo/main.cpp#L298)方法,基本上就是打开一个文件,然后写入生成的二进制目标代码。针对目标机器生成目标代码的大量工作,就用这么简单的几行代码就实现了,是不是帮了你的大忙了?
|
||||
|
||||
## 课程小结
|
||||
|
||||
本节课,我们我们完成了从生成IR到编译执行的完整过程,同时,也初步熟悉了LLVM的接口。当然了,完全熟悉LLVM的接口还需要多做练习,掌握更多的细节。就本节课而言,我希望你掌握的重点如下:
|
||||
|
||||
<li>
|
||||
LLVM用一套对象模型在内存中表示IR,包括模块、函数、基本块和指令,你可以通过API来生成这些对象。这些对象一旦生成,就可以编译和执行。
|
||||
</li>
|
||||
<li>
|
||||
对于if语句和循环语句,需要生成多个基本块,并通过跳转指令形成正确的控制流图(CFG)。当存在多个前序节点可能改变某个变量的值的时候,使用phi指令来确定正确的值。
|
||||
</li>
|
||||
<li>
|
||||
存储在内存中的本地变量,可以多次赋值。
|
||||
</li>
|
||||
<li>
|
||||
LLVM能够把外部函数和IR模型中的函数等价对待。
|
||||
</li>
|
||||
|
||||
另外,为了降低学习难度,本节课,我没有做从AST翻译成IR的工作,而是针对一个目标功能(比如一个C语言的函数),硬编码调用API来生成IR。你理解各种功能是如何生成IR以后,再从AST来翻译,就更加容易了。
|
||||
|
||||
## 一课一思
|
||||
|
||||
既然我带你演示了if语句如何生成IR,那么你能思考一下,对于for循环和while循环语句,它对应的CFG应该是什么样的?应该如何生成IR?欢迎你在留言区分享你的看法。
|
||||
|
||||
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
|
||||
|
||||
|
364
极客时间专栏/编译原理之美/实现一门编译型语言 · 原理篇/27 | 代码优化:为什么你的代码比他的更高效?.md
Normal file
364
极客时间专栏/编译原理之美/实现一门编译型语言 · 原理篇/27 | 代码优化:为什么你的代码比他的更高效?.md
Normal file
@@ -0,0 +1,364 @@
|
||||
<audio id="audio" title="27 | 代码优化:为什么你的代码比他的更高效?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9d/6d/9d54233386f3f9347abd3252782ff86d.mp3"></audio>
|
||||
|
||||
在使用LLVM的过程中,你应该觉察到了,优化之后和优化之前的代码相差很大。代码优化之后,数量变少了,性能也更高了。而针对这个看起来很神秘的代码优化,我想问你一些问题:
|
||||
|
||||
- 代码优化的目标是什么?除了性能上的优化,还有什么优化?
|
||||
- 代码优化可以在多大的范围内执行?是在一个函数内,还是可以针对整个应用程序?
|
||||
- 常见的代码优化场景有哪些?
|
||||
|
||||
这些问题是代码优化的基本问题,很重要,我会用两节课的时间带你了解和掌握。
|
||||
|
||||
当然了,代码优化是编译器后端的两大工作之一(另一个是代码生成),弄懂它,你就掌握了一大块后端技术。而学习代码优化的原理,然后通过LLVM实践一下,这样原理与实践相结合,会帮你早日弄懂代码优化。
|
||||
|
||||
接下来,我带你概要地了解一下代码优化的目标、对象、范围和策略等内容。
|
||||
|
||||
## 了解代码优化的目标、对象、范围和策略
|
||||
|
||||
- 代码优化的目标
|
||||
|
||||
代码优化的目标,是优化程序对计算机资源的使用。我们平常最关心的就是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。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/32/9e/327a0631236e89016d9bf56feed3309e.jpg" alt="">
|
||||
|
||||
如果通过分析CFG,我们发现t在其他地方没有被使用,就可以把第二行删掉。这种针对一个函数、基于CFG的优化,叫做**全局优化(Global Optimization)。**
|
||||
|
||||
比全局优化更大范围的优化,叫做**过程间优化(Inter-procedural Optimization),**它能跨越函数的边界,对多个函数之间的关系进行优化,而不是仅针对一个函数做优化。
|
||||
|
||||
- 代码优化的策略
|
||||
|
||||
最后,你不需要每次都把代码优化做彻底,因为做代码优化本身也需要消耗计算机的资源。所以,你需要权衡代码优化带来的好处和优化本身的开支这两个方面,然后确定做多少优化。比如,在浏览器里加载JavaScript的时候,JavaScript引擎一定会对JavaScript做优化,但如果优化消耗的时间太长,界面的响应会变慢,反倒影响用户使用页面的体验,所以JavaScript引擎做优化时要掌握合适的度或调整优化时机。
|
||||
|
||||
接下来,我带你认识一些常见的代码优化的场景,这样可以让你对代码优化的认识更加直观,然后我们也可以将这部分知识作为后面讨论算法的基础。
|
||||
|
||||
## 一些优化的场景
|
||||
|
||||
- 代数优化(Algebraic Optimazation)
|
||||
|
||||
代数优化是最简单的一种优化,当操作符是代数运算的时候,你可以根据学过的数学知识进行优化。
|
||||
|
||||
比如“x:=x+0 ”这行代码,操作前后x没有任何变化,所以这样的代码可以删掉;又比如“x:=x*0” 可以简化成“x:=0”;对某些机器来说,移位运算的速度比乘法的快,那么“x:=x*8”可以优化成“x:=x<<3”。
|
||||
|
||||
- 常数折叠(Constant Folding)
|
||||
|
||||
它是指,对常数的运算可以在编译时计算,比如 “x:= 20 * 3 ”可以优化成“x:=60”。另外,在if条件中,如果条件是一个常量,那就可以确定地取某个分支。比如:“If 2>0 Goto BB2” 可以简化成“Goto BB2”就好了。
|
||||
|
||||
- 删除不可达的基本块
|
||||
|
||||
有些代码永远不可能被激活。比如在条件编译的场景中,我们会写这样的程序:“if(DEBUG) {...}”。如果编译时,DEBUG是一个常量false,那这个代码块就没必要编译了。
|
||||
|
||||
- 删除公共子表达式(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
|
||||
|
||||
```
|
||||
|
||||
- 拷贝传播(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了。
|
||||
|
||||
- 死代码删除(Ded code elimination)
|
||||
|
||||
在上面的拷贝传播中,如果没有其他地方使用y变量了,那么第二行就是死代码,就可以删除掉,这种优化叫做死代码删除。
|
||||
|
||||
**最后我强调一下,**一个优化可能导致另一个优化,比如,拷贝传播导致y不再被使用,我们又可以进行死代码删除的优化。所以,一般进行多次优化、多次扫描。
|
||||
|
||||
了解了优化的场景之后,你能直观地知道代码优化到底做了什么事情,不过知其然还要知其所以然,你还需要了解这些优化都是怎么实现的。
|
||||
|
||||
## 如何做本地优化
|
||||
|
||||
上面这些优化场景,可以用于本地优化、全局优化和过程间优化。这节课我们先看看如何做本地优化,因为它相对简单,学习难度较低,下节课再接着讨论全局优化。
|
||||
|
||||
假设下面的代码是一个基本块(省略最后的终结指令):
|
||||
|
||||
```
|
||||
a := b
|
||||
c := a + b
|
||||
c := b
|
||||
d := a + b
|
||||
e := a + b
|
||||
|
||||
```
|
||||
|
||||
为了优化它们,我们的方法是计算一个“可用表达式(available expression)”的集合。可用表达式,是指存在一个变量,保存着某个表达式的值。
|
||||
|
||||
**我们从上到下顺序计算这个集合:**
|
||||
|
||||
1.一开始是空集。<br>
|
||||
2.经过第一行代码后,集合里增加了“a:=b”;<br>
|
||||
3.经过第二行代码后,增加了“c:=a+b”。<br>
|
||||
**4.注意,**在经过第三行代码以后,由于变量c的定义变了,所以“c:=a+b”不再可用,而是换成了“c:=b”。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ee/70/eeeff152fea3ede1b9bae3892bdc4070.jpg" alt="">
|
||||
|
||||
你能看到,代码“e:=a+b”,和集合中的“d:=a+b”等号右边部分是相同的,所以我们首先可以**删除公共子表达式,**优化成“e:=d”。变成下面这样:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2f/94/2f3d1f14385efd1e6d336e962ddf5494.jpg" alt="">
|
||||
|
||||
然后,我们可以做一下**拷贝传播,**利用“a:=b”,把表达式中的多个a都替换成b。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2b/08/2b3e1177ce5d7f3e5f003df7c8980508.jpg" alt="">
|
||||
|
||||
到目前为止,a都被替换成了b,对e的计算也简化了,优化后的代码变成了下面这样:
|
||||
|
||||
```
|
||||
a := b
|
||||
c := b + b
|
||||
c := b
|
||||
d := b + b
|
||||
e := d
|
||||
|
||||
```
|
||||
|
||||
观察一下这段代码,它似乎还存在可优化的空间,比如,会存在死代码,而我们可以将其删除。
|
||||
|
||||
假设,在后序的基本块中,b和c仍然会被使用,但其他变量就不会再被用到了。那么,上面这5行代码哪行能被删除呢?这时,我们要做另一个分析:活跃性分析(Liveness Analysis)。
|
||||
|
||||
我们说一个变量是活的,意思是它的值在改变前,会被其他代码读取。(对于SSA格式的IR,变量定义出来之后就不会再改变,所以你只要看后面的代码有没有使用这个变量的就可以了)我们会分析每个变量的活跃性,把死的变量删掉。
|
||||
|
||||
**怎么做呢?**我们这次还是要借助一个集合,不过这个集合是从后向前,倒序计算的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1d/84/1d37597496a58e0e59e9748f13b6e884.jpg" alt="">
|
||||
|
||||
一开始集合里的元素是{b, c},这是初始值,表示b和c会被后面的代码使用,所以它们是活的。
|
||||
|
||||
- 扫描过“e := d”后,因为用到了d,所以d是活的,结果是{b, c, d}。
|
||||
- 再扫描“d := b + b”,用到了b,但集合里已经有b了;这里给d赋值了,已经满足了后面代码对d的要求,所以可以从集合里去掉d了,结果是{b,c}。
|
||||
- 再扫描“c := b”,从集合里去掉c,结果是{b}。
|
||||
- 继续扫描,一直到第一行,最后的集合仍然是{b}。
|
||||
|
||||
现在,基于这个集合,我们就可以做死代码删除了。**当给一个变量赋值时,它后面跟着的集合没有这个变量,说明它不被需要,就可以删掉了。**图中标橙色的三行,都是死代码,都可以删掉。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d9/42/d9161dc7dc88123948dace3e2d199042.jpg" alt="">
|
||||
|
||||
删掉以后,只剩下了两行代码。**注意,**由于“ e := d”被删掉了,导致d也不再被需要,变成了死变量。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ca/65/caf9537c22f8c8d969746f1061ddbc65.jpg" alt="">
|
||||
|
||||
把变量d删掉以后,就剩下了一行代码“c := b”了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/89/c2/899dbdf21a4aa1661ef4cb46de1d3cc2.jpg" alt="">
|
||||
|
||||
到此为止,我们完成了整个的优化过程,5行代码优化成了1行代码,成果是很显著的!
|
||||
|
||||
**我来带你总结一下这个优化过程:**
|
||||
|
||||
<li>
|
||||
我们首先做一个正向扫描,进行可用表达式分析,建立可用表达式的集合,然后参照这个集合替换公共子表达式,以及做拷贝传播。
|
||||
</li>
|
||||
<li>
|
||||
接着,我们做一个反向扫描,进行活跃性分析,建立活变量的集合,识别出死变量,并依据它删除给死变量赋值的代码。
|
||||
</li>
|
||||
<li>
|
||||
上述优化可能需要做不止一遍,才能得到最后的结果。
|
||||
</li>
|
||||
|
||||
这样看来,优化并不难吧?当然了,目前我们做的优化是基于一段顺序执行的代码,没有跳转,都是属于一个基本块的,属于本地优化。
|
||||
|
||||
直观地理解了本地优化之后,我们可以把这种理解用**更加形式化的方式表达出来,**这样,你可以理解得更加透彻。本地优化中,可用表达式分析和活跃性分析,都可以看做是由下面4个元素构成的:
|
||||
|
||||
<li>
|
||||
D(方向)。是朝前还是朝后遍历。
|
||||
</li>
|
||||
<li>
|
||||
V(值)。代码的每一个地方都要计算出一个值。可用表达式分析和活跃性分析的值是一个集合,也有些分析的值并不是集合,在下一讲你会看到这样的例子。
|
||||
</li>
|
||||
<li>
|
||||
F(转换函数,对V进行转换)。比如,在做可用表达式分析的时候,遇到了“c := b”时,可用表达式的集合从{a := b, c := a + b}转换成了{a := b, c := b}。**这里遵守的转换规则是:**因为变量c被重新赋值了,那么就从集合里,把变量c原来的定义去掉,并把带有c的表达式都去掉,因为过去的c已经失效了,然后,把变量c新的定义加进去。
|
||||
</li>
|
||||
<li>
|
||||
I(初始值,是算法开始时V的取值)。做可用表达式分析的时候,初始值是空集。在做活跃性分析的时候,初始值是后面代码中还会访问的变量,也就是活变量。
|
||||
</li>
|
||||
|
||||
这样形式化以后,我们就可以按照这个模型来统一理解各种本地优化算法。接下来,我们来体验和熟悉一下LLVM的优化功能。
|
||||
|
||||
## 用LLVM来演示优化功能
|
||||
|
||||
在[25讲](https://time.geekbang.org/column/article/153192)中,我们曾经用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文件中的“optnone”这个属性去掉,因为这个它的意思是不要代码优化。
|
||||
|
||||
我们还可以简化上述操作,给opt命令带上-S参数,直接对.ll文件进行优化:
|
||||
|
||||
```
|
||||
opt -S -O2 fun1.ll -o fun1-O2.ll
|
||||
|
||||
```
|
||||
|
||||
**另外,我解释一下-O2参数:**-O2代表的是二级优化,LLVM中定义了多个优化级别,基本上数字越大,所做的优化就越多。
|
||||
|
||||
我们可以不使用笼统的优化级别,而是指定采用某个特别的优化算法,比如mem2reg算法,会把对内存的访问优化成尽量访问寄存器。
|
||||
|
||||
```
|
||||
opt -S -mem2reg fun1.ll -o fun1-O2.ll
|
||||
|
||||
```
|
||||
|
||||
用opt --help命令,可以查看opt命令所支持的所有优化算法。
|
||||
|
||||
对于常数折叠,在调用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最强大的特性,即静态单赋值中的定义-使用链,这种定义-使用关系会被用到优化算法中。
|
||||
|
||||
在[26讲](https://time.geekbang.org/column/article/154438)中,我们已经讲过了Value类。
|
||||
|
||||
如果一个类是从Value继承的,意味着它定义了一个值。另一个类是User类,函数和指令也是User类的子类,也就是说,在函数和指令中,可以使用别的地方定义的值。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/43/40/43261470d69b33bb36930dfa698c4b40.jpg" alt="">
|
||||
|
||||
**这两个类是怎么帮助到优化算法中的呢?**
|
||||
|
||||
在User中,可以访问所有它用到的Value,比如一个加法指令(%c = add nsw i32 %a, %b)用到了a和b这两个变量。
|
||||
|
||||
而在Value中,可以访问所有使用这个值的User,比如给c赋值的这条指令。
|
||||
|
||||
所以,你可以遍历一个Value的所有User,把它替换成另一个Value,这就是拷贝传播。
|
||||
|
||||
**接下来,我们看看如何用程序实现IR的优化。**
|
||||
|
||||
在LLVM内部,优化工作是通过一个个的Pass(遍)来实现的,它支持三种类型的Pass:
|
||||
|
||||
<li>
|
||||
一种是分析型的Pass(Analysis Passes),只是做分析,产生一些分析结果用于后序操作。
|
||||
</li>
|
||||
<li>
|
||||
一些是做代码转换的(Transform Passes),比如做公共子表达式删除。
|
||||
</li>
|
||||
<li>
|
||||
还有一类pass是工具型的,比如对模块做正确性验证。你可以查阅LLVM所支持的[各种Pass。](https://llvm.org/docs/Passes.html)
|
||||
</li>
|
||||
|
||||
下面的代码创建了一个PassManager,并添加了两个优化Pass:
|
||||
|
||||
```
|
||||
// 创建一个PassManager
|
||||
TheFPM = std::make_unique<legacy::FunctionPassManager>(TheModule.get());
|
||||
|
||||
// 窥孔优化和一些位计算优化
|
||||
TheFPM->add(createInstructionCombiningPass());
|
||||
|
||||
// 表达式重关联
|
||||
TheFPM->add(createReassociatePass());
|
||||
|
||||
TheFPM->doInitialization();
|
||||
|
||||
```
|
||||
|
||||
之后,再简单地调用PassManager的run()方法,就可以对代码进行优化:
|
||||
|
||||
```
|
||||
TheFPM->run(*fun);
|
||||
|
||||
```
|
||||
|
||||
你可以查看本讲附带的代码,尝试自己编写一些示例程序,查看优化前和优化后的效果。
|
||||
|
||||
## 课程小结
|
||||
|
||||
本节课,我带你学习了代码优化的原理,然后通过LLVM实践了一下,演示了优化功能,我希望你能记住几个关键点:
|
||||
|
||||
1.代码优化分为本地优化、全局优化和过程间优化三个范围。有些优化对于这三个范围都是适用的,但也有一些优化算法是全局优化和过程间优化专有的。
|
||||
|
||||
2.可用表达式分析和活跃性分析是本地优化时的两个关键算法。这些算法都是由扫描方向、值、转换函数和初始值这四个要素构成的。
|
||||
|
||||
3.LLVM用pass来做优化,你可以通过命令行或程序来使用这些Pass。你也可以编写自己的Pass。
|
||||
|
||||
最后,我建议你多编写一些测试代码,并用opt命令去查看它的优化效果,在这个过程中增加对代码优化的感性认识。
|
||||
|
||||
## 一课一思
|
||||
|
||||
针对不同的领域(商业、科学计算、游戏等),代码优化的重点可能是不同的。针对你所熟悉的计算机语言和领域,你知道有哪些优化的需求?是采用什么技术实现的?欢迎在留言区分享你的观点。
|
||||
|
||||
最后,感谢你的阅读,如果这篇文章有所收获,也欢迎你将它分享给更多的朋友。
|
188
极客时间专栏/编译原理之美/实现一门编译型语言 · 原理篇/28 | 数据流分析:你写的程序,它更懂.md
Normal file
188
极客时间专栏/编译原理之美/实现一门编译型语言 · 原理篇/28 | 数据流分析:你写的程序,它更懂.md
Normal file
@@ -0,0 +1,188 @@
|
||||
<audio id="audio" title="28 | 数据流分析:你写的程序,它更懂" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a2/8d/a24a8b5371075824b3eec964ee244c8d.mp3"></audio>
|
||||
|
||||
上一讲,我提到了删除公共子表达式、拷贝传播等本地优化能做的工作,其实,这几个工作也可以在全局优化中进行。
|
||||
|
||||
只不过,全局优化中的算法,不会像在本地优化中一样,只针对一个基本块。而是更复杂一些,因为要覆盖多个基本块。这些基本块构成了一个CFG,代码在运行时有多种可能的执行路径,这会造成多路径下,值的计算问题,比如活跃变量集合的计算。
|
||||
|
||||
当然了,还有些优化只能在全局优化中做,在本地优化中做不了,比如:
|
||||
|
||||
- 代码移动(code motion)能够将代码从一个基本块挪到另一个基本块,比如从循环内部挪到循环外部,来减少不必要的计算。
|
||||
- 部分冗余删除(Partial Redundancy Elimination),它能把一个基本块都删掉。
|
||||
|
||||
总之,全局优化比本地优化能做的工作更多,分析算法也更复杂,因为CFG中可能存在多条执行路径。不过,我们可以在上一节课提到的本地优化的算法思路上,解决掉多路径情况下,V值的计算问题。**而这种基于CFG做优化分析的方法框架,就叫做数据流分析。**
|
||||
|
||||
本节课,我会把全局优化的算法思路讲解清楚,借此引入数据流分析的完整框架。而且在解决多路径情况下,V值的计算问题时,我还会带你学习一个数学工具:半格理论。这样,你会对基于数据流分析的代码优化思路建立清晰的认识,从而有能力根据需要编写自己的优化算法。
|
||||
|
||||
## 数据流分析的场景:活跃性分析
|
||||
|
||||
[上一讲,](https://time.geekbang.org/column/article/155338)我已经讲了本地优化时的活跃性分析,那时,情况比较简单,你不需要考虑多路径问题。**而在做全局优化时,情况就要复杂一些:**代码不是在一个基本块里简单地顺序执行,而可能经过控制流图(CFG)中的多条路径。我们来看一个例子(例子由if语句形成了两条分支语句):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/16/e2/16486275b06058985190f1a5ae51a6e2.jpg" alt="">
|
||||
|
||||
基于这个CFG,我们可以做全局的活跃性分析,从最底下的基本块开始,倒着向前计算活跃变量的集合(也就是从基本块5倒着向基本块1计算)。
|
||||
|
||||
**这里需要注意,**对基本块1进行计算的时候,它的输入是基本块2的输出,也就是{a, b, c},和基本块3的输出,也就是{a, c},计算结果是这两个集合的并集{a, b, c}。也就是说,基本块1的后序基本块,有可能用到这三个变量。这里就是与本地优化不同的地方,我们要基于多条路径来计算。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c4/71/c453c9f74802eee6d98bdd813b66a271.jpg" alt="">
|
||||
|
||||
基于这个分析图,我们马上发现y变量可以被删掉(因为它前面的活变量集合{x}不包括y,也就是不被后面的代码所使用),并且影响到了活跃变量的集合。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/19/57/191329a421402539bff0babf41b9de57.jpg" alt="">
|
||||
|
||||
删掉y变量以后,再继续优化一轮,会发现d也可以删掉。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c3/7c/c339a2653ab9ce296ada6a3f49c25a7c.jpg" alt="">
|
||||
|
||||
d删掉以后,2号基本块里面已经没有代码了,也可以被删掉,**最后的CFG是下面这样:**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1d/31/1da42a4e00475f274281ecb1a702be31.jpg" alt="">
|
||||
|
||||
到目前为止,我们发现:全局优化总体来说跟本地优化很相似,唯一的不同,就是要基于多个分支计算集合的内容(也就是V值)。在进入基本块1时,2和3两个分支相遇(meet),我们取了2和3V值的并集。**这就是数据流分析的基本特征,你可以记住这个例子,建立直观印象。**
|
||||
|
||||
但是,上面这个CFG还是比较简单的,因为它没有循环,属于有向无环图。**这种图的特点是:**针对图中的每一个节点,我们总能找到它的前序节点和后序节点,所以我们只需要按照顺序计算就好了。但是如果加上了环路,就不那么简单了,来看一看下面这张图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a2/6e/a2aa2818e6890db5dc4ca2ee02bad36e.jpg" alt="">
|
||||
|
||||
基本块4有两个后序节点,分别是5和1,所以要计算4的活跃变量,就需要知道5和1的输出是什么。5的输出好说,但1的呢?还没计算出来呢。因为要计算1,就要依赖2和3,从而间接地又依赖了4。**这样一来,1和4是循环依赖的。**再进一步探究的话,你发现其实1、2、3、4四个节点之间,都是循环依赖的。
|
||||
|
||||
所以说,一旦在CFG中引入循环回路,严格的前后计算顺序就不存在了。**那你要怎么办呢?**
|
||||
|
||||
其实,我们不是第一次面对这个处境了。在前端部分,我们计算First和Follow集合的时候,就会遇到循环依赖的情况,只不过那时候没有像这样展开,细细地分析。不过,你可以回顾一下[17讲](https://time.geekbang.org/column/article/138385)和[18讲](https://time.geekbang.org/column/article/139628),那个时候你是用什么算法来破解僵局的呢?是不动点法。**在这里,我们还是要运用不动点法,具体操作是:**给每个基本块的V值都分配初始值,也就是空集合。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ee/ef/eead71f9e3ae1486465e8d6adcfc96ef.jpg" alt="">
|
||||
|
||||
然后对所有节点进行多次计算,直到所有集合都稳定为止。第一遍的时候,我们按照5-4-3-2-1的顺序计算(实际上,采取任何顺序都可以),计算结果如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9e/63/9e4acf5bd72492306c230b11d6f6fd63.jpg" alt="">
|
||||
|
||||
如果现在计算就结束,我们实际上可以把基本块2中的d变量删掉。但如果我们再按照5-4-3-2-1的顺序计算一遍,就会往集合里增加一些新的元素(在图中标的是橙色)。**这是因为,**在计算基本块4的时候,基本块1的输出{b, c, d}也会变成4的输入。这时,我们发现,进入基本块2时,活变量集合里是含有d的,所以d是不能删除的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/54/cc/547bb7c93a63468b854a2b0d7188b7cc.jpg" alt="">
|
||||
|
||||
你再仔细看看,这个d是哪里需要的呢?**是基本块3需要的:**它会跟1去要,1会跟4要,4跟2要。所以,再次证明,1、2、3、4四个节点是互相依赖的。
|
||||
|
||||
我们再来看一下,对于活变量集合的计算,当两个分支相遇的情况下,最终的结果我们取了两个分支的并集。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/28/fc/28c7218ee10c14ce2b121aa527191bfc.jpg" alt="">
|
||||
|
||||
在上一讲,我们说一个本地优化分析包含四个元素:方向(D)、值(V)、转换函数(F)和初始值(I)。在做全局优化的时候,我们需要再多加一个元素,就是两个分支相遇的时候,要做一个运算,计算他们相交的值,这个运算我们可以用大写的希腊字母Λ(lambda)表示。包含了D、V、F、I和Λ的分析框架,**就叫做数据流分析。**
|
||||
|
||||
那么Λ怎么计算呢?研究者们用了一个数学工具,叫做“半格”(Semilattice),帮助做Λ运算。
|
||||
|
||||
## 直观地理解半格理论
|
||||
|
||||
如果要从数学理论角度完全把“半格”这个概念说清楚,需要依次介绍清楚“格”(Lattice)、“半格”(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三个变量,那么这个半格画成图形是这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d9/85/d9811d73fef1347e92fc3151fdd48485.jpg" alt="">
|
||||
|
||||
沿着上面图中的线,两个值是可以比较大小的,按箭头的方向依次减少:{}>{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} 。
|
||||
|
||||
<li>
|
||||
如果一个偏序集中,任意两个元素都有最大下界,那么这个偏序集就叫做**交半格(Meet Semilattice)。 **
|
||||
</li>
|
||||
<li>
|
||||
与此相对应的,如果集合中的每个元素都有**最小上界(Least Upper Bound),<strong>那么这个偏序集叫做**并半格(Join Semilattice)。</strong>
|
||||
</li>
|
||||
<li>
|
||||
如果一个偏序集既是交半格,又是并半格,我们说这个偏序集是一个格,示例的这个偏序集就是一个格。
|
||||
</li>
|
||||
|
||||
你可能会奇怪,为什么要引入这么复杂的一套数学工具呢?不就是集合运算吗?两个分支相遇,就计算它们的并集,不就可以了吗?**事情没那么简单。**因为并不是所有的分析,其V值都是一个集合,就算是集合,相交时的运算也不一定是求并集,而有可能是求交集。
|
||||
|
||||
我们通过另一个案例来分析一下非集合的半格运算:**常数传播。**
|
||||
|
||||
## 数据流分析的场景:常数传播
|
||||
|
||||
常数传播,就是如果知道某个变量的值是个常数,那么就把用到这个变量的表达式,都用常数去替换。看看下面的例子,在基本块4中,a的值能否用一个常数替代?
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ec/0e/ecf6d32b7428d960654400ddd34be90e.jpg" alt="">
|
||||
|
||||
**答案是不能。**到达基本块4的两条路径,一条a=3,另一条a=4。我们不知道在实际运行的时候,会从哪条路径过来,所以这个时候a的取值是不确定的,基本块4中的a无法用常数替换。
|
||||
|
||||
那么,运用数据流分析的框架怎么来做常数传播分析呢?
|
||||
|
||||
在这种情况下,V不再是一个集合,而是a可能取的常数值,但a有可能不是一个常数啊,所以我们再定义一个特殊的值:Top(T)。
|
||||
|
||||
除了T之外,我们再引入一个与T对应的特殊值:Bottom(它的含义是,某个语句永远不会被执行)。总结起来,常数传播时,V的取值可能是3个:
|
||||
|
||||
- 常数c
|
||||
- Top:意思是a的值不是一个常数
|
||||
- Bottom:某个语句不会被执行。
|
||||
|
||||
**这些值是怎么排序的呢?**最大的是Top,中间各个常数之间是无法比较的,Bottom是最小的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3e/ae/3e7cf0f8d1052d125ada693afee96aae.jpg" alt="">
|
||||
|
||||
接下来,我们看看如何计算多个V值相交的值。
|
||||
|
||||
我们再把计算过程形式化一下。在这个分析中,当我们经过每个语句的时候,V值都可能发生变化,我们用下面两个函数来代表不同地方的V值:
|
||||
|
||||
- C(a, s, in)。表示在语句s之前a的取值,比如,C(a, b:=a+2, in) = 3。
|
||||
- C(a, s, out)。表示在语句s之后a的取值,比如,C(a, a:=4, out) = 4。
|
||||
|
||||
如果s的前序有i条可能的路径,那么多个输出和一个输入“C(a, si, out)和C(a, s, in)”的关系,可以制定一系列规则:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cf/8e/cf28a8e40983204c6d0381197b471e8e.jpg" alt="">
|
||||
|
||||
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值会有啥变化:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/03/a2/0344859185c57f3cd6f7bbb83f364fa2.jpg" alt="">
|
||||
|
||||
1.如果输入是Bottom,那么输出也是Bottom。也就是这条路径不会经过。<br>
|
||||
2.如果该Statement就是“ a := 常数”,那么输出就是该常数。<br>
|
||||
3.如果该Statement是a赋予的一个比较复杂的表达式,而不是常数,那么输出就是Top。<br>
|
||||
4.如果该Statement不是对a赋值的,那么V值保持不变。
|
||||
|
||||
好了,转换函数F也搞清楚了。初始值I是什么呢?是Top,因为一开始的时候,a还没有赋值,所以不会是常数;方向D是什么呢?D是向下。**这个时候,D、V、F、I和Λ5个元素都清楚了,我们就可以写算法实现了。**
|
||||
|
||||
## 课程小结
|
||||
|
||||
本节课,我们基于全局优化分析的任务,介绍了数据流分析这个框架,并且介绍了半格这个数学工具。**我希望你在本讲记住几个要点:**
|
||||
|
||||
<li>
|
||||
全局分析比本地分析多处理的部分就是CFG,因为有了多条执行分支,所以要计算分支相遇时的值,当CFG存在环路的时候,要用不动点法来计算出所有的V值。
|
||||
</li>
|
||||
<li>
|
||||
数据流分析框架包含方向(D)、值(V)、转换函数(F)、初始值(I)和交运算(Λ)5个元素,只要分析清楚这5个元素,就可以按照固定的套路来编写分析程序。
|
||||
</li>
|
||||
<li>
|
||||
对于半格理论,关键是要知道如何比较偏序集中元素的大小,理解了这个核心概念,那么求最大下界、最小上界这些也就没有问题了。
|
||||
</li>
|
||||
|
||||
**数据流分析也是一个容易让学习者撞墙的知识点,**特别是再加上“半格”这样的数学术语的时候。不过,我们通过全局活跃性分析和全局常数传播的示例,对“半格”的抽象数学概念建立了直觉的理解。遇到全局分析的任务,你也应该能够比照这两个示例,设计出完整的数据流分析的算法了。**不过我建议你,**还是要按照上一讲中对LLVM优化功能的介绍,多做几个例子实验一下。
|
||||
|
||||
## 一课一思
|
||||
|
||||
如果我们想做一个全局分析,用于删除公共子表达式,它的数据流分析框架应该是怎样的?也就是D、V、F、I和Λ各自应该如何设计呢?欢迎分享你的想法。
|
||||
|
||||
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
|
||||
|
||||
|
240
极客时间专栏/编译原理之美/实现一门编译型语言 · 原理篇/29 | 目标代码的生成和优化(一):如何适应各种硬件架构?.md
Normal file
240
极客时间专栏/编译原理之美/实现一门编译型语言 · 原理篇/29 | 目标代码的生成和优化(一):如何适应各种硬件架构?.md
Normal file
@@ -0,0 +1,240 @@
|
||||
<audio id="audio" title="29 | 目标代码的生成和优化(一):如何适应各种硬件架构?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c5/dd/c56a40e80036f8cd4f63709deca4bedd.mp3"></audio>
|
||||
|
||||
在编译器的后端,我们要能够针对不同的计算机硬件,生成优化的代码。在[23讲](https://time.geekbang.org/column/article/150798),我曾带你试着生成过汇编代码,但当时生成汇编代码的逻辑是比较幼稚的,一个正式的编译器后端,代码生成部分需要考虑得更加严密才可以。
|
||||
|
||||
那么具体要考虑哪些问题呢?**其实主要有三点:**
|
||||
|
||||
<li>
|
||||
指令的选择。同样一个功能,可以用不同的指令或指令序列来完成,而我们需要选择比较优化的方案。
|
||||
</li>
|
||||
<li>
|
||||
寄存器分配。每款CPU的寄存器都是有限的,我们要有效地利用它。
|
||||
</li>
|
||||
<li>
|
||||
指令重排序。计算执行的次序会影响所生成的代码的效率。在不影响运行结果的情况下,我们要通过代码重排序获得更高的效率。
|
||||
</li>
|
||||
|
||||
我会用两节课的时间,带你对这三点问题建立直观认识,然后,我还会介绍LLVM的实现策略。这样一来,你会对目标代码的生成,建立比较清晰的顶层认知,甚至可以尝试去实现自己的算法。
|
||||
|
||||
接下来,我们针对第一个问题,聊一聊为什么需要选择指令,以及如何选择指令。
|
||||
|
||||
## 选择正确的指令
|
||||
|
||||
你可能会问:我们为什么非要关注指令的选择呢?我来做个假设。
|
||||
|
||||
如果我们不考虑目标代码的性能,可以按照非常机械的方式翻译代码。比如,我们可以制定一个代码翻译的模板,把形如“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的第i个元素赋值为b。假设a和b都是栈里的本地变量,i是放在寄存器ri中。这个表达式可以用一个AST表示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4f/a1/4f732f78dfe9cebb4265c26c5dc3ffa1.jpg" alt="">
|
||||
|
||||
你可能觉得这棵树看着像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有点儿像,但更简化、易读:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/04/7f/04c989734ec56c979db6002144d6417f.jpg" alt="">
|
||||
|
||||
**注意,**我们生成的汇编代码还是比较精简的。如果采用比较幼稚的方法,逐个树节点进行翻译,代码会很多,你可以手工翻译试试看。
|
||||
|
||||
用树覆盖的方法可以大大减少代码量,其中用橙色的线包围的部分被形象地叫做**一个瓦片(tiling),**那些包含了操作符的瓦片,就可以转化成一条指令。每个瓦片可以覆盖多个节点,所以生成的指令比较少。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/82/09/826353008c1523120ae46439ca5b0f09.jpg" alt="">
|
||||
|
||||
那我们是用什么来做瓦片的呢?原来,每条机器指令,都会对应IR的一些模式(Pattern),可以表示成一些小的树,而这些小树就可以当作瓦片:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8f/54/8fcf946ca8f73f351ff7c2050e71bc54.jpg" alt="">
|
||||
|
||||
我们的算法可以遍历AST,遇到上面的模式,就可以生成对应的指令。**以load指令为例,它有几个模式:**任意一个节点加上一个常量就行,这相当于汇编语言中的间接地址访问;或者mem下直接就是一个常量就行,这相当于是直接地址访问。最后,地址值还可以由下级子节点计算出来。
|
||||
|
||||
所以,从一棵AST生成代码的过程,就是用上面这些小树去匹配一棵大树,并把整个大树覆盖的过程,所以叫做树覆盖算法。2、4、5、6、8、9这几个节点依次生成汇编代码。
|
||||
|
||||
要注意的是,覆盖方式可能会有多个,比如下面这个覆盖方式,相比之前的结果,**它在8和9两个瓦片上是有区别的:**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0d/25/0d631b14c3f3d4e15bfb373bb191bc25.jpg" alt="">
|
||||
|
||||
生成的汇编代码最后两句也不同:
|
||||
|
||||
```
|
||||
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内存里
|
||||
|
||||
```
|
||||
|
||||
你可以体会一下,这两个覆盖方式的差别:
|
||||
|
||||
<li>
|
||||
对于瓦片8中的加法运算,一个当做了间接地址的计算,一个就是当成加法;
|
||||
</li>
|
||||
<li>
|
||||
对于根节点的操作,一个翻译成从store,把寄存器中的b的值写入到内存。一个翻译成movm指令,直接在内存之间拷贝值。至于这两种翻译方法哪种更好,比较总体的性能哪个更高就行了。
|
||||
</li>
|
||||
|
||||
到目前为止,你已经直观地了解了为什么要进行指令选择,以及最常用的树覆盖方法了。当然了,树覆盖算法有很多,比如Maximal Munch算法、动态规划算法、树文法等,LLVM也有自己的算法。
|
||||
|
||||
**简单地说一下Maximal Munch算法。**Maximal Munch直译成中文,是每次尽量咬一大口的意思。具体来说,就是从树根开始,每次挑一个能覆盖最多节点的瓦片,这样就形成几棵子树。对每棵子树也都用相同的策略,这样会使得生成的指令是最少的。注意,指令的顺序要反过来,按照深度优先的策略,先是叶子,再是树根。这个算法是Optimal的算法。
|
||||
|
||||
Optimal被翻译成最佳,我不太赞正这种翻译方法,翻译成“较优”会比较合适,它指的是在局部,相邻的两个瓦片不可能连接成代价更低的瓦片。覆盖算法除了Optimal的还有Optimum的,Optimum是全局最优化的状态,就是代码总体的代价是最低的。
|
||||
|
||||
关于其他算法的细节在本节课就不展开了,因为根据我的经验,在学指令选择时,最重要的还是建立图形化的、直观的理解,理解什么是瓦片,如何覆盖会得到最优的结果。
|
||||
|
||||
接下来,我们继续探讨开篇提到的第二个问题:寄存器分配。
|
||||
|
||||
## 分配寄存器
|
||||
|
||||
寄存器优化的任务是:最大程度地利用寄存器,但不要超过寄存器总数量的限制。
|
||||
|
||||
因为我们生成IR时,是不知道目标机器的信息的,也就不知道目标机器到底有几个寄存器可以用,所以我们在IR中可以使用无限个临时变量,每个临时变量都代表一个寄存器。
|
||||
|
||||
现在既然要生成针对目标机器的代码,也就知道这些信息了,那么就要把原来的IR改写一下,以便使用寄存器时不超标。
|
||||
|
||||
那么寄存器优化的原理是什么呢?**我用一个例子带你了解一下。**
|
||||
|
||||
下图左边的IR中,a、d、f这三个临时变量不会同时出现。假设a和d在这个代码块之后成了死变量,那么这三个变量可以共用同一个寄存器,就像右边显示的那样:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fa/6a/fa047ba1f0d83d048b06f94d9cdcb36a.jpg" alt="">
|
||||
|
||||
实际上,这三行代码是对“b + c + e + 10”这个表达式的翻译,所以a和d都是在转换为IR时引入的中间变量,用完就不用了。这和在23讲,我们把8个参数以及一个本地变量相加时,只用了一个寄存器来一直保存累加结果,是一样的。
|
||||
|
||||
所以,通过这个例子,**你可以直观地理解寄存器共享的原则:**如果存在两个临时变量a和b,它们在整个程序执行过程中,最多只有一个变量是活跃的,那么这两个变量可以共享同一个寄存器。
|
||||
|
||||
在[27](https://time.geekbang.org/column/article/155338)和[28讲](https://time.geekbang.org/column/article/156878)中,你已经学过了如何做变量的活跃性分析,所以你可以很容易分析出,在任何一个程序点,活跃变量的集合。然后,你再看一下,哪些变量从来没有出现在同一个集合中就行。**看看下面的这个图:**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cb/08/cb7f92bdd1b8b280cc05fdbda5931308.jpg" alt="">
|
||||
|
||||
上图中,凡是出现在同一个花括号里的变量,都不能共享寄存器,因为它们在某个时刻是同时活跃的。那a到f,哪些变量从来没碰到过呢?我们再画一个图来寻找一下。
|
||||
|
||||
下图中,每个临时变量作为一个节点,如果两个变量同时存在过,就画一条边。这样形成的图,叫做寄存器干扰图(Register Interference Graph, RIG)。在这张图里,凡是没有连线的两个变量,就可以分配到同一个寄存器,例如,a和b,b和d,a和d,b和e,a和e。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/45/47/4568f4898523c5cfbf03799ced3cbb47.jpg" alt="">
|
||||
|
||||
**那么问题来了:**针对这个程序,我们一共需要几个寄存器?怎么分配呢?
|
||||
|
||||
**一个比较常用的算法是图染色算法:**只要两个节点之间有连线,节点就染成不同的颜色。最后所需要的最少颜色,就是所需要的寄存器的数量。我画了两个染色方案,都是需要4种颜色:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bc/b5/bc48864acb35432ba68b67918c9f33b5.jpg" alt="">
|
||||
|
||||
不过我们是手工染色的,那么如何用算法来染色呢?假如一共有4个寄存器,我们想用算法知道寄存器是否够用?**应该如何染色?**
|
||||
|
||||
染色算法很简单。如果想知道k个寄存器够不够用,你只需要找到一个少于k条边的节点,把它从图中去掉。接着再找下一个少于k条边的节点,再去掉。如果最后整个图都被删掉了,那么这个图一定可以用k种颜色来染色。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c7/18/c7e3d74bd9dfb74ef08e65a50a711f18.jpg" alt="">
|
||||
|
||||
**为什么呢?**因为如果一个图(蓝色边的)是能用k种颜色染色的,那么再加上一个节点,它的边的数量少于k个,比如是n,那么这个大一点儿的图(橙色边的)还是可以用k种颜色染色的。道理很简单,因为加进来的节点的边数少于k个,所以一定能找到一个颜色,与这个点的n个邻居都不相同。
|
||||
|
||||
所以,我们把刚才一个个去掉节点的顺序反过来,把一个个节点依次加到图上,每加上一个,就找一个它的邻居没有用的颜色来染色就行了。整个方法简单易行。
|
||||
|
||||
但是,如果所需要寄存器比实际寄存器的数量多,该怎么办呢?当然是用栈了。这个问题就是寄存器溢出(Register Spilling),溢出到栈里去,我在[21讲](https://time.geekbang.org/column/article/146635)关于运行时机制时提到过,像本地变量、参数、返回值等,都尽量用寄存器,如果寄存器不够用,那就放到栈里。另外再说一下,无论放在寄存器里,还是栈里,都是活动记录的组成部分,所以活动记录这个概念比栈桢更广义。
|
||||
|
||||
**还是拿上面的例子来说,**如果只有3个寄存器,那么要计算一下3个寄存器够不够用。我们先把a和b从图中去掉:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d6/18/d69bb8a9362cc35bf4136fa015ab2c18.jpg" alt="">
|
||||
|
||||
这时你发现,剩下的4个节点,每个节点都有3个邻居。所以,3个寄存器肯定不够用,必须要溢出一个去。我们可以选择让f保存在栈里,把f去掉以后,剩下的c,d,e可以用3种颜色成功染色。
|
||||
|
||||
这就结束了吗?当然没有。f虽然被保存到了栈里,但每次使用它的时候,都要load到一个临时变量,也就是寄存器中。每次保存f,也都要用一个临时变量写入到内存。所以,我们要把原来的代码修改一下,把每个使用f的地方,都加上一条load或save指令,以便在使用f的时候把f放到寄存器,用完后再写回内存。**修改后的CFG如下:**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f7/cb/f7374940932e5fade63ac3632bed23cb.jpg" alt="">
|
||||
|
||||
因为原来有4个地方用到了f,所以我们引入了f1到f4四个临时变量。这样的话,总的临时变量反而变多了,从6个到了9个。不过没关系,虽然临时变量更多了,但这几个临时变量的生存期都很短,图里带有f的活跃变量集合,比之前少多了。所以,即使有9个临时变量,也能用三种颜色染色,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c0/2a/c03659bb2d1e989d8bf6a4b00c86e02a.jpg" alt="">
|
||||
|
||||
最后,在选择把哪个变量溢出的时候,你实际上是要有所选择的。你最好选择使用次数最少的变量。在程序内循环中的变量,就最好不要溢出,因为每次循环都会用到它们,还是放在寄存器里性能更高。
|
||||
|
||||
目前为止,代码生成中的第二项重要工作,分配寄存器就概要地讲完了。我留给你一段时间消化本节课的内容,在下一讲,我会接着讲指令重排序和LLVM的实现。
|
||||
|
||||
## 课程小结
|
||||
|
||||
目标代码生成过程中有三个关键知识点:指令选择、寄存器分配和指令重排序,本节课,我讲了前两个,期望能帮你理解这两个问题的实质,让你对指令选择和寄存器分配这两个问题建立直观理解。这样你再去研究不同的算法时,脑海里会有这两个概念的顶层的、图形化的认识,事半功倍。与此同时,本节课我希望你记住几个要点如下:
|
||||
|
||||
<li>
|
||||
相同的IR可以由不同的机器指令序列来实现。你要理解瓦片为什么长那个样子,并且在大脑里建立用瓦片覆盖一棵AST的直观印象,最好具备多种覆盖方式,从而把这个问题由抽象变得具象。
|
||||
</li>
|
||||
<li>
|
||||
寄存器分配是编译器必须要做的一项工作,它把可以使用无限多寄存器的IR,变成了满足物理寄存器数量的IR,超出的要溢出到内存中保管。染色算法是其中一个可行的算法。
|
||||
</li>
|
||||
|
||||
## 一课一思
|
||||
|
||||
关于指令选择,你是否知道其他的例子,让同一个功能可以用不同的指令实现?欢迎在留言区分享你的经验。
|
||||
|
||||
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
|
226
极客时间专栏/编译原理之美/实现一门编译型语言 · 原理篇/30 | 目标代码的生成和优化(二):如何适应各种硬件架构?.md
Normal file
226
极客时间专栏/编译原理之美/实现一门编译型语言 · 原理篇/30 | 目标代码的生成和优化(二):如何适应各种硬件架构?.md
Normal file
@@ -0,0 +1,226 @@
|
||||
<audio id="audio" title="30 | 目标代码的生成和优化(二):如何适应各种硬件架构?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/14/5b/141d10c21ecf2a2a59f9f1518909e65b.mp3"></audio>
|
||||
|
||||
前一讲,我带你了解了指令选择和寄存器分配,本节课我们继续讲解目标代码生成的,第三个需要考虑的因素:**指令重排序(Instruction Scheduling)。**
|
||||
|
||||
我们可以通过重新排列指令,让代码的整体执行效率加快。那你可能会问了:就算重新排序了,每一条指令还是要执行啊?怎么就会变快了呢?
|
||||
|
||||
别着急,本节课我就带你探究其中的原理和算法,来了解这个问题。而且,我还会带你了解LLVM是怎么把指令选择、寄存器分配、指令重排序这三项工作组织成一个完整流程,完成目标代码生成的任务的。这样,你会对编译器后端的代码生成过程形成完整的认知,为正式做一些后端工作打下良好的基础。
|
||||
|
||||
首先,我们来看看指令重排序的问题。
|
||||
|
||||
## 指令重排序
|
||||
|
||||
如果你有上面的疑问,其实是很正常的。因为我们通常会把CPU看做一个整体,把CPU执行指令的过程想象成,依此检票进站的过程,改变不同乘客的次序,并不会加快检票的速度。所以,我们会自然而然地认为改变顺序并不会改变总时间。
|
||||
|
||||
但当我们进入CPU内部,会看到CPU是由多个功能部件构成的。下图是Ice Lake微架构的CPU的内部构成(从[Intel公司的技术手册](https://software.intel.com/sites/default/files/managed/9e/bc/64-ia-32-architectures-optimization-manual.pdf)中获取):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d5/72/d542a9f16a9153cf7ddd1d85b83af172.png" alt="">
|
||||
|
||||
在这个结构中,一条指令执行时,要依次用到多个功能部件,分成多个阶段,虽然每条指令是顺序执行的,但每个部件的工作完成以后,就可以服务于下一条指令,从而达到并行执行的效果。这种结构叫做**流水线(pipeline)结构。**我举例子说明一下,比如典型的RISC指令在执行过程会分成前后共5个阶段。
|
||||
|
||||
- IF:获取指令;
|
||||
- ID(或RF):指令解码和获取寄存器的值;
|
||||
- EX:执行指令;
|
||||
- ME(或MEM):内存访问(如果指令不涉及内存访问,这个阶段可以省略);
|
||||
- WB:写回寄存器。
|
||||
|
||||
对于CISC指令,CPU的流水线会根据指令的不同,分成更多个阶段,比如7个、10个甚至更多。
|
||||
|
||||
在执行指令的阶段,不同的指令也会由不同的单元负责,我们可以把这些单元叫做执行单元,比如,Intel的Ice Lake架构的CPU有下面这些执行单元:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/24/2b/2401aa716a0c74399de1659b3354662b.jpg" alt="">
|
||||
|
||||
其他执行单元还有:BM、Vec ALU、Vec SHFT、Vec Add、Vec Mul、Shuffle等。
|
||||
|
||||
因为CPU内部存在着多个功能单元,所以在同一时刻,不同的功能单元其实可以服务于不同的指令,看看下面这个图;
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a4/cc/a4dd7af42c3584583feaaee0745612cc.jpg" alt="">
|
||||
|
||||
这样的话,多条指令实质上是并行执行的,从而减少了总的执行时间,这种并行叫做**指令级并行:**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a3/00/a35bdd36774d6b901f3f7b49f3ef4000.jpg" alt="">
|
||||
|
||||
如果没有这种并行结构,或者由于指令之间存在依赖关系,无法并行,那么执行周期就会大大加长:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/88/ef/882d6476ba5c9e68396e7d9f5b319fef.jpg" alt="">
|
||||
|
||||
**我们来看一个实际的例子。**
|
||||
|
||||
**为了举例子方便,我们做个假设:**假设load和store指令需要3个时钟周期来读写数据,add指令需要1个时钟周期,mul指令需要2个时钟周期。
|
||||
|
||||
图中橙色的编号是原来的指令顺序,绿色的数字是每条指令开始时的时钟周期,你把每条指令的时钟周期累计一下就能算出来。最后一条指令开始的时钟周期是20,该条指令运行需要3个时钟周期,所以在第22个时钟周期执行完所有的指令。右边是重新排序后的指令,一共花了13个时钟周期。**这个优化力度还是很大的!**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/41/1a/4141c409e10c26acb3642ffde72a171a.jpg" alt="">
|
||||
|
||||
仔细看一下左边前两条指令,这两条指令的意思是:先加载数据到寄存器,然后做一个加法。但加载需要3个时钟周期,所以add指令无法执行,只能干等着。
|
||||
|
||||
右列的前三条都是load指令,它们之间没有数据依赖关系,我们可以每个时钟周期启动一个,到了第四个时钟周期,每一条指令的数据已经加载完毕,所以就可以执行加法运算了。
|
||||
|
||||
我们可以把右边的内容画成下面的样子,你能看到,很多指令在时钟周期上是重叠的,**这就是指令级并行的特点。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3a/29/3a5274e2e422d64237d846496ab7a629.jpg" alt="">
|
||||
|
||||
当然了,不是所有的指令都可以并行,最后的3条指令就是顺序执行的,导致无法并行的原因有几个:
|
||||
|
||||
- 数据依赖约束
|
||||
|
||||
如果后一条指令要用到前一条指令的结果,那必须排在它后面,比如下面两条指令:add和mul。
|
||||
|
||||
对于第二条指令来说,除了获取指令的阶段(IF)可以和第一条指令并行以外,其他阶段需要等第一条指令的结果写入r1,第二条指令才可以使用r1的值继续运行。
|
||||
|
||||
```
|
||||
add r2, r1
|
||||
mul r3, r1
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/26/69/263002b235228a1558f03ca3d950ab69.jpg" alt="">
|
||||
|
||||
- 功能部件约束
|
||||
|
||||
如果只有一个乘法计算器,那么一次只能执行一条乘法运算。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b7/71/b71572487c156634271b72c8b5bad071.jpg" alt="">
|
||||
|
||||
- 指令流出约束
|
||||
|
||||
指令流出部件一次流出n条指令。
|
||||
|
||||
- 寄存器约束
|
||||
|
||||
寄存器数量有限,指令并行时使用的寄存器不可以超标。
|
||||
|
||||
后三者也可以合并成为一类,称作资源约束。
|
||||
|
||||
在数据依赖约束中,如果有因为使用同一个存储位置,而导致不能并行的,可以用重命名变量的方式消除,这类约束被叫做伪约束。而先写再写,以及先读后写是伪约束的两种呈现方式:
|
||||
|
||||
<li>
|
||||
先写再写:如果指令A写一个寄存器或内存位置,B也写同一个位置,就不能改变A和B的执行顺序,不过我们可以修改程序,让A和B写不同的位置。
|
||||
</li>
|
||||
<li>
|
||||
先读后写:如果A必须在B写某个位置之前读某个位置,那么不能改变A和B的执行顺序。除非能够通过重命名让它们使用不同的位置。
|
||||
</li>
|
||||
|
||||
以上就是指令重排序的原理,掌握这个原理你就明白为什么重排序可以提升性能了,**不过明白原理之后,我们还有能够用算法实现出来才行。**
|
||||
|
||||
用算法排序的关键点,是要找出代码之间的数据依赖关系。下图展现了示例中各行代码之间的数据依赖,可以叫做**数据的依赖图(dependence graph)。**它的边代表了值的流动,比如a行加载了一个数据到r1,b行利用r1来做计算,所以b行依赖a行,这个图也可以叫做优先图(precedence graph),因为a比b优先,b比d优先。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fe/c7/fea031f5e118c90910ff8d9a1149afc7.jpg" alt="">
|
||||
|
||||
我们可以给图中的每个节点再加上两个属性,利用这两个属性,就可以对指令进行排序了:
|
||||
|
||||
- 一是操作类型,因为这涉及它所需要的功能单元。
|
||||
- 二是时延属性,也就是每条指令所需的时钟周期。
|
||||
|
||||
图中的a、c、e、g是叶子,它们没有依赖任何其他的节点,所以尽量排在前面。b、d、f、h必须出现在各自所依赖的节点后面。而根节点i,总是要排在最后面。
|
||||
|
||||
根据时延属性,我们计算出了每个节点的累计时延(每个节点的累计时延等于父节点的累计时延加上本节点的时延)。其中a-b-d-f-h-i 路径是关键路径,代码执行的最少时间就是这条路径所花的时钟周期之和。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3e/a0/3eed222cc7b0beb7fb0a9011e6795ea0.jpg" alt="">
|
||||
|
||||
因为a在关键路径上,所以首先考虑把a节点排在第1行。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/da/ea/da26ecdc8469a6b7bb5c10337e17fcea.jpg" alt="">
|
||||
|
||||
剩下的树中,c-d-f-h-i变成了关键路径,因为c的累计时延最大。c节点可以排在第2行。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d4/73/d411f2990a11676a3765e4c269d9d073.jpg" alt="">
|
||||
|
||||
b和e的累计时延都是最长的,但由于b必须在a执行完毕后,才会开始执行,所以最好等够a的3个时钟周期,否则还是会空等,所以先不考虑b,而是把e放到第3行。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/21/ed/21eaa664cd7463824bc17f9e48409fed.jpg" alt="">
|
||||
|
||||
继续按照这个方式排,最后可以获得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寄存器。但在别的情况下,就有可能带来更大的优化。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/28/d5/28062620b1e1662a5804032704b162d5.jpg" alt="">
|
||||
|
||||
我们刚才其实是采用了一种最常见的算法,List Scheduling算法,**大致分为4步:**
|
||||
|
||||
1.把变量重命名来消除伪约束(可选步骤)。<br>
|
||||
2.创建依赖图。<br>
|
||||
3.为每行代码计算优先值(计算方法可以有很多,比如我们示例中基于最长时延的方法就是一种)。<br>
|
||||
4.迭代处理代码并排序。
|
||||
|
||||
除了List Scheduling算法以外,还有其他的算法,这里我就不展开了。不过,讲到算法时,我们需要考虑算法的复杂度。前一讲讲算法时,我没有提这个问题,是想在这里集中讲一下。
|
||||
|
||||
这两节课中,关于指令选择、寄存器分配和指令重排序的算法,其难度(时间复杂度)都是“NP-完全”的。“NP-完全”是什么意思呢?也就是这类问题找不到一个随规模(代码行数)计算量增长比较慢的算法(多项式时间算法)来找到最优解。反之,有可能计算量会随着代码行数呈指数级上升。因此,编译原理中的一些难度最高的算法,都在代码生成这一环。
|
||||
|
||||
当然了,找最优解太难,我们可以退而求其次,找一个次优解。就比如我们用地图软件导航的时候,没必要要求导航路径每次都是找到最短的。这时,就会有比较简单的算法,计算量不会随规模增长太快,但结果还比较理想。**我们这两讲的算法都是这个性质的。**
|
||||
|
||||
到目前为止,我带你了解了目标代码生成的三大考虑因素:指令选择、寄存器分配和指令重排序。现在,我们来看看目标代码生成,在LLVM中是如何实现的,这样,你能从概念过渡到实操,从而把知识点掌握得更加扎实。
|
||||
|
||||
## LLVM的实现
|
||||
|
||||
LLVM的后端需要多个处理步骤来生成目标代码:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/79/fd/79bde8c10be1eaea92a70890dbea56fd.jpg" alt="">
|
||||
|
||||
图中橙色的部分是重要的步骤,它本身包含了多个Pass,所以也叫做超级Pass。图中蓝框的Pass,是用来做一些额外的优化处理(关于LLVM的Pass机制,我在27讲提到过,如果你忘记了,可以回顾一下)。
|
||||
|
||||
接下来,我来讲解一下LLVM生成目标代码的关键步骤。
|
||||
|
||||
- 指令选择
|
||||
|
||||
LLVM的指令选择算法是基于DAG(有向无环图)的树模式匹配,与前一讲基于AST的算法有一些区别,但总思路是一致的(具体算法描述可以参见[这篇论文](http://www.llvm.org/pubs/2008-CGO-DagISel.pdf))。这个算法是Near-Optimal(接近Optimal)的,能够在线性的时间内完成指令的选择,并且它特别关注产生的代码的尺寸,要求尺寸足够小。
|
||||
|
||||
DAG是融合了公共子表达式的AST,也是一种结构化的IR。下面两行代码对应的AST和DAG分如图所示,你能看到,DAG把a=5这棵子树给融合了:
|
||||
|
||||
```
|
||||
a = 5
|
||||
b = (2 + a)+ (a * 3)
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/25/d0/2515d15395bdf611a2a13a26dadf26d0.jpg" alt="">
|
||||
|
||||
LLVM把内存中的IR模型,转化成了一个体现了某个目标平台特征的SelectionDAG,用于做指令选择。每个基本块转化成一个DAG,DAG中的节点通常代表指令,边代表指令之间的数据流动。
|
||||
|
||||
在这个阶段之后,LLVM会把DAG中的LLVM IR节点,全部转换成目标机器的节点,代表目标机器的指令,而不是LLVM的指令。
|
||||
|
||||
- 指令排序(寄存器分配之前)
|
||||
|
||||
基于前一步的处理结果,我们要对指令进行排序。但因为DAG不能反映没有依赖关系的节点之间的排序,所以LLVM要先把DAG转换成一种三地址模式,这个格式叫做MachineInstr。这个阶段会把指令排序,并尽量发挥指令级并行的能力。
|
||||
|
||||
- 寄存器分配
|
||||
|
||||
接下来做寄存器的分配。LLVM的IR支持无限多的寄存器,在这个环节要分配到实际的寄存器上,分配不下的就溢出到内存。
|
||||
|
||||
- 指令排序(寄存器分配之后)
|
||||
|
||||
分配完寄存器之后,LLVM会再做一次指令排序。因为寄存器分配,会指定确定的寄存器,而访问不同的寄存器的时钟周期,可能是不同的。对于溢出到内存中的变量,也增加了一些指令在内存和寄存器之间传输数据。利用这些信息,LLVM可以进一步优化指令的排序。
|
||||
|
||||
- 代码输出
|
||||
|
||||
做完上面的所有工作后,就可以输出目标代码了。
|
||||
|
||||
LLVM在这一步把MachineInstr格式转换为MCInst格式,因为后者更有利于汇编器和链接器输出汇编代码或二进制目标代码。
|
||||
|
||||
**在这里,我想延伸一下,和你探讨一个问题:**如果现在有一个新的CPU架构,要实现一个崭新的后端,来支持各种语言,应该怎么做。
|
||||
|
||||
在我国大力促进芯片研发的背景下,这是一个值得探讨的问题,新芯片需要编译器的支持才可以呀。你要实现各种指令选择的算法、寄存器分配的算法、指令排序的算法来反映这款CPU的特点。
|
||||
|
||||
对于这个难度颇高的任务,LLVM的TableGen模块会给你提供很大的帮助。这个模块能够帮助你为某个新的CPU架构快速生成后端。你可以用一系列配置文件定义你的CPU架构的特征,比如寄存器的数量、指令集等等。
|
||||
|
||||
一旦你定义了这些信息,TableGen模块就能根据这些配置信息,生成各种算法,如指令选择器、指令排序器、一些优化算法等等。这就像编译器前段工具可以帮你生成词法分析器,和语法分析器一样,能够大大降低开发一个新后端的工作量,所以说,把LLVM研究透彻,会有助于你在这样的重大项目中发挥重要作用。
|
||||
|
||||
## 课程小结
|
||||
|
||||
本节课,我讲解了目标代码生成的第三个主题:指令重排序。
|
||||
|
||||
要理解这个主题,你首先要知道CPU内部是分成多个功能部件的,要知道一条指令的执行过程中,指令获取、解码、执行、访问数据都是如何发生的,这样你会知道指令级并行的原理。
|
||||
|
||||
其次,从算法角度,你要知道List Scheduling的步骤,掌握基于最大时延的优先级计算策略。有了这个基础之后,你可以进一步地研究其他算法。
|
||||
|
||||
**我想强调的是,**指令选择、寄存器分配、指令重排序这三个领域的算法,都是“NP-完全”的,所以寻找优化的算法,是这个领域最富有挑战的任务。要研究清楚这些算法,你需要阅读相关的资料,比如本讲推荐的论文和其他该领域的经典论文。
|
||||
|
||||
另外,我建议你阅读CPU厂商的手册,因为只有手册才会提供相关CPU的具体信息,解答你对技术细节的一些疑惑。比如网上曾经有人提问说:为什么mov指令要用到ALU部件?这个其实看一下手册就知道了。
|
||||
|
||||
最后,我带你了解了LLVM是如何做这些后端工作的,这样可以加深你对代码生成这部分知识的了解。
|
||||
|
||||
## 一课一思
|
||||
|
||||
为了方便教学,本讲的示例用的时延值都比较少,这其实是不符合实际的。假设我们忽略指令获取和解码的阶段,只考虑执行和写入寄存器两个阶段,这时候add指令需要3个时钟周期(2个执行,1个写寄存器),mul指令也需要3个时钟周期,那么会对示例代码的排序产生什么影响呢?你可以实际推演一下,这对于你理解指令重排序的算法会很有帮助。
|
||||
|
||||
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
|
496
极客时间专栏/编译原理之美/实现一门编译型语言 · 原理篇/加餐 | 汇编代码编程与栈帧管理.md
Normal file
496
极客时间专栏/编译原理之美/实现一门编译型语言 · 原理篇/加餐 | 汇编代码编程与栈帧管理.md
Normal file
@@ -0,0 +1,496 @@
|
||||
<audio id="audio" title="加餐 | 汇编代码编程与栈帧管理" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/47/f0/47bbe8056d999ecd7da58570cbc831f0.mp3"></audio>
|
||||
|
||||
在[22讲](https://time.geekbang.org/column/article/147854)中,我们侧重讲解了汇编语言的基础知识,包括构成元素、汇编指令和汇编语言中常用的寄存器。学习完基础知识之后,你要做的就是多加练习,和汇编语言“混熟”。小窍门是查看编译器所生成的汇编代码,跟着学习体会。
|
||||
|
||||
不过,可能你是初次使用汇编语言,对很多知识点还会存在疑问,比如:
|
||||
|
||||
- 在汇编语言里调用函数(过程)时,传参和返回值是怎么实现的呢?
|
||||
- [21讲](https://time.geekbang.org/column/article/146635)中运行期机制所讲的栈帧,如何通过汇编语言实现?
|
||||
- 条件语句和循环语句如何实现?
|
||||
- ……
|
||||
|
||||
为此,我策划了一期加餐,针对性地讲解这样几个实际场景,希望帮你加深对汇编语言的理解。
|
||||
|
||||
## 示例1:过程调用和栈帧
|
||||
|
||||
这个例子涉及了一个过程调用(相当于C语言的函数调用)。过程调用是汇编程序中的基础结构,它涉及到**栈帧的管理、参数的传递**这两个很重要的知识点。
|
||||
|
||||
假设我们要写一个汇编程序,实现下面C语言的功能:
|
||||
|
||||
```
|
||||
/*function-call1.c */
|
||||
#include <stdio.h>
|
||||
int fun1(int a, int b){
|
||||
int c = 10;
|
||||
return a+b+c;
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[]){
|
||||
printf("fun1: %d\n", fun1(1,2));
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
fun1函数接受两个整型的参数:a和b,来看看这两个参数是怎样被传递过去的,手写的汇编代码如下:
|
||||
|
||||
```
|
||||
# function-call1-craft.s 函数调用和参数传递
|
||||
# 文本段,纯代码
|
||||
.section __TEXT,__text,regular,pure_instructions
|
||||
|
||||
_fun1:
|
||||
# 函数调用的序曲,设置栈指针
|
||||
pushq %rbp # 把调用者的栈帧底部地址保存起来
|
||||
movq %rsp, %rbp # 把调用者的栈帧顶部地址,设置为本栈帧的底部
|
||||
|
||||
subq $4, %rsp # 扩展栈
|
||||
|
||||
movl $10, -4(%rbp) # 变量c赋值为10,也可以写成 movl $10, (%rsp)
|
||||
|
||||
# 做加法
|
||||
movl %edi, %eax # 第一个参数放进%eax
|
||||
addl %esi, %eax # 把第二个参数加到%eax,%eax同时也是存放返回值的寄存器
|
||||
addl -4(%rbp), %eax # 加上c的值
|
||||
|
||||
addq $4, %rsp # 缩小栈
|
||||
|
||||
# 函数调用的尾声,恢复栈指针为原来的值
|
||||
popq %rbp # 恢复调用者栈帧的底部数值
|
||||
retq # 返回
|
||||
|
||||
.globl _main # .global伪指令让_main函数外部可见
|
||||
_main: ## @main
|
||||
|
||||
# 函数调用的序曲,设置栈指针
|
||||
pushq %rbp # 把调用者的栈帧底部地址保存起来
|
||||
movq %rsp, %rbp # 把调用者的栈帧顶部地址,设置为本栈帧的底部
|
||||
|
||||
# 设置第一个和第二个参数,分别为1和2
|
||||
movl $1, %edi
|
||||
movl $2, %esi
|
||||
|
||||
callq _fun1 # 调用函数
|
||||
|
||||
# 为pritf设置参数
|
||||
leaq L_.str(%rip), %rdi # 第一个参数是字符串的地址
|
||||
movl %eax, %esi # 第二个参数是前一个参数的返回值
|
||||
|
||||
callq _printf # 调用函数
|
||||
|
||||
# 设置返回值。这句也常用 xorl %esi, %esi 这样的指令,都是置为零
|
||||
movl $0, %eax
|
||||
|
||||
# 函数调用的尾声,恢复栈指针为原来的值
|
||||
popq %rbp # 恢复调用者栈帧的底部数值
|
||||
retq # 返回
|
||||
|
||||
# 文本段,保存字符串字面量
|
||||
.section __TEXT,__cstring,cstring_literals
|
||||
L_.str: ## @.str
|
||||
.asciz "Hello World! :%d \n"
|
||||
|
||||
```
|
||||
|
||||
**需要注意,**手写的代码跟编译器生成的可能有所不同,但功能是等价的,代码里有详细的注释,你肯定能看明白。
|
||||
|
||||
**借用这个例子,我们讲一下栈的管理。**在示例代码的两个函数里,有这样的固定结构:
|
||||
|
||||
```
|
||||
# 函数调用的序曲,设置栈指针
|
||||
pushq %rbp # 把调用者的栈帧底部地址保存起来
|
||||
movq %rsp, %rbp # 把调用者的栈帧顶部地址,设置为本栈帧的底部
|
||||
|
||||
...
|
||||
|
||||
# 函数调用的尾声,恢复栈指针为原来的值
|
||||
popq %rbp # 恢复调用者栈帧的底部数值
|
||||
|
||||
```
|
||||
|
||||
在C语言生成的代码中,一般用%rbp寄存器指向栈帧的底部,而%rsp则指向栈帧的顶部。**栈主要是通过push和pop这对指令来管理的:**push把操作数压到栈里,并让%rsp指向新的栈顶,pop把栈顶数据取出来,同时调整%rsp指向新的栈顶。
|
||||
|
||||
在进入函数的时候,用pushq %rbp指令把调用者的栈帧地址存起来(根据调用约定保护起来),而把调用者的栈顶地址设置成自己的栈底地址,它等价于下面两条指令,你可以不用push指令,而是运行下面两条指令:
|
||||
|
||||
```
|
||||
subq $8, %rsp #把%rsp的值减8,也就是栈增长8个字节,从高地址向低地址增长
|
||||
movq %rbp, (%rsp) #把%rbp的值写到当前栈顶指示的内存位置
|
||||
|
||||
```
|
||||
|
||||
而在退出函数前,调用了popq %rbp指令。它恢复了之前保存的栈指针的地址,等价于下面两条指令:
|
||||
|
||||
```
|
||||
movq (%rsp), %rbp #把栈顶位置的值恢复回%rbp,这是之前保存在栈里的值。
|
||||
addq $8, %rsp #把%rsp的值加8,也就是栈减少8个字节
|
||||
|
||||
```
|
||||
|
||||
上述过程画成一张直观的图,表示如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/45/df/450388ce0b3189fbf263da402bc447df.jpg" alt="">
|
||||
|
||||
上面每句指令执行以后,我们看看%rbp和%rsp值的变化:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1b/6a/1beeb1ded99922d15cc98e7cc3359a6a.jpg" alt="">
|
||||
|
||||
再来看看使用局部变量的时候会发生什么:
|
||||
|
||||
```
|
||||
subq $4, %rsp # 扩展栈
|
||||
|
||||
movl $10, -4(%rbp) # 变量c赋值为10,也可以写成 movl $10, (%rsp)
|
||||
|
||||
...
|
||||
|
||||
addq $4, %rsp # 缩小栈
|
||||
|
||||
```
|
||||
|
||||
我们通过减少%rsp的值,来扩展栈,然后在扩展出来的4个字节的位置上写入整数,这就是变量c的值。在返回函数前,我们通过addq $4, %rsp再把栈缩小。这个过程如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/94/4a/94cf6dbfae7169f6ef01a09e804b7c4a.jpg" alt="">
|
||||
|
||||
在这个例子中,我们通过移动%rsp指针来改变帧的大小。%rbp和%rsp之间的空间就是当前栈帧。而过程调用和退出过程,分别使用call指令和ret指令。“callq _fun1”是调用_fun1过程,这个指令相当于下面两句代码,它用到了栈来保存返回地址:
|
||||
|
||||
```
|
||||
pushq %rip # 保存下一条指令的地址,用于函数返回继续执行
|
||||
jmp _fun1 # 跳转到函数_fun1
|
||||
|
||||
```
|
||||
|
||||
_fun1函数用ret指令返回,它相当于:
|
||||
|
||||
```
|
||||
popq %rip #恢复指令指针寄存器
|
||||
jmp %rip
|
||||
|
||||
```
|
||||
|
||||
上一讲,我提到,在X86-64架构下,新的规范让程序可以访问栈顶之外128字节的内存,所以,我们甚至不需要通过改变%rsp来分配栈空间,而是直接用栈顶之外的空间。
|
||||
|
||||
上面的示例程序,你可以用as命令生成可执行程序,运行一下看看,然后试着做一下修改,逐步熟悉汇编程序的编写思路。
|
||||
|
||||
## 示例2:同时使用寄存器和栈来传参
|
||||
|
||||
上一个示例中,函数传参只使用了两个参数,这时是通过两个寄存器传递参数的。这次,我们使用8个参数,来看看通过寄存器和栈传参这两种不同的机制。
|
||||
|
||||
在X86-64架构下,有很多的寄存器,所以程序调用约定中规定尽量通过寄存器来传递参数,而且,只要参数不超过6个,都可以通过寄存器来传参,使用的寄存器如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4d/53/4d066afb9834f2a602bca2010e6edb53.jpg" alt="">
|
||||
|
||||
超过6个的参数的话,我们要再加上栈来传参:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/45/89/45587ab64c83ea52f9d1fd3fedc6b189.jpg" alt="">
|
||||
|
||||
根据程序调用约定的规定,参数1~6是放在寄存器里的,参数7和8是放到栈里的,先放参数8,再放参数7。
|
||||
|
||||
在23讲,我会带你为下面的一段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));
|
||||
|
||||
```
|
||||
|
||||
现在,我们可以按照调用约定,先手工编写一段实现相同功能的汇编代码:
|
||||
|
||||
```
|
||||
# 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 # 调用函数
|
||||
|
||||
# 为pritf设置参数
|
||||
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"
|
||||
|
||||
```
|
||||
|
||||
用as命令,把这段汇编代码生成可执行文件,运行后会输出结果:“fun1: 46”。
|
||||
|
||||
```
|
||||
as functio-call2-craft.s -o function-call2
|
||||
./function-call2
|
||||
|
||||
```
|
||||
|
||||
这段程序虽然有点儿长,但思路很清晰,比如,每个函数(过程)都有固定的结构。7~10行,我叫做序曲,是设置栈帧的指针;25~26行,我叫做尾声,是恢复栈底指针并返回;13~22行是做一些计算,还要为本地变量在栈里分配一些空间。
|
||||
|
||||
**我建议你读代码的时候,**对照着每行代码的注释,弄清楚这条代码所做的操作,以及相关的寄存器和内存中值的变化,脑海里有栈帧和寄存器的直观的结构,就很容易理解清楚这段代码了。
|
||||
|
||||
除了函数调用以外,我们在编程时经常使用循环语句和if语句,它们转换成汇编是什么样子呢?我们来研究一下,首先看看while循环语句。
|
||||
|
||||
## 示例3:循环语句的汇编码解析
|
||||
|
||||
看看下面这个C语言的语句:
|
||||
|
||||
```
|
||||
void fun1(int a){
|
||||
while (a < 10){
|
||||
a++;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们要使用"gcc -S ifstmt.c -o ifstmt.s"命令,把它转换成汇编语句(注意不要带优化参数):
|
||||
|
||||
```
|
||||
.section __TEXT,__text,regular,pure_instructions
|
||||
.macosx_version_min 10, 15
|
||||
.globl _fun1 ## -- Begin function fun1
|
||||
.p2align 4, 0x90
|
||||
_fun1: ## @fun1
|
||||
.cfi_startproc
|
||||
## %bb.0:
|
||||
pushq %rbp
|
||||
.cfi_def_cfa_offset 16
|
||||
.cfi_offset %rbp, -16
|
||||
movq %rsp, %rbp
|
||||
.cfi_def_cfa_register %rbp
|
||||
movl %edi, -4(%rbp) #把参数a放到栈里
|
||||
LBB0_1: ## =>This Inner Loop Header: Depth=1
|
||||
cmpl $10, -4(%rbp) #比较参数1和立即数10,设置eflags寄存器
|
||||
jge LBB0_3 #如果大于等于,则跳转到LBB0_3基本块
|
||||
## %bb.2: ## in Loop: Header=BB0_1 Depth=1
|
||||
movl -4(%rbp), %eax #这2行,是给a加1
|
||||
addl $1, %eax
|
||||
movl %eax, -4(%rbp)
|
||||
jmp LBB0_1
|
||||
LBB0_3:
|
||||
popq %rbp
|
||||
retq
|
||||
.cfi_endproc
|
||||
## -- End function
|
||||
|
||||
.subsections_via_symbols
|
||||
|
||||
```
|
||||
|
||||
这段代码的15、16、21行是关键,我解释一下:
|
||||
|
||||
- 第15行,用cmpl指令,将%edi寄存器中的参数1(即C代码中的参数a)和立即数10做比较,比较的结果会设置EFLAGS寄存器中的相关位。
|
||||
|
||||
EFLAGS中有很多位,下图是[Intel公司手册](https://software.intel.com/en-us/download/intel-64-and-ia-32-architectures-sdm-combined-volumes-1-2a-2b-2c-2d-3a-3b-3c-3d-and-4)中对各个位的解释,有的指令会影响这些位的设置,比如cmp指令,有的指令会从中读取信息,比如16行的jge指令:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d7/46/d79cff3bef9e77f825ed9866c5dd1146.jpg" alt="">
|
||||
|
||||
<li>
|
||||
第16行,jge指令。jge是“jump if greater or equal”的缩写,也就是当大于或等于的时候就跳转。大于等于是从哪知道的呢?就是根据EFLAGS中的某些位计算出来的。
|
||||
</li>
|
||||
<li>
|
||||
第21行,跳转到循环的开始。
|
||||
</li>
|
||||
|
||||
在这个示例中,我们看到了jmp(无条件跳转指令)和jge(条件跳转指令)两个跳转指令。条件跳转指令很多,它们分别是基于EFLAGS的状态位做不同的计算,判断是否满足跳转条件,看看下面这张表格:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ce/d5/ce52ac9632428550896ce20f958651d5.jpg" alt="">
|
||||
|
||||
表格中的跳转指令,是基于有符号的整数进行判断的,对于无符号整数、浮点数,还有很多其他的跳转指令。现在你应该体会到,汇编指令为什么这么多了。**好在其助记符都是有规律的,可以看做英文缩写,所以还比较容易理解其含义。**
|
||||
|
||||
**另外我再强调一下,**刚刚我让你生成汇编时,不要带优化参数,那是因为优化算法很“聪明”,它知道这个循环语句对函数最终的计算结果没什么用,就优化掉了。后面学优化算法时,你会理解这种优化机制。
|
||||
|
||||
不过这样做,也会有一个不好的影响,就是代码不够优化。比如这段代码把参数1拷贝到了栈里,在栈里做运算,而不是直接基于寄存器做运算,这样性能会低很多,这是没有做寄存器优化的结果。
|
||||
|
||||
## 示例4:if语句的汇编码解析
|
||||
|
||||
循环语句看过了,if语句如何用汇编代码实现呢?
|
||||
|
||||
看看下面这段代码:
|
||||
|
||||
```
|
||||
int fun1(int a){
|
||||
if (a > 10){
|
||||
return 4;
|
||||
}
|
||||
else{
|
||||
return 8;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
把上面的C语言代码转换成汇编代码如下:
|
||||
|
||||
```
|
||||
.section __TEXT,__text,regular,pure_instructions
|
||||
.macosx_version_min 10, 15
|
||||
.globl _fun1 ## -- Begin function fun1
|
||||
.p2align 4, 0x90
|
||||
_fun1: ## @fun1
|
||||
.cfi_startproc
|
||||
## %bb.0:
|
||||
pushq %rbp
|
||||
.cfi_def_cfa_offset 16
|
||||
.cfi_offset %rbp, -16
|
||||
movq %rsp, %rbp
|
||||
.cfi_def_cfa_register %rbp
|
||||
movl %edi, -8(%rbp)
|
||||
cmpl $10, -8(%rbp) #将参数a与10做比较
|
||||
jle LBB0_2 #小于等于的话就调转到LBB0_2基本块
|
||||
## %bb.1:
|
||||
movl $4, -4(%rbp) #否则就给a赋值为4
|
||||
jmp LBB0_3
|
||||
LBB0_2:
|
||||
movl $8, -4(%rbp) #给a赋值为8
|
||||
LBB0_3:
|
||||
movl -4(%rbp), %eax #设置返回值
|
||||
popq %rbp
|
||||
retq
|
||||
.cfi_endproc
|
||||
## -- End function
|
||||
|
||||
.subsections_via_symbols
|
||||
|
||||
```
|
||||
|
||||
了解了条件跳转指令以后,再理解上面的代码容易了很多。还是先做比较,设置EFLAGS中的位,然后做跳转。
|
||||
|
||||
## 示例5:浮点数的使用
|
||||
|
||||
之前我们用的例子都是采用整数,现在使用浮点数来做运算,看看会有什么不同。
|
||||
|
||||
看看下面这段代码:
|
||||
|
||||
```
|
||||
float fun1(float a, float b){
|
||||
float c = 2.0;
|
||||
return a + b + c;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
使用-O2参数,把C语言的程序编译成汇编代码如下:
|
||||
|
||||
```
|
||||
.section __TEXT,__text,regular,pure_instructions
|
||||
.macosx_version_min 10, 15
|
||||
.section __TEXT,__literal4,4byte_literals
|
||||
.p2align 2 ## -- Begin function fun1
|
||||
LCPI0_0:
|
||||
.long 1073741824 ## float 2 常量
|
||||
.section __TEXT,__text,regular,pure_instructions
|
||||
.globl _fun1
|
||||
.p2align 4, 0x90
|
||||
_fun1: ## @fun1
|
||||
.cfi_startproc
|
||||
## %bb.0:
|
||||
pushq %rbp
|
||||
.cfi_def_cfa_offset 16
|
||||
.cfi_offset %rbp, -16
|
||||
movq %rsp, %rbp
|
||||
.cfi_def_cfa_register %rbp
|
||||
addss %xmm1, %xmm0 #浮点数传参用XMM寄存器,加法用addss指令
|
||||
addss LCPI0_0(%rip), %xmm0 #把常量2.0加到xmm0上,xmm0保存返回值
|
||||
popq %rbp
|
||||
retq
|
||||
.cfi_endproc
|
||||
## -- End function
|
||||
|
||||
.subsections_via_symbols
|
||||
|
||||
```
|
||||
|
||||
这个代码的结构你应该熟悉了,栈帧的管理方式都是一样的,都要维护%rbp和%rsp。不一样的地方,有几个地方:
|
||||
|
||||
<li>
|
||||
传参。给函数传递浮点型参数,是要使用XMM寄存器。
|
||||
</li>
|
||||
<li>
|
||||
指令。浮点数的加法运算,使用的是addss指令,它用于对单精度的标量浮点数做加法计算,这是一个SSE1指令。SSE1是一组指令,主要是对单精度浮点数(比如C或Java语言中的float)进行运算的,而SSE2则包含了一些双精度浮点数(比如C或Java语言中的double)的运算指令。
|
||||
</li>
|
||||
<li>
|
||||
返回值。整型返回值是放在%eax寄存器中,而浮点数返回值是放在xmm0寄存器中的。调用者可以从这里取出来使用。
|
||||
</li>
|
||||
|
||||
## 课程小结
|
||||
|
||||
利用本节课的加餐,我带你把编程中常见的一些场景,所对应的汇编代码做了一些分析。你需要记住的要点如下:
|
||||
|
||||
<li>
|
||||
函数调用时,会使用寄存器传参,超过6个参数时,还要再加上栈,这都是遵守了调用约定。
|
||||
</li>
|
||||
<li>
|
||||
通过push、pop指令来使用栈,栈与%rbp和%rsp这两个指针有关。你可以图形化地记住栈的增长和回缩的过程。需要注意的是,是从高地址向低地址走,所以访问栈里的变量,都是基于%rbp来减某个值。使用%rbp前,要先保护起来,别破坏了调用者放在里面的值。
|
||||
</li>
|
||||
<li>
|
||||
循环语句和if语句的秘密在于比较指令和有条件跳转指令,它们都用到了EFLAGS寄存器。
|
||||
</li>
|
||||
<li>
|
||||
浮点数的计算要用到MMX寄存器,指令也有所不同。
|
||||
</li>
|
||||
|
||||
通过这次加餐,你会更加直观地了解汇编语言,接下来的课程中,我会带你尝试通过翻译AST自动生成这些汇编代码,让你直观理解编译器生成汇编码的过程。
|
||||
|
||||
## 一课一思
|
||||
|
||||
你了解到哪些地方会使用汇编语言编程?有没有一些比较有意思的场景?是否实现了一些普通高级语言难以实现的结果?欢迎在留言区分享你的经验。
|
||||
|
||||
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
|
||||
|
||||
|
Reference in New Issue
Block a user