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是一种图的数据结构。那么我们例子中的两个图有什么特点呢这个特点对于图的处理有什么好处呢
欢迎在留言区分享你的看法。由于我们后面会经常用到图的数据结构,所以你现在要开始把有关图的算法熟悉起来了!我是宫文学,我们下节课见。