编译原理学习笔记。参考:
编译原理(中科大)
编译原理(哈工大)
笔记是根据书+网课+本校课程按照理解过程记录,和书或网课的顺序不一致。
github持续更新。
第一章 引论
1.语言处理器
-
编译器
-
解释器
2.编译器的结构
-
词法分析:将语素转换为词法单元
-
语法分析:使用词法单元创建树形的中间表示,常用语法树。
-
语义分析:检查源程序是否和语言定义的语义一致,并进行类型检查。
-
中间代码生成
-
代码优化
-
代码生成:以源程序的中间表示形式作为输入,映射到目标语言。
3.程序设计语言的发展历程
4.编译器相关科学
5.编译技术应用
6.程序设计语言基础
-
静态和动态的区别:使用的策略可处理编译时刻可决定的问题,则为静态策略;一个只允许在运行程序时作出决定的策略则为动态策略。
-
声明的作用域:仅阅读程序就可以确定一个声明的作用域,则该语言使用的是静态作用域。
-
环境:名字到存储位置的映射。
-
状态:内存位置到值的映射。
-
块结构:{}界定一个块。
-
静态作用域规则:如果名字x的声明D属于B块,那么D的作用域包含整个B,但是以任意深度嵌套在B中,重新声明了x的所有块C不在此作用域中。
-
动态作用域:一个作用域策略依赖于一个或多个在程序执行时刻才能知道的因素,就是动态的。
//动态作用域例:
#define a (x+1)
int x = 2;
void b() { int x =1; printf("%d\n",a);}
void c() {printf("%d\n",a);}
void main(){b();c();}>();<span style="color:#000000">c</span>();}</span></span>
-
参数传递:参数可以通过值或引用的方式从调用过程传递给被调用过程。通过值传递传递大型对象时,实际被传递的是指向这些对象的引用。
-
别名:当参数被以引用传递方式传递时,两个形式参数可能会指向同一个对象,这会造成一个变量的修改改变了另一个变量的值。
第二章 词法分析
在开始词法分析之前,需要了解一些基本概念。
1.词法&语法分析基本概念
字母表
字母表是有穷符号的集合。两个字母表可以进行乘积,幂,正闭包,克林闭包运算。
-
乘积:{0,1}{a,b} = {0a,0b,1a,1b}
-
幂:{0,1}^3 = {0,1}{0,1}{0,1}
-
正闭包:{a,b,c,d}+ = {a,b,c,d,aa,ab......}
-
克林闭包:{a,b,c,d}* = {a,b,c,d}+ ε(空串)
串
串是字母表中符号的一个有穷集合,串s的长度通常记作|s|。串的运算为连接和幂,串x=str1,y=str2,则xy=str1str2,幂运算同理。
文法
文法是用来描述语法结构(语言规则)的。一个自然语言的文法例子如下:
-
<句子> -> <名词短语><动词短语>
-
<名词短语> -> <形容词><名词短语>
-
<名词短语> -> <名词>
-
...其他规则
-
<形容词> -> {...}
-
<名词> -> {...}
文法的形式化定义:G=(Vt,Vn,P,S)。
-
Vt:终结符集合。终结符是文法所定义语言的基本符号。例如Vt={difficult,parse,course.....}
-
Vn:非终结符集合。非终结符是表示语法成分的符号,也被称为语法变量。例:{<句子>,<名词短语>,<动词>......}
-
P:产生式集合。产生式描述了终结符(基本符号)和非终结符(语法成分)组成串的方式。一般形式为:α→β。例如:<句子>→<动词短语><名词短语>。另外,同一个非终结符(语法成分)的产生式可以组合在一起表示,用|分隔: list → list + digit,list → list-digit可以合并成list → list+digit | list-digit。(|的优先级最低)
-
S:开始符号,文法中最大的语法成分。例:S=<句子>
有了语言规则,就可以判断一个词串是否是一个语言的句子了。句子可以进行推导,也可以进行规约,分别对应着生成语言和识别语言的角度,下面是自然语言的例子:
2.词法分析和词法分析器
词法分析是编译的第一个阶段,任务是把源程序的输入字符(词素)转换为已知语言的成分(词法单元序列)。转换后的词法单元序列会交给语法分析器进行语法分析。通常语法分析起有一个getNextToken命令让词法分析器读取输入字符,直到词法分析器能识别一个词素,并将该词素生成为词法单元交给语法分析器。实现一个词法分析器有两种方式,第一种是手动用代码实现(需要画状态转移图辅助),另一种是使用词法分析器生成工具(需要使用正则表达式描述出词素的模式)。
下面是词法分析使用的术语:
-
词法单元:由一个词法单元名和一个可选属性值组成。词法单元名是表示词法单位的抽象符号,属性值表示了词法单元的相关信息。比如一个词法单元“id”表示程序设计语言的标识符,那么属性值就是指向这个具体的标识符信息(在符号表里)的指针;一个词法单元“number”表示数字常量,那么属性值就是这个数字常量的具体值。
-
模式:描述了一个词法单元的词素可能具有的形式。
-
词素:源程序的一个字符序列,和一个词法单元的模式匹配,可以被识别为一个词法单元的实例。
例:
3.输入缓冲
识别输入流中的词素之前,首先要读入输入流。为了处理输入的字符,需要使用两个缓冲区。每个缓冲区容量都是N个字符,通常N是一个磁盘块的大小。处理读入字符流时有两个指针,lexemeBegin指针指向当前词素的开始处,forward指针一直扫描,直到发现某个模式匹配为止。如果forward已经扫描到了EOF,就再读入N个字符到另一个缓冲区,这样只要词素的长度不大于N,就不会在识别到词素之前覆盖掉缓冲区中的词素。EOF也被称为哨兵标记,输入的末尾和缓冲区的末尾都有该字符。
*4.正则表达式
正则表达式是用来描述语言的一种方式,可以描述字母表上的符号通过并,连接,闭包这些运算而得到的语言。用符号给正则表达式命名,然后进行定义,写法与文法很相似,例如用正则表达式表示无符号数(5280,0.01234,6.336E4,1.89E-4)的串number:
digit ——→ 0|1|...|9
digits ——→ digit digit*
optionalFraction ——→ . digits | e
optionalExponent ——→ (E(+|-|e)digits) | e
number ——→ digits optionalFraction optionalExponent
正则表达式还有一些扩展的写法,如后缀+表示语言的及其正闭包,后缀?表示零个或一个出现,字符类可以用[a-z]表示a|b|...|z。
5.状态转换图

对于保留字(if,else)的识别,通常为这些保留字建立单独的状态图。也可以将其先填入符号表中,识别该词法单元后,如果符号表中没有该符号,就说明不是保留字,添加到符号表,并返回条目指针作为属性。
6.词法分析工具Lex
开始介绍词法分析时,提到了将词素转换为词法单元有两种方式:第一种是手动用代码实现(需要画状态转移图辅助),另一种是使用词法分析器生成工具(需要使用正则表达式描述出词素的模式)。画状态转移图的方式已经介绍过了。而Lex就是另一种方式的工具,即词法分析器生成工具。
Lex中,使用正则表达式描述词法单元的模式,Lex编译器将输入的模式转换成一个状态转换图,并生成相应的实现代码,存放到lex.yy.c文件中。Lex编译器编译的内容是由Lex语言表示的。因此Lex的使用方式如下:
Lex程序结构如下:
<span style="background-color:#f3f3f3"><span style="color:#555555">声明部分
%%
转换规则
%%
辅助函数</span></span>
-
声明部分:包括变量和明示常量(如一个词法单元的名字)。
-
转换规则:形式为:模式{动作},模式为正则表达式,动作则是代码片段。
-
辅助函数:各个动作需要的辅助函数。
仅看上面的结构很难理解Lex程序到底是怎么构造的,需要结合下面这个例子来理解各个部分:
%{
/*明示常量:LT,LE,NE,...,ID,NUMBER...*/
/*这部分(%{%})里面的内容会直接复制到C代码中*/
#define LT 10000
......
%}
/* 一些正则表达式 */
delim [ \t\n]
ws {delim}+
letter [a-zA-Z]
digit [0-9]
id {letter}({letter}|{digit})*
number {digit}+(\.{digit}+)?(E[+-]?{digit}+)? //\.表示‘.’
%%
{ws} {}
if {return(IF);} //保留字先被列出
then {return(THEN);}
else {return(ELSE);}
{id} {yyval = (int)installID();return(ID);}
{number}{yyval = (int)installNum();return(NUMBER);}
"<" {yyval = LT;return(RELOP);}
......
%%
int installID(){
/*
将找到的词素放到符号表中
返回一个指向符号表的指针到yyval,这是个全局变量,语法分析器或编译器等后续组件可以使用
把词法单元名返回到语法分析器。
*/
}
...
最后,如果Lex遇到了冲突,即一个词素可以被识别为多种词法单元,按照以下规则处理:
-
选择最长前缀(例如选择<=,而不是<)。
-
有多个模式可匹配,选择在Lex中先被列出的。
书中还提到了关键字不是保留字的情况,这里不讨论。
*7.有穷自动机
-
有穷自动机是识别器,只能对可能的输入串回答'是'或'否'。
-
有穷自动机分为两类:
-
不确定的有穷自动机(NFA):对其边上的标号没有限制,可以是空串,且一个符号可以标记离开一个状态的多条边。(一个状态接收到一个符号后,可能到达不同的状态)
-
确定的有穷自动机(DFA):对于每个状态及自动机输入字母表中的每个符号,有且只有一条离开该状态,以该符号为标号的边。(一个状态接收一种符号,只到达一种下一状态)
-
7.1 不确定的有穷自动机(NFA)
一个NFA由以下几个部分组成:
不管是NFA还是DFA,实际上都可以用转换图来表示,但是NFA与状态转换图的不同在于:
-
同一个符号可以标记从同一状态到多个目标状态的边。(一个符号可以转换到不同状态)
-
标号不仅是字母表中的符号,还可以是空符号串ε
下面是一个识别正则表达式(a|b)*abb的NFA的转换图:
上面的状态3表示串被接收,所有该FA接受的串构成的集合记为L(M),称为被该FA定义(或接收)的语言。从状态0接收到‘a’,可能到达状态0,也可能到达状态1,因此这个FA是不确定有穷自动机NFA。除了状态转换图以外,转换表也可以表示NFA,以下是上图的NFA对应的转换表:
上面已经看到NFA接收一个符号,可能转换到不同状态的特点了,下面的例子展现了另一个特点,空串也可以作为状态转换的标号:
7.2 确定的有穷自动机(DFA)
DFA是NFA的特例,特点是:
-
没有输入ε的转换动作。
-
每个状态s和符号a,只有一条标号为a的边离开s。
DFA相对于NFA识别更加具体和简单,因为一个符号只能导致从一个状态转换到另一个特定的状态(而不是不确定的多个状态),由于每个NFA都可以转变为一个接收相同语言的DFA,实际上我们模拟和实现的都是DFA。
*8.正则表达式到自动机
介绍自动机是要说明Lex词法分析的过程。自动机本质其实就是状态机,可以说Lex的工作就是根据自动机的状态变化产生词法分析代码。我们输入Lex的是正则表达式,因此Lex要先把这些正则表达式转化为自动机。通常先把正则表达式转换为NFA,再把NFA转换为DFA(因为直接从正则表达式构建DFA比较复杂)。
8.1 正则表达式到NFA
对正则表达式进行NFA的构造只要按照以下规则:
8.2 NFA到DFA
有了NFA后,需要将NFA转换为DFA。这个过程采用的是子集构造法。基本思想是:DFA的每个状态是NFA的状态集合(例如NFA中的某个状态A,得到字符x后可能回到A,可能到B或C,那么转换到DFA后,ABC就是为一个状态集合,表示DFA中的一个状态。)。下面直接看两个例子:
8.3 DFA最小化
对于同一个语言,存在多个识别该语言的DFA。使用DFA来实现词法分析器,总是希望使用的DFA状态数最少。因此需要进行DFA最小化。DFA最小化通常使用的是Hopcroft算法,也称为基于等价类的算法。
该算法的原理是,假设有一个状态集合S,如果有一个字符c,可以将S切割,那么这个S就被切割成S1,S2。不断的进行切割,直到不可切割时,每个集合S1,S2...中的状态就是可以合并的。字符c可以切割S这样解释,假设状态1,2,3一开始都在集合S中,状态1和状态2接收到字符c都转换为状态4,而状态3接收到c不转换到状态4,那么就说字符c可以切割集合S,集合S被切割成S1(状态1,状态2)和S2(状态3)。最开始的时候,现将所有状态分成两个集合,接收状态的集合和非接受状态的集合。该算法的描述如下:
split(S)
foreach (character c)
if(c can split S)
split S int T1,...,Tk
hopcroft()
split all nodes into N,A
while (set is still changes)
split(S)
9.DFA到词法分析器代码
能够从正则表达式构建NFA,将NFA转换到DFA并最小化,最后一步就是将DFA转变成词法分析器的代码。DFA是一个有向图,可以表示为转移表,哈希表等不同的表示,具体的表示取决于在实际实现中对时间空间的权衡。
先看一下转移表的表示:
状态\字符 | a | b | c |
---|---|---|---|
0 | 1 | ||
1 | 1 | 1 |
状态的变化可以直接查表,再加上驱动代码就可以构成词法分析器:
nextToken(){
state = 0;
stack = [];
while(state!=ERROR){
c = getchar();
if(state is ACCEPT) clear(stack);
push(state);
state = table[state][c];
}
while(state is not ACCEPT){
state = pop();
rollback();
}
if(state is ACCEPT) return ACCEPT_TOKEN;
}
还有使用跳转表的代码实现,使用goto语句进行状态跳转,if语句进行字符判断并状态跳转。这样实现的方式优点是不需要存储状态表,当状态表很大,状态非常多的时候,这样能节省许多空间。