词法分析: 词法分析——TEST编译器(1)
语法分析: 语法分析——TEST编译器(2)
语义分析: 语义分析——TEST编译器(3)
1 虚拟机
1.1 功能
读取中间代码并执行,此处的虚拟机相当于模拟了一个在机器上运行编译好的TEST代码的过程
1.2 特点
- 读取时采用结构体数组code储存指令及其操作数
- 执行时采用操作数栈stack进行操作
- 栈顶寄存器top为栈顶的下一个单元的下标,基址寄存器base为当前函数的数据区在stack中的起始地址
- 到此处一般就没有什么错误了,因为错误都在前面几个过程报错了,能到达虚拟机的都是没有问题的了
1.3 设计思路
-
由于虚拟机的执行过程就是不断读取中间代码的指令,所以关键就是把每一个指令对应的功能实现,并且确定下一个指令是哪一条。所以只需要一条条读取中间代码,每个指令作为一个分支,根据指令名字执行对应的功能即可
-
如何确定下一条指令是哪一个:如果学过计算机组成原理的话,就知道在运行程序的时候会有一个程序计数器ip,用来确定下一条指令是第几个,此处虚拟机也要使用这个,正常情况都是按顺序读取中间代码的指令,每读取一条ip就加一,但如果遇到BR、BRF等需要跳转位置的指令的话,ip就会直接赋值成对应的操作数,然后再继续按顺序执行。
-
操作数栈原理:操作数栈就是记录在程序运行过程中每个变量的值怎么变化,判断条件是什么等等。既然说是栈,那么就说明所有的操作都是在栈顶完成的,例如STO就是把栈顶元素的值赋值给操作数对应的变量。操作数栈还会涉及到函数开辟空间的问题,每进入一个函数就要开辟一个空间,用来存放返回的位置,基地址和该函数的局部变量,有了这些之后就知道该函数调用完后要回到哪里,该函数从栈的第几个位置开始。这些东西都是在语义分析的时候就确定了具体要开辟多少空间。
-
由于指令有很多种,在判断每一个指令时可以选择使用许多的if或者switch进行判断,但是选哪一个更好呢,我之前看过一篇关于if和switch进行比较的博客,里面提到switch的平均执行完成时间比 if 的平均执行完成时间快了约 2.33 倍。原因是在 switch 中只取出了一次变量和条件进行比较,而 if 中每次都会取出变量和条件进行比较,因此 if 的效率就会比 switch 慢很多。而且分支越多switch性能高的特性体现就越明显,所以在我们的虚拟机程序中选择各种指令时,使用了switch进行分支选择。同时,为了使代码更容易理解、阅读,可以用枚举类型表示每一个case。
-
使用switch进行分支选择,但是如何表示每一个case呢?如果是用数字1、2、3等等代表每一个执行指令则会非常不方便,使代码的阅读、理解难度增大,所以是不适合的,如果换成每个指令的字符串形式,也有点麻烦但是阅读起来就很容易理解很多了。所以我们选择结合这两种类型,使用枚举类型表示每一个指令,这样一来,判断相等、阅读代码等很多方面就会便捷很多。在我理解中,枚举就有点像#define指令,给一个东西一个重命名,可以方便我们读代码的时候好理解含义。思考之后认为使用枚举的好处是:如果有大量需要重命名的内容,如果全部用#define指令会把代码弄的很长、很麻烦。而且在判断枚举类型数据时不需要像字符串一样使用strcmp,直接使用==就可以判断,就很方便。
-
最开始打虚拟机代码的时候,就一直在纠结虚拟机部分的代码需不需语义分析产生的符号表,以为没有符号表就找不到具体的标识符了。但是在重复学习了分配内存的原理之后,明白了实际上是不需要再传入符号表的。在中间代码中也只有需要操作数的时候才会查符号表,那么在语义分析的时候比如STO的操作数就已经得到变量的相对地址了,那么对我有用的就是通过这个相对地址区分变量就好了,从而也没必要再次知道这个变量长什么样子。所以在虚拟机部分只需要传入中间代码即可,不再需要符号表了。
2 完整代码
#include<stdio.h>
#include<ctype.h>
#include<stdlib.h>
#include<string.h>
#include<string>
#include<map>
using namespace std ;
struct Code{ //中间代码
char opt[10];
int operand;
};
Code code[1000];//用于保存中间代码
map<string, int> choseOpt ; //用map集合方便switch中选择不同case,判断字符串方便
enum opts {LOAD, LOADI, STO, ADD, SUB, MULT, DIV, BR, BRF, EQ, NOTEQ, GT, LES, GE, LE, AND,
OR, NOT, IN, OUT, CAL, ENTER, RETURN}; //用具体的名称代替1234使代码更易阅读
//初始化map,方便之后switch中选择case
void mapInit() {
choseOpt["LOAD"] = LOAD ; choseOpt["LOADI"] = LOADI ; choseOpt["STO"] = STO ;
choseOpt["ADD"] = ADD ; choseOpt["SUB"] = SUB ; choseOpt["MULT"] = MULT ;
choseOpt["DIV"] = DIV ; choseOpt["BR"] = BR ; choseOpt["BRF"] = BRF ;
choseOpt["EQ"] = EQ ; choseOpt["NOTEQ"] = NOTEQ ; choseOpt["GT"] = GT ;
choseOpt["LES"] = LES ; choseOpt["GE"] = GE ; choseOpt["LE"] = LE ;
choseOpt["AND"] = AND ; choseOpt["OR"] = OR ; choseOpt["NOT"] = NOT ;
choseOpt["IN"] = IN ; choseOpt["OUT"] = OUT ; choseOpt["CAL"] = CAL ;
choseOpt["ENTER"] = ENTER ; choseOpt["RETURN"] = RETURN ;
}
//虚拟机
void TESTmachine(){
FILE *in;
char codein[100]; //输入文件名
int codenum=0; //指令条数
int top=0, base=0 ; //栈顶和栈底
int ip=0; //当前指令位置
int stack[1000]; //操作数栈
printf("请输入目标文件名(包括路径):");
scanf("%s",codein);
if((in=fopen(codein, "r"))==NULL){//打开输入文件
printf("\n打开%s错误!\n",codein);
exit(-1) ; //出错就运行结束
}
while(!feof(in)){ //读取中间代码
fscanf(in,"%s %d",&code[codenum].opt,&code[codenum].operand);
codenum++;
}
codenum-- ; //最后一次读取会多加1
fclose(in);
// for(int i=0;i<codenum;i++)
// printf("%s %d\n",code[i].opt,code[i].operand);
stack[0]=0;
stack[1]=0;
mapInit() ; //将map初始化
memset(stack, 0, sizeof(stack)) ;
while(ip < codenum){ //执行指令直到执行到最后一条指令为止,最后一条指令代表主函数结束
Code temp = code[ip] ; //用一个临时变量来执行操作
ip++ ; //每执行一个指令地址就往后移动一位
switch(choseOpt[temp.opt]) { //根据操作码进行选择要执行的指令
case LOAD : { //LOAD D 将D中的内容加载到操作数栈
stack[top] = stack[temp.operand+base] ; //找到栈中存放变量的位置
top++;
break ;
}
case LOADI : { //LOADI a 将常量a压入操作数栈
stack[top]=temp.operand;
top++;
break ;
}
case STO : { //STO D将操作数栈顶单元内容存入D
top--; //先将栈顶元素减一
stack[temp.operand+base] = stack[top] ;
break ;
}
case ADD : { //ADD 将栈顶单元与次栈顶单元出栈并相加,和置于栈顶
stack[top-2] += stack[top-1] ;
top--;
break ;
}
case SUB : { //将次栈顶单元减去栈顶单元并出栈,差置于栈顶。
stack[top-2] = stack[top-2]-stack[top-1];
top--;
break ;
}
case MULT : { //将次栈顶与栈顶单元出栈并相乘,积置于栈顶。
stack[top-2] = stack[top-1]*stack[top-2];
top--;
break ;
}
case DIV : { //将次栈顶与栈顶单元出栈并相除,商置于栈顶
stack[top-2] = stack[top-2]/stack[top-1];
top--;
break ;
}
case BR : { //BR lab 无条件转移到lab
ip = temp.operand; //操作数记录的就是要跳转的位置
break ;
}
case BRF : { //BRF 若栈顶单元逻辑值,假(0)则转移到lab
if(stack[top-1]==0)
ip = temp.operand; //操作数记录的就是要跳转的位置
top-- ;
break ;
}
case EQ : { //将栈顶两单元做相等比较,并将结果真或假(1或0)置于栈顶
stack[top-2] = (stack[top-2] == stack[top-1]) ;
top--;
break ;
}
case NOTEQ : { //栈顶两单元做不等于比较,并将结果 (1或0)置于栈顶
stack[top-2] = (stack[top-2] != stack[top-1]) ;
top--;
break ;
}
case GT : { //次栈顶大于栈顶操作数,则栈顶置1,否则置0
stack[top-2] = (stack[top-2] > stack[top-1]) ;
top--;
break ;
}
case LES : { //次栈顶小于栈顶操作数,则栈顶置1,否则置0
stack[top-2] = (stack[top-2] < stack[top-1]) ;
top--;
break ;
}
case GE : { //次栈顶大于等于栈顶操作数,则栈顶置1,否则置0
stack[top-2] = (stack[top-2] >= stack[top-1]) ;
top--;
break ;
}
case LE : { //次栈顶小于等于栈顶操作数,则栈顶置1,否则置0
stack[top-2] = (stack[top-2] <= stack[top-1]) ;
top--;
break ;
}
case AND : { //将栈顶两单元做逻辑与运算,并将结果 (1或0)置于栈顶
stack[top-2] = (stack[top-2] && stack[top-1]) ;
top--;
break ;
}
case OR : { //将栈顶两单元做逻辑或运算,并将结果 (1或0)置于栈顶
stack[top-2] = (stack[top-2] || stack[top-1]) ;
top--;
break ;
}
case NOT : { //将栈顶的逻辑值取反
stack[top-1] = !stack[top-1];
break ;
}
case IN : { //从标准输入设备(键盘)读入一个整型数据,并入操作数栈
printf("输入数据:\n");
scanf("%d", &stack[top]) ;
top++;
break ;
}
case OUT : { //将栈顶单元内容出栈,并输出到标准输出设备上(显示器)
printf("输出:%d\n",stack[top-1]);
top--;
break ;
}
case CAL : { //调用函数
stack[top] = base ; //记录主函数的基地址
stack[top+1] = ip ; //记录函数执行完要返回主函数的位置
ip = temp.operand ; //执行指令位置跳转到函数开始的位置
base = top ; //进入函数后对于当前函数的基地址
break ;
}
case ENTER : { //进入函数体
top += temp.operand ; //为函数开辟空间
break ;
}
case RETURN : { //函数返回
top = base ; //释放函数开辟的空间
ip = stack[top+1] ; //第二个位置存放的是返回到主函数的位置
base = stack[top] ; //回到栈底就是主函数的基地址,重新赋值
break ;
}
}
}
}
int main() {
TESTmachine();
return 0 ;
}
3 总结
- 这就是TEST编译器实验的全部内容了,说实话上理论课的时候,讲的都是啥根本听不懂,只有自己打过这个代码之后才恍然大悟。还有学过计算机组成原理之后再来看编译原理就感觉轻松很多。
- 过段时间还要参加一个编译器的比赛,希望到时候能做出比这个好很多倍的代码,说实话做这个代码的时候老师太恶心人,实在没有心情好好做,就随便做了一下应付,真正的还要做好多优化,并且我这个代码还存在许多不合理但是能混过检查的地方。