基本的c语言编译器的实现
项目地址
实验目标
设计并实现一款基本的c语言编译器。从词法分析开始,逐步完成c语言文法的设计,文法分析,语法制导翻译,生成中间代码并最终生成可运行的汇编代码。并且在过程中完成符号表的管理和错误管理。
开发环境
采用c++编写,使用makefile对项目进行管理,开发环境为ubuntu。
项目结构
项目的主要结构如下:
其中,源码的结构如下(源码的头文件在include下对应的目录下
对于资源文件中
核心要点
词法分析
词法分析,顾名思义就是对源程序进行符合相应语言词法规则的单词进行分析和切分。对于c语言的词法分析器,其输入是c语言的源代码,其输出是一个个连续的token,而token是这样的二元组:(A, B),其中A表示该token所属的类型,比如是id(变量、函数名等)或者是value(如整数,浮点数、char等),或者是其它的c语言的关键字、操作符、分隔符号等。而B代表是该token附带属性,如如果是id,则B是该id在符号表中的入口地址,如果是value,那么B则是该value的具体的值。
为了方便,将每一个关键字、操作符、分隔符等符号进行编码,(可使用宏定义)如if 为1, else为2等。
对于词法分析过程,主要使用的是有穷状态自动机进行匹配处理,从开始状态出发,通过识别不同的字符进入相应的状态,从而实现分析过程。在这其中需要注意的是,当遇到id时,需要将其插入到符号表中进行管理,方便后续的翻译。
词法分析可以识别c语言程序的单词、关键字等拼写错误。对于此时,如果遇到了错误(读入当前字符但是无法和任何状态进行匹配)该如何处理:典型的处理方法是抛弃读入的字符直到读入可以正确匹配的字符后正常进行匹配。在这过程可能涉及到错误报告等内容。
语法分析
语法分析是根据已有的文法,判断读入的token串是否是该文法的一个句子。语法分析的方法主要包括自顶向下的语法分析和自顶向下的分析两种。
自顶向下的语法分析是从文法的开始符号出发,一步步推导出符合当前符号的文法,其主要包括递归下降法和LL1分析方法,其中前者是将文法的每一个非终结符号都写成一个处理子程序,通过递归调用最终实现语法的分析,好处是准确性高,但是缺点是随着文法的复杂程度提高代码量也随之升高,同事其对文法也有要求,必须满足LL1文法。而后者需要求出first集合和follow集合,并且据此得出LL1的预测分析表,并根据此表,利用堆栈实现词法分析。以上的处理方法都需要判断文法,如果文法不满足LL1文法的条件,则其复杂度超过了处理能力。
自底向上的语法是从输入的token开始,利用goto表和action表、分析栈,通过移入和规约,一步步得到文法的开始符号的分析方法。其主要包括算符优先算法,LLR(0),SLR(1),LR(1)和LALR(1)等几种分析方法。算符优先算法根据符号的优先级决定移入和规约。对于LR(0)其移入和规约过程中不会向前看任何信息,直接根据文法规则执行动作,这就使其无法处理移入规约等冲突,其分析能力甚至无法分析算数表达式。而SLR(1)则是在规约时会向前看follow集合来决定是否执行规约动作,因此其分析能力超过了LR(0),但是由于其仅仅在规约过程中使用了follow集合,其仍然后产生冲突。而LR(1)则对每一个文法单元都含有一个展望符,执行任何动作都需要对展望符进行判断。LR(1)分析能力最强,但是由于其具有许多‘心’相同而展望符不同的状态,因此其分析的代价要更高一些。而LALR(1)则是合并了LR(1)中‘心’相同的并且合并不会产生冲突的状态以此来降低复杂度,但是合并状态后可能会造成冲突,因此其分析能力要低于LR(1)。
我使用的方法(注:这里用到的算法可以到书中查阅,这里只会给出难点重点)
我使用的是LR(1)进行语法分析,因为其分析能力最强,当然难度也更大一些,其主要步骤如下:
【1】构造c语言文法
从c程序整体出发,逐步增加c语言语法,其中的几个要点如下:
- 不定数目的重复单元的文法
如int a, b, c,d = 1…;这样的的句型该如何构造文法,我采用了这样的方法:
value_declare_define_list -> ID
value_declare_define_list -> value_declare_define_list , ID
value_declare_define_list -> array
value_declare_define_list -> value_declare_define_list , array
value_declare_define_list -> ID = expression
value_declare_define_list -> value_declare_define_list , ID = expression
可以看到ID可以规约为value_declare_define_list,同时value_declare_define_list,ID也可以规约为value_declare_define_list,这样在自底向上移入规约过程中就可以处理这种不定数目的重复单元的语句。
- 空的文法非终结符
我们的需要的是含有空产生式的文法,因为在后期语法制导翻译的过程中需要使用这样的文法符号用于标记回填动作。
- 文法后关联的动作
在后期进行翻译过程中,对大部分的产生式,在执行规约动作的同时会采取相应的翻译动作,因此,文法应该具备显示标记该执行什么动作的功能,我采用了如下的方法:
value_declare_define_list -> ID = expression $action action_value_define
如上述的文法,在处理文法的同时,读到 $action 标记后,则下一次读入的字符串是该产生式的处理标记。
- 文法的扩广
如开始符号为S,则在文法中需要加入这样一个文法
S`->S
加入这一个文法是因为后面的LR(1)语法分析过程中dfa的状态转移需要一个状态来标记文法识别结束。
【2】求first集合
在构造LR(1)文法分析表之前,我们的程序需要能够求得特定串的first集合的能力,因为LR(1)在进行引入规约的每一个语法单元都有一个展望符号,而这个展望符号就是根据小圆点的位置后的串求得的。其中求串的first集合依赖于求符号的first集合,具体算法在这里不再详细讲解了。
【3】构造dfa
LR的dfa就是从开始状态出发,当读入不同的符号会进入不同的状态,其中,读入的符号包括终结符和非终结符号,不同的状态是每一个LR(1)的item,每一个item具有这样的结构:
[A->a.Bc, m],其中A->aBc是文法中的产生式,小圆点加在B之前说明已经读入a,等待读入B,而m是该item的展望符号。
在构造dfa过程中,有两个关键步骤:
其一是求文法的闭包,这个完整构造任意dfa的关键。其具体步骤如下:对于开始状态,将[S`->.S,#]加入该状态,对于任意一个加在小圆点后面的非终结符号,将其加入闭包,如S->A,则将[S->.A,?]加入状态中,而?需要根据first集合求得。
其二是go函数。所谓go函数就是当读入某一个符号,从状态A进入状态B的过程。如上述的文法,初始状态读入S,则进入新的状态2,该状态中[S`->S.],然后根据闭包算法算出该状态的闭包即可。
使用上述两个方法则可以成功构造lr的dfa。
【4】根据dfa构造goto表和action表
即根据小圆点位置和小圆点后面的符号来决定是否移入还是规约或者ac,具体算法略。
【5】编写主控程序
主控程序需要使用一个状态栈,一个符号栈,根据goto表和action表,分析从词法分析器中读入的token,来决定是否规约还是移入。其中的一个难点是如何处理空产生式,我的处理方法如下:
当语法分析中遇到错误的时候,尝试用当前符号和空字符来重新查询aciton表获取action,若仍然出错,则报错,否则则认为当前需要插入一个空状态,从而纠正分析状态。具体的程序如下:
语义分析
语法制导翻译就是在进行语义分析的同事进行语法分析。当使用某一个产生式进行规约时,则使用挂钩程序调用该产生式所对应的语义动作函数执行相应的动作。而其中的翻译模式又分为S属性翻译和L属性翻译
如条件语句这种需要的进行跳转的语句进行翻译的时候,在翻译的当时我们无法获知应该跳转到哪一个地方,此时有两种方法,其一是扫描第二遍来填写信息,因为需要扫描两遍,因此这种方法的效率比较低。其二就是回填技术,回填就是在遇到jmp X时,此时不填写X,而在后续相应的位置对其进行填写,此时就要对文法进行特殊的修改,加入空的非终结符来指示在哪一步需要记录位置,哪一步需要填写位置,哪一步需要合并等,如我对for语句修改后的回填文法如下
for_statement-> for F1 ( expression_assignment ; bool_expression ; F2 expression_assignment ) F3 statement $action action_FOR
for_statement -> for F1 ( expression_assignment ; bool_expression ; F2 expression_unary ) F3 statement $action action_FOR
F1 -> null $action action_F1_FLAG
F2 ->