一、前言
编译原理是大学一门计算机基础课程,学习了编译原理并不意味着可以写出一个编译器,但学习编译原理可以给我们程序开发者提供一个系统知识性的视角。用这种视角可以去思考很多常见的开发问题,当然也可以参与和研究一些编译器相关的课题或项目,更进一步可以研究一些拓展性问题。
1、面对各种眼花缭乱的开发语言,他们的共性是什么?
2、代码文本是怎么实现文本流到编译到执行的?
3、编译过程的原理是不是有定向的思考范围?
4、语言从发明到编译,跨语言之间翻译是不是可以为跨平台提供思路?
等等这些问题,可以从编译原理的学习中找到自己的答案。
二、编译链路中的概念
在讲编译过程之前,先梳理下编译链接过程。一个文本源程序,一般先要进行:
- 预处理过程:相当于非核心编译工作先交个秘书处理;
- 编译过程:通过词法分析和语法分析,在确认所有的指令都符合语法规则之后,将其翻译成等价的中间代码表示或汇编代码,广义上也可以包含汇编过程;
- 汇编过程:把汇编语言代码翻译成目标机器指令的过程;
- 链接过程:将多个目标文件以及所需的库文件链接成最终可执行文件。
其中链接又分静态链接、动态链接(静态链接:库被拷贝到可执行程序中,代码被装入执行进程的虚拟地址空间中;动态链接:可执行文件被执行时,动态链接库的全部内容将被映射到运行时相应进程的虚地址空间,动态链接程序将根据可执行程序中记录的信息找到相应的函数代码)
其中,编译过程大致流程分为:词法分析、语法分析、中间代码生成、代码优化、目标代码生成。
当我们进入编译流程之后,会发现上述每个环节都有相关知识的嵌套。这里将把包含在编译过程中的知识点概念先罗列出来,以便于理解每个每个知识点的前因后果,具体将在后面展开(重点是词法分析和语法分析阶段
):
1、词法分析阶段:
词法分析阶段,起始就是读入源程序文本字符流,然后产出
记号流(Token)
提供给下一步的语法分析。
词法分析就需要设计词法分析器
(有被广泛应用的Lex
词法分析器工具),词法分析这个过程,识别字符流的时候会引出一个目前高级语言中普遍的遵循的概念: 关键字(定义符) 、标识符、常数、运算符、 分界符
。
词法分析器处理识别这些关键字、标识符等等就需要引入二元式或多元式的概念
,简单来说二元式就是如(type ,value)的字符表达式。
另外词法分析器识别字符流到形成标记Token的过程,需要用到状态转换图
,确定状态的有限状态自动机(DFA)
,非确定状态的有限状态自动机(NFA)
这些概念。
2、语法分析阶段:
语法分析阶段,就是讲词法分析之后形成的Token流,进入到语法分析器中,根据语法规则形成抽象语法树(AST)。
想要语法分析器判断记号流是否符合特定的语法规则,那么首先就需要形式化地来描述语言的语法规则。这就引出了文法的概念:上下文无关文法(CFG)
,CFG是一个用来描述语言语法规则的数学工具,它涵盖有CFG 的数学定义、CFG 的推导(产生式
等概念)。
根据上下文无关文法(CFG)的数学规则,有了自顶向下的分析算法
,自顶向下的算法貌似到头了,有了自底向上分析算法
。
然后在自底向上分析算法的基础上,出现了定义编程语言的语言:BNF范式
。其中语法分析工具Yacc
中的语法规约就是用BNF范式来描述。
通过语法分析器最终会形成抽象语法树(AST)
以上是语法分析一个脉络中呈现出的概念,涉及众多具体的知识点,具体在文章后面进行扩展。
3、中间代码生成阶段:
中间代码是为源程序人为设计一种表示方式:
逆波兰式(后缀式)、三地址码(三元式、四元式)、抽象语法树、有向无环图
。
这里先要问:为什么不直接翻译成机器码呢,而多此一举生成中间代码再转换?
如果不生成中间代码而是直接生成机器语言或者汇编语言形式的目标代码,优点是编译时间短,但缺点是目标代码执行效率和质量都比较低,移植性差。
具体来说,因为不同的cpu的指令集是不一样的,假如直接翻译成机器代码,那么当你换了一块cpu之后,可能你的编译器就完全不能运行了,所以为了鲁棒性,将代码先翻译成中间代码,然后在能在多个不同的cpu上做出相应的改变并且运行了。
4、代码优化阶段:
代码优化顾名思义就是空间和时间复杂度上来优化生成的中间代码。
5、目标代码生成阶段:
目标代码生成,就已经可以涵盖把汇编语言代码翻译成目标机器指令的过程。
以下将重点展开词法分析和语法分析。
三、词法分析
词法分析就需要设计和运用词法分析器。词法分析器的功能就是输入源程序,按照构词规则分解成一系列单词符号。单词是语言中具有独立意义的最小单位,包括关键字、标识符、运算符、分界符和常量
等。
词法分析器以源码字符串为输入,它的输出是标记流token,即一连串的标记,标记以二元式的方式:(单词类别,单词自身的属性值),也可以设计成多元式(类型名称,值,类型码,DFA终止态)。
总的来说,对于一个词法分析器:
- 输入:一个字符串源程序文本;
- 预处理:过滤掉源程序中的多余字符;
- 词法分析:对字符串文本进行分析,也就是对关键字、标识符、运算符、分界符和常量识别;
- 输出:词法分析单词识别之后,保存分析的二元式结果,并输出标记流token;
1、单词:
目前常规的各类开发语言,单词总结起来就5种,可以拆分为两大类:
1、确定性的:
1)、关键字,如if、for、new等,它是确定的;
2)、运算符,如+、-、、/等,它是确定的;
3)、分界符,如逗号、分号、(、)、/* */等,它是确定的;
2、不限制的:
1)、标识符,如变量名、数组名等,它是不限的;
2)、常量,如整型、实型、布尔型、文字型等,它是不限的;
2、词法分析器的结构:
词法分析的实践部分就是需要通过逻辑原理设计和开发词法分析器,如下图是词法分析器的基本结构:
扫描缓冲区说明:
- 输入缓冲区:源程序进入输入缓冲区;
- 预处理程序:取消注释、剔除无用的空白、回车、换行等;
- 扫描缓冲区:从输入缓冲区输入固定长度的字符串到另一个缓冲区(扫描缓冲区),词法分析可以直接在此缓冲区中进行符号识别
分析器对扫描缓冲区进行扫描时一般用两个指示器,一个指向当前正在识别的单词的开始位置(新单词的首字符)叫起点指示器,另一个用于向后搜索以寻找单词的终点,叫搜索指示器。搜索指示器搜索到并分析出 这是某个单词的终点时候,就把两个指示器之间包括的串视为一个单词符号。
但是要考虑一个问题,我们的扫描缓冲区的大小是有限的,有可能出现一个情况,就是从输入缓冲区预处理完的串装进扫描缓冲区时候,一次没有装完(往往不可能一次就干完),末尾的某个单词被分开了,为了解决这个问题,就需要扫描缓冲区最好使用一个如下所示的一分为二的区域,设置双缓冲区。
当左缓冲区读完后,新读入的字符存入右缓冲区;反之,存放在左缓冲区;
起点指针 (lexeme Begin) :用来指示正在扫描的单词的起点;
搜索指针 (forward) :用于向前搜索,寻找单词的结束;
我们这里假定每个半区可容120个字符,而这两个搬去又是互补使用的,如果搜索指示器从单词起点出发搜索到搬去边缘还没有达到单词终点,就会调用预处理程序,把后续的120个输入字符装进另一个半区,搜索指示器进去那个半区再扫描就好了,这就不存在断掉的问题了,相当于是个循环链表。而还有没有可能出现意外呢?当然可能,加入某个标志符或者常数的长度超过120了,这神仙也没办法,所以应该在长度上加以一定的限制。
上述缓冲区的设计逻辑和表述引自于西安交大冯博琴教授的编译原理公开课。上述展开就是为了说明一下词法分析器具体设计实践中需要各个环节细节的分析。
3、单词符号的识别
前面当我们学习做词法分析器时候,需要考虑上述预处理,扫描缓冲区设置,但词法分析器最重要的就是:扫描字符串,单词识别,输出单词符号,也可以称作单词Token流。如何识别单词,需要用到下面的逻辑和思路:
1)、超前搜索
上面我们了解了词法分析器结构中的扫描缓冲区的一些知识,我们再考虑一下这些单词符号应该怎么扫描,就是说词法分析器如何确定断词的,如何知道那就是个单词符号,举个例子,比如看到 i了,应该不会停下,往后走看到个 f,你敢说就是 if 吗,这是有问题的,他需要继续往下扫描,看到个括号,OK,可以确定前面是个 if,对于 if 以外其他的更复杂的关键字,这看上去还是不太容易的一件事情,这就需要用到超前搜索:
超前搜索起点指针指向当前单词的开始处。搜索指针用于向前搜索,寻找单词的结束,搜索指针前移前需要确定是否达到末尾。单词确定后,搜索指针指向该词素结尾字符。生成单词并记录或返回给语法分析器后, 生成单词并记录或返回给语法分析器后, 生成单词并记录或返回给语法分析器后起点指针指向下一个字符。
2)、标识符的识别
多数语言的标识符是字母开头的“字母/数字”串,而且在标识符出现都后面跟着算符或者界符,标志符的断词界限一般是很明显的。
3)、常数的识别
多数语言的常熟表示也大体相似,识别比较直接,但有些依然需要超前搜索。
4)、算符和界符的识别
比如++,>=,--这类算符,依然需要超前搜素,不可能遇到个+,就说它是加号。
4、状态转换图
当我们需要去识别单词的时候,这里就需要引出状态转换图
的问题研究。
状态转换图:是一张有限方向图。在状态转换图中,结点代表 状态,用圆圈表示。状态之间用箭弧连接。箭弧上的标记(字符)代表在射出结状态下可能出现的输入字符或字符类。
状态转换图的功能:用于识别一定的字符串。
初态:一张转换图的启动条件,至少有一个,用圆圈表示。
终态:一张转换图的结束条件,至少有一个,用双圈表示。
我们可以根据一个单词描述的初态、判断、到终态的画出状态转换图,根据状态转换图设计逻辑程序实现单词识别。
5、状态转换图的实现
状态状态图是一种逻辑思路,具体实现流程细分如下:
- CHAR :字符变量,存放最新读进的源程序字符。
- TOKEN :字符数组,存放构成单词的字符串。
- GETCHAR :函数过程,将下一输入字符读入CHAR,搜索指示器前移一个字符。
- GETB :函数过程,检查CHAR中的字符是否为空白。若是,则调用GETCHAR
直至CHAR中进入一个非空白字符。- CONCAT :函数过程,把CHAR中的字符连接到TOKEN之后。
- LETTER: 布尔函数过程,它们分别判断CHAR中的字符是数字或是字母,DIGIT从而给出真假值TRUE、FALSE。
- RESERVE :整型函数过程,用TOKEN中的字符串查保留字表,若是一个保留
字则给予编码,否则回送0值(假定0不是保留字的编码)。- RETRACT :函数过程,把搜索指示器回调一个字节,把CHAR中的字符置为空白。
以上函数和子程序过程都不难编制,使用它们能够方便的构造状态转换图的对应程序。一般,我们可以让每一个状态结对应一个程序段。
例如:我们可以让不含回路的分叉结,对应一个CASE 语句,或者是一组IF…THEN…ELSE语句。具体见后面实例。
终态结一般对应一个RETURN(C,VAL)语句。其中C为单词种别编码;VAL是字符数组的TOKEN ,或者是一个整数值,或者无定义。具体见后面实例。
为了把状态转换图转化成程序,每个状态要建立一段程序,它要做的工作如下:
第一步:从输入缓冲区中取一个字符。为此,我们使用函数GETCHAR,每次调用它,推进先行指针,送回一个字符。
第二步:确定在本状态下,哪一条箭弧是用刚刚来的输入字符标识的。如果找到,控制就转到该弧所指向的状态;若找不到,那么寻找该单词的企图就失败了。
失 败:先行指针必须重新回到开始指针处,并用另一状态图来搜索另一单词。如果所有的状态转换图都试过之后,还没有匹配的,就表明这是一个词法错误,此时,调用错误校正程序。
6、有限自动机(DFA)
可以识别单词之后,随着会有一个更高的诉求:要有一个自动生成器,专门生成词法分析器的。
跟自动生成器一说规则,自动生成器就生成对应的词法分析器。
我们需要什么样的词法生成器?怎么告诉自动生成器,自动生成器能听懂。这就需要一个语言,需要我们去说一些话,描述性的话,让自动生成器能听懂。
这个话怎么说?怎么描述?就引出一个概念:有限自动机(DFA,deterministic finite automaton)
。这个概念不仅仅是词法分析里面用的,这个概念是一种思想和思路,在其他领域也有广泛的运用。
理解这一部分,需要用数学思维和数学概念,先理解这种数学思维和概念,再去用这个概念去解决词法生成器中的问题。
一个确定有限自动机(DFA) M是一个五元式:
M = (S, ∑, δ, s0 , F) ,其中
- S是一个有限集,它的每个元素称为一个状态
- ∑是一个有穷字母表,它的每个元素称为一个输入字符
- δ是一个从S×∑至S的单值部分映射。 δ(s,a)=s´意味着:当现行状态为S、输入字符为a时,将转换到下一状态s´。我们称s´为s的一个后继状态。
- s0∈S是唯一的初态
- F S是一个终态集(可空)。
一个非确定有限自动机(NFA) M是一个五元式:
M = (S, ∑, δ, S0, F) ,其中
- S是一个有限集,它的每个元素称为一个状态
- ∑是一个有穷字母表,它的每个元素称为一个输入字符
- δ是一个从S×∑至S的子集的映射,即δ: S×∑ → 2s
- S0∈S是唯一的初态
- F S是一个终态集(可空)。
定理:如果语言L被一个NFA所接受,那么一定存在一些DFA也接受这一语言L
有限自动机其特点是可以实现状态的自动转移,可以用于解决字符匹配问题。
DFA 的核心构成要素是状态和状态的转移
- 定义自动机所具备的 N 种状态,并定义初始状态
- 定义上一个状态转移至下一个状态的条件
如何用 DFA 实现词法分析器呢,步骤如下
先定义不同的状态 :如操作符状态、数字状态、符号状态等
再定义状态转移条件 :如当前状态是初始状态,遇到数字则转移至数字状态,遇到符号则转移至符号状态
如果字符读取完成,则整个转移过程结束
let state = 0; // 当前状态, 也是初始状态
while ((ch = nextChar()) !== false) {
let match = false;
// 获取下一个状态, 如果下一个状态不是初始状态, 则说明匹配成功
let nextState = getNextState(ch, state);
if (nextState) {
match = true;
}
// 如果匹配成功, 则字符读取序列的下标+1,并转移至下一个状态
if (match) {
incrSeq();
flowtoNextState(ch, nextState);
} else {
// 不匹配则生成token,并转移至初始状态,开始重新匹配
produceToken();
flowtoResetState();
}
}
上述有数学概念,这部分还有更多的没有展开。另外 DFA 实现词法分析器部分摘自于编译器前端之如何实现基于DFA的词法分析器
7、词法分析器的样子
上述是一个支持多语言扩展的 JS版词法分析器,可以作为词法分析直观学习分析。更多的词法分析器可以进一步学习常见的lex、flex、jlex
等词法分析工具。至此,涉及词法分析的绝大部分概念都有了相关说明,接下来就是语法分析。
四、语法分析
经过词法分析之后,我们可以拿到单词符号,或者说单词Token流。下面是编译前端流程图:
这些独立的单词符号,如何连接起来变成我们规定编程语言的文法?而且还可以根据文法规约,进行调用操作行为呢?这些答案在广义的语法分析里。
那可以一步步来分析:
既然语法分析器要判断记号流是否符合特定的语法规则,那么首先我们就需要形式化地来描述语言的语法规则。
如何描述语言规则,语言描述需要具备的特点:
- 形式上严格、准确;
- 易于理解;
- 具有较强的描述能力;
- 有利于句子的分析和翻译,构造语法分析器
基于这些疑问和要求,需要引出文法的概念。
1、文法的概念
文法的概念之前引出了一个背景:
一位叫乔姆斯基的教授打算利用数学来研究人类自然语言的结构和规律。研究期间他发明了很多数学工具和方法,被后人称为乔姆斯基文法体系,其中一个就是 CFG。从我们后人的角度来看,一开始他本打算用这些数据工具来研究自然语言,却无意中用在了计算机领域,正是无心插柳柳成荫。
乔姆斯基文法体系给出了 4 类文法:
0 型文法:任意文法,在程序设计语言当中不常用
1 型文法:上下文有关文法,在程序设计语言当中也不常用
2 型文法:即 CFG(Context Free Grammar),这种上下文无关文法可以用来描述语言的语法结构。
3 型文法:又叫做正则文法,其实词法分析器已经讨论过了,即正则表达式,这种文法可以用来描述语言的词法结构。
在这些文法中,0 型文法的表达能力最强,3 型文法最弱。同时,与程序语言语法有关的是上下文无关文法。
CFG上下文无关文法的特点:
它所定义的语法范畴(或语法单位)是完全独立于这种范畴可能出现的环境的
CFG上下文无关文法只能描述一部分语言,但已足够描述现今的程序设计语言。自然语言要用其他的文法来描述。
CFG上下文无关文法数学表达:
上下文无关文法 G 是一个四元式:G = (T, N, S, P)
其中:
T: 是非空有限集,终结符集合,终结符一般用小写,它的每个元素都是终结符号;
N: 是非空有限集,非终结符集合,非终结符一般用大写,它的每个元素都是非终结符号;
S: 唯一的开始符号 S∈N,即只能是非终结符;
P: 产生式集合(有限),每一个产生式形式是:{ P->α| P∈N, α∈(T∪N)*,S至少一次为P };
备注:
终结符:是用以组成语言中的串的基本符号,与程序语言中“单词”是同义语;
如:表达式id+(id)( - id)中,+、-、、/、id均为终结符
非终结符:是标记某种串的集合的特定符号,与“语法变量”、“语法范畴”是同义词;
如:表达式、运算符都表示一个串的集合
举个例子,G = (T, N, S, P)中对应的各个值是:
T :num, id, +, *,()
N :表达式、运算符
S :表达式
P :
- 表达式 ->表达式 运算符 表达式
- 表达式 ->(表达式)
- 表达式 -> -表达式
- 表达式 -> id
- 运算符 -> + | - | * | / |
也可以表达为(E代表表达式):
T = {num, id, +, *}
N = {E}
S = {E}
P = {E -> num, E -> id, E -> E+E, E -> E*E}
对于这样一个文法,我们就可以这样来表示这个 CFG:
1. E -> num
2. E -> id
3. E -> E + E
4. E -> E * E
可以简化为产生式表达:
E -> num
| id
| E + E
| E * E
上述就是产生式,其中产生式的定义:
产生式:规定由终结符和别的语法范畴组成一个新的语法范畴的办法;
结构:非终结符 -> 一串非终结符和终结符
如:A ->α
A -> α
↓ ↓
左部符号 右部候选式
VN α=X1X2…Xn,Xi∈V
产生式的习惯记号:
N: 大写字母A、B、C、S等;
T: 小写字母,0~9,+、- 等运算符,标点,分界符,黑体字母串id、if;
X、Y、Z: 文法符号,或N或T一个符号;
u、v、 w…z: T中串;
α、β、γ: 文法符号串∈(T∪N)*;
S: 开始符号,第一个产生式中出现;
->: 定义为(元语言符号);
**|: ** 或(元语言符号);
2、自顶向下分析算法
上面说的分析方式,都是从开始符号出发推出句子,因此称为自顶向下分析,对应于分析树就是自顶向下的构造顺序。
自顶向下分析算法就是从文法开始符号出发,自上而下的为输入串建立一棵语法树。
例如:
文法G:
S —> cAd
A —> ab
A —> a
形成语法树:
上述分析方法的实现:
LL(1) 分析算法,从左(L)向右读入程序,最左(L)推导,采用一个(1)前看符号。
- 每一非终结符对应一个递归子程序,在只生成两个串的文法,过程无须递归,而对生成无数个串的文法,递归是不可避免的;
- 递归子程序:是一个布尔过程,一旦发现它的某个候选式与输入串匹配,它就按此式扩充语法树,并返回true,指针移过已匹配子串。否则, 返回false,保持原来的语法树和指针不变。
困难和问题:
- 文法的左递归
- 回溯
- 用替换符的顺序会影响所接受的语言
- 如:A —> ab|a 改为 A —> a|ab
- 难以报告出错的确切位置
- 穷举试探法——低效的分析方法
3、自底向上分析算法
LR 分析算法
也被叫做移进-规约 算法
,是语法分析器的自动生成器中广泛采用的算法,例如:YACC 等。相比于 LL(1),消除文法的左递归,克服回溯问题,它的分析同样高效,并且不需要对文法进行特殊处理。LR 算法之所以被称为是 移进-规约算法
,是因为算法中两个核心操作是:移进(shift)和规约(reduce)。
- 移进:不规约,继续展开右边的式子
- 规约:把推导式右边的部分,归成左边的非终结符
4、语法分析器的实现方法总结:
1)、手工方式
递归下降分析器
2)、使用语法分析器的自动生成器
LL(1)
LR(1)
整个语法分析就是把词法分析器中产生的Token经过语法分析形成抽象语法树,这个过程涉及的文法概念和算法是实现的基础。如果需要实现一个语法分析器,上述涉及的CFG的推导和算法理解还需要更深入,也有一定难度。目前常用的一个语法生成工具是Yacc,它是基于自底向上分析实现的。
5、BNF范式:
用Yacc来实现语法分析器,需要了解一个概念:BNF范式
,就是需要用BNF范式描述定义编程语言。
BNF范式(BNF: Backus-Naur Form 的缩写)即巴科斯范式, 是由 John Backus 和 Peter Naur 首先引入的用来描述计算机语言语法的符号集。
现在, 几乎每一位新编程语言书籍的作者都使用巴科斯范式来定义编程语言的语法规则。
BNF范式的内容
BNF范式中的每个标准语法都具有以下结构:
name ::= expansion
其中 ::== 符号表示为 “可扩展为” 或 “可替换为”, “可定义为”;
在某些文本中, name 也称为非终止符, 一般为语言中某些抽象的概念;
BNF范式中的每个 name 都用尖括号<>括起来, 无论它出现在语法中的何处;
expansion 是包含终止符和非终止符的表达式, 通过排序和选择连接在一起;
终止符是像 + 或 function 这样的字符, 或者是一类字符 (如 integer), 一般为可以直接出现在语言中的符号;
竖线| 表示选择, 表示在其左右两边任选一项, 相当于 “or” ;
双引号”” 表示为其原本含义。
五、结束语
经过词法分析、语法分析阶段,编译器前端程序语言的处理已经完成了一部分。语法树的生成和语法树的解释是重点和难点,仍然有很多细节和问题需要思考。