字节码(二):解释器是如何解释执行字节码的?

字节码的解释执行在编译流水线中的位置你可以参看下图:

如何生成字节码?

V8线对代码进行解析(parser),并生成为AST和作用域信息,之后AST和作用域输入到Ignition 的解释器中,并转换为字节码,之后再由Ignition解释器来解释执行。

function add(x, y) {
var z = x+y
return z
}
console.log(add(1, 2))

刚刚我们提到了,V8首先会将函数的源码解析为AST,这一步由解析器(Parser)完成,你可以在d8中通过–print-ast 命令来查看V8内部生成的AST。

[generating bytecode for function: add]
--- AST ---
FUNC at 12
. KIND 0
. LITERAL ID 1
. SUSPEND COUNT 0
. NAME "add"
. PARAMS
. . VAR (0x7fa7bf8048e8) (mode = VAR, assigned = false) "x"
. . VAR (0x7fa7bf804990) (mode = VAR, assigned = false) "y"
. DECLS
. . VARIABLE (0x7fa7bf8048e8) (mode = VAR, assigned = false) "x"
. . VARIABLE (0x7fa7bf804990) (mode = VAR, assigned = false) "y"
. . VARIABLE (0x7fa7bf804a38) (mode = VAR, assigned = false) "z"
. BLOCK NOCOMPLETIONS at -1
. . EXPRESSION STATEMENT at 31
. . . INIT at 31
. . . . VAR PROXY local[0] (0x7fa7bf804a38) (mode = VAR, assigned = false) "z"
. . . . ADD at 32
. . . . . VAR PROXY parameter[0] (0x7fa7bf8048e8) (mode = VAR, assigned = false) "x"
. . . . . VAR PROXY parameter[1] (0x7fa7bf804990) (mode = VAR, assigned = false) "y"
. RETURN at 37
. . VAR PROXY local[0] (0x7fa7bf804a38) (mode = VAR, assigned = false) "z"

同样,我们将其图形化:

从图中可以看出,函数的字面量被解析为AST树的形态,这个函数主要拆分成四部分。
第一部分为参数的声明(PARAMS)
第二部分是变量声明节点(DECLS)
第三部分是x+y的表达式节点,我们可以看到,节点add下面使用了var proxy x和varproxy x的语法,它们指向了实际x和y的值。
第四部分是RETURN节点,它指向了z的值,在这里是local[0]。
V8在生成AST的同时,还生成了add函数的作用域,可以用-print-scopes命令来查看:

Global scope:
function add (x, y) { // (0x7f9ed7849468) (12, 47)
    // will be compiled
    // 1 stack slots
    // local vars:
    VAR y; // (0x7f9ed7849790) parameter[1], never assigned
    VAR z; // (0x7f9ed7849838) local[0], never assigned
    VAR x; // (0x7f9ed78496e8) parameter[0], never assigned
}

作用域中的变量都是未使用的,默认值都是undefined,在执行阶段,作用域中的变量会指向堆和栈中相应的数据,作用域和实际数据的关系如下图所示:

在解析期间,所有函数体中声明的变量和函数参数,都被放进作用域中,如果是普通变量,那么默认值是undefined,如果是函数声明,那么将指向实际的函数对象。
一旦生成了作用域和AST,V8就可以依据它们来生成字节码了。AST之后会被作为输入传到字节码生成器(BytecodeGenerator)。

[generated bytecode for function: add (0x079e0824fdc1 <SharedFunctionInfo add>)]
Parameter count 3
Register count 2
Frame size 16
        0x79e0824ff7a @  0 : a7         StackCheck
        0x79e0824ff7b @  1 : 25 02      Ldar a1
        0x79e0824ff7d @  3 : 34 03 00   Add a0, [0]
        0x79e0824ff80 @  6 : 26 fb      Star r0
        0x79e0824ff82 @  8 : 0c 02      LdaSmi [2]
        0x79e0824ff84 @  10 : 26 fa     Star r1
        0x79e0824ff86 @  12 : 25 fb     Ldar r0
        0x79e0824ff88 @  14 : ab        Return
Constant pool (size = 0)
Handler Table (size = 0)
Source Position Table (size = 0)

我们可以看到,生成的字节码第一行提示了“Parameter count 3”,这是告诉我们这里有三个参数,包括了显式地传入了x 和 y,还有一个隐式地传入了this。下面是字节码的详细信息:

StackCheck
Ldar a1
Add a0, [0]
Star r0
LdaSmi [2]
Star r1
Ldar r0
Return

理解字节码:

解释器的架构设计通俗地讲,你可以把这一行行字节码看成是一个个积木块,每个积木块块负责实现特定的功能,有实现运算的,有实现跳转的,有实现返回的,有实现内存读取的。一段JavaScript代码最终被V8还原成一个个积木块,将这些积木搭建在一起就实现了JavaScript的功能,现在我们大致了解了字节码就是一些基础的功能模块,接下来我们就来认识下这些构建块。


解释器执行时主要有四个模块,内存中的字节码、寄存器、栈、堆。

完整分析一段字节码

StackCheck
Ldar a1
Add a0, [0]
Star r0
LdaSmi [2]
Star r1
Ldar r0
Return

执行这段代码时,整体的状态如下图所示:

参数对象parameter保存在栈中,包含了a0和a1两个值,在上面的代码中,这两个值分别是1和2;
PC寄存器指向了第一个字节码StackCheck,我们知道,V8在执行一个函数之前,会判断栈是否会溢出,这里的StackCheck字节码指令就是检查栈是否达到了溢出的上限,如果栈增长超过某个阈值,我们将中止该函数的执行并抛出一个RangeError,表示栈已溢出。
然后继续执行下一条字节码,Ldar a1,这是将a1寄存器中的参数值加载到累加器中,这时候第一个参数就保存到累加器中了。
接下来执行加法操作,Add a0, [0],因为a0是第一个寄存器,存放了第一个参数,Add a0就是将第一个寄存器中的值和累加器中的值相加。并将结果保存在累加器中。
现在累加器中就保存了相加后的结果,然后执行第四段字节码,Star r0,这是将累加器中的值,也就是1+2的结果3保存到寄存器r0中,那么现在寄存器r0中的值就是3了。
然后将常数2加载到累加器中,又将累加器中的2加载到寄存器r1中,我们发现这里两段代码可能没实际的用途,不过V8生成的字节码就是这样。
接下来V8将寄存器r0中的值加载到累加器中,然后执行最后一句Return指令,Return指令会中断当前函数的执行,并将累加器中的值作为返回值。
这样V8就执行完成了add函数。

此文章为5月Day20学习笔记,内容来源于极客时间《图解 Google V8》,日拱一卒,每天进步一点点💪💪

豪猪
4 声望4 粉丝

undefined