五、语法制导翻译与抽象语法树,《编译原理》(本科教学版),第2版
文章目录
零、LR(1)分析工具
1.1 历史
Antlr-v4 是基于 LL(*)算法,这里介绍下 基于LR(1)的 yacc
- YACC 是 Yet Another Compiler-Compiler 缩写
- Compiler-Compiler:写一个模块,可以把这个C编译器生成出来
- 在1975年首先由Steve Johnson在 Unix 上实现
- 后来,很多工具在此基础上做了改进:
- 例如GNU Bison,下面会用
- 并且移植到了很多其他语言上
- YACC现在是一个标准的工具(见IEEE Posix 标准 P1003.2)
1.2 Yacc
Yacc 语言很简单,分为三大块:
用户代码和Yacc声明:可以在接下来的部分使用
%%
语法规则:上下文无关文法的规则及相应语义动作(后续变成一个表驱动的语法分析算法)
%%
用户代码:用户提供的代码
下面写一个简单的检测表达式输入的文法
- 创建一个文件 test.y
- 书写语法规则
- 并在用户代码部分书写主函数,调用 yyparse()
%%
exp: n
| exp '+' exp
;
n: '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '0';
%%
int main(int argc, char **argv){
yyparse();
return 0;
}
- 我们 通过 bison + 文件名来进行编译
- 发现报错:出现二义性
root@LAPTOP-292TJJC6:~/UstcCompile# bison test.y
test.y: warning: 1 shift/reduce conflict [-Wconflicts-sr]
- 我们强制左结合优先:
%left '+'
%%
exp: n
| exp '+' exp
;
n: '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '0';
%%
int main(int argc, char **argv){
yyparse();
return 0;
}
- 发现可以编译,并且生成了 test.tab.c
- test.tab.c 是一个长达上千行,自动生成的一个LR(1)语法分析器
root@LAPTOP-292TJJC6:~/UstcCompile# bison test.y
root@LAPTOP-292TJJC6:~/UstcCompile# ls
test.tab.c test.y
root@LAPTOP-292TJJC6:~/UstcCompile#
- 我们利用gcc 去编译 test.tab.c:再次报错
root@LAPTOP-292TJJC6:~/UstcCompile# gcc test.tab.c
test.tab.c: In function ‘yyparse’:
test.tab.c:1197:16: warning: implicit declaration of function ‘yylex’ [-Wimplicit-function-declaration]
1197 | yychar = yylex ();
| ^~~~~
test.tab.c:1324:7: warning: implicit declaration of function ‘yyerror’; did you mean ‘yyerrok’? [-Wimplicit-function-declaration]
1324 | yyerror (YY_("syntax error"));
| ^~~~~~~
| yyerrok
/usr/bin/ld: /tmp/ccdewM9y.o: in function `yyparse':
test.tab.c:(.text+0x328): undefined reference to `yylex'
/usr/bin/ld: test.tab.c:(.text+0x628): undefined reference to `yyerror'
/usr/bin/ld: test.tab.c:(.text+0x7be): undefined reference to `yyerror'
collect2: error: ld returned 1 exit status
root@LAPTOP-292TJJC6:~/UstcCompile#
因为我们没有实现 yylex 和 yyerror
- yylex:类似于getToken
- yyerror:语法报错
%{
int yylex();
void yyerror();
%}
%left '+'
%%
exp: n
| exp '+' exp
;
n: '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '0';
%%
int yylex(){
return getchar();
}
void yyerror(char *s){
fprintf(stderr, "%s\n", s);
}
int main(int argc, char **argv){
yyparse();
return 0;
}
- 再次重新编译,又报错:
- 因为没引头文件
root@LAPTOP-292TJJC6:~/UstcCompile# bison test.y
root@LAPTOP-292TJJC6:~/UstcCompile# ls
test.tab.c test.y
root@LAPTOP-292TJJC6:~/UstcCompile# gcc test.tab.c
test.y: In function ‘yylex’:
test.y:22:12: warning: implicit declaration of function ‘getchar’ [-Wimplicit-function-declaration]
22 | return getchar();
| ^~~~~~~
test.y:19:1: note: ‘getchar’ is defined in header ‘<stdio.h>’; did you forget to ‘#include <stdio.h>’?
18 |
+++ |+#include <stdio.h>
19 | %%
test.y: In function ‘yyerror’:
test.y:26:5: warning: implicit declaration of function ‘fprintf’ [-Wimplicit-function-declaration]
26 | fprintf(stderr, "%s\n", s);
| ^~~~~~~
test.y:26:5: warning: incompatible implicit declaration of built-in function ‘fprintf’
test.y:26:5: note: include ‘<stdio.h>’ or provide a declaration of ‘fprintf’
test.y:26:13: error: ‘stderr’ undeclared (first use in this function)
26 | fprintf(stderr, "%s\n", s);
| ^~~~~~
test.y:26:13: note: ‘stderr’ is defined in header ‘<stdio.h>’; did you forget to ‘#include <stdio.h>’?
test.y:26:13: note: each undeclared identifier is reported only once for each function it appears in
root@LAPTOP-292TJJC6:~/UstcCompile#
- 我们把头文件加上
%{
#include <stdio.h>
#include <stdlib.h>
int yylex();
void yyerror();
%}
root@LAPTOP-292TJJC6:~/UstcCompile# bison test.y
root@LAPTOP-292TJJC6:~/UstcCompile# gcc test.tab.c
root@LAPTOP-292TJJC6:~/UstcCompile# ls
a.out test.tab.c test.y
- 产生了可执行文件
- 运行(注意,非终结符为 Ctrl + D)
我们通过增加 lines 产生式来实现多行输入检测:
%%
lines: line
| line lines
;
line: exp
| exp '\n'
;
exp: n
| exp '+' exp
;
n: '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '0';
%%
一、语法制导的翻译
- 编译器在做语法分析的过程中,除了回答程序语法是否合法外,还必须完成后续工作
- 可能的工作包括(但不限于)
- 类型检查
- 目标代码生成
- 中间代码生成
- 可能的工作包括(但不限于)
- 这些后续的工作一般可通过语法制导的翻译完成
1.1 LR分析中的语法制导翻译
我们有如下文法,对于每个产生规则我们添加了一个语义动作 ai,这些语义动作要在右部被识别完,进行规约的时候来执行。
X -> β1a1
| β2 a2
| β3 a3
...
| βn an
用伪代码表示就是下面这样,我们在 弹栈规约前调用了 ai
if (action[s, t] == "ri")
ai
pop(βi)
state s' = stack[top]
push(x)
push(goto[s', x])
示例:计算表达式的的值:
按照产生式1进行规约时,我们将 n 赋值给 E
同样的,按照产生式0进行规约时,我们一定已经得到了E1和E2的值了,那么我们将 E1+E2 赋值给 E
0: E -> E+E{E = E1 + E2}
1: | n{E = n}
1.1.1 实例
回看前面的 Yacc 程序,我们在其中加入语义动作:
- Yacc 中语义动作在产生式后 用 花括号包裹
- 产生式右部 的token 从左至右 分别为 $1, $2 … %n
- 左部用 $$ 表示
line: exp {printf("value=%d\n", $1);}
| exp '\n' {printf("value=%d\n", $1);}
;
exp: n {$$=$1;}
| exp '+' exp {$$=$1+$3;}
;
n: '1' {$$=1;}
| '2' {$$=2;}
| '3' {$$=3;}
| '4' {$$=4;}
| '5' {$$=5;}
| '6' {$$=6;}
| '7' {$$=7;}
| '8' {$$=8;}
| '9' {$$=9;}
| '0' {$$=0;}
;
root@LAPTOP-292TJJC6:~/UstcCompile# ./a.out
1+3
value=4
2+5
value=7
6+7
value=13
root@LAPTOP-292TJJC6:~/UstcCompile#
1.2 语法制导翻译的实现原理
X -> β1a1
| β2 a2
| β3 a3
...
| βn an
- 给每条产生式规则附加一个语义动作
- 一个代码片段
- 语义动作在产生式**“归约”**时执行
- 即由右部的值计算左部的值
- 以自底向上的技术为例进行讨论
- 自顶向下的技术与此类似
1.2.1 LR分析中的语法制导翻译
if (action[s, t] == "ri")
ai
pop(βi)
state s' = stack[top]
push(x)
push(goto[s', x])
- 在分析栈上维护三元组:<symbo1, value,state>
- 其中symbo1是终结符或非终结符,value是symbo1所拥有的值,state是当前的分析状态
1.2.2 例子
这里对前面Yacc 中的例子来进行解释
E -> E + E{$$=$1+$3;}
| n {$$=n;}
我们对下面这个输入进行语法制导翻译
7+8+9
栈中存储三元组<symbol, value, state>
-
<S, _, 0> 入栈
-
读入7,<7, 7, 1> 入栈
-
执行语义动作 <$$ = n;>,弹栈规约,<E, 7, 2> 入栈
-
读入+,<+, +, 3> 入栈
-
读入8,<8, 8, 1> 入栈
-
弹栈规约,<E, 8, 4>
-
此时的栈的状态:
-
这个时候执行语义动作<$$ = $1 + $3>,弹栈规约,<E, 15, 2>入栈
-
后面读入 + 和 9 重复前面的情况,不再赘述
1.3 抽象语法树
这一part非常简单,其实就是数据结构中讲的表达式树:表达式树与前中后缀表达式的转化_二叉树 前缀后缀中缀-CSDN博客
现代语法分析器的输出往往是一个抽象语法树
1.3.1 分析树
- 分析树编码了句子的推导过程
- 但是包含很多不必要的信息
- 注意:这些节点要占用额外的存储空间
- 本质上,这里的哪些信息是重要的?
- 对于表达式而言,编译只需要知道运算符和运算数
- 优先级、结合性等已经在语法分析部分处理掉了
- 对于语句、函数等语言,其他构造语言也一样
- 例如,编译器不关心赋值符号是**=还是:=**或其他
1.3.2 抽象语法树
可见右边的抽象语法树是对于左侧分析树的浓缩。
当然,如果还记得数据结构课程中的表达式树的话,对于抽象语法树应该不会陌生。
1.3.3 具体语法和抽象语法
- 具体语法是语法分析器使用的语法
- 必须适合于语法分析,如各种分隔符、消除左递归、提取左公因子,等等
- 抽象语法是用来表达语法结构的内部表示
- 现代编译器一般都采用抽象语法作为前端(词法语法分析)和后端(代码生成)的接口
比如我们可以有这样一个具体文法:
E -> E + T
| T
T -> T * F
| F
F -> n
| (E)
如果我们将其简化:
E -> n
| E + E
| E * E
但是这个文法是有二义性的,但是这样的抽象文法却有助于我们对于数据结构的建立。
1.3.4 抽象语法树数据结构
- 在编译器中,为了定义抽象语法树,需要使用实现语言(编译器的实现语言)来定义一组数据结构。
- 和实现语言密切相关
- 早期的编译器有的不采用抽象语法树数据结构
- 直接在语法制导翻译中生成代码(早期内存受限)
- 但现代的编译器一般采用抽象语法树作为语法分析器的输出
- 更好的系统的支持
- 简化编译器的设计
1.3.5 抽象语法树的定义(C语言)
文法:
E -> n
| E + E
| E * E
1.3.5.1 数据结构
- 下面的定义个人理解是一种类似于侵入式链表结构
- 将数据和指针进行了解耦合,USTC的PPT上的代码有些语法错误,做了更正
- 事实上如果按照他这种侵入式的设计方式,实际运用应该加一些成员访问的逻辑,Exp指针 如何访问 不同类型的Exp_ 的成员
- 所以下面就当成是伪代码,看个乐就行
/*数据结构*/
enum kind {E_INT, E_ADD, E_TIMES};
struct Exp {
enum kind kind;
};
// int
struct Exp_Int {
enum kind kind;
int n;
};
// +
struct Exp_Add {
enum kind kind;
struct Exp *left;
struct Exp *right;
};
// *
struct Exp_Times {
enum kind kind;
struct Exp *left;
struct Exp *right;
};
1.3.5.2 “构造函数”
struct Exp_Int *Exp_Int_new(int n) {
struct Exp_Int *p = (struct Exp_Int*)malloc(sizeof (*p));
p->kind = E_INT;
p->n = n;
return p;
}
struct Exp_Add *Exp_Add_new(struct Exp *left, struct Exp *right) {
struct Exp_Add *p = (struct Exp_Add*)malloc(sizeof (*p));
p->kind = E_ADD;
p->left = left;
p->right = right;
return p;
}
1.3.5.3 示例
用数据结构来编码程序 “2+3+4”
e1 = Exp_Int_new(2);
e2 = Exp_Int_new(3);
e3 = Exp_Int_new(4);
e4 = Exp_Times_new(e2, e3);
e5 = Exp_Add_new(e1, e4);
得到如下抽象语法树
1.3.6 AST上的操作成为树的遍历
/*优美打印*/
void pretty_print(e) {
switch(e->kind) {
case E_INT: printf("%d", e->n); return;
case E_ADD:
printf("(");
pretty_print(e->left);
printf(")");
printf("+");
printf("(");
pretty_print(e->right);
printf(")");
other cases: /* 类似 */
}
}
示例:树的规模
通过 dfs 来计算节点个数
/*节点的个数*/
int numNodes(E e) {
switch (e->kind) {
case E_INT:
case E_ADD:
case E_TIMES:
return 1 + numNodes (e->left) + numNodes (e->right);
default:
error("complier bug");
}
}
示例:从表达式到栈式计算机Stack的编译器
/*编译器:请参考课程第一部分的作业内容:Sum -> Stack*/
List all;// 存放生成的所有指令
void compile (E e) {
switch (e->kind) {
case E_INT: emit(push e->n); return;
case E_ADDS:
case E_TIMES:
// 留作练习
default:
error("compiler bug");
}
}
1.3.7 抽象语法树的自动生成
1.3.7.1 LR分析中生成抽象语法树
- 在语法动作中,加入生成语法树的代码片段
- 片段一般是语法树的’构造函数’
- 在产生式归约的时候,会自底向上构造整棵树
- 从叶子到根
生成方法非常的巧妙,通过规约前的语义动作完成构造,而且不会影响结合性!
1.3.8 源代码信息的保留和传播
- 抽象语法树是编译器前端和后端的接口
- 程序一旦被转换成抽象语法树,则源代码即被丢弃
- 后续的阶段只处理抽象语法树
- 所以抽象语法树必须编码足够多的源代码信息
- 例如,它必须编码每个语法结构在源代码中的位置(文件、行号、列号等)
- 这样,后续的检查阶段才能精确的报错
- 或者获取程序的执行创面
- 例如,它必须编码每个语法结构在源代码中的位置(文件、行号、列号等)
- 抽象语法树必须仔细设计
示例:位置信息
我们还可以在节点中加入位置信息
struct position_t {
char *file;
int line;
int column;
};
struct Exp_Add {
enum kind kind;
Exp *left;
Exp *right;
struct position_t from;
struct position_t to;
};
设计好的抽象语法树是一门艺术,绝对不是教条化,绝对化的规则。要根据设计编译器的需求和目的,来设计抽象语法树的合理表示,来保证我们需要的信息,来满足设计需求。
而且不会影响结合性!
1.3.8 源代码信息的保留和传播
- 抽象语法树是编译器前端和后端的接口
- 程序一旦被转换成抽象语法树,则源代码即被丢弃
- 后续的阶段只处理抽象语法树
- 所以抽象语法树必须编码足够多的源代码信息
- 例如,它必须编码每个语法结构在源代码中的位置(文件、行号、列号等)
- 这样,后续的检查阶段才能精确的报错
- 或者获取程序的执行创面
- 例如,它必须编码每个语法结构在源代码中的位置(文件、行号、列号等)
- 抽象语法树必须仔细设计
示例:位置信息
我们还可以在节点中加入位置信息
struct position_t {
char *file;
int line;
int column;
};
struct Exp_Add {
enum kind kind;
Exp *left;
Exp *right;
struct position_t from;
struct position_t to;
};
设计好的抽象语法树是一门艺术,绝对不是教条化,绝对化的规则。要根据设计编译器的需求和目的,来设计抽象语法树的合理表示,来保证我们需要的信息,来满足设计需求。
原文地址:https://blog.csdn.net/EQUINOX1/article/details/143662626
免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!