C语言解释器的实现--让脚本跑起来(六)
目录:
在前面的文章中,我主要讲解了语言的解析部分,最终我们生产了脚本的中间代码。接下来,将是一个最困难的时刻,怎么解析执行中间代码!
执行代码其实是经过一定处理后的中间代码的另外一种表示。正如前面提到的,我们的中间代码是三元组的形式,比如:c = a + b * c; 可以表示成 @1 = b * c; @2 = a + @1; @3 = c = @2;但是,这种中间代码还得经过一定的转换才能更方便我们解析执行。接下来,我将一步步的说明,中间代码被执行的每个过程。
1.脚本的执行要素
一个脚本要被执行,必须要为它创建一个环境,就想操作系统中为没有程序创建一个进程一样。
一个C语言程序,其实只有几个要素:运算符,变量,函数。所以,一个C脚本要被执行,首先,它必须具备中间代码命令的解析;其次,必须要有变量的内存空间;再次,必须要有函数的调用解析,即函数调用栈的模拟。所以,一个脚本的执行,最重要的是变量内存的分配和栈的维护,还有命令的执行。
2.栈的模拟.
如果你熟悉C的调用栈,那么这个就很容易理解了。我们先不说函数调用时,栈的变化,姑且先说明一个函数的执行过程。还是这个例子:
int add( int a, int b )
{
int c, d, e;
c = a + b;
}
那么它的中间代码是这样的:
@1 = a + b;
@2 = c = @1;
在执行时,我们不能直接根据变量名去查找变量,这样既麻烦,而且效率也很低;而是应该根据变量的地址去存取变量。但是变量保存在哪里,怎么计算,这就是引入栈的原因了。我们首先看看上面的函数对应的栈:
address var
--------------
-20 a
-16 b
-12 eip
-8 esp
-4 return-address
0 <-------------------esp
0 c
4 d
8 e
12 @1
16 @2
--------------
eip表示调用该函数时,当前的命令位置,当函数返回时,我们要pop出这个eip,继续执行eip的下一条命令。
esp表示调用该函数时,当前函数的变量空间的开始位置,即调用者的esp,当函数返回时,我们要还原该esp。esp的意思是,一个函数的变量空间在栈中的基地址。每个函数在执行时,我们都会有一个固定的esp,每个变量在栈中都有具体的位置,这些变量相对于esp的距离都是固定。
return-address主要是保存函数返回值得地址,即函数在被调用时产生的临时变量。在函数返回时,返回值会被填入该地址中。这样调用者就可以从这个临时变量中获取调用结果了。例如:int a = add( 3, 4 ); 那么,return-address就应该是a的地址,或者是另一个临时变量的地址,总之,最后要为a赋值,必须依赖于return-address。
有了这个栈,我们的中间代码就应该被处理成这样:
@1 = a + b 对应于 [esp+12] = [esp-20] + [esp-16];
@2 = c = @1 对应于 [esp+16] = [esp+0] = [esp+12];
上述的代码中"[xx]"表示地址xx中的值,因为esp在执行时,每个函数在实现时esp是固定的,所以我们可以省略esp不写,所以上面的代码可以改为:
[12] = [-20] + [-16];
[16] = [0] = [12];
为了方便处理,我们将中间变量也放到栈里面,但是,中间变量的地址是可以被重用的,因为一条语句被执行完后,这条语句的中间变量就不会再被用到,所以,上一条语句的中间变量是可以被回收的。
3.变量在栈中的地址计算
首先,每个函数中,都有一个固定的esp,可以视为该函数在栈中的起始位置。然后其他的变量都被表示为距离esp的值,即偏移量。例如上面的例子,我们在解析出一个函数的中间代码时,就知道了这个函数的所有的局部变量,形参列表,并且知道这些变量的类型。所有我们可以根据类型的大小,计算他们在栈中的位置。
int add( int a, int b )
{
int c, d, e;
c = a + b;
return c;
}
int main(){
add( 4, 5 ); <---------①
}
当执行到①的时候,他的栈空间是这样的:
address offset var
--------------------------
....
15988 -12 eip
15992 -8 esp
15996 -4 return-address
16000 0 <-------------------(main-esp假设为16000)
16000 -20 4
16004 -16 5
16008 -12 eip eip指向add(4,5)的下一条命令
16012 -8 main-esp 16000
16016 -4 return-address
0 <-------------------(add-esp = 160000+20 = 160020 )
16020 0 c
16024 4 d
16028 8 e
16032 12 @1
16036 16 @2
....
---------------------------
当add函数返回时,该函数的栈会被回收。即变成:
address offset var
--------------------------
....
15988 -12 eip
15992 -8 esp
15996 -4 return-address
16000 0 <-------------------(main-esp假设为16000)
--------------------------
5.命令的解析
每条中间变量都由一个操作符和若干个操作数组成,这里没办法罗列出所有的操作符的解析。仅仅说明一个最简单的情况:
@1 = a + b 对应于 [esp+12] = [esp-20] + [esp-16];
这条中间代码,它的操作符是"+", 操作数是[-20],[-16], 目标操作数是[12]。所以解析过程相当简单,变成C代码就是这样的:
*(int*)(esp+12) = *(int*)(esp-12) + *(int*)(esp-16);
实际上我就是这么干的,只不过是为了适应各种命令的解析,显得比较的烦死,但是原理都是一样的。这里的int类型,是操作数中包含的类型信息,这是必须的,在中间代码的处理时,每个变量的类型都必须被确定,否则代码在执行时,没办法知道它所占的内存空间。
这是每条命令的定义,它其实是一个双向链表,这有利于跳转语句的跳转。
typedef struct _cmd{
char cmd;
struct{
char type;
int size;
union{
int64 i;
double d;
}d;
}d[3];
int ex;
struct _cmd * next;
struct _cmd * pre;
}cmd_t;
cmd 操作符
d[3] 操作数
ex 某些命令的附加信息
next 下一条命令
pre 前一条命令
6.C的库函数调用
C语言有它的库函数,如果我们的解释器要自己实现这些库函数的话,那么工作量就大大增加了,有什么办法直接调用系统的库函数呢。如果能做到这点,那么也就能解释器的使用者提供更加强大的交换方式----即使用者可以注册自己的函数,供脚本使用。想了很多方法,唯有用汇编了。具体的做法就是:
例如,脚本有一行代码 fopen( "test", "r" );
那么,我们获取了函数名fopen,发现他是被注册的函数,所以我们得到fopen的函数指针,假设是fptr.所以这条语句的执行是这样的:
push 0x123243 ; "test"的地址
push 0x894982 ; "r"的地址
call fptr ; 调用系统的fopen函数
...
我写了一个汇编代码,为了在liunx下顺利的移植代码,使用了nasm(我原来是使用masm)。:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;nasm -fcoff call.asm -o outfile
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
[bits 32] ;使用32位模式的处理器
[section .text]
%define WIN32
%ifdef WIN32
%define _funptr _asm_funptr ;保存函数指针
%define _argtab _asm_argtab ;参数列表
%define _argtye _asm_argtye ;参数类型列表
%define _argnum _asm_argnum ;参数个数
%define _call _asm_call
%else
%define _funptr asm_funptr
%define _argtab asm_argtab
%define _argtye asm_argtye
%define _argnum asm_argnum
%define _call asm_call
%endif
extern _funptr
extern _argtab
extern _argtye
extern _argnum
global _call
_call:
xor edx, edx
xor ecx, ecx
mov ebx, [_argnum]
cmp ebx, 0
jz end
beg:
cmp dword[_argtye + ecx], 1
jz ft
push dword[_argtab+ecx]
add edx,4
jmp fe
ft:
fld dword [_argtab+ecx]
sub esp,8
fstp qword [esp]
add edx,8
fe:
add ecx, 8
sub ebx, 1
jnz beg
end:
mov [_argnum], edx
mov eax, [_funptr]
call eax
add esp, [_argnum]
ret