mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2026-05-11 04:04:34 +08:00
mod
This commit is contained in:
258
极客时间专栏/编译原理实战课/预备知识篇/01 | 编译的全过程都悄悄做了哪些事情?.md
Normal file
258
极客时间专栏/编译原理实战课/预备知识篇/01 | 编译的全过程都悄悄做了哪些事情?.md
Normal file
@@ -0,0 +1,258 @@
|
||||
<audio id="audio" title="01 | 编译的全过程都悄悄做了哪些事情?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f8/53/f8f7692d5f6b0e7c243e3584da40f553.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
正如我在开篇词中所说的,这一季课程的设计,是要带你去考察实际编译器的代码,把你带到编译技术的第一现场,让你以最直观、最接地气的方式理解编译器是怎么做出来的。
|
||||
|
||||
但是,毕竟编译领域还是有很多基本概念的。对于编译原理基础不太扎实的同学来说,在跟随我出发探险之前,最好还是做一点准备工作,磨刀不误砍柴工嘛。所以,在正式开始本课程之前,我会先花8讲的时间,用通俗的语言,帮你把编译原理的知识体系梳理一遍。
|
||||
|
||||
当然,对于已经学过编译原理的同学来说,这几讲可以帮助你复习以前学过的知识,把相关的知识点从遥远的记忆里再调出来,重温一下,以便更好地进入状态。
|
||||
|
||||
今天这一讲,我首先带你从宏观上理解一下整个编译过程。后面几讲中,我再针对编译过程中的每个阶段做细化讲解。
|
||||
|
||||
好了,让我们开始吧。
|
||||
|
||||
**编译,其实就是把源代码变成目标代码的过程。**如果源代码编译后要在操作系统上运行,那目标代码就是汇编代码,我们再通过汇编和链接的过程形成可执行文件,然后通过加载器加载到操作系统里执行。如果编译后是在解释器里执行,那目标代码就可以不是汇编代码,而是一种解释器可以理解的中间形式的代码即可。
|
||||
|
||||
我举一个很简单的例子。这里有一段C语言的程序,我们一起来看看它的编译过程。
|
||||
|
||||
```
|
||||
int foo(int a){
|
||||
int b = a + 3;
|
||||
return b;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段源代码,如果把它编译成汇编代码,大致是下面这个样子:
|
||||
|
||||
```
|
||||
.section __TEXT,__text,regular,pure_instructions
|
||||
.globl _foo ## -- Begin function foo
|
||||
_foo: ## @foo
|
||||
pushq %rbp
|
||||
movq %rsp, %rbp
|
||||
movl %edi, -4(%rbp)
|
||||
movl -4(%rbp), %eax
|
||||
addl $3, %eax
|
||||
movl %eax, -8(%rbp)
|
||||
movl -8(%rbp), %eax
|
||||
popq %rbp
|
||||
retq
|
||||
|
||||
```
|
||||
|
||||
你可以看出,源代码和目标代码之间的差异还是很大的。那么,我们怎么实现这个翻译呢?
|
||||
|
||||
其实,编译和把英语翻译成汉语的大逻辑是一样的。前提是你要懂这两门语言,这样你看到一篇英语文章,在脑子里理解以后,就可以把它翻译成汉语。编译器也是一样,你首先需要让编译器理解源代码的意思,然后再把它翻译成另一种语言。
|
||||
|
||||
表面上看,好像从英语到汉语,一下子就能翻译过去。但实际上,大脑一瞬间做了很多个步骤的处理,包括识别一个个单词,理解语法结构,然后弄明白它的意思。同样,编译器翻译源代码,也需要经过多个处理步骤,如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0a/d4/0aa7939fbdb80f923ae7a8090ec7f3d4.jpg" alt="">
|
||||
|
||||
我来解释一下各个步骤。
|
||||
|
||||
## 词法分析(Lexical Analysis)
|
||||
|
||||
首先,编译器要读入源代码。
|
||||
|
||||
在编译之前,源代码只是一长串字符而已,这显然不利于编译器理解程序的含义。所以,编译的第一步,就是要像读文章一样,先把里面的单词和标点符号识别出来。程序里面的单词叫做Token,它可以分成关键字、标识符、字面量、操作符号等多个种类。**把字符串转换为Token的这个过程,就叫做词法分析。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d8/9a/d80623403c912ab43c328623df8a0e9a.jpg" alt="">
|
||||
|
||||
## 语法分析(Syntactic Analysis)
|
||||
|
||||
识别出Token以后,离编译器明白源代码的含义仍然有很长一段距离。下一步,**我们需要让编译器像理解自然语言一样,理解它的语法结构。**这就是第二步,**语法分析**。
|
||||
|
||||
上语文课的时候,老师都会让你给一个句子划分语法结构。比如说:“我喜欢又聪明又勇敢的你”,它的语法结构可以表示成下面这样的树状结构。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6c/a7/6cca65a7d46f1d96e278bd53027a12a7.jpg" alt="">
|
||||
|
||||
那么在编译器里,语法分析阶段也会把Token串,转换成一个**体现语法规则的、树状的数据结构**,这个数据结构叫做**抽象语法树**(AST,Abstract Syntax Tree)。我们前面的示例程序转换为AST以后,大概是下面这个样子:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/68/6b/6801cb9b637afae44e728fa08267756b.jpg" alt="">
|
||||
|
||||
这样的一棵AST反映了示例程序的语法结构。比如说,我们知道一个函数的定义包括了返回值类型、函数名称、0到多个参数和函数体等。这棵抽象语法树的顶部就是一个函数节点,它包含了四个子节点,刚好反映了函数的语法。
|
||||
|
||||
再进一步,函数体里面还可以包含多个语句,如变量声明语句、返回语句,它们构成了函数体的子节点。然后,每个语句又可以进一步分解,直到叶子节点,就不可再分解了。而叶子节点,就是词法分析阶段生成的Token(图中带边框的节点)。对这棵AST做深度优先的遍历,你就能依次得到原来的Token。
|
||||
|
||||
## 语义分析(Semantic Analysis)
|
||||
|
||||
生成AST以后,程序的语法结构就很清晰了,编译工作往前迈进了一大步。但这棵树到底代表了什么意思,我们目前仍然不能完全确定。
|
||||
|
||||
比如说,表达式“a+3”在计算机程序里的完整含义是:“获取变量a的值,把它跟字面量3的值相加,得到最终结果。”但我们目前只得到了这么一棵树,完全没有上面这么丰富的含义。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/83/da/83e78e9e6ba8506b9e26a00fc77ef1da.jpg" alt="">
|
||||
|
||||
这就好比西方的儿童,很小的时候就能够给大人读报纸。因为他们懂得发音规则,能念出单词来(词法分析),也基本理解语法结构(他们不见得懂主谓宾这样的术语,但是凭经验已经知道句子有不同的组成部分),可以读得抑扬顿挫(语法分析),但是他们不懂报纸里说的是什么,也就是不懂语义。这就是编译器解读源代码的下一步工作,**语义分析**。
|
||||
|
||||
**那么,怎样理解源代码的语义呢?**
|
||||
|
||||
实际上,语言的设计者在定义类似“a+3”中加号这个操作符的时候,是给它规定了一些语义的,就是要把加号两边的数字相加。你在阅读某门语言的标准时,也会看到其中有很多篇幅是在做语义规定。在ECMAScript(也就是JavaScript)标准2020版中,Semantic这个词出现了657次。下图是其中[加法操作的语义规则](https://tc39.es/ecma262/2020/#sec-additive-operators),它对于如何计算左节点、右节点的值,如何进行类型转换等,都有规定。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bc/8b/bc77f55a2801542999b7eee10e6f1c8b.jpg" alt="">
|
||||
|
||||
所以,我们可以在每个AST节点上附加一些语义规则,让它能反映语言设计者的本意。
|
||||
|
||||
- add节点:把两个子节点的值相加,作为自己的值;
|
||||
- 变量节点(在等号右边的话):取出变量的值;
|
||||
- 数字字面量节点:返回这个字面量代表的值。
|
||||
|
||||
这样的话,如果你深度遍历AST,并执行每个节点附带的语义规则,就可以得到a+3的值。这意味着,我们正确地理解了这个表达式的含义。运用相同的方法,我们也就能够理解一个句子的含义、一个函数的含义,乃至整段源代码的含义。
|
||||
|
||||
这也就是说,AST加上这些语义规则,就能完整地反映源代码的含义。这个时候,你就可以做很多事情了。比如,你可以深度优先地遍历AST,并且一边遍历,一边执行语法规则。那么这个遍历过程,就是解释执行代码的过程。你相当于写了一个基于AST的解释器。
|
||||
|
||||
不过在此之前,编译器还要做点语义分析工作。**那么这里的语义分析是要解决什么问题呢?**
|
||||
|
||||
给你举个例子,如果我把示例程序稍微变换一下,加一个全局变量的声明,这个全局变量也叫a。那你觉得“a+3”中的变量a指的是哪个变量?
|
||||
|
||||
```
|
||||
int a = 10; //全局变量
|
||||
int foo(int a){ //参数里有另一个变量a
|
||||
int b = a + 3; //这里的a指的是哪一个?
|
||||
return b;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们知道,编译程序要根据C语言在作用域方面的语义规则,识别出“a+3”中的a,所以这里指的其实是函数参数中的a,而不是全局变量的a。这样的话,我们在计算“a+3”的时候才能取到正确的值。
|
||||
|
||||
而把“a+3”中的a,跟正确的变量定义关联的过程,就叫做**引用消解**(Resolve)。这个时候,变量a的语义才算是清晰了。
|
||||
|
||||
变量有点像自然语言里的代词,比如说,“我喜欢又聪明又勇敢的你”中的“我”和“你”,指的是谁呢?如果这句话前面有两句话,“我是春娇,你是志明”,那这句话的意思就比较清楚了,是“春娇喜欢又聪明又勇敢的志明”。
|
||||
|
||||
引用消解需要在上下文中查找某个标识符的定义与引用的关系,所以我们现在可以回答前面的问题了,**语义分析的重要特点,就是做上下文相关的分析。**
|
||||
|
||||
在语义分析阶段,编译器还会识别出数据的类型。比如,在计算“a+3”的时候,我们必须知道a和3的类型是什么。因为**即使同样是加法运算,对于整型和浮点型数据,其计算方法也是不一样的。**
|
||||
|
||||
语义分析获得的一些信息(引用消解信息、类型信息等),会附加到AST上。这样的AST叫做**带有标注信息的AST**(Annotated AST/Decorated AST),用于更全面地反映源代码的含义。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/36/b7/3685791207d4430e5a48bc7d96aa99b7.jpg" alt="">
|
||||
|
||||
好了,前面我所说的,都是如何让编译器更好地理解程序的语义。不过在语义分析阶段,编译器还要做很多语义方面的检查工作。
|
||||
|
||||
在自然语言里,我们可以很容易写出一个句子,它在语法上是正确的,但语义上是错误的。比如,“小猫喝水”这句话,它在语法和语义上都是对的;而“水喝小猫”这句话,语法是对的,语义上则是不对的。
|
||||
|
||||
计算机程序也会存在很多类似的语义错误的情况。比如说,对于“int b = a+3”的这个语句,语义规则要求,等号右边的表达式必须返回一个整型的数据(或者能够自动转换成整型的数据),否则就跟变量b的类型不兼容。如果右边的表达式“a+3”的计算结果是浮点型的,就违背了语义规则,就要报错。
|
||||
|
||||
总结起来,在语义分析阶段,编译器会做**语义理解和语义检查**这两方面的工作。词法分析、语法分析和语义分析,统称编译器的**前端**,它完成的是对源代码的理解工作。
|
||||
|
||||
**做完语义分析以后,接下来编译器要做什么呢?**
|
||||
|
||||
本质上,编译器这时可以直接生成目标代码,因为编译器已经完全理解了程序的含义,并把它表示成了带有语义信息的AST、符号表等数据结构。
|
||||
|
||||
**生成目标代码的工作,叫做后端工作**。做这项工作有一个前提,就是编译器需要懂得目标语言,也就是懂得目标语言的词法、语法和语义,这样才能保证翻译的准确性。这是显而易见的,只懂英语,不懂汉语,是不可能做英译汉的。通常来说,目标代码指的是汇编代码,它是汇编器(Assembler)所能理解的语言,跟机器码有直接的对应关系。汇编器能够将汇编代码转换成**机器码**。
|
||||
|
||||
熟练掌握汇编代码对于初学者来说会有一定的难度。但更麻烦的是,对于不同架构的CPU,还需要生成不同的汇编代码,这使得我们的工作量更大。所以,我们通常要在这个时候增加一个环节:先翻译成中间代码(Intermediate Representation,IR)。
|
||||
|
||||
## 中间代码(Intermediate Representation)
|
||||
|
||||
中间代码(IR),是处于源代码和目标代码之间的一种表示形式。
|
||||
|
||||
我们倾向于使用IR有两个原因。
|
||||
|
||||
第一个原因,是很多解释型的语言,可以直接执行IR,比如Python和Java。这样的话,编译器生成IR以后就完成任务了,没有必要生成最终的汇编代码。
|
||||
|
||||
第二个原因更加重要。我们生成代码的时候,需要做大量的优化工作。而很多优化工作没有必要基于汇编代码来做,而是可以基于IR,用统一的算法来完成。
|
||||
|
||||
## 优化(Optimization)
|
||||
|
||||
**那为什么需要做优化工作呢?**这里又有两大类的原因。
|
||||
|
||||
**第一个原因,是源语言和目标语言有差异。**源语言的设计目的是方便人类表达和理解,而目标语言是为了让机器理解。在源语言里很复杂的一件事情,到了目标语言里,有可能很简单地就表达出来了。
|
||||
|
||||
比如“I want to hold your hand and with you I will grow old.” 这句话挺长的吧?用了13个单词,但它实际上是诗经里的“执子之手,与子偕老”对应的英文。这样看来,还是中国文言文承载信息的效率更高。
|
||||
|
||||
同样的情况在编程语言里也有。以Java为例,我们经常为某个类定义属性,然后再定义获取或修改这些属性的方法:
|
||||
|
||||
```
|
||||
Class Person{
|
||||
private String name;
|
||||
public String getName(){
|
||||
return name;
|
||||
}
|
||||
public void setName(String newName){
|
||||
this.name = newName
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果你在程序里用“**person.getName()**”来获取Person的name字段,会是一个开销很大的操作,因为它涉及函数调用。在汇编代码里,实现一次函数调用会做下面这一大堆事情:
|
||||
|
||||
```
|
||||
#调用者的代码
|
||||
保存寄存器1 #保存现有寄存器的值到内存
|
||||
保存寄存器2
|
||||
...
|
||||
保存寄存器n
|
||||
|
||||
把返回地址入栈
|
||||
把person对象的地址写入寄存器,作为参数
|
||||
跳转到getName函数的入口
|
||||
|
||||
#_getName 程序
|
||||
在person对象的地址基础上,添加一个偏移量,得到name字段的地址
|
||||
从该地址获取值,放到一个用于保存返回值的寄存器
|
||||
跳转到返回地
|
||||
|
||||
```
|
||||
|
||||
你看了这段伪代码,就会发现,简单的一个**getName()方法**,开销真的很大。保存和恢复寄存器的值、保存和读取返回地址,等等,这些操作会涉及好几次读写内存的操作,要花费大量的时钟周期。但这个逻辑其实是可以简化的。
|
||||
|
||||
怎样简化呢?就是**跳过方法的调用**。我们直接根据对象的地址计算出name属性的地址,然后直接从内存取值就行。这样优化之后,性能会提高好多倍。
|
||||
|
||||
这种优化方法就叫做**内联**(inlining),也就是把原来程序中的函数调用去掉,把函数内的逻辑直接嵌入函数调用者的代码中。在Java语言里,这种属性读写的代码非常多。所以,Java的JIT编译器(把字节码编译成本地代码)很重要的工作就是实现内联优化,这会让整体系统的性能提高很大的一个百分比!
|
||||
|
||||
总结起来,我们在把源代码翻译成目标代码的过程中,没有必要“直译”,而是可以“意译”。这样我们完成相同的工作,对资源的消耗会更少。
|
||||
|
||||
**第二个需要优化工作的原因,是程序员写的代码不是最优的,而编译器会帮你做纠正**。比如下面这段代码中的**bar()函数**,里面就有多个地方可以优化。甚至,整个对bar()函数的调用,也可以省略,因为bar()的值一定是101。这些优化工作都可以在编译期间完成。
|
||||
|
||||
```
|
||||
int bar(){
|
||||
int a = 10*10; //这里在编译时可以直接计算出100这个值,这叫做“常数折叠”
|
||||
int b = 20; //这个变量没有用到,可以在代码中删除,这叫做“死代码删除”
|
||||
|
||||
|
||||
if (a>0){ //因为a一定大于0,所以判断条件和else语句都可以去掉
|
||||
return a+1; //这里可以在编译器就计算出是101
|
||||
}
|
||||
else{
|
||||
return a-1;
|
||||
}
|
||||
}
|
||||
int a = bar(); //这里可以直接换成 a=101
|
||||
|
||||
```
|
||||
|
||||
综上所述,在生成目标代码之前,需要做的优化工作可以有很多,这通常也是编译器在运行时,花费时间最长的一个部分。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/26/82/26ad195418f2ce03155a625043348982.jpg" alt="">
|
||||
|
||||
而采用中间代码来编写优化算法的好处,是可以把大部分的优化算法,写成与具体CPU架构无关的形式,从而大大降低编译器适配不同CPU的工作量。并且,如果采用像LLVM这样的工具,我们还可以让多种语言的前端生成相同的中间代码,这样就可以复用中端和后端的程序了。
|
||||
|
||||
## 生成目标代码
|
||||
|
||||
编译器最后一个阶段的工作,是生成高效率的目标代码,也就是汇编代码。这个阶段,编译器也有几个重要的工作。
|
||||
|
||||
第一,是要选择合适的指令,生成性能最高的代码。
|
||||
|
||||
第二,是要优化寄存器的分配,让频繁访问的变量(比如循环变量)放到寄存器里,因为访问寄存器要比访问内存快100倍左右。
|
||||
|
||||
第三,是在不改变运行结果的情况下,对指令做重新排序,从而充分运用CPU内部的多个功能部件的并行计算能力。
|
||||
|
||||
目标代码生成以后,整个编译过程就完成了。
|
||||
|
||||
## 课程小结
|
||||
|
||||
本讲我从头到尾概要地讲解了编译的过程,希望你能了解每一个阶段存在的原因(Why),以及要完成的主要任务(What)。编译是一个比较复杂的过程,但如果我们能够分而治之,那么每一步的挑战就会降低很多。这样最后针对每个子任务,我们就都能找到解决的办法。
|
||||
|
||||
我希望这一讲能帮你在大脑里建立起一个概要的地图。在后面几讲中,我会对编译过程的各个环节展开讨论,让你有越来越清晰的理解。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/70/a5/706f4e0f50ab6ce77fd6246cb8cea0a5.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
你觉得做计算机语言的编译和自然语言的翻译,有哪些地方是相同的,哪些地方是不同的?
|
||||
|
||||
欢迎在留言区分享你的见解,也欢迎你把今天的内容分享给更多的朋友。感谢阅读,我们下一讲再见。
|
||||
222
极客时间专栏/编译原理实战课/预备知识篇/02 | 词法分析:用两种方式构造有限自动机.md
Normal file
222
极客时间专栏/编译原理实战课/预备知识篇/02 | 词法分析:用两种方式构造有限自动机.md
Normal file
@@ -0,0 +1,222 @@
|
||||
<audio id="audio" title="02 | 词法分析:用两种方式构造有限自动机" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2b/3e/2b367effc121c8d0eee6c9539acc493e.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
上一讲,我带你把整个编译过程走了一遍。这样,你就知道了编译过程的整体步骤,每一步是做什么的,以及为什么要这么做。
|
||||
|
||||
进一步地,你就可以研究一下每个环节具体是如何实现的、有哪些难点、有哪些理论和算法。通过这个过程,你不仅可以了解每个环节的原理,还能熟悉一些专有词汇。这样一来,你在读编译原理领域的相关资料时,就会更加顺畅了。
|
||||
|
||||
不过,编译过程中涉及的算法和原理有些枯燥,所以我会用尽量通俗、直观的方式来给你解读,让你更容易接受。
|
||||
|
||||
本讲,我主要跟你讨论一下词法分析(Lexical Analysis)这个环节。通过这节课,你可以掌握词法分析这个阶段是如何把字符串识别成一个个Token的。进而,你还会学到如何实现一个正则表达式工具,从而实现任意的词法解析。
|
||||
|
||||
## 词法分析的原理
|
||||
|
||||
首先,我们来了解一下词法分析的原理。
|
||||
|
||||
通过上一讲,你已经很熟悉词法分析的任务了:输入的是字符串,输出的是Token串。所以,词法分析器在英文中一般叫做Tokenizer。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d8/9a/d80623403c912ab43c328623df8a0e9a.jpg" alt="">
|
||||
|
||||
但具体如何实现呢?这里要有一个计算模型,叫做**有限自动机**(Finite-state Automaton,FSA),或者叫做有限状态自动机(Finite-state Machine,FSM)。
|
||||
|
||||
有限自动机这个名字,听上去可能比较陌生。但大多数程序员,肯定都接触过另一个词:**状态机**。假设你要做一个电商系统,那么订单状态的迁移,就是一个状态机。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/91/62/91093cbeaeb516a9b7bb7b393a53dc62.jpg" alt="">
|
||||
|
||||
有限自动机就是这样的状态机,它的状态数量是有限的。当它收到一个新字符的时候,会导致状态的迁移。比如说,下面的这个状态机能够区分标识符和数字字面量。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ac/14/ac4f8932b2488ad0f3815853a4180114.jpg" alt="">
|
||||
|
||||
在这样一个状态机里,我用单线圆圈表示临时状态,双线圆圈表示接受状态。接受状态就是一个合格的Token,比如图3中的状态1(数字字面量)和状态2(标识符)。当这两个状态遇到空白字符的时候,就可以记下一个Token,并回到初始态(状态0),开始识别其他Token。
|
||||
|
||||
可以看出,**词法分析的过程,其实就是对一个字符串进行模式匹配的过程。**说起字符串的模式匹配,你能想到什么工具吗?对的,**正则表达式工具**。
|
||||
|
||||
大多数语言,以及一些操作系统的命令,都带有正则表达式工具,来帮助你匹配合适的字符串。比如下面的这个Linux命令,可以用来匹配所有包含“sa”“sb” … “sh”字符串的进程。
|
||||
|
||||
```
|
||||
ps -ef | grep 's[a-h]'
|
||||
|
||||
```
|
||||
|
||||
在这个命令里,“**s[a-h]**”是用来描述匹配规则的,我们把它叫做一个**正则表达式**。
|
||||
|
||||
同样地,正则表达式也可以用来描述词法规则。这种描述方法,我们叫做**正则文法**(Regular Grammar)。比如,数字字面量和标识符的正则文法描述是这样的:
|
||||
|
||||
```
|
||||
IntLiteral : [0-9]+; //至少有一个数字
|
||||
Id : [A-Za-z][A-Za-z0-9]*; //以字母开头,后面可以是字符或数字
|
||||
|
||||
```
|
||||
|
||||
与普通的正则表达式工具不同的是,词法分析器要用到很多个词法规则,每个词法规则都采用**“Token类型: 正则表达式”**这样一种格式,用于匹配一种Token。
|
||||
|
||||
然而,当我们采用了多条词法规则的时候,有可能会出现词法规则冲突的情况。比如说,int关键字其实也是符合标识符的词法规则的。
|
||||
|
||||
```
|
||||
Int : int; //int关键字
|
||||
For : for; //for关键字
|
||||
Id : [A-Za-z][A-Za-z0-9]*; //以字母开头,后面可以是字符或数字
|
||||
|
||||
```
|
||||
|
||||
所以,词法规则里面要有优先级,比如排在前面的词法规则优先级更高。这样的话,我们就能够设计出区分int关键字和标识符的有限自动机了,可以画成下面的样子。其中,状态1、2和3都是标识符,而状态4则是int关键字。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/31/1c/31d083e8e33b25c1b6ea721f20e7ce1c.jpg" alt="">
|
||||
|
||||
## 从正则表达式生成有限自动机
|
||||
|
||||
现在,你已经了解了如何构造有限自动机,以及如何处理词法规则的冲突。基本上,你就可以按照上面的思路来手写词法分析器了。但你可能觉得,这样手写词法分析器的步骤太繁琐了,**我们能否只写出词法规则,就自动生成相对应的有限自动机呢?**
|
||||
|
||||
当然是可以的,实际上,正则表达式工具就是这么做的。此外,词法分析器生成工具lex(及GNU版本的flex)也能够基于规则自动生成词法分析器。
|
||||
|
||||
它的具体实现思路是这样的:**把一个正则表达式翻译成NFA,然后把NFA转换成DFA。**对不起,我这里又引入了两个新的术语:NFA和DFA。
|
||||
|
||||
先说说**DFA**,它是“Deterministic Finite Automaton”的缩写,即**确定的有限自动机**。它的特点是:该状态机在任何一个状态,基于输入的字符,都能做一个确定的状态转换。前面例子中的有限自动机,都属于DFA。
|
||||
|
||||
再说说**NFA**,它是“Nondeterministic Finite Automaton”的缩写,即**不确定的有限自动机**。它的特点是:该状态机中存在某些状态,针对某些输入,不能做一个确定的转换。
|
||||
|
||||
这又细分成两种情况:
|
||||
|
||||
1. 对于一个输入,它有两个状态可以转换。
|
||||
1. 存在ε转换的情况,也就是没有任何字符输入的情况下,NFA也可以从一个状态迁移到另一个状态。
|
||||
|
||||
比如,“a[a-zA-Z0-9]*bc”这个正则表达式,对字符串的要求是以a开头,以bc结尾,a和bc之间可以有任意多个字母或数字。可以看到,在图5中,状态1的节点输入b时,这个状态是有两条路径可以选择的:一条是迁移到状态2,另一条是仍然保持在状态1。所以,这个有限自动机是一个NFA。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/41/d6/41e29db09305197dda72697e97aa83d6.jpg" alt="">
|
||||
|
||||
这个NFA还有引入ε转换的画法,如图6所示,它跟图5的画法是等价的。实际上,图6表示的NFA可以用我们下面马上要讲到的算法,通过正则表达式自动生成出来。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/47/9a/4776f1393ea7d700668f42a6065e059a.jpg" alt="">
|
||||
|
||||
需要注意的是,无论是NFA还是DFA,都等价于正则表达式。也就是说,**所有的正则表达式都能转换成NFA或DFA;而所有的NFA或DFA,也都能转换成正则表达式。**
|
||||
|
||||
理解了NFA和DFA以后,接下来我再大致说一下算法。
|
||||
|
||||
首先,一个正则表达式可以机械地翻译成一个NFA。它的翻译方法如下:
|
||||
|
||||
- **识别字符i的NFA。**
|
||||
|
||||
当接受字符i的时候,引发一个转换,状态图的边上标注i。其中,第一个状态(i,initial)是初始状态,第二个状态(f,final)是接受状态。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/13/37/13c0df37440d701b4ec1484dd900ec37.jpg" alt="">
|
||||
|
||||
- **转换“s|t”这样的正则表达式。**
|
||||
|
||||
它的意思是,或者s,或者t,二者选一。s和t本身是两个子表达式,我们可以增加两个新的状态:开始状态和接受状态。然后,用ε转换分别连接代表s和t的子图。它的含义也比较直观,要么走上面这条路径,那就是s,要么走下面这条路径,那就是t:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b7/31/b7c2a649036b35b45b1f2af597b45e31.jpg" alt="">
|
||||
|
||||
- **转换“st”这样的正则表达式。**
|
||||
|
||||
s之后接着出现t,转换规则是把s的开始状态变成st整体的开始状态,把t的结束状态变成st整体的结束状态,并且把s的结束状态和t的开始状态合二为一。这样就把两个子图衔接了起来,走完s接着走t。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f5/03/f52985c394f156ff477f91a8b7f83303.jpg" alt="">
|
||||
|
||||
- **对于“?”“*”和“+”这样的符号,它们的意思是可以重复0次、0到多次、1到多次,转换时要增加额外的状态和边**。以“s*”为例,我们可以做下面的转换:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5b/09/5bb1470f0a07b6decfc8ac4fdbf5eb09.jpg" alt="">
|
||||
|
||||
你能看出,它可以从i直接到f,也就是对s匹配0次,也可以在s的起止节点上循环多次。
|
||||
|
||||
如果是“s+”,那就没有办法跳过s,s至少要经过一次:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/54/de/543966ed03d9b25cec569d80f8b304de.jpg" alt="">
|
||||
|
||||
通过这样的转换,所有的正则表达式,都可以转换为一个NFA。
|
||||
|
||||
基于NFA,你仍然可以实现一个词法分析器,只不过算法会跟基于DFA的不同:当某个状态存在一条以上的转换路径的时候,你要先尝试其中的一条;如果匹配不上,再退回来,尝试其他路径。这种试探不成功再退回来的过程,叫做**回溯(Backtracking)**。
|
||||
|
||||
小提示:下一讲的**递归下降算法**里,也会出现回溯现象,你可以对照着理解。
|
||||
|
||||
基于NFA,你也可以写一个正则表达式工具。实际上,我在示例程序中已经写了一个简单的正则表达式工具,使用了[Regex.java](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/16-18/src/main/java/play/parser/Regex.java)中的**regexToNFA方法**。如下所示,我用了一个测试用的正则表达式,它能识别int关键字、标识符和数字字面量。在示例程序中,这个正则表达式首先被表示为一个内部的树状数据结构,然后可以转换成NFA。
|
||||
|
||||
```
|
||||
int | [a-zA-Z][a-zA-Z0-9]* | [0-9]*
|
||||
|
||||
```
|
||||
|
||||
示例程序也会将生成的NFA打印输出,下面的输出结果中列出了所有的状态,以及每个状态到其他状态的转换,比如“0 ε -> 2”的意思是从状态0通过 ε 转换,到达状态2 :
|
||||
|
||||
```
|
||||
NFA states:
|
||||
0 ε -> 2
|
||||
ε -> 8
|
||||
ε -> 14
|
||||
2 i -> 3
|
||||
3 n -> 5
|
||||
5 t -> 7
|
||||
7 ε -> 1
|
||||
1 (end)
|
||||
acceptable
|
||||
8 [a-z]|[A-Z] -> 9
|
||||
9 ε -> 10
|
||||
ε -> 13
|
||||
10 [0-9]|[a-z]|[A-Z] -> 11
|
||||
11 ε -> 10
|
||||
ε -> 13
|
||||
13 ε -> 1
|
||||
14 [0-9] -> 15
|
||||
15 ε -> 14
|
||||
ε -> 1
|
||||
|
||||
```
|
||||
|
||||
我用图片来直观展示一下输出结果,分为上、中、下三条路径,你能清晰地看出解析int关键字、标识符和数字字面量的过程:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/38/71/38332d3ad4bbedc1979f5fe2936efc71.jpg" alt="">
|
||||
|
||||
**那么生成NFA之后,我们要如何利用它,来识别某个字符串是否符合这个NFA代表的正则表达式呢?**
|
||||
|
||||
还是以图12为例,当我们解析“intA”这个字符串时,首先选择最上面的路径进行匹配,匹配完int这三个字符以后,来到状态7,若后面没有其他字符,就可以到达接受状态1,返回匹配成功的信息。
|
||||
|
||||
**可实际上,int后面是有A的,所以第一条路径匹配失败。**失败之后不能直接返回“匹配失败”的结果,因为还有其他路径,所以我们要回溯到状态0,去尝试第二条路径,在第二条路径中,我们尝试成功了。
|
||||
|
||||
运行Regex.java中的**matchWithNFA()方法**,你可以用NFA来做正则表达式的匹配。其中,在匹配“intA”时,你会看到它的回溯过程:
|
||||
|
||||
```
|
||||
NFA matching: 'intA'
|
||||
trying state : 0, index =0
|
||||
trying state : 2, index =0 //先走第一条路径,即int关键字这个路径
|
||||
trying state : 3, index =1
|
||||
trying state : 5, index =2
|
||||
trying state : 7, index =3
|
||||
trying state : 1, index =3 //到了末尾,发现还有字符'A'没有匹配上
|
||||
trying state : 8, index =0 //回溯,尝试第二条路径,即标识符
|
||||
trying state : 9, index =1
|
||||
trying state : 10, index =1 //在10和11这里循环多次
|
||||
trying state : 11, index =2
|
||||
trying state : 10, index =2
|
||||
trying state : 11, index =3
|
||||
trying state : 10, index =3
|
||||
true
|
||||
|
||||
```
|
||||
|
||||
**从中你可以看到用NFA算法的特点**:因为存在多条可能的路径,所以需要试探和回溯,在比较极端的情况下,回溯次数会非常多,性能会变得非常差。特别是当处理类似“s*”这样的语句时,因为s可以重复0到无穷次,所以在匹配字符串时,可能需要尝试很多次。
|
||||
|
||||
NFA的运行可能导致大量的回溯,**那么能否将NFA转换成DFA,让字符串的匹配过程更简单呢?**如果能的话,那整个过程都可以自动化,从正则表达式到NFA,再从NFA到DFA。
|
||||
|
||||
方法是有的,这个算法就是**子集构造法**。不过我这里就不展开介绍了,如果你想继续深入学习的话,可以去看看本讲最后给出的参考资料。
|
||||
|
||||
总之,只要有了准确的正则表达式,是可以根据算法自动生成对字符串进行匹配的程序的,这就是正则表达式工具的基本原理,也是有些工具(比如ANTLR和flex)能够自动给你生成一个词法分析器的原理。
|
||||
|
||||
## 课程小结
|
||||
|
||||
本讲涵盖了词法分析所涉及的主要知识点。词法分析跟你日常使用的正则表达式关系很密切,你可以用正则表达式来表示词法规则。
|
||||
|
||||
在实际的编译器中,词法分析器一般都是手写的,依据的基本原理就是构造有限自动机。不过有一些地方也会用手工编码的方式做一些优化(如javac编译器),有些编译器会做用一些特别的技巧来提升解析速度(如JavaScript的V8编译器),你在后面的课程中会看到。
|
||||
|
||||
基于正则表达式构造NFA,再去进行模式匹配,是一个很好的算法思路,它不仅仅可以用于做词法分析,其实还可以用于解决其他问题(比如做语法分析),值得你去做举一反三的思考。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/90/bb/90af03a17ffc5fb441327198a753ecbb.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
你可以试着写出识别整型字面量和浮点型字面量的词法规则,手工构造一个有限自动机。
|
||||
|
||||
欢迎在留言区谈谈你的实践体会,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
## 参考资料
|
||||
|
||||
关于从NFA转DFA的算法,你可以参考_Compilers - Principles, Techniques & Tools_(龙书,第2版)第3.7.1节,或者《编译原理之美》的[第16讲](https://time.geekbang.org/column/article/137286)。
|
||||
391
极客时间专栏/编译原理实战课/预备知识篇/03 | 语法分析:两个基本功和两种算法思路.md
Normal file
391
极客时间专栏/编译原理实战课/预备知识篇/03 | 语法分析:两个基本功和两种算法思路.md
Normal file
@@ -0,0 +1,391 @@
|
||||
<audio id="audio" title="03 | 语法分析:两个基本功和两种算法思路" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fd/b2/fde19baf67bb15e4abf77a5abcf43eb2.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
通过[第1讲](https://time.geekbang.org/column/article/242479)的学习,现在你已经清楚了语法分析阶段的任务:依据语法规则,把Token串转化成AST。
|
||||
|
||||
今天,我就带你来掌握语法分析阶段的核心知识点,也就是两个基本功和两种算法思路。理解了这些重要的知识点,对于语法分析,你就不是外行了。
|
||||
|
||||
- **两个基本功**:第一,必须能够阅读和书写语法规则,也就是掌握上下文无关文法;第二,必须要掌握递归下降算法。
|
||||
- **两种算法思路**:一种是自顶向下的语法分析,另一种则是自底向上的语法分析。
|
||||
|
||||
## 上下文无关文法(Context-Free Grammar)
|
||||
|
||||
在开始语法分析之前,我们要解决的第一个问题,就是**如何表达语法规则**。在上一讲中,你已经了解了,我们可以用正则表达式来表达词法规则,语法规则其实也差不多。
|
||||
|
||||
我还是以下面这个示例程序为例,里面用到了变量声明语句、加法表达式,我们看看语法规则应该怎么写:
|
||||
|
||||
```
|
||||
int a = 2;
|
||||
int b = a + 3;
|
||||
return b;
|
||||
|
||||
```
|
||||
|
||||
第一种写法是下面这个样子,它看起来跟上一讲的词法规则差不多,都是左边是规则名称,右边是正则表达式。
|
||||
|
||||
```
|
||||
start:blockStmts ; //起始
|
||||
block : '{' blockStmts '}' ; //语句块
|
||||
blockStmts : stmt* ; //语句块中的语句
|
||||
stmt = varDecl | expStmt | returnStmt | block; //语句
|
||||
varDecl : type Id varInitializer? ';' ; //变量声明
|
||||
type : Int | Long ; //类型
|
||||
varInitializer : '=' exp ; //变量初始化
|
||||
expStmt : exp ';' ; //表达式语句
|
||||
returnStmt : Return exp ';' ; //return语句
|
||||
exp : add ; //表达式
|
||||
add : add '+' mul | mul; //加法表达式
|
||||
mul : mul '*' pri | pri; //乘法表达式
|
||||
pri : IntLiteral | Id | '(' exp ')' ; //基础表达式
|
||||
|
||||
```
|
||||
|
||||
在语法规则里,我们把冒号左边的叫做**非终结符**(Non-terminal),又叫**变元**(Variable)。非终结符可以按照右边的正则表达式来逐步展开,直到最后都变成标识符、字面量、运算符这些不可再展开的符号,也就是**终结符**(Terminal)。终结符其实也是词法分析过程中形成的Token。
|
||||
|
||||
提示:<br>
|
||||
1.在本课程,非终结符以小写字母开头,终结符则以大写字母开头,或者是一个原始的字符串格式。<br>
|
||||
2.在谈论语法分析的时候,我们可以把Token和终结符这两个术语互换使用。
|
||||
|
||||
像这样左边是非终结符,右边是正则表达式的书写语法规则的方式,就叫做**扩展巴科斯范式(EBNF)。**你在ANTLR这样的语法分析器生成工具中,经常会看到这种格式的语法规则。
|
||||
|
||||
对于EBNF的严格定义,你可以去参考[Wikipedia](https://zh.wikipedia.org/wiki/%E6%89%A9%E5%B1%95%E5%B7%B4%E7%A7%91%E6%96%AF%E8%8C%83%E5%BC%8F)上的解释。
|
||||
|
||||
在教科书中,我们还经常采用另一种写法,就是**产生式**(Production Rule),又叫做**替换规则**(Substitution Rule)。产生式的左边是非终结符(变元),它可以用右边的部分替代,中间通常会用箭头连接。
|
||||
|
||||
为了避免跟EBNF中的“*”号、“+”号等冲突,在本节课中,凡是采用EBNF格式,就给字符串格式的终结符加引号,左右两边用“::=”或冒号分隔开;凡是采用产生式,字符串就不加引号,并且采用“->”分隔产生式的左右两侧。
|
||||
|
||||
```
|
||||
add -> add + mul
|
||||
add -> mul
|
||||
mul -> mul * pri
|
||||
mul -> pri
|
||||
|
||||
```
|
||||
|
||||
也有个偷懒的写法,就是把同一个变元的多个产生式写在一起,用竖线分隔(但这时候,如果产生式里面原本就要用到“|”终结符,那么就要加引号来进行区分)。但也就仅此为止了,不会再引入“*”和“+”等符号,否则就成了EBNF了。
|
||||
|
||||
```
|
||||
add -> add + mul | mul
|
||||
mul -> mul * pri | pri
|
||||
|
||||
```
|
||||
|
||||
产生式不用“ * ”和“+”来表示重复,而是用迭代,并引入“ε”(空字符串)。所以“blockStmts : stmt*”可以写成下面这个样子:
|
||||
|
||||
```
|
||||
blockStmts -> stmt blockStmts | ε
|
||||
|
||||
```
|
||||
|
||||
总结起来,语法规则是由4个部分组成的:
|
||||
|
||||
- 一个有穷的非终结符(或变元)的集合;
|
||||
- 一个有穷的终结符的集合;
|
||||
- 一个有穷的产生式集合;
|
||||
- 一个起始非终结符(变元)。
|
||||
|
||||
那么符合这四个特点的文法规则,就叫做**上下文无关文法**(Context-Free Grammar,CFG)。
|
||||
|
||||
你可能会问,**上下文无关文法和词法分析中用到的正则文法是否有一定的关系?**
|
||||
|
||||
**是的,正则文法是上下文无关文法的一个子集。**其实,正则文法也可以写成产生式的格式。比如,数字字面量(正则表达式为“[0-9]+”)可以写成:
|
||||
|
||||
```
|
||||
IntLiteral -> Digit IntLiteral1
|
||||
IntLiteral1 -> Digit IntLiteral1
|
||||
IntLiteral1 -> ε
|
||||
Digit -> [0-9]
|
||||
|
||||
```
|
||||
|
||||
但是,在上下文无关文法里,产生式的右边可以放置任意的终结符和非终结符,而正则文法只是其中的一个子集,叫做**线性文法**(Linear Grammar)。它的特点是产生式的右边部分最多只有一个非终结符,比如X->aYb,其中a和b是终结符。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/99/29/99a69f477f20f1a4eae194116adb7829.jpg" alt="">
|
||||
|
||||
你可以试一下,把上一讲用到的正则表达式“a[a-zA-Z0-9]*bc”写成产生式的格式,它就符合线性文法的特点。
|
||||
|
||||
```
|
||||
S0 -> aS1bc
|
||||
S1 -> [a-zA-Z0-9]S1
|
||||
S1 -> ε
|
||||
|
||||
```
|
||||
|
||||
但对于常见的语法规则来说,正则文法是不够的。比如,你最常用的算术表达式的规则,就没法用正则文法表示,因为有的产生式需要包含两个非终结符(如“add + mul”)。你可以试试看,能把“2+3”“2+3*5”“2+3+4+5”等各种可能的算术表达式,用一个正则表达式写出来吗?实际是不可能的。
|
||||
|
||||
```
|
||||
add -> add + mul
|
||||
add -> mul
|
||||
mul -> mul * pri
|
||||
mul -> pri
|
||||
|
||||
```
|
||||
|
||||
好,现在你已经了解了上下文无关文法,以及它与正则文法的区别。可是,**为什么它会叫“上下文无关文法”这样一个奇怪的名字呢?难道还有上下文相关的文法吗?**
|
||||
|
||||
答案的确是有的。举个例子来说,在高级语言里,本地变量必须先声明,才能在后面使用。这种制约关系就是上下文相关的。
|
||||
|
||||
不过,在语法分析阶段,我们一般不管上下文之间的依赖关系,这样能使得语法分析的任务更简单。而对于上下文相关的情况,则放到语义分析阶段再去处理。
|
||||
|
||||
好了,现在你已经知道,用上下文无关文法可以描述程序的语法结构。学习编译原理,阅读和书写语法规则是一项基本功。针对高级语言中的各种语句,你要都能够手写出它们的语法规则来才可以。
|
||||
|
||||
接下来,我们就要**依据语法规则,编写语法分析程序,把Token串转化成AST。**语法分析的算法有很多,但有一个算法也是你必须掌握的一项基本功,这就是递归下降算法。
|
||||
|
||||
## 递归下降算法(Recursive Descent Parsing)
|
||||
|
||||
递归下降算法其实很简单,它的基本思路就是按照语法规则去匹配Token串。比如说,变量声明语句的规则如下:
|
||||
|
||||
```
|
||||
varDecl : types Id varInitializer? ';' ; //变量声明
|
||||
varInitializer : '=' exp ; //变量初始化
|
||||
exp : add ; //表达式
|
||||
add : add '+' mul | mul; //加法表达式
|
||||
mul : mul '*' pri | pri; //乘法表达式
|
||||
pri : IntLiteral | Id | '(' exp ')' ; //基础表达式
|
||||
|
||||
```
|
||||
|
||||
如果写成产生式格式,是下面这样:
|
||||
|
||||
```
|
||||
varDecl -> types Id varInitializer ';'
|
||||
varInitializer -> '=' exp
|
||||
varInitializer -> ε
|
||||
exp -> add
|
||||
add -> add + mul
|
||||
add -> mul
|
||||
mul -> mul * pri
|
||||
mul -> pri
|
||||
pri -> IntLiteral
|
||||
pri -> Id
|
||||
pri -> ( exp )
|
||||
|
||||
```
|
||||
|
||||
而基于这个规则做解析的算法如下:
|
||||
|
||||
```
|
||||
匹配一个数据类型(types)
|
||||
匹配一个标识符(Id),作为变量名称
|
||||
匹配初始化部分(varInitializer),而这会导致下降一层,使用一个新的语法规则:
|
||||
匹配一个等号
|
||||
匹配一个表达式(在这个步骤会导致多层下降:exp->add->mul->pri->IntLiteral)
|
||||
创建一个varInitializer对应的AST节点并返回
|
||||
如果没有成功地匹配初始化部分,则回溯,匹配ε,也就是没有初始化部分。
|
||||
匹配一个分号
|
||||
创建一个varDecl对应的AST节点并返回
|
||||
|
||||
```
|
||||
|
||||
用上述算法解析“int a = 2”,就会生成下面的AST:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/31/ed/3102dff3c43e5bcd40ddf6442947dced.jpg" alt="">
|
||||
|
||||
那么总结起来,递归下降算法的特点是:
|
||||
|
||||
- 对于一个非终结符,要从左到右依次匹配其产生式中的每个项,包括非终结符和终结符。
|
||||
- 在匹配产生式右边的非终结符时,要下降一层,继续匹配该非终结符的产生式。
|
||||
- 如果一个语法规则有多个可选的产生式,那么只要有一个产生式匹配成功就行。如果一个产生式匹配不成功,那就回退回来,尝试另一个产生式。这种回退过程,叫做**回溯**(Backtracking)。
|
||||
|
||||
所以说,递归下降算法是非常容易理解的。它能非常有效地处理很多语法规则,但是它也有两个缺点。
|
||||
|
||||
**第一个缺点,就是著名的左递归(Left Recursion)问题。**比如,在匹配算术表达式时,产生式的第一项就是一个非终结符add,那么按照算法,要下降一层,继续匹配add。这个过程会一直持续下去,无限递归下去。
|
||||
|
||||
```
|
||||
add -> add + mul
|
||||
|
||||
```
|
||||
|
||||
所以,递归下降算法是无法处理左递归问题的。那么有什么解决办法吗?
|
||||
|
||||
你可能会说,把产生式改成右递归不就可以了吗?也就是add这个递归项在右边:
|
||||
|
||||
```
|
||||
add -> mul + add
|
||||
|
||||
```
|
||||
|
||||
这样确实可以避免左递归问题,但它同时也会导致**结合性**的问题。
|
||||
|
||||
举个例子来说,我们按照上面的语法规则来解析“2+3+4”这个表达式,会形成如下所示的AST。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/08/20/08df3cff28b8b53a4b3dd1e30d282820.jpg" alt="">
|
||||
|
||||
它会先计算“3+4”,而不是先计算“2+3”。这破坏了加法的结合性规则,加法运算本来应该是左结合的。
|
||||
|
||||
其实有一个标准的方法,能避免左递归问题。我们可以改写原来的语法规则,也就是引入`add'`,把左递归变成右递归:
|
||||
|
||||
```
|
||||
add -> mul add'
|
||||
add' -> + mul add' | ε
|
||||
|
||||
```
|
||||
|
||||
接下来,我们用刚刚改写的规则再次解析一下 “2+3+4”这个表达式,会得到下图中的AST:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/86/46/861f47308498c402dfab6798c3b7d246.jpg" alt="">
|
||||
|
||||
你能看出,这种改写方法虽然能够避免左递归问题,但由于`add'`的规则是右递归的,采用标准的递归下降算法,仍然会出现运算符结合性的错误。那么针对这点,我们有没有解决办法呢?
|
||||
|
||||
有的,方法就是**把递归调用转化成循环**。这里利用了很多同学都知道的一个原理,即递归调用可以转化为循环。
|
||||
|
||||
其实我把上面的规则换成用EBNF方式来表达就很清楚了。在EBNF格式里,允许用“*”号和“+”号表示重复:
|
||||
|
||||
```
|
||||
add : mul ('+' mul)* ;
|
||||
|
||||
```
|
||||
|
||||
所以说,对于`('+'mul)*`这部分,我们其实可以写成一个循环。而在循环里,我们可以根据结合性的要求,手工生成正确的AST。它的伪代码如下:
|
||||
|
||||
```
|
||||
左子节点 = 匹配一个mul
|
||||
while(下一个Token是+){
|
||||
消化掉+
|
||||
右子节点 = 匹配一个mul
|
||||
用左、右子节点创建一个add节点
|
||||
左子节点 = 该add节点
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
采用上面的算法,就可以创建正确的AST,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fd/5b/fdf3da5d525ddd949e318b1a6fa5895b.jpg" alt="">
|
||||
|
||||
**递归下降算法的第二个缺点,就是当产生式匹配失败的时候,必须要“回溯”,这就可能导致浪费。**
|
||||
|
||||
这个时候,我们有个针对性的解决办法,就是预读后续的一个Token,判断该选择哪个产生式。
|
||||
|
||||
以stmt变元为例,考虑它的三个产生式,分别是变量声明语句、表达式语句和return语句。那么在递归下降算法中,我们可以在这里预读一个Token,看看能否根据这个Token来选择某个产生式。
|
||||
|
||||
经过仔细观察,你发现如果预读的Token是Int或Long,就选择变量声明语句;如果是IntLiteral、Id或左括号,就选择表达式语句;而如果是Return,则肯定是选择return语句。因为这三个语句开头的Token是不重叠的,所以你可以很明确地做出选择。
|
||||
|
||||
如果我们手写递归下降算法,可以用肉眼识别出每次应该基于哪个Token,选择用哪个产生式。但是,对于一些比较复杂的语法规则,我们要去看好几层规则,这样比较辛苦。
|
||||
|
||||
**那么能否有一个算法,来自动计算出选择不同产生式的依据呢?**当然是有的,这就是LL算法家族。
|
||||
|
||||
## LL算法:计算First和Follow集合
|
||||
|
||||
LL算法的要点,就是计算First和Follow集合。
|
||||
|
||||
**First集合是每个产生式开头可能会出现的Token的集合。**就像stmt有三个产生式,它的First集合如下表所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/63/b3/6316103438404e64f89e402ef28498b3.jpg" alt="">
|
||||
|
||||
而stmt的First集合,就是三个产生式的First集合的并集,也是Int Long IntLiteral Id ( Return。
|
||||
|
||||
总体来说,针对非终结符x,它的First集合的计算规则是这样的:
|
||||
|
||||
- 如果产生式以终结符开头,那么把这个终结符加入First(x);
|
||||
- 如果产生式以非终结符y开头,那么把First(y)加入First(x);
|
||||
- 如果First(y)包含ε,那要把下一个项的First集合也加入进来,以此类推;
|
||||
- 如果x有多个产生式,那么First(x)是每个产生式的并集。
|
||||
|
||||
在计算First集合的时候,具体可以采用“**不动点法**”。相关细节这里就不展开了,你可以参考示例程序[FirstFollowSet](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/16-18/src/main/java/play/parser/FirstFollowSet.java)类的CalcFirstSets()方法,运行示例程序能打印各个非终结符的First集合。
|
||||
|
||||
不过,这样是不是就万事大吉了呢?
|
||||
|
||||
其实还有一种特殊情况我们需要考虑,那就是对于某个非终结符,它自身会产生ε的情况。比如说,示例文法中的blockStmts,它是可能产生ε的,也就是块中一个语句都没有。
|
||||
|
||||
```
|
||||
block : '{' blockStmts '}' ; //语句块
|
||||
blockStmts : stmt* ; //语句块中的语句
|
||||
stmt = varDecl | expStmt | returnStmt; //语句
|
||||
|
||||
```
|
||||
|
||||
语法解析器在这个时候预读的下一个Token是什么呢?是右花括号。这证明blockStmts产生了ε,所以才读到了后续跟着的花括号。
|
||||
|
||||
**对于某个非终结符后面可能跟着的Token的集合,我们叫做Follow集合。**如果预读到的Token在Follow中,那么我们就可以判断当前正在匹配的这个非终结符,产生了ε。
|
||||
|
||||
Follow的算法也比较简单,以非终结符x为例:
|
||||
|
||||
- 扫描语法规则,看看x后面都可能跟着哪些符号;
|
||||
- 对于后面跟着的终结符,都加到Follow(x)集合中去;
|
||||
- 如果后面是非终结符y,就把First(y)加Follow(x)集合中去;
|
||||
- 最后,如果First(y)中包含ε,就继续往后找;
|
||||
- 如果x可能出现在程序结尾,那么要把程序的终结符$加入到Follow(x)中去。
|
||||
|
||||
这样在计算了First和Follow集合之后,你就可以通过预读一个Token,来完全确定采用哪个产生式。这种算法,就叫做**LL(1)算法**。
|
||||
|
||||
LL(1)中的第一个L,是Left-to-right的缩写,代表从左向右处理Token串。第二个L,是Leftmost的缩写,意思是最左推导。**最左推导是什么呢?**就是它总是先把产生式中最左侧的非终结符展开完毕以后,再去展开下一个。这也就相当于对AST从左子节点开始的深度优先遍历。LL(1)中的1,指的是预读一个Token。
|
||||
|
||||
## LR算法:移进和规约
|
||||
|
||||
前面讲的递归下降和LL算法,都是自顶向下的算法。还有一类算法,是自底向上的,其中的代表就是**LR算法**。
|
||||
|
||||
自顶向下的算法,是从根节点逐层往下分解,形成最后的AST;而LR算法的原理呢,则是从底下先拼凑出AST的一些局部拼图,并逐步组装成一棵完整的AST。**所以,其中的关键之处在于如何“拼凑”。**
|
||||
|
||||
假设我们采用下面的上下文无关文法,来推演一个实例,具体语法规则如下所示:
|
||||
|
||||
```
|
||||
start->add
|
||||
add->add+mul
|
||||
add->mul
|
||||
mul->mul*pri
|
||||
mul->pri
|
||||
pri->Int
|
||||
pri->(add)
|
||||
|
||||
```
|
||||
|
||||
如果用于解析“2+3*5”,最终会形成下面的AST:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c7/8e/c7dc8d39cdbd32785e785c53e21e738e.jpg" alt="">
|
||||
|
||||
那算法是怎么从底部凑出这棵AST来的呢?
|
||||
|
||||
LR算法和LL算法一样,也是从左到右地消化掉Token。在第1步,它会取出“2”这个Token,放到一个栈里,这个栈是用来组装AST的工作区。同时,它还会预读下一个Token,也就是“+”号,用来帮助算法做判断。
|
||||
|
||||
在下面的示意图里,我画了一条橙色竖线,竖线的左边是栈,右边是预读到的一个Token。在做语法解析的过程中,竖线会不断地往右移动,把Token放到栈里,这个过程叫做“**移进**”(Shift)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6c/97/6c6ecc3391053afdbff110a4ada60d97.jpg" alt="">
|
||||
|
||||
注意,我在图7中还用虚线框推测了AST的其他部分。也就是说,如果第一个Token遇到的是整型字面量,而后面跟着一个+号,那么这两个Token就决定了它们必然是这棵推测出来的AST的一部分。而图中右边就是它的推导过程,其中的每个步骤,都使用了一个产生式加了一个点(如“.add”)。这个点,就相当于图中左边的橙色竖线。
|
||||
|
||||
所以你就可以根据这棵假想的AST,也就是依据假想的推导过程,给它反推回去。把Int还原为pri。这个还原过程,就叫做“**规约**”(Reduce)。工作区里的元素也随之更新成pri。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a1/81/a1189673c2bc6964b43f32b7db2aa781.jpg" alt="">
|
||||
|
||||
按照这样的思路,不断地移进和规约,这棵AST中推测出来的节点会不断地被证实。而随着读入的Token越来越多,这棵AST也会长得越来越高,整棵树变得更大。下图是推导过程中间的一个步骤。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/41/82/412af5303189b7c80b0de5960cf85982.jpg" alt="">
|
||||
|
||||
最后,整个AST构造完毕,而工作区里也就只剩了一个Start节点。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5a/b7/5ae146b8f2b622741daff8ca97f995b7.jpg" alt="">
|
||||
|
||||
通过上面的介绍,你应该已经建立了对LR算法的直觉认识。如果要把这个推导过程写成严密的算法,你可以参考《编译原理之美》的[第18讲](https://time.geekbang.org/column/article/139628)。
|
||||
|
||||
从示例中,你应该已经看出来了,相对于LL算法,LR算法的优点是能够处理左递归文法。但它也有缺点,比如不利于输出全面的编译错误信息。因为在没有解析完毕之前,算法并不知道最后的AST是什么样子,所以也不清楚当前的语法错误在整体AST中的位置。
|
||||
|
||||
最后我再提一下LR的意思,来帮你更完整地理解LR算法。L还是代表从左到右读入Token,而R是最右推导(Rightmost)的意思。我把“2+3*5”最右推导的过程写在了下面,而如果你从最后一行往前一步步地看,它恰好就是规约的过程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ec/6f/ec26627ee1ca27094227c53a42d7476f.jpg" alt="">
|
||||
|
||||
如果你见到LR(k),那它的意思就是会预读k个Token,我们在示例中采用的是LR(1)。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天花了一讲的时间,把语法分析的要点给你讲解了一下。
|
||||
|
||||
对于上下文无关的文法,你要知道产生式、非终结符、终结符、EBNF这几个基本概念,能够熟练阅读各种语言的语法规则,这是一个基本功。
|
||||
|
||||
递归下降算法是另一项基本功,所以也一定要掌握。**你要注意,递归下降是深度优先的,只有最左边的子树都生成完了,才会往右生成它的兄弟节点。**有的同学会在没有把左侧的非终结符匹配完毕的情况下,就开始匹配右边的项,从而不自觉地采用了宽度优先的思路,这是我发现很多同学会容易陷入的一个思维误区。
|
||||
|
||||
对于LL算法和LR算法,我只做了简单的讲解,目的是为了帮助你建立直观的理解。我们在后面的课程中,还会遇到使用它们的实际例子,到时你可以与这一讲的内容相互印证。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/24/18/249343a116119f7d9e1e1e803d6c5318.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
你可以计算一下示例文法中block、blockStmts、stmt、varDecl、returnStmt和expStmt的First和Follow集合吗?这样,你也可以熟悉一下First和Follow集合的计算方法。
|
||||
|
||||
欢迎在留言区分享你的答案。如果觉得有收获,也欢迎你把这节课分享给你的朋友。
|
||||
|
||||
## 参考资料
|
||||
|
||||
1.线性文法(Linear Grammar):参见[Wikipedia](https://en.wikipedia.org/wiki/Linear_grammar)。<br>
|
||||
2.左递归及其消除方法:参见[Wikipedia](https://en.wikipedia.org/wiki/Left_recursion)。
|
||||
217
极客时间专栏/编译原理实战课/预备知识篇/04 | 语义分析:让程序符合语义规则.md
Normal file
217
极客时间专栏/编译原理实战课/预备知识篇/04 | 语义分析:让程序符合语义规则.md
Normal file
@@ -0,0 +1,217 @@
|
||||
<audio id="audio" title="04 | 语义分析:让程序符合语义规则" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/35/bc/35e87316ad38293af7e9ed1a38f625bc.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。这一讲,我们进入到语义分析阶段。
|
||||
|
||||
对计算机程序语义的研究,是一个专门的学科。要想很简单地把它讲清楚,着实不是太容易的事情。但我们可以退而求其次,只要能直观地去理解什么是语义就可以了。**语义,就是程序要表达的意思**。
|
||||
|
||||
因为计算机最终是用来做计算的,那么理解程序表达的意思,就是要知道让计算机去执行什么计算动作,这样才好翻译成目标代码。
|
||||
|
||||
那具体来说,语义分析要做什么工作呢?我们在[第1讲](https://time.geekbang.org/column/article/242479)中说过,每门计算机语言的标准中,都会定义很多语义规则,比如对加法运算要执行哪些操作。而在语义分析阶段,就是去检查程序是否符合这些语义规则,并为后续的编译工作收集一些语义信息,比如类型信息。
|
||||
|
||||
再具体一点,这些**语义规则可以分为两大类**。
|
||||
|
||||
第一类规则与上下文有关。因为我们说了,语法分析只能处理与上下文无关的工作。而与上下文有关的工作呢,自然就放到了语义分析阶段。
|
||||
|
||||
第二类规则与类型有关。在计算机语言中,类型是语义的重要载体。所以,语义分析阶段要处理与类型有关的工作。比如,声明新类型、类型检查、类型推断等。在做类型分析的时候,我们会用到一个工具,就是属性计算,也是需要你了解和掌握的。
|
||||
|
||||
补充:某些与类型有关的处理工作,还必须到运行期才能去做。比如,在多态的情况,调用一个方法时,到底要采用哪个子类的实现,只有在运行时才会知道。这叫做动态绑定。
|
||||
|
||||
在语义分析过程中,会使用**两个数据结构**。一个还是AST,但我们会把语义分析时获得的一些信息标注在AST上,形成带有标注的AST。另一个是符号表,用来记录程序中声明的各种标识符,并用于后续各个编译阶段。
|
||||
|
||||
那今天这一讲,我就会带你看看如何完成与上下文有关的分析、与类型有关的处理,并带你认识符号表和属性计算。
|
||||
|
||||
首先,我们来学习如何处理与上下文有关的工作。
|
||||
|
||||
## 上下文相关的分析
|
||||
|
||||
那什么是与上下文有关的工作呢?在解析一个程序时,会有非常多的分析工作要结合上下文来进行。接下来,我就以控制流检查、闭包分析和引用消解这三个场景和你具体分析下。
|
||||
|
||||
**场景1:控制流检查**
|
||||
|
||||
像return、break和continue等语句,都与程序的控制流有关,它们必须符合控制流方面的规则。在Java这样的语言中,语义规则会规定:如果返回值不是void,那么在退出函数体之前,一定要执行一个return语句,那么就要检查所有的控制流分支,是否都以return语句结尾。
|
||||
|
||||
**场景2:闭包分析**
|
||||
|
||||
很多语言都支持闭包。而要正确地使用闭包,就必须在编译期知道哪些变量是自由变量。这里的自由变量是指在本函数外面定义的变量,但被这个函数中的代码所使用。这样,在运行期,编译器就会用特殊的内存管理机制来管理这些变量。所以,对闭包的分析,也是上下文敏感的。
|
||||
|
||||
**场景3:引用消解**
|
||||
|
||||
我们重点说一下引用消解,以及相关的作用域问题。
|
||||
|
||||
引用消解(Reference Resolution),有时也被称作名称消解(Name Resolution)或者标签消解(Label Resolution)。对变量名称、常量名称、函数名称、类型名称、包名称等的消解,都属于引用消解。因此,引用消解是一种非常重要的上下文相关的语义规则,我来重点讲解下。
|
||||
|
||||
在高级语言里,我们会做变量、函数(或方法)和类型的声明,然后在其他地方使用它们。这个时候,我们要找到定义和使用之间的正确引用关系。
|
||||
|
||||
我们来看一个例子。在语法分析阶段,对于“int b = a + 3”这样一条语句,无论a是否提前声明过,在语法上都是正确的。而在实际的计算机语言中,如果引用某个变量,这个变量就必须是已经声明过的。同时,当前这行代码,要处于变量a的作用域中才行。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/50/03/5092c6f4103a967ea0609dceea873d03.jpg" alt="">
|
||||
|
||||
对于变量来说,为了找到正确的引用,就需要用到**作用域**(Scope)这个概念。在编译技术里面,作用域这个词,有两个稍微有所差异的使用场景。
|
||||
|
||||
作用域的第一个使用场景,指的是变量、函数等标识符可以起作用的范围。下图列出了三个变量的作用域,每个变量声明完毕以后,它的下一句就可以引用它。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a8/ba/a82fbf11da007a038ed2ee282b30c9ba.jpg" alt="">
|
||||
|
||||
作用域的第二个使用场景,是词法作用域(Lexical Scope),也就是程序中的不同文本区域。比如,一个语句块、参数列表、类定义的主体、函数(方法)的主体、模块主体、整个程序等。
|
||||
|
||||
到这里,咱们来总结下这两个使用场景。**标识符和词法的作用域的差异**在于:一个本地变量(标识符)的作用域,虽然属于某个词法作用域(如某个函数体),但其作用范围只是在变量声明之后的语句。而类的成员变量(标识符)的作用域,跟词法作用域是一致的,也就是整个类的范围,跟声明的位置无关。如果这个成员变量不是私有的,它的作用域还会覆盖到子类。
|
||||
|
||||
那具体到不同的编程语言,它们的作用域规则是不同的。比如,C语言里允许你在一个if语句块里定义一个变量,覆盖外部的变量,而Java语言就不允许这样。所以,在给Java做语义分析时,我们要检查出这种错误。
|
||||
|
||||
```
|
||||
void foo(){
|
||||
int a = 2;
|
||||
if (...){
|
||||
int a = 3; //在C语言里允许,在Java里不允许
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在做引用消解的时候,为了更好地查找变量、类型等定义信息,编译器会使用一个辅助的数据结构:**符号表**。
|
||||
|
||||
## 符号表(Symbol Table)
|
||||
|
||||
在写程序的时候,我们会定义很多标识符,比如常量名称、变量名称、函数名称、类名称,等等。在编译器里,我们又把这些标识符叫做符号(Symbol)。用来保存这些符号的数据结构,就叫做符号表。
|
||||
|
||||
比如,对于变量a来说,符号表中的基本信息可以包括:
|
||||
|
||||
- 名称:a
|
||||
- 分类:变量
|
||||
- 类型:int
|
||||
- 作用域:foo函数体
|
||||
- 其他必要的信息。
|
||||
|
||||
符号表的具体实现,每个编译器可能都不同。比如,它可能是一张线性的表格,也可能是按照作用域形成的一种有层次的表格。以下面这个程序为例,它包含了两个函数,每个函数里面都定义了多个变量:
|
||||
|
||||
```
|
||||
void foo(){
|
||||
int a;
|
||||
int b;
|
||||
if (a>0){
|
||||
int c;
|
||||
int d;
|
||||
}
|
||||
else{
|
||||
int e;
|
||||
int f;
|
||||
}
|
||||
}
|
||||
|
||||
void bar(){
|
||||
int g;
|
||||
{
|
||||
int h;
|
||||
int i;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
它的符号表可能是下面这样的,分成了多个层次,每个层次对应了一个作用域。在全局作用域,符号表里包含foo和bar两个函数。在foo函数体里,有两个变量a和b,还有两个内部块,每个块里各有两个变量。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6e/6c/6ef69555fc2fc1abe06e206fab52ac6c.jpg" alt="">
|
||||
|
||||
那针对引用消解,其实就是从符号表里查找被引用的符号的定义,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1a/4e/1aca158938cf6e9895dce3f954b3db4e.jpg" alt="">
|
||||
|
||||
更进一步地,符号表除了用于引用消解外,还可以辅助完成语义分析的其他工作。比如,在做类型检查的时候,我们可以从符号表里查找某个符号的类型,从而检查类型是否兼容。
|
||||
|
||||
其实,不仅仅是在语义分析阶段会用到符号表,其他的编译阶段也会用到。比如,早在词法分析阶段,你就可以为符号表建立条目;在生成IR、做优化和生成目标代码的时候,都会用到符号表里的信息。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/36/31/3608f553a443cd9a4a59934b7e937e31.jpg" alt="">
|
||||
|
||||
有的编译器,在前期做语法分析的时候,如果不依赖符号表的话,它是不可能完整地做语法分析的。甚至,除了编译阶段,在链接阶段,我们也要用到符号表。比如,在foo.c中定义了一个函数foo(),并编译成目标文件foo.o,在bar.c中使用了这个foo()函数。那么在链接的时候,链接器需要找到foo()函数的地址。为了满足这个场景,你必须在目标文件中找到foo符号的相关信息。
|
||||
|
||||
同样的道理,在Java的字节码文件里也需要保存符号信息,以便在加载后我们可以定位其中的类、方法和成员变量。
|
||||
|
||||
好了,以上就是语义分析的第一项重要工作上下文相关的分析,以及涉及的数据结构符号表的重点内容了。我们再来考察一下语义分析中第二项重要的工作:类型分析和处理。
|
||||
|
||||
## 类型分析和处理
|
||||
|
||||
语义分析阶段的一个重要工作就是做类型检查,现代语言还普遍增加了类型推断的能力。那什么是类型呢?
|
||||
|
||||
通常来说,**在计算机语言里,类型是数据的一个属性,它的作用是来告诉编译器或解释器,程序可以如何使用这些数据。**比如说,对于整型数据,它可能占32或者64位存储,我们可以对它做加减乘除操作。而对于字符串,它可能占很多个字节,并且通过一定的编码规则来表示字符。字符串可以做连接、查找、获取子字符串等操作,但不能像整数一样做算术运算。
|
||||
|
||||
一门语言的类型系统是包含了与类型有关的各种规则的一个逻辑系统。类型系统包含了一系列规则,规定了如何把类型用于变量、表达式和函数等程序元素,以及如何创建自定义类型,等等。比如,如果你定义了某个类有哪些方法,那你就只能通过调用这些方法来使用这个类,没有别的方法。这些强制规定减少了程序出错的可能性。
|
||||
|
||||
所以在语义分析阶段,一个重要的工作就是做类型检查。
|
||||
|
||||
**那么,类型检查是怎样实现的呢?我们要如何做类型检查呢?**
|
||||
|
||||
关于类型检查,编译器一般会采用**属性计算**的方法,来计算出每个AST节点的类型属性,然后检查它们是否匹配。
|
||||
|
||||
## 属性计算
|
||||
|
||||
以“int b = a+3”为例,它的AST如下图所示。编译器会计算出b节点所需的类型和init节点的实际类型,比较它们是否一致(或者可以自动转换)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/16/60/16772e733d25f008332dcc507b544960.jpg" alt="">
|
||||
|
||||
我们首先要计算等号右边“a+3”的类型。其中,3是个整型字面量,我们可以据此把它的类型标注为整型;a是一个变量,它的类型可以从符号表中查到,也是整型。
|
||||
|
||||
**那么“a+3”是什么类型呢**?根据加法的语义,两个整型数据相加,结果仍然是整型,因此“a+3”这个表达式整体是整型的。因为init只有一个子节点(add),所以init的类型也一样是整型。
|
||||
|
||||
在刚才这段推理中,我们实际上是依据“a+3”的AST,从下级节点的类型计算出上级节点的类型。
|
||||
|
||||
**那么,我们能否以同样的方法计算b节点的类型呢?**答案是不可以。因为b根本没有子节点。但声明变量b的时候,有个int关键字,所以在AST中,b有一个兄弟节点,就是int关键字。根据变量声明的语义,b的类型就是int,因此它的类型是从AST的兄弟节点中获得的。
|
||||
|
||||
你看,同样是计算AST节点的类型,等号右边和左边的计算方法是不一样的。
|
||||
|
||||
实际上,我们刚才用的分析方法,就是**属性计算**。其中,有些属性是通过子节点计算出来的,这叫做 S属性(Synthesized Attribute,综合出来的属性),比如等号右边的类型。而另一些属性,则要根据父节点或者兄弟节点计算而来,这种属性叫做 I属性(Inherited Attribute,继承到的属性),比如等号左边的b变量的类型。
|
||||
|
||||
计算出来的属性,我们可以标注在AST上,这就形成我[第1讲](https://time.geekbang.org/column/article/242479)曾经提过的带有标注信息的AST,(Annotated Tree),也有人称之为Decorated Tree,或者Attributed Tree。虽然叫法有很多,但都是一个意思,都是向AST中添加了语义信息。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7a/a2/7a72cb24e044c68c4ccda78ee40fafa2.jpg" alt="">
|
||||
|
||||
属性计算的方法,就是基于语法规则,来定义一些属性计算的规则,在遍历AST的时候执行这些规则,我们就可以计算出属性值。**这种基于语法规则定义的计算规则,被叫做属性文法(Attribute Grammar)。**
|
||||
|
||||
补充:基于属性计算的方法可以做类型检查,那其实也可以做类型推断。有些现代语言在声明一个变量的时候,可以不明确指定的类型,那么它的类型就可以通过变量声明语句的右边部分推断出来。
|
||||
|
||||
你可能会问,属性计算的方法,除了计算类型,还可以计算什么属性呢?
|
||||
|
||||
根据不同语言的语义,可能有不同的属性需要计算。其实,value(值)也可以看做是一个属性,你可以给每个节点定义一个“value”属性。对表达式求值,也就是对value做属性计算,比如,“a + 3”的值,我们就可以自下而上地计算出来。这样看起来,value是一个S属性。
|
||||
|
||||
针对value这个属性的属性文法,你可以参考下面这个例子,在做语法解析(或先解析成AST,再遍历AST)的时候,执行方括号中的规则,我们就可以计算出AST的值了。
|
||||
|
||||
```
|
||||
add1 → add2 + mul [ add1.value = add2.value + mul.value ]
|
||||
add → mul [ add.value = mul.value ]
|
||||
mul1 → mul2 * primary [ mul1.value = mul2.value * primary.value ]
|
||||
mul → primary [ mul.value = primary.value ]
|
||||
primary → ( add ) [ primary.value = add.value ]
|
||||
primary → integer [ primary.value = strToInt(integer.str) ]
|
||||
|
||||
```
|
||||
|
||||
这种在语法规则上附加一系列动作,在解析语法的时候执行这些动作的方式,是一种编译方法,在龙书里有一个专门的名字,叫做**语法制导的翻译**(Syntax Directed Translation,SDT)。使用语法制导的翻译可以做很多事情,包括做属性计算、填充符号表,以及生成IR。
|
||||
|
||||
## 课程小结
|
||||
|
||||
在实际的编译器中,语义分析相关的代码量往往要比词法分析和语法分析的代码量大。因为一门语言有很多语义规则,所以要做的语义分析和检查工作也很多。
|
||||
|
||||
并且,因为每门语言之间的差别主要都体现在语义上,所以每门语言在语义处理方面的工作差异也比较大。比如,一门语言支持闭包,另一门语言不支持;有的语言支持泛型,另一门语言不支持;一门语言的面向对象特性是基于继承实现的,而另一门语言则是基于组合实现的,等等。
|
||||
|
||||
不过,这没啥关系。我们主要抓住它们的共性就好了。这些共性,就是我们本讲的内容:
|
||||
|
||||
- 做好上下文相关的分析,比如对各种引用的消解、控制流的检查、闭包的分析等;
|
||||
- 做好与类型有关的分析和处理,包括类型检查、类型推断等;
|
||||
- 掌握属性计算这个工具,用于计算类型、值等属性;
|
||||
- 最后,把获得的语义信息保存到符号表和AST里。
|
||||
|
||||
我把本讲的知识点也整理成了脑图,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f5/1e/f56cdb074915e743e9c21fd3c5eff11e.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
你能否阅读你所熟悉的编程语言的标准,查看其中的语义规则,并选择一组有意思的语义规则(比如,第1讲提到的ECMAScript中加法操作符的语义规则),分析一下在语义分析阶段要针对这组语义规则做哪些处理工作?
|
||||
|
||||
欢迎在留言区分享你的答案,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
## 参考资料
|
||||
|
||||
1. 关于计算机程序的语义进行处理的形式化方法,你可以参考:[The Formal Semantics of Programming Languages: An Introduction](https://www.amazon.com/Formal-Semantics-Programming-Languages-Introduction/dp/0262231697/ref=sr_1_3?crid=2YNEXB86EUVNY&dchild=1&keywords=semantics+of+programming+languages&qid=1590211662&sprefix=the+semantics+programming+%2Caps%2C1148&sr=8-3)
|
||||
1. 关于[数据类型](https://en.wikipedia.org/wiki/Data_type)、[类型系统](https://en.wikipedia.org/wiki/Type_system)、[类型理论](https://en.wikipedia.org/wiki/Type_theory)的定义,你可以参考Wikipedia。
|
||||
1. 《编译原理之美》的[第8讲](https://time.geekbang.org/column/article/128623)中,有关于如何在计算机语言里实现作用域的介绍,可以加深你对作用域的理解。
|
||||
237
极客时间专栏/编译原理实战课/预备知识篇/05 | 运行时机制:程序如何运行,你有发言权.md
Normal file
237
极客时间专栏/编译原理实战课/预备知识篇/05 | 运行时机制:程序如何运行,你有发言权.md
Normal file
@@ -0,0 +1,237 @@
|
||||
<audio id="audio" title="05 | 运行时机制:程序如何运行,你有发言权" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3d/de/3d14870659061280987c96defc20c5de.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。在语义分析之后,编译过程就开始进入中后端了。
|
||||
|
||||
经过前端阶段的处理分析,编译器已经充分理解了源代码的含义,准备好把前端处理的结果(带有标注信息的AST、符号表)翻译成目标代码了。
|
||||
|
||||
我在[第1讲](https://time.geekbang.org/column/article/242479)也说过,如果想做好翻译工作,编译器必须理解目标代码。而要理解目标代码,它就必须要理解目标代码是如何被执行的。通常情况下,程序有两种执行模式。
|
||||
|
||||
第一种执行模式是在物理机上运行。针对的是C、C++、Go这样的语言,编译器直接将源代码编译成汇编代码(或直接生成机器码),然后生成能够在操作系统上运行的可执行程序。为了实现它们的后端,编译器需要理解程序在底层的运行环境,包括CPU、内存、操作系统跟程序的互动关系,并要能理解汇编代码。
|
||||
|
||||
第二种执行模式是在虚拟机上运行。针对的是Java、Python、Erlang和Lua等语言,它们能够在虚拟机上解释执行。这时候,编译器要理解该语言的虚拟机的运行机制,并生成能够被执行的IR。
|
||||
|
||||
理解了这两种执行模式的特点,我们也就能弄清楚用高级语言编写的程序是如何运行的,进而也就理解了编译器在中后端的任务是什么。接下来,我们就从最基础的物理机模式开始学习吧。
|
||||
|
||||
## 在物理机上运行
|
||||
|
||||
在计算机发展的早期,科学家们确立了计算机的结构,并一直延续至今,这种结构就是**冯·诺依曼结构**。它的主要特点是:数据和指令不加区别,混合存储在同一个存储器中(即主存,或叫做内存);用一个指令指针指向内存中指令的位置,CPU就能自动加载这个位置的指令并执行。
|
||||
|
||||
在x86架构下,这个指针是eip寄存器(32位模式)或rip寄存器(64位模式)。一条指令执行完毕,指令指针自动增加,并执行下一条指令。如果遇到跳转指令,则跳转到另一个地址去执行。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9e/fe/9e4026ca0798caf346131d24586940fe.jpg" alt="">
|
||||
|
||||
这其实就是计算机最基本的运行原理。这样,你就可以在大脑中建立起像图1那样的直观结构。
|
||||
|
||||
通过图1,你会看到,**计算机指令的执行基本上只跟两个硬件相关:一个是CPU,一个是内存。**
|
||||
|
||||
### CPU
|
||||
|
||||
CPU是计算机的核心。从硬件构成方面,我们需要知道它的三个信息:
|
||||
|
||||
- 第一,CPU上面有寄存器,并且可以直接由指令访问。寄存器的读写速度非常快,大约是内存的100倍。所以我们**编译后的代码,要尽量充分利用寄存器**,而不是频繁地去访问内存。
|
||||
- 第二,CPU有高速缓存,并且可能是多级的。高速缓存也比内存快。CPU在读取指令和数据的时候,不是一次读取一条,而是读取相邻的一批数据,放到高速缓存里。接下来要读取的数据,很可能已经在高速缓存里了,通过这种机制来提高运行性能。因此,**编译器要尽量提高缓存的命中率**。
|
||||
- 第三,CPU内部有多个功能单元,有的负责计算,有的负责解码,等等。所以,一个指令可以被切分成多个执行阶段,每个阶段在不同的功能单元上运行,这为实现指令级并行提供了硬件基础。在第8讲,我还会和你详细解释这个话题。
|
||||
|
||||
好了,掌握了这个知识点,我们可以继续往下学习了。我们说,CPU是运行指令的地方,**那指令到底是什么样子的呢?**
|
||||
|
||||
我们知道,CPU有多种不同的架构,比如x86架构、ARM架构等。不同架构的CPU,它的指令是不一样的。不过它们的共性之处在于,指令都是01这样的机器码。为了便于理解,我们通常会用汇编代码来表示机器指令。比如,b=a+2指令对应的汇编码可能是这样的:
|
||||
|
||||
```
|
||||
movl -4(%rbp), %eax #把%rbp-4内存地址的值拷贝到%eax寄存器
|
||||
addl $2, %eax #把2加到%eax寄存器
|
||||
movl %eax, -8(%rbp) #把%eax寄存器的值保存回内存,地址是%rbp-8
|
||||
|
||||
```
|
||||
|
||||
上面的汇编代码采用的是GNU汇编器规定的格式。每条指令都包含了两部分:**操作码(opcode)和操作数(oprand)**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7e/0a/7e5aea959b228c635ee92f899748ea0a.jpg" alt="">
|
||||
|
||||
**操作码是让CPU执行的动作**。这段示例代码中,movl、addl是助记符(Assembly Mnemonic),其中的mov和add是指令,l是后缀,表示操作数的位数。
|
||||
|
||||
**而操作数是指令的操作对象**,它可以是常数、寄存器和某个内存地址。图2示例的汇编代码中,“$2”就是个常数,在指令里我们把它叫做立即数;而“%eax”是访问一个寄存器,其中eax是寄存器的名称;而带有括号的“-4(%rbp)”,则是对内存的访问方式,这个内存的地址是在rbp寄存器的值的基础上减去4。
|
||||
|
||||
如果你还想对指令、汇编代码有更多的了解,可以再去查阅些资料学习,比如去参考下我的《编译原理之美》中的第[22](https://time.geekbang.org/column/article/147854)、[23](https://time.geekbang.org/column/article/150798)、[31](https://time.geekbang.org/column/article/160990)这几讲。
|
||||
|
||||
**这里要提一下**,虽然程序觉得自己一直在使用CPU,但实际上,背后有操作系统在做调度。操作系统是管理系统资源的,而CPU是计算机的核心资源,操作系统会把CPU的时间划分成多个时间片,分配给不同的程序使用,每个程序实际上都是在“断断续续”地使用CPU,这就是操作系统的**分时调度机制**。在后面课程里讨论并发的时候,我们会更加深入地探讨这个机制。
|
||||
|
||||
### 内存
|
||||
|
||||
好了,接下来我说说执行指令相关的另一个硬件:内存。
|
||||
|
||||
程序在运行时,操作系统会给它分配一块虚拟的内存空间,让它可以在运行期内使用。内存中的每个位置都有一个地址,地址的长度决定了能够表示多大空间,这叫做**寻址空间**。我们目前使用的都是64位的机器,理论上,你可以用一个64位的长整型来表示内存地址。
|
||||
|
||||
不过,由于我们根本用不了这么大的内存,所以AMD64架构的寻址空间只使用了48位。但这也有256TB,远远超出了一般情况下的需求。所以,像Windows这样的操作系统还会给予进一步的限制,缩小程序的寻址空间。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4e/c3/4e4406dfa49bef594d7de9783ed287c3.jpg" alt="">
|
||||
|
||||
但即使是在加了限制的情况下,程序在逻辑上可使用的内存一般也会大于实际的物理内存。不过进程不会一下子使用那么多的内存,只有在向操作系统申请内存的时候,操作系统才会把一块物理内存,映射成进程寻址空间内的一块内存。对应到图4中,中间一条是物理内存,上下两条是两个进程的寻址空间,它们要比物理内存大。
|
||||
|
||||
对于有些物理内存的内容,还可以映射进多个进程的地址空间,以减少内存的使用。比如说,如果进程1和进程2运行的是同一个可执行文件,那么程序的代码段是可以在两个进程之间共享的。你在图中可以看到这种情况。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/32/9a/3245928f1986ae440399ca58d340709a.jpg" alt="">
|
||||
|
||||
另外,对于已经分配给进程的内存,如果进程很长时间不用,操作系统会把它写到磁盘上,以便腾出更多可用的物理内存。在需要的时候,再把这块空间的数据从磁盘中读回来。这就是操作系统的**虚拟内存机制**。
|
||||
|
||||
当然,也存在没有操作系统的情况,这个时候你的程序所使用的内存就是物理内存,我们必须自己做好内存的管理。
|
||||
|
||||
**那么从程序角度来说,我们应该怎样使用内存呢?**
|
||||
|
||||
本质上来说,你想怎么用就怎么用,并没有什么特别的限制。一个编译器的作者,可以决定在哪儿放代码,在哪儿放数据。当然了,别的作者也可能采用其他的策略。比如,C语言和Java虚拟机对内存的管理和使用策略就是不同的。
|
||||
|
||||
不过尽管如此,大多数语言还是会采用一些通用的内存管理模式。以C语言为例,会把内存划分为代码区、静态数据区、栈和堆,如下所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/27/8b/27685617695d64ad5488189921ad478b.jpg" alt="">
|
||||
|
||||
其中,代码区(也叫做文本段),主要存放编译完成后的机器码,也就是CPU指令;静态数据区会保存程序中的全局变量和常量。这些内存是静态的、固定大小的,在编译完毕以后就能确定清楚所占用空间的大小、代码区每个函数的地址,以及静态数据区每个变量和常量的地址。这些内存在程序运行期间会一直被占用。
|
||||
|
||||
而堆和栈,属于程序动态、按需获取的内存。我来和你分析下这两种内存。
|
||||
|
||||
我们先看看**栈**(Stack)。使用栈的一个好处是,操作系统会根据程序使用内存的需求,自动地增加或减少栈的空间。通常来说,操作系统会用一个寄存器保存栈顶的地址,程序可以修改这个寄存器的值,来获取或者释放空间。有的CPU,还有专门的指令来管理栈,比如x86架构,会使用push和pop指令,把数据写入栈或弹出栈,并自动修改栈顶指针。
|
||||
|
||||
在程序里使用栈的场景是这样的,程序的运行可以看做是在逐级调用函数(或者叫过程)。像下面的示例程序,存在着**main->bar->foo**的调用结构,这也就是**控制流转移**的过程。
|
||||
|
||||
```
|
||||
int main(){
|
||||
int a = 1;
|
||||
foo(3);
|
||||
bar();
|
||||
}
|
||||
|
||||
int foo(int c){
|
||||
int b = 2;
|
||||
return b+c;
|
||||
}
|
||||
|
||||
int bar(){
|
||||
return foo(4) + 1;
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2b/16/2b3b5c34c9026499365c1c0c4bbd5c16.jpg" alt="">
|
||||
|
||||
每次调用函数的过程中,都需要一些空间来保存一些信息,比如参数、需要保护的寄存器的值、返回地址、本地变量等,这些信息叫做这个过程的**活动记录**(Activation Record)。
|
||||
|
||||
**注意,活动记录是个逻辑概念。**在物理实现上,一些信息可以保存在寄存器里,使得性能更高。比如说依据一些约定,返回值和少于6个的参数,是通过寄存器传递的。这里所说的“依据约定”,是指在调用一个函数时,如何传递参数、如何设定返回地址、如何获取返回值的这种约定,我们把它称之为ABI(Application Binary Interface,应用程序二进制接口)。利用ABI,使得我们可以用一种语言写的程序,去调用另外的语言写的程序。
|
||||
|
||||
另一些信息会保存在栈里。每个函数(或过程)在栈里保存的信息,叫做**栈帧**(Stack Frame)。我们可以自由设计栈帧的结构,比如,下图就是一种常见的设计:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ac/10/acb100c9ba680f32bf117d2cf4856410.jpg" alt="">
|
||||
|
||||
- **返回值:**一般放在最顶上,这样它的地址是固定的。foo函数返回以后,它的调用者可以到这里来取到返回值。在实际情况中,ABI会规定优先通过寄存器来传递返回值,比通过内存传递性能更高。
|
||||
- **参数:**在调用foo函数时,我们把它所需要一个整型参数写到栈帧的这个位置。同样,我们也可以通过寄存器来传递参数,而不是通过内存。
|
||||
- **控制链接:**就是上一级栈帧(也就是main函数的栈帧)的地址。如果该函数用到了上一级作用域中的变量,那么就可以顺着这个链接找到上一级作用域的栈帧,并找到变量的值。
|
||||
- **返回地址:** foo函数执行完毕以后,继续执行哪条指令。同样,我们可以用寄存器来保存这个信息。
|
||||
- **本地变量:** foo函数的本地变量b的存储空间。
|
||||
- **寄存器信息:**我们还经常在栈帧里保存寄存器的数据。如果在foo函数里要使用某个寄存器,可能需要先把它的值保存下来,防止破坏了别的代码保存在这里的数据。**这种约定叫做被调用者责任,**也就是使用寄存器的函数要保护好寄存器里原有的信息。某个函数如果使用了某个寄存器,但它又要调用别的函数,为了防止别的函数把自己放在寄存器中的数据覆盖掉,这个函数就要自己把寄存器信息保存在栈帧中。**这种约定叫做调用者责任。**
|
||||
|
||||
对于示例程序,在多级调用以后,栈里的信息可能是下面这个样子。如果你想看到这个信息,通常可以在调试程序的时候打印出来。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2d/12/2db7cdca7fdf1478be6aa89f2bb2d212.jpg" alt="">
|
||||
|
||||
理解了栈的机制以后,我们再来看看动态获取内存的第二种方式:**堆**(Heap)。
|
||||
|
||||
操作系统一般会提供一个API,供应用申请内存。当应用程序用完之后,要通过另一个API释放。如果忘记释放,就会造成内存越用越少,这叫做**内存泄漏**。
|
||||
|
||||
相对于栈来说,这是堆的一个缺点。不过,相应的好处是,**应用在堆里申请的对象的生存期,可以由自己控制,不会像栈里的内存那样,在退出作用域之后就被自动收回。**所以,如果数据的生存期超过了创建它的作用域的生存期,就必须在堆中申请内存。
|
||||
|
||||
扩展:反之,如果数据的生存期跟创建它的作用域一致的话,那么在栈里和堆里申请都是可以的。当然,肯定在栈里申请更划算。所以,编译优化中的逃逸分析,本质就是分析出哪些对象的生存期是跟函数或方法的生存期一致的,那么就不需要到堆里申请了。
|
||||
|
||||
另外,在并发的场景下,由于栈是线程独享的,而堆是多个线程共享的,所以在堆里申请内存的效率会更低,因为需要在多个线程之间同步,避免出现竞争。
|
||||
|
||||
那为了避免内存泄漏,在设计一门语言的时候,通常需要提供内存管理的方案。
|
||||
|
||||
一种方案是像C和C++那样,由程序员自己负责内存的释放,这对程序员的要求就比较高。另一种方案是,像Java语言那样自动地管理内存,这个特性也叫做**垃圾收集**。垃圾收集是语言的运行时功能,能够通过一定的算法来回收不用的内存。
|
||||
|
||||
总结起来,在计算机上运行一个程序,我们需要跟两个硬件打交道:一个是CPU,它能够从内存中读取指令并顺序执行;第二个硬件是内存,内存使用模式有栈和堆两种方式,两种方式有各自的优点和适用场景。
|
||||
|
||||
### 运行时系统
|
||||
|
||||
除了硬件支撑,程序的运行还需要软件,这些软件叫做**运行时系统**(Runtime System),或者叫**运行时**(Runtime)。前面我们提到的垃圾收集器,就是一个运行时的软件。进行并发调度的软件,也是运行时的组成部分。
|
||||
|
||||
**实际上,对于把源代码编译成机器码在操作系统上运行的语言来说(比如C、C++),操作系统本身就可以看做是它们的运行时系统**。它可以帮助程序调度CPU资源、内存资源,以及其他一些资源,如IO端口。
|
||||
|
||||
但也有很多语言,比如Java、Python、Erlang和Lua等,它们不是直接在操作系统上运行的,而是运行在虚拟机上。那么它们的执行模式有什么特点?对编译有什么影响呢?
|
||||
|
||||
## 在虚拟机上运行
|
||||
|
||||
虚拟机是计算机语言的一种运行时系统。虚拟机上运行的是**中间代码**,而不是CPU可以直接认识的指令。
|
||||
|
||||
虚拟机有两种模型:一种叫做**栈机**(Stack Machine),一种叫做**寄存器机**(Register Machine)。它们的区别,主要在于如何获取指令的操作数。
|
||||
|
||||
栈机是从栈里获取,而寄存器机是从寄存器里获取。这两种虚拟机各有优缺点。
|
||||
|
||||
### 基于栈的虚拟机
|
||||
|
||||
首先说说栈机。JVM和Python中的解释器,都采用了栈机的模型。在本讲中,我主要介绍Java的虚拟机的运行机制。
|
||||
|
||||
JVM中,每一个线程都有一个JVM栈,每次调用一个方法都会生成一个栈帧,来支持这个方法的运行。这跟C语言很相似。但JVM的栈帧比C语言的复杂,它包含了一个本地变量数组(包括方法的参数和本地变量)、操作数栈、到运行时常量池的引用等信息。
|
||||
|
||||
对比JVM的栈帧和C语言栈帧的设计,你应该得到一些启示:栈帧的结构是语言的作者可以自己设计的,没有什么死规定。所以我们学知识也不要学死了,以为栈帧只有一种结构。
|
||||
|
||||
注意,我们这里提到了两个栈,一个是类似于C语言的栈的方法栈,另一个是方法栈里每个栈帧中的操作数栈。而我们说的栈机中的“栈”,指的是这个操作数栈,不要弄混了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2e/04/2e2ef9a3b11a80b2882b77f3bb8b3404.jpg" alt="">
|
||||
|
||||
对于每个指令,解释器先要把它的操作数压到栈里。在执行指令时,从栈里弹出操作数,计算完毕以后,再把结果压回栈里。
|
||||
|
||||
以“2+3*5”为例,它对应的栈机的代码如下:
|
||||
|
||||
```
|
||||
push 2 //把操作数2入栈
|
||||
push 3 //把操作数3入栈
|
||||
push 5 //把操作数5入栈, 栈里目前是2、 3、 5
|
||||
imul //弹出5和3,执行整数乘法运算,得到15,然后把结果入栈,现在栈里是2、15
|
||||
iadd //弹出15和2,执行整数加法运算,得到17,然后把结果入栈,最后栈里是17
|
||||
|
||||
```
|
||||
|
||||
提示:对于不同大小的常量操作数,实际上生成的指令会不同。这里只是示意。
|
||||
|
||||
注意一点,要从AST生成上面的代码,你只需要对AST做深度优先的遍历即可。先后经过的节点是:**2->3->5->*->+**(注:这种把操作符放在后面的写法,叫做**逆波兰表达式**,也叫**后缀表达式**)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/71/d5/71f7d40150ebfeb2bf880e4c846003d5.jpg" alt="">
|
||||
|
||||
生成上述栈机代码,只需要深度优先地遍历AST,并且只需要进行两种操作:
|
||||
|
||||
- 在遇到字面量或者变量的时候,生成push指令;
|
||||
- 在遇到操作符的时候,生成相应的操作指令即可。
|
||||
|
||||
你能看出,这个算法相当简单,这也是栈机最大的优点。
|
||||
|
||||
你还会注意到,像imul和iadd这样的指令,不需要带操作数,因为指令所需的操作数就在栈顶。这是栈机的指令跟汇编语言的指令的最大区别。
|
||||
|
||||
注意:imul和iadd中的i,代表这两个指令是对整型值做操作。对浮点型、长整型等不同类型,分别对应不同的指令前缀。
|
||||
|
||||
好了,现在你已经了解了栈机的原理。基于对栈机的认知,你再去阅读Java和Python的字节码,就会更加容易了。而关于Python的虚拟机,我还会在后续课程中详细展开。
|
||||
|
||||
### 基于寄存器的虚拟机
|
||||
|
||||
除了栈机之外,另一种虚拟机是寄存器机。寄存器机使用寄存器名称来表示操作数,所以它的指令也跟汇编代码相似,像add这样的操作码后面要跟操作数。
|
||||
|
||||
在实践中,早期版本的安卓系统中,用于解释执行代码的Dalvik虚拟机,就采用了寄存器模式,而Erlang和Lua语言的虚拟机也是寄存器机。JavaScript引擎V8的比较新的版本中,也引入了一个解释器Ignition,它也是个寄存器机。
|
||||
|
||||
**与栈机相比**,利用寄存器机编译所生成的代码更少,因为省去了很多push指令。
|
||||
|
||||
不过,寄存器机所指的寄存器,不一定是真正的物理寄存器,有可能只是栈帧中的一个位置。当然,有的寄存器机在实现的时候,确实会用到物理寄存器,从而提高计算性能。我们在后面研究V8的Ignition解释器时,会看到这种实现。
|
||||
|
||||
## 课程小结
|
||||
|
||||
本讲我带你了解了代码是如何被运行的,以及是在什么样的环境中运行的。这样,你才会知道如何让编译器生成正确的代码。
|
||||
|
||||
现有的程序有两大类执行模式。**一类是编译成本地代码(机器码),运行在物理机和操作系统上**,这时候你需要掌握目标机器的汇编代码,知道指令是如何跟CPU和内存打交道的,也需要知道操作系统在其中扮演了什么角色。**另一大类是在虚拟机上运行的**,虚拟机又分为栈机和寄存器机两大类,你需要明确它们之间的区别,才能知道为什么它们的IR是不同的,又分别有什么优缺点。
|
||||
|
||||
不过,现代程序的运行环境往往比较复杂。像Java等语言,既可以解释执行字节码,又能够即时编译成本地代码运行,所以它们的运行时机制就更复杂一些。你要综合两种运行时机制的知识,才能完整地理解JVM。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f4/72/f4535fcf01b83075b24096091b0ddb72.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
我们现在已经知道,栈是一种自动管理内存的机制,你只要修改栈顶指针,就可以获得所需的内存。那么,你能否结合操作系统的知识,研究一下这个过程是如何实现的呢?
|
||||
|
||||
欢迎在留言区分享你的答案,如果这节课对你有帮助,也欢迎你把它分享给你的朋友。
|
||||
|
||||
## 参考资料
|
||||
|
||||
1.关于JVM栈帧的结构,可以参考[JVM Specification](https://docs.oracle.com/javase/specs/jvms/se14/html/jvms-2.html#jvms-2.6)。<br>
|
||||
2. 关于Java字节码的指令集,可以参考[Java Language Specification](https://docs.oracle.com/javase/specs/jvms/se14/html/jvms-6.html)。
|
||||
316
极客时间专栏/编译原理实战课/预备知识篇/06 | 中间代码:不是只有一副面孔.md
Normal file
316
极客时间专栏/编译原理实战课/预备知识篇/06 | 中间代码:不是只有一副面孔.md
Normal file
@@ -0,0 +1,316 @@
|
||||
<audio id="audio" title="06 | 中间代码:不是只有一副面孔" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c9/60/c9580d8876c9995173164ea21045e260.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。今天这一讲,我来带你认识一下中间代码(IR)。
|
||||
|
||||
IR,也就是中间代码(Intermediate Representation,有时也称Intermediate Code,IC),它是编译器中很重要的一种数据结构。编译器在做完前端工作以后,首先就是生成IR,并在此基础上执行各种优化算法,最后再生成目标代码。
|
||||
|
||||
所以说,编译技术的IR非常重要,它是运行各种优化算法、代码生成算法的基础。不过,鉴于IR的设计一般与编译器密切相关,而一些教科书可能更侧重于讲理论,所以对IR的介绍就不那么具体。这就导致我们对IR有非常多的疑问,比如:
|
||||
|
||||
- **IR都有哪些不同的设计,可以分成什么类型?**
|
||||
- **IR有像高级语言和汇编代码那样的标准书写格式吗?**
|
||||
- **IR可以采用什么数据结构来实现?**
|
||||
|
||||
为了帮助你把对IR的认识从抽象变得具体,我今天就从全局的视角和你一起梳理下IR有关的认知。
|
||||
|
||||
首先,我们来了解一下IR的用途,并一起看看由于用途不同导致IR分成的多个层次。
|
||||
|
||||
## IR的用途和层次
|
||||
|
||||
设计IR的目的,是要满足编译器中的各种需求。需求的不同,就会导致IR的设计不同。通常情况下,IR有两种用途,一种是用来做分析和变换的,一种是直接用于解释执行的。我们先来看第一种。
|
||||
|
||||
编译器中,基于IR的分析和处理工作,一开始可以基于一些抽象层次比较高的语义,这时所需要的IR更接近源代码。而在后面,则会使用低层次的、更加接近目标代码的语义。
|
||||
|
||||
基于这种从高到低的抽象层次,IR可以归结为HIR、MIR和LIR三类。
|
||||
|
||||
### HIR:基于源语言做一些分析和变换
|
||||
|
||||
假设你要开发一款IDE,那最主要的功能包括:发现语法错误、分析符号之间的依赖关系(以便进行跳转、判断方法的重载等)、根据需要自动生成或修改一些代码(提供重构能力)。
|
||||
|
||||
这个时候,你对IR的需求,是能够准确表达源语言的语义就行了。这种类型的IR,可以叫做High IR,简称HIR。
|
||||
|
||||
其实,AST和符号表就可以满足这个需求。也就是说,AST也可以算作一种IR。如果你要开发IDE、代码翻译工具(从一门语言翻译到另一门语言)、代码生成工具、代码统计工具等,使用AST(加上符号表)就够了。
|
||||
|
||||
当然,有些HIR并不是树状结构(比如可以采用线性结构),但一般会保留诸如条件判断、循环、数组等抽象层次比较高的语法结构。
|
||||
|
||||
基于HIR,可以做一些高层次的代码优化,比如常数折叠、内联等。在Java和Go的编译器中,你可以看到不少基于AST做的优化工作。
|
||||
|
||||
### MIR:独立于源语言和CPU架构做分析和优化
|
||||
|
||||
大量的优化算法是可以通用的,没有必要依赖源语言的语法和语义,也没有必要依赖具体的CPU架构。
|
||||
|
||||
这些优化包括部分算术优化、常量和变量传播、死代码删除等,我会在下一讲和你介绍。实现这类分析和优化功能的IR可以叫做Middle IR,简称MIR。
|
||||
|
||||
因为MIR跟源代码和目标代码都无关,所以在讲解优化算法时,通常是基于MIR,比如三地址代码(Three Address Code,TAC)。
|
||||
|
||||
TAC的特点是,最多有三个地址(也就是变量),其中赋值符号的左边是用来写入的,而右边最多可以有两个地址和一个操作符,用于读取数据并计算。
|
||||
|
||||
我们来看一个例子,示例函数foo:
|
||||
|
||||
```
|
||||
int foo (int a){
|
||||
int b = 0;
|
||||
if (a > 10)
|
||||
b = a;
|
||||
else
|
||||
b = 10;
|
||||
return b;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对应的TAC可能是:
|
||||
|
||||
```
|
||||
BB1:
|
||||
b := 0
|
||||
if a>10 goto BB3 //如果t是false(0),转到BB3
|
||||
BB2:
|
||||
b := 10
|
||||
goto BB4
|
||||
BB3:
|
||||
b := a
|
||||
BB4:
|
||||
return b
|
||||
|
||||
```
|
||||
|
||||
可以看到,TAC用goto语句取代了if语句、循环语句这种比较高级的语句,当然也不会有类、继承这些高层的语言结构。但是,它又没有涉及数据如何在内存读写等细节,书写格式也不像汇编代码,与具体的目标代码也是独立的。
|
||||
|
||||
所以,它的抽象程度算是不高不低。
|
||||
|
||||
### LIR:依赖于CPU架构做优化和代码生成
|
||||
|
||||
最后一类IR就是Low IR,简称LIR。
|
||||
|
||||
这类IR的特点,是它的指令通常可以与机器指令一一对应,比较容易翻译成机器指令(或汇编代码)。因为LIR体现了CPU架构的底层特征,因此可以做一些与具体CPU架构相关的优化。
|
||||
|
||||
比如,下面是Java的JIT编译器输出的LIR信息,里面的指令名称已经跟汇编代码很像了,并且会直接使用AMD64架构的寄存器名称。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d9/8f/d9ebaec471be87da8afff32c0718f48f.jpg" alt="">
|
||||
|
||||
好了,以上就是根据不同的使用目的和抽象层次,所划分出来的不同IR的关键知识点了。
|
||||
|
||||
HIR、MIR和LIR这种划分方法,主要是参考“鲸书(Advanced Compiler Design and Implementation)”的提法。对此有兴趣的话,你可以参考一下这本书。
|
||||
|
||||
在实际操作时,有时候IR的划分标准不一定跟鲸书一致。在有的编译器里(比如Graal编译器),把相对高层次的IR叫做HIR,相对低层次的叫做LIR,而没有MIR。你只要知道它们代表了不同的抽象层次就足够了。
|
||||
|
||||
其实,在一个编译器里,有时候会使用抽象层次从高到低的多种IR,从便于“人”理解到便于“机器”理解。而编译过程可以理解为,抽象层次高的IR一直lower到抽象层次低的IR的过程,并且在每种IR上都会做一些适合这种IR的分析和处理工作,直到最后生成了优化的目标代码。
|
||||
|
||||
扩展:lower这个词的意思,就是把对计算机程序的表示,从抽象层次比较高的、便于人理解的格式,转化为抽象层次比较低的、便于机器理解的格式。
|
||||
|
||||
有些IR的设计,本身就混合了多个抽象层次的元素,比如Java的Graal编译器里就采用了这种设计。Graal的IR采用的是一种图结构,但随着优化阶段的进展,图中的一些节点会逐步从语义比较抽象的节点,lower到体现具体架构特征的节点。
|
||||
|
||||
## P-code:用于解释执行的IR
|
||||
|
||||
好了,前3类IR是从抽象层次来划分的,它们都是用来做分析和变换的。我们继续看看第二种直接用于解释执行的IR。这类IR还有一个名称,叫做P-code,也就是Portable Code的意思。由于它与具体机器无关,因此可以很容易地运行在多种电脑上。这类IR对编译器来说,就是做编译的目标代码。
|
||||
|
||||
到这里,你一下子就会想到,Java的字节码就是这种IR。除此之外,Python、Erlang也有自己的字节码,.NET平台、Visual Basic程序也不例外。
|
||||
|
||||
其实,你也完全可以基于AST实现一个全功能的解释器,只不过性能会差一些。对于专门用来解释执行IR,通常会有一些特别的设计,跟虚拟机配合来尽量提升运行速度。
|
||||
|
||||
需要注意的是,P-code也可能被进一步编译,形成可以直接执行的机器码。Java的字节码就是这样的例子。因此,在这门课程里,我会带你探究Java的两个编译器,一个把源代码编译成字节码,一个把字节码编译成目标代码(支持JIT和AOT两种方式)。
|
||||
|
||||
好了,通过了解IR的不同用途,你应该会对IR的概念更清晰一些。用途不同,对IR的需求也就不同,IR的设计自然也就会不同。这跟软件设计是由需求决定的,是同一个道理。
|
||||
|
||||
接下来的一个问题是,**IR是怎样书写的呢?**
|
||||
|
||||
## IR的呈现格式
|
||||
|
||||
虽然说是中间代码,但总得有一个书写格式吧,就像源代码和汇编代码那样。
|
||||
|
||||
其实IR通常是没有书写格式的。一方面,大多数的IR跟AST一样,只是编译过程中的一个数据结构而已,或者说只有内存格式。比如,LLVM的IR在内存里是一些对象和接口。
|
||||
|
||||
另一方面,为了调试的需要,你可以把IR以文本的方式输出,用于显示和分析。在这门课里,你也会看到很多IR的输出格式。比如,下面是Julia的IR:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0d/8d/0dc758c3d40f9c41f026663271a4858d.jpg" alt="">
|
||||
|
||||
在少量情况下,IR有比较严格的输出格式,不仅用于显示和分析,还可以作为结果保存,并可以重新读入编译器中。比如,LLVM的bitcode,可以保存成文本和二进制两种格式,这两种格式间还可以相互转换。
|
||||
|
||||
我们以C语言为例,来看下fun1函数,及其对应的LLVM IR的文本格式和二进制格式:
|
||||
|
||||
```
|
||||
//fun1.c
|
||||
int fun1(int a, int b){
|
||||
int c = 10;
|
||||
return a+b+c;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
LLVM IR的文本格式(用“clang -emit-llvm -S fun1.c -o fun1.ll”命令生成,这里只节选了主要部分):
|
||||
|
||||
```
|
||||
; ModuleID = 'fun1.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
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
二进制格式(用“clang -emit-llvm -c fun1.c -o fun1.bc”命令生成,用“hexdump -C fun1.bc”命令显示):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/98/4b/9830e3f7bb6e250a56805d8802eb564b.jpg" alt="">
|
||||
|
||||
## IR的数据结构
|
||||
|
||||
既然我们一直说IR会表现为内存中的数据结构,那它到底是什么结构呢?
|
||||
|
||||
在实际的实现中,有线性结构、树结构、有向无环图(DAG)、程序依赖图(PDG)等多种格式。编译器会根据需要,选择合适的数据结构。在运行某些算法的时候,采用某个数据结构可能会更顺畅,而采用另一些结构可能会带来内在的阻滞。所以,**我们一定要根据具体要处理的工作的特点,来选择合适的数据结构。**
|
||||
|
||||
那我们接下来,就具体看看每种格式的特点。
|
||||
|
||||
### 第一种:类似TAC的线性结构(Linear Form)
|
||||
|
||||
你可以把代码表示成一行行的指令或语句,用数组或者列表保存就行了。其中的符号,需要引用符号表,来提供类型等信息。
|
||||
|
||||
这种线性结构有时候也被称作goto格式。因为高级语言里的条件语句、循环语句,要变成用goto语句跳转的方式。
|
||||
|
||||
### 第二种:树结构
|
||||
|
||||
树结构当然可以用作IR,AST就是一种树结构。
|
||||
|
||||
很多资料中讲指令选择的时候,也会用到一种树状的结构,便于执行树覆盖算法。这个树结构,就属于一种LIR。
|
||||
|
||||
树结构的缺点是,可能有冗余的子树。比如,语句“`a=5; b=(2+a)+a*3;` ”形成的AST就有冗余。如果基于这个树结构生成代码,可能会做两次从内存中读取a的值的操作,并存到两个临时变量中。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/74/2f/74e6c6d63d3c49656ecee767a8d8b52f.jpg" alt="">
|
||||
|
||||
### 第三种:有向无环图(Directed Acyclic Graph,DAG)
|
||||
|
||||
DAG结构,是在树结构的基础上,消除了冗余的子树。比如,上面的例子转化成DAG以后,对a的内存访问只做一次就行了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/16/a2/168f1617889bbe76095e65d4968333a2.jpg" alt="">
|
||||
|
||||
在LLVM的目标代码生成环节,就使用了DAG来表示基本块内的代码。
|
||||
|
||||
### 第四种:程序依赖图(Program Dependence Graph,PDG)
|
||||
|
||||
程序依赖图,是显式地把程序中的数据依赖和控制依赖表示出来,形成一个图状的数据结构。基于这个数据结构,我们再做一些优化算法的时候,会更容易实现。
|
||||
|
||||
所以现在,有很多编译器在运行优化算法的时候,都基于类似PDG的数据结构,比如我在课程后面会分析的Java的JIT编译器和JavaScript的编译器。
|
||||
|
||||
这种数据结构里,因为会有很多图节点,又被形象地称为“**节点之海(Sea of Nodes)**”。你在很多文章中,都会看到这个词。
|
||||
|
||||
以上就是常用于IR的数据结构了。接下来,我再介绍一个重要的IR设计范式:SSA格式。
|
||||
|
||||
## SSA格式的IR
|
||||
|
||||
SSA是Static Single Assignment的缩写,也就是静态单赋值。这是IR的一种设计范式,它要求一个变量只能被赋值一次。我们来看个例子。
|
||||
|
||||
“y = x1 + x2 + x3 + x4”的普通TAC如下:
|
||||
|
||||
```
|
||||
y := x1 + x2;
|
||||
y := y + x3;
|
||||
y := y + x4;
|
||||
|
||||
```
|
||||
|
||||
其中,y被赋值了三次,如果我们写成SSA的形式,就只能写成下面的样子:
|
||||
|
||||
```
|
||||
t1 := x1 + x2;
|
||||
t2 := t1 + x3;
|
||||
y := t2 + x4;
|
||||
|
||||
```
|
||||
|
||||
**那我们为什么要费力写成这种形式呢,还要为此多添加t1和t2两个临时变量?**
|
||||
|
||||
原因是,使用SSA的形式,体现了精确的“**使用-定义(use-def)**”关系。并且由于变量的值定义出来以后就不再变化,使得基于SSA更容易运行一些优化算法。在后面的课程中,我会通过实际的例子带你体会这一点。
|
||||
|
||||
在SSA格式的IR中,还会涉及一个你经常会碰到的,但有些特别的指令,叫做 **phi指令**。它是什么意思呢?我们看一个例子。
|
||||
|
||||
同样对于示例代码foo:
|
||||
|
||||
```
|
||||
int foo (int a){
|
||||
int b = 0;
|
||||
if (a > 10)
|
||||
b = a;
|
||||
else
|
||||
b = 10;
|
||||
return b;
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
它对应的SSA格式的IR可以写成:
|
||||
|
||||
```
|
||||
BB1:
|
||||
b1 := 0
|
||||
if a>10 goto BB3
|
||||
BB2:
|
||||
b2 := 10
|
||||
goto BB4
|
||||
BB3:
|
||||
b3 := a
|
||||
BB4:
|
||||
b4 := phi(BB2, BB3, b2, b3)
|
||||
return b4
|
||||
|
||||
```
|
||||
|
||||
其中,变量b有4个版本:b1是初始值,b2是else块(BB2)的取值,b3是if块(BB3)的取值,最后一个基本块(BB4)要把b的最后取值作为函数返回值。很明显,b的取值有可能是b2,也有可能是b3。这时候,就需要phi指令了。
|
||||
|
||||
phi指令,会根据控制流的实际情况确定b4的值。如果BB4的前序节点是BB2,那么b4的取值是b2;而如果BB4的前序节点是BB3,那么b4的取值就是b3。所以你会看到,如果要满足SSA的要求,也就是一个变量只能赋值一次,那么在遇到有程序分支的情况下,就必须引入phi指令。关于这一点,你也会在课程后面经常见到它。
|
||||
|
||||
最后我要指出的是,**由于SSA格式的优点,现代语言用于优化的IR,很多都是基于SSA的了,包括我们本课程涉及的Java的JIT编译器、JavaScript的V8编译器、Go语言的gc编译器、Julia编译器,以及LLVM工具等。**所以,你一定要高度重视SSA。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天这一讲,我希望你能记住关于IR的几个重要概念:
|
||||
|
||||
- **根据抽象层次和使用目的不同,可以设计不同的IR;**
|
||||
- **IR可能采取多种数据结构,每种结构适合不同的处理工作;**
|
||||
- **由于SSA格式的优点,主流的编译器都在采用这种范式来设计IR。**
|
||||
|
||||
通过学习IR,你会形成看待编译过程的一个新视角:整个编译过程,就是生成从高抽象度到低抽象度的一系列IR,以及发生在这些IR上的分析与处理过程。
|
||||
|
||||
我还展示了三地址代码、LLVM IR等一些具体的IR设计,希望能给你增加一些直观印象。在有的教科书里,还会有三元式、四元式、逆波兰格式等不同的设计,你也可以参考。而在后面的课程里,你会接触到每门编译器的IR,从而对IR的理解更加具体和丰满。
|
||||
|
||||
本讲的思维导图如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/19/55/19a96336d4b579a86263f82d98c72255.jpg" alt="">
|
||||
|
||||
### 一课一思
|
||||
|
||||
你能试着把下面这段简单的程序,改写成TAC和SSA格式吗?
|
||||
|
||||
```
|
||||
int bar(a){
|
||||
int sum = 0;
|
||||
for (int i = 0; i< a; i++){
|
||||
sum = sum+i;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
欢迎在留言区分享你的见解,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
### 参考资料
|
||||
|
||||
1. 关于程序依赖图的论文参考:[The Program Dependence Graph and its Use in Optimization](https://www.cs.utexas.edu/~pingali/CS395T/2009fa/papers/ferrante87.pdf)。
|
||||
1. 更多的关于LLVM IR的介绍,你可以参考《编译原理之美》的第[25](https://time.geekbang.org/column/article/153192)、[26](https://time.geekbang.org/column/article/154438)讲,以及[LLVM官方文档](https://llvm.org/docs/LangRef.html)。
|
||||
1. 对Java字节码的介绍,你可以参考《编译原理之美》的[第32讲](https://time.geekbang.org/column/article/161944),还可以参考[Java Language Specification](https://docs.oracle.com/javase/specs/jvms/se14/html/jvms-6.html)。
|
||||
1. 鲸书(Advanced Compiler Design and Implementation)第4章。
|
||||
400
极客时间专栏/编译原理实战课/预备知识篇/07 | 代码优化:跟编译器做朋友,让你的代码飞起来.md
Normal file
400
极客时间专栏/编译原理实战课/预备知识篇/07 | 代码优化:跟编译器做朋友,让你的代码飞起来.md
Normal file
@@ -0,0 +1,400 @@
|
||||
<audio id="audio" title="07 | 代码优化:跟编译器做朋友,让你的代码飞起来" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/63/15/6382793ffb771f8220a7bfbad8fe7b15.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
一门语言的性能高低,是它能否成功的关键。拿JavaScript来说,十多年来,它的性能多次得到成倍的提升,这也是前端技术栈如此丰富和强大的根本原因。
|
||||
|
||||
因此,编译器会无所不用其极地做优化,而优化工作在编译器的运行时间中,也占据了很大的比例。
|
||||
|
||||
不过,对编译技术的初学者来说,通常会搞不清楚编译器到底做了哪些优化,这些优化的实现思路又是怎样的。
|
||||
|
||||
所以今天这一讲,我就重点给你普及下编译器所做的优化工作,及其工作原理。在这个过程中,你还会弄明白很多似曾相识的术语,比如在前端必须了解的AST、终结符、非终结符等,在中后端必须熟悉的常数折叠、值编号、公共子表达式消除等。只有这样,你才算是入门了。
|
||||
|
||||
首先,我带你认识一些常见的代码优化方法。
|
||||
|
||||
## 常见的代码优化方法
|
||||
|
||||
对代码做优化的方法有很多。如果要把它们分一下类的话,可以按照下面两个维度:
|
||||
|
||||
- **第一个分类维度,是机器无关的优化与机器相关的优化。**机器无关的优化与硬件特征无关,比如把常数值在编译期计算出来(常数折叠)。而机器相关的优化则需要利用某硬件特有的特征,比如SIMD指令可以在一条指令里完成多个数据的计算。
|
||||
- **第二个分类维度,是优化的范围。**本地优化是针对一个基本块中的代码,全局优化是针对整个函数(或过程),过程间优化则能够跨越多个函数(或过程)做优化。
|
||||
|
||||
但优化算法很多,仅仅按照这两个维度分类,仍显粗糙。所以,我就按照优化的实现思路再分分类,让你了解起来更轻松一些。
|
||||
|
||||
### 思路1:把常量提前计算出来
|
||||
|
||||
程序里的有些表达式,肯定能计算出一个常数值,那就不要等到运行时再去计算,干脆在编译期就计算出来。比如 “`x=2*3`”可以优化成“`x=6`”。这种优化方法,叫做**常数折叠(Constant Folding)**。
|
||||
|
||||
而如果你一旦知道x的值其实是一个常量,那你就可以把所有用到x的地方,替换成这个常量,这叫做**常数传播(Constant Propagation)**。如果有“`y=x*2`”这样一个语句,那么就能计算出来“`y=12`”。所以说,常数传播会导致更多的常数折叠。
|
||||
|
||||
就算不能引起新的常数折叠,比如说“`z=a+x`”,替换成“`z=a+6`”以后,计算速度也会更快。因为对于很多CPU来说,“`a+x`”和“`a+6`”对应的指令是不一样的。前者可能要生成两条指令(比如先把a放到寄存器上,再把x加上去),而后者用一条指令就行了,因为常数可以作为操作数。
|
||||
|
||||
更有用的是,常数传播可能导致分支判断条件是常量,因此导致一个分支的代码不需要被执行。这种优化叫做**稀疏有条件的常数传播(Sparse Conditional Constant Propagation)**。
|
||||
|
||||
```
|
||||
a = 2
|
||||
b = 3
|
||||
if(a<b){ //判断语句去掉
|
||||
... //直接执行这个代码块
|
||||
}
|
||||
else{
|
||||
... //else分支会去掉
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 思路2:用低代价的方法做计算
|
||||
|
||||
完成相同的计算,可以用代价更低的方法。比如“`x=x+0`”这行代码,操作前后x没有任何变化,所以这样的代码可以删掉;又比如“`x=x*0`” 可以简化成“`x=0`”。这类利用代数运算的规则所做的简化,叫做**代数简化(Algebra Simplification)。**
|
||||
|
||||
对于很多CPU来说,乘法运算改成移位运算,速度会更快。比如,“`x*2`”等价于“`x<<1`”,“`x*9`”等价于“`x<<3+x`”。这种采用代价更低的运算的方法,也叫做**强度折减(Strength Reduction)**。
|
||||
|
||||
### 思路3:消除重复的计算
|
||||
|
||||
下面的示例代码中,第三行可以被替换成“`z:=2*x`”, 因为y的值就等于x。这个时候,可能x的值已经在寄存器中,所以直接采用x,运算速度会更快。这种优化叫做**拷贝传播(Copy Propagation)。**
|
||||
|
||||
```
|
||||
x := a + b
|
||||
y := x
|
||||
z := 2 * y
|
||||
|
||||
```
|
||||
|
||||
**值编号(Value Numbering)**也能减少重复计算。值编号是把相同的值,在系统里给一个相同的编号,并且只计算一次即可。比如,[Wikipedia](https://en.wikipedia.org/wiki/Value_numbering)上的这个案例:
|
||||
|
||||
```
|
||||
w := 3
|
||||
x := 3
|
||||
y := x + 4
|
||||
z := w + 4
|
||||
|
||||
```
|
||||
|
||||
其中w和x的值是一样的,因此编号是相同的。这会进一步导致y和z的编号也是相同的。进而,它们可以简化成:
|
||||
|
||||
```
|
||||
w := 3
|
||||
x := w
|
||||
y := w + 4
|
||||
z := y
|
||||
|
||||
```
|
||||
|
||||
值编号又可以分为两种,本地值编号(在一个基本块中)和全局值编号(GVN,在一个函数范围内)。
|
||||
|
||||
还有一种优化方法叫做**公共子表达式消除(Common Subexpression Elimination,CSE),**也会减少计算次数。下面这两行代码,x和y右边的形式是一样的,如果这两行代码之间,a和b的值没有发生变化(比如采用SSA形式),那么x和y的值一定是一样的。
|
||||
|
||||
```
|
||||
x := a + b
|
||||
y := a + b
|
||||
|
||||
```
|
||||
|
||||
那我们就可以让y等于x,从而减少了一次对“`a+b`”的计算,这就是公共子表达式消除。
|
||||
|
||||
```
|
||||
x := a + b
|
||||
y := x
|
||||
|
||||
```
|
||||
|
||||
**部分冗余消除(Partial Redundancy Elimination,PRE)**,是公共子表达式消除的一种特殊情况。比如,这个来自[Wikipedia](https://en.wikipedia.org/wiki/Partial_redundancy_elimination)的例子中,一个分支有“`x+4`”这个公共子表达式,而另一个分支则没有。
|
||||
|
||||
```
|
||||
if (some_condition) {
|
||||
// some code that does not alter x
|
||||
y = x + 4;
|
||||
}
|
||||
else {
|
||||
// other code that does not alter x
|
||||
}
|
||||
z = x + 4;
|
||||
|
||||
```
|
||||
|
||||
但是,上述代码仍然可以优化,使得在if结构中,“`x+4`”这个值肯定会被计算一次,因此“`z=x+4`”就可以被优化。
|
||||
|
||||
```
|
||||
if (some_condition) {
|
||||
// some code that does not alter x
|
||||
t = x + 4;
|
||||
y = t;
|
||||
}
|
||||
else {
|
||||
// other code that does not alter x
|
||||
t = x + 4;
|
||||
}
|
||||
z = t;
|
||||
|
||||
```
|
||||
|
||||
### 思路4:化零为整,向量计算
|
||||
|
||||
很多CPU支持向量运算,也就是SIMD(Single Instruction Multiple Data)指令。这就可以在一条指令里计算多个数据。比如AVX-512指令集,可以使用512位的寄存器做运算,这个指令集的一条add指令相当于一次能把16个整数加到另16个整数上,以1当16呀。
|
||||
|
||||
比如,把16万个整数相加,应该怎样写程序呢?普通方法,是循环16万次,每次读1个数据,并做累加。向量化的方法,是每次读取16个,用AVX-512指令做加法计算,一共循环计算1万次,最后再把得到的16个数字相加就行了。
|
||||
|
||||
向量优化的一个例子是**超字级并行(Superword-Level Parallelism,SLP)**。它是把基本块中的多个变量组成一个向量,用一个指令完成多个变量的计算。
|
||||
|
||||
向量优化的另一个例子是**循环向量化(Loop Vectorization)**,我会在下面针对循环的优化思路中讲到它。
|
||||
|
||||
### 思路5:化整为零,各个优化
|
||||
|
||||
另一个思路是反着的,是化整为零。
|
||||
|
||||
很多语言都有结构和对象这样的复合数据类型,内部包含了多个成员变量,这种数据类型叫做**聚合体(aggregates)**。通常,为这些对象申请内存的时候,是一次就申请一整块,能放下里面的所有成员。但这样做,非常不利于做优化。
|
||||
|
||||
通常的优化算法都是针对标量(Scalar)的。如果经过分析,发现可以把聚合体打散,像使用单个本地变量(也就是标量)一样使用聚合体的成员变量,那就有可能带来其他优化的机会。比如,可以把聚合体的成员变量放在寄存器中进行计算,根本不需要访问内存。
|
||||
|
||||
这种优化叫做**聚合体的标量替换(Scalar Replacement of Aggregates,SROA)。**在研究Java的JIT编译器时,我们会见到一个这类优化的例子。
|
||||
|
||||
### 思路6:针对循环,重点优化
|
||||
|
||||
在编译器中,对循环的优化从来都是重点,因为程序中最多的计算量都是被各种循环消耗掉的。所以,对循环做优化,会起到事半功倍的效果。如果一个循环执行了10000次,那么你的优化效果就会被扩大10000倍。
|
||||
|
||||
对循环做优化,有很多种方法,我来和你介绍几种常用的。
|
||||
|
||||
**第一种:归纳变量优化(Induction Variable Optimization)。**
|
||||
|
||||
看下面这个循环,其中的变量j是由循环变量派生出来的,这种变量叫做该循环的归纳变量。归纳变量的变化是很有规律的,因此可以尝试做**强度折减**优化。示例代码中的乘法可以由加法替代。
|
||||
|
||||
```
|
||||
int j = 0;
|
||||
for (int i = 1; i < 100; i++) {
|
||||
j = 2*i; //2*i可以替换成j+2
|
||||
}
|
||||
return j;
|
||||
|
||||
```
|
||||
|
||||
**第二种:边界检查消除(Unnecessary Bounds-checking Elimination)。**
|
||||
|
||||
当引用一个数组成员的时候,通常要检查下标是否越界。在循环里面,如果每次都要检查的话,代价就会相当高(例如做多个数组的向量运算的时候)。如果编译器能够确定,在循环中使用的数组下标(通常是循环变量或者基于循环变量的归纳变量)不会越界,那就可以消除掉边界检查的代码,从而大大提高性能。
|
||||
|
||||
**第三种:循环展开(Loop Unrolling)。**
|
||||
|
||||
把循环次数减少,但在每一次循环里,完成原来多次循环的工作量。比如:
|
||||
|
||||
```
|
||||
for (int i = 0; i< 100; i++){
|
||||
sum = sum + i;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
优化后可以变成:
|
||||
|
||||
```
|
||||
for (int i = 0; i< 100; i+=5){
|
||||
sum = sum + i;
|
||||
sum = sum + i + 1;
|
||||
sum = sum + i + 2;
|
||||
sum = sum + i + 3;
|
||||
sum = sum + i + 4;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
进一步,循环体内的5条语句就可以优化成1条语句:“`sum = sum + i*5 + 10;`”。
|
||||
|
||||
减少循环次数,本身就能减少循环条件的执行次数。同时,它还会增加一个基本块中的指令数量,从而为指令排序的优化算法创造机会。指令排序会在下一讲中介绍。
|
||||
|
||||
**第四种:循环向量化(Loop Vectorization)。**
|
||||
|
||||
在循环展开的基础上,我们有机会把多次计算优化成一个向量计算。比如,如果要循环16万次,对一个包含了16万个整数的数组做汇总,就可以变成循环1万次,每次用向量化的指令计算16个整数。
|
||||
|
||||
**第五种:重组(Reassociation)。**
|
||||
|
||||
在循环结构中,使用代数简化和重组,能获得更大的收益。比如,如下对数组的循环操作,其中数组`a[i,j]`的地址是“`a+i*N+j`”。但这个运算每次循环就要计算一次,一共要计算`M*N`次。但其实,这个地址表达式的前半截“`a+i*N`”不需要每次都在内循环里计算,只要在外循环计算就行了。
|
||||
|
||||
```
|
||||
for (i = 0; i< M; i++){
|
||||
for (j = 0; j<N; j++){
|
||||
a[i,j] = b + a[i,j];
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
优化后的代码相当于:
|
||||
|
||||
```
|
||||
for (i = 0; i< M; i++){
|
||||
t=a+i*N;
|
||||
for (j = 0; j<N; j++){
|
||||
*(t+j) = b + *(t+j);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**第六种:循环不变代码外提(Loop-Invariant Code Motion,LICM)。**
|
||||
|
||||
在循环结构中,如果发现有些代码其实跟循环无关,那就应该提到循环外面去,避免一次次重复计算。
|
||||
|
||||
**第七种:代码提升(Code Hoisting,或Expression Hoisting)。**
|
||||
|
||||
在下面的if结构中,then块和else块都有“`z=x+y`”这个语句,它可以提到if语句的外面。
|
||||
|
||||
```
|
||||
if (x > y)
|
||||
...
|
||||
z = x + y
|
||||
...
|
||||
}
|
||||
else{
|
||||
z = x + y
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样变换以后,至少代码量会降低。但是,如果这个if结构是在循环里面,那么可以继续借助**循环不变代码外提**优化,把“`z=x+y`”从循环体中提出来,从而降低计算量。
|
||||
|
||||
```
|
||||
z = x + y
|
||||
for(int i = 0; i < 10000; i++){
|
||||
if (x > y)
|
||||
...
|
||||
}
|
||||
else{
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
另外,前面说过的部分冗余优化,也可能会产生可以外提的代码,借助这一优化方法,可以形成进一步优化的效果。
|
||||
|
||||
针对循环能做的优化还有不少,因为对循环做优化往往是收益很高的!
|
||||
|
||||
### 思路7:减少过程调用的开销
|
||||
|
||||
你知道,当程序调用一个函数的时候,开销是很大的,比如保存原来的栈指针、保存某些寄存器的值、保存返回地址、设置参数,等等。其中很多都是内存读写操作,速度比较慢。
|
||||
|
||||
所以,如果能做一些优化,减少这些开销,那么带来的优化效果会是很显著的,具体的优化方法主要有下面几种。
|
||||
|
||||
**第一种:尾调用优化(Tail-call Optimization)和尾递归优化(Tail-recursion Elimination)。**
|
||||
|
||||
尾调用就是一个函数的最后一句,是对另一个函数的调用。比如,下面这段示例代码:
|
||||
|
||||
```
|
||||
f(){
|
||||
...
|
||||
return g(a,b);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
而如果g()本身就是f()的最后一行代码,那么f()的栈帧已经没有什么用了,可以撤销掉了(修改栈顶指针的值),然后直接跳转到g()的代码去执行,就像f()和g()是同一个函数一样。这样可以让g()复用f()的栈空间,减少内存消耗,也减少一些内存读写操作(比如,保护寄存器、写入返回地址等)。
|
||||
|
||||
如果f()和g()是同一个函数,这就叫做**尾递归**。很多同学都应该知道,尾递归是可以转化为一个循环的。我们在[第3讲](https://time.geekbang.org/column/article/244906)改写左递归文法为右递归文法的时候,就曾经用循环代替了递归调用。尾递归转化为循环,不但可以节省栈帧的开销,还可以进一步导致针对循环的各种优化。
|
||||
|
||||
**第二种:内联(inlining)。**
|
||||
|
||||
内联也叫做过程集成(Procedure Integration),就是把被调用函数的代码拷贝到调用者中,从而避免函数调用。
|
||||
|
||||
对于我们现在使用的面向对象的语言来说,有很多短方法,比如getter、settter方法。这些方法内联以后,不仅仅可以减少函数调用的开销,还可以带来其他的优化机会。在探究Java的JIT编译器时,我就会为你剖析一个内联的例子。
|
||||
|
||||
**第三种:内联扩展(In-Line Expansion)。**
|
||||
|
||||
内联扩展跟普通内联类似,也是在调用的地方展开代码。不过内联扩展被展开的代码,通常是手写的、高度优化的汇编代码。
|
||||
|
||||
**第四种:叶子程序优化(Leaf-Routine Optimization)。**
|
||||
|
||||
叶子程序,是指不会再调用其他程序的函数(或过程)。因此,它也可以对栈的使用做一些优化。比如,你甚至可以不用生成栈帧,因为根据某些调用约定,程序可以访问栈顶之外一定大小的内存。这样就省去了保存原来栈顶、修改栈顶指针等一系列操作。
|
||||
|
||||
### 思路8:对控制流做优化
|
||||
|
||||
通过对程序的控制流分析,我们可以发现很多优化的机会。这就好比在做公司管理,优化业务流程,就会提升经营效率。我们来看一下这方面的优化方法有哪些。
|
||||
|
||||
**第一种:不可达代码消除(Unreacheable-code Elimination)**。根据控制流的分析,发现有些代码是不可能到达的,可以直接删掉,比如return语句后面的代码。
|
||||
|
||||
**第二种:死代码删除(Dead-code Elimination)**。通过对流程的分析,发现某个变量赋值了以后,后面根本没有再用到这个变量。这样的代码就是死代码,就可以删除。
|
||||
|
||||
**第三种:If简化(If Simplification)**。在讲常量传播时我们就见到过,如果有可能if条件肯定为真或者假,那么就可以消除掉if结构中的then块、else块,甚至整个消除if结构。
|
||||
|
||||
**第四种:循环简化(Loop Simplification)**。也就是把空循环或者简单的循环,变成直线代码,从而增加了其他优化的机会,比如指令的流水线化。
|
||||
|
||||
**第五种:循环反转(Loop Inversion)**。这是对循环语句常做的一种优化,就是把一个while循环改成一个repeat…until循环(或者do…while循环)。这样会使基本块的结构更简化,从而更有利于其他优化。
|
||||
|
||||
**第六种:拉直(Straightening)**。如果发现两个基本块是线性连接的,那可以把它们合并,从而增加优化机会。
|
||||
|
||||
**第七种:反分支(Unswitching)**。也就是减少程序分支,因为分支会导致程序从一个基本块跳到另一个基本块,这样就不容易做优化。比如,把循环内部的if分支挪到循环外面去,先做if判断,然后再执行循环,这样总的执行if判断的次数就会减少,并且循环体里面的基本块不那么零碎,就更加容易优化。
|
||||
|
||||
这七种优化方法,都是对控制流的优化,有的减少了基本块,有的减少了分支,有的直接删除了无用的代码。
|
||||
|
||||
## 代码优化所依赖的分析方法
|
||||
|
||||
前面我列举了很多优化方法,目的是让你认识到编译器花费大量时间去做的,到底都是一些什么工作。当然了,我只是和你列举了最常用的一些优化方法,不过这已经足够帮助你建立对代码优化的直觉认知了。我们在研究具体的编译器的时候,还会见到其他一些优化方法。不过你不用担心,根据上面讲到的各种优化思路,你可以举一反三,非常快速地理解这些新的优化方法。
|
||||
|
||||
上述优化方法,有的比较简单,比如常数折叠,依据AST或MIR做点处理就可以完成。但有些优化,就需要比较复杂的分析方法做支撑才能完成。这些分析方法包括控制流分析、数据流分析、依赖分析和别名分析等。
|
||||
|
||||
**控制流分析(Control-Flow Analysis,CFA)**。控制流分析是帮助我们建立对程序执行过程的理解,比如哪里是程序入口,哪里是出口,哪些语句构成了一个基本块,基本块之间跳转关系,哪个结构是一个循环结构(从而去做循环优化),等等。
|
||||
|
||||
前面提到的控制流优化,就是要基于对控制流的正确理解。下面要讲的数据流分析算法,在做全局分析的时候,也要基于控制流图(CFG),所以也需要以控制流分析为基础。
|
||||
|
||||
**数据流分析(Data-Flow Analysis,DFA)**。数据流分析,能够帮助我们理解程序中的数据变化情况。我们看一个分析变量活跃性的例子。
|
||||
|
||||
如下图所示,它从后到前顺序扫描代码,花括号中的是在当前位置需要的变量的集合。如果某个变量不被需要,那就可以做死代码删除的优化。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/68/8f/6812816f06d89014070633aed1ee3f8f.jpg" alt="">
|
||||
|
||||
经过多遍扫描和删除后,最后的代码会精简成一行:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/00/b9/00e66d72db71e2fd1f674af2589e89b9.jpg" alt="">
|
||||
|
||||
关于数据流分析框架的详细描述,你可以再参考下其他资料(比如,《编译原理之美》专栏第[27](https://time.geekbang.org/column/article/155338)和[28](https://time.geekbang.org/column/article/156878)两讲)。
|
||||
|
||||
除了做变量活跃性分析以外,数据流分析方法还可以做很多有用的分析。比如,可达定义分析(Reaching Definitions Analysis)、可用表达式分析(Available Expressions Analysis)、向上暴露使用分析(Upward Exposed Uses Analysis)、拷贝传播分析(Copy-Propagation Analysis)、常量传播分析(Constant-Propagation Analysis)、局部冗余分析(Partial-Redundancy Analysis)等。
|
||||
|
||||
就像基于变量活跃性分析可以做死代码删除的优化一样,上述分析是做其他很多优化的基础。
|
||||
|
||||
**依赖分析(Dependency Analysis)**。依赖分析,就是分析出程序代码的控制依赖(Control Dependency)和数据依赖(Data Dependency)关系。这对指令排序和缓存优化很重要。
|
||||
|
||||
指令排序会在下一讲介绍。它能通过调整指令之间的顺序来提升执行效率。但指令排序不能打破指令间的依赖关系,否则程序的执行就不正确。
|
||||
|
||||
**别名分析(Alias Analysis)**。在C、C++等可以使用指针的语言中,同一个内存地址可能会有多个别名,因为不同的指针都可能指向同一个地址。编译器需要知道不同变量是否是别名关系,以便决定能否做某些优化。
|
||||
|
||||
好了,你已经了解了优化的方法和所依赖的分析方法。那么,这些方法这么多,哪些优化方法更重要,优化的顺序又是什么呢?
|
||||
|
||||
## 优化方法的重要性和顺序
|
||||
|
||||
我们先看看哪些优化方法更重要。
|
||||
|
||||
有些优化,比如对循环的优化,对每门语言都很重要,因为循环优化的收益很大。
|
||||
|
||||
而有些优化,对于特定的语言更加重要。在课程后面分析像Java、JavaScript这样的面向对象的现代语言时,你会看到,内联优化和逃逸分析的收益就比较大。而对于某些频繁使用尾递归的函数式编程语言来说,尾递归的优化就必不可少,否则性能损失太大。
|
||||
|
||||
至于优化的顺序,有的优化适合在早期做(基于HIR和MIR),有的优化适合在后期做(基于LIR和机器代码)。并且,你通过前面的例子也可以看到,一般做完某个优化以后,会给别的优化带来机会,所以经常会在执行某个优化算法的时候,调用了另一个优化算法,而同样的优化算法也可能会运行好几遍。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天这讲,我带你认识了很多常见的优化方法和背后的分析方法。我们很难一下子记住所有的方法,但完全可以先对这些概念建立总体印象。这样可以避免在研究具体编译器时,我们产生“瞎子摸象”的感觉。
|
||||
|
||||
另外,熟悉我提到的那些名词术语也很重要,因为它们经常在代码注释和相关文献里出现。这些名词要成为你的一项基本功。
|
||||
|
||||
我把今天的课程内容,也整理成了思维导图,供你复习、参考。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ec/a0/ec83e01a9471d19b285aac365694c3a0.jpg" alt="">
|
||||
|
||||
在课程的第二个模块“真实编译器解析篇”的时候,我会和你分析某些优化算法具体的实现细节,并带你跟踪编译优化的过程。
|
||||
|
||||
根据我的经验,当你写的程序对性能要求很高的时候,你需要能够跟踪了解编译优化的过程,看看如何才能达到最好的优化效果。我之前写过与内存计算有关的程序,就特别关注如何才能让编译器做向量优化。因为是否使用向量,性能差别很大。现在做AI工作的同学,一定也有类似的需求。
|
||||
|
||||
还有些开源项目,它们的性能与内联关系密切。这就要做一定的调优,以确保使用频率最高、性能影响最大的函数全部内联。
|
||||
|
||||
还有,Chrome、Android和Flutter共同使用的二维图形引擎Skia对性能很敏感,所以即使在Windows平台上,仍然要求用Clang编译。为啥坚持用Clang编译呢?因为Skia跟LLVM的优化方法是紧密配合的,换了其他编译器就达不到这么好的优化效果。
|
||||
|
||||
类似的例子还有很多。了解优化,能够充分利用编译器的优化能力,应该是我们想拥有的一项高级技能。
|
||||
|
||||
## 一课一思
|
||||
|
||||
你可以比较一下值编号和公共子表达式消除这两个优化方法,说说它们的相同点和不同点吗?你能举出一个例子来,是其中一个算法能做优化,而另一个算法不能的吗?
|
||||
|
||||
欢迎在留言区中分享你的思考,也欢迎你把这节课分享给你的朋友。
|
||||
|
||||
## 参考资料
|
||||
|
||||
1. 龙书(Compilers Principles, Techniques and Tools):第9章,机器无关的优化,里面介绍了各种优化算法。
|
||||
1. 鲸书(Advanced Compiler Design and Implementation)中讲优化的算法有很多,第7~15章你都可以看看。
|
||||
320
极客时间专栏/编译原理实战课/预备知识篇/08 | 代码生成:如何实现机器相关的优化?.md
Normal file
320
极客时间专栏/编译原理实战课/预备知识篇/08 | 代码生成:如何实现机器相关的优化?.md
Normal file
@@ -0,0 +1,320 @@
|
||||
<audio id="audio" title="08 | 代码生成:如何实现机器相关的优化?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9a/ba/9af81d5c8381d98758b1ccb6504f23ba.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。我们继续来学习编译器后端的技术。
|
||||
|
||||
在编译过程的前几个阶段之后,编译器生成了AST,完成了语义检查,并基于IR运行了各种优化算法。这些工作,基本上都是机器无关的。但编译的最后一步,也就是生成目标代码,则必须是跟特定CPU架构相关的。
|
||||
|
||||
这就是编译器的后端。不过,后端不只是简单地生成目标代码,它还要完成与机器相关的一些优化工作,确保生成的目标代码的性能最高。
|
||||
|
||||
这一讲,我就从机器相关的优化入手,带你看看编译器是如何通过指令选择、寄存器分配、指令排序和基于机器代码的优化等步骤,完成整个代码生成的任务的。
|
||||
|
||||
首先,我们来看看编译器后端的任务:生成针对不同架构的目标代码。
|
||||
|
||||
## 生成针对不同CPU的目标代码
|
||||
|
||||
我们已经知道,编译器的后端要把IR翻译成目标代码,那么要生成的目标代码是什么样子的呢?
|
||||
|
||||
我以foo.c函数为例:
|
||||
|
||||
```
|
||||
int foo(int a, int b){
|
||||
return a + b + 10;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
执行“**clang -S foo.c -o foo.x86.s**”命令,你可以得到对应的x86架构下的汇编代码(为了便于你理解,我进行了简化):
|
||||
|
||||
```
|
||||
#序曲
|
||||
pushq %rbp
|
||||
movq %rsp, %rbp #%rbp是栈底指针
|
||||
|
||||
#函数体
|
||||
movl %edi, -4(%rbp) #把第1个参数写到栈里第一个位置(偏移量为4)
|
||||
movl %esi, -8(%rbp) #把第2个参数写到栈里第二个位置(偏移量为8)
|
||||
movl -4(%rbp), %eax #把第1个参数写到%eax寄存器
|
||||
addl -8(%rbp), %eax #把第2个参数加到%eax
|
||||
addl $10, %eax #把立即数10加到%eax,%eax同时是放返回值的地方
|
||||
|
||||
#尾声
|
||||
popq %rbp
|
||||
retq
|
||||
|
||||
```
|
||||
|
||||
小提示:上述汇编代码采用的是GNU汇编器的代码格式,源操作数在前面,目的操作数在后面。
|
||||
|
||||
我在[第1讲](https://time.geekbang.org/column/article/242479)中说过,要翻译成目标代码,编译器必须要先懂得目标代码,就像做汉译英一样,我们必须要懂得英语。可是,**通常情况下,我们会对汇编代码比较畏惧,觉得汇编语言似乎很难学。**其实不然。
|
||||
|
||||
补充说明:有些编译器,是先编译成汇编代码,再通过汇编器把汇编代码转变成机器码。而另一些编译器,是直接生成机器码,并写成目标文件,这样编译速度会更快一些。但这样的编译器一般就要带一个反汇编器,在调试等场合把机器码转化成汇编代码,这样我们看起来比较方便。<br>
|
||||
因此,在本课程中,我有时会不区分机器码和汇编代码。我可能会说,编译器生成了某机器码,但实际写给你看的是汇编代码,因为文本化的汇编代码才方便阅读。你如果看到这样的表述,不要感到困惑。
|
||||
|
||||
那为什么我说汇编代码不难学呢?你可以去查阅下各种不同CPU的指令。然后,你就会发现这些指令其实主要就那么几种,一类是做加减乘除的(如add指令),一类是做内存访问的(如mov、lea指令),一类是控制流程的(如jmp、ret指令),等等。说得夸张一点,这就是个复杂的计算器。
|
||||
|
||||
只不过,相比于高级语言,汇编语言涉及的细节比较多。它是啰嗦,但并不复杂。那我再分享一个我学习汇编代码的高效方法:**让编译器输出高级语言的汇编代码,多看一些各种情况下汇编代码的写法,自然就会对汇编语言越来越熟悉了。**
|
||||
|
||||
不过,虽然针对某一种CPU的汇编并不难,但问题是**不同架构的CPU,其指令是不同的。编译器的后端每支持一种新的架构,就要有一套新的代码。**这对写一个编译器来说,就是很大的工作量了。
|
||||
|
||||
我来举个例子。我们使用“**clang -S -target armv7a-none-eabi foo.c -o foo.armv7a.s**”命令,生成一段针对ARM芯片的汇编代码:
|
||||
|
||||
```
|
||||
//序曲
|
||||
sub sp, sp, #8 //把栈扩展8个字节,用于放两个参数,sp是栈顶指针
|
||||
|
||||
//函数体
|
||||
str r0, [sp, #4] //把第1个参数写到栈顶+4的位置
|
||||
str r1, [sp] //把第2个参数写到栈顶位置
|
||||
ldr r0, [sp, #4] //把第1个参数从栈里加载到r0寄存器
|
||||
ldr r1, [sp] //把第2个参数从站立加载到r1寄存器
|
||||
add r0, r0, r1 //把r1加到r0,结果保存在r0
|
||||
add r0, r0, #10 //把常量10加载到r0,结果保存在r0,r0也是放返回值的地方
|
||||
|
||||
//尾声
|
||||
add sp, sp, #8 //缩减栈
|
||||
bx lr //返回
|
||||
|
||||
```
|
||||
|
||||
把这段代码,与前面生成的针对x86架构的汇编代码比较一下,你马上就会发现一些不同。这两种CPU,完成相同功能所使用的汇编指令和寄存器都不同。我们来分析一下其中的原因。
|
||||
|
||||
x86的汇编,mov指令的功能很强大,可以从内存加载到寄存器,也可以从寄存器保存回内存,还可以从内存的一个地方拷贝到另一个地方、从一个寄存器拷贝到另一个寄存器。add指令的操作数也可以使用内存地址。
|
||||
|
||||
而在ARM的汇编中,从寄存器到内存要使用str(也就是Store)指令,而从内存到寄存器要使用ldr(也就是Load)指令。对于加法指令add而言,两个操作数及计算结果都必须使用寄存器。
|
||||
|
||||
知识扩展:ARM的这种指令风格叫做**Load-Store架构**。在这种架构下,指令被分为内存访问(Load和Store)和ALU操作两大类,而后者只能在寄存器上操作。各种RISC指令集都是Load-Store架构的,比如PowerPC、RISC-V、ARM和MIPS等。<br>
|
||||
而像x86这种CISC指令,叫做**Register-Memory架构**,在指令里可以混合使用内存地址和寄存器。
|
||||
|
||||
为了支持不同的架构,你可以通过手写算法来生成目标代码,但这样工作量显然会很大,维护负担也比较重。
|
||||
|
||||
另一种方法,是编写“代码生成器的生成器”。也就是说,你可以把CPU架构的各种信息(比如有哪些指令、指令的特点、有哪些寄存器等)描述出来,然后基于这些信息生成目标代码的生成器,就像根据语法规则,用ANTLR、bison这样的工具来生成语法解析器一样。
|
||||
|
||||
经过这样的处理,虽然我们生成的目标代码是架构相关的,但中间的处理算法却可以尽量做成与架构无关的。
|
||||
|
||||
## 生成目标代码时的优化工作
|
||||
|
||||
生成目标代码的过程要进行多步处理。比如,你一定注意到了,前面foo.c函数示例程序生成的汇编代码是不够优化的:它把参数信息从寄存器写到栈里,然后再从栈里加载到寄存器,用于计算。实际上,改成更优化的算法,是不需要内存访问的,从而节省了内存读写需要花费的大量时间。
|
||||
|
||||
所以接下来,我就带你一起了解在目标代码生成过程中进行的优化处理,包括指令选择、寄存器分配、指令排序、基于机器代码的优化等步骤。在这个过程中,你会知道编译器的后端,是如何充分发挥硬件的性能的。
|
||||
|
||||
首先,我们看看指令选择,它的作用是在完成相同功能的情况下,选择代价更低的指令组合。
|
||||
|
||||
### 指令选择
|
||||
|
||||
为了理解指令选择有什么用,这里我和你分享三个例子吧。
|
||||
|
||||
第一个例子:对于foo.c示例代码,在编译时加上“-O2”指令,就会得到如下的优化代码:
|
||||
|
||||
```
|
||||
#序曲
|
||||
pushq %rbp
|
||||
movq %rsp, %rbp
|
||||
|
||||
#函数体
|
||||
leal 10(%rdi,%rsi), %eax
|
||||
|
||||
#尾声
|
||||
popq %rbp
|
||||
retq
|
||||
|
||||
```
|
||||
|
||||
它使用了lea指令,可以一次完成三个数的相加,并把结果保存到%eax。这样一个lea指令,代替了三条指令(一条mov,两条add),显然更优化。
|
||||
|
||||
这揭示了我们生成代码时面临的一种情况:**对于相同的源代码和IR,编译器可以生成不同的指令,而我们要选择代价最低的那个。**
|
||||
|
||||
第二个例子:对于“a[i]=b”这样一条语句,要如何生成代码呢?
|
||||
|
||||
你应该知道数组寻址的原理,a[i]的地址就是从数组a的起始地址往后偏移i个单位。对于整型数组来说,a[i]的地址就是a+i*4。所以,我可以用两条指令实现这个赋值操作:第一条指令,计算a[i]的地址;第二条指令,把b的值写到这个地址。
|
||||
|
||||
数组操作是很常见的现象,于是x86芯片专门提供了一种寻址方式,简化了数组的寻址,这就是**间接内存访问。**间接内存访问的完整形式是:偏移量(基址,索引值,字节数),其地址是:基址 + 索引值*字节数 + 偏移量。
|
||||
|
||||
所以,如果我们把a的地址放到%rdi,i的值放到%rax,那么a[i]的地址就是(%rdi,%rax,4)。这样的话,a[i]=b用一条mov指令就能完成。
|
||||
|
||||
第三个例子。我们天天在用的x86家族的芯片,它支持很多不同的指令集,比如SSE、AVX、FMA等,每个指令集里都有能完成加减乘除运算的指令。当然,每个指令集适合使用的场景也不同,我们要根据情况选择最合适的指令。
|
||||
|
||||
好了,现在你已经知道了指令选择的作用了,它在具体实现上有很多算法,比如树覆盖算法,以及BURS(自底向上的重写系统)等。
|
||||
|
||||
我们再看一下刚刚这段优化后的代码,你是不是发现了,优化后的算法对寄存器的使用也更加优化了。没错,接下来我们就分析下寄存器分配。
|
||||
|
||||
### 寄存器分配
|
||||
|
||||
优化后的代码,去掉了内存操作,直接基于寄存器做加法运算,比优化之前的运行速度要快得多(我在[第5讲](https://time.geekbang.org/column/article/246281)提到过,内存访问比寄存器访问大约慢100倍)。
|
||||
|
||||
同样的,ARM的汇编代码也可以使用“-O2”指令优化。优化完毕以后,最后剩下的代码只有三行。而且因为不需要访问内存,所以连栈顶指针都不需要挪动,进一步减少了代码量。
|
||||
|
||||
```
|
||||
add r0, r0, r1
|
||||
add r0, r0, #10
|
||||
bx lr
|
||||
|
||||
```
|
||||
|
||||
对于编译器来说,肯定要尽量利用寄存器,不去读写内存。因为内存读写对于CPU来说就是IO,性能很低。特别是像函数中用到的本地变量和参数,它们在退出作用域以后就没用了,所以能放到寄存器里,就放寄存器里吧。
|
||||
|
||||
在IR中,通常我们会假设寄存器是无限的(就像LLVM的IR),但实际CPU中的寄存器是有限的。所以,我们就要用一定的算法,把寄存器分配给使用最频繁的变量,比如循环中的变量。而对于超出物理寄存器数量的变量,则“溢出”到栈里,通过内存来保存。
|
||||
|
||||
寄存器分配的算法有很多种。一个使用比较广泛的算法是寄存器染色算法,它的特点是计算结果比较优化,但缺点是计算量比较大。
|
||||
|
||||
另一个常见的算法是线性扫描算法,它的优点是计算速度快,但缺点是有可能不够优化,适合需要编译速度比较快的场景,比如即时编译。在解析Graal编译器的时候,你会看到这种算法的实现。
|
||||
|
||||
寄存器分配算法对性能的提升是非常显著的。接下来我要介绍的指令排序,对性能的提升同样非常显著。
|
||||
|
||||
### 指令排序
|
||||
|
||||
首先我们来看一个例子。下面示例程序中的params函数,有6个参数:
|
||||
|
||||
```
|
||||
int params(int x1,int x2,int x3,int x4,int x5,int x6){
|
||||
return x1 + x2 + x3 + x4 + x5 + x6 + 10;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
把它编译成ARM汇编代码,如下:
|
||||
|
||||
```
|
||||
//序曲
|
||||
push {r11, lr} //把r11和lr保存到栈中,lr里面是返回地址
|
||||
mov r11, sp //把栈顶地址保存到r11
|
||||
|
||||
//函数体
|
||||
add r0, r0, r1 //把参数2加到参数1,保存在r0
|
||||
ldr lr, [r11, #8] //把栈里的参数5加载到lr,这里是把lr当通用寄存器用
|
||||
add r0, r0, r2 //把参数3加到r0
|
||||
ldr r12, [r11, #12] //把栈里的参数6加载到r12
|
||||
add r0, r0, r3 //把参数4加到r0
|
||||
add r0, r0, lr //把参数5加到r0
|
||||
add r0, r0, r12 //把参数6加到r0
|
||||
add r0, r0, #10 //把立即数加到r0
|
||||
|
||||
//尾声
|
||||
pop {r11, pc} //弹出栈里保存的值。注意,原来lr的值直接赋给了pc,也就是程序计数器,所以就跳转到了返回地址
|
||||
|
||||
```
|
||||
|
||||
根据编译时使用的调用约定,其中有4个参数是通过寄存器传递的(r0~r3),还有两个参数是在栈里传递的。
|
||||
|
||||
值得注意的是,在把参数5和参数6用于加法操作之前,它们就被提前执行加载(ldr)命令了。那,为什么会这样呢?这就涉及到CPU执行指令的一种内部机制:**流水线(Pipeline)。**
|
||||
|
||||
原来,CPU内部是分成多个功能单元的。对于一条指令,每个功能单元处理完毕以后,交给下一个功能单元,然后它就可以接着再处理下一条指令。所以,在同一时刻,不同的功能单元实际上是在处理不同的指令。这样的话,多条指令实质上是并行执行的,从而减少了总的执行时间,这种并行叫做**指令级并行。**
|
||||
|
||||
在下面的示意图中,每个指令的执行被划分成了5个阶段,每个阶段占用一个时钟周期,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/af/0c/af8fd31e7e2e4af759569882a0b0730c.jpg" alt="">
|
||||
|
||||
因为每个时钟周期都可以开始执行一条新指令,所以虽然一条指令需要5个时钟周期才能执行完,但在同一个时刻,却可以有5条指令并行执行。
|
||||
|
||||
**但是有的时候,指令之间会存在依赖关系,后一条指令必须等到前一条指令执行完毕才能运行**(在上一讲,我们曾经提到过依赖分析,指令排序就会用到依赖分析的结果)。比如,前面的示例程序中,在使用参数5的值做加法之前,必须要等它加载到内存。这样的话,指令就不能并行了,执行时间就会大大延长。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ae/e3/ae0a9bb26533c3e153753cc92d843ee3.jpg" alt="">
|
||||
|
||||
讲到这里,你就明白了,为什么在示例程序中,要把ldr指令提前执行,目的就是为了更好地利用流水线技术,实现指令级并行。
|
||||
|
||||
补充:这里我把执行阶段分为5段,只是给你举个例子。很多实际的CPU架构,划分了更多的阶段。比如,某类型的奔腾芯片支持21段,那理论上也就意味着可以有21条指令并行执行,但它的前提是必须做好指令排序的优化。<br>
|
||||
另外,现代一些CISC的CPU在硬件层面支持乱序执行(Out-of-Order)。一批指令给到CPU后,它也会在内部打乱顺序去优化执行。而RISC芯片一般不支持乱序执行,所以像ARM这样的芯片,做指令排序就更加重要。
|
||||
|
||||
另外,在上一讲,我提到过对循环做优化的一种技术,叫做**循环展开(Loop Unroll)**,它会把循环体中的代码重复多次,与之对应的是减少循环次数。这样一个基本块中就会有更多条指令,增加了通过指令排序做优化的机会。
|
||||
|
||||
指令排序的算法也有很多种,比如基于数据依赖图的List Scheduling算法。在后面的课程中,我会带你考察一下真实世界中的编译器都使用了什么算法。
|
||||
|
||||
OK,了解完指令排序以后,还有什么优化可以做呢?
|
||||
|
||||
### 窥孔优化(Peephole Optimization)
|
||||
|
||||
基于LIR或目标代码,代码还有被进一步优化的可能性。这就是代码优化的特点。比如,你在前面做了常数折叠以后,后面的处理步骤修改了代码或生成新的代码以后,可能还会产生出新的常数折叠的机会。另外,有些优化也只有在目标代码的基础上才方便做。
|
||||
|
||||
给你举个例子吧:假设相邻两条指令,一条指令从寄存器保存数据到栈里,下一条指令又从栈里原封不动地把数据加载到原来的寄存器,那么这条加载指令就是冗余的,可以去掉。
|
||||
|
||||
```
|
||||
str r0, [sp, #4] //把r0的值保存到栈顶+4的位置
|
||||
ldr r0, [sp, #4] //把栈顶+4位置的值加载到r0寄存器
|
||||
|
||||
```
|
||||
|
||||
基于目标代码的优化,最常用的方法是**窥孔优化(Peephole Optimization)**。窥孔优化的思路,是提供一个固定大小的窗口,比如能够容纳20条指令,并检查窗口内的指令,看看是否可以优化。然后再往下滑动窗口,再次检查优化机会。
|
||||
|
||||
最后,还有一个因素会影响目标代码的生成,就是调用约定。
|
||||
|
||||
## 调用约定的影响
|
||||
|
||||
还记得前面示例的x86的汇编代码吗?其中的%edi寄存器用来传递第一个参数,%esi寄存器用来传递第二个参数,这就是遵守了一种广泛用于Unix和Linux系统的调用约定“[System V AMD64 ABI](https://github.com/hjl-tools/x86-psABI/wiki/x86-64-psABI-1.0.pdf)”。这个调用约定规定,对于整型参数,前6个参数可以用寄存器传递,6个之后的参数就要基于栈来传递。
|
||||
|
||||
```
|
||||
#序曲
|
||||
pushq %rbp
|
||||
movq %rsp, %rbp #%rbp是栈底指针
|
||||
|
||||
#函数体
|
||||
movl %edi, -4(%rbp) #把第1个参数写到栈里第一个位置(偏移量为4)
|
||||
movl %esi, -8(%rbp) #把第2个参数写到栈里第二个位置(偏移量为8)
|
||||
movl -4(%rbp), %eax #把第1个参数写到%eax寄存器
|
||||
addl -8(%rbp), %eax #把第2个参数加到%eax
|
||||
addl $10, %eax #把立即数10加到%eax,%eax同时是放返回值的地方。
|
||||
|
||||
#尾声
|
||||
popq %rbp
|
||||
retq
|
||||
|
||||
```
|
||||
|
||||
知识扩展:ABI是Application Binary Interface的缩写,也就是应用程序的二进制接口。通常,ABI里面除了规定调用约定外,还要包括二进制文件的格式、进程初始化的方式等更多内容。
|
||||
|
||||
而在看ARM的汇编代码时,我们会发现,它超过了4个参数就要通过栈来传递。实际上,它遵循的是一种不同ABI,叫做EABI(嵌入式应用程序二进制接口)。在调用Clang做编译的时候,-target参数“**armv7a-none-eabi**”的最后一部分,就是指定了EABI。
|
||||
|
||||
```
|
||||
//序曲
|
||||
sub sp, sp, #8 //把栈扩展8个字节,用于放两个参数,sp是栈顶指针
|
||||
|
||||
//函数体
|
||||
str r0, [sp, #4] //把第1个参数写到栈顶+4的位置
|
||||
str r1, [sp] //把第2个参数写到栈顶位置
|
||||
ldr r0, [sp, #4] //把第1个参数从栈里加载到r0寄存器
|
||||
ldr r1, [sp] //把第2个参数从站立加载到r1寄存器
|
||||
add r0, r0, r1 //把r1加到r0,结果保存在r0
|
||||
add r0, r0, #10 //把常量10加载到r0,结果保存在r0,r0也是放返回值的地方
|
||||
|
||||
//尾声
|
||||
add sp, sp, #8 //缩减栈
|
||||
bx lr //返回
|
||||
|
||||
```
|
||||
|
||||
在实现编译器的时候,你可以发明自己的调用约定,比如哪些寄存器用来放参数、哪些用来放返回值,等等。但是,如果你要使用别的语言编译好的目标文件,或者你想让自己的编译器生成的目标文件被别人使用,那你就要遵守某种特定的ABI标准。
|
||||
|
||||
## 后端处理的整体过程
|
||||
|
||||
好了,到这里,我已经介绍完了生成目标代码过程中所做的各种优化处理。**那么,我们怎么把它们串成一个整体呢?**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e5/06/e500f1602aa03fd6893933517bf71a06.jpg" alt="">
|
||||
|
||||
在实际实现时,我们通常是先做指令选择,然后做一次指令排序。在分配完寄存器以后,还要再做一次指令排序,因为寄存器分配算法会产生新的指令排序优化的机会。比如,一些变量会溢出到栈里,从而增加了一些内存访问指令。
|
||||
|
||||
这个处理过程,其实也是IR不断lower的过程。一开始是MIR,在做了指令选择以后,就变成了具体架构相关的LIR了。在没做寄存器分配之前,我们在LIR中用到寄存器还是虚拟的,数量是无限的,做完分配以后,就变成具体的物理寄存器的名称了。
|
||||
|
||||
与机器相关的优化(如窥孔优化)也会穿插在整个过程中。最后一个步骤,是通过一个Emit目标代码的程序生成目标代码。因为IR已经被lower得很接近目标代码了,所以这个翻译程序是比较简单的。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天这一讲,我带你认识了编译器在后端的主要工作,也就是生成目标代码时,所需要的各种优化和处理。你需要注意理解每一步处理的原理,比如到底为什么需要做指令选择,形成直观认识。
|
||||
|
||||
这一讲,我没有带你去深入算法的细节,而是希望先带你建立一个整体的认知。在我们考察真实的编译器时,你要注意研究它们的后端是如何实现的。
|
||||
|
||||
我把今天的课程内容,也整理成了思维导图,供你参考。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b7/62/b71852806b0a7531c62c2ddc3fe51e62.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
用Clang(或gcc)来生成汇编代码,对研究生成目标代码时的优化效果非常有帮助。你可以设计一个C语言的简单函数,测试出编译器在指令选择、寄存器分配或指令排序的任意方面的优化效果吗?
|
||||
|
||||
你可以比较下,带和不带“-O2”参数生成的汇编代码,有什么不同。你还可以查看手册,使用更多的选项(比如对于[x](https://clang.llvm.org/docs/ClangCommandLineReference.html#x86)[86架构](https://clang.llvm.org/docs/ClangCommandLineReference.html#x86),你可以控制是否使用AVX指令集)。这个练习,会帮助你获得更多的直观理解。
|
||||
|
||||
在留言区,把你动手实验的成果分享出来吧,我们一起交流讨论。如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
## 参考链接
|
||||
|
||||
1. 关于汇编代码、寄存器、调用约定等内容更详细的介绍,你可以参考《编译原理之美》的第[23](https://time.geekbang.org/column/article/150798)、[24](https://time.geekbang.org/column/article/151939)讲。
|
||||
1. 关于指令选择的算法,你可以参考《编译原理之美》的[第29讲](https://time.geekbang.org/column/article/158315),我介绍了一个树覆盖算法。
|
||||
1. 关于寄存器分配的算法,你也可以参考《编译原理之美》的[第29讲](https://time.geekbang.org/column/article/158315),我介绍了一个寄存器染色算法。
|
||||
1. 关于指令排序的算法,你可以参考《编译原理之美》的[第30讲](https://time.geekbang.org/column/article/159552),深入去看一下基于数据依赖图的List Scheduling算法。
|
||||
355
极客时间专栏/编译原理实战课/预备知识篇/知识地图 | 一起来复习编译技术核心概念与算法.md
Normal file
355
极客时间专栏/编译原理实战课/预备知识篇/知识地图 | 一起来复习编译技术核心概念与算法.md
Normal file
@@ -0,0 +1,355 @@
|
||||
<audio id="audio" title="知识地图 | 一起来复习编译技术核心概念与算法" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ca/04/cab42ab557be6fb1f47819f2696ca104.mp3"></audio>
|
||||
|
||||
你好,我是学习委员朱英达。
|
||||
|
||||
在“预备知识篇”这个模块,宫老师系统地梳理了编译过程中各个阶段的核心要点,目的就是让我们建立一个编译原理的基础知识体系。那到今天为止,我们就学完了这部分内容,迈出了编译之旅中扎实的第一步。不知道你对这些知识掌握得怎样了?
|
||||
|
||||
**为了复习,也为了检测我们的学习成果,我根据自己的知识积累和学习情况,整理了一张知识大地图,你可以根据这张地图中标记的七大编译阶段,随时速查常用的编译原理概念和关键算法。**
|
||||
|
||||
如果你也总结了知识地图,那你可以对照着我这个,给自己一个反馈,看看它们之间有哪些异同点,我们可以在留言区中一起交流和讨论。
|
||||
|
||||
不过知识地图的形式,虽然便于你保存、携带、速查,但考虑到图中涉及的概念等内容较多,不方便查看和检索。**所以,我还把地图上的知识点,用文字的形式帮你梳理出来了。**你可以对照着它,来复习和回顾编译技术的核心概念和算法的知识点,构建自己的知识框架。
|
||||
|
||||
你在学习这些预备知识的过程中,可能会发现,宫老师并没有非常深入地讲解编译原理的具体概念、理论和算法。所以,如果你想继续深入学习这些基础知识,可以根据宫老师在每讲最后给出的参考资料,去学习龙书、虎书、鲸书等经典编译原理书籍。当然,你也可以去看看宫老师的第一季专栏课《编译原理之美》。
|
||||
|
||||
在我看来,相较于编译方面的教科书而言,《编译原理之美》这门课的优势在于,更加通俗易懂、与时俱进,既可以作为新手的起步指导,也能够帮助已经熟悉编译技术的工程师扩展视野,我很推荐你去学习这门课。所以,我邀请编辑添加了相应的知识点到《编译原理之美》的文章链接,如果你有深入学习的需要,你会很方便地找到它。
|
||||
|
||||
好了,一起开始复习吧!
|
||||
|
||||
## 一、词法分析:根据词法规则,把字符串转换为Token
|
||||
|
||||
### 核心概念:正则文法
|
||||
|
||||
- 正则文法:词法分析工作的主要文法,它涉及的重要概念是正则表达式。
|
||||
- 正则表达式:正则文法的一种直观描述,它是按照指定模式匹配字符串的一种字符组合。
|
||||
- 正则表达式工具:字符串的模式匹配工具。大多数程序语言都内置了正则表达式的匹配方法,也可以借助一些编译工具,自动化根据正则表达式生成字符串匹配程序,例如C++的Lex/Yacc以及Java的ANTLR。
|
||||
|
||||
### 具体实现:手工构造词法分析器、自动生成词法分析器
|
||||
|
||||
[**手工构造词法分析器**](https://time.geekbang.org/column/article/118378)
|
||||
|
||||
- 构造词法分析器使用的计算模型:有限自动机(FSA)。它是用于识别正则文法的一种程序实现方案。
|
||||
- 其组成的词法单元是Token,也就是指程序中标记出来的单词和标点符号,它可以分成关键字、标识符、字面量、操作符号等多个种类。
|
||||
- 在实际的编译器中,词法分析器一般都是手写的。
|
||||
|
||||
[**自动生成词法分析器**](https://time.geekbang.org/column/article/137286)
|
||||
|
||||
- 具体实现思路:把一个正则表达式翻译成NFA,然后把NFA转换成DFA。
|
||||
- DFA:确定的有限自动机。它的特点是:该状态机在任何一个状态,基于输入的字符,都能做一个确定的状态转换。
|
||||
- NFA:不确定的有限自动机。它的特点是:该状态机中存在某些状态,针对某些输入,不能做一个确定的转换。这里可以细分成两种情况:一种是对于一个输入,它有两个状态可以转换;另一种是存在ε转换的情况,也就是没有任何字符输入的情况下,NFA也可以从一个状态迁移到另一个状态。
|
||||
|
||||
### 技术难点
|
||||
|
||||
**首先,你需要注意,NFA和DFA都有各自的优缺点,以及不同的适用场景。**
|
||||
|
||||
- NFA:优点是在设计上更简单直观,缺点是它无法避免回溯问题,在某些极端的情况下可能会造成编译器运行的性能低下。主要适用于状态较为简单,且不存在回溯的场景。
|
||||
- DFA:优点是它可以避免回溯问题,运行性能较高,缺点是DFA通常不容易直接设计出来,需要通过一系列方案,基于NFA的转换而得到,并且需要占用额外的空间。主要适用于状态较为复杂,或者对时间复杂度要求较为严苛的工业级词法分析器。
|
||||
|
||||
**其次,你需要了解基于正则表达式构造NFA,再去进行模式匹配的算法思路。**
|
||||
|
||||
- 从正则表达式到NFA:这是自动生成词法分析器的一种算法思路。它的翻译方法是,匹配一个字符i —>匹配“或”模式s|t —> 匹配“与”模式st —> 重复模式,如“?”“*”和“+”等符号,它们的意思是可以重复0次、0到多次、1到多次,注意在转换时要增加额外的状态和边。
|
||||
- 从NFA到DFA:NFA的运行可能导致大量的回溯,所以我们可以把NFA转换成DFA,让字符串的匹配过程更简单。从NFA转换成DFA的算法是子集构造法,具体的算法思路你可以参考第16讲。
|
||||
|
||||
## 二、语法分析:依据语法规则,编写语法分析程序,把 Token 串转化成 AST
|
||||
|
||||
### 核心概念:上下文无关文法
|
||||
|
||||
- 上下文无关的意思:在任何情况下,文法的推导规则都是一样的。
|
||||
- 语法规则由4个部分组成:一个有穷的非终结符(或变元)的集合、一个有穷的终结符的集合、一个有穷的产生式集合、一个起始非终结符(变元)。符合这四个特点的文法规则就是上下文无关文法。
|
||||
- 两种描述形式:一种是巴科斯范式(BNF),另一种是巴科斯范式的一种扩展形式(EBNF),它更利于自动化生成语法分析器。其中,产生式、终结符、非终结符、开始符号是巴科斯范式的基本组成要素。
|
||||
- 上下文无关文法与正则文法的区别:上下文无关文法允许递归调用,而正则文法不允许。上下文无关文法比正则文法的表达能力更强,正则文法是上下文无关文法的一个子集。
|
||||
|
||||
### 具体实现:自顶向下、自底向上
|
||||
|
||||
**一种是自顶向下的算法思路,它是指从根节点逐层往下分解,形成最后的AST。**
|
||||
|
||||
- [递归下降算法](https://time.geekbang.org/column/article/119891):它的算法思路是按照语法规则去匹配Token串。优点:程序结构基本上是跟文法规则同构的。缺点:会造成[左递归](https://time.geekbang.org/column/article/120388)和[回溯](https://time.geekbang.org/column/article/125926)问题。**注意**,递归下降是深度优先(DFS)的,只有最左边的子树都生成完了,才会往右生成它的兄弟节点。
|
||||
- [LL算法](https://time.geekbang.org/column/article/138385):对于一些比较复杂的语法规则来说,这个算法可以自动计算出选择不同产生式的依据。方法:从左到右地消化掉 Token。要点:计算 First 和 Follow 集合。
|
||||
|
||||
**另一种是**[**自底向上的算法思路**](https://time.geekbang.org/column/article/139628)**,它是指从底下先拼凑出AST的一些局部拼图,并逐步组装成一棵完整的AST。**
|
||||
|
||||
- 自底向上的语法分析思路:移进,把token加入工作区;规约,在工作区内组装AST的片段。
|
||||
- LR算法和 LL 算法一样,也是从左到右地消化掉 Token。
|
||||
|
||||
### 技术难点
|
||||
|
||||
**首先,你需要掌握LL算法的要点,也就是**[**计算First和Follow集合**](https://time.geekbang.org/column/article/138385)。
|
||||
|
||||
**其次,你要了解LL算法与LR算法的异同点。**
|
||||
|
||||
- LL算法:优点是较为直观、容易实现,缺点是在一些情况下不得不处理左递归消除和提取左因子问题。
|
||||
- LR算法:优点是不怕左递归,缺点是缺少完整的上下文信息,编译错误显示不友好。
|
||||
|
||||
## 三、语义分析:检查程序是否符合语义规则,并为后续的编译工作收集语义信息
|
||||
|
||||
### 核心概念:[上下文相关文法](https://time.geekbang.org/column/article/133737)
|
||||
|
||||
- 属性文法:上下文相关文法对EBNF进行了扩充,在上下文无关的推导过程中,辅助性解决一些上下文相关的问题。
|
||||
- 注意:上下文相关文法没有像状态图、BNF那样直观的分析范式。
|
||||
- 应用场景:控制流检查、闭包分析、引用消解等。
|
||||
|
||||
### 场景案例
|
||||
|
||||
**1.控制流检查**
|
||||
|
||||
像return、break 和continue等语句,都与程序的控制流有关,它们必须符合控制流方面的规则。在 Java 这样的语言中,语义规则会规定:如果返回值不是 void,那么在退出函数体之前,一定要执行一个 return 语句,那么就要检查所有的控制流分支,是否都以 return 语句结尾。
|
||||
|
||||
**2.**[**闭包分析**](https://time.geekbang.org/column/article/131317)
|
||||
|
||||
很多语言都支持闭包。而要正确地使用闭包,就必须在编译期知道哪些变量是自由变量。这里的自由变量是指在本函数外面定义的变量,但被这个函数中的代码所使用。这样,在运行期,编译器就会用特殊的内存管理机制来管理这些变量。所以,对闭包的分析,也是上下文敏感的。
|
||||
|
||||
### 具体实现:引用消解、符号表、类型系统、属性计算
|
||||
|
||||
[**引用消解**](https://time.geekbang.org/column/article/133737)
|
||||
|
||||
- 概念解释:引用消解是一种非常重要的上下文相关的语义规则,它其实就是从符号表里查找被引用的符号的定义。
|
||||
- [作用域](https://time.geekbang.org/column/article/128623):指计算机语言中变量、函数、类等起作用的范围。对于变量来说,为了找到正确的引用,就需要用到作用域。一般来说,它有两种使用场景,一种是标识符作用域,一种是词法作用域。
|
||||
|
||||
[**符号表**](https://time.geekbang.org/column/article/130422)
|
||||
|
||||
- 符号表内包含的信息:名称、分类、类型、作用域等。
|
||||
- 存储形式:线性表格、层次化表格。
|
||||
- 符号表的作用:维护程序中定义的标识符(ID类Token),提供给编译器的各个环节进行操作。
|
||||
- 建立符号表的过程:整个编译器前端都会涉及到,词法分析阶段将ID类Token收集到符号表中,语法分析阶段可进行读取和补充,语义分析阶段做引用消解时符号表的作用至关重要。
|
||||
- 注意:符号表跟编译过程的多个阶段都相关。
|
||||
|
||||
[**类型系统**](https://time.geekbang.org/column/article/132693)
|
||||
|
||||
- 类型:在计算机语言里,类型是数据的一个属性,它的作用是来告诉编译器或解释器,程序可以如何使用这些数据。
|
||||
- 类型系统:类型系统是一门语言所有的类型的集合,操作这些类型的规则,以及类型之间怎么相互作用的(比如一个类型能否转换成另一个类型)。
|
||||
- 类型检查:这是与类型有关的分析和处理工作之一。主要用于对源程序代码中的一些类型不匹配的情况进行隐式类型转换或直接抛错。
|
||||
- [子类型](https://time.geekbang.org/column/article/134978):面向对象编程时,我们可以给某个类创建不同的子类,实现一些个性化的功能;写程序时,我们可以站在抽象度更高的层次上,不去管具体的差异。把这里的结论抽象成一般意义上的类型理论,就是子类型。
|
||||
- 类型转换:比如说,表达式“`a=b+10`”,如果 a 的类型是浮点型,而右边传过来的是整型,那么一般就要进行缺省的类型转换。
|
||||
- 参数化类型/泛型:泛型是程序设计语言的一种风格或范式。泛型允许程序员在强类型程序设计语言中编写代码时使用一些以后才指定的类型,在实例化时作为参数指明这些类型。
|
||||
|
||||
[**属性计算**](https://time.geekbang.org/column/article/133737)
|
||||
|
||||
- 编译器一般会采用属性计算的方法,来计算出每个 AST 节点的类型属性,然后检查它们是否匹配。
|
||||
- 属性文法:属性计算的方法,就是基于语法规则,来定义一些属性计算的规则,在做语法解析或遍历AST的时候执行这些规则,我们就可以计算出属性值。这种基于语法规则定义的计算规则,被叫做属性文法(Attribute Grammar)。
|
||||
- 属性计算:S属性(综合属性)、I属性(继承属性)。
|
||||
- 形成的数据结构:Annotated AST(带有标注信息的AST)。
|
||||
- 语法制导的翻译:属性计算的特点是会基于语法规则,增加一些与语义处理有关的规则,我们把这种语义规则的定义叫做语法制导的定义,如果变成计算动作,就叫做语法制导的翻译。
|
||||
|
||||
## 四、运行时机制:程序的两种不同的执行模式
|
||||
|
||||
通常情况下,程序有两种执行模式:基于物理机、基于虚拟机。
|
||||
|
||||
### [在物理机上运行](https://time.geekbang.org/column/article/146635)
|
||||
|
||||
**举例**:C、C++、Golang。
|
||||
|
||||
**程序运行的原理**:基于指令指针寄存器的控制,顺序从内存读取指令执行。
|
||||
|
||||
[**CPU**](https://time.geekbang.org/column/article/147854):运行指令的地方。
|
||||
|
||||
- 多种架构:x86、ARM、MIPS、RISC-V、PowerPC等。
|
||||
- 关键构成:寄存器、高速缓存、功能单元。
|
||||
- [汇编代码](https://time.geekbang.org/column/article/150798):操作码(让 CPU 执行的动作)、操作数(指令的操作对象,可以是常数、寄存器和某个内存地址)。
|
||||
|
||||
**内存**:执行指令相关的另一个硬件。
|
||||
|
||||
1.代码区:存放编译完成以后的机器码。<br>
|
||||
2.静态数据区:保存程序中全局的变量和常量。<br>
|
||||
3.栈:适合保存生存期比较短的数据,比如函数和方法里的本地变量。
|
||||
|
||||
- 重要知识点:栈帧的构成、活动记录、逐级调用过程。
|
||||
- 栈的特点:申请和释放—修改栈顶指针,生存期与作用域相同。
|
||||
|
||||
4.堆:适合管理生存期较长的一些数据,这些数据在退出作用域以后也不会消失。
|
||||
|
||||
- 重要知识点:通过操作系统API手动申请和释放。
|
||||
- 管理机制:自动管理、手动管理。
|
||||
|
||||
**操作系统**:除了硬件支撑,程序的运行还需要软件,这就是运行时系统。
|
||||
|
||||
- 定义:除了硬件支撑,程序的运行还需要软件,这些软件叫做运行时系统。
|
||||
- 操作系统:对于把源代码编译成机器码在操作系统上运行的语言来说(比如 C、C++),操作系统本身就可以看做是它们的运行时系统。
|
||||
- 管理CPU资源:分时执行。比如,时间片轮转算法,将CPU以时钟周期为单元执行多进程任务,实现并发。
|
||||
- 管理内存资源:逻辑内存(系统内核对内存地址的划定)、物理内存(硬件中具体每一个bit实际的硬件存储情况)、虚拟内存(基于操作系统内核对内存管理问题的抽象,通常有一部分虚拟内存实际上是存在磁盘上的)。
|
||||
|
||||
### [在虚拟机上运行](https://time.geekbang.org/column/article/161944)
|
||||
|
||||
**举例**:Java、Python、Erlang、Lua。
|
||||
|
||||
**程序运行的原理**:虚拟机是计算机语言的另一种运行时系统。虚拟机上运行的是中间代码,而不是 CPU 可以直接认识的指令。
|
||||
|
||||
**基于栈的虚拟机**:指令在操作数栈的栈顶获取操作数(如JVM、Python虚拟机)。
|
||||
|
||||
- 优点:易于生成代码。
|
||||
- 缺点:代码数量较多、不能充分利用寄存器。
|
||||
|
||||
**基于寄存器的虚拟机**:类似于物理机,从寄存器取操作数(如Erlang、Lua、Dalvik、Ignition)。
|
||||
|
||||
- 优点与缺点:与栈机相反。
|
||||
|
||||
**二者的区别:**主要在于如何获取指令的操作数。
|
||||
|
||||
## 五、中间代码:运行各种优化算法、代码生成算法的基础
|
||||
|
||||
在这门课程中,宫老师主要从用途和层次、解释执行、呈现格式和数据结构等角度来给你讲解IR这一关键概念。如果你想要更深入地了解IR的特点,理解如何生成IR来实现静态编译的语言,你可以去看《编译原理之美》的第[24](https://time.geekbang.org/column/article/151939)、[25](https://time.geekbang.org/column/article/153192)、[26](https://time.geekbang.org/column/article/154438)讲。
|
||||
|
||||
### IR的用途和层次(从抽象层次的角度来划分)
|
||||
|
||||
- 第一类用途:基于源语言做一些分析和变换(HIR)。
|
||||
- 第二类用途:独立于源语言和CPU架构做分析和优化(MIR)。
|
||||
- 第三类用途:依赖于CPU架构做优化和代码生成(LIR)。
|
||||
|
||||
### IR的解释执行
|
||||
|
||||
- P-code:直接用于解释执行的IR。由于它与具体机器无关,因此可以很容易地运行在多种电脑上。这类IR对编译器来说,就是做编译的目标代码。
|
||||
- 注意:P-code也可能被进一步编译,形成可以直接执行的机器码。如Java的字节码。
|
||||
|
||||
### IR的呈现格式
|
||||
|
||||
大部分IR没有像源代码和汇编代码那样的书写格式。
|
||||
|
||||
- 大多数的IR跟AST一样,只是编译过程中的一个数据结构而已,或者说只有内存格式。比如,LLVM的IR在内存里是一些对象和接口。
|
||||
- 为了调试的需要,你可以把IR以文本的方式输出,用于显示和分析。
|
||||
|
||||
### IR的数据结构
|
||||
|
||||
- 第一种:类似TAC的线性结构。
|
||||
- 第二种:树结构。
|
||||
- 第三种:DAG-有向无环图。
|
||||
- 第四种:PDG-程序依赖图。
|
||||
|
||||
### SSA格式的IR
|
||||
|
||||
- 概念:SSA,即静态单赋值。这是IR的一种设计范式,它要求一个变量只能被赋值一次。
|
||||
- 要点:使用SSA的形式,体现了精确的“**使用-定义(Use-def)**”关系,并且由于变量的值定义出来以后就不再变化,使得基于SSA更容易运行一些优化算法。
|
||||
- 注意:现代语言用于优化的IR,很多都是基于SSA的,包括Java的JIT编译器、JavaScript的V8编译器、Go语言的gc编译器、Julia编译器,以及LLVM工具等。
|
||||
|
||||
## 六、代码分析与优化:优化程序对计算机资源的使用,以提高程序的性能
|
||||
|
||||
### 优化分类
|
||||
|
||||
**是否与机器有关**
|
||||
|
||||
- 机器无关:指与硬件特征无关,比如把常数值在编译期计算出来(常数折叠)。
|
||||
- 机器有关:需要利用某硬件特有的特征,比如 SIMD 指令可以在一条指令里完成多个数据的计算。
|
||||
|
||||
**优化范围**
|
||||
|
||||
- 本地优化/局部优化:基本块内。
|
||||
- 全局优化:函数(过程)内。
|
||||
- 过程间优化:跨函数(过程)。
|
||||
|
||||
### [**优化方法**](https://time.geekbang.org/column/article/155338)
|
||||
|
||||
**1.把常量提前计算出来**
|
||||
|
||||
- 常数折叠:程序里的有些表达式,肯定能计算出一个常数值,那就不要等到运行时再去计算,干脆在编译期就计算出来。比如“`x=2*3`”可以优化成“`x=6`” 。
|
||||
- 常数传播:如果你一旦知道 x 的值其实是一个常量,那你就可以把所有用到 x 的地方,替换成这个常量。
|
||||
- 稀疏有条件的常数传播:基于常数传播,还可以进一步导致分支判断条件转化为常量,导致一个分支的代码不会被执行。
|
||||
|
||||
**2.用低代价的方法做计算**
|
||||
|
||||
- 代数简化:利用代数运算规则所做的简化,比如“`x=x*0`”可以简化成“`x=0`”。
|
||||
|
||||
**3.消除重复的计算**
|
||||
|
||||
- 拷贝传播:遇到相同引用的变量,拷贝替换为同一个,节省内存到寄存器的操作,以此提升运算速度。
|
||||
- 值编号(VN和GVN):把相同的值,在系统里给一个相同的编号,并且只计算一次。
|
||||
- 公共子表达式消除(CSE):也会减少程序的计算次数。比如“`x:=a+b`”和“`y:=a+b`”,x和y右边的形式是一样的,就可以让y等于x,从而减少了一次对“`a+b`”的计算。
|
||||
- 部分冗余消除(PRE):是公共子表达式消除的一种特殊情况。
|
||||
|
||||
**4.化零为整,向量计算**
|
||||
|
||||
- 超字级并行(SLP):把基本块中的多个变量组成一个向量,用一个指令完成多个变量的计算。
|
||||
- 循环向量化:在循环展开的基础上,把多次计算优化成一个向量计算。
|
||||
|
||||
**5.化整为零,各个优化**
|
||||
|
||||
- 聚合体的标量替换(SROA):很多语言都有结构和对象这样的复合数据类型,内部包含了多个成员变量,这种数据类型叫做聚合体(aggregates)。
|
||||
- 编译器可以把聚合体的成员变量放在寄存器中进行计算,不需要访问内存。
|
||||
|
||||
**6.针对循环,重点优化**
|
||||
|
||||
- 归纳变量优化:归纳变量是指在循环体内由循环变量派生出来的变量,其变化是很有规律的,因此可以尝试做强度折减优化。
|
||||
- 边界检查消除:在循环体内每次循环都会执行的边界检查代码,将其整合抽离出来,避免每次重复判断。
|
||||
- 循环展开:通过把循环次数减少,但在每一次循环里,完成原来多次循环的工作量。
|
||||
- 循环向量化:在循环展开的基础上,把多次计算优化成一个向量计算。
|
||||
- 重组:在循环结构中,使用代数简化和重组,能获得更大的收益。
|
||||
- 循环不变代码外提(LICM):在循环结构中,如果发现有些代码其实跟循环无关,那就应该提到循环外面去,避免一次次重复计算。
|
||||
- 代码提升:在条件语句中,如果多个分支条件里都执行了同一句代码,可将其提升至判断条件之前;如果是在循环体内,还可以继续借助循环不变代码外提优化,进一步提升到循环体之外,从而降低计算量。
|
||||
|
||||
**7.减少过程调用的开销**
|
||||
|
||||
- 尾调用优化和尾递归优化:尾调用就是一个函数的最后一句,是对另一个函数的调用。如果函数最后一句调用的函数是自己,就称为尾递归。尾调用可以将函数调用栈合并,尾递归还可以转换成循环,从而进一步做一系列针对循环语句的优化工作。
|
||||
- 内联:内联也叫做过程集成,就是把被调用函数的代码拷贝到调用者中,从而避免函数调用。
|
||||
- 内联扩展:内联扩展跟普通内联类似,也是在调用的地方展开代码。不过内联扩展被展开的代码,通常是手写的、高度优化的汇编代码。
|
||||
- 叶子程序优化:叶子程序,是指不会再调用其他程序的函数(或过程)。因此,它也可以对栈的使用做一些优化。
|
||||
|
||||
**8.对控制流做优化**
|
||||
|
||||
- 不可达代码的消除:根据控制流的分析,发现有些代码是不可能到达的,可以直接删掉,比如 return 语句后面的代码。
|
||||
- 死代码删除:通过对流程的分析,发现某个变量赋值了以后,后面根本没有再用到这个变量。这样的代码就是死代码,就可以删除。
|
||||
- if简化:在讲常量传播时我们就见到过,如果有可能if条件肯定为真或者假,那么就可以消除掉 if 结构中的then块、else块,甚至整个消除if结构。
|
||||
- 循环简化:也就是把空循环或者简单的循环,变成直线代码,从而增加了其他优化的机会,比如指令的流水线化。
|
||||
- 循环反转:这是对循环语句常做的一种优化,就是把一个 while 循环改成一个 repeat…until 循环(或者 do…while 循环)。这样会使基本块的结构更简化,从而更有利于其他优化。
|
||||
- 拉直:如果发现两个基本块是线性连接的,那可以把它们合并,从而增加优化机会。
|
||||
- 反分支:也就是减少程序分支,因为分支会导致程序从一个基本块跳到另一个基本块,这样就不容易做优化。比如,把循环内部的 if 分支挪到循环外面去,先做 if 判断,然后再执行循环,这样总的执行 if 判断的次数就会减少,并且循环体里面的基本块不那么零碎,就更加容易优化。
|
||||
|
||||
### [**分析方法**](https://time.geekbang.org/column/article/156878)
|
||||
|
||||
1. **控制流分析**(CFA): 基于程序的控制语句(如条件语句、循环语句、分支语句和基本块语句等)进行分析,建立对程序执行过程的理解,从而进一步做出优化。
|
||||
1. **数据流分析**(DFA):基于数据流分析框架(包含“方向(D)”“值(V)”“转换函数(F)”“初始值(I)”和“交运算(Λ)”5 个元素)等方式,建立对程序中数据变化情况的理解,从而进一步做出优化。
|
||||
1. **依赖分析**:分析出程序代码的控制依赖(Control Dependency)和数据依赖(Data Dependency)关系。这对指令排序和缓存优化很重要。
|
||||
1. **别名分析**:在 C、C++ 等可以使用指针的语言中,同一个内存地址可能会有多个别名,因为不同的指针都可能指向同一个地址。编译器需要知道不同变量是否是别名关系,以便决定能否做某些优化。
|
||||
|
||||
### 优化方法的重要性和顺序
|
||||
|
||||
**重要性**
|
||||
|
||||
- 对所有语言都重要:循环优化等。
|
||||
- 面向对象语言:内联、逃逸等。
|
||||
- 函数式语言:尾递归优化等。
|
||||
|
||||
**顺序**
|
||||
|
||||
- 要点:机器无关-早期,机器相关-后期。
|
||||
- 注意:一个优化会导致另一个优化,同一个优化会多遍运行。
|
||||
|
||||
## 七、目标代码生成:编译器最后一个阶段的工作,生成针对不同架构的目标代码,也就是生成汇编代码
|
||||
|
||||
### 生成针对不同架构的目标代码
|
||||
|
||||
- **x86**:CISC指令,Register-Memory架构。在指令里可以混合使用内存地址和寄存器。
|
||||
- **ARM**:RISC指令,Load-Store架构。在ARM的汇编中,从寄存器到内存要使用str(也就是Store)指令,而从内存到寄存器要使用ldr(也就是Load)指令。在这种架构下,指令被分为内存访问(Load和Store)和ALU操作两大类,且后者只能在寄存器上操作。
|
||||
- **策略**:编写“代码生成器的生成器”。也就是把CPU架构的各种信息描述出来,基于这些信息生成目标代码的生成器,就像根据语法规则,用ANTLR、bison这样的工具来生成语法解析器一样。
|
||||
|
||||
### [**生成目标代码时的优化工作**](https://time.geekbang.org/column/article/158315)
|
||||
|
||||
**1.指令选择**
|
||||
|
||||
- 做指令选择的原因:生成更精简、性能更高的代码;使得同一个功能可以由多种方式实现。
|
||||
- 算法:树覆盖算法、自底向上的重写系统(BURS)
|
||||
|
||||
**2.寄存器分配**
|
||||
|
||||
- 原理:两个变量,不同时活跃,可以共享寄存器。
|
||||
- 算法:图染色算法(优点-更优化,缺点-速度慢)、线性扫描算法(优点-不够优化,缺点-速度快)
|
||||
|
||||
**3.**[**指令排序**](https://time.geekbang.org/column/article/159552)
|
||||
|
||||
- 原理:CPU内部的单元可并行。
|
||||
- 实现:基于数据依赖图的List Scheduling算法。
|
||||
|
||||
**4.窥孔优化**
|
||||
|
||||
思路:提供一个固定大小的窗口,比如能够容纳10条指令,并检查窗口内的指令,看看是否可以优化;然后往下滑动窗口,再次检查优化机会。
|
||||
|
||||
### 调用约定的影响
|
||||
|
||||
- 调用约定:你可以发明自己的调用约定,比如哪些寄存器用来放参数、哪些用来放返回值。但是如果要使用别的语言编译好的目标文件,或者想让自己的编译器生成的目标文件被别人使用,那就要遵守某种特定的ABI标准。
|
||||
- Unix和Linux系统的调用约定:System V AMD64 ABI。
|
||||
- ABI:即Application Binary Interface,应用程序的二进制接口。
|
||||
|
||||
### 整体的处理过程
|
||||
|
||||
- 典型过程:指令选择->指令排序->寄存器分配->指令排序->Emit目标代码
|
||||
- 要点:基于LIR,并且不断lower。
|
||||
|
||||
## 知识地图
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a2/22/a260d574faae4a033b9ae3f616d45222.jpg" alt="">
|
||||
Reference in New Issue
Block a user