编译原理课程实验报告
Contents
Github地址: https://github.com/corvofeng/compiler
课程主要目的
- 加深对编译原理及编译程序构造过程的理解
- 增强程序设计能力
- 学会编写工具软件
课程要求
SeuLex
- Lex输入文件的解析
- 正规表达式的解析
- 一个正规表达式到NFA的转换算法实现
- 多个NFA的合并
- NFA的确定化和最小化算法实现
- 返回状态与返回内容的对应
- SeuLex应用
SeuYacc
- Yacc输入文件的解析
- 上下文无关文法到对应LR(1)文法的下推自动机的构造
- LR(1)文法的下推自动机到相应分析表的构造
- LR(1)总控程序的构造(查表程序)
- LR(1)到LALR(1)的映射
- 符号表的构建与相应管理程序
- 语义动作程序的加入
- SeuYacc的应用
工具使用
本次实验, 使用了众多的开源工具进行辅助, 下面我将列出最主要的几种. 这一节可以完全跳过阅读.
Linux
- 操作系统
在此次开发中, 为了大家环境的统一, 也为了使用已有的成熟的开发工具 我们全组使用了Linux作为系统开发环境.
GCC
- 编译器
作为编译器被使用: 项目中也大量使用了C++11
的新特性, 使用成熟编译器极大的提高了
开发效率
GDB
调试工具
内存泄露以及
Segmentation fault
调试的利器, 有些时候针对某个断点的透彻调试更有
效率, GDB的命令行模式有时更加清晰明了
Git
版本控制
使用Git轻松进行了协作, 即使组很小, 我们也采用了协作方式,
Git
的使用大大减轻了
交流的困难
CMake(Makefile)
- 编译安装工具
整个项目构建均使用CMake, 跨平台支持性良好, 可以支持众多的IDE构建项目. 动态链接库, 静态链接库的使用支持大大简化了项目构建难度, 使得开发者能够尽快的专注于代码
Qt Creator
IDE
与CMake相仿的跨平台开发利器, 组员均在Linux上进行开发. 相比于
Eclipse
,
Qt Creator
对CMake
优秀的支持减轻了我们生成项目的烦恼
valgrind
软件分析工具
由于项目使用C++进行编写, 内存控制十分头疼, 使用
valgrind
. 可以方便分析泄露, 在
项目进行过程中, 有不止一处的内存泄露, 凭借valgrind
可以很快找到泄露位置, 由于
我们水平所限, 仅用来进行泄露分析
lex&yacc
- 编程工具
项目中使用了lex与yacc进行了语法与语义分析示例, 在项目尚未开始时, 我们需要首先对 Lex与Yacc进行理解, 而已有的工具可以帮助我们快速入门
Lex设计
Lex为整个项目的第一个核心组件, 下面本文档将会分为 进行介绍
Lex架构分析
此图形象的介绍了
Lex
的使用场景以及我们将要实现的目标:Lex
编译器, 一个可以读 取Lex
源程序(lex.l), 并生成lex.yy.c的工具, 抛开NFA
,DFA
这些专业术语不说, 我们已经对当前所做的项目的输入输出了解, 剩下的实现细节将由文档后文慢慢道来.
输入文件(lex源程序)
- 由于实现难度, 本项目中使用的源程序与标准lex源程序有所不同, lex源程序存放在
./input/require.l
中, (源程序片段)
1 | %{ |
标准的
lex源文件
是没有中间的%! %!
段的, 自行编写程序时,[0-9], [a-zA-Z]
很难进行表述, 所以这里采用一种事先定义函数的形式, 可以由自定义程序进行单个 字符的解析
输出文件(lex词法解析程序)
- 该输出文件中, 主要由
scaner
函数构成
1 | //扫描函数 |
此文件为
Lex
程序的输出文件, 可以看到case 1
中添加了有关解析式的使用, 在Lex运行 过程中, 将上方的lex源文件
进行了正则解析, 并对匹配到的正则表达式进行操作, 在case1中, 我们可以得到下面的正则表达式所对应的操作输出
1 | ({letter}|_)({letter}|_|{digit})* { |
处理流程
- 当我们已知了
Lex
程序的输入输出后, 接下来要介绍流程的介绍, 接下来, 文档将 会通过简单的示例介绍从正则表达式开始的整个流程,
1.假设我们有如下正则表达式a*(b|(cd?))+
与表达式ef
2.进行正则表达式到NFA
转换
转换过程
在Re2NFA类中, 定义有re2post
与post2nfa
函数, 分别完成中缀正则表达式转为后缀,
以及后缀表达式转换为NFA, 具体实现敬请查看项目代码中./lex/nfa.h
3.进行NFA
表达式合并
合并过程
由于合并过程也是产生NFA
的过程, 这里, 我们使用NFA2LIST
类进行合并, 用户可以
不断调用merge
函数进行合并, 合并时, 新建一个新的起始节点, 再将两个NFA
的字符
集进行合并
4.NFA
到DFA
转换过程
采用子集构造法
使用NFA
中的初始状态作为DFA
的初始状态, 并进行闭包操作, 只经过标号为$\epsilon$
的边所能到达的状态, 在实现中, 我将其称为findSimple
, 从核心点衍生到所有点,
此函数用于核心点到其周围点的映射
以上图NFA
为例, 节点0将会作为DFA
的初始状态, findSimple
函数将会进行扩展
将节点1, 3, 4, 7, 9
也添加到DFA
的初始状态.
这里我想说明一下, 核心点的设置是十分有必要的,
由于我们每产生一个新的DFA
状态就要与先前存在过的DFA
状态进行比较, 如果将该DFA
状态所对应的核心点均进行比较, 相对而言, 代价巨大, 设置了核心点后, 可以大大减少
比较次数.
下面我将使用伪代码进行描述
1 | while(存在未遍历的DFA状态) { |
以上图的DFA
状态0为例, 我们遍历a, b, c, d, e, f
这些状态
同时, 我们将会发现可以构建这样一些核心节点的DFA
状态, 括号中为核心节点
- 0 -> e -> 1 (16)
- 0 -> a -> 2 (7)
- 0 -> c -> 4 (15)
- 0 -> b -> 3 (12)
拥有了核心节点之后可以使用findSimple
进行扩展
重要数据结构定义
基础数据结构
1 | /* |
NFA与DFA
1 | /* |
Lex
1 | class Lex { |
词法分析程序的使用
我们获取了生成的词法分析程序(例如out.c), 接下来将要解析文件
1 | ☁ input gcc out.c -o out # out就是我们的词法分析程序 |
Yacc设计
Yacc架构设计
与
Lex
类似,Yacc
是作为生成y.tab.c
的工具, 而y.tab.c
也就是语法分析工具 可以了解到, 输入为Yacc
源程序, 输出为y.tab.c
. 本程序处理过程中, 将Yacc
处理程序集成到Yacc
中, 并未将语法分析程序独立开来, 只是使用预测分析表进行了 简单测试
Yacc输入文件
1 |
|
为了解析方便, 该语法与
Yacc
的标准语法略有不同, 不过这不影响文法解析.
在
%{, %}
中包围的仍是语法输出的预定义, 由于我们不产生y.tab.c
, 这里只作为占 位使用,%!, $!
中包含了tocken
,head
, 以及left
和right
, 在这里均有意义 其中token
记录终结符,head
记录起始的非终结符(在上方为expr),left, right
记录优先级和结合律
Yacc输出
- 在此项目中,
Yacc
是没有输出的, 我们将Yacc
类中添加了parse
函数, 此函数读取 词法分析器的生成文件, 进行解析输出
词法分析器输出文件
1 | <$NUM,23> |
parse
函数输出
1 | 0 s3 |
处理流程
- 当我们已知了
Yacc
程序的输入输出后, 流程中将会精简无关的输入输出, 并且挑选主要 的逻辑流程进行介绍, 特别具体的细节, 请查看代码. 此处, 我们将以如下文法为例, 进行 流程分析
1 | "S->S;A", |
1.文法的存储以及First
集合的求解
文法被保存时, 首先进行转换, 具有相同的非终结符的产生式会被合并, 上文中的文法将会 这样保存
1 | A -> E|i=E |
First
集合的求解调用makeFirst
函数即可.
1 | /** |
上述代码的粘贴可能并不容易了解到函数的使用, 我将会在接下来进行一步步解析
我们依次来看, A -> E|i=E
, 进行A -> E
解析First(E)∈First(A)
, 深度优先遍历会
求解First(E)
, First(E) = i
, 而后First(A) = First(E) ∩ {i}
而后S->A|S;A
, 可知First(S)=First(A)
所以First(A) = First(S) = First(E) = {i}
2.进行LR1项集族构造
1 | /** |
接下来, 我将会从起始状态的构建开始说明整个项集族的构建过程
2.构建LR1起始状态
在items
函数中, 首先插入#->.S, $
表达式作为闭包运算的起始
3.核心产生式的闭包运算
以起始状态的核心产生式闭包扩展为例, # -> .S term char: $
, 该式进行扩展需要
用到LRState::findAllExpr
函数, 该函数主要做了这样一件事: 不断遍历标准状态, 函数
中主要用到了LRState::findAllExpr
1 | /** |
4.LR1项集族状态转移
当我们达到某个状态, 并且已经由核心的产生式进行闭包运算, 接下来就需要到达下一个
状态, 此时, items
函数调用LR1::getAllNextState
进行扩展
1 | /** |
5.ACTION与GOTO表的构建
在完成LR1项集族的构建后, 可以调用LR1::makeACTIONGOTO
函数进行预测分析表的构建,
预测分析表的构建过程中, 面对二义性文法, 是有可能发生冲突的, 这里, 冲突主要体现在
Action
表的构建过程中, 具体的优先级和结合性对表构造的影响将不在这里展开, 相信有
兴趣的读者会对代码进行探索
1 | /** |
6.进行语法分析
当我们有了ACTION
与GOTO
表之后, 需要对其进行应用, 这里, 我们直接以最终的输出为例,
这里, 并不是说我们的LR1只适用于当前的输入, 只是为了讲解方便, 并且该示例中的计算
过程经过了许久的验证. 整个语法如下
1 |
|
表头为 ( ) * + - / NUM $
表头为A
待解析文本
1 | <$NUM,23> |
当我们读入NUM
时 我们处于0
状态, 此时对应的操作为s3
, 进行移入操作, 并且处于
状态3, 而后我们读到+, 此时为r5
, 使用第5个产生式进行规约, 如此继续, 下面为堆栈图
1 | 符号栈 状态栈 实际计算栈 当前动作 |
重要数据结构定义
1 | /** |
语法分析程序的使用
- 具体的解析过程上面已经列出, 接着主要说明语法分析程序的使用
1 | # 使用词法分析工具生成待解析文件 |
项目中遇到的问题
- 对于一个比较大的项目来说, 遇到的问题是很多的, 这里我挑选一些整个过程中遇到的 一些令人困扰的问题, 也在此告诫自己要不断提高编程水平来解决之后的学习生活中遇到 的问题.
Lex中遇到的问题
1.正则表达式中缀转后缀
逆波兰表达式的构建是我们要做的第一步, 中缀转后缀其实很早就已经做过, 但是做为整个 程序的输入, 需要首先保持起正确性. 而且, 正则表达式的后缀与普通的计算式的后缀略有 不同, 因为普通的计算式是通过
+-*/
进行连接, 而正则表达式是通过不可见的连接符进行 连接.因此, 写代码之前首先进行类似问题的查找, 确定已经有别人写好的代码, 对其进行参考 才最终完成, 我认为这是一个在项目构建中无法回避的问题, 有些时候, 我们由于一些 项目上的需求需要事先查看别人的程序进行参考.
2.NFA的定义
NFA是转换DFA的基础, NFA的表示一直到最终构建词法分析器的时候仍然存在. 正则表达式 的后缀形式转NFA在一定程度上是具有确定形式的. 这里, 我们首先论证了, 每个NFA节点 最多有两个出度即可表示所有的情况, 包括
a+, a*, a|b ..
, 其实这一步是影响整个Lex构建 的问题所在, 由于自己的小失误, 导致在整个NFA构建中a|b
形式的NFA构建出现了问题, 一直影响到最后词法分析器的构成, 不得不说, 这样恼人的小错误造成了整个系统的失误
3.正则表达式对应动作的实现
程序当前状态: 之前的程序中其实已经完成了由NFA向DFA的转换, 但是, 多个NFA有多种结束与初始状态, 如果分 别将NFA转换为DFA, 依旧无法解决分析的问题, 因此, 此处需要现将NFA进行合并操作, 合并之后 再转换为DFA.
问题: 想法总是很好, 但随之而来的问题也并不简单, 多个NFA有多种结束状态, 每种结束状态对应的操 作函数不尽相同, 因此, 我们虽然将NFA进行了合并, 但是, 每个NFA所携带的结束状态的信息却 无法合并. 此时, 才是真正的尴尬, 如果无法解决这个问题, 程序是无法继续进行的.
解决方案: 此时, 我向State与DState类中, 添加了endFunc变量, 总体来看, 变量的添加造成了多余空 间的使用, 也使得程序并不优雅. 但此时, 主体框架已经定义, 不可能过多的修改NFA与DFA的创 建类, 当状态过多时, 将会导致程序的内存占用率上升.
由于我追求了DFA与NFA尽可能的分离, 并且分别定义了State类以及DState类, 由此带来的问 题只能通过使用多余空间进行解决. 此种方法有利有弊, 但不是权宜之计, 是解决之道.
4.正则表达式状态的表示
函数输入:
1 | ({letter}|_)({letter}|_|{digit})* { |
这里给出了一个匹配变量名的正则表达式(字母,数字,下划线组成, 只能由字母与下划线开头的字符串) 以及表达式对应的操作
首先调用getReAndFunc将正则表达式与处理逻辑相分离, 我们可以得到re以及func分别对应于两 部分, re = ({letter}|)({letter}||{digit})* func = {…} 而后的正则表达式进行处理, 因为表达式中有{letter},{digit}这样的长名称, 因此, 我们 通过state2ch来进行 {letter} -> char 的替换. 生成的正则表达式将会变为: re_s = (a|)(a||b)* 这里的a, b仅为填充字符, 真实的替换后正则表达式中, a b均为 不可见的字符
另外: 对于(%=?)这样的正则表达式, 因为表达式中有’‘存在, 直接匹配将会出现错误, 因此, 我们使用%进行转义, 而正则表达式中的转义为*, 我们也在此处进行修改, 它将会变为: (*=?)
基础的处理过正则表达式后, 我们将正则表达式与其对应的处理函数存储进入NFA类中 Re2NFA *pre2Nfa = new Re2NFA(re_s, func);
Yacc中遇到的问题
1.产生式的表示
万事总是开头难, 从一开始构建
Yacc
时, 就无法回避表达式的保存问题, 因为构建LR1 状态族时需要考虑First集合的构建, 一开始的数据结构选择很重要. 选择Expression
可以保存S -> a | b
这样的表达式, 很方便的寻找同一个非终结符对应的产生式, 但也就是这样的产生式, 造成了后来LR1构建的麻烦. 由于Expression
类中保存的表达式混杂在了一起, 如果每次状态转换进行检验, 工作量 巨大, 由于不堪此类的困扰, 所以在构造项集族时使用了新的自定义的类SingleExpression
状态转移使用的S -> aB.c, $
2.求解闭包操作(由核心的产生式进行扩展)
整个扩展过程中, 最初只是想要从核心产生式到全部产生式的形式, 到最后却发现了, 在扩展过程中, 我们将会遇到产生式的结尾(好吧, 是我没有考虑到), 而后的调参过程还是很辛苦的
1 | if (pos >= right.size()) { // 如果当前位置已经处于末尾, 则不进行处理 |
CMake使用中的问题
1.CMake进行库的构建
项目中涉及到了大量的
.cpp, .h
文件, 如果没有合适的管理工具是无法做到所有人进度 同步的, 以Lex
为例
1 | set(SOURCE_LIB ./lex.cpp ./lex.h |
- 添加输入文件
构建过程中需要用到一些输入文件, 由于
CMake
是代码外进行构建, 需要同时将Lex
与Yacc
的文件进行输入, 下面可以实现将文件拷贝到编译的目录中.
1 | file(COPY |
小组分工
- 本次的课程设计项目由三个人组成, 分别是
09014310冯盼贺
,09014312冯裕浩
, 09014315张春秋
Lex
正则表达式中缀至后缀, 后缀到NFA, NFA到DFA,
Lex
语法分析程序的构建(读取lex源文件, 生成语法分析程序)
lex源文件的书写, lex源文件的规范定义
Yacc
存取表达式并生成First集合, LR1项集族的构建, 预测分析表的构建, 读取Yacc源文件进行解析 解析结果的应用(使用预测分析表进行语法分析)
参考文献
代码实现Thompson构造:由简单到复杂的构建NFA状态机