简单的C语言解释运行器实现(六)—— 生成指令

上一篇:语义分析

执行指令

在我们有了带类型的语法树后,我们就可以生成指令了。如果你有读过Java虚拟机规范的话,可以看到庞大的JVM指令集,好像是近200条?接下来我们要考虑代码的执行过程并生成指令。

首先我们考虑代码的执行过程,我们知道程序运行是基于栈的,那么具体是怎么运行的呢?开篇我们提到了这样的一个语法树:

   S
  /|\
 F = E
 |  /|\
 i F + T
   |  /|\
   i F * F
     |   |
     2   i

这个是关于语句x = a + 2 * c的,我介绍了生成的指令是

iload a
iconst 2
iload c
imul
iadd
istore x

这样的(应该和JVM规范比较类似)。

我们可以看到有这样的一些指令:constloadmuladdstore。我首先介绍他们的意义分别是向栈中压入一个常量、向栈中压入一个变量的值、在栈顶取出两个值做乘法再将值压入栈,在栈顶取出两个值做加法再将值压入栈,将栈顶的值存入变量。我们看到loadconststore指令还可以带参数。

你应该注意到了我实际调用的指令都有前缀i,这表示指令是操作int整型变量,因为我们要加快实际运行的速度,我们不应该在调用运行的时候才来判断指令要操作的操作数的类型。因此我们在生成指令的时候可以根据语义分析得到的类型生成对应的指令,这也是我们在语义分析的时候就把隐式类型转换转化为强制类型转换的原因,如果我们对任意两种基本类型都来一种指令,那指令数量就爆炸了。而强制类型转换的指令数并不会很多。

接下来我们模拟一下这段指令的执行过程(假设c=4;a=5):

<空>  // 栈初始为空,左侧为栈底,右侧为栈顶
5     // 执行了iload a,因为a = 5,所以栈顶压入5
5 2   // 执行了iconst 2,栈顶压入2
5 2 4 // 执行了iload c,因为c = 4,所以栈顶压入4
5 8   // 执行了imul,栈顶取出2个元素2和4,做乘法得到8,重新压入栈
13    // 执行了iadd,栈顶取出2个元素5和8,做加法得到13,重新压入栈
<空>  // 执行了istore x,栈顶取出13,赋给x,现在x就等于13了

执行完一段有意义的语句后栈就应该清空,比如我们有这样的一个语句func(1, 2);。假设func的返回值为int,那么我们生成的指令应该是这样的:

iconst 1
iconst 2
invoke_static #func

其中invoke_static表示调用静态(全局)的函数,并将栈顶的参数弹出,执行#func,将函数的返回值压入栈。假设func(1, 2) = 3,那么栈有:

<空> 
1   // 执行了iconst 1
1 2 // 执行了iconst 2
3   // 执行了invoke_static #func

因为func的返回值永远不会被使用到,所以我们还需要将多余的元素弹出。比如加一个指令pop表示弹出栈顶元素。
也许你会问不弹出会怎么样,如果我们有这样的一个语句:

i + (1 + (func(1, 2), 1));

由于逗号表达式的特性,只返回最右的那个分句的值,因此前面分句的值是无用的。生成的指令可能这样:

i       // iload i
i 1     // iconst 1
i 1 1   // iconst 1
i 1 1 2 // iconst 2
i 1 3   // invoke_static #func
i 1 3 1 // iconst 1
i 1 4   // iadd
i 5     // iadd

然后结果就变成了栈顶元素5,而不是i + 2
如果我们这样就没有问题

i       // iload i
i 1     // iconst 1
i 1 1   // iconst 1
i 1 1 2 // iconst 2
i 1 3   // invoke_static #func
i 1     // pop
i 1 1   // iconst 1
i 2     // iadd
i+2     // iadd

指令集

这里的指令是借鉴了JVM的指令,因为Java中不支持野指针,因此相关的操作会不太一样,下面我介绍一下我用到的所有指令:

指令 子指令 栈变化 说明
?add ? = i, l, d, f, ld …, v1, v2 -> …, v3 将栈顶两个元素做加法并将结果压回栈顶
?sub ? = i, l, d, f, ld, p …, v1, v2 -> …, v3 将栈顶两个元素做减法并将结果压回栈顶(如果操作数是指针,那么则为指针的差)
?mul ? = i, l, d, f, ld …, v1, v2 -> …, v3 将栈顶两个元素做乘法并将结果压回栈顶
?div ? = i, l, d, f, ld …, v1, v2 -> …, v3 将栈顶两个元素做除法并将结果压回栈顶
?eq ? = i, l, d, f, ld …, v1, v2 -> …, v3 将栈顶两个元素比较,相等v3=1,否则v3=0
?le ? = i, l, d, f, ld …, v1, v2 -> …, v3 将栈顶两个元素比较,v1
?ge ? = i, l, d, f, ld …, v1, v2 -> …, v3 将栈顶两个元素比较,v1>v2则v3=1,否则v3=0
?rem ? = i, l …, v1, v2 -> …, v3 将栈顶两个元素取模并将结果压回栈顶
?and ? = b, i, l …, v1, v2 -> …, v3 将栈顶两个元素做与运算并将结果压回栈顶
?or ? = b, i, l …, v1, v2 -> …, v3 将栈顶两个元素做或运算并将结果压回栈顶
?xor ? = b, i, l …, v1, v2 -> …, v3 将栈顶两个元素做异或运算并将结果压回栈顶
?shl ? = i, l …, v1, v2 -> …, v3 将栈顶两个元素做左移操作并将结果压回栈顶
?shr ? = i, l …, v1, v2 -> …, v3 将栈顶两个元素做右移操作并将结果压回栈顶
?inc x ? = i, l, d, f, ld …, v1 -> v3 将栈顶元素自增,操作数v1必须是引用
?not ? = b, i, l …, v1 -> …, v3 将栈顶元素取反并将结果压回栈顶
?neg ? = i, l, d, f, ld …, v1 -> …, v3 将栈顶元素取反并将结果压回栈顶
?deref ? = b, s, c, i, l, d, f, ld …, v1 -> v3 将栈顶元素解引用(指向基本类型的指针)并将结果压回栈顶
deref_pointer …, v1 -> v3 将栈顶元素解引用(多维指针)并将结果压回栈顶
deref_array …, v1 -> v3 将栈顶元素(数组)解引用并将结果压回栈顶(因为数组的解引用并不改变指针指向的地址)
ref …, v1 -> v3 将栈顶元素取地址,并将结果压回栈顶
store …, v1, v2 -> … 将栈顶元素取出,其中v1是要存储到的元素,v2是元素的新的值。
?load x … -> …, v1 加载变量x的值,压入栈顶
?load_static x … -> …, v1 加载静态/全局变量x的值,压入栈顶
move x …, v1, v2 -> v2 将栈顶元素弹出(是个指针),移动x * v2的距离,将结果压入栈顶
?return ? = b, s, c, i, l, d, f, ld v -> 将栈顶元素弹出,作为函数的返回值
return -> 函数结束(针对void类型无返回值的函数)
if x …, v1 -> … 弹出栈顶元素,如果为0,跳转到第x条指令,否则不跳转(用于if语句,如果条件为真,则继续执行if语句内的语句,否则跳转到else语句或者跳到if块的下一条语句)
jmp x … -> … 无条件跳转到第x条指令(实现goto、break、continue、有else分支的if语句,在if块的末尾跳过else指令等)
dup …, v1 -> …, v1, v1 复制栈顶元素,并压入栈(我在赋值语句中使用了这个指令)
?2? …, v1 -> …, v3 将栈顶元素强制转换,并将结果压入栈顶(b2i c2i s2i i2b i2c i2s i2l i2l i2f i2d i2ld l2i l2f l2d l2ld f2i f2d f2ld f2l d2i d2f d2ld d2l ld2i ld2f ld2d ld2l)

其中小于等于操作可转换为大于操作和取反操作、大于等于操作可转换为小于操作和取反操作,不等于操作可转换为等于操作和去反操作。
还有?代指,b=bools=shortc=chari=intl=long longd=doublef=floatld=long double(我们这里可以规定char是1字节整数,short是2字节整数,int是4字节整数,long是4字节整数,long long是8字节整数,因此在字节码中不需要特别表示long)。

更多的信息请参阅
https://github.com/huanghongxun/Compiler/tree/master/compiler/instructions.h

为了方便我们操作指针,我们对于操作数栈的每一个元素都要保存其地址(如果有)。比如在指针解引用的时候我们就知道元素的地址,在加载变量的时候我们也知道元素的地址,这两种情况下,其实就表示当前的元素是左值(因为我们知道地址,可以修改它,我们在语义分析过程中确保我们需要地址的时候就有地址)。所以我们不使用JVM虚拟机的一套?store指令,而是采用store指令带两个操作数,分别是地址和值。

我们存储指针使用void*就可以了,并不需要明确其类型,如果我们想要得到具体的指针,我们使用reinterpret_cast就可以了。

操作数栈的相关实现请参阅
https://github.com/huanghongxun/Compiler/blob/master/compiler/operand_stack.h

指令生成

关于指令的生成,我们明显可以借鉴语法树的计算的思路。比如表达式,每个语法树的节点计算都只会得到一个结果。那么我们先生成子树的指令,那么这个时候先执行完了子树的指令后,栈顶就应该是子树的结果了,我们就直接添加当前表达式的指令即可。

比如对于本文开头的语法树,我们首先遇到赋值节点S,因此我们递归先处理子树,对于子树F,我们可以得到指令iload i,就能保证栈顶是i,再对于子树加法节点E,我们先处理子树F,我们可以得到指令iload i,然后对于乘法节点T,我们递归处理两个子树F,得到两个iload i,此时我们保证了乘法的操作数已经在栈顶了,(因为子树在计算完成后应当在栈顶只留一个结果),那么添加imul即可。回到加法节点E,因为我们先处理好了两个子树,所以加法的操作数此时也在栈顶了,所以我们添加iadd即可,最后我们添加指令store就完成了。

操作数栈

我们实现操作数栈的时候,可以直接使用stack<T>去直接模拟操作数栈
由于一个函数内需要同时使用的对象不可能无穷多,所以例如Java在编译的时候会预先计算操作数栈内元素可能的最多的个数,这样我们可以在进入函数后确定操作数栈的大小以便减小频繁动态改变操作数栈空间引发的效率问题。不过不像Java的操作数栈每个元素都是4个字节(其中8字节的longdouble都被拆成了两个元素放在操作数栈里面),我们的操作数栈内除了4字节和8字节的基本类型、指针,还有结构体。所以我们在加载元素的时候需要知道元素的内存占用。
对于结构体,实际上我们并不需要将结构体整块的内存都放到操作数栈里面,因为结构体本身的操作只有赋值、取地址和取成员操作,都不关心结构体内存块的具体内容,这样我们就不需要频繁地复制结构体的内存块。我们预先处理出各个成员在结构体中的偏移,我们在取成员的时候,只要将指针从首地址转移到那个成员的首地址即可(同时适用于成员是结构体和基本类型的情况),如果我们要对结构体赋值,那么就是要将右值的内存复制到左值的位置,store指令仍然能正常工作。

本地变量表

Java编译的时候生成的字节码会包含本地变量表,并保存变量的类型签名(比如Ljava/lang/String),不过我们这里就不再需要保存类型,因为我们可以全部转换成指针操作,也就是说,我们预先计算出函数最多需要的本地变量池的大小,然后把每个变量分配在这个表内。对于结构体的取成员的操作,我们只要将指针偏移就可以了。需要注意的是,由于函数的参数实际上也算变量,所以计算本地变量表的大小的时候不要忘了参数。

load指令

然后在处理指令的时候,我们肯定要知道变量的地址,也就是?load指令的行为是如何。我们知道函数内的定义的变量是一段连续的在栈上的空间,那么我们对于每个函数,先处理出函数内定义了多少变量,这些变量总共需要多少空间,我们记录下各个变量在变量池中的位置。比如如果我们定义了int a; double d; char c;。那么需要的内存空间是 4 + 8 + 1 = 13 ,各个变量的相对地址是0; 4; 12(变量池空间地址的范围是 [ 0 , 13 ) )。那么假设我们需要dload d,那么实际上就是加载变量池的 [ 4 , 12 ) 这段存储d的空间。那么我们可以转化为dload 4。同时我们还要注意区分函数内的变量、函数内的静态变量和全局变量。其中函数内的变量我们每调用一次函数就要创建一份内存池存储函数的变量,特别注意递归调用的情况和多线程调用同一个函数的情况。然后函数内的静态变量和全局变量都是静态变量,存储在另外一个内存池中。所以我们在写load指令的时候要区分加载的实际内存地址。我在实现的时候另外添加了load_static指令表示加载静态变量,而令load指令表示加载函数内的变量(与函数状态有关的变量,包括函数的参数,因为函数的参数实际上也可以算是变量)。

数组相关

我们在进行指针相关操作的时候,要区分解引用的是数组(或者指向数组的指针)还是指针。因为我们存在a+3这种操作,如果a是一个一维整型数组的话,那么a+3就应该是int*型,因为我们前面讲过了数组是不可移动的,能移动的是指针。所以如果我们进行a+3,应当将a的第一维抹去改成一维指针,然后再按照指针的运算进行操作。比如int b[32][32]*b中的b的类型就应该转成int (*)[32],表示指向int [32]的指针,对b解引用后的类型应该是int[32]。然后对数组解引用并不改变指向的地址。这里涉及move指令和deref指令,当然我们在语义分析阶段就完成相关的转换也是可以的。

指令生成的代码参见
https://github.com/huanghongxun/Compiler/blob/master/compiler/bytecode_generator.h
https://github.com/huanghongxun/Compiler/blob/master/compiler/bytecode_generators.h
https://github.com/huanghongxun/Compiler/blob/master/compiler/bytecode_function_generator.h

可变参数

为了支持scanfprintf,我们需要支持可变参数列表。对于一个参数不定的函数,只有调用方才知道有多少个参数,但是被调用的函数是无法知道有多少个参数的,也就是说对于本地变量池,我们要在运行的时候才能知道参数的个数(以及调用者传进来的参数的类型),这样我们才能够计算本地变量池的大小,参见virtual_machine.cpp
由于参数不定,由于我们需要将参数与局部变量放在同一个本地变量池里面,如果参数放在本地变量池最开始的地方,那么结果就是局部变量在本地变量池中的相对地址是变化的,调用传入的参数多了局部变量的地址就变大,少了就变小,这样会对我们的load指令生成造成困难。然而如果我们把参数放在局部变量表的高地址,并且按照参数的顺序从低地址到高地址排列,那么局部变量和固定的参数在局部变量表中的相对地址就不再变化了(我们需要预先计算局部变量的内存空间)。你可能会问可变参数的地址还是变化的,确实,因此我们不能够直接访问这些可变参数,C语言也是这么规定的。对于这些可变参数,由于我们确保了参数是连续存放的,那么我们只要依次遍历参数就可以了,比如我知道可变参数的第一个参数的地址,那么第一个参数的地址偏移第一个参数占用的字节数之后就是第二个参数的地址,以此我们就可以得到更多的地址。这也是va_listva_startva_argva_end的实现方法。实际上这四个宏很好实现的:

#define va_list char*
#define va_start(ap, v) ((ap) = (va_list)&v + sizeof(v))
#define va_arg(ap, t) (*(t*)(((ap) += sizeof(t)) - sizeof(t)))
#define va_end(ap) ((ap) = 0)

进行一次va_arg,先取出当前参数的值,然后va_list这个指针就会向高地址偏移sizeof(t),就得到下一个参数的地址了。

执行指令

最后我们回到指令的执行,我们已经生成好了基于操作数栈的指令,那么我们在执行函数的时候创建一个本地变量池和一个操作数栈允许指令操作即可。当然还要开放特定的接口给returnjmpinvoke_static这类需要操作指令执行过程的特殊指令。

具体执行指令的代码请参见
https://github.com/huanghongxun/Compiler/blob/master/compiler/virtual_machine.h

我们在遇到函数调用的invoke_static指令时,我们可以先检查这个函数是不是内建的__built_in函数(比如putcharmallocfree这些操作系统相关的必须内建的函数),如果是,我们就调用相关的函数即可。我在virtual_machine类中定义了built_in_funcs存储相关的内建函数并允许额外注册内建函数。

内建函数

内建函数参考
https://github.com/huanghongxun/Compiler/blob/master/compiler/built_in_function.h
https://github.com/huanghongxun/Compiler/blob/master/compiler/built_in_functions.h

我直接将scanfprintf写在内建函数中,这会导致一个问题,就是调用scanf时参数是运行时才能知道的,我们写代码的时候是不知道有多少个参数的,因此我们不能直接调用scanf,应该调用vfscanf,这个函数需要我们给一个va_list,刚才我们讲过了va_list的实现方法,我们这里就可以建立一个人工的va_list,就是说我们把这类内建的可变参数函数的参数复制到一块连续的内存里(我的实现就是粗暴地memcpy),然后这块内存就直接当va_list用就可以了。具体实现参见built_in_scanfbuilt_in_printf

运行时检查

我们在执行指令的时候,仍然需要检查指令的合法性,因为如果我们每次执行的总是外来的指令序列,那么我们不能确保指令序列是正确的。比如我们需要进行类型检查(iload iconst dadd是不可以的,对非指针类型进行解引用),pop指令不能在栈为空的时候弹出,栈元素不足的时候不能执行需要操作数的指令,跳转指令不能跳转到超出指令序列的地方。
我在实现的时候要求return指令执行前操作数栈必须为空或者只能有一个需要返回的元素。这样可以方便我们检查pop指令是否在正确的地方使用了。不过这样也会导致pop指令的数量很多。

这样我们自然地实现了函数的递归调用。函数递归调用的时候是需要将程序指令复制一次的,我们因为是虚拟机,所以不需要再复制一次指令。

最后

如果你想直接生成汇编代码的话,可以去看看LLVM,LLVM替我们做了代码优化、汇编、链接等一系列工作,也被广泛地使用着(当然生成的中间代码就不一样了,不过这也不算什么了)

至此我的简单C语言解释运行器实现的系列文章就到此结束了,祝大家创造编程语言愉快。

猜你喜欢

转载自blog.csdn.net/huanghongxun/article/details/79780708