编译原理入门

461 阅读10分钟

编译原理

编译原理基础

编译原理就是研究「翻译」的科学,把一种机器语言翻译成另一种机器语言

例如:高级语言 ==> 低级语言(如机器语言,汇编语言,字节码等)

目的是让计算机理解更高级的语言并执行

编译原理1.png

编译器与解释器

编译器: 源程序的每一条语句都编译成机器语言,并保存成二进制文件,这样运行时计算机可以直接以机器语言来运行此程序, 速度很快

解释器: 只在执行程序时,才一条一条的解释成机器语言给计算机来执行,所以运行速度是不如编译后的程序运行的快的.

解释器是一种电脑程序,能够把高级编程语言一行一行直接转译运行。解释器不会一次把整个程序转译出来,只像一位 “中间人”,每次运行程序时都要先转成另一种语言再作运行,因此解释器的程序运行速度比较缓慢。它每转译一行程序叙述就立刻运行,然后再转译下一行,再运行,如此不停地进行下去。 在解释器上运行程序比直接运行编译过的代码来得慢,是因为解释器每次都必须去分析并转译它所运行到的程序行,而编译过的程序就只是直接运行。在解释器中,变量的访问也是比较慢的,因为每次要访问变量的时候它都必须找出该变量实际存储的位置,而不像编译过的程序在编译的时候就决定好了变量的位置了。

共同点: 解释器和编译器的目标就是将使用高级语言编写的源程序转换成另一种形式。 不同点: 如果一个翻译器将源程序翻译成机器语言,那么它就是一个编译器。如果一个翻译器直接处理并运行源程序,不先把源程序翻译成机器语言,那么它就是一个解释器

解释型语言:运行时才进行编译并运行

编译型语言: 将源代码一次性编译成机器码,然后在执行。一次编译,永久执行

脚本语言: 是解释型语言的一种,由对应的脚本引擎来执行,需要解释器才能执行,只要系统上有对应的解释器就可以做到跨平台,比如javascript,只要操作系统上有浏览器或者node.js就能执行js程序

特殊的java语言: java语言是编译型 + 解释型语言,他需要编译,但是他不是编译成机器语言,而是编译成中间代码(字节码),然后运行的时候可以被java解释器jvm根据所在操作系统解释成不同的机器语言。字节码和机器语言很相近,所以它比纯解释型语言性能好,但比编译性语言性能低

打个比方: 就好比一本中文书,现在有一个美国人来读这本书,有两种办法:一是把整本书翻译成英语,这样这个美国人只需要买一本翻译后的书;二是雇佣一个翻译,这样的好处是不必买书,美国人读到哪里就让翻译翻译到哪里。在这里的话,前者就是编译型,后者就是解释型。

编译的流程

  1. 词法分析:分词断句 + 判断词性,就是将一段代码中每个词都做上对应的标识

  2. 语法分析:根据词法分析的结果形成抽象语法树

  3. 语义分析:通过语义分析对抽象语法树进行语法检查,也就是检查语法错误

  4. 翻译:根据抽象语法树生成中间代码(这里指三地址代码)更加接近计算机的指令。也可以对三地址代码进行存储、传输和一些优化

  5. 编译器将中间代码转换成机器码

  6. 运行时环境:

    • 有的编译器将代码编译成机器码,按照操作系统的约定编译成一个应用,运行成为操作系统的进程,比如go
    • 有的编译器将代码编译成中间代码(字节码、三地址代码等),然后在操作系统中启动一个虚拟容器(进程)来执行他们,比如java
    • JIT编译器一遍执行中间代码,一边编译他们,现在大部分的编译器都是JIT

词法分析

将字符流转成符号流。输入:源代码(字符流) 输出:符号流

词法分析过程类似我们语文学习中的「词性标注」,每个符号是一个元组,至少包括一个字符串和一个词性的描述

编译原理2.png

上面黑框中每一行就是一个符号,冒号左边是字符串,冒号右边是此行的描述

词法分析器的结果是一个个的符号,英文Token,也叫词法单元

数学上符号是一个元组,例如整数123可以表示为(123,Integer)

常见的符号类型

  1. KeyWord(关键字)
  2. Variable(变量)
  3. Operator(操作符)
  4. Bracket(括号)
  5. String(字符串)
  6. Float(浮点数)
  7. Boolean(布尔)

语法分析

定义:源代码结构的抽象,也称为分析树,就是将词法分析的结果抽象成语法树

编译原理3.png

编译器中的树

  1. 每个节点都是源代码中的一种结构
  2. 每个节点都携带了源代码中的一些关键信息
  3. 每个节点的子节点代表着语言上的关系

语法分析器

根据语法规则,将符号,转换成抽象语法树

上下文无关文法

和自然语言不同,编译器只能识别「上下文无关文法」

也就是说不需要理解这门语言,给定任意这个语言的句子,就可以得到一个合理的抽象语法树,这对于有编程语言基础的人来说会很好理解,为了便于理解,咱们举个例子:

你跟一个没看过《机器猫》的人说,看,那有一个机器猫,他会认为那只是有一个机器的猫,你跟看过《机器猫》的人说,他就会知道你说的是哆啦A梦。这个例子前者就是不知道上下文的,后者是知道上下文的

「上下文无关法」就是就算没看过机器猫,也能做出了正确官方的输出,就是'那有一个机器猫'`,再传给下一个人(流程)处理

语义分析

语义分析就是对语法分析生成的抽象语法树进行审查,检查是否与语法错误,每门语言的语义分析是不一样的,也可能有相同的部分

举个例:var a = 1 + (1 * "abc"),我们知道整数类型不能与字符串类型相乘,这时在语义分析阶段就会检查出这个错误,返回给我们用的编辑器,然后编辑器就出现了程序员最不想见到的红色报错

编译原理4.png

综合过程

我们再来看这张图

编译原理1.png

在语义分析过后,就到了综合过程,图中「中间表示」其实就是经过了语义分析后合法抽象语法树,就不做过多解释了

中间代码生成

考虑var a = 1 * 2 + 3CPU需要几次运算

CPU每次只能计算两个寄存器的值,并存入第三个寄存器

过程如下:

  1. 将1读入寄存器
  2. 将2读入寄存器
  3. 计算1*2,写入寄存器
  4. 计算2+3,写入寄存器
  5. 读出寄存器结果到内存

我们发现这条语句需要CPU需要执行五个指令,两次计算。

试想如果我们把源代码var a = 1 * 2 + 3直接扔给CPU,CPU会很难分析,要判断先计算哪个,还要找地方存数据,是不是想想就麻烦。

实际上CPU也不可能直接处理源代码,CPU只能看懂机器码,这里只是一个假设

三地址代码

这时如果我们将源代码转换成CPU比较容易分析的中间代码(如:中间代码、字节码),CPU运行的速度就会更快。

我们将var a = 1 * 2 + 3拆成中间代码,以三地址代码为例

三地址代码定义:一行最多有3个地址的代码

  • p1 = 1 * 2
  • p2 = p1 + 3

我们再举几个例子 编译原理5.png

编译原理6.png

编译原理7.png

如果你是CPU,你看到这样的三地址代码会不会很开心

就这样我们通过加了一个生成中间代码的环节,让CPU运行的速度提升了很多

生成了中间代码,接下来就是要思考如何确定作用域

词法作用域与符号表

想要了解编译器怎么确定作用域,我们要先理解作用域相关的一些知识

作用域分为两种一种是词法作用域,另一个是动态作用域

  1. 词法作用域:也叫静态作用域,它的作用域是指在词法分析阶段就确定了,不会改变。简单来说就是写完代码就确定了
  2. 动态作用域:运行时根据程序的流程信息来动态确定的,而不是在写代码时进行静态确定的。简单来说就是看运行时的才确定,具体要看调用栈

题外话:如果你熟悉javascript,你会发现动态作用域的行为跟this关键字很像

现在大多数语言都是基于词法作用域的,几乎没有基于动态作用域的了,所以以下我们以词法作用域为前提来实现作用域

知道了什么是词法作用域,我们再来了解符号表,它是实现作用域的基本

符号表:用于存储符号(变量、常量、标签)在源代码中的位置、数据类型以及位置信息决定的词法作用域和运行时的相对内存地址,符号表就是词法作用域的具体实现

编译原理8.png

两个块里面的代码可以访问到外层作用域的变量,所有他俩是外层作用域的child,运行时时会根据符号表来判断代码的可访问范围,也就是作用域

活动记录:

完全可以理解活动记录就是作用域在程序运行时的另一个称呼

编译器会根据符号表来维持一个栈,活动记录就保存在栈中,举个例子:

这段代码在运行时会前后出现三个活动记录,会利用压栈出栈和栈的后进先的特性来保证代码的可访问状态

var a = 0
var b = 1
{
  c = b + 1
  d = C + 1
}
{
  var e = 0
  var f = 1
}

一开始全局作用域被压栈

编译原理9.png

运行到一个块作用域:第一个块的作用域也被压栈,这个块中的代码可以访问当前活动记录中的变量

编译原理10.png

运行到第二个块作用域:第一个块的作用域被出栈,第二个块的作用域被压栈,这个块作用域可以访问当前活动记录中的变量

编译原理11.png

生成目标代码

最后根据具体的CPU、操作系统,将中间代码就可以转换为目标代码。如果目标代码是符合选定操作系统指定格式的可执行文件,可执行程序就会被载入内存,开启一个进程运行。

程序运行时的内存:

编译原理12.png

这里有一个点就是堆增长会向下侵占未分配的内存,栈增长是向上侵占未分配的内存。