【编译原理】中间代码(二)

本文是关于中间代码的第二篇文章。在第一篇文章中,我们介绍了3种表示中间代码的方式,本文将接着介绍和静态类型检查以及中间代码生成相关的内容。另外,本文使用第一篇文章中介绍的三地址代码来表示中间代码,如果读者还不熟悉三地址代码,可以先阅读第一篇文章。

类型表达式

我们使用类型表达式来表示类型的结构。类型表达式可以是基本类型,也可以是通过把称为类型构造算子的运算符作用于类型表达式得到的。不同语言的基本类型和类型构造算子可能不同。具体地,一个类型表达式可以是:

  • 基本类型。例如C语言的基本类型包括int、float、double、char等;
  • 数组。将类型构造算子array作用于一个数字和一个类型表达式可以得到一个类型表达式;
  • 记录。一个记录是包含一个或多个字段的数据结构,这些字段都有一个唯一的名字。将类型构造算子record作用于字段的名字和类型可以得到一个类型表达式;
  • 函数。使用类型构造算子可以构造得到函数类型的类型表达式,“从类型s到类型t的函数”记作“s→t”;
  • 类。在面向对象的编程语言中用来封装数据和方法的抽象数据类型(ADT)。

可以使用与DAG(参见第一篇文章)相似的方式表示一个类型表达式:叶子结点可以是基本类型、类和类型变量,内部结点是类型构造算子。例如,类型 int[3][4] 可以表示成:

【编译原理】中间代码(二)_第1张图片

类型检查

类型检查具有发现程序中错误的作用。如果一个语言能够在编译期间进行类型检查,从而保证不会在运行期间发生类型错误,那么这个语言是强类型的。

类型检查有两种方式,综合和推导:

  • 类型综合:根据子表达式的类型构造出父表达式的类型,它要求名字先声明再使用。例如,表达式A+B的类型是根据A和B的类型定义的;
  • 类型推导:根据一个语言中结构的使用方式来确定该结构的类型,它不要求名字在使用前先进行声明。例如,如果empty是一个测试列表是否为空的函数,那么表达式empty(x)中的x必须是一个列表类型。

类型转换

类型转换有两种方式,拓宽和窄化:

  • 拓宽转换:把范围较小的类型拓宽为范围较大的类型,这种转换能够保留所有原有的信息,通常由编译器自动完成,因此也称为自动类型转换。例如,把int类型转换为float类型;
  • 窄化转换:把范围较大的类型拓宽为范围较小的类型,这种转换可能丢失一些原有的信息,通常由开发者手动完成,因此也称为强制类型转换。例如,把float类型转换为int类型。

声明语句的翻译

(大多数语言中)变量只有在声明后才能使用。对变量进行声明赋予了该变量名字和类型,变量名可以使其在表达式中被引用,变量类型可以在存储分配、类型检查等过程中发挥作用。

变量类型

一个变量的类型可以是基本类型、数组类型或记录类型。变量的类型可以用下面的文法表示:

    T → BC | record'{'D'}'
    B → int | float
    C → [num]C | ε

在这个文法中:

  • 符号T表示基本类型、数组类型(一维或多维数组)和记录类型(用类型构造算子record构造得到的类型表达式);
  • 符号B表示基本类型,这里只给出了int和float两种;
  • 符号C表示数组的维度,可以是零维(没有中括号)、一维(有一个中括号)或多维(有多个中括号),C加上B才表示一个数组类型;
  • 符号D表示一个声明序列,它是对一个或多个变量的声明,这里用于表示记录类型中的字段。关于变量声明将在下一小节介绍。

变量声明

我们考虑一次只声明一个变量的情况。这种情况下,一次声明包括一个变量类型和一个变量名并以一个”;”结尾,如int x;。这种声明方式可以扩展成序列的形式,如int x; float y; char z;,这种形式通常用于声明记录中的字段。变量的声明可以用下面的文法表示:

    D → T id;D | ε

在这个文法中:

  • 符号T表示基本类型、数组类型和记录类型;
  • 符号D表示声明序列,这个序列中至少声明了一个变量;
  • 符号id表示变量名,严格地说,在声明序列中的任何两个变量的名字都不相同。

变量存储

变量的类型告诉我们它在运行时刻需要多大的内存空间,在编译时刻,我们可以使用变量的内存大小信息为每个变量分配一个相对地址。

一个变量的相对地址需要用该变量的起始地址和该变量的类型宽度来刻画。其中,起始地址是用于存储变量的字节块的第一个字节的地址,类型宽度是用于存储变量的字节块包含的字节数。例如,假设x是一个整型变量并且它的起始地址为100,那么x的相对地址是从100开始,到103结束的4字节的字节块。

假设存储变量的方式是连续的(即对变量x和y,x的相对地址是从d1到di的字节块,那么y的起始地址是di+1),则存储变量的关键问题变成了确定每个变量需要多大的存储空间(说白了就是确定每个类型的宽度)。下面我们对计算基本类型、数组类型和记录类型的宽度分别进行说明:

  • 基本类型。基本类型的宽度是由语言事先定义好的,比如Java的int类型的宽度是4个字节;
  • 数组类型。数组类型的宽度是由元素的类型宽度和元素的数量共同决定的,比如数组类型int[3]的宽度是4*3=12个字节;
  • 记录类型。记录类型的宽度是由该记录中所有字段的类型宽度共同决定的,比如记录类型record{ int x; int y; }的宽度是4+4=8个字节。

包括基本类型、数组类型和记录类型在内的声明语句的一个可行的SDT如下:

【编译原理】中间代码(二)_第2张图片

在表0中,每个符号的含义是:

  • 每个非终结符号都有两个综合属性type和width,前者表示类型,后者表示类型宽度;
  • 非终结符号C还有两个继承属性t和w,它们的作用是将类型和宽度信息从语法分析树的B结点传递到对应于产生式C→ε的结点;
  • 变量Env表示符号表,top表示指向符号表的指针,offset表示记录中的字段相对记录首地址的偏移量,Stack表示用于存放top和offset的栈。

对记录类型的宽度进行计算是一个比较复杂的过程,这种情况下,需要把记录中的字段保存到一个新的符号表中(相近的概念是变量的作用域):

  1. 首先,在产生式T→record'{'P'}'中,第一个动作对当前的上下文环境进行保存,该动作把指向当前符号表的指针top和记录字段偏移量的变量offset保存到Stack中,并把top指向一个新符号表,把offset重置为0;
  2. 然后,在产生式D→T id;D1中,动作把名为id.lexeme的变量加入到符号表中,并将offset加上该变量的类型宽度;
  3. 接着,重复第2步直到没有新的变量被声明,即到达产生式D→ε
  4. 最后,在产生式T→record'{'P'}'中,第二个动作计算记录类型的宽度并对之前的上下文环境进行还原,该动作把记录的类型设为record(top),把记录的宽度设为offset,并把top指向第1步保存在Stack中的符号表,把offset设为第1步保存在Stack中的值。

表达式的翻译

包括赋值运算、加法运算和取负运算在内的表达式的一个可行的SDT如下:

【编译原理】中间代码(二)_第3张图片

在表1中,每个符号的含义是:

  • 非终结符号S表示一个表达式;非终结符号E表示一个子表达式,它的addr属性表示对应变量的代数值;终结符号id表示一个运算分量,它的lexeme属性是由词法分析器返回的值;
  • top是一个指向当前符号表的指针,top.get(x)表示从符号表中取得标号为x的记录;
  • gen是一个负责生成三地址代码的函数,传递给它的参数就是需要生成的三地址代码。参数中包括变量和字面常量,字面常量需要在左右加上单引号;
  • Temp是一个负责生成临时地址的函数,这个临时地址通常用于编译器产生的临时变量。

以表达式x = a + (-b)为例,它的注释语法分析树和三地址代码如下:

【编译原理】中间代码(二)_第4张图片

在图1中,详细的翻译过程如下:

  1. 由于每个语义动作都在产生式的最右端,因此这个SDT可以在自底向上的语法分析过程中实现;
  2. 第一次归约发生在图1(a)中的标记1处,这里使用了产生式E→-E,相应的语义动作生成了图1(b)中的第1条指令;
  3. 第二次归约发生在图1(a)中的标记2处,这里使用了产生式E→E+E,相应的语义动作生成了图1(b)中的第2条指令;
  4. 第三次归约发生在图1(a)中的标记3处,这里使用了产生式S→id=E,相应的语义动作生成了图1(b)中的第3条指令。

数组引用的翻译

数组引用的一个可行的SDT如下:

【编译原理】中间代码(二)_第5张图片

在表2中,每个符号的含义是:

  • 非终结符号S和E、终结符号id、变量top、函数gen和Temp的含义与表1中的相同,非终结符号L表示一个数组变量;
  • L.array是指向数组名字对应的符号表条目的指针,假设L.array指向条目a,a.base是数组的基地址,a.type是数组的类型,a.type.elem是数组中元素的类型。例如,对类型为int[2]的数组a,有L.array=aL.array.base=a[0]L.array.type=int[2]L.array.type.elem=int
  • L.addr是相对数组基地址偏移的字节数,对距离数组基地址L.addr个字节的元素的引用是L.array.base[L.addr];
  • L.type是数组中元素的类型,等同于L.array.type.elem,L.type.width表示数组中元素的类型宽度。例如,对类型为int[2]的数组a,有L.type=intL.type.width=4;对类型为int[3][4]的数组b,有L.type=int[4]L.type.width=16

以表达式a[k] = b[i][j]为例,它的注释语法分析树和三地址代码如下:

【编译原理】中间代码(二)_第6张图片

在图2中,详细的翻译过程如下:

  1. 由于每个语义动作都在产生式的最右端,因此这个SDT可以在自底向上的语法分析过程中实现;
  2. 第一次归约发生在图2(a)中的标记1处,这里使用了产生式L→id[E],相应的语义动作生成了图2(b)中的第1条指令;
  3. 第二次归约发生在图2(a)中的标记2处,这里使用了产生式L→id[E],相应的语义动作生成了图2(b)中的第2条指令;
  4. 第三次归约发生在图2(a)中的标记3处,这里使用了产生式L→L[E],相应的语义动作生成了图2(b)中的第3和第4条指令;
  5. 第四次归约发生在图2(a)中的标记4处,这里使用了产生式E→L,相应的语义动作生成了图2(b)中的第5条指令;
  6. 第五次归约发生在图2(a)中的标记5处,这里使用了产生式S→L=E,相应的语义动作生成了图2(b)中的第6条指令。

布尔表达式的翻译

布尔表达式的一个可行的SDT如下:

【编译原理】中间代码(二)_第7张图片

在表3中,每个符号的含义是:

  • 非终结符号B表示一个布尔表达式,它的truelist属性是一个包含指令地址的列表,这些地址是当B为真时控制流应该跳转到的指令地址,它的falselist属性也是一个包含指令地址的列表,这些地址是当B为假时控制流应该跳转到的指令地址;
  • 非终结符号E表示一个表达式,它的addr属性表示对应变量的代数值;
  • 符号M是一个标记非终结符号,它的instr属性负责记录下一条指令的地址;
  • 变量nextinstr表示下一条指令的地址,即下一次生成的三地址代码会被放在nextinstr所指向的地址上;
  • 函数makelist(i)负责创建一个只包含指令地址i的列表,并返回一个指向新创建列表的指针;
  • 函数merge(p, q)负责将p和q指向的列表进行合并,并返回一个指向合并的列表的指针;
  • 函数backpatch(p, i)的功能比较复杂,首先,p是一个指向列表的指针,对p指向的列表中的每个指令地址j,地址j上的指令是一个未填写目标跳转地址的转移指令(如goto _);其次,i是一个地址,这个地址是一个目标跳转地址;最后,函数backpatch用i填写每个j上的转移指令的目标跳转地址。

以表达式x<100 || x>200 && x!=y为例,它的注释语法分析树和三地址代码如下:

【编译原理】中间代码(二)_第8张图片

在图3中,详细的翻译过程如下:

  1. 由于每个语义动作都在产生式的最右端,因此这个SDT可以在自底向上的语法分析过程中实现;
  2. 为了美观,truelist、falselist和instr都用它们的首字母表示,nextinstr用ni表示。假设初始时指令地址从100开始,即nextinstr指向地址100,如图3(b)(1)所示;
  3. 第一次归约发生在图3(a)中的标记1处,此时nextinstr指向地址100。这里使用了产生式B→E rel E,相应的语义动作把地址100放入B.truelist中,把地址101放入B.falselist中,并生成了图3(b)(2)中的两条转移指令,这两条转移指令的目标跳转地址都未被填写;
  4. 第二次归约发生在图3(a)中的标记2处,此时nextinstr指向地址102。这里使用了产生式M→ε,相应的语义动作把M.instr设为102;
  5. 第三次归约发生在图3(a)中的标记3处,此时nextinstr指向地址102。这里使用了产生式B→E rel E,相应的语义动作把地址102放入B.truelist中,把地址103放入B.falselist中,并生成了图3(b)(3)中的两条转移指令,这两条转移指令的目标跳转地址都未被填写;
  6. 第四次归约发生在图3(a)中的标记4处,此时nextinstr指向地址104。这里使用了产生式M→ε,相应的语义动作把M.instr设为104;
  7. 第五次归约发生在图3(a)中的标记5处,此时nextinstr指向地址104。这里使用了产生式B→E rel E,相应的语义动作把地址104放入B.truelist中,把地址105放入B.falselist中,并生成了图3(b)(4)中的两条转移指令,这两条转移指令的目标跳转地址都未被填写;
  8. 第六次归约发生在图3(a)中的标记6处,此时nextinstr指向地址106。这里使用了产生式B→B1 && MB2,相应的语义动作设置了B.truelistB.falselist,并用地址104填充地址102上的转移指令的目标跳转地址,如图3(b)(5)所示;
  9. 第七次归约发生在图3(a)中的标记7处,此时nextinstr指向地址106。这里使用了产生式B→B1 || MB2,相应的语义动作设置了B.truelistB.falselist,并用地址102填充地址101上的转移指令的目标跳转地址,如图3(b)(6)所示;
  10. 最终的三地址代码如图3(b)(7)所示,在第六和第七次归约中填充转移指令的目标跳转地址的技术称为回填,回填技术用来在一趟扫描中完成对布尔表达式或控制流语句的目标代码生成。

控制流语句的翻译

控制流语句的一个可行的SDT如下:

【编译原理】中间代码(二)_第9张图片

在表4中,每个符号的含义是:

  • 非终结符号S表示一个表达式,L表示一个语句列表,A表示一个赋值语句,B表示一个布尔表达式;
  • S的nextlist属性是一个包含指令地址的列表,这些地址是紧跟在S代码之后的转移指令的地址;L的nextlist属性与此类似;
  • 符号M是一个标记非终结符号,它的instr属性负责记录下一条指令的地址;
  • 符号N是一个标记非终结符号,它的nextlist属性是一个包含指令地址的列表,这些地址上的指令是无条件转移指令。

以表达式if (x<100) y=1 else y=2为例,它的注释语法分析树和三地址代码如下:

【编译原理】中间代码(二)_第10张图片

在图4中,详细的翻译过程如下:

  1. 由于每个语义动作都在产生式的最右端,因此这个SDT可以在自底向上的语法分析过程中实现;
  2. 为了美观,truelist、falselist、nextlist和instr都用它们的首字母表示,nextinstr用ni表示。假设初始时指令地址从100开始,即nextinstr指向地址100,如图4(b)(1)所示;
  3. 第一次归约发生在图4(a)中的标记1处,此时nextinstr指向地址100。这里使用了产生式B→E rel E,相应的语义动作把地址100放入B.truelist中,把地址101放入B.falselist中,并生成了图4(b)(2)中的两条转移指令,这两条转移指令的目标跳转地址都未被填写;
  4. 第二次归约发生在图4(a)中的标记2处,此时nextinstr指向地址102。这里使用了产生式M→ε,相应的语义动作把M.instr设为102;
  5. 第三次归约发生在图4(a)中的标记3处,此时nextinstr指向地址102。这里使用了产生式S→A,相应的语义动作把S.nextlist设为空,并生成了图4(b)(3)中的一条赋值指令;
  6. 第四次归约发生在图4(a)中的标记4处,此时nextinstr指向地址103。这里使用了产生式N→ε,相应的语义动作把地址103放入N.nextlist中,并生成了图4(b)(4)中的一条转移指令,这条转移指令的目标跳转地址未被填写;
  7. 第五次归约发生在图4(a)中的标记5处,此时nextinstr指向地址104。这里使用了产生式M→ε,相应的语义动作把M.instr设为104;
  8. 第六次归约发生在图4(a)中的标记6处,此时nextinstr指向地址104。这里使用了产生式S→A,相应的语义动作把S.nextlist设为空,并生成了图4(b)(5)中的一条赋值指令;
  9. 第七次归约发生在图4(a)中的标记7处,此时nextinstr指向地址105。这里使用了产生式S→if (B) MS1N else MS2,相应的语义动作设置了S.nextlist,并用地址102填充地址100上的转移指令的目标跳转地址,用地址104填充地址101上的转移指令的目标跳转地址,如图4(b)(6)所示;
  10. 最终的三地址代码如图4(b)(7)所示,在第六次归约中用到了回填技术。

【编译原理】中间代码(二)_第11张图片
欢迎关注微信公众号fightingZhヾ(๑╹◡╹)ノ”

你可能感兴趣的:(编译原理)