笔记大部分内容来自参考资料[1], 看了B站上中科大华保健老师的编译原理课视频(参考资料[2]),补充完善了DFA的代码表示、Hopcroft 算法、文法重写、LL(1)算法、LR算法等内容
有许多知识是结合了自己的理解进行整理,所以可能会有错误之处
再往后因为时间问题就有点烂尾了...
文章末尾有北京工业大学2019年软件学院编译原理的考题回忆及朱娜斐老师划分的考点(recoded by @杰哥)
Keyword
编译器是一个程序,核心功能是把源代码翻译成目标代码
源代码-->编译器的静态计算-->目标程序-->计算机的动态计算-->计算结果
静态计算
是指编译器只根据程序文本静态地分析(如做报错分析、优化分析),而不是真的拿 CPU 去执行
计算机
可能是一个 x86 的物理器(如对应 C 语言),也可能是 JVM java 虚拟机(如对应 java)。
解释器也是一类处理程序的程序
区别在于:
编译器:输入源代码,输出一个可执行程序,但不去执行。
(存放在磁盘上等待被加载到内存中执行)
解释器:输入源代码,直接输出执行结果。
其实 JVM 就是一个解释器,而不是一个单纯的编译器。输入 java 字节码 bytecode ,然后直接输出执行结果,而不是输出汇编代码。
第一个编译器是Fortran语言的编译器
该编译器给计算机科学的发展产生了巨大的影响:
编译器具有非常模块化的高层结构
编译器可以看做多个阶段构成的“流水线” 结构
编译器规模庞大,拆分模块容易实现和维护
编译器通常会被划分为两个部分(如下图):
[图片上传失败...(image-188606-1560422078662)]
背景一:现在我们设计一个叫做 Sum 的语言,特别简单,仅仅支持两种语法。第一是整形数字 n
,第二是加法表达式 e1 + e2
。举几个例子:
3
5 + 6
7 + 8 + 9
(加法要满足左结合性,即先计算 7 + 8
)7 + (8 + 9)
7 + 8 * 9
,Sum 语言中没有乘法背景二:有一个栈式计算机 Stack (后面会再次讲到),其中有一个操作数栈,只支持两条指令,push n
和 add
。之所以选择栈式计算机,第一是因为简单,第二是因为 JVM 就是采用了这种形式。其指令的详细解释例子如下:
push 3
将 3 压栈push 4
将 4 压栈add
将 3 和 4 出栈,然后做加法得到 7 ,再将 7 压栈。即将栈顶的两个元素都出栈,做加法,将结果再压栈有了上述两个背景之后,接下来的任务是:将程序 1 + 2 + 3
编译到栈式计算机 Stack 。
第一个阶段是词法分析,先不管其中的原理是什么,总之词法分析会将 1 + 2 + 3
拆分为 1
+
2
+
3
这 5 个部分。(后面会提到词法分析的原理就是用正则表达式匹配)
第二阶段是语法分析,就是将词法分析拆分出来的内容,分析是否满足 Sum 语言的语法要求,即 n
或 e1 + e2
这种语法。
第三个阶段是语法树构造(有时算在语法分析阶段里) ,经过某些计算之后(可以看出是按中序遍历生成了二叉树),得到的抽象语法树如下图:
[图片上传失败...(image-d8b081-1560422078662)]
第四个阶段是根据抽象语法树做代码生成。首先,要满足加法的左结合性,对树进行遍历的时候就要优先遍历左子树,即后序遍历(左右根)。
在遍历树节点的过程中,如果遇到整数 n
就生成一条 push n
指令,如果遇到 +
就生成一条 add
指令。
接下来详细看一下这棵树的遍历过程:
1
,生成 push 1
,将 1 压栈2
,生成 push 2
,将 2 压栈+
,生成 add
,将 1 2 出栈,计算加法得到 3 ,将 3 压栈 (这里即体现了加法的左结合性)3
,生成 push 3
,将 3 压栈+
,生成 add
,将 3 3 出栈,计算加法得到 6 ,将 6 压栈,完成从编译器内部结构得知,执行编译的第一个阶段就是词法分析。
词法分析的任务:将字符流转为记号流
字符流即源程序代码,记号流即编译器内部定义的数据结构、编码所识别出的词法单元
词法分析即将源程序代码与编译器内部定义的数据结构相对应
通俗来说,就是将源代码进行最细粒度的拆解,例如上面的例子将 1 + 2 + 3
拆分为 1
+
2
+
3
一样
[图片上传失败...(image-58705d-1560422078662)]
如上图,从源代码到记号流(单词流)。
词法分析器会将源程序根据关键字、标识符(变量)、括号、引号、运算符、值(整数、字符串)等这些要素,将其从左到右拆分为若干个记号(单词),其中会忽略空格和换行等。上图中记号流输出的含义:
IF
关键字LPAREN
RPAREN
左右括号INDENT(x)
即标识符(变量),有一个属性 x
,表示变量名GR
即 >
INT(t)
即 int
类型值,属性是 5
EOF
是结束符根据上面的例子,可以总结出 token 其实有固定的形式,就可以定义其数据结构,如下图(本文中高级语言的示例,默认情况下都是 C 语言)
[图片上传失败...(image-61f347-1560422078662)]
理解了例子,定义了数据,接下来就要去探寻词法分析的实现算法,第一,手工构造;第二,自动生成 。
手工构造即手写一个词法分析器,例如 GCC LLVM ,优点是利于掌控和优化细节,缺点是工作量大、易出错。手工构造法主要用到“转移图”这种数据结构,下面举两个例子说明。
[图片上传失败...(image-fafc05-1560422078662)]
上图的转移图模型,即可识别逻辑运算符,如 <=
<
<>
>=
>
。识别到第一个字符,就继续往下做分支判断,直到返回一个确定的运算符。
图中的 *
即一次回溯,即将当前的这个字符再返回到词法分析器重新进行分析。
例如 >1
,读到了 1
这个字符时,此时已经确定了运算符是 >
,而当前的 1
并不是运算符的一部分,因此将 1
再重新返回到词法分析器中重新进行分析。
[图片上传失败...(image-8d0828-1560422078662)]
上图是标识符(变量)的转移图模型,以及伪代码。其中 *
即一次回溯,跟上面一样。
关键字(如 class
if
for
等)是一种特殊的标识符,也满足标识符的规则。
要识别关键字,有两种解决方案:
O(1)
复杂度内完成。所谓自动生成技术,就是有这样现成的工具(如 lex
flex
jlex
),输入一些声明式的规范,即可自动生成一个词法分析器。优点当然是简单快速,缺点就是无法控制细节。而这里的“声明式规范”,就是我们常见的正则表达式。下文的内容,就是如何用程序去解析正则表达式,如果你之前看过关于“正则表达式 原理”这类的文章,可能早就有了解了。
先说一下自动生成技术的几个阶段,专业术语后面都有解释:
Thompson
算法)Hopcroft
最小化算法))正则表达式是一种数学上的概念,
首先它要有一个完整的字符集 Σ = {...}
要能涵盖程序所有的关键字、变量名、数字、运算符、括号、引号、特殊符号等
ASC
编码,即 256 个字符unicode
编码,可能几万甚至十几万个字符集(因为 java 的变量名称并不仅限于英文、中文也可以作为变量)然后只有以下几个基本的逻辑:
M|N
两者取并集MN
是正则表达式,两者相连M*
={ ,M,MM,MMM...} 称为“闭包”(和程序的闭包不一样),即可以有 0 或者若干个 M
a|(bc*)
这就是正则表达式的定义,而现代正则表达式这么多的语法,例如 [a-b]
?
+
等,都是后来扩展出的语法糖,即对基本规则的一种简写方式。
C语言中的关键字,例如if,while等,如何用正则表达式表示?
就是用 if
, while
表示,因为 i
是字符集(C语言对应ASC字符集)的元素, f
是字符集中的元素,所以 它们连接形成if
是正则表达式
同理可说明为什么其它关键字是正则表达式
C语言中的标识符:以字母或下划线开头,后跟零个或多个字母、数字或下划
线。如何用正则表达式表示?
(a|b|c|...|z|A|B|...|Z|_ )(a|b|c|...|z|A|B|...|Z|_ |0|1|2...|9)*
如果用语法糖来表示的话就是[a-zA-Z_] [a-zA-Z_0-9]*
也称“有穷自动机”,是一种数学模型。
简单理解,就是输入一个字符串,输出这个字符串是否满足某个规则(true / false)。
FA(有限状态自动机) 实质上是带有边和节点的有向图。
例如有 a + b
这样一个规则,输入“1 + 2 ”
就满足,输 “abc”
这就不满足。
实现原理: 就是先设定几个状态,然后根据输入的字符做状态转移,看最后能否转移到最终的状态。
如下图,输入 abbaabb
,初始状态是 0
,然后分别输入一个一个的字符,看最后能否将状态转移到 3
。
[图片上传失败...(image-d00190-1560422078662)]
有限状态自动机 FA 又分为两种:
0
状态,输入 a
时有两个出口,所以它是 NFA 。其实每一个正则表达式,都能对应一个 FA ,因此接下来看一下正则表达式如何生成 FA 。
先将正则表达式生成 NFA ,再将 NFA 生成 DFA 。
这是因为:
其基本的逻辑是:
下图是 、c、e1 e2 的NFA:
下图是 e1 | e2 、e1* 的NFA:
示例如下:
a(b|c)*
用 Thompson算法的思想画出其NFA的思路:
e1 e2
结构的NFAe1 *
结构的NFA,替换掉第一步的e2所谓“子集”就是原来 NFA 的若干状态的集合,通过构造子集,来实现 DFA 。也就是说,此时构造出来的 DFA 就不单单是一个一个的状态节点了,而是一个一个的状态子集。
实际上可分为两步:
所以n0通过a可到的状态集合(边界)为{n1,n2,n3,n4,n6,n9}, 记该集合为q1,n0为集合q0,课类似画出覆盖原NFA所有转换的图
伪代码如下:
最后得到的DFA图的结点中,如果其所代表的集合包含了原NFA中的接受状态(终结状态),则该结点也应该化为两个圈的形式表示接受状态。
基本逻辑是,将生成的 DFA 的子集再进行合并,减少节点数量。状态节点越少,占用的空间复杂度越少,提高运算效率。
基于等价类的思想,Hopcroft 算法思路如下:
hopcroft 算法示例一:
对于a(b|c)* 的DFA图如下,原来包含q0,q1,q2,q3四个结点:
q0是非终止结点,q1,q2,q3是终止结点,将它们划分为两类
q0无法再分,考虑q1,q2,q3是否可以继续划分:易发现q1,q2,q3之前可以通过c或b进行转换,而c、b转换使得这三个结点仍然处于这个三个结点的组成的集合的区域内,所以这个区域无法再分,将之记为q4
这样,原来的DFA就简化为下面所示的图
hopcroft 算法示例二:
hopcroft 算法伪代码如下:
DFA(确定的有限状态自动机) 实质上是带有边和节点的有向图,所以可以用表示图的数据结构来表示,不过实际上不同的编译器都对数据结构进行了优化。
可以用多种方式来进行DFA的代码表示:
至于选用哪种方式,取决于对时间和空间的权衡
包括两个部分,转移表和驱动代码。
转移表就是一个存储状态和输入字符对应的状态转移情况
驱动代码就是读取输入,然后与转移表内容相比较,看输入的串是否满足DFA的条件。
转移表示例如下:
[图片上传失败...(image-61d03e-1560422078662)]
图中第一列是状态,第一行是字符,例如:
0
时,输入字符 a
,行列交叉点是 1
,表示可以转向状态 1
1
时,输入字符 a
,行列交叉点是 2
,表示可以转向状态 2
1
时,输入字符 b
,行列交叉点是 1
,表示可以转向状态 1
有了以上所有的逻辑,就可以判断一个字符串是否符合一个 RE 的规定,如果符合则可以将字符串拆分为一个一个的记号(token,单词)。
驱动代码如下:
大部分编译器对输入的串都是进行最长匹配
跳转表法的好处就是不需要维护转移表法那样大的数组,执行时只需要一个一个代码段执行,所以往往效率较高,但仍不能说跳转表法一定比转移表法好,还是得根据具体的应用场景分析。
对于C语言来说,跳转表可能就是用Label来实现,不同Label的代码段表示不同的状态,转移到某个状态就跳转到对应的代码段,在特定代码段内有显式地在特定条件下跳转到别的 label 的语句。
词法分析之后,输出了记号流,然后传递给语法分析,这里主要有两部分工作:
输入一个程序语法的表示(上下文无关文法),判断是否符合程序的语法
给定一个文法 G 和句子 s ,要确定:是否能在 G 的推导结果中,找到 s (即,是否存在对句子的推导
如果符合,就根据输入的符号集,生成抽象语法树 AST
上文所说的“程序语法的表示”,就是上下文无关文法 CFG(context-free grammar) ,是一个描述语言语法规则的标准的数学工具。
取名为 “上下文无关” 的原因就是因为字符 A总可以被字串 自由替换,而无需考虑字符 X 出现的上下文。
一个形式语言是上下文无关的,如果它是由上下文无关文法生成的。
[图片上传失败...(image-db91e5-1560422078662)]
上图的左侧就是一个 CFG 的简单示例,其中每一条叫做 “产生式”(即规则),图中的 |
即“或”的意思。简单解释一下这个 CFG 的意思:
[图片上传失败...(image-10a19a-1560422078662)]
上图就用 CFG 描述了一个 *
和 +
表达式,其中 num
表示一个具体的数字, id
表示标识符(变量),这俩都是终结符,只有E
是非终结符。
上面定义英文句子的规则就可以说是一个上下文无关文法。其中,<句子>
被称为开始符号,<主语>
<谓语>
<代词>
之类的被称为非终结符号,He
、gave
之类的被称为终结符号。
归纳起来,一个上下文无关文法G包括四个部分:终结符号,非终结符号,开始符号,产生式。
终结符号就是一门语言中最基本的符号。在程序语言中,基本字、标识符、常数、运算符号等都算终结符号。
非终结符号更像一个抽象的集合,比如“算数表达式”、“赋值句”都可以看做非终结符号。
产生式就是推导规则。
按以下方法可以从文法中推导出它所代表的语言
从上面的例子来看,可以根据一个 CFG 推导出若干个句子,例如上图的 CFG 可以推导出 id + num
或者 id * num
或者 (id + num) * num
或者 ……
语法分析就是:给定一个文法 G 和句子 s ,要确定:是否能在 G 的推导结果中,找到 s (即,是否存在对句子的推导)**
如果能推导出来,说明句子 s 符合文法 G 的语法,否则不符合。如下图:
[图片上传失败...(image-717bca-1560422078662)]
推导方式一般有两种:
文法被分为4类,分别是0型文法、1型文法、2型文法、3型文法。
具体释义和特点如下:
xSy -> xAy
。也就是说,S推导出A是和上下文x, y相关的,即S只有在上下文x, y的环境中才能推导出A; S -> A
。S可以无条件的推导出A,和上下文无关,上下文无关文法因此得名;四种文法就是规定产生式的左和右边的字符的组成规则不同而已
如何根据产生式左边和右边的特征来进行判断。首先,应该明确,四种文法,从0型到3型,其规则和约定越来越多,限制条件也越来越多,所以,我们判断时可以从最复杂的3型进行判断,依次向下判断,如果不符合3型的,那再看是不是2型的,不是2型的,再看是不是1型的。
先看3型文法(正则文法):
左边必须只有一个字符,且必须是非终结符;
其右边最多只能有两个字符,且当有两个字符时必须有一个为终结符而另一个为非终结符。当右边只有一个字符时,此字符必须为终结符。
对于3型文法中的所有产生式,其右边有两个字符的产生式,这些产生式右边两个字符中终结符和非终结符的相对位置一定要固定,也就是说如果一个产生式右边的两个字符的排列是:终结符+非终结符,那么所有产生式右边只要有两个字符的,都必须前面是终结符而后面是非终结符。反之亦然。
例如这样:
A->wB
或 A->w
,(右线性文法)
A->Bw
或A->w
,(左线性文法)
再看2型文法如何判断:
与3型文法的第一点相同,即:左边必须有且仅有一个非终结符。
2型文法所有产生式的右边可以含有若干个终结符和非终结符(只要是有限的就行,没有个数限制)。 例如这样: S -> aSb S -> ab
这个文法有两个产生式,每个产生式左边只有一个非终结符S,这就是上下文无关文法,因为你只要找到符合产生式右边的串,就可以把它归约为对应的非终结符。
再看1型文法如何判断:
aSb -> aaSbb S -> ab
这就是上下文相关文法,因为它的第一个产生式左边有不止一个符号,所以你在匹配这个产生式中的S的时候必需确保这个S有正确的“上下文”,也就是左边的a和右边的b,所以叫上下文相关文法。最后是0型文法,只要能描述出来,都属于这个类型,即0型。
文法 是描述语言的语法结构的形式规则(即语法规则)。
上下文无关文法 就是文法的一种,它所定义的语法单位是完全上下文无关的。
假设G是一个文法,S是开始符号,如果S经过零步或者若干步推出 α,那么称α是一个句型。只包含终结符号的句型是一个句子。文法G产生的所有句子构成一门语言,记为L(G)。
需要注意: 一个文法可以唯一确定一个语言,但是一个语言不一定唯一对应一个文法。
[图片上传失败...(image-28b199-1560422078662)]
如上图,在文法的推导过程中,可以用树的形式来表示,即分析树。
其中, 内部节点都是非终结符,叶子节点都是终结符,中序遍历即可得到最终的句子。PS:到这里,貌似已经看到了最终输出的抽象语法树 AST 的雏形了,其本质就是来源于 CFG 的格式。
上面是对3 + 4 * 5的语法推导,A、B 分别是两种不同的推导顺序(A是最左推导,B不是最右推导,注意上面方式B中,E*E -> E+E * E, 是把左边的第一个E替换成了E+E)
所谓“二义性”就是指文法的书写会产生一些歧义,例如上图中 *
和 +
表达式的文法,采用不同的推导顺序得到的结果是不一样的,得到了不同的分析树,虽然它们的叶子结点都相同(即得到的句子相同),但是分析树的含义取决于树的后序遍历,可能分别得出 (3+4)*5
和 3+(4*5)
,显然计算结果不同。
为了避免文法的二义性,只能是重写文法,将文法表述的更加详细一些。
对文法的重写要具体问题具体分析,不存在统一的算法。
课程里也没给怎么重写的方法,就是直接给出重写的文法然后推导验证
该重写后的文法也能产生句子 3+4+5
自顶向下分析思路的算法包含
常规的自顶向下分析算法
递归下降分析算法(预测分析算法)
LL(1)分析算法
上文已经明确了语法分析的定义,即看一个文法 G 是否存在对句子 s 的推导。
自顶向下分析就是其中一个比较典型的算法,其基本逻辑是:
因为这是从开始符号出发推出句子,因此称为自顶向下分析,对应于分析树自顶向下的构造顺序
但是,上述过程比较笨重,因为一个 G 能推导出来的句子可能有非常多种,都拿来跟 s 做比较,会发生很多回溯,非常耗时。可以用以下方式进行优化:
按照从左到右的推导顺序,可以最先得到句子 t 的左侧,得到第一个元素就可以开始和s的第一个元素进行匹配了,如果匹配成功则对下一个字符进行匹配,否则该元素回溯,寻找对应产生式(文法的行)的下一个终结符,如果都匹配不上就回溯到父节点...知道遍历完找不到才是匹配失败。
但是,上述优化后的算法,还是可能会有回溯发生,这远远达不到编译器的性能要求。编译器要处理的程序动辄几十万行,必须要求线性时间复杂度的算法,一旦有回溯就会严重影响性能。
每个非终结符(如下面的N V N )构造一个分析函数,因为非终结符是可以层层定义的,因此是“递归”(即将整个文法匹配整个句子的方式,拆解开,用单个非终结符去匹配句子中的字符,即算法的分治思想)。
体现在下面的例子中则为将S与句子匹配这个任务拆解为 分别将N V N 与对应位置的句子中的字符相匹配
用“前看符号”指导当前产生式规则的选择(即读取输入的句子的下一个字符,直接与当前非终结符的全部右部比较,而不是顺序挨个入栈出栈比较)。
但是当某个非终结符的右部有多个相同开头的时候,比如 a
和 aB
,那下一步应该走 a
还是 aB
呢? 下面讲到的计算FIRST集(开始集)和FOLLOW集(跟随集)来处理这个问题
系统地解决"前看符号"发现的多分支问题,设计到FIRST集(开始集)和FOLLOW集(跟随集)的计算,这个将在下面讲到,其实除了系统的解决方法外,我们还可以通过分析文法的特点来写各个非终结符的分析函数,例如下面可以发现E的右部一定是T+T+T...的形式,T则一定是F* F * F * ...的形式,就可以通过设计合适的分析函数来判断句子是否符合该文法
递归下降分析算法适合于手工编码,而 LL(1) 分析算法适用于语法分析的自动生成。
词法分析器的自动生成需要输入的声明式规范是正则表达式,语法分析器的自动生成需要输入的声明式规范是CFG(上下文无关文法)
所谓“LL(1)”,是指:从左(L)向右读入程序,最左(L)推导,采用 1 个前看符号。分析高效,也是线性时间复杂度。
其基本思想是 —— 表驱动的算法,如下图。
第一列都是非终结符,第一行都是终结符,行列交叉点表示对应的产生式序号(注意要是在非终止符及其FIRST集中的元素的交叉点填写)。
[图片上传失败...(image-2cac5e-1560422078662)]
回顾之前讲过的自顶向下分析算法,最大的问题就在于去盲目推导,盲目匹配出句子,然后再去和目标句子 s 做对比,对比出错就要回溯,时间复杂度非常高。因此,就需要在推导过程中就需要做分析预测,就可以从参考这个分析表。从分析表中,通过预测输入能得到产生式的序号,就知道接下来要匹配哪个产生式了,就不需要回溯了。
FIRST(N)= 从非终结符N开始推导,得出的全部句子中的开头的所有可能的终结符集合。如下面的FIRST(N)={s,t,g,w}
注意其定义也是递归的,如果N的右部有非终结符的话,还要接着递归知道终结符
注意下面给出的计算公式只是近似公式,后面还要提到Numball集和
下图中第一列是非终止符,第一行是循环进行的迭代次数,列是FIRST集(开始符号集)
其中第二次循环S(S-> N V N)的变化是被赋予了上一次循环中得到的N的FIRST集,因为N是不可穿过的,所以V对S没有影响(这个后面会讲到,如果N的开始集中包含空串的画则N就是可穿过的)
如果文法的分析表每个表项只有一个元素的话,那么就将其称为LL(1)文法
如果表项中有多余一个元素的话,则称之为LL(1)分析表中更多冲突
对于一个非终结符的FOLLOW集,其元素即其产生式中每个非终结符后面跟的第一个终结符
其实本质就是扫描每一条产生式,然后按照每一条产生式中所包含的字符的排列顺序,求解每个非终结符后面跟的第一个终结符,然后把这个终结符并入该非终结符的跟随集中。循环这个过程。
最右推导:每一步替换最右边的非终结符,最右推导称为规范推导
实质上是最右推导的逆过程
上文主要讲自顶向下的分析算法,而 LR 分析算法是自底向上的思路,但是输入、输出都是一样的。
画出有限状态自动机
状态和字符结合 移进
规约,右侧弹出去,左侧弹进来
[图片上传失败...(image-76af8b-1560422078662)]
如上图,先看下抽象语法树 AST 和高级语言如何对应,根据代码对比一下,应该不难理解。其中,if 的最左侧节点是判断条件,中间节点是成功分支,右侧节点是 else 分支。
再来回顾一下上文讲的 CFG 的分析树(上文有示意图),它详细编码了句子的推导过程,并且包含了很多无关信息(非终结符),会占用很多存储空间,会增加算法的空间和时间复杂度。如果能把这些“无关信息”给去掉,只留下运算符,数字,标识符等和程序相关的信息,就构成了抽象语法树 AST ,如下图。
[图片上传失败...(image-93b432-1560422078662)]
既然是一棵树,那么就是一个标准的数据结构,各个类型的节点的数据结构,也就可以固定了。如下图
[图片上传失败...(image-2f5363-1560422078662)]
AST 是编译器中非常重要的数据结构,因为它是编译器前端和后端的接口形式。后续的过程仅仅依赖于 AST ,不会再依赖于前面的源码或者字符集。因此,一旦生成了 AST ,前面的源码就会被丢弃。因此,AST 中要有很详细的信息,不仅仅是本课程中讲的这个简单的树。例如,AST 要存储当前程序的文件、行、列,这样在语法报错时才能准确的给出具体的错误位置。
语法分析输出 AST ,然后对 AST 进行语义分析(有些教材也会叫做 “类型检查” 或者 “上下文相关分析” 等名字)。注意,程序如果能通过了语义分析这个阶段,那再往后就不应该出现任何语法错误,除非是编译器自己的 bug 。
上文中的语法分析用到的是 CFG 即上下文无关的语法,即不依赖于上下文。例如 C 语言中 printf(n);
不符合语法,而 print("%d", n);
就符合语法,但是其中的 n
变量是否在上文已经定义了,语法分析是不知道的。
因此,语义分析是在 AST 基础上,结合上下文来分析:
例如表达式的类型检查,定义一个类型检查函数,传入 AST 的某个表达式的节点,然后判断最后返回的类型。如果类型检查错误,就报错。如下图。
[图片上传失败...(image-f8e536-1560422078662)]
上下文相关分析,就涉及到上下文信息的记录和读取,这些信息就被记录到符号表中,一个非常核心的数据结构。符号表用来存储程序中的变量相关信息(表的信息要足够丰富):
privte
protected
等)其数据结构最简单的可以使用一个 key-val
的字典来实现,例如 { key1: {…}, key2: {…}, key3: {…} }
。但是编译器要处理的程序规模可能非常庞大,因此这个数据结构必须要合理规划。在实际工程中,高效的查询方式可以有一下选择:
O(1)
的时间复杂度O(logN)
的时间复杂度变量都有“作用域”的概念,不同作用域可以有相同的变量名。符号表处理作用域的方式:
[图片上传失败...(image-615737-1560422078662)]
经过语义分析的 AST ,即可用来做代码生成,即生成最终的机器(物理机或者虚拟机)代码。注意,这里直接从 AST 到目标代码,是一种最简单的编译器模型,暂时忽略了优化的部分。优化过程下文会详细解说。
代码生成是把源程序翻译成“目标机器”(可能是真实的机器,也可能是虚拟机)上的代码,而且要保证和源程序的“等价性”(重要!!!)。主要的任务是:
接下来通过两个示例来看代码生成的过程:
70 年代有栈计算机的物理机,但是今天已经退出了历史舞台,因为执行效率太低。但是这里还要研究 Stack ,一来是因为在 Stack 上代码生成比较简单,二来是很多虚拟机是这样设计的,例如 JVM 。
[图片上传失败...(image-c53112-1560422078662)]
上图就是一个 Stack 的原型图,简单解释一下图中各个部分。
x
分配内存空间的伪指令:.int x
(伪指令,不会被 ALU 执行)push NUM
,把一个立即数压栈load x
,得到内存中的变量 x
的值,并压栈store x
,把栈顶元素弹出,并赋值给 x
add
,加法,pop 赋值给 x
,再 pop 赋值给 y
,然后 push x+y
sub
,减法,同上times
,乘法,同上div
,除法,同上PS:以上这几条指令,就是 java 字节码的一个子集。真实的 java 字节码有 200+ 个。
[图片上传失败...(image-b62eb6-1560422078662)]
上图就是高级语言到最终的 Stack 计算机机器语言的对应,展示了最终的输入和输出。至于代码生成如何实现,在文章一开始的“Sum 语言 + Stack”的例子中这部分已经写的比较详细,就不再赘述了,翻看上文吧。
这种机器类型是基于寄存器架构,所有操作都在寄存器完成,执行效率非常高(因为寄存器访问速度是内存访问速度的百倍),访存都通过 load
或 store
指令(RISC 指令集特点)。
[图片上传失败...(image-4336da-1560422078662)]
上图就是寄存器计算机的原型图,解释一下图中各个部分。
x
分配寄存器的伪指令 .int x
(伪指令不会被 ALU 执行)movn n, r1
把立即数 n 存入寄存器 r1mov r1, r2
把 r1 的值赋值给 r2load [x], r1
将 x 地址的值取出,放在 r1 。其中 x 是指针,[x] 即取出指针对应内存的值。store r1, [x]
将 r1 的值赋值给 x 内存地址add r1, r2, r3
加法,表示 r3 = r1 + r2sub r1, r2, r3
减法,同理times r1, r2, r3
乘法,同理div r1, r2, r3
除法,同理[图片上传失败...(image-69743d-1560422078662)]
上图就是高级语言和目标代码的对应关系。图中有对应的 AST ,对这棵树进行后续遍历(先左、再右、最后根),每遍历一个节点都会对应到右侧的一行指令。
最后,实际的物理机器上不可能有无限多的寄存器,因此要确定哪些变量被用于寄存器?哪些变量被“溢出”放在内存?—— 这个问题是另外一个编译器的重要部分:编译器分配。如何进行编译器分配,这个问题会在下文介绍。
中间表示是一个统称,有很多种表示形式,AST 就是其中之一。上文提到,从 AST 直接生成目标代码是比较原始的编译技术,现代编译器中往往会在编译器的“后端”进行各种各样的代码优化,不同的优化形式就需要不同的表示形式。
[图片上传失败...(image-e26a41-1560422078662)]
常见的中间代码形式:
所谓“三地址码”,即一个指令有一个运算符,最多有三个操作数。这样就使得每一条指令都比较简单,易于和机器语言对应。
[图片上传失败...(image-f5e86e-1560422078662)]
上图就是一个高级语言和三地址码的对应关系(虽然三地址码是通过 AST 生成的,已经和源代码没有关系)。从图中可以看出三地址码的特点:
a = 3 + 4 * 5
拆解成一个一个的中间变量goto
和 call
。例如将 if else
改为 Cjmp
(条件跳转指令)三地址码是一种线性的表示方式,这就没法通过它来分析和确定流程。例如上图中,哪些指令会跳转到 L_1
和 L_2
?并不好确定。控制流图是一种更加精细的三地址码(本质上还是三地址码),将程序中的各个控制流块都表示了出来,如下图。
[图片上传失败...(image-828b69-1560422078662)]
控制流图就是一个有向图 G = (V, E)
,其中节点 V
表示程序的基本块,边 E
表示基本块之间的跳转关系。生成控制流图的目的有很多,但都是为了做代码优化,例如:
x
可能的值是什么?所谓“数据流分析”,就是通过静态的观察程序(并不执行)来判断其中的变量和数据的一些变化,例如某程序第五行的 x 变量的值会有几种可能?
[图片上传失败...(image-32689a-1560422078662)]
如上图,通过控制流图,既可以判断一个变量 y
的赋值可能性。如果 y
能编译器识别为一个固定的值,直接 a = 3
并且把一开始的 y = 3
删掉。这就是一个优化过程。
但是这仅仅是静态的分析,程序并未执行,因此如果 y
在一个逻辑分支中出现,就不好预估其准确结果,但是至少能预估一个结果集(称为“保守信息”)。如果能将这个结果集做到最小,和执行的结果越接近,就越好优化。这仍然是编译器现在的一个热门话题。
类似数据流分析的还有“到达定义分析”,即分析一个变量是如何一步一步的被定义和使用的,原理和目的基本一致,这里不再赘述。
上文中提到 REG 机器假设有无限个寄存器,但实际情况不是。因此需要寄存器分配 —— 即用到活性分析。所谓“活性分析”,即分析变量的活跃区间(可以理解为声明周期)然后来做寄存器的分配。
[图片上传失败...(image-e42d64-1560422078662)]
如上图,三个变量,只有一个寄存器,该如何分配?答案是:计算出每个变量的活跃区间,即可共享寄存器。寄存器分配,就依赖于变量的活动区间数据。如下图:
[图片上传失败...(image-d59a1e-1560422078662)]
现代生产环境下的编译器,代码优化是其重要工作之一,而且一直在不断的持续优化中。
代码优化的目的是让目标程序更精简、更快速、更节省空间、更节能(所谓的多快好省),当然在不改变语义的前提下 —— 这些应该都比较好理解。但是还有几点关于优化的需要重点说明一下。
[图片上传失败...(image-ca8235-1560422078662)]
即对 AST 进行优化,下面列举几个例子来说明。
第一,常量折叠。静态计算,可以在数字类型和 bool 类型进行优化,例如:
a = 3 + 5
变为 a = 8
(少了一步 +
计算,就相当于帮 AST 节省了一个分支)if (true && false)
变为 if (false)
。而且,if (else)
还可以进行“不可达代码”优化(见下文)第二,代数化简。利用代数的恒等式,进行优化,例如:
a = 0 + b
变为 a = b
(少一个运算符,简化 AST)a = 1 * b
变为 a = b
(少一个运算符,简化 AST)2 * a
变为 a + a
(因为乘法运算复杂度高)2 * a
变为 a << 1
(位运算效率最高)第三,死代码(不可达)代码优化,例如
if (false)
不会被执行,测试环境的 debug 代码,到了线上环境就会是死代码return
之后的语句,不会被执行如常量传播、拷贝传播,在上文讲数据流分析的时候已经写过,不再赘述。
第一个编译器是?(Fortran编译器)
有限状态自动机、正则表达式、上下文无关文法的应用阶段
(词法分析、词法分析、语法分析)
关于LR(0)、SLR的一些的性质、优缺点
//还有两题忘了是啥了,反正挺简单的...
编译模式和翻译模式的比较
列举至少三种中间代码形式,并说明为什么要有不同的表示方式
语法制导翻译的主要思想
具体语法和抽象语法的区别
(15分) 画出最基本的词法分析器模块化结构图 (词法分析、语法分析、语义分析、代码生成及其对应的数据流)
(15分) 依照示例写一段代码(简单,照猫画虎就行了)
(20分)正则表达式 -> NFA(Thompson算法) 、NFA -> DFA(子集构造算法), DFA 的优化( Hopcroft 最小化算法) (PPT原题)
(20分) 计算FIRST
集、NULLABEL
集 FOLLOW
集、画LL(1)分析树 (PPT原题)
[1] 编译原理学习笔记
[2] 编译原理 中科大华宝健_bilibili(带目录索引)
[3] 编译原理 中科大华宝健_bilibili(完整版)
[4] 朱娜斐老师的PPT