前言
最近被某学长介绍了毕昇杯编译器比赛, 有点感兴趣, 遂开始用 C 自己实现, 然后就:
还是 Java 香!
本文记录了我用 Bison 和 Flex 来构建 SysY2021 语言前端的尝试.
Show me the code.
前置声明: 大体的代码架构和使用方法在最后, 可以交叉着看.
lex
Lex 可以将输入按照正则表达式匹配然后执行对应的代码. 这个代码一般是直接返回对应 token 的类型; 如果一个 token 还有附加信息, 那么你也可以在代码里把它保存在哪个全局变量里面.
它会生成一个文件:
lex.yy.c
: 包含了 yylex() 的定义
/* src/frontend/lexer.lex */
/* ==========================================================
* 一个 lex 文件大概由三部分组成
* 第一部分是下面这个被 %{ ... %} 框起来的部分
* 这里可以写一些 C 代码, 它们会被原封不动的拷贝进生成的 C 文件里
* 一般是写头文件和一些下面要用到的函数
* ========================================================== */
%{
#include "util.h"
#include "ast.h"
/* 这个是 Yacc 自动生成的头文件, 包含了 Lex 应该返回的枚举的定义和 yylval */
#include "parser.tab.h"
%}
/* ==========================================================
* 第二部分就是用来放传递给 lex 的选项的部分
* 选项以%开头, 紧跟着选项名, 后接参数
* ========================================================== */
/* 这个选项表示不需要使用 yywarp */
%option noyywrap
/* 这个选项表示 C_COMMENT 是一个状态 */
%x C_COMMENT
/* ==========================================================
* 第三部分就是每一个 token 的定义
* 包含在 %% ... %% 内
* 每一行都按如下格式: <正则表达式> <动作>
* 一般来讲每当读完一个 token, <动作> 部分就应该 return 一个 int 值, 它将作为 yylex() 的返回值
* 对于携带特殊信息的 token, 我们一般把值存进全局变量 yylval 里供程序的其他部分使用
* ========================================================== */
%%
/* 忽略空白 */
" " { continue; }
\t { continue; }
\n { continue; }
/* 处理单行注释 */
"\\".*\n { continue; }
/* 处理 C 风格的注释 */
/* 这是 lex 暴露出来的给我们手动控制 DFA 节点的写法 */
/* 当碰到 "/*" 时就进入 C_COMMENT 状态 */
"/*" { BEGIN(C_COMMENT); }
/* 当在状态 C_COMMENT 并且碰到 "*/" " 时就回到正常状态 INITIAL (lex 自己提供的) */
<C_COMMENT>"*/" { BEGIN(INITIAL); }
/* 当在状态 C_COMMENT 时, 输入一切字符都当不存在 */
<C_COMMENT>. { continue; }
/* 这里的状态处理没有考虑嵌套注释; 如果要支持的话可以在全局变量里保存一个变量 level 表示注释的层级,
* 每次碰到新的 "/*" 就 level++, "*/" " 就 level--; 当 level == 0 时才退出 C_COMMENT 状态 */
/* 关键字 */
const { return T_CONST; }
int { return T_INT; }
void { return T_VOID; }
if { return T_IF; }
else { return T_ELSE; }
while { return T_WHILE; }
break { return T_BREAK; }
continue { return T_CONTINUE; }
return { return T_RETURN; }
/* 符号, 对于特殊的字符可以用双引号括起来, 这样它们不会被当成正则表达式里的元字符, 也不需要转义 */
"," { return T_COMMA; }
";" { return T_SEMICOCLON; }
"(" { return T_PAREN_LEFT; }
")" { return T_PAREN_RIGHT; }
"[" { return T_SQU_LEFT; }
"]" { return T_SQU_RIGHT; }
"{" { return T_CURLY_LEFT; }
"}" { return T_CURLY_RIGHT; }
"=" { return T_ASSIGN; }
"+" { return T_ADD; }
"-" { return T_SUB; }
"*" { return T_MUL; }
"/" { return T_DIV; }
"%" { return T_MOD; }
"==" { return T_EQ; }
"!=" { return T_NOT_EQ; }
/* 如果有多个匹配, lex 会匹配比较靠前的规则 */
"<=" { return T_LESS_EQ; }
">=" { return T_GREATER_EQ; }
"<" { return T_LESS; }
">" { return T_GREATER; }
"!" { return T_LOG_NOT; }
"&&" { return T_LOG_AND; }
"||" { return T_LOG_OR; }
putf { return T_PUTF; }
/* 标识符 */
[a-zA-Z_][0-9a-zA-Z_]* { yylval.sval = String(yytext); return T_IDENT; }
/* 数字, 十进制, 八进制和十六进制字面量 */
[1-9][0-9]* { yylval.ival = (int)strtol(yytext, 0, 0); return T_NUM; }
0[0-7]* { yylval.ival = (int)strtol(yytext, 0, 0); return T_NUM; }
0(x|X)[0-9a-fA-F]* { yylval.ival = (int)strtol(yytext, 0, 0); return T_NUM; }
/* 字符串字面量 */
"\"".*"\"" { yylval.sval = String(yytext); return T_STR; }
/* 对于不符合以上任何匹配的, 做一个错误处理 */
. { panic("Unknown token: %s", yytext); }
%%
另外, 如果你需要在别的对正则表达式有内建支持的语言(比如Python)里快速做一个 lexer 的话, 你可以这样干:
import re
from itertools import starmap
from typing import *
def sub_regax(token_type: str, reg: str) -> str:
# 命名组合, 参见: https://docs.python.org/zh-cn/3/library/re.html#regular-expression-syntax
return f'(?P<{token_type}>{reg})'
# token 类型要求首字母大写, token 附加数据的要求首字母小写
lexer_rules = [
('LeftBracket', r'\(' ),
('RightBracket', r'\)' ),
('Number', r'(?P<num>[0-9]+)' ),
('String', r'"(?P<str>.*?)"' ),
('Identifier', r'(?P<id>[a-zA-Z][a-zA-Z0-9]*)')
]
# 直接把所有 token 对应的正则表达式都并起来, 然后编译一个大的
# 并且希望你那个语言的正则表达式的编译可以生成最优的 DFA
lexer_reg = re.compile('|'.join(starmap(sub_regax, lexer_rules)))
# 找到 d 里面 第一个 d[key] 不为 None 且首字母大写的, 并且返回 key
def get_key(d: dict[str, Union[str, Any]]) -> str:
for k, v in d.items():
if v and k[0].isupper():
return k
raise RuntimeError(f'All value in dict is none: {d}')
def regenize(inputs: Iterable[str]) -> Iterable[tuple[str, Any]]:
for input in inputs:
for tok_match in lexer_reg.finditer(input):
# tok_match.groupdict() 返回一个 dict, 里面大概是这样的:
# {'LeftBracket': None, 'RightBracket': None, 'Number': None, 'String': None, 'Identifier': 'sad0', 'num': None, 'id': 'sad0', 'str': None}
type = get_key(tok_match.groupdict())
if type in ['LeftBracket', 'RightBracket']:
yield (type, None)
elif type == 'Number':
yield (type, tok_match.group('num'))
elif type == 'String':
yield (type, tok_match.group('str'))
elif type == 'Identifier':
yield (type, tok_match.group('id'))
else:
raise RuntimeError(f'Unknown token type: {type}')
if __name__ == '__main__':
prog = '(define (p2 x) (mul x x)) \n (p2 234)'
for tok, data in regenize(prog.split('\n')):
print(tok, data)
# Output:
'''
LeftBracket None
Identifier define
LeftBracket None
Identifier p2
Identifier x
RightBracket None
LeftBracket None
Identifier mul
Identifier x
Identifier x
RightBracket None
RightBracket None
LeftBracket None
Identifier p2
Number 234
RightBracket None
'''
yacc
YACC 实现了一个 LALR(1) 的 SDT 的 parser. 每一个 Non-terminal, 在 reduce 的时候, 都会执行 reduce 成那个符号的规则对应的语义动作. 每一个 Non-terminal 都有且仅有一个属性, 这个属性可以在语义动作里通过 $ 来获取.
在内存充裕的现代, 我们一般可以直接生成整颗 AST 保存在内存中, 所以一般来讲 YACC 的语义动作里都是构建 AST 的代码.
它会生成两个文件:
parser.tab.h
: 定义了所有 terminal 的枚举, 声明了 yyparser() 和 yylval, 一般我们会让 lex 和要调用 yyparser 的文件包含这个文件parser.tab.c
: 定义了 yyparser() 和 yylval
/* src/frontend/parser.y */
/* ==========================================================
* 一个 yacc 文件跟 lex 文件一样由三个部分组成
* %{ ... %} ... %% ... %%
* 三个部分作用也大同小异
* 区别就在于 yacc 有很多选项可以调, 所以中间部分比较长
* ========================================================== */
%{
#include "util.h"
#include "ast.h"
#include "parser.tab.h"
/* 声明 yylex() 函数 */
extern int yylex();
/* 当出现错误时 yacc 生成的函数会调用这个东西 */
#define yyerror(s) panic("Yacc error: %s", s);
/* 如果 YYDEBUG 为 1, 那么 yacc 生成的 parser 在运行时会输出很多信息 */
#ifdef DEBUG
# undef YYDEBUG
# define YYDEBUG 1
#else
# undef YYDEBUG
# define YYDEBUG 0
#endif
/* 用来保存最后结果供外界使用的变量 */
extern Ast_List result;
%}
/* ==========================================================
* 第二部分, 在这里要写:
* 1) yylval 的类型
* 2) 各个 token(terminal) 对应于 yylval 的哪个 field
* 3) 各个 symbol(non-terminal) 对应于 yylval 的哪个 field
* 4) 起始规则
* ========================================================== */
/* 设置 yylval 为枚举类型, 有下面的这些 field */
%union {
int ival;
string sval;
Ast_Node node;
Ast_List node_list;
}
/* 设置 token 的相关属性和别名
* %token <<yylval 的 field 名>> [<token 的枚举名> ["<token 名字的替代>"]]+ */
/* 这里设置了 "如果 lexer 读到一个标识符, 那么 yylex() 应该返回一个 T_IDENT, 并且在 yylval 里的 sval 域将会有一个值;
* 在下文的规则里见到 "identifier" 就当成终结符号 标识符" */
/* 要注意这里的 <token 名字的替代>必须用双引号而不是单引号, 单引号的字符(比如'c')会被当成数字
* 意思就变成了 "当 yylex() 返回 'c' 时就意味着获得了这个 token" 而不是设置别名
* 因此, Yacc 为 token 生成的枚举类型里首个枚举值是以 256 开始的 */
%token <sval> T_IDENT "identifier" T_STR "string literal" T_PUTF "putf"
%token <ival> T_NUM "number literal"
/* 这里设置了 "如果 lexer 读到一个const, 那么 yylex() 应该返回一个 T_CONST, 并且此时 yylval 里不确定有啥;
* 在下文的规则里见到 "const" 就当成终结符号 const" */
%token T_CONST "const" T_INT "int" T_VOID "void"
%token T_IF "if" T_ELSE "else" T_WHILE "while"
%token T_BREAK "break" T_RETURN "return" T_CONTINUE "continue"
%token T_COMMA "," T_SEMICOCLON ";"
%token T_PAREN_LEFT "(" T_PAREN_RIGHT ")"
%token T_SQU_LEFT "[" T_SQU_RIGHT "]"
%token T_CURLY_LEFT "{" T_CURLY_RIGHT "}"
%token T_ASSIGN "="
%token T_ADD "+" T_SUB "-" T_MUL "*" T_DIV "/" T_MOD "%"
%token T_EQ "==" T_NOT_EQ "!=" T_LESS "<"
%token T_LESS_EQ "<=" T_GREATER ">" T_GREATER_EQ ">="
%token T_LOG_NOT "!" T_LOG_AND "&&" T_LOG_OR "||"
/* 设置 non-terminal 的相关属性 */
/* 这里设置 Non-terminal CompUnit 的规则求值之后在 yylval 的 node_list 域里有一个合法的值 */
%type <node_list> CompUnit
%type <node> FuncDef FuncParam
%type <node_list> FuncParamList
%type <node> Decl
%type <node> DefOne
%type <node_list> DefAny
%type <node> InitVal
%type <node_list> ArrInit
%type <node> Stmt Block IfStmt WhileStmt PutfForm
%type <node_list> BlockItemList
%type <node> PrimaryExp UnaryExp MulExp AddExp
%type <node> RelExp EqExp LAndExp LOrExp Cond Exp
%type <node> LVal
%type <node_list> ArrIdx FuncArgs
/* 设置起始规则为 CompUnit 对应的规则 */
%start CompUnit
%%
/* ==========================================================
* 第三部分, 在这里要写各个 Non-terminal 对应的产生式
* 只支持基本的 BNF, 不支持 EBNF
* ========================================================== */
/* 规则是, 如果碰到了 Decl CompUnit, 就规约成一个 CompUnit, 同时执行后面的语义动作
* 语义动作里, 用 $$ 指代产生式头代表的变量, $n 表示产生式体里第 n 个元素代表的变量
* 这里的语义动作会"大概"被翻译成: yylval.node_list = result = cons_Ast(yylval.node, yylval.node_list);
* 单独拎一个 result 出来是因为当 yyparse() 函数结束之后 yylval 会被回收,
* 也就是说不能通过 yylval 获取最后的结果了, 所以最后的结果要另外存一份 */
CompUnit: Decl CompUnit { $$ = result = cons_Ast($1, $2); }
| FuncDef CompUnit { $$ = result = cons_Ast($1, $2); }
| /* empty */ { $$ = result = 0; }
;
//---------------- Declaration & Definition ----------------------------
/* 在产生式体里只有一个 Block 的情况下, 可以用$<Non-terminal名字>来指代那个元素的变量
* 这里的语义动作会"大概"被翻译成: yylval.node = ast_FuncDef(FRT_INT, yylval.sval, yylval.node, yylval.node);
* 产生式体里的每一个 Symbol 都有它自己的 yylval, 用$塞进去的那个都是保存着那个 Symbol 当时被求出的那个的 yylval 的值 */
FuncDef: "int" T_IDENT "(" FuncParamList ")" Block { $$ = ast_FuncDef(FRT_INT, $2, $4, $Block); }
| "void" T_IDENT "(" FuncParamList ")" Block { $$ = ast_FuncDef(FRT_VOID, $2, $4, $Block); }
;
FuncParamList: FuncParam { $$ = cons_Ast($1, 0); }
| FuncParam "," FuncParamList { $$ = cons_Ast($1, $3); }
| /* empty */ { $$ = 0; }
;
FuncParam: "int" LVal { $$ = $LVal; }
;
Decl: "const" "int" DefAny ";" { $$ = ast_Decl(true, $DefAny); }
| "int" DefAny ";" { $$ = ast_Decl(false, $DefAny); }
;
DefOne: LVal { $$ = ast_VarDef($1, 0); }
| LVal "=" InitVal { $$ = ast_VarDef($1, $3); }
;
DefAny: DefOne { $$ = cons_Ast($1, 0); }
| DefOne "," DefAny { $$ = cons_Ast($1, $3); }
;
InitVal: Exp { $$ = ast_InitExp($Exp); }
| "{" ArrInit "}" { $$ = ast_InitArr($2); }
;
ArrInit: InitVal { $$ = cons_Ast($1, 0); }
| InitVal "," ArrInit { $$ = cons_Ast($1, $3); }
;
//---------------------------- Stmt ---------------------------
/* 语义动作留空的话, 它默认是: { $$ = $1; } */
Stmt: Block
| IfStmt
| WhileStmt
| PutfForm
| Exp ";" { $$ = ast_StmtExp($Exp); }
| LVal "=" Exp ";" { $$ = ast_StmtAssign($1, $3); }
| ";" { $$ = ast_StmtEmpty(); }
| "break" ";" { $$ = ast_StmtBreak(); }
| "continue" ";" { $$ = ast_StmtContinue(); }
| "return" ";" { $$ = ast_StmtReturn(0); }
| "return" Exp ";" { $$ = ast_StmtReturn($Exp); }
;
Block: "{" BlockItemList "}" { $$ = ast_Block($2); }
;
BlockItemList: Decl BlockItemList { $$ = cons_Ast($1, $2); }
| Stmt BlockItemList { $$ = cons_Ast($1, $2); }
| /* Empty */ { $$ = 0; }
;
IfStmt: "if" "(" Cond ")" Stmt { $$ = ast_StmtIf($Cond, $5, 0); }
| "if" "(" Cond ")" Stmt "else" Stmt { $$ = ast_StmtIf($Cond, $5, $7); }
;
WhileStmt: "while" "(" Cond ")" Stmt { $$ = ast_StmtWhile($Cond, $Stmt); }
;
PutfForm: "putf" "(" T_STR "," FuncArgs ")" ";" { $$ = ast_ExpPutf($3, $5); }
;
//----------------- Expression ----------------------------
Exp : AddExp;
Cond: LOrExp;
/* 可以看到, 如果在上面第二部分对每一个终结符设置了好看的别名的话, 就能极大地提高规则的可读性
* 比如这条规则我们就不用写 LOrExp T_OR LAndExp 而可以向下面这样写 */
LOrExp: LAndExp
| LOrExp "||" LAndExp { $$ = ast_ExpOp(OP_LOG_OR, $1, $3); }
;
LAndExp: EqExp
| LAndExp "&&" EqExp { $$ = ast_ExpOp(OP_LOG_AND, $1, $3); }
;
EqExp: RelExp
| EqExp "==" RelExp { $$ = ast_ExpOp(OP_EQ, $1, $3); }
| EqExp "!=" RelExp { $$ = ast_ExpOp(OP_NOT_EQ, $1, $3); }
;
RelExp: AddExp
| RelExp "<" AddExp { $$ = ast_ExpOp(OP_LESS, $1, $3); }
| RelExp "<=" AddExp { $$ = ast_ExpOp(OP_LESS_EQ, $1, $3); }
| RelExp ">" AddExp { $$ = ast_ExpOp(OP_GREATER, $1, $3); }
| RelExp ">=" AddExp { $$ = ast_ExpOp(OP_GREATER_EQ, $1, $3); }
;
AddExp: MulExp
| AddExp "+" MulExp { $$ = ast_ExpOp(OP_ADD, $1, $3); }
| AddExp "-" MulExp { $$ = ast_ExpOp(OP_SUB, $1, $3); }
;
MulExp: UnaryExp
| MulExp "*" UnaryExp { $$ = ast_ExpOp(OP_MUL, $1, $3); }
| MulExp "/" UnaryExp { $$ = ast_ExpOp(OP_DIV, $1, $3); }
| MulExp "%" UnaryExp { $$ = ast_ExpOp(OP_MOD, $1, $3); }
;
UnaryExp: PrimaryExp
| T_IDENT "(" FuncArgs ")" { $$ = ast_ExpCall($1, $3); }
| "+" UnaryExp { $$ = ast_ExpOp(OP_SUB, $2, 0); }
| "-" UnaryExp { $$ = ast_ExpOp(OP_ADD, $2, 0); }
| "!" UnaryExp { $$ = ast_ExpOp(OP_LOG_NOT, $2, 0); }
;
PrimaryExp: "(" Exp ")" { $$ = $2; }
| LVal { $$ = ast_ExpLval($1); }
| T_NUM { $$ = ast_ExpNum($1); }
;
LVal: T_IDENT ArrIdx { $$ = ast_Lval($1, $2); }
;
ArrIdx: ArrIdx "[" Exp "]" { $$ = cons_Ast($3, $1); }
| /* empty */ { $$ = 0; }
;
FuncArgs: Exp { $$ = cons_Ast($1, 0); }
| Exp "," FuncArgs { $$ = cons_Ast($1, $3); }
| /* empty */ { $$ = 0; }
;
使用方法
我选择的目录结构大概是这样的:
.
├── build.sh
└── src
├── ast.h # AST 对应的构造函数
├── frontend
│ ├── lexer.lex # 我们写的 Lex 文件
│ ├── lex.yy.c # Lex 生成的文件
│ ├── parser.tab.c # Yacc 生成的文件
│ ├── parser.tab.h # Yacc 生成的文件
│ └── parser.y # 我们写的 Yacc 文件
├── main.c
└── util.h # 杂项工具
主函数:
// main.c
#include "util.h"
#include "ast.h"
#include "frontend/parser.tab.h"
// 定义最后的结果
Ast_List result;
int main(int argc, char **argv) {
// 调用此函数从 STDIN 解析输入并根据输入匹配的规则执行对应的语义动作
yyparse();
// 查看输出
ast_dump_list(stdout, result);
}
构建脚本:
# build.sh
#!bash
cd SysY2021/src
# 生成前端
cd frontend
# 生成 lex.yy.c
flex lexer.lex
# 生成 parser.tab.c 和 parser.tab.h
bison -d parser.y
cd ..
if [ $1 == "production" ]
then
addition_flag="-O2"
else
addition_flag="-Wall -g -DDEBUG"
fi
# 全部编译起来并且链接
# ref: https://unix.stackexchange.com/a/19656
for c_file in $(find . | grep ".*\.c$")
do
gcc -std=c11 ${addition_flag} -c -I$PWD -o ${c_file%.c}.o $c_file
done
gcc -std=c11 ${addition_flag} $(find . | grep ".*\.o$") -o ../ssyc
执行:
./build.sh && ./ssyc
并且输入一段 SysY 语言程序, 就可以看到它对应的输出了.
参考
Flex/Bison
基本使用: calvin/flex和bison使用
比较详细, 介绍了一些C语言接口: TLDP/Lex-YACC-HOWTO
官方 Bison 文档: Bison Manual
杂项: YYSTYPE
建议使用:
%define api.value.type {double}
or
%define api.value.type {struct semantic_type}
来代替
#define YYSTYPE double
以获得更好的Bison体验.