这是编译原理大作业的第一步:词法分析与语法分析,最终效果是构建出语法树。
学过编译原理都知道,词法分析要用自动机,语法分析要用 CFG。善良的老师说,我们可以不用手写自动机和 CFG,可以用工具。然后又看到实验室里做逻辑公式求解器的代码也用的是 flex 和 bison,于是我就需要学习 flex+bison 这一套现代工具链。
过程中我发誓我一定要写一篇 flex 和 bison 踩坑记
推荐资料:《flex&bison》(中文版叫《flex与bison(中文版)》)、bison官方文档,做的过程中遇到问题在谷歌里搜索,StackOverflow 的解答还是相当不错的,也有些国内的博客回答问题也很 nice,这是少有的。
环境:Ubuntu18,flex 2.6.4,bison 3.0.4。强烈推荐 Windows 用户使用 WSL,右键即可打开当前目录下的 Linux 环境,妈妈再也不用担心我没有 Linux。
项目地址。
Task
有一个语言叫 Tiny,具体规则在这里。
- 将 Tiny 扩展成 Tiny+,用 EBNF 描述 Tiny+ 的语法;
- 将 Tiny+ 源程序翻译成对应的 token 序列,并能检查一定的词法错误;
- 将 token 序列转换成语法分析树,并能检查一定的语法错误。
(听说有标准的 Tiny+ 语言,但是我没找到,老师的要求是自行扩展,那么我就自行扩展了,主要让它尽量完备。放在这里)
flex 与 bison 的组合
flex 是用来做词法分析的,bison 是用来做语法分析的。
最早的组合版本叫 lex+yacc,然后 lex 升级成了 flex,yacc 升级成了 bison,于是现代工具链就变成了 flex+bison。
flex 需要编写一个 .l
文件,然后用 flex 根据它生成一个 lex.yy.c
文件,用 C 编译器编译就可以了。这个文件读入字符串,通过调用 int yylex()
每次返回一个 token。
bison 需要编写一个 .y
文件,然后用 bison 根据它生成一个 .tab.h
和 .tab.c
文件。后者实现了一个过程 yyparse()
,每次读入一个 token,根据 CFG 规则进行归约;前者则给出了相关接口(如 token 的定义)。
组合使用的方法为:lex.yy.c
里面 #include"xxx.tab.h"
;在 xxx.tab.c
里实现 main
函数或者单开一个文件实现 main
函数并 #include"xxx.tab.h"
(推荐后者),然后使用 C/C++ 编译器联合编译。
所以编译的时候最好写个 Makefile。
bison
bison 的基本格式是
1 | %{ |
part 1 将原封不动抄到生成代码的开头,part 3 将原封不动抄到生成代码的结尾,part 2 是 CFG 规则描述区,其余部分可以用来设置一些有用的开关和定义。
/*
和 */
是注释。bison - part 1
part 1 一般用来声明头文件和接口,例如
1 | %{ |
前面说过
int yylex()
是每次调用返回一个 token,这是与 flex 的接口之一。这里有个坑,
yyerror
过程是它生成的代码里会用到的,但是它自己没有声明!!还得我们用户来声明!!root
则是我们要构建的语法树的根。语法树的结点基类是 node
,而语法规则里的每一个非终结符对应一个自己特有的派生类。这些定义放在 syntax_tree.h
里。这个写法比较工程,每个非终结符对应自己特有的派生类,就既可以在基类定义公有方法,也可以定义自己特有的变量和方法。比如“表达式 Expression”需要记录该表达式的类型,或者值,但是“区块 Block”就不用这些东西。同时,还可以用虚函数等多态特性,避免臃肿的
switch
。bison - part 1.5
在 part 2 之前,我们需要先定义 token 和所有标识符。
1 | /* part 1 */ |
这里用
token
来定义终止标识符,用 type
来定义非终止标识符,后面尖括号是它的类型,无类型则不用加括号。":="
这样的用引号括起来的表示别名。类型是这么回事:CFG 的每条产生式在进行归约的时候,可以让你返回一个值,比如返回语法树结点的指针。从词法分析器来的 token,也可以带有值,比如常数值。
在 bison 3.0 以上的版本,可以用
%define api.value.type union
开关然后在下面尖括号里面直接写变量类型。否则,需要手动写一个 union
来把这些类型合并起来。注意这里需要使用能被 union
起来的类型,像 std::string
这类变长的就不行。(更高级的版本特性支持
std::string
,详见官方文档。)有些 token 如果只是单个字符,则可以不用专门起名字,不用在这里声明。例如加号“+”,要用的时候用单引号引起来即可。
bison - part 2
part 2 用来写 CFG 规则,形式如
1 | %% |
例如
1 | %% |
\$\$
表示当前标识符的返回值,$1,$2...
分别表示当前产生式的各标识符的返回值(无论是终止标识符还是非终止标识符都是有返回值的)。把程序语言的规则逐条抄下来,这就可以把整个语法树构建起来了。
由于每个结点的子结点数量不相同,为方便维护,推荐使用可变参列表来传递子结点信息。可以在基类写一个统一的构造函数,子类的构造函数就把可变参列表传递给基类。
bison - part 3
part 3 没啥要写的,调试的时候可以在这里写个 main
函数,但最终还是分离出去好些。
这里要实现的是 void yyerror(const char *s)
函数,这玩意在 part 1 被声明,在它生成的代码里被调用,那么就在 part 3 这里实现。这个是语法分析出错时向用户输出报错信息的,把 s
输出就可以了。
1 | /* part 2 */ |
bison 编译
编译命令是 bison -d xxx.y
,要加上 -d
才能生成 xxx.tab.h
头文件。
flex
flex 的基本格式与 bison 相同:
1 | %{ |
part 1 将原封不动抄到生成代码的开头,part 3 将原封不动抄到生成代码的结尾,part 2 是 token 的规则描述区,其余部分可以用来设置一些有用的开关和定义。
/*
和 */
是注释。flex - part 1
part 1 一般用来声明头文件,比如
1 | %{ |
这里要引用 bison 生成的头文件
xxx.tab.h
,里面用 enum
定义了 token。因为 part 2 是要完善 int yylex()
,这个的返回值就是 token 的 enum
。flex - part 1.5
这里可以用正则表达式定义一些记号,避免后面过于繁琐,比如
1 | Integer ([1-9][0-9]*|[0]) |
flex - part 2
part 2 写 token 规则。基本格式是
1 | <正则表达式> {要做的事} |
例如
1 | %% |
return
的值是该 token 的 enum
值;变量 yylval
用来记录这个 token 的值,在 bison 里的 $1,$2...
被用到;变量 yytext
记录了当前匹配到的输入字符串。第一部分用引号引起来的是关键字,直接返回相应的无类型 token。
第二部分是字符 token,如果是单个字符,在 bison 里没有声明,就直接返回字符本身即可(也就是
yytext[0]
)。注意有个坑,每个正则表达式都要放在行首,不能因为
|
符号就接在别人后面,一定要另起一行。第三部分是复杂一点的 token:整数、浮点数、变量名函数名、双引号非空字符串。这里不仅要返回 token 的
enum
,还要记录 token 的值,token 的值的类型就是 bison part 1.5 里的大 union,如果用了 %define api.value.type union
开关的话,标识符的名称就是值的类型的名称。第四部分是空白字符和注释,它们不做任何操作。
第五部分是一个简单的词法分析报错,如果匹配不上以上规则,就输出非法字符。
flex - part 3
没啥做的。调试的话可以在这里写 main
函数。
flex 编译
flex xxx.l
余下的事情
经过上面两步,已经生成好词法分析和语法分析的 C 代码了,接下来只要把 syntax_tree.h
和 main.c/cpp
完成,就可以编译了。
main
里要做的,就是调用 int yyparse()
函数,进行语法分析。该函数正常结束时返回 0。当然,还有必要的用户交互要在这里写。
如果要画出语法树,那么就在基类里写一个 dfs 就好了,每个子类实现一个虚函数 get_type
返回它的类型和相关信息即可。
简单的 Makefile 如下:
1 | tiny: tiny.tab.c lex.yy.c main.cpp syntax_tree.h syntax_tree.cpp |
高级特性
使用 g++ 编译
flex 里使用开关 %option noyywrap
。写在 part 1.5 的地方。
因为 flex 生成的代码用到了 yywrap
这个东西,这个东西在动态链接库 -lfl
中,而这个库会导致不能用 g++。
因此直接把 yywrap
禁了就好了。这个东西是历史遗留功能,用不上的。
flex 文件读入
flex 生成的代码里有个 FILE *yyin
变量,默认是指向 stdin
。在 main.cpp
里 extern
出来,然后指向一个文件,就可以文件读入了。
通常应该像命令行操作的编译器一样,由运行参数指定文件名。
语法分析器的 debug
观察 bison 生成的 .tab.c
文件,发现它 printf 了很多调试信息,怎么把它们用起来呢?
在 bison 里打开两个开关,一个是 %define parse.trace
,写在 part 1.5 的地方;另一个是将 yydebug
这个变量赋一个非零值(这个变量在 .tab.h
里声明了,因此随时随地可以直接赋值)。
这个 debug 信息非常详细,bison 是用 LALR 的,它把它每一步进入了什么状态、栈里有什么、移进归约选择全部列出来了,十分好用,就是有点长。
查看 LALR 状态以及哪个规则发生冲突
有时候 bison 编译 warning 说有移进归约冲突 / 归约归约冲突,所以想看看是哪条规则搞的。
bison 编译的时候加个 -v
,即可生成一个 .output
文件,里面列出了 LALR 的详细信息,包括状态、移进归约指示。被方括号括起来的规则,就是因冲突而弃用的规则。
优先级
这是个比较大的话题。
flex 的默认冲突处理是:匹配尽量长的输入字符串,等长时匹配写在最前面的规则。
bison 的默认冲突处理是:移进归约冲突优先移进。
一般来说,出现二义性,多数情况下是语法本身有问题,应当优先修改语法。
但是也有例外,例如运算表达式,虽然可以修改语法使得加减乘除之间不具有二义性,但是运算符变多了以后这个方法就很蠢了。例如 if-else,可以用龙书的 matched_stmt 和 open_stmt 来使得它没有歧义,但是相关的 stmt 的定义会变得很丑。
运算表达式的解决方法:优先级符号,在 bison part 1.5 的部分定义优先级
1 | %nonassoc "==" "!=" '<' "<=" |
优先级从上往下依次递增,同一行优先级相同。
left
表示该符号左结合,right
表示该符号右结合,nonassoc
表示不具有结合性。一个规则的优先级取决于该规则最右边的 token 的优先级。于是就可以直接像下面这么写了:
1 | Expression: |
这里还定义了负数。
'-' Expression
这里用了 %prec
,这表示规定这条规则的优先级是后面那个符号,而不是减号的优先级。if-else 的解决方法:一般来说不用解决,因为有默认规则“移进归约冲突优先移进”。如果看 warning 不顺眼非要消除,可以用优先级符号把 else 的优先级抬高。
报错输出行号
可以在 flex 里使用开关 %option yylineno
,这样你就拥有了一个变量 yylineno
记录当前的行号。
在 void yyerror(const char *s)
过程里,就可以输出行号了:
1 | void yyerror(const char *msg) { |
但是要在 bison 的 part 1 的部分
extern int yylineno;
。输出更多报错信息
传给 yyerror
的报错信息默认只有 “syntax error”。但是观察 .tab.c
,发现它还有诸如这样的代码:
1 | switch (yycount) |
于是我们知道又有开关没用起来。
这个只需要在 bison 使用
%error-verbose
(写在 part 1.5)即可。这样,我们的报错信息就又稍微丰富了些。error 标识符
但是这个报错信息还是简陋了些,怎样才能像 g++ 那样有丰富的报错信息呢?
在 bison 中可以使用 error
标识符,它不用提前声明,作用有点类似万能匹配符。在语法分析出现错误的时候,它就开始退栈,直到能匹配带有 error
标识符的规则。
因此可以针对性地输出错误信息了:
1 | AssignStatement: |
可以自行搞一些交互变量来阻止默认的
yyerror
调用以避免重复报错。详见《flex&bison》得到更加丰富的报错信息,例如加上当前所在文件、错误发生的列号等。destructor
上面还存在一个问题:语法分析出错要退栈的时候,树结点是要析构的!
因此在 bison part 1.5 要写有 %destructor{要做的事} 标识符或<类型>
,例如
1 | %destructor{} <int> <double> <char*> |
<*>
表示匹配任意类型,因为每个标识符有自己的子类所以类型挺多的。然后普通的类型不用析构,所以把普通类型又单列了出来。clear(node*)
是自己写的递归析构。yynerrs
直接上面这样写是有问题的。凡是非归约退栈都会调用 destructor
,这不仅包括遇到错误退栈,还包括整个语法分析完成后的根节点退栈。于是你发现,根节点被析构了。。。
因此要加一点判断,只有遇到错误时才进行析构。.tab.c
里有一个 yynerrs
变量记录了当前总共发生了多少次错误,把它 extern
出来,将它作为析构条件。
1 | %destructor{ if (yynerrs) clear($$); } <*> |