gitbook/手把手带你写一门编程语言/docs/418808.md
2022-09-03 22:05:03 +08:00

252 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 18|生成本地代码第3关:实现完整的功能
你好,我是宫文学。
到目前为止,我们已经把挑战生成本地代码的过程中会遇到的各种难点都解决了,也就是说,我们已经实现基本的寄存器分配算法,并维护好了栈桢。在这个基础上,我们只需要再实现其他的语法特性就行了。
所以,在今天这节课,我们要让编译器支持条件语句和循环语句。这样的话,我们就可以为前面一直在使用的一些例子,比如生成斐波那契数列的程序,生成本地代码了。然后,我们可以再比较一次不同运行时机制下的性能表现。
还记得吗?我们在前面已经分别使用了AST解释器、基于JavaScript的虚拟机和基于C语言的虚拟机来生成斐波那契数列。现在我们就看看用我们自己生成的本地代码,性能上是否会有巨大的变化。
在这个过程中,我们还会再次认识CFG这种数据结构,也会考察一下如何支持一元运算,让我们的语言特性更加丰富。
那首先我们来看一下,如何实现if语句和for循环语句。
## 支持if语句和for循环语句
在前面的课程中,我们曾经练习过为if语句和for循环语句生成字节码。不知道你还记不记得,其中的难点就是生成跳转指令。在今天这节课,我们会完成类似的任务,但采用的是一个稍微不同的方法。
我们还是先来研究一下if语句,看看针对if语句,编译器需要生成什么代码。我们采用下面一个C语言的示例程序,更详细的代码你可以参见代码库中的[if.c](https://gitee.com/richard-gong/craft-a-language/blob/master/16-18/if.c):
```plain
int foo(int a){
if (a > 10)
return a + 8;
else
return a - 8;
}
```
C语言的编译器针对这段示例程序,会生成下面的汇编代码(参见代码库中的[if.s](https://gitee.com/richard-gong/craft-a-language/blob/master/16-18/if.s)),我对汇编代码进行了整理,并添加了注释。
这段汇编代码是未经优化的。不过,我相信你经过前面课程的训练,应该可以看出来很多可以用手工优化的地方。不过现在我们关注的重点是跳转指令,所以你可以重点看一下代码中的cmpl指令、jle指令和jmp指令。
![图片](https://static001.geekbang.org/resource/image/8f/1e/8fdce9c2357d7f91809ca31fd199011e.png?wh=1182x922)
我们现在来分析一下。第一个cmpl指令的作用是比较两个整数的大小。在这个例子中,是比较a和10的大小,计算结果会设置到eflags寄存器中相应的标志位。
第二个jle指令,它的作用是根据eflags寄存器中标志位决定是否进行跳转,如果发现是小于等于的结果,那么就进行跳转,这里是跳转到else块。如果是大于呢,就会顺着执行下面的指令,也就是if块的内容。
最后我们来看jmp指令,这是无条件跳转指令,相当于我们前面学过的字节码中的goto指令。
认识了这三个指令以后,我们就知道程序的跳转逻辑了。在这个C语言的示例程序中,一共有四个基本块,我把它们之间的跳转关系画成了图,可以更加直观一些:
![图片](https://static001.geekbang.org/resource/image/6f/64/6f93391a1538f9c83cbcfc74a1yy3964.jpg?wh=1920x1080)
在分析清楚了整个思路以后,为if语句生成本地代码的逻辑也就很清楚了,我们现在就动手吧。完整的代码你可以查看代码库里的[visitIfStmt方法](https://gitee.com/richard-gong/craft-a-language/blob/master/16-18/asm_x86-64.ts#L488),我这里挑重点和你分析一下。
首先,我们要生成4个基本块:
```plain
//条件
let bbCondition = this.getCurrentBB();
let compOprand = this.visit(ifStmt.condition) as Oprand;
//if块
let bbIfBlcok = this.newBlock();
this.visit(ifStmt.stmt);
//else块
let bbElseBlock:BasicBlock|null = null
if (ifStmt.elseStmt != null){
bbElseBlock = this.newBlock();
this.visit(ifStmt.elseStmt);
}
//最后,要新建一个基本块,用于If后面的语句。
let bbFollowing = this.newBlock();
```
接着,我们要添加跳转指令,在4个基本块之间建立正确的跳转关系。这其中,最关键的就是我们怎么来为基本块0,也就是if条件所在的基本块生成跳转指令。
你会看到,在[visitBinary](https://gitee.com/richard-gong/craft-a-language/blob/master/16-18/asm_x86-64.ts#L624)函数中,我们为if条件中的比较表达式生成了一条cmpl指令。那这个时候,visitBinary为上级调用者返回什么操作数呢?
回忆一下,在上一节课,当我们为其他表达式生成指令之后,会返回一个操作数,代表该指令的执行结果会放在该操作数中,这个操作数通常会是一个逻辑寄存器。不过,cmpl指令跟加减乘除等指令不同,该指令不会改变目标寄存器的值,而是改变eflags寄存器的值。
为了体现这个逻辑,我就使用了一种新的操作数类型,也就是flag类型。这样的话,比较运算就返回一个flag类型的操作数就好了,而且这个操作数里也会记录下比较运算符。
```plain
case Op.G:
case Op.L:
case Op.GE:
case Op.LE:
case Op.EQ:
case Op.NE:
insts.push(new Inst_2(OpCode.cmpl, right, dest));
dest = new Oprand(OprandKind.flag, this.getOpsiteOp(bi.op));
break;
```
现在,根据这个操作数中记录的比较运算符,我们可以生成正确的跳转指令了。比如,如果比较运算符是大于,那么生成的跳转指令就是小于等于,也就是jle。跳转到哪里呢?跳转到基本块2,对应的指令是“jle LBB0\_2”。其中,0\_2代表第0个函数的第2个基本块。
不过,在这个阶段,我并没有基于计算出这个标签值来,而是采用了类型为“bb”的一个操作数,指向目标基本块即可。接下来,我们会再通过一个[Lower过程](https://gitee.com/richard-gong/craft-a-language/blob/master/16-18/asm_x86-64.ts#L1329),计算出每个基本块准确的标签值。在这里,我们又一次使用了抽象类型的操作数,这会使指令生成过程得到简化。
这里你要注意,在编译器生成基本块的过程中,有些基本块可能是空的块,也就是块中没有代码。这也很容易理解,因为有些Block可能就是空的。那么在Lower的过程中,这些空块也会被去除掉。
好了,到目前为止,我们已经能够为if语句正确的生成指令了。
**那接下来,我们为for循环语句生成指令的要点也是类似的,都是生成多个基本块,并且用跳转指令进行正确的拼接。**你可以参考一下代码库里的[example\_for.ts](https://gitee.com/richard-gong/craft-a-language/blob/master/16-18/example_for.ts)。
我这里也摘取了一个关键的示例程序,我们来简单分析一下:
```plain
let sum:number = 0;
for (let i:number =0; i<10; i++){
sum = sum + i;
}
println(sum);
```
这个示例程序的CFG是这样的(基于[for.s](https://gitee.com/richard-gong/craft-a-language/blob/master/16-18/for.s)生成):
![图片](https://static001.geekbang.org/resource/image/5d/63/5d33015898c5c1a62dfe08279327bd63.jpg?wh=1920x1080)
你可以多熟悉一下这些if和for循环结构所对应的CFG图,因为我们后面在学习寄存器分配算法的时候,还要跟这样的CFG打交道。
在实现for循环语句的过程中,我们还会碰到一个小的技术点,也就是一元运算。一元运算,特别是++和- -这样的运算符,实现起来有点特殊,也值得我们稍微探讨一下。
## 实现一元运算
在for循环语句中,我们通常都会使用一个递增变量。比如在[example\_for.ts](https://gitee.com/richard-gong/craft-a-language/blob/master/16-18/example_for.ts)中,你就会看到i++这种表达式。
对于i++来说,我们需要返回i的当前值,然后再让i的值加1。但对于++i来说,则是先让i的值加1,然后返回增加后的值。
这里的具体实现你可以参照代码库里[visitUnary](https://gitee.com/richard-gong/craft-a-language/blob/master/16-18/asm_x86-64.ts#L744)方法,下面我摘取了其中处理++和- -的代码片段,我们来分析一下。
```plain
let oprand = this.visit(u.exp) as Oprand;
//用作返回值的Oprand
let result:Oprand = oprand;
//++和--
if(u.op == Op.Inc || u.op == Op.Dec){
let tempVar = new Oprand(OprandKind.varIndex, this.allocateTempVar());
insts.push(new Inst_2(OpCode.movl, oprand, tempVar));
if(u.isPrefix){ //前缀运算符
result = tempVar;
}
else{ //后缀运算符
//把当前操作数放入一个临时变量作为返回值
result = new Oprand(OprandKind.varIndex, this.allocateTempVar());
insts.push(new Inst_2(OpCode.movl, oprand, result));
this.s.deadTempVars.push(tempVar.value);
}
//做+1或-1的运算
let opCode = u.op == Op.Inc ? OpCode.addl : OpCode.subl;
insts.push(new Inst_2(opCode, new Oprand(OprandKind.immediate,1), tempVar));
insts.push(new Inst_2(OpCode.movl, tempVar, oprand));
}
```
对于++i来说,计算逻辑大致如下面的伪代码。也就是我们先申请一个临时变量t1,把t1加1,再赋给i,最后返回t1。这个时候返回值就是i+1。
```plain
t1 = i
t1 = t1+1
i = t1
return t1
```
但对于i++来说,它的计算逻辑就要多引入一个临时变量t2了,并且一开始要让t1和t2都等于i。t1的作用,仍然是i增加了1,但返回值却是t2,也就是i原来的值。
```plain
t1 = i
t2 = i
t1 = t+1
i = t1
return t2;
```
除了++和- -外,还有其他的一元运算符,如+和-。
对+号的处理比较简单,实际上我们直接忽略就行了。其实,忽略+号的这个动作甚至不用等到生成汇编代码的环节,我们应该在代码优化的环节就把它去掉。
对于-号,它的计算逻辑则是用0去减当前的值。在这个过程中,我们仍然需要用到临时变量,你可以看看下面这个代码:
```plain
//-
else if (u.op == Op.Minus){
let tempVar = new Oprand(OprandKind.varIndex, this.allocateTempVar());
//用0减去当前值
insts.push(new Inst_2(OpCode.movl, new Oprand(OprandKind.immediate,0), tempVar));
insts.push(new Inst_2(OpCode.subl, oprand, tempVar));
result = tempVar;
if (this.isTempVar(oprand)){
this.s.deadTempVars.push(oprand.value);
}
}
```
好了,在实现了if语句和for循环语句以后,我们几乎已经完成生成本地代码的所有工作了。那现在又到了检验我们工作成果的时候了,我们再做一次性能比拼,看看这一次,我们的程序性能有多高。
## 再一次进行性能比拼
我们还是用斐波那契数列的例子来做测试,你用make example\_fibo命令就可以编译并生成汇编代码和可执行文件,并且能够打印出每次执行所花费的时间。你还可以用make fibo命令编译C语言版本的示例程序,用来做性能比较。
我把比较结果放在下面的表格中,这次,我们只比较C语言的栈机和本地代码版就可以了,因为本地代码版速度实在是快太多了,以至于跟AST解释器和TypeScript版的栈机做比较已经没有太大意义了。
![图片](https://static001.geekbang.org/resource/image/93/6d/934a282a25ddd316ffa25639cae37b6d.jpg?wh=1920x1051)
从表格可以看出,本地代码版的速度基本上是栈机版的20多倍,可以说性能有了极大的提升。
另外,我们还把用纯C语言写的版本([fibo.c](https://gitee.com/richard-gong/craft-a-language/blob/master/16-18/fibo.c))的性能加到了表格里,作为比较。我还绘制了曲线图。
![图片](https://static001.geekbang.org/resource/image/a3/c7/a3a37270cc5bb25655db6176fd65b1c7.png?wh=1466x854)
你可能不放大这张图仔细观察,还注意不到本地代码的曲线在哪里。因为我们生成的本地代码和C语言生成的本地代码,性能上的差异已经比较小了,不过也还是有一小点差异的。我把它俩的对比又单独画了一张图,你再看一下。
![图片](https://static001.geekbang.org/resource/image/3e/fd/3e3ea4f885yye81d594821b4fec6cdfd.png?wh=1466x854)
那这种差异来自于哪里呢?我们不是都用了相同的寄存器分配算法吗?
如果你比较一下二者的汇编代码,还是能看出一小点差别来的。**最主要的差别,就在于如何处理Caller需要保护的寄存器。**
在我们这门课当前使用的算法里,我们记录了在一个函数中,如果有Caller保护的寄存器被使用了,那么我们在每次调用函数的时候,要把所有被使用的这些寄存器都保存到内存里。
这里有一些浪费。因为其实有些寄存器不再用了,也就意味着它不再需要保护了。如果我们进一步优化一下的话,这里其实需要一个算法,来检测哪些临时变量仍然是在使用中的,然后保护它们正在使用的寄存器就可以了。
我们在下节课里,在实现升级版的寄存器分配算法的时候,就会做变量活跃性的检测。所以,这种优化我们会在下一节课一并实现。
## 课程小结
这就是今天这节课的所有内容了。这节课的内容相对简单,你只需要记住以下几个知识点就可以了:
首先,我们又重温了一下如何生成跳转指令。对于条件跳转来说,首先要把两个操作数做比较,比较结果会被设置到eflags寄存器中。这样,我们后面的条件跳转指令就可以根据eflags寄存器的内容来做跳转了。并且,对于条件跳转,我们再一次看到了一个现象,就是生成的跳转指令跟比较操作符恰好是相反的。对于>号的比较运算符,生成的反倒是jle指令,也就是如果小于等于,则跳转。
不过,我们目前的if条件仍然只支持一个简单的条件表达式,还不支持把多个条件表达式组合成一个逻辑表达式的情况。你有兴趣的话,可以研究一下怎么扩展这方面的特性。
另外,对于一元运算,我们也稍微展开讲了一下。你要注意,特别是针对i++这样的表达式,怎样实现不同的当前值和返回值
最后,我们又一次用斐波那契数列做了一下性能的对比测试。相比C语言的栈机版本,这次我们的性能提高了20多倍。看来,我们这好几节课努力学习底层架构和汇编代码的相关技术,还是很值得的。
掌握了我们当前的生成汇编代码的技术,其实你已经可以去实现很多有用的底层功能了,比如做一个图形化的开发工具,用来生成能够在物联网设备、自动设备上运行的机器码。
不过,我们当然不会满足于现在的成绩。下节课,我们将升级寄存器分配算法,继续提升程序的性能!
## 思考题
今天这节课里,我们画了两个CFG的图。CFG是一种图的数据结构。那么,我们例子中的两个图有什么特点呢?这个特点对于图的处理有什么好处呢?
欢迎在留言区分享你的看法。由于我们后面会经常用到图的数据结构,所以你现在要开始把有关图的算法熟悉起来了!我是宫文学,我们下节课见。