北邮 编译原理(下)

文章目录

第五章 语法制导翻译技术


在学习这一章开始之前,我必须吐槽一下国内的课程,几乎网上所有的课程(包括B站、慕课以及教科书)都只是泛泛的说语法制导翻译的定义是什么,而根本就没有任何的承上启下的说明,况且在课程概述的时候也根本没有提及过有这样的一个名词的存在,导致初学者(包括我)在学习完语法分析之后突然看到要学习的不是语义分析而是这样的一个意义不明确的名词的时候是感到迷茫和懵逼的,这也导致我从这一章开始编译原理的课程就跟不上老师了(上课根本听不懂老师在讲什么,因为你根本不知道这些玩意在整个编译的过程中有什么作用,没办法结合上下文来加深自己的理解,况且编译原理刚好又是这种极其抽象的课程),况且不仅仅是跟不上老师,我自己在网上搜寻了大量的参考资料还是只能听的半懂不懂的,根本不知道这章有什么用。更离谱的事情发生了,北邮的教材在语法制导翻译之后就是语义分析,当时我整个人是傻的,不是说语法制导翻译就是语义分析吗?这不是在和我开玩笑?而实际上当我们仔细看了看教材准备解决自己的疑惑的时候,教材的语义分析章节直接抛出更加令人疑惑的符号表等概念。所以我的初衷是决定好好的根据网上能够搜集到的所有资料将这些知识点能够做一个串联,让身为初学者的大家能够一步一步的明白我们学习的每一个知识点到底有什么用,接下来才应该是去学习和理解;


我们往往会将语法分析作为程序的主程序,而在整个编译程序的前端过程当中我们还需要进行语义分析和中间代码的生成,大多数教材将语义分析和中间代码生成合并在一起(因为语义分析的结果经常直接表现为中间代码的形式)称之为语义翻译,而语法制导翻译SDT(Syntax-Directed Translation scheme)是在语法分析的基础上对于语义的理解(可以理解为在语法分析的同时进行语义分析并生成中间代码)

语法制导翻译的定义:

  • 传统定义:语法制导翻译使用上下文无关文法CFG来引导对语言的翻译,是一种面向文法的翻译技术(简单理解语法制导翻译技术就是整合了语法分析、语义分析以及中间代码生成的一种手段);
  • 简述:语法制导翻译SDT是在产生式右部中嵌入了程序片段(称为语义动作)的CFG,SDT可以看作是语法制导定义SDD的具体实施方案;

这里简单回顾一下文法的分类(这是形式语言的范畴,编译原理不考察这里只做了解)

我们知道编译器前端部分的最终目的就是生成中间代码,中间代码是指不是机器语言但便于生成机器语言的、便于代码优化的一种语言,中间代码的形式主要有如下:

  • 逆波兰表达式
  • 树形表示法
  • 三元式
  • 四元式(最常用的形式)

中间代码的生成有两种方法,一种就是本章将要介绍的语法制导翻译,另一种是属性文法制导翻译(我们不会介绍这个方法,注意该属性文法和语法制导翻译中的属性文法并不是同一个东西);

关于中间代码的论述我们之后还会详细介绍,本章的重点是语法制导翻译技术中的名词性概念和使用方法;

至于为什么我们的课程组织是语法分析->语法制导翻译->语义分析->中间代码生成而不是语法分析->语义分析->中间代码->语法制导翻译,是因为语法制导翻译中的一些名词性概念是语义分析和中间代码生成的前置知识点,所以我们会先介绍语法制导翻译技术,接着将这个技术拆分开分别讲解语义分析和中间代码生成;

1.语法制导翻译技术概述

文章参考:编译原理 - 网易云课堂 (163.com)

语法制导翻译的基本思想(这两个思想贯穿整个语法制导翻译):

  • 如何表示语义信息?
    • 为CFG中的文法符号设置语义属性,用来表示语法成分对应的语义信息
  • 如何计算语义属性?
    • 文法符号的语义属性值是用与文法符号所在产生式(语法规则)相关联的语义规则计算得到:对于给定的输入串x,构建x的语法分析树,并利用与产生式(语法规则)相关联的语义规则来计算分析树中各结点对应的分析树语义属性值

语法制导翻译技术是指在语法分析的基础上边进行语法分析边翻译(翻译我们理解为一个过程,输入是源代码,输出是中间代码):

  • 语法制导翻译时会根据文法产生式右部符号串的含义进行翻译,翻译的结果就是生成的相应中间代码(简单理解就是根据句子是什么意思将其翻译为相应的中间代码)
  • 语法制导翻译是使用语义子程序来翻译的,即语法分析程序在语法分析过程中调用该语义子程序来进行翻译
    • 具体做法就是为每个产生式配置一个语义子程序,当语法分析进行归约或推导时,调用语义子程序完成一部分的翻译任务
  • 语法分析过程结束时翻译工作同步结束

语法制导翻译主要有两种形式:

  • 自上而下的语法制导翻译:LL语法制导翻译;
  • 自下而上的语法制导翻译:LR语法制导翻译;

语法制导翻译全称为语法制导翻译技术,是一种技术而不是设计编译器的时候需要划分的一个阶段!!!(这也是为什么我们没有在阶段图中看到过它的原因)

1.1 语义子程序

参考视频:44_bilibili_哔哩哔哩_bilibili

1.1.1 作用

语义子程序的作用:

  • 用于描述一个产生式对应的翻译工作(这些工作可能是改变某些变量的值、产生中间代码、发现并报告源程序的错误等),这些翻译工作很大程度上决定了会产生什么形式的中间代码
    • 如果将语义子程序改成产生某种中间代码的动作,就能在语法分析制导下,随着分析的进展逐步生成中间代码;
    • 若把语义子程序改成产生某种机器的汇编语言指令,就能随着分析的进展逐步生成某机器的汇编语言代码;

1.1.2 格式

语义子程序的写法如下:

  • 语义子程序写在产生式后面的花括号内,形如
X->α{语义子程序1}

注:在一个产生式中同一个文法符号可能出现多次,但他们代表的是不同的语义值,需要加上角标以区分(如E->E1+E2);

语义值:为了描述语义动作,需要为每个文法符号赋予不同的语义值:如类型、地址、代码值等(可以理解为每个文法符号的语义值就是它在符号表的入口,可能代表变量的类型、变量的地址或变量的值等)

注意:只有非终结符才有语义值!!!

1.1.3 语义栈

各个符号的语义值都存放在语义栈中:

  • 当产生式进行归约时,需对产生式右部符号的语义值进行综合,其结果作为左部符号的语义值保存到语义栈中;

之前我们介绍的LR分析法当中下推栈包含了两部分:状态栈和符号栈,现在需要额外增加一个语义栈,同样的,语义栈和状态栈、符号栈的变化是同步的

因为将语义值存放在语义栈中,因此语义值可以使用栈顶指针TOP指出,因此我们可以将1.1.1中的语义子程序改写为如下更加直观的形式

介绍完以上的知识点其实我们已经能够给出一个大致的语法制导翻译的计值过程了,与LR分析的流程图非常类似

分析输入串(7+9)*5,已知分析表如下

小结:上面的概述向我们全面展示完整的语法制导翻译技术的流程(语法分析+语义分析+中间代码生成),但实际上我们的语法制导翻译技术还涉及很多细节,同时现在市面上使用的教材中的一些名词性概念和概述中的并不完全一致,接下来的章节我们将详细介绍这些名词性概念和语法制导翻译技术的详细细节;

1.2 两个概念

将语义规则和语法规则(即产生式)联系起来涉及两个概念:

  • 语法制导定义(Syntax-Directed Definitions,SDD)
  • 语法制导翻译方案(Syntax-Directed Translation Scheme,SDT)
1.2.1 语法制导定义SDD

SDD是对上下文无关文法CFG的推广,主要进行了如下两方面的拓展:

  1. 将每个文法符号和一个语义属性集合相关联
  2. 将每个产生式和一组语义规则相关联,这些规则用于计算该产生式中各文法符号的属性值

注:L与L1表示的是同一个符号,下标的出现是为了方便讨论区别L在不同地方的出现。如果X是一个文法符号,a是X的一个属性,则用X.a表示某个标号为X的属性a;

1.2.2 语法制导翻译方案SDT

SDT是在产生式右部嵌入了程序片段的CFG,这些程序片段称为语义动作。按照惯例,语义动作放在花括号内

一个语义动作在产生式中的位置决定了这个动作的执行时间

关于SDD和SDT我们在下面还会进行详细的说明,这里抛出来只是简单的做一个铺垫,SDD和SDT的关系可以表达如下

SDDSDT
是关于语言翻译的高层次规格说明可以看作是对SDD的一种补充,本质上还是SDD
隐蔽了许多具体实现细节,使用户不必显式地说明翻译发生的顺序显式地指明了语义规则的计算顺序,以便说明某些实现细节

(这么来看其实SDD是要比SDT高级一些的,高级意味着更加易于理解,所以会先从SDD入手开始介绍)

2.属性文法(SDD)

学习视频:编译原理_中国大学MOOC(慕课) (icourse163.org)

2.1 概述

属性文法也称属性翻译文法,以上下文无关文法为基础,我们给上下文无关文法配备一些语义规则就得到了属性文法(属性文法就是上面介绍过的SDD,但是SDD和属性文法之间还是存在一定的区别,通常将没有副作用的SDD称为属性文法,属性文法的规则仅仅通过其它属性值和常量来定义一个属性值);

属性文法对上下文无关文法做了两个扩充,扩充了语义的描述机制:

  • 为每个文法符号(终结符或非终结符)配备了若干相关的值,这个值也称为属性,这些属性代表与文法符号相关的信息;当然属性文法中的属性除了能够存放具体的数值,还可以用来记录类型、代码序列、符号表内容等;
  • 为了描述属性如何计算,对于文法的每个产生式都配备了一组属性的语义规则,对属性进行计算和传递;这些语义规则说明了每一个语法规则或产生式所涉及的语法单位之间的语义关系,这种语义关系是通过属性计算来表达的;当然属性文法中语义规则除了这类数值计算规则以外,还可以用来描述更加广泛的属性处理功能如进行类型信息的传递、代码的拼接、符号表的访问和修改,凡是能够用程序实现的信息处理都可以成为语义规则;

属性文法实际上描述的是某个程序设计语言的变量声明语句的语法和语义

2.1.1 属性

属性包括综合属性和继承属性两类;

(1)综合属性

定义:某个属性是它的子结点属性计算得到的,那么这个属性就是综合属性;

简述:语义规则中左部的非终结符对应的是产生式左部的非终结符,则该属性为综合属性;

综合属性主要用来自下而上的传递信息;

  • 从语法规则的角度:属性文法当中总是根据产生式右部候选式的符号的属性来计算左部符号的非终结符(即被定义的符号的综合属性)的综合属性
  • 从paser tree的角度:综合属性的计算都是根据子节点的属性和父节点自身的属性计算父节点的综合属性

注:终结符可以具有综合属性,终结符的综合属性值是由词法分析器提供的词法值,因此在SDD中没有计算终结符属性值的语义规则

下面给出带综合属性的SDD

(2)继承属性

定义:某个属性是它的父结点或其兄弟结点计算得到的,那么这个属性就是继承属性;

简述:语义规则中左部的非终结符对应的是产生式右部的非终结符,则该属性为继承属性;

继承属性主要用来自上而下的传递信息;

  • 从语法规则的角度:继承属性的计算都是根据产生式右部候选式中的符号的属性和左部被定义符号的属性来计算语法规则右部候选式中的符号的继承属性;

  • 从paser tree的角度:继承属性的计算总是根据父节点和兄弟节点的属性来计算子节点的继承属性;

注:终结符没有继承属性,终结符从词法分析器处获得的属性值被归为综合属性值

下面给出一个带继承属性的SDD

如何根据所给的SDD判断文法符号的属性呢?我们拿上面的SDD举例;

  • 非终结符T只有一个属性type,可以看出type属性值都是由其子节点定义的,因此T.type是一个综合属性;

  • 非终结符L只有一个属性inh,根据第一个产生式和第四个产生式的语义规则可以看出L的inh属性是由其兄弟节点或者父节点的属性值所定义,因此L.inh是一个继承属性;

  • 终结符id的综合属性lexeme是由词法分析器提供的词法值;

  • addtype我们称为副作用,其功能是为id.lexeme在符号表中创造一条记录,并将其类型设置为L.inh;

总结:非终结符T用于生成一个类型关键字,非终结符L用于生成标记符序列,L.inh属性用于描述L生成的标记符序列标记符对应的类型;

(3)属性依赖

所有的语义规则都可以写成对某些属性进行f函数变换,把变换结果设置为某个属性的值;

在属性文法中,对应于每个产生式A->α,都有一套与之关联的语义规则,每条规则的形式都可以写成一个统一的模式

  • f是一个函数,接受若干输入参数;
  • c1、c2等都是某些符号的某些属性;
  • b是我们期望设置、计算的属性;

我们说属性b依赖于属性c1、c2…这种依赖关系有两类:

  • b是产生式左边被定义的非终结符的综合属性,而c1、c2是产生式右部中的某些符号的某些属性(包括综合属性和依赖属性);
  • b是产生式右边中的某个文法的符号的继承属性,c1、c2要么是左部符号的属性,要么是产生式右边的某个符号的属性

结论1:终结符只有综合属性,由词法分析器提供

因为终结符没有子节点,因此综合属性不能从子节点计算,只能由词法分析器提供;

结论2:非终结符在属性文法中既可以有综合属性也可以有继承属性,文法开始符号的所有继承属性作为属性计算前的初始值,必须设置

我们可以根据需要,既定义非终结符的综合属性,也可以定义它的继承属性;

因为开始符号(从paser tree的角度来看)没有父节点,所以如果文法的开始符号有继承属性的话那么它所有的继承属性都应当事先给定,即必须初始化;

结论3:一个符号的同一个属性不能既是综合属性又是继承属性,但实际上综合属性和继承属性除了计算方式不同以外使用方式上没有差异;

2.1.2 语义规则

语义规则是属性文法对上下文无关文法所做的另一个扩充;


Q:每个产生式应当配备什么样的语义规则?

A:每个产生式配备的语义规则应该是说明该产生式中出现的语法符号的对应的属性的计算方法,以表达这个产生式所对应的语法结构的意义,这就是语义规则设计的目的;


一般来说,程序设计语言的一个语法单位的语义是由构成该单位的各个部分决定的,所以语义规则就是描述该产生式中出现的语法符号的属性之间的相互关系,语义规则以函数计算的方式体现这种关系;

  • 对于出现在产生式右边的符号的继承属性和出现在产生式左边的符号的综合属性都必须提供计算规则,都必须由这个产生式的语义规则来提供计算方法;属性计算只能使用相应产生式中的文法符号的属性;这样描述的语义规则才是有意义的;
  • 对于出现在产生式左部的符号的继承属性和出现在产生式右边符号的综合属性的计算方法不应当由这个产生式的属性计算规则来描述,这些属性的计算应当由其他产生式的属性规则计算或者由属性计算器作为参数提前设置;

总结:语义规则建立了属性之间的依赖关系,在对语法分析树节点的一个属性求值之前,必须首先求出这个属性值所依赖的所有属性值 —— SDD 为CFG中的文法符号设置语义属性。对于给定的输入串x,应用语义规则计算分析树中各结点对应的属性值,语义规则给出了SDD需要按照什么顺序计算属性值(这就是语义规则的作用)

2.1.3 带注释的分析树

为了适应翻译的需要,将语法规则中对语义无关紧要的具体规定去掉,将剩下的本质性东西称为抽象语法,而语法树(也被称为抽象语法树AST)是源代码的抽象语法结构的树状表现形式;

语法树是分析树的抽象/压缩形式,它去掉了分析树中语义无关的成分:

  • 语法树中每个内部节点表示一个运算符号,其子节点表示运算分量;
  • 在语法树中,运算符号和关键字都不在叶结点的位置出现,而是与分析树中作为这些叶结点的父节点对应;

语法制导翻译既可以基于分析树,也可以基于语法树,这两种情况下都是将属性附加到树的节点上;


本节对paser tree进行拓广,普通的paser tree描述了语法单位之间的构成关系,是一种层次结构,如下是一个变量声明语句的paser tree

我们对paser tree中的节点(即终结符和非终结符)都标注上对应的属性值,就得到了带注释的paser tree(准确来说应该是带属性注释的paser tree)

  • paser tree中,一个节点的综合属性的值由其子节点和它本身的属性值确定,因此对综合属性来说可以使用自底向上的方法在每一个节点处使用语义规则来计算出综合属性的值;

结论1:假如一个属性文法中只有综合属性没有继承属性,这种属性文法我们称为S属性文法

  • paser tree中,一个节点的继承属性由其父节点、兄弟节点和本身的某些属性值来确定,因此对于继承属性来说,可以使用自下而上的方法在每一个节点处使用语义规则进行计算;使用继承属性来表示程序设计语言结构中的上下文依赖很方便;

为表达式构造AST的过程与计算表达式的值类似:通过为每一个运算符号或运算分量建立相应的结点来为子表达式构造子树,运算符结点的子结点分别是与其各运算分量相应的子树的根;

2.2 属性计算

(PS:这一节书上倒是讲的很细致,如果听不大懂的话就看看书仔细理解一下)

语义规则可以完成很多计算,包括产生代码、在符号表中存放信息、给出错误信息、执行其他任何动作;所有这些处理都可以理解为信息的变换或翻译;因此对输入串的翻译实际上就是根据语义规则进行计算

在语法制导翻译中,语法分析和语义分析的结合方式是多种多样的,但都以语法分析来驱动语义分析,下面将介绍三种按照语义规则进行属性计算的方法;

2.2.1 依赖图

依赖图方法是通过寻找属性之间的依赖关系来确定属性计算的先后顺序,选择相应的语义规则来完成属性计算(依赖图不是重点这里不做详细的描述,了解即可);

在一棵Paser tree中的节点的继承属性和综合属性之间的相互依赖关系可以由依赖图(有向图)来描述;

构造属性依赖图:为每一个语法符号的每一个属性设置一个节点,如果属性b依赖于属性c,则从属性c的节点有一条有向边连到属性b的节点;

(下图是一个非常简单的示意图,我们之后还会给出更复杂的示意图)

将上述做法描述为算法就得到了依赖图的构造算法,这个算法分为两大步骤:

  1. 建立依赖图节点;
  2. 构造有向边;

下面我们拿之前的例子直观的感受一下这两大步骤

首先是建立依赖图节点

(第四条产生式的第二条语义规则是产生一个副作用,也就是说在符号表中将id.lexeme所代表的标识符的类型设置为L.in所代表的类型。我们可以把它看成是产生式左部的L的一个虚综合属性规则。因此我们在依赖图中为L设置一个虚节点)

接着根据语法规则构建有向边(虚线是语法图,实线是属性依赖图)

(L的虚综合属性它用到了其子节点的lexeme属性和其自身的in属性看,因此在依赖图中分别从L的in属性节点和id的lexeme属性节点引出指向L虚属性节点的有向边)

良定义的属性文法:如果属性文法不存在属性之间的循环依赖关系,则称该文法是良定义的;

对于良定义的属性文法,属性依赖图的任何拓扑排序都给出了一个paser tree中节点的语义规则计算的有效顺序,也就给出了使用语义规则进行属性计算的有效顺序;

现在我们总结使用依赖图的属性处理方法

2.2.2 树遍历

树遍历的属性计算方法和依赖图有相同和不同之处:

  • 相同之处在于都是先通过语法分析为输入串建立paser tree,并认为树中已经存在开始符号的继承属性和终结符的综合属性;
  • 不同之处在于依赖图的方法是在paser tree的基础上先构造依赖图再按照拓扑顺序计算属性,而树遍历方法不需要构造依赖图,而是直接以某种秩序遍历paser tree,在遍历过程中计算所有能够计算出的属性,不能计算的属性留到之后再次遍历的时候进行计算,直到计算出所有的属性;
    • 可以采取深度优先,从左到右的遍历顺序

树遍历算法采取的是递归这一典型的计算思维方法,正是基于递归,树遍历算法的设计较简单;该算法分为两个部分:

  • 主算法是一个循环,只要整个树中还有未被计算的属性,就反复调用计算函数,直到paser tree中所有的属性都被计算出来

结论1:如果属性文法是良定义的,通过反复遍历,一定能将所有的属性都计算出来;

  • 计算函数是该算法的主要部分,输入参数是一个paser tree的节点,功能是以输入参数为根的子树进行深度优先、从左到右的遍历,依次考察每个节点,将能够计算出的属性都计算出来

2.2.3 一遍扫描

依赖图算法和树遍历算法都需要先对输入串进行一遍扫描(即进行语法分析建立paser tree),然后进行多遍扫描才能完成属性计算,多边扫描的方法效率较低 —— 虽然通过遍历分析树进行属性计算的方法有一定的通用性,但它是在语法分析遍之后进行的,不能体现语法制导方法的优势,在实际的编译程序中,语法制导的语义计算大都采取一遍的过程,即语法分析过程的同时完成相应的语义动作,这样属性的计算仅对应一个自顶向下或自底向上的过程,当然并非所有的属性文法都适合单遍处理的过程,下面主要讨论两种受限的属性文法 —— S属性文法和L属性文法;

一遍扫描的处理方法在语法分析的同时计算属性值,一边进行语法分析一边计算属性,语法分析结束时paser tree构造完毕,所有节点的属性也计算完毕

恭喜你终于学到这一节了,当然我很佩服你没有在学完第一节过渡到第二节的时候就放弃,现在我们开始形成一个完美的闭环;

第一节中我们介绍的语义子程序实际就是上面出现过无数次的语义规则,语义值实际就是属性;第一节中描述的那种随着语法分析结束而结束的分析方法就是我们接下来会介绍的一遍扫描;

很多人学到这里难免会有疑问,语义分析到底有什么用,和属性有什么关系?可以简单举个例子,对于句子"5*3+5"进行语义分析就得到了结果20(至于为什么不转换为机器语言再进行计算,可以将对算术表达式的计算看作编译的过程对代码的优化,使得机器语言更加简洁),对声明语句"int a=10"进行语义分析就将a成功存入符号表;

一遍扫描的处理方法通常和所采用的语法分析方法相关,将属性计算穿插在语法分析的过程中进行,语法分析产生语法结构的顺序决定了属性计算的顺序;

这也是语法制导翻译真正的思想:为文法中每个产生式配上一组语义规则,并且在语法分析的同时执行这些语义规则;

语义规则被计算的时机和分析方法有关:

  • 自上而下分析,一个产生式匹配输入串成功时;

  • 自下而上分析,一个产生式被用于进行归约时;


对于只具有综合属性的SDD,可以按照任何自底向上的顺序计算它们的值。对于同时具有继承属性和综合属性的SDD,不能保证存在一个顺序来对各个节点上的属性进行求值;

从计算的角度看,给定一个SDD,很难确定是否存在某棵语法分析树,使得SDD的属性之间存在循环依赖关系;

幸运的是,接下来介绍的两类SDD可以和自顶向下及自底向上的语法分析过程一起高效地实现

  • S- 属性定义 (S-Attributed Definitions, S-SDD)
  • L- 属性定义 (L-Attributed Definitions, L-SDD)
2.2.4 S-属性文法

前面大部分内容介绍的都是如何使用语法制导定义来说明翻译,本节主要介绍通过改造LR分析程序来实现翻译;

LR分析程序是使用一个栈来存放已经分析过的子树的信息,而根据定义5.1可知分析树中某节点的综合属性是由其子节点的属性值计算得到,这就意味着可以在分析输入符号串的同时自底向上的计算综合属性;

要实现这种功能我们需要对LR分析器做一定的改造;


(1)S属性定义的SDT实现(自底向上)

参考视频:9.1.1 5-5语法制导翻译方案SDT_哔哩哔哩_bilibili

(标题取名为基于S-翻译模式的语义计算可能会更加易于理解,因为基于翻译模式的语义计算实际上就是和依赖图,树遍历并列的第三种语义计算方式,关于SDT的详细介绍我们放在下一节)

S-属性文法是只含有综合属性的文法(只使用综合属性的SDD),综合属性只依赖于子节点和自身属性计算,所以可以在分析输入符号的同时由自下而上的分析器来计算属性

结论:如果一个SDD是S属性的,可以按照语法分析树节点的任何自底向上顺序来计算它的各个属性值;

  • 为了计算属性,我们扩充了分析器,使得分析器不仅保存分析中形成的语法符号,同时还保存与栈中文法符号有关的综合属性值
    • 一个文法符号也可以支持多个综合属性,所以我们需要使栈记录变得足够大或者是在栈记录中存放指针

  • 将语义动作中的抽象定义式改写成具体的可执行的栈操作(这个栈操作有时候也被称为代码行执行代码等,只要确定了SDT则代码行也相应的被确定)

  • 每当进行归约的时候,新的语法符号的属性值由栈中正在归约的产生式右边的符号的属性值来计算;

实际上我们在第一节中演示的语义子程序的分析过程就是典型的对S-属性文法的自上而下的分析过程;

我们这里直接给出一个例题来理解S-属性文法的分析过程(句子中的n表示句末符),视频参考[15.1.1]–S-属性文法_哔哩哔哩_bilibili

我们知道自底向上实际上有多种分析方法,并且一般都是给出的分析表,如果题目中直接给出的自动机我们可以直接使用自动机进行分析,当然最保险的方法还是构造分析表之后再进行分析;

2.2.5 翻译模式(SDT)

语义规则给出了属性计算的定义,但是没有给出属性计算的次序等实现细节,因此我们需要通过依赖图、树遍历等方法确定属性计算的顺序(之所以上面的一边扫描只用了一遍是因为对于S属性文法的分析是很简单的,但是对于L属性文法的分析必须严格在合适的位置选择合适的语义规则);

实际上我们可以在属性文法的基础上进一步给出每个产生式的语义计算规则具体实现的细节:包括产生式分析到什么时候执行哪个语义规则,这就是翻译模式(很多地方也称为翻译方案翻译动作)的概念;

翻译模式是对属性文法朝着具体实现的方向做的进一步改造,主要就是给出了使用语义规则进行计算的次序,将某些实现细节表示出来,翻译模式也就是前面介绍的语法制导翻译方案SDT;

翻译模式的实现很简单,只需要将与文法符号相关的属性和语义规则用花括号{}括起来,插入到产生式右部合适的位置上;

本节主要关注如何使用SDT来实现两类重要的SDD,因为在这两种情况下,SDT可在语法分析过程中实现:

  • 基本文法可以使用LR分析技术,且SDD是S属性的;
  • 基本文法可以使用LL分析技术,且SDD是L属性的;

(1)S翻译模式

对于S文法,可以使用如下规则将S-SDD转换为SDT:将每个语义动作都放在产生式的最后

(2)L翻译模式

对于L文法,我们可以给出一个基本的翻译模式的设计原则:设计L属性的翻译模式时,必须保证当某个动作引用一个属性时它必须是有定义的、可计算的;(即每个动作不引用尚未计算出的属性,这刚好是符合L-属性文法的特点的)

  • 当产生式中的属性都是综合属性时:
    • 为每一个语义规则建立一个包含赋值的动作,并将该动作放在相应的产生式右边的末尾;(即关于综合属性计算的语义规则放在产生式右边的最后,这样做最安全)

当产生式既有综合属性又有继承属性,则需要保证以下规则:

  • 产生式左边非终结符的综合属性只有在它所引用的所有属性都计算出来以后才能计算,计算这种属性的动作通常可放在产生式右端的末尾;
  • 产生式右边的符号的继承属性必须在这个符号之前的动作中计算出来(下面这个例子就不符合这条规则);

    • 一个动作{语义规则}不能引用这个动作{语义规则}右边的符号的综合属性(因为右边符号的综合属性要等到右边符号完全匹配完成之后才能得到);

简单来说,上述将L-SDD转换为SDT的规则为:

  • 将计算某个非终结符号A的继承属性的动作插入到产生式右部中紧靠在A的本次出现之前的位置上

    • 因为产生式右端某个符号继承属性的计算必须位于该符号之前;
  • 将计算一个产生式左部符号的综合属性的动作放置在这个产生式右部的最右端

    • 因为产生式左边非终结符的综合属性只有在它所引用的所有属性都计算出来以后才能计算

我们看下面这个例子

  • 第一条产生式的第一条语义规则是计算继承属性的,所以可以放在T’之前;第一条产生式的第二条语义规则用于计算综合属性,因此将其放在产生式最右端;

注:子节点的继承属性不能使用父节点的综合属性,同时父节点的综合属性只能通过其子结点或其本身的属性值来定义,因此左部符号的综合属性动作放置到最右端是最合理的,并且对子节点的继承属性的计算并没有影响;

(3)语义动作执行时机统一

翻译模式的设计是与分析器综合考虑的

  • 如果翻译模式中有一些语义动作是嵌入在产生式中间的,那么就需要在产生式归约或推导的过程中得到一部分结果的时候就要执行语义动作;
  • 而对于那些出现在产生式末尾的动作,其执行实际就是当整个产生式进行归约或推导完成的时候执行;

语义动作不同的执行时机给语法分析器的设计带来了困难,我们希望将这些语义动作的执行时机统一起来,于是考虑,如果我们能够从翻译模式中去掉那些嵌入在产生式中间的动作,使得所有的语义动作都出现在产生式末尾,那么执行语义动作的时间就统一了;

下面我们介绍一种改造翻译模式的方法,它能够去掉嵌入在产生式中间的动作,使得所有嵌入的语义动作都出现在产生式的末尾;

(4)消除翻译模式中的左递归

在自上而下的翻译过程中,语义动作是在处于相同位置上的符号被展开或者是被匹配的时候执行的;前面介绍过,为了构造不带回溯的自顶向下的语法分析,必须消除文法中的左递归,但是当我们增加了语义规则之后,消除左递归改变产生式后,原来产生式对应的语义规则该如何处理?

下面我们介绍一种方法用于在消除一个翻译模式的基本文法的左递归的同时考虑属性计算,适合于带综合属性的翻译模式的改造;

详细讲解可以参考链接编译原理_中国大学MOOC(慕课) (icourse163.org)(说实话这小节挺难的,得手动跟着写一遍理解才行)

消除翻译模式中的左递归的一般方法

一般的具有左递归的翻译模式都可以抽象为如下形式(每个文法符号都有一个综合属性,用小写字母表示,g和f是任意函数)

可以直接记忆结论,推导过程和证明过程实在是有点烧脑…

注意:带有左递归的翻译模式只能使用自下而上的翻译方法

2.2.6 L-属性文法

一遍扫描属性计算方法是在语法分析的同时计算属性值,语法分析结束的时候,语法分析树的构造完成,所有节点的属性也计算完毕,同时完成语法分析和语义分析;

S-属性文法由于只有综合属性,所以非常适合与一遍扫描的自下而上的语法分析结合进行,当一个产生式被用来进行归约的时候执行该产生式对应的语义规则;

如果属性文法中既有综合属性又有继承属性该如何处理呢?是否也有一遍扫描的处理方法呢?

L-属性文法适合一遍扫描的自上而下的分析;

L-属性文法可以和自上而下的语法分析器配合,当使用一个产生式推导或者匹配输入串的时候,执行该产生式对应的语义规则;

下面我们要介绍的这种属性计算方法主要是针对这类特殊的属性文法(L-属性文法)进行一遍扫描的处理:该方法按照深度优先遍历paser tree,计算所有属性值;通常与LL(1)分析法结合;

L-属性文法是指对于文法的每个产生式对应的语义规则中的每个属性要么是综合属性,要么是继承属性:

  • 对综合属性没有要求;
  • 对于产生式右部符号的继承属性,该继承属性不能依赖于它右边的符号的属性,只能依赖于它左边的符号的属性或产生式左部符号的继承属性来计算;
    • 产生式左部符号的继承属性:因为父节点的综合属性可以依赖于子节点的综合属性和继承属性,若子节点的继承属性再依赖于父节点的综合属性就会造成循环依赖;
    • 左边的符号的属性:若子节点可以同时依赖其左右两侧符号的属性就会造成循环依赖;

通过上面的定义我们可以知道S-属性文法一定是L-属性文法;


这里给出几道例题对L-SDD进行判断,L属性定义对综合属性没有限制,它只限制继承属性,因此此SDD是否为L-SDD取决于继承属性所依赖的属性值;

下图中,Q的继承属性的计算只能依赖于它左边的符号(这里体现为A)来计算,而划线处使用了其右部符号的属性来计算,因此该文法不属于L-属性文法;

下图中,第一个T’.inh依赖的是它左边兄弟的值,因此它不违反LSDD对继承属性的限制,第二个T’.inh依赖于其父亲节点的继承属性和其兄弟节点的值,也不违反LSDD对继承属性的限制,所以此SDD是LSDD;

下图中,Q的继承属性依赖了它右边兄弟节点的综合属性,因此违法了LSDD的继承属性的限制,因此此SDD不是LSDD;


如果一个L-SDD的基本文法可以使用LL分析技术,那么它的SDT 可以在LL或LR语法分析过程中实现

(1)L属性定义的SDT实现(自顶向下)

文章参考(注意只介绍了非递归预测分析,因为递归预测分析的LL在课堂上也没介绍,感兴趣参考10.1.1 5-7在递归的预测分析过程中进行翻译_哔哩哔哩_bilibili):

首先我们需要拓展语法分析栈,一个非终结符A的继承属性和综合属性的计算时机是不同的。其继承属性是在非终结符即将出现的时刻进行计算,而其综合属性必须在其子节点都分析完毕后才可以计算。因此我们将A的继承属性与综合属性存放在不同的记录当中:

  1. 增加属性值(value)字段

  2. 将继承属性和综合属性存放在不同的记录中

    • A的继承属性放在其本身的记录中,A的综合属性产生的时间不同,因此放在Asyn中;
  3. 增加动作记录action用来存放语义动作代码的指针

    • 不光是动作记录,分析栈中的每一个记录都对应着一段执行代码:
    • 综合记录出栈时,要将综合属性值复制给后面特定的语义动作;
    • 变量展开时(即变量本身的记录出栈时),如果其含有继承属性,则要将继承属性值复制给后面特定的语义动作;

经过拓展后的语法分析栈成为如下形式(需要注意的是与自底向上的语法分析区分,该分析过程没有状态栈)

PS:继承属性相对于综合属性在左边是有原因的,根据L-SDD的定义可知继承属性可能来自于其左边的文法符号的属性或其父节点的继承属性;

除了拓展语法分析栈,我们还需要改写翻译模式SDT,改写后的SDT可以看做是特殊的上下文无关文法(CFG),在此类文法中有三类符号 —— 终结符、非终结符和动作符号

  • 因为SDT中有六个语义动作,因此分别取名ai,用这些动作符号替代原来SDT中的语义动作;

下面通过一个例子实际展示翻译过程(参考视频9.2.1 5-6在非递归的预测分析过程中进行翻译_哔哩哔哩_bilibili):

(1)初始时刻,输入指针指向第一个输入符号3,因为采用的是自顶向下分析,因此分析栈栈顶在左侧,栈底在右侧(自底向上分析则相反);

  • 初始时刻只有开始符号T,因为T没有继承属性所以T的本身字段初始记录是空的;
  • 因为T有综合属性所以T有记录Tsyn,用于存放T的综合属性val;
  • 此时栈顶是非终结符T,其表示当前句型最左非终结符;

(2)根据第一条产生式,将T替换成F{a1}T’{a2};

  • T出栈,但T的综合属性Tsyn不能出栈,因为T的综合属性val值要等T的子节点都分析完毕才能计算出来;
  • T出栈后,F{a1}T’{a2}入栈,因F跟T’都具有综合属性,因此它们的综合属性与其本身属性一起进栈;
  • T’具有继承属性,因此T’本身的记录将存放T’的继承属性inh;
  • 此时栈顶是非终结符F,其表示当前句型最左非终结符;

(3)根据第四条产生式将栈顶F替换成digit{a6};

  • digit是终结符只有词法分析器提供的综合属性值,因此digit的记录存放其综合属性值;

(4)digit与3匹配成功,digit可以出栈;

  • digit后连接的语义动作{a6}将使用digit的属性值计算F的综合属性值,因此digit出栈前需要将其属性备份到{a6}的属性中,备份后digit即可出栈;

(5)digit出栈后指向下一个符号乘号,此时栈顶露出动作记录a6;

  • a6记录中存放了指向用于计算F的综合属性值的语义代码的指针(红框已经将a6抽象定义式改写为可以执行的栈操作),a6的任务是利用其备份的digit的属性值计算F的综合属性值,并将结果存放在F的综合记录中;

  • a6的栈操作实际上是将3赋值给其栈之后的Fsyn对应的val,此时栈中Fsyn对应的val变为3;

  • 动作记录出栈

(6)F的综合属性计算出来后,Fsyn理论上可以出栈;

  • 根据第一条产生式可知,F后面连接的a1将要使用F的综合属性计算T’的继承属性,因此Fsyn出栈前需要将其对应的综合属性值val备份到a1对应的字段中;
  • 备份后F的综合记录Fsyn就可以出栈了;

(7)此时{a1}露出栈顶

  • a1是用于计算T’的继承属性值inh,因此T’的继承属性值变为3;

  • 动作记录a1出栈,此后栈顶变为终结符T’;

(8)此时栈顶为T’,当前输入符号为*,因此选择第二个产生式进行替换;

  • T’出栈 *F{a3}T1’{a4}进栈;
  • 因为语义动作a3需要使用T’的继承属性值来计算T1’的继承属性值,因此需要 将T’的继承属性值备份到a3所在的动作记录中

(9)此时栈顶的终结符与当前的*匹配成功出栈,匹配成功后输入指针指向下一个输入符号5,栈顶符号出栈;

(10)F出栈digit{a6}进栈;

(11)栈顶与输入符5匹配成功digit出栈;

  • digit后连接的语义动作{a6}将使用digit的属性值计算F的综合属性值,因此digit出栈前需要将其属性备份到{a6}的属性中,备份后digit即可出栈;
  • 之后输入指针指向$符号(字符串结束符);

(12)a6用于计算F的综合属性;

  • 计算完后Fsyn对应的值val为5,a6出栈;

(13)Fsyn出栈

Fsyn出栈前将其值备份至a3中;

(14)执行a3对应的语义代码,用于计算T1’的继承属性值, 计算完成后a3出栈;

  • 这里有个小技巧就是完全不需要看代码段的栈操作,直接看a3的语义动作能够直接明白a3的语义;

(15)a3出栈后露出T1’而此时输入指针指向$,因此选用第三个产生式替换T1’即T1’出栈 a5进栈;

  • 因为a5要使用到其继承属性,因此T1’出栈前将其继承属性备份到a5的所在记录中;
  • 因为T1’出栈后a5将处于栈顶,因此栈顶的属性值字段是不需要改变的;

(16)执行a5对应的操作,将T1’的继承属性保存到T1’的综合属性中,然后a5出栈;

(17)栈顶变为T1’syn;

  • 将其综合属性值备份到a4的所在记录中,然后T1’syn出栈;

(18)执行a4对应的代码,其作用是用于计算T’的综合属性;

  • 计算完后T’的综合属性的值变为15,动作记录a4出栈,此时栈顶变为T’syn;

(19)T’syn出栈前将其综合属性备份至a2所对应的记录中,备份后T’syn出栈;

(20)接着执行a2对应的语义代码,其作用是用于计算T的综合属性值的;

  • 执行完a2的语义代码后T的综合属性就计算出来了即T.val = 15,之后a2出栈;

总结

只要分析栈中的记录存放了属性值,则这些记录都对应着一段执行代码

  • 综合记录出栈时,要将综合属性值复制给后面特定的语义动作;
  • 变量展开时(即变量本身的记录出栈时),如果其含有继承属性,则要将继承属性值复制给后面特定的语义动作;
(2)L属性定义的SDT实现(自底向上)

文章参考:编译器笔记26-语法制导翻译-L属性定义的自底向上翻译 - 简书 (jianshu.com)

视频参考:10.2.1 5-8L-属性定义的自底向上翻译_哔哩哔哩_bilibili

3.小结

本章的小结有些特殊,准确来说应该是对本章知识点的一个概述,我们在本章学了一堆看起来很抽象的知识点,这些知识点究竟和编译过程中的哪些行为对应?起到了什么作用?我们将详细介绍;

整个编译程序的任务是将源程序转换成等价的目标程序,何为等价?即目标程序必须和源程序具有同样的语义,在前面的章节我们只是对源程序进行了词法分析和语法分析,本章我们在语法分析的基础上对源程序的语义进行了分析和处理,目的是检查每个语法单位的静态语义,以验证语法结构正确的语法成分或程序是否具有正确的语义,进而完成相应的翻译工作;

本章介绍的语法制导翻译技术是目前大多数编译程序普遍采用的一种技术(语法分析+语义分析+中间代码生成),尽管它并非一种形式系统,但还是非常接近形式化。使用这种方法对上下文无关语言进行翻译的整体思路是:

  1. 根据翻译目标的要求确定每个产生式所包含的语义,分析文法中每个符号的语义,并将这些语义以属性的形式附加到相应的文法符号上(也就是将语义和语言结构联系起来);
  2. 确定产生式的语义规则,即根据产生式的语义给出符号属性的求值规则,从而形成语法制导定义;
  3. 在语法制导下进行翻译,即根据语法分析过程中所使用的产生式,执行与之相应的语义规则,完成符号属性值的计算,进而完成翻译;

由此可见,翻译目标决定了产生式的含义、决定了文法符号应该具有的属性,也决定了产生式的语义规则;

每条语义规则都可以表示为一个赋值语句、一个过程调用或一段程序代码;将这些语义规则插入到产生式右部适当的位置形成翻译模式,语义规则在产生式中出现的位置表明了它的执行时机;

第六章 语义分析

语义分析是编译程序的一个重要任务,由语义分析程序完成,通过检查名字的定义和引用是否合法来检查程序中各语法成分的含义是否正确,目的是保证程序各部分能够有机地结合在一起;

本章将利用前面介绍的语法制导翻译技术进行语义分析,并分析其具体过程;

1.语义分析概述

程序的结构可由上下文无关文法来描述,通过语法分析可以检查程序中是否含有语法错误;

为什么要进行语义分析?因为语法正确的程序并不一定都具有正确的含义,程序结构的含义与其上下文有关(上下文无关文法是不考虑上下文的,所以有必要在语法分析之后进行语义分析),语义分析程序应该能够诊断出源程序中存在的与上下文有关的错误;

这里有人要杠了,为什么语法分析不针对上下文有关文法?回答参考为什么编程语言都是上下文无关文法,不能采用上下文有关文法吗? - 知乎 (zhihu.com),当然教材也给出了答案:为程序设计语言构造一个上下文有关文法,这在理论上是可行的,但实际上并没有这么做(至少目前没有),原因是为语言构造一个能够反映其上下文有关特性的文法并不是一件容易的事情,另外,上下文有关文法的分析程序不但很复杂,而且执行速度慢;

目前常用的方法是利用语法制导翻译技术实现对源程序的语义分析,即根据源语言的语义设计专门的语义规则,扩充上下文无关文法的分析程序,在语法制导下完成语义分析;

1.1 语义分析的任务

语义分析程序通过将变量的定义与变量的引用联系起来,对源程序的含义进行检查,即检查每一个语法成分是否具有正确的语义,如检查每一个表达式是否具有正确的类型、检查每一个名字的引用是否正确等;

通常为编译程序设计一个称作符号表的数据结构来保存上下文有关的信息。当分析声明语句时,收集所声明标识符的有关信息(如类型、存储位置、作用域等)并记录在符号表中,只要在编译期间控制处于声明该标识符的程序块中,就可以从符号表中查到它的记录,根据符号表中记录的信息检查对它的引用是否符合语言的上下文有关的特性,所以符号表的建立和管理是语义分析的一个主要任务;

语义分析的另一个重要任务是类型检查,如对表达式/赋值语句中出现的操作数进行类型一致性检查、检查if-then-else语句中出现在if和then之间的表达式是否为布尔表达式等;

  • 强类型语言(如Ada语言)要求表达式中的各个操作数、赋值语句左部变量和右部表达式的类型应该相同,所以,其编译程序必须对源程序进行类型检查,若发现类型不相同,则要求程序员进行显式转换;
  • 对于无此严格要求的语言(如C语言),编译程序也要进行类型检查,当发现类型不一致但可相互转换时,就要作相应的类型转换,如当表达式中同时存在整型和实型操作数时,一般要将整型转换为实型;

2.符号表

编译过程包括五个阶段,各个阶段都由对应的程序模块实现,各个模块都会和符号表管理模块打交道,本节将介绍符号表管理技术(在一定意义上本节讨论的内容是“词法分析”的继续,即如何使用符号表建立标识符与其属性值之间的联系);

符号表是编译程序使用的一个非常重要的数据结构。基于符号表中记录的信息,可以检查源程序上下文语义的正确性,可以辅助正确地生成代码。这些信息是语义分析程序在处理声明语句时获得,或根据标识符在源程序中出现的上下文间接地获得,并保存在符号表中的。如变量的名字和类型、函数的名字、形参类型和返回值类型等;

符号表是一种动态数据结构。编译过程中,随着识别出的标识符的增加,符号表的表项数量也增加,但在某些情况下又在不断地删除。另外,编译程序对符号表的访问是非常频繁的,因为对于每一个标识符在源程序中的每一次出现都要访问符号表,这种频繁的交互使符号表的存取操作占用了编译期间的大部分时间,所以符号表的效率直接影响编译的效率。因此,高效的符号表组织和管理方法对编译程序是非常重要的;

2.1 符号表的作用与组织

2.1.1 符号表的作用
  • 登记各类名字的信息:编译程序在工作过程中需要使用符号表来登记程序中出现的每个名字以及名字的各种属性信息,比如这些名字包括常量名、变量名或函数名等等;

  • 编译各阶段都需要使用符号表:在编译的各个阶段,每当遇到一个名字都需要使用符号表,有可能需要增加、修改或使用其中的信息

    • 一致性检查和作用域分析
      • 在语义分析阶段,符号表登记的内容将用于语义检查,如检查一个名字的使用和原先的说明是否一致;
      • 另一项重要的工作是对名字的作用域分析,也离不开符号表的支持;
    • 辅助代码生成
      • 在目标代码的生成阶段,对符号名进行地址分配的时候,符号表也是地址分配的依据,用来辅助目标代码的生成;
2.1.2 符号表的组织

整体上看一张符号表的每一项或者也称为一个入口,一般会包含两个栏目:

  • 名字栏:名字栏也称为主栏、关键字栏,用于项的填写和查找,一般通过匹配名字来进行;
  • 信息栏:可以包含许多子栏目,用于记录相应名字的各种属性;

原则上编译时候可以使用一张统一的符号表,但是大多数编译程序都是按照名字的不同种属建立多张符号表,因为不同种属的名字的相应信息各不相同,信息栏的长度也各有差异,因此按照不同的种属建立不同的符号表在处理上会比较方便;


在编译期间对符号表的操作大致可以分为五类:

  • 填入名称
  • 查找名字
  • 访问信息
  • 填写修改信息
  • 删除

不同种类的表涉及的操作往往各不相同,上述操作只是一个基本的共同操作;


对符号表进行操作的时机主要有两个:

  • 一个是编译程序在处理到名字的定义性出现的时候(如处理到了声明语句,就需要将名字的各种属性登记到符号表中);
  • 一个是当处理到名字的使用性出现的时候,比如分析到表达式或者是语句当中出现了某个名字,就需要使用该名字到符号表中去访问它的某些信息;

符号表最简单的组织方式是让各项各栏所处的存储空间的长度固定,这种项、栏长度固定的表格易于组织、填写和查找,但是预留栏目的长度必须满足最大需求;

另一种选择是采取间接存储方式安排各栏的存储单元,符号表的栏目中只存放一些固定长度的指针,把标识符信息放在一片可长可短的存储区中;


符号表的存放也有不同方案;

对于一张可以容纳n项的符号表,我们把每一项都置于连续的k各单元中构成了一个k*n的表,这是一种比较固定的存放方式;

第二种比较灵活,将整个符号表分为m个子表,每个子表都有n项,但是每个表的栏目不同,把这些子表合并起来就得到了符号表的全部内容,在编译程序的工作过程中每一遍使用的符号表的信息可能略有差别,为了合理使用存储空间,大多数都采取这种方式组织;

2.2 符号表的整理和查找

在编译过程中符号表的查找是非常频繁的;

2.2.1 线性查找

按照关键字出现的顺序将各个名字填写到符号表中,其结构简单、省空间、填表快,但是查找很慢;

2.2.2 二分查找

表格中的项按名字的“大小”(名字内部的二进制值)顺序整理排列,使用二分查找方式进行查找;

2.2.3 HASH杂凑

实现名字到表格项目位置的映射;

2.3 符号表的内容

符号表的信息栏中登记了每个名字有关的信息;

不同的程序设计语言对于名字性质的定义各不相同,这里使用一个简单的PL语言来介绍符号表的内容和使用;

视频参考[20.2.1]–符号表的内容_哔哩哔哩_bilibili

2.3.1 名字表

2.3.2 程序体表

2.3.3 层次显示表

2.3.4 数组信息表

2.3.5 中间代码表

2.4 分析作用域

视频参考[20.3.1]–名字的作用域分析_哔哩哔哩_bilibili

本节学习符号表的使用,将利用符号表分析名字的作用域

名字的作用域分析和程序的结构相关,有两种典型的程序体结构

3.运行环境

这部分本来应该放在后面才介绍,但是按照老师的授课来说这部分也可以放在这里作为一个前置知识点;

在讨论为源程序中各语法成分生成目标代码之前(现代编译器的流程一般都是“源代码->中间代码->目标代码”),需要先弄清楚程序在运行过程中所需要的信息是怎样存储和访问的,这就需要把静态源程序正文与程序运行时刻的动作联系起来

程序执行过程中对数据的操作是通过对相应存储单元的存取完成的,不论数据对象的存储空间是静态分配还是动态分配,编译程序都需要完成与存储组织和管理有关的工作,这是一个复杂而又十分重要的任务;

本节主要讨论目标程序运行时的存储组织及管理技术;

3.1 运行时存储组织

程序运行时需要内存空间来存放它的指令序列、所处理的数据对象、以及它的运行环境信息等;同样的,编译程序需要从操作系统那里得到一个存储区域,以便被编译的程序在其中被编译、运行,那么这个存储空间如何使用、其中的各种信息如何组织等与程序设计语言的性质密切相关;

不同语言间过程的特性差别较大,这个特性对程序执行所需的结构和运行环境的复杂程度有很大的影响(如在静态环境中所有的内存分配是在编译时进行的、基于栈的动态环境以及动态内存管理等);

过程:与程序执行密切相关的一个概念是过程;过程的使用始于编译方法,早期是由于可用内存有限而将一个程序分裂成多个小的可独立编译的块,每个过程都可单独编译;

在程序设计时,通常会将一段多次使用的代码定义为一个过程,使用过程声明语句把一个标识符(称为过程名)和这段代码(称为过程体)联系起来,需要时直接使用过程名即可调用过程体。另外,可以根据是否有返回值进一步区分它为过程(无返回值)或是函数(有返回值);

即使对于明确区分过程和函数的语言(如Pascal),把函数看作过程也是完全可以的,同样,也可以把一个完整的程序看成是一个过程(但是我们基本上还是会将过程和函数区分开);

调用:当一个过程名出现在一个可执行语句中时,称此过程在该点被调用;

过程调用即执行被调用过程的过程体,如果过程调用发生在表达式中(因为表达式中的过程调用需要有返回值,也就意味着一定是函数调用),则称作函数调用或函数引用,过程调用时将实参传递给被调用过程;

活动:与描述过程定义的静态文本相对应的一个动态概念是活动,即一个过程的每一次执行称为它的一次活动;

每一次过程调用都将激活一个活动,该活动可以存取分配给它使用的数据对象;

3.1.1 运行空间划分

程序的运行空间指的是程序的逻辑或者虚拟地址空间,是编译程序为目标程序的运行向操作系统申请的一块存储区;根据不同的使用目的,该空间又被划分成不同的区域,用于保存生成的目标代码(即指令序列)、数据对象以及程序的运行环境等,一个典型的空间划分如下

结论:栈和堆空间的大小都随程序的运行而变化,所以使它们的增长方向相对;

不同区域采用的存储分配策略是不同的;

  • 目标代码区:目标代码的长度及某些数据对象的大小在编译时可以确定,因此采用静态存储分配策略,由编译程序把它们放在一个静态确定的区域内;

  • 静态数据区:对于静态分配的数据对象,编译程序甚至可以把它们的存储地址直接生成在指令中;

  • 栈区:对于允许过程递归调用的语言,仅采用静态存储分配是不够的,还需要借助栈来管理过程的活动;发生过程调用时,当前活动的执行被中断,有关断点的现场信息(如返回地址、各寄存器的值、以及调用参数等)就需要保存于栈中;当控制从被调用过程返回时,则需要将计算结果返回给调用过程(若有返回值的话),并根据所保存的断点信息恢复调用过程的运行环境(如恢复有关寄存器的值、根据返回地址设置程序计数器等),这样,被中断的活动就可以从过程调用点之后继续执行。生存期包含在同一个活动中的那些数据对象,可以与该活动有关的其他信息一起存放在栈中,对它们所需栈空间的分配采用动态的栈式存储分配策略,即在目标程序运行过程中,通过执行调用序列来完成,所谓调用序列指的是编译程序为过程调用语句生成的将控制从调用过程转移到被调用过程的一段代码;

  • 堆区:对于允许在程序控制之下对数据进行动态存储分配(在程序运行过程中创建和管理动态数据结构、允许动态地建立过程、允许动态地创建方法),需要采用动态的堆式存储分配策略;

3.1.2 活动记录&控制栈

活动记录:在程序执行过程中,每个过程的每次活动都需要一个连续的存储空间,该存储空间被划分为若干个区域来保存活动相关的各种信息,并且该存储空间的组织形式对所有活动都是一样的,所以又称为活动记录(一个经典的活动记录包含如下各个域,但实践中并不是所有的语言、编译程序需要使用如下所有的域);

(1)返回值域:存放返回给调用过程的值。实践中常用寄存器保存返回值以提高效率。
(2)参数域:存放由调用过程提供给该活动的实参。实践中常用寄存器传递参数以提高效率。
(3)控制链域(可选项):这是为跟踪活动踪迹而设计的一个指针域,也称为动态链域,用于本次活动结束时实现控制返回到调用过程。它总是指向本次活动的调用者的活动记录,即调用过程的最新活动的活动记录。像FORTRAN语言不需要控制链,因为它的所有空间都是静态分配的。
(4)访问链域(可选项):这是为实现过程对非局部名字的访问而设计的一个指针域,也称为静态链域,该域的使用实现了名字的静态作用域规则。它总是指向该过程的直接外层过程的最新活动的活动记录。像FORTRAN和C语言不需要访问链,因为FORTRAN程序的所有空间都是静态分配的,C语言程序的所有非局部数据实际上都是全局的,也是静态分配的,它们的存储地址可以直接生成在指令中;而像Pascal、Ada等支持嵌套过程的语言,就需要访问链,
(5)机器状态域:存放本活动开始之前的活动现场信息,即调用过程在调用点的断点环境,其中包括返回地址和控制返回时必须恢复的寄存器的值。
(6)局部数据区:为本次活动的局部数据分配的空间,该数据区的布局在下面讨论。(7)临时数据区:为本次活动中产生的一些临时数据(如表达式计算的中间结果等)
分配的空间。

通常,活动记录中各个域的位置是根据其所需空间大小的确定时间来安排的。原则是将大小能够较早确定的域放在活动记录的中间、较晚才能确定并且变化较多的域放在两端;

控制栈:程序运行空间中用于保存活动记录的存储区域采用栈式存储管理,称为控制栈;

程序运行过程中,控制栈中保存着当前所有活着的活动的活动记录,主程序的活动记录在栈底,被调用过程的活动记录压在调用过程的活动记录之上;当前正在执行的过程的活动记录在栈顶。由此可知,控制栈记录了程序执行的活动踪迹;

3.1.3 名字作用域&名字绑定

声明:声明是一个把信息与名字联系起来的语法结构,可以是显式的,也可以是隐式的;

作用域:在一个程序的不同部分,可能有对相同名字的相互独立的声明,语言的作用域规则决定了当这样的名字在程序中被引用时应该使用哪一个声明,一个声明起作用的程序部分称为该声明的作用域;

作用域是名字声明的一个性质,可以用“名字X的作用域”来描述。作用域规则有静态和动态之分,目前绝大多数语言采用静态作用域规则,即遵循最近嵌套原则,如Pascal、C等。拼写相同但作用域不同的名字被认为是不同的名字;

名字绑定:名字绑定(binding)是指把名字映射到存储单元的过程,根据名字的类型不同,其存储单元可能是一个字节、一个字或者是若干连续字节的集合;

在静态作用域规则下(静态作用域和静态存储分配策略是两个完全不同的概念),由于名字局部于其声明所在的过程,它的存储空间被安排在该过程的活动记录中。所以,不同的名字将被绑定到不同的存储单元。即使在一个程序中每个名字只被声明一次,程序运行过程中,同一个名字也可能映射到不同的存储空间(如递归过程中声明的名字);

名字的值:程序运行过程中,名字的值有左右之分,左值指的是它的存储空间的地址,右值指的是其存储空间的内容;

赋值语句的执行仅改变名字的右值,而不改变其左值;

需要指出的是,编译程序如何组织名字的存储空间,以及采用什么样的名字绑定方法等,主要取决于语言本身的性质;

3.2 存储分配策略

图示的程序运行空间中,除了目标代码区(采用的静态存储分配策略)外其余三种数据空间采用的存储分配策略是不同的,分别是静态存储分配、栈式存储分配以及堆式存储分配;

3.2.1 静态存储分配

对于源程序中声明的各种数据对象,如果在编译时能够确定它们所需存储空间的大小(如简单变量、常界数组和非变体记录等),则编译程序就可以在程序运行空间中给它们分配固定的存储位置,在把程序装入内存时完成所有名字的地址绑定,而且在程序运行过程中名字的左值保持固定不变,即总是使用这些存储单元作为它们的数据空间,这种存储分配方式称为静态存储分配;

静态存储分配策略的使用对源语言的限制较多,主要有:所有数据对象的大小和它们在程序运行空间中的位置必须能够在编译时确定,不能建立动态数据结构;不允许过程递归调用;

静态存储分配策略的实现比较简单。编译程序在处理源程序正文时,首先对每个变量均建立一个符号表表项,包括其名字、类型及存储地址等属性,当然也包括名字的作用域信息。由于每个变量所需存储空间的大小由其类型确定,并且在编译时是已知的,因此可以使用翻译方案处理声明语句,为变量分配存储地址;

3.2.2 栈式存储分配

栈式存储分配是基于控制栈的思想,把存储空间组织成栈的形式。活动记录在活动开始时人栈、在活动结束时出栈,过程中声明的局部变量的存储空间分配在相应的活动记录中。由于每次过程调用都激活一个新的活动,随着其活动记录的入栈,局部变量被绑定到新的存储单元;当活动结束时,随着活动记录的出栈,局部变量的存储空间被释放,局部变量的生存期也随之结束;

调用序列和返回序列的功能分别是完成活动记录的入栈和出栈操作,实现控制的转移;

调用序列:调用序列指的是目标程序中实现控制从调用过程进入被调用过程的一段代码;

为完成活动记录的入栈,在调用序列中有调用过程和被调用过程各自需要完成的任务,例如,如果被调用过程有参数的话,则需要由调用过程准备实参、并把实参的值(右值或者左值)传递给被调用过程,即写入被调用过程的活动记录中(参数传递机制);然后为被调用过程访问非局部名字建立环境、还要为控制返回做准备;而被调用过程则需要保存调用点的机器状态、初始化局部数据等;

返回序列:返回序列指的是目标程序中实现控制从被调用过程返回到调用过程的一段代码;

为实现活动记录的出栈,在返回序列中也有调用过程和被调用过程各自需要完成的任务,例如,如果被调用过程有返回值的话,返回值由被调用过程提供,写入自己的活动记录中,然后恢复调用点的运行环境,完成控制返回;而调用过程则需要自行取回返回值;

3.2.3 堆式存储分配

如果程序设计语言支持在活动结束后,其局部名字的空间可以保留,或者被调用过程的活动生存期可以超过调用过程的生存期,则栈式存储分配策略将无法处理,因为在这些情况下,活动记录的释放不遵循后进先出的原则,因此其存储空间不能组织成栈。由于堆式存储管理模式下,空间的释放可以按任意顺序进行,所以,针对这种情况可以采用堆式存储分配策略;

3.3 参数传递

当一个过程调用另一个过程时,它们之间传递数据的常用方法有两种,一种是通过非局部名字,另一种是通过参数;

参数传递机制对过程调用的语义有重大影响。不同语言之间的差别大体上与参数传递机制的种类及其影响范围有关,有些语言只提供一种基本的参数传递机制,有些语言提供两种或更多,本节讨论的参数传递机制主要分为四类:传值调用、引用调用、复制恢复以及传名调用(之所以有这么多种参数传递方法,是由于对表达式代表的含义的解释不同所产生的);

参数传递方法之间的主要区别在于实参代表的是右值、左值还是实参的名字本身(“左值”指的是存储单元的地址,“右值”指的是存储单元中的内容),因而也就出现了多种不同的参数传递方法。

3.3.1 传值调用

定义:把实在参数的值传递给相应的形式参数

传值调用(call-by-value)是最一般、也是最简单的参数传递方法。调用过程先计算出实参的值,然后将其右值传递给被调用过程。这意味着,在被调用过程执行时,参数值如同常数,于是可以将传值调用解释为:用相应的实参的值替代过程体中出现的所有形参;

传值调用也是C++和Pascal 语言的内置机制,本质上,也是C语言和Java语言唯一的参数传递机制。在这些语言中,参数被看作是过程的局部变量,其初值由调用过程提供的实参给出。因此,在过程中,参数和局部变量一样可以被赋值,但其结果不影响过程体之外变量的值。实现这种传值调用的基本思想如下:
(1)把形参当作过程的局部名字看待,形参的存储单元分配在被调用过程的活动记录中(即参数域);
(2)调用过程先对实参求值,发生过程调用时,由调用序列把实参的右值写人被调用过程活动记录的参数域中;

注意:传值调用并不意味着参数的使用一定不会影响过程体外变量的值。例如,若参数的类型为指针,则参数的值就是一个存储地址,通过它可以改变过程体外部的存储空间的值;

实现方式如下:

  • 调用程序预先把实在参数的值计算出来,并传递到被调用过程相应的形式单元中

  • 被调用过程中,像引用局部数据一样引用形式参数直接访问对应的形式单元

3.3.2 引用调用 – 传地址

定义:把实在参数的地址传递给相应的形式参数;

引用调用(call-by-reference)也称为传地址调用,原则上要求实参必须是已经分配了存储空间的变量,调用过程把实参的存储单元地址传递给被调用过程的形参,或者说调用过程把一个指向实参存储单元的指针传递给被调用过程的相应形参。被调用过程执行时,通过形参间接地引用实参,因此,可以把形参看成是实参的别名,对形参的任何引用都是对相应实参的引用;

实现引用调用的基本思想如下:
(1)调用过程对实参求值;
(2)如果实参是具有左值的名字或表达式,那么传递这个左值本身;
(3)如果实参是一个没有左值的表达式(如a+b或2等),则为它申请一临时数据空间,将计算出的表达式的值存人该单元,然后传递这个存储单元的地址;

简单来说,实现传地址只需要以下两步:

  • 调用程序把实在参数(实参)的地址传递到被调用过程相应的形式单元中;
  • 被调用过程中,对形式参数(形参)的引用或赋值被处理成对形式单元的间接访问;

假设有如下过程swap实现两个整型数值的交换

在主程序中调用该过程的时候,具体实现如下

下面再来看一道题

解题过程如下,需要注意的就是所有的形参都是地址,所以一定会影响实参的值

3.3.3 复制恢复 – 得结果

定义:传地址的一种变形

复制恢复(copy-restore)参数传递机制是传值调用和引用调用的一种混合形式,它综合了传值调用和引用调用两种方式的特点,也称为copy-in/copy-out传递方式。实现思想如下:
(1)过程调用时,调用过程对实参求值,并将实参的右值传递给被调用过程,如同传值调用一样。与传值调用不同的是,这里要求在调用之前求出实参的左值;
(2)当控制返回时,被调用过程根据实参的左值把形参的当前右值复制到相应实参的存储空间中,该左值是在调用之前计算出来的,当然,只有具有左值的那些实参的值被复制出来;

第(1)步是将实参的右值“复制入”被调用过程活动记录的参数域中相应形参的空间中,第(2)步是将形参的右值“复制出”,写人调用过程活动记录中相应实参的存储单元中。所以,这种方法有时也称为“复制入-复制出”传递方法。

主要实现步骤可以分为以下三步:

  • 每个形参对应两个形式单元,第一个形式单元存放实参地址,第二个单元存放实参的值

  • 在过程体中对形参的任何引用或赋值都看作对它的第二个单元的直接访问

  • 过程完成返回前,把第二个单元的内容存放到第一个单元所指的实参单元中

同样使用一个例题来帮助理解

3.3.4 传名调用

定义:相当于把被调用过程的过程体抄到调用出现的地方,但把其中出现的形式参数都替换成相应的实参;

传名调用(call-by-name)是Algol 60中定义的一种特殊的参数传递方式,计划用作种高级语言过程的内嵌(inline)机制。这种机制使得过程的语义可以简单地由文本替换形式描述,而不是作为对环境的一种请求。Algol 60中用复制规则对其进行了如下定义:
(1)把过程当作宏处理,即在调用出现的地方,用被调用过程的过程体替换调用语句,并用实参的名字替换相应的形参。这种文字替换称为宏扩展;
(2)被调用过程中的局部名字不能与调用过程中的名字重名,因此可以考虑在做宏扩展之前,对被调用过程中的每一个名字都系统地重新命名,即给以一个不同的新名字;
(3)为保持实参的完整性,可以用括号把实参的名字括起来;

历史上对传名调用的解释是:实参作为函数在被调用过程执行时计算。也就是说,进入被调用过程之前不对实参求值,调用点上的实参名字本身可以看作是一个函数定义,在被调用过程中,每次遇到形参时就对相应实参函数进行求值。因此,在结果程序中,对应每一个这样的参数都需要编制单独的一个程序或过程,这种参数子程序称为trunk。每当过程体中用到相应的形参时,就调用这个程序。当调用时,若实参不是变量,则形参替换程序就计算实参,并送回此值所在的地址,过程体中每当引用形参时,就调用trunk,接着就利用所送回的地址去引用该值。因此,在传名调用机制下,实参总是在调用者的环境内求值;

传名调用机制是其他延迟计算机制的基础;

实现方法:

  • 在进入被调用过程的之前不对实在参数预先进行计值,而是让过程体中每当使用到相应的形式参数时才逐次对它实行计值(或计算地址)
  • 通常把实在参数处理成一个子程序(称为参数子程序),每当过程体中使用到相应的形式参数时就调用这个子程序

注意这个地方进行了一个简单的名称切换,这是为了区分局部变量的A和全局变量的A,当然如果子程序和主程序中的变量本身就不同则不需要考虑换名

下面来看一道例题

4.类型表达式&类型等价

(这一节本来是想打算看书理解的,但是书上讲的太烂了,感觉像是纯粹按照某个英文教材翻译并且还没翻译正确的感觉,这节本质上也不是什么重点,所以简单写了写)

  • 强类型语言:强调最大程度的限制,要求执行严格的类型检查(显式声明类型、要求编译程序严格执行类型检查等);
  • 弱类型语言:强调数据类型应用的灵活性,建议采用隐式类型,翻译时无需进行类型检查,在程序运行期间,系统对每一个值的类型进行拓展检查;
  • 无类型语言:没有类型系统的语言,也称为动态语言,这并不意味着一个无类型语言允许其程序破坏数据,只意味着所有安全检查都是在程序执行期间进行的;

因为强类型语言的类型规则的严格性,确保了大多数存在数据被破坏的错误的程序在编译阶段被检测出;

类型检查有静态类型检查和动态类型检查两类

  • 静态类型检查是指由编译程序完成的检查
  • 动态类型检查是指目标程序运行时完成的检查

原则上,如果目标代码把每个元素的类型和该元素的值一起保存,那么任何检查都可以动态完成;

这部分其他内容可以参考教材P194,这里不再赘述;

第七章 中间语言

(原则上中间语言应该和语义分析放在一个章节,但是个人是真的不喜欢这种界限不清的感觉,所以组织的时候将其分开,并且老师授课的时候也是将它们分开进行授课的,所以blog的组织方式肯定是没有问题的)

本章主要介绍如何利用前面学习的属性文法知识,对程序设计语言中常用的语句进行中间代码生成(语义分析在上一章),包括用属性文法来描述这些语句的语义并构造适合一遍扫描的翻译模式;

1.概述

中间语言的特点:

  • 独立于机器

  • 复杂性界于源语言和目标语言之间

中间语言的特点:

  • 使编译程序的结构在逻辑上更为简单明确

  • 便于进行与机器无关的代码优化工作

  • 易于移植

常见的中间语言形式主要有:

  • 后缀式,逆波兰表示

  • 图表示:抽象语法树(AST)、有向无环图(DAG)

  • 三地址代码

    • 三元式
    • 四元式
    • 间接三元式

2.中间语言形式

2.1 后缀式

在后缀式的表示法中,所有的操作符都置于操作数的后面;

一个表达式E的后缀式形式的严格的语法定义由下面三条规则组成

从定义上来看,后缀式的表示法不需要用括号来标识操作符的优先级,只要知道每个算符的目数,对于后缀式,不论从哪一端进行扫描,都能对它进行无歧义地分解;

后缀式的计算的实现也很简单,可以使用一个栈来实现后缀式的计算:自左至右扫描后缀式,每碰到运算量就把它推进栈。每碰到k目运算符就把它作用于栈顶的k个项,并用运算结果代替这k个项;

下面介绍一个将中缀表达式翻译成后缀形式的属性文法

左部产生式是属性文法的基础文法部分即上下文无关文法,描述的是中缀表达式;

右部语义规则为每个产生式配上相应的语义规则,说明对应的语法单位的语义;

上述语义规则的定义实际和后缀式的定义是对应的;

根据属性文法,我们还能进一步设计一个中缀表达式翻译成后缀表达式的翻译模式;

首先考虑使用一个数组POST存放翻译后的后缀式:k为下标初值为1;对照上述属性文法,设计的翻译模式如下

2.2 图表示法

前面学过的抽象语法树就是一种图表示法的中间语言,下面将介绍一种新的与抽象语法树类似的中间语言叫做有向无环图;

在有向无环图中,表达式中的每一个子表达式都对应一个节点,与抽象语法树类似,一个内部节点都代表了一个操作符,其子节点代表操作树;父节点具有该运算符作用于其子节点对应的值之后的结果;在有向无环图中代表公共子表达式的节点可以具有多个父节点,这就是有向无环图与抽象语法树的区别,一个子节点可能有多个父节点故它是图不是树;

下面结合例子对比抽象语法树和有向无环图,对于下面的赋值语句,其抽象语法树和有向无环图分别表示如下,可以注意到两者的差异:

  1. 赋值语句中的两个子表达式在抽象语法树中都对应有两棵独立的子树,这两棵子树是一样的(因为两个子表达式一样),而在有向无环图中,这两棵子树被合并成了一个子树(也可以理解为消除了多余的子树),故加法节点的左右两棵子树都是乘法的结果;
  2. 需要注意的是有向图中,由于缺省的是由父节点指向子节点,所以有向图中也可以省略箭头(带上箭头可以看到这里面是没有环的);
  3. 有向无环图的子树共享的方式可以帮助我们生成优化的代码;

下面是一个将带中缀表达式的赋值语句翻译成抽象语法树的属性文法,其中关于表达式的定义部分前面已经介绍过,这里额外增加了赋值语句S这个语法单位的定义

2.3 三地址码

三地址码的基本形式如下

x:=y op z

其中x是结果,y是第一操作数,z是第二操作数;

三地址代码可以看成是抽象语法树或有向无环图的一种线性表示

下面是一个赋值语句翻译而成的抽象语法树,可以将其转换成一组等价的三地址代码,按照自底向上、从左到右的顺序,遍历树的各个节点,生成各运算对应的三地址代码

当然也可以将该赋值语句的有向无环图的表现形式转换为等价的三地址代码

三地址语句有多种形式可以表达不同的运算,主要有如下种类

2.3.1 四元式

三地址语句有多种实现形式,四元式就是其中一种;

将每一条三地址语句,都表示为一个带有四个域的记录结构,分别为op,arg1,arg2和result;

下面是严格对赋值语句翻译得到的四元式序列(顺序得到六条四元式的指令),需要注意的是第一操作数和第二操作数的顺序不能颠倒;

2.3.2 三元式

三元式也是一种对三地址语句的实现,可以将每一条三地址语句表示为一个带有三个域的记录结构,分别是op操作符、arg1第一操作数和arg2第二操作数,三元式的这种实现并没有保存结果的域,如果后面的三元式需要引用前面的计算结果(如引用前面的临时变量或中间结果的话),可以通过引用计算该值的语句的位置来实现

对于比较复杂的三地址语句,一般都需要需要使用连续两条三元式来实现

三元式通过引用语句的位置来实现计算值的传递,这给优化带来了困难,因为假如需要增加/删除语句或调整语句的顺序,都可能需要改变某些语句的位置下标,则引用了这些语句的语句也必须做相应的修改;

2.3.3 间接三元式

为了克服三元式出现的困难,出现了间接三元式的表达方式;

间接三元式由两部分构成,第一部分是三元式表,第二部分是间接码表:

  • 三元式表用来存放三元式指令;
  • 间接码表是一张指示器表,按运算的先后次序列出有关三元式在三元式表中的位置;

使用间接码表间接给优化器带来支持

3.声明语句的翻译

(下面这几个翻译的部分说实话还是很重要的,但是不管是哈工大还是国科大的视频的讲解都不是很细致,主要还是因为课程组织很离谱,然后看PPT也基本是看不懂的,教材的话还是一样的问题,这些汉字合在一起根本就看不懂,所以下面几个部分我们都参考的是简书的内容)

文章参考:编译器笔记28-中间代码生成-声明语句的翻译 - 简书 (jianshu.com)


3.1 类型表达式

参考视频:11.1.1 6-1类型表达式_哔哩哔哩_bilibili

类型也有结构,类型的结构使用类型表达式来表示;

结论1:基本类型是类型表达式

结论2:可以为类型表达式命名,类型名也是类型表达式

结论3:将类型构造符(typeconstructor)作用于类型表达式可以构成新的类型表达式

3.2 声明语句的翻译

对于声明语句,语义分析的主要任务是

  1. 收集标识符的类型等属性信息;
    • 从类型表达式可以知道该类型在运行时刻所需要的存储单元数量,将其称为类型的宽度
    • 关于类型表达式,实际上基本类型(int、bool、void等)就是类型表达式,也可以将类型构造符(如数组构造符、指针构造符、笛卡尔乘积构造符、函数构造符、记录构造符等)作用于类型表达式以构造成新的类型表达式;
  2. 为每一个名字分配一个相对地址;
    • 在编译时,可以使用类型的宽度为每一个名字分配一个相对地址

常常将名字的类型和相对地址等信息保存在符号表中(符号表在上面介绍过),变量声明语句的SDT如下

我们举两个例子来说明变量声明语句的语法制导翻译是如何进行的(这部分最好借助视频&纸上演算逐步进行理解,纯看图不动手画难度很大,参考视频11.2.1 6-2声明语句的翻译_哔哩哔哩_bilibili,这里使用的是LL1的自顶向下的非递归分析,并不是采用画栈而是使用的作树的方式)

4.赋值语句的翻译

赋值语句的基本文法如下

一个表达式的值要通过一段三地址码来计算,因此赋值语句翻译的主要任务是生成对表达式求值的三地址码,下面源程序片段对应的三地址码为

为了将赋值语句翻译成为三地址代码,需要频繁地进行符号表操作,例如:在符号表中查找名字id是否存在,根据id,entry访问符号表以获取名字的类型信息,若id是数组类型的,还需要根据id,entry获取数组相关的信息(如元素的类型、维数、各维的上下界等),若id是记录类型,还需要进一步获取记录中各域的信息等。在生成三地址代码的过程中,为了保存中间计算结果,通常要引入临时变量,这些临时变量也需要保存在符号表中。为此,需要设计如下函数:

赋值语句的SDT如下

对上述SDT进行改进可以得到增量翻译

这里举一个例子来熟悉简单赋值语句的翻译,视频参考:12.1.1 6-3简单赋值语句的翻译_哔哩哔哩_bilibili(这个SDT采用的是LR分析技术,且所有的语义动作都位于产生式右部的末尾,因此可以采用自底向上的方式来翻译)

4.1 数组引用的翻译

数组引用的基本文法如下(这是对前面赋值语句的基本文法进行的拓展)

  • 根据第三个产生式:引入了新的非终结符L,L表示的是一个数组元素;
  • 根据第二个产生式:数组元素本身就是一个表达式;
  • 根据第一个产生式:可以为数组元素赋值;

将数组引用翻译成三地址码时要解决的主要问题是确定数组元素的存放地址,也就是数组元素的寻址;

注:变量的偏移地址是指变量所在段的起始地址到该变量的字节距离

例如,假设type(a) = array(3, array(5, array(8, int))) ,一个整型变量占用4个字节,则

i1*w1=i1*5*8*4=i1*160  - 160是数组a[i1]的宽度,a[i1]是一个[5*8]的二维整型数组
i2*w2=i2*8*4=i2*32     - 数组a[i1][i2]是一个一维数组
i3*w3=i3*4=i3*4        - a[i1][i2][i3]是一个整型变量

下面给出一个带有数组引用的赋值语句的翻译的例子

  • a是一个包含n个整型变量的数组,要翻译的源程序片段为c=a[i],根据前面的地址计算公式可以计算得到a[i]的地址,进而得到其对应的三地址码;

  • 这里用到了一个数组的名字a表示数组的基地址,数组的基地址加上数组的偏移地址就得到数组的实际地址t2

再来看一个二维数组的例子

数组引用的SDT

在数组引用的翻译方案的设计过程中,关键的问题是如何将地址计算公式同数组引用的文法关联起来;

对于例子中,前面对于简单赋值语句采用的是自底向上的翻译方法,这里同样可以延续这个流程,实际上很好理解,直接无脑自底向上构造即可;

根据以上的分析可以得到数组引用的SDT

5.控制流语句的翻译

程序设计语言的控制流程一般分为三种:顺序结构、分支结构和循环结构

控制流语句的基本文法如下

注:此处的S用于生成可执行语句的序列,一个可执行语句可以是赋值语句,也可以是一个分支语句(以if语句为例),也可以是循环语句(以while语句为例),可执行语句序列可构成程序P

控制流语句的代码结构如下(此处以if-then-else为例)

注:布尔表达式B被翻译成由跳转指令构成的跳转代码

控制流语句的SDT如下

上图第三条产生式是赋值语句,赋值语句的翻译方案前面已经讲过这里不再赘述,下面将重点介绍分支语句和循环语句的翻译方案

根据所给的代码结构可以编写相应的SDT

控制流语句的例子

完整版的控制流语句的SDT如下(布尔表达式的SDT我们将在后面详细介绍)

上述SDT中的基础文法并不是一个LL1文法,因此不能在自顶向下的语法分析过程中同时实现语义翻译,同时因为在产生式右部中内嵌有语义动作,因此使用自底向上的语法分析中进行语义翻译需要修改上述文法;

详细流程参考13.3.1 6-7控制流翻译的例子_哔哩哔哩_bilibili

5.1 布尔表达式的翻译

(按数值表示的布尔表达式的翻译不是重点,这里只介绍作为条件控制的布尔表达式的翻译)

基本思路如下

按照上面所讲,可以将if-then-else翻译为如下三地址指令

在分支语句和循环语句中都会用到布尔表达式,布尔表达式的基本文法如下

  • 将关系运算符作用于两个算术表达式就可以得到一个关系表达式,关系表达式本身可以构成一个布尔表达式;
  • 将逻辑运算符作用于布尔表达式可以得到一个新的布尔表达式;

在跳转代码中,逻辑运算符&& || 和 ! 被翻译成跳转指令,运算符本身不出现在代码中,布尔表达式的值是通过代码序列中的位置来表示的

输入的程序片段如下,将其翻译为三地址码,可以看出并没有逻辑运算符&& || 和 !;

  • 布尔表达式被翻译成由跳转指令构成的跳转代码,用指令的标号标识一条三地址指令;

下面给出布尔表达式的SDT(与或非是分开介绍的)

语义函数newlabel,返回一个新的符号标号;

对于一个布尔表达式E,设置两个继承属性;

  • E.true是E为’真”时控制流转向的标号

  • E.false是E为’假’时控制流转向的标号

E.code记录E生成的三地址代码序列;

or产生式的语义规则

and产生式的语义规则

5.2 布尔表达式的回填

(这节内容在其他地方也被称为一遍扫描实现布尔表达式的回填)

在为布尔表达式和控制流语句生成中间代码的时候,关键的问题是确定跳转指令的目标标号,在生成跳转指令的时候目标标号还不能确定,在前面介绍的翻译方案中,解决这一问题的方法就是将存放标号的地址作为继承属性传递到跳转指令生成的地方,但是这样做需要额外的处理(将标号和特定的地址绑定);

下面将介绍回填技术,回填技术的基本思想如下:生成一个跳转指令时,暂时不指定该跳转指令的目标标号。这样的指令都被放入由跳转指令组成的列表中,等到能够确定正确的目标标号时,才去填充这些指令的目标标号;

为非终结符设置两个综合属性:

  • B.truelist:指向一个包含跳转指令的列表,这些指令最终获得的目标标号就是当B为真时控制流应该转向的指令的标号;

  • B.falselist:指向一个包含跳转指令的列表,这些指令最终获得的目标标号就是当B为假时控制流应该转向的指令的标号;

为了处理跳转指令的列表,需要构造以下函数:

  • makelist(i):创建一个只包含i的列表,i是跳转指令的标号,函数返回指向新创建的列表的指针。(注:i对应的跳转指令一般没有包含目标标号,需要被回填);

  • merge(p1, p2):将p1和p2指向的列表进行合并,返回指向合并后的列表的指针;

  • backpatch(p, i):回填函数,将i作为目标标号插入到p所指列表中的各指令中;

下面介绍布尔表达式的回填(relop是关系运算符)

上述的布尔表达式将被翻译成两条跳转指令gen,两条跳转指令的标号都不填写因为这两条跳转指令的标号都在等待回填,因此我们要把它放到相应的列表中;

  • 第一条跳转指令的目标标号是B的真出口,因此我们把它放到B.truelist中。调用makelist函数生成一个只包含nextquad的列表,并把这个列表的指针赋值给truelist,这里的nextquad是指即将生成的下一条指令的标号,即gen(‘if’ E1.addr relop E2.addr’goto_')这条指令的标号;
  • 第二条跳转指令的目标标号是B的假出口,因此把这条跳转指令存放到B.falselist中。因此我们调用makelist函数生成一个只包含nextquad+1这样一个标号的列表,nextquad+1标号就是gen(‘goto_’)这条指令的标号;

接下来看下一条产生式

当B定义为true时,此时可以确定布尔表达式的值为真,生成一条跳转到B的真出口的一条指令,由于此真出口的标号不能确定有待回填,我们把它放入到B.truelist中;

当B定义为false时,此时可以确定布尔表达式的值为假,生成一条跳转到B的假出口的一条指令。由于此真出口的标号不能确定有待回填,我们把它放入到B.falselist中;

对B的翻译与其对应的子表达式B1的翻译是相同的,因此B的属性值等于B1的属性值;

B的值与B1的值正好相反,因此将两个非终结符的属性进行对调;

B1.truelist中的这些指令都要跳转到B1的真出口,当B1为真的时候整个表达式的值就是为真的,因此B1的真出口就是B的真出口。要跳转到B1的真出口就是跳转到B的真出口,因此B1.truelist中的指令都要放到B.truelist中;

B2.truelist的指令都要跳转到真出口,当B2为真时整个表达式的值也为真,因此B2的真出口就是B的真出口。要跳转到B2的真出口就是要跳转到B的真出口,因此B2.truelist中的指令都要放到B.truelist中;

B1.falselist中的指令它们都是要跳转到B1的假出口,当B1的值为假的时候我们要进一步判断B2的值,因此B1的假出口就是B2的第一条指令,因此B1.falselist中的指令都要跳转到B2的第一条指令;

B2.falselist中的指令都要跳转到B2的假出口,当B2的值为假的时候那么整个布尔表达式的值也是假的。因此B2的假出口就是B的假出口,要跳转到B2的假出口也就是要跳转到B的假出口。B2.falselist中的指令都要放置B.falselist中;

根据此示意图可以看出,在分析B2之前,要用B2的第一条指令的标号来回填B1.falselist中的各条指令。我们可以记录下B2的第一条指令的标号,在归约时完成此回填动作。为了记下B2第一条指令的标号我们在非终结符B2之前插入一个标记非终结符M。与M关联的语义动作它的任务就是记录下B2的第一条语义动作的标号。我们给M设置一个综合属性quad, M.quad等于下一条指令的标号。因为我们把M放在B2之前,因此M.quad记录的是第二条指令的标号。根据翻译方案示意图,我们要用M.quad来回填B1.falselist中的各条指令,因此调用backpatch用M.quad回填B1.falselist中的各条属性。B.truelist是由B1.truelist和B2.truelist合并而成的,因此我们调用merge函数将B1.truelist和B2.truelist进行合并,将合并后的指针赋值给B.truelist;

关于and的回填我们不再详细介绍,感兴趣看视频学习;


下面举一个例子(上面看不懂也没关系,会做题就行)

需要翻译的布尔表达式如下

因为定义的都是综合属性,所以可以采用自底向上的分析;

从左向右扫描输入串,将a<b规约为文法符号B,根据B产生式的语义动作,makelist函数生成一个只包含下一条指令的列表,并把指针赋值给B.trulist;

假设下一条指令从100开始,则B.trulist只包含标号100,B.falselist只包含标号101;

接下来生成两条跳转指令,gen(‘if ’ E 1 .addr relop E 2 .addr ‘goto _’)中E1.address等于a,relop就是小于号,E2.address等于b,引号中的字符串按字面值传递,因此生成的100号指令和101号指令可以确定;

注:下划线表示待回填的目标标号

接下来读入下一个输入符号or,对应于对产生式B -> B1 or M B2的运用;

将栈顶中的空串归约成一个标记非终结符M(这句话的意思就是看到or和and无脑归约一个M即可),并执行M的语义动作;

接着读入后面的输入符号,将c<d规约为B,与上述分析方法相同,得到102和103的指令

根据逻辑运算符的优先级,and优先级高于or的优先级,因此采用移入动作将and移入栈中(此时栈中已经存在B or B),对应于对and产生式的运用;

读入关键字and之后,将栈顶的空串规约为M并执行M的语义动作,实际上就是计算下一条指令的标号;

接着继续读入输入符号、归约、执行语义动作;

接着就可以计算B and B(执行and产生式关联的语义动作);

接着执行栈顶的B or B

上图是整个B对应的三地址码,其中有四条指令是等待回填的,在B的truelist中有两条指令100和104,当B的真出口确定以后我们将用B的真出口的标号回填这两条指令。同理B的faillist中有两条指令103和105,当B的假出口确定以后将会用B的假出口的标号回填此两条指令;

5.3 控制流语句的回填

知识点部分参考编译器笔记35-中间代码生成-控制流语句的回填 - 简书 (jianshu.com),这里我们直接举例说明

下面是上述输入的控制流语句对应的注释语法分析树,接下来看看是如何自底向上构造的

从左向右读取输入,当读入while会运用产生式

接着将a<b规约为非终结符B,并执行B相关的语义动作,生成两条跳转指令;

接着读入关键字do,栈顶归约出非终结符M2;

接着读入if,对应如下产生式的使用

将c<5规约为布尔表达式B,执行其语义动作,生成两条跳转指令;

接着读入输入符号then,将栈顶归约非终结符M1…

6.过程调用语句的翻译

过程调用语句的基本文法如下

过程调用语句的代码结构如下

过程调用语句的SDD如下

可以简单看一下这个例子

  • 5
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

坂.y

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值