我们将尝试构建一个C语言的解释器,这个项目也是我C语言的大作业。现在回顾看来,当时对整个项目都是知其然不知其所以然,从现在的眼光来看,当时很多做法都是很稚嫩的。尤其是对于这个项目结构的理解也远远不足,所以打算重构一遍。
构建流程
首先是整个编译器的构建流程,一般来讲,编译器的编写有三个步骤:
词法分析器,用于将文本文件转换为内部的表示结构
语法分析器,在词法分析得到的标记的基础之上,构建一棵语法树
目标代码的生成,将语法树转换成目标代码
但是由于这里我们编写的是一个解释器,所以我们自定义自己的虚拟机框架和指令。我们通过语法分析词法分析之后得到的语法树,来生成使用自定义虚拟机框架的指令的目标代码。
编译器的框架
仿照c4编译器的写法:
这里我们的解释器主要包括4个函数:
next() 用于词法分析,获取下一个标记
program() 用于语法分析,分析整个C语言的程序
expression(level) 用于解析一个表达式
eval() 虚拟机的入口,用于执行目标代码
可能在此基础之上我会做出一点自己的改动,但是总体框架还是确定的,首先是实现对要解释的文件的处理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 #include <stdio.h> #include <stdlib.h> #include <string.h> int line; char *src; char *old_src; int main (int argc, char *argv[]) { int i, fd; argc--; argv++; int poolsize = 256 *1024 ; line = 1 ; { if ((fd = open(*argv,0 )) < 0 ){ printf ("Could not open %s\n" , *argv); exit (1 ); } if (!(src = old_src = malloc (poolsize))){ printf ("Could not malloc %d bytes\n" , poolsize); exit (1 ); } if ((i=read(fd, src, poolsize-1 )) <= 0 ){ printf ("Could not read %s\n" , *argv); exit (1 ); } src[i] = 0 ; close(fd); } program(); return eval(); }
我们将读取到的文件内容复制到src内存空间中。
现在我们就需要开始模拟一台计算机应有的功能了,我们的指令需要运行在我们的虚拟机上。那么首先,我们就需要定义一个自己的虚拟机,一台机器必须要有以下几个部分:
内存: 这是用来存放数据的主要空间,我们的代码段数据
还有函数调用时的栈段都存放在这里
寄存器:我们需要寄存器帮助我们实现各种命令,同时也需要程序计数器指向我们的指令
CPU:本来这一部分应该是一大堆电路,但是这里我们可以通过操作指针和寄存器的方式,用一系列程序,简单的模拟它
计算机内部实现
内存
首先是内存部分,一般情况下,内存会被分成几个段。由于我们是简单实现的理想模型,我们只需要设置几个最基本的部分就可以了:
代码段: 我们分析得到的指令就被存放在这里
数据段: 这里用来存放我们源程序中用的数据,通常和token信息绑定
栈段: 我们处理函数调用将会用到它,存放栈帧和局部变量等数据
现在我们就可以简单的对我们的模拟的计算机内存进行初始化:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 ...... int *text; int *old_text; int *stack ; char *data; int main (int argc, char *argv[]) { ...... { if (!(text = old_text = malloc (poolsize))){ printf ("Could not malloc %d bytes for text\n" , poolsize); exit (1 ); } if (!(stack = malloc (poolsize))){ printf ("Could not malloc %d bytes for stack\n" , poolsize); exit (1 ); } if (!(data = malloc (poolsize))){ printf ("Could not malloc %d bytes for data\n" , poolsize); exit (1 ); } memset (text, 0 , poolsize); memset (stack , 0 , poolsize); memset (data, 0 , poolsize); } program(); return eval(); }
寄存器
寄存器用来存放计算机的运行状态,我们的虚拟机中只简单的使用四个寄存器,分别是:
PC程序计数器,用于存放一个内存地址,该地址中用于存放下一条要执行的指令
SP寄存器,永远指向当前的栈顶(注意这里的栈是从高地址向低地址增长的)
BP基址寄存器,用于指向栈底,当我们在函数调用解析参数时需要用到它
AX通用寄存器,用来存放一条指令执行后的结果
我们对他们进行简单的初始化:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 ...... int *pc; int *bp; int *sp; int ax; int cycle; int main (int argc, char *argv[]) { ...... { bp = sp = (int *)((char *)stack + poolsize); ax = 0 ; cycle = 0 ; } program(); return eval(); }
指令
现在我们的虚拟机已经有了基本的骨架,我们可以开始尝试实现我们我们虚拟机最基本的指令集。不过首先我们需要明确我们需要用到哪些指令。这里我们直接照搬C4的指令集就行了:
1 2 3 4 5 6 7 enum { LEA, IMM, JMP, CALL, JZ, JNZ, ENT, ADJ, LEV, LI, LC, SI, SC, PUSH, OR, XOR, AND, EQ, NE, LT, GT, LE, GE, SHL, SHR, ADD, SUB, MUL, DIV, MOD, OPEN, READ, CLOS, PRTF, MALC, MSET, MCMP, EXIT };
这些指令顺序是有意的,带有参数的指令在前,没有参数指令的放在后面,为了方便我们之后打印调试信息。
MOV
MOV dest,srcx86中最基础重要的一个公式,它将SOURCE中的内容放在DEST中。但在我们的虚拟机中只提供了一个通用寄存器AX,加上由于对参数类型的识别是一件复杂的事情,所以我们将MOV拆分成五个子操作来实现:
IMM <num>
将num放入寄存器ax中
LC
将对应地址中的字符载入ax中,要求ax中存放地址
LI
将对应地址中的整数载入ax中,要求ax中存放地址
SC将ax中的数据作为字符存放入地址中,要求栈顶存放地址
SI将ax中的数据作为整数存放入地址中,要求栈顶存放地址
这样的拆分,极大程度上的减小了指令实现的复杂度,现在我们可以开始具体的实现了:
1 2 3 4 5 6 7 8 9 10 11 int eval () { int op, *tmp; while (1 ){ if (op == IMM) {ax = *pc++;} else if (op == LC) {ax = *(char *)ax;} else if (op == LI) {ax = *(int *)ax;} else if (op == SC) { *(char *)*sp++ = ax;} else if (op == SI) { *(int *)*sp++ = ax;} } return 0 ; }
这里得*sp++实际上就是一个退栈操作,将值存储到弹出得地址中。
这里我之前一直没有搞懂,为什么LI/LC要将对应地址中的字符载入ax中,因为我们的默认的计算结果是存储在ax中的,也就是我们指令计算出的地址,可以直接被LI/LC所使用,这样就很高效。
PUSH
PUSH的作用很简单,就是将值或者寄存器压入到栈中。在这里我们PUSH的作用给就是将ax的值压入栈中,因为我们只有这一个寄存器:
1 else if (op == PUSH) {*--sp = ax;}
JMP
JMP <addr>是跳转指令,无条件的将当前的PC寄存器设置为指定的<addr>,实现如下:
1 else if (op == JMP) {pc = (int *)*pc;}
pc始终指向的是下一条指令,所以此时它存放的存放的是JMP指定的参数,即<addr>的值
JZ/JNZ
为了实现if语句
我们也需要条件判断相关的指令。这里我们只需要实现两个最简单的条件判断,即ax==0/ax!=0的情况下的跳转:
1 2 else if (op == JZ) {pc = ax ? pc+1 : (int *)*pc;}else if (op == JNZ) {pc = ax ? (int *)*pc : pc+1 ;}
子函数调用
这个是比较重要的一部分,我很大程度上也是为了这一部分重新回头别写这个编译器。这里我们将根据子函数调用约定,用几个最简单的指令来实现这个过程,我们要引入的指令有CALL ENT ADJ LEV。
首先我们介绍CALL,它用于跳转到一个子函数的开始地址。程序将会跳转到地址<addr>,并将当前的位置信息保存起来,以用于函数调用后的返回。这里我们将返回的PC存放在栈中:
1 else if (op == CALL) {*--sp = (int )(pc+1 ); pc = (int *)*pc;}
现在我们能进入调用函数了,那么我们便需要进一步的规范我们的函数调用规则。在实际的函数调用中,我们不仅要考虑函数的地址,也要考虑如何传递参数和返回结果,这里我们将每次的返回结果保存在ax中,对于参数传递,我们需要遵循C语言的调用标准:
由调用者将参数入栈
调用结束时,由调用者将参数出栈
参数逆序入栈(因为先进后出)
我们可以看下面的这个例子:
1 2 3 4 5 6 7 8 int callee (int , int , int ) ;int caller (void ) { int i, ret; ret = callee(1 , 2 , 3 ); ret += 5 ; return ret; }
会生成下面的汇编代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 caller: ; make new call frame push ebp mov ebp, esp sub 1, esp ; save stack for variable: i ; push call arguments push 3 push 2 push 1 ; call subroutine 'callee' call callee ; remove arguments from frame add esp, 12 ; use subroutine result add eax, 5 ; restore old call frame mov esp, ebp pop ebp ; return ret
但是我们的虚拟机难以解决其中的几个问题:
push ebp 我们的PUSH无法指定寄存器
mov ebp,esp 我们的MOV也无法直接指定源和目的
add esp,12 我们的ADD也无法直接实现两个操作数的加法
所以我们需要额外的指令来代替这几个的功能,所以我们需要定义以下几个指令:
ENT
ENT <size>指的是enter我们用它实现make new call frame的操作,即保存当前的栈指针,并在栈上保留一定的空间,用来存放局部变量,汇编代码的表现形式如下:
1 2 3 4 ; make new call frame push ebp mov ebp, esp sub size, esp ; save stack for variable
我们可以通过以下形式实现它:
1 else if (op == ENT) {*--sp = (int )bp; bp = sp; sp = sp - *pc++;}
ADJ
ADJ <size>用于实现remove argument from frame。用于将调用子函数时压入栈中的数据清楚,之所以单独定义这个指令,是对我们ADD功能局限做出的妥协。其汇编实现如下:
1 2 ; remove arguments from frame add esp, size
我们可以通过以下形式实现它:
1 else if (op == ADJ) {sp = sp + *pc++;}
LEV
本质上这个指令并不是必需的而且我们的指令集中并没有POP的指令,所以为了简洁的进行函数的退出操作,我们专门定义了LEV的封装。对应的汇编操作如下:
1 2 3 4 5 ; restore old call frame mov esp, ebp pop ebp ; return ret
我们用以下形式实现:
1 else if (op == LEV) {sp = bp; bp = (int *)sp++; pc = (int *)*sp++;}
LEA
上述的指令解决了调用帧的问题,解决了我们函数的运行和返回的问题。现在,为了进一步的执行调用函数,我们需要想办法获取先前压入的参数。在此之前,我们首先需要了解当参数调用时,栈中的调用帧是什么样的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 sub_function(arg1, arg2, arg3); | .... | high address +---------------+ | arg: 1 | new_bp + 4 +---------------+ | arg: 2 | new_bp + 3 +---------------+ | arg: 3 | new_bp + 2 +---------------+ |return address | new_bp + 1 +---------------+ | old BP | <- new BP +---------------+ | local var 1 | new_bp - 1 +---------------+ | local var 2 | new_bp - 2 +---------------+ | .... | low address
我们首先将参数压入栈中,然后进行调用,调用会将返回地址压入栈中,然后保存当前的栈基址(我们将这个保存的栈帧称为old_bp),准备创建下一个栈帧。当前视图就是进行push rbp; mov rbp,rsp;之后的栈帧状态。此时如果我们想要访问函数的第一个参数,我们就需要访问new_bp+4地址上的内容。
但是这里有一个问题,我们的ADD指令无法对bp进行运算,所以我们需要设置一个新的指令,用于访问栈帧上的地址LEA <offset>。我们具体的实现如下:
1 else if (op == LEA) {ax = (int )(bp + *pc++);}
现在,我们就完成了实现函数调用的所有指令了。
运算符指令
我们在C语言中支持的各种运算符号都是二元的,所以我们指令的参数也应该是由两个的,但是我们只有一个通用寄存器,这就导致我们需要将其中一个参数放在栈上,另一个参数存放在ax中,我们通过指令计算出来的结果也存放在ax上。
我们的实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 else if (op == OR) ax = *sp++ | ax;else if (op == XOR) ax = *sp++ ^ ax;else if (op == AND) ax = *sp++ & ax;else if (op == EQ) ax = *sp++ == ax;else if (op == NE) ax = *sp++ != ax;else if (op == LT) ax = *sp++ < ax;else if (op == LE) ax = *sp++ <= ax;else if (op == GT) ax = *sp++ > ax;else if (op == GE) ax = *sp++ >= ax;else if (op == SHL) ax = *sp++ << ax;else if (op == SHR) ax = *sp++ >> ax;else if (op == ADD) ax = *sp++ + ax;else if (op == SUB) ax = *sp++ - ax;else if (op == MUL) ax = *sp++ * ax;else if (op == DIV) ax = *sp++ / ax;else if (op == MOD) ax = *sp++ % ax;
内置命令
有很多由系统支持的函数,我们往往难以在机器上支持实现,所以这里我们简单的对函数进行封装,设置为内置的指令,用来支持一些常用的库函数调用。
这里我们就简单的对exit open close read printf malloc memset指令进行封装以确保我们的程序有基本的能力:
1 2 3 4 5 6 7 8 else if (op == EXIT) {printf ("exit(%d)" , *sp); return *sp;} else if (op == OPEN) {ax = open((char *)sp[1 ], sp[0 ]);} else if (op == CLOS) {ax = close(*sp);} else if (op == READ) {ax = read(sp[2 ], (char *)sp[1 ], *sp);} else if (op == PRTF) {tmp = sp+pc[1 ]; ax = printf ((char *)tmp[-1 ],tmp[-2 ],tmp[-3 ],tmp[-4 ],tmp[-5 ],tmp[-6 ]);} else if (op == MALC) {ax = (int )malloc (*sp);} else if (op == MSET) {ax = (int )memset ((char *)sp[2 ], sp[1 ], *sp);} else if (op == MCMP) {ax = memcmp ((char *)sp[2 ], (char *)sp[1 ], *sp);}
这里我们的参数在函数调用时是顺序入栈,所以看起来比较反直觉,结合栈是自顶向下索引的,所以我们最终可以很好的理解我们的参数调用顺序。
最后在加上一个错误判断,我们的虚拟机指令就完成了:
1 2 3 4 else { printf ("unknown instruction:%d\n" , op); return -1 ; }
测试
现在我们的虚拟机指令就完成了,我们可以做一个简单的测试,来看我们的机器能否正确的运行:
1 2 3 4 5 6 7 8 9 10 11 12 i = 0 ; pc = text; text[i++] = IMM; text[i++] = 12 ; text[i++] = PUSH; text[i++] = IMM; text[i++] = 5 ; text[i++] = DIV; text[i++] = PUSH; text[i++] = IMM; text[i++] = 1 ; text[i++] = EXIT;
程序执行的结果是:
1 2 ylin@Ylin:~/Program/c4$ ./a.out exit (2 )
至此我们的虚拟机以及指令架构就完成了