编译原理及技术(一)——简单介绍一个编译器的结构

一、语言处理器

计算机很笨,只能认识0和1,而人呢,脑子的存储能力又非常有限,很难记住大量无规律的东西。所以人只能通过高级语言或者中低级语言,再或者是汇编语言去永久记忆一些自己的逻辑想法,无法掌握大量、繁杂的机器码;计算机又只认识机器码,而不能辨认其他语言。人若想操纵计算机,就必须有一个第三者的存在,这个第三者就是语言处理器。下图是语言处理器的结构:
在这里插入图片描述

源程序: 即源语言所写程序,也就是我们平时用高级语言写的代码。

预处理器: 源程序需要经过预处理操作,这个过程的作用主要有两个,都是在程序编译之前完成的。

  • 一个源程序可能会被分割成很多个模块,并存放于独立的文件中,这时候就需要预处理器把存储在不同文件中的源程序聚合在一起。
  • 学过C的都知道宏定义,预处理器要把被称为宏的缩写语句转换为原始语句。(C++中的const常量好像也是在这一步转换的,先记下来,等我下来查一下再修改。查了一下C++将用到const常量的地方替换成对应的值这一操作,会因为不同编译器而不同,所以不同于宏替换)

编译器: 主角登场了!将经过预处理的源程序作为输入传递给编译器,编译器可能会产生一个汇编语言程序作为输出。而这个过程对大多数人而言就是一个黑盒子,它能够把源程序映射为语义上面等价的目标程序。

汇编器: 汇编器会对由编译器产生的汇编语言处理,生成可重定位的机器码。那什么是可重定位的机器码?写过LLVM IR的都知道IR是通过br指令在basicblock之间跳转来实现逻辑,这种basicblock被称为逻辑地址空间,而程序在运行的时候,真正用到的是物理地址空间,所以这个时候就需要有一种从逻辑地址到物理地址的映射。由于操作系统给进程分配内存的起始位置L并不固定,所以不能在编译的时候就把逻辑地址和物理地址一一对应写死,要不然程序没法跑了。那怎么办呢?如果在编译时,涉及到有关地址的操作,如某个地址对应数据的读取和写入、地址之间的跳转等有一种动态的方式根据起始位置去调整,这样就可以达到我们的预期(起始位置 +相对地址=绝对地址,根据这个规则调整,感兴趣的可以看看是怎么调整的,挺好理解的,就不展开说了)。而上面的这个根据起始位置动态调整过的代码叫做可重定位代码,它是在加载的时候,也就是系统给进程确定了物理地址时,才生成绝对地址的。

链接器/加载器:

  • 链接器,大型程序经常被分为多个部分进行编译,因此可重定位的机器代码有必要和其他可重定位的目标文件以及库文件连接到一起,形成真正在机器上面运行的代码,链接器就是做这件事的。
  • 加载器,修改可重定位地址,将修改后的指令和数据放到内存中适当的位置。由汇编器生成可重定位的代码后,逻辑地址和物理地址还并没有生成真实的映射关系,待系统给进程分配了物理地址,根据起始位置 +相对地址=绝对地址 才生成绝对地址。

以上就是一个语言处理器的基本构成,在这里做了个简单的介绍,而我们展开细说的正是编译器

二、编译器的结构

编译器就是一个程序,他可以阅读以某一种语言(源语言)编写的程序,并把该程序翻译成为一个等价的、用另一种语言(目标语言)编写的程序。编译器一个很重要的任务就是报告他在翻译的过程中发现的源程序中的错误。

之前说编译器对很多人而言是一个黑匣子,但是当你打开这个黑匣子之后就发现,这个黑匣子主要有两部分组成,分析部分(前端)和综合部分(后端)。分析部分主要负责生成中间代码,综合部分主要负责将中间代码转成目标机器代码。注意这里的目标机器代码并不是机器码:如果你要将源语言编译成汇编语言,这里的目标语言就是汇编语言;如果你打算直接编译成机器码,也就是跳过汇编器,那这里的目标语言就是机器码。
在这里插入图片描述
从上面编译器的步骤图中可以看出,前端的工作主要包括:词法分析、语法分析、语义检查、生成中间代码。这个过程相对于后端,尤其是优化而言,是相对简单一点,但是也很难,只不过他的所有操作都有了成型的理论支撑,比较好做。当你去给一种复杂的语言从零开始写一个前端的时候(比如基于LLVM做一个语言的编译器),就知道有多酸爽了,写过的人都懂。

后端的主要工作内容:对中间代码优化(也就是机器无关代码优化),生成目标机器语言,对目标语言优化(也就是机器相关代码优化)。

三、词法分析

词法分析(lexical analysis)是编译器的第一个步骤,也叫扫描(scanning),他的主要任务是从左向右逐行扫描源程序的字符,识别出各个单词,确定单词的类型,将识别出的单词转换成统一的机内表示—— 词法单元(token) 形式。等下会通过实例来解释这个概念。下面是词法单元token的组成:

token:<种别码,属性值 >

  • 种别码,乍一看还是个很抽象的概念,比如:if (a > b),词法分析器从左向右逐行按字符读取到的token依次是if(a>b)。看过编译器结构就知道,词法分析产生的token是供给语法分析器来进行语法分析的,在语法分析阶段,语法分析器怎么知道前面给的token代表什么内容呢?好办!咋们提前约定好一种对应关系,如果token是if,就给他起个名字叫IF;如果token是(就起个名字叫SLP;如果token是a,就起个名字叫IDN……,当然这个名字你可以随便乱起,但最合理的起名方式就是看到名字知道他代表什么,起的这种名字就叫做种别码。

  • 属性值:属性值是指向符号表中关于这个词法单元(token)的符号表条目(我习惯叫符号表项,如果后面叫混了,要知道是一回事),符号表条目的信息会被语义分析和代码生成步骤使用。这里又出现了两个新的概念,符号表和符号表项,现在只需知道这是很重要很重要的两个概念,后面会专门解释。

看下面表格(留意token类型,后面举例会用到):
在这里插入图片描述

  1. 第一行是关键字,高级程序中每个关键字都是确定的,if就是ifwhile就是while,所以关键字的种别码是一词一码。

  2. 第二行是标识符,这就很多了啊!一个程序有若干个变量,若干个函数(学过plsql语言的就知道下表中的记录和过程)等,所以这种就用一个种别码去表示一类标识符就可以,如:用var表示变量,用array表示数组,用func表示函数等。

  3. 后面几行就按照前面的理解方式理解,一型一码的意思是一种类型对应一个种别码。

  4. 下面是我们做的语言的部分种别码,这里面的种别码名字和我们后面说到的一些可能不一样,不过不要在意,只需要感受一下就ok:

    public enum XcloudTokenType {
          
          
    	// 保留字
    	AND, ARRAY, BEGIN, CASE, CONST, CONTINUE, DELETE, DIV, DO, DOWNTO, ELSE, ELSIF, END,
    	
    	......
    	
    	// 表的压缩格式
    	COMPRESSION,
    	// 伪包名
    	DBMS_RANDOM, DBMS_UTILITY,
    	// 游标
    	CURSOR, OPEN, FETCH, CLOSE,
    	// 隐式游标
    	SQL, NOTFOUND, FOUND, ROWCOUNT, ISOPEN, ROWTYPE,
    	// 分层查询中的伪列
    	CONNECT_BY_ISCYCLE, CONNECT_BY_ISLEAF, LEVEL,
    	// 分层查询中的一元操作符PRIOR,CONNECT_BY_ROOT
    	PRIOR, CONNECT_BY_ROOT,
    	// DMPP关键字
    	DMPP_FUNCTION_CALL, DMPP_PROCEDURE_CALL, DMPP_PROCEDURE_SINGLE_CALL,
    	DMPP_FUNCTION_SINGLE_CALL,
    	// 嵌套表
    	MEMBER, MULTISET, CARDINALITY,
    	// 特殊符号
    	PLUS("+"), MINUS("-"), STAR("*"), DOUBLE_STAR("**"), SLASH("/"), COLON_EQUALS(":="),
    	DOT("."), COMMA(","), SEMICOLON(";"), COLON(":"), QUOTE("'"), EXCLAMATION_POINTS("!"),
    	EQUALS("="), NOT_EQUALS("<>"), NOT_EQUALS1("!="), NOT_EQUALS2("^="), WAVE_LINE("~"),
    	NOT_EQUALS3("~="), LESS_THAN("<"), LESS_EQUALS("<="), GREATER_EQUALS(">="),
    	GREATER_THAN(">"), LEFT_PAREN("("), RIGHT_PAREN(")"), LEFT_BRACKET("["),
    	DOUBLE_VERTICAL_LINE("||"), VERTICAL_LINE("|"), RIGHT_BRACKET("]"), LEFT_BRACE("{"),
    	RIGHT_BRACE("}"), AT("@"), UP_ARROW("^"), DOT_DOT(".."), QUESTION_MARK("?"),
    	SPECIFIED_PARAM("=>"),
    	}
    

【举个例子】 词法分析器对下面代码分析时的整个过程,结合下面的图片一起看分析过程。

while(value!=100){
    
    
	num++;
}
  1. 词法分析器的位针(我瞎起的一个名称)首先指到第一个字符w,这是一个字母,什么样的token类型会以字母开始?看上面token表就知道是关键字或标识符,这个时候词法分析器位针向右探一位,但不移动,探取到下一个字符为h,关键字都是由字母组成,标识符也包括字母,所以h不能分割前面的w让其构成一个token,此时词法分析器的位针移到h上面,依次向下探取到i……这样一直重复。直到位针指字母e,向下探取到(,关键字都是由字母组成,所以不在关键字范畴;标识符是数字、字母下划线(针对一般语言),所以(也不是标识符范畴。此时就可以将前面扫描过得字符串while分割,从而构造成一个token,然后在关键字集合中查找是否有该字符串表示的关键字,一查结果还真有,然后就给该token的设置一个种别码,咋们提前约定好的while关键字的种别码是WHILE,那将其设置上就OK,至于属性值,关键字没有属性值,等后面专门会专门为词法分析开一篇博客,到时候再细讲。
  2. while构造完一个token之后,此时位针在(,分析器一分析,这是一个界限符,包含有(的token就只有左括号本身了,所以(构成一个token,然后再将提前约定好的种别码设置到上面。
  3. (构建完成一个token后,位针在v上面,按照构建while的方式,一直向后探取,分析,位针移动,直到位针指向e,此时在向后探取一位字符,发现是!。由于根据目前扫描的字符集构建的token是value,初步分析其是关键字或标识符,但是!并不符合构建这两个的任意一个,所以就此截取value为一个token,将此token在关键字集中查找一下,发现没有value关键字,所以确定此token为标识符,因为标识符是多词一码,所以需要设置一个属性值对其区分。
  4. 再往下走的所有token都在下面这个图中,就不一一分析了,和上面分析方法一样。需要注意,词法分析器在扫描的过程中,遇到空格符、换行符、以及注释等非源码必须的字符时,会将分割当前扫描的字符集构成一个token,然后再跳过空格、换行符、注释等,直到遇到下一个有效字符。这个功能谁来实现?那肯定是你自己呗!其实挺好实现的,这里说就是不要让你在试着分析的时候被其所迷惑。
    在这里插入图片描述

四、语法分析

语法分析(syntax analysis)是编译器的第二个步骤,也叫解析(parsing)。语法分析器(parser)从词法分析器输出的token序列中识别出各类短语,从而构造语法分析树(syntax tree),并判断源程序在结构上是否正确。

语法分析树其实就是一个树的数据结构,用来描述源程序的语法结构。以一个简单的赋值语句来举例:

position = initial + rate * 60;

赋值语句是计算出等号右边表达式的值,然后再赋值给左边的变量。所以它对应的语法树左子树是个标识符,右子树是个表达式。
在这里插入图片描述
加法运算是将加号两边的两个常量或变量相加,所以它对应的语法树左子树和右子树均是标识符或常量。乘法表达式同理。所以上面赋值语句就有了下面的语法树结构:
在这里插入图片描述

这条赋值语句s = 2 * 3.14 * r * (h + r);相比于上面要复杂些,它对应的语法分析树如下:

在这里插入图片描述
可能很多人对于编译器如何解析表达式,从而构建一颗语法树这一过程不甚了解,这里不再做过多的解释,后面会专门开一篇博客来解释。

五、语义分析

语义分析器(semantic analyzer)使用语法树和符号表中的信息来检查源程序是否和语言定义的语义一致。他同时也收集标识符的属性信息,并把这些信息存放在语法树或符号表中(又一次出现了符号表概念),以便在后面中间代码生成过程中使用。

语义分析的一个重要部分是类型检查(type checking)。编译器检查每个运算符是否具有匹配的运算分量,比如数组的下标要求必须是一个整数,如果用浮点数作为数组下标,编译器就应该报错。

所以语义分析主要有两个任务:收集标识符信息语义检查。收集标识符信息包括标识符的种属 (Kind),如常量、变量、数组、函数等;标识符的类型(type),如整型、实型、字符型等。语义检查主要检查源程序与语言对应的语义是否相一致,一些常用语言的错误语义如下:

  • 变量或过程未经声明就使用
  • 变量或过程名重复声明
  • 运算分量类型不匹配
  • 操作符与操作数之间的类型不匹配,如:
    • 数组下标不是整数
    • 对非数组变量使用数组访问操作符
    • 对非过程名使用过程调用操作符
    • 过程调用的参数类型或数目不匹配
    • 函数返回类型有误

有的编译器是将语法分析和语义分析一起处理的,以下面赋值语句为例解释:

int a = 1;
int sum = 0;
sum = a + 110;

编译器在解析sum = a + 110的语法的时候,就会判断=两边的类型是否相兼容,如果不兼容要报错。在解析a + 110加法表达式的时候,也会判断+两边的表达式是否相兼容,不兼容就会报错,这里注意表达式也会产生一个类型,常见的语言都是int+int产生intdouble+int产生double

而有的编译器是将语法分析和语义分析分成两个部分分别去处理的。编译器首先进行语法解析,在语法解析的时候不做语义检查,而是等语法解析完成后,再基于语法解析生成的语法分析树做语义检查。这两种做法要根据实际情况去选择,之前我们是用的第一种,后面又改了架构,开始用了第二种。

六、中间代码生成

编译器的主要目的就是将高级语言写的源程序翻译成目标机器对应的汇编语言,再交由汇编器去处理生成可重定位的机器码。一般的过程都是:源程序——语法树——中间代码——目标代码,源语言到中间代码一般称为前端,中间代码到目标代码一般成为后端(编译器结构中已说过)。

在这个翻译的过程中,一个编译器可能构造出一个或多个中间表示,且这些中间表示可以有多种形式。语法分析时产生的语法分析树也算是一种中间表示。一种常见的中间代码是三地址码,可以通过四元式、三元式或间接三元式的方式表示。 下面是一些常用的三地址代码指令:
在这里插入图片描述

这是上面三地址指令对应的四元式表示:
在这里插入图片描述
现在工业界比较流行的一种编译器框架是LLVM,基于LLVM做一些语言的编译器需求也是越来越多,如果感兴趣的话,可以看看LLVM。下面举了一个简单的例子:

常见的高级语言代码:

   int a = 10;
   int b = 11;
   return a + b;

编译过去之后的IR代码:

  %a = alloca i32, align 4
  %b = alloca i32, align 4
  store i32 0, i32* %retval, align 4
  store i32 10, i32* %a, align 4
  store i32 11, i32* %b, align 4
  %0 = load i32, i32* %a, align 4
  %1 = load i32, i32* %b, align 4
  %add = add nsw i32 %0, %1
  ret i32 %add

七、机器无关代码代码优化

后面再补。。。。

猜你喜欢

转载自blog.csdn.net/qq_42570601/article/details/110312075