笔记|v8编译-字节码解释器

1,862 阅读7分钟

前言

文章主要参考v8.dev/docs ,谷歌官方文档。

解释型与编译型语言

解释型语言

JavaScript属于解释性语言,也可以说是脚本语言,动态语言。我们写的源代码(高级语言),可以在任何含有其解释器(或虚拟机)的机器上执行,在使用者看来,不需提前编译。比如JS运行在浏览器,shell脚本运行在shell对话框。

编译型语言

编译型语言的区别在于,需要人为手动的提前编译我们写的代码,输出的代码是操作系统可以直接运行的。比如C,C++

小结

我们常说的解释型还是编译型,仅仅是对使用者是否需要手动编译的判断简单、粗暴的描述。js不需要手动编译,即解释型,c需要手动,即编译型。这样描述,不够具体也不够形象。比如Java,我们一般说他是编译型,因为需要提前javac编译,但编译出来的字节码并不能被操作系统直接执行,需要Jvm虚拟机来解释执行。

编译型的话会把源代码直接生成为机器码,保存在磁盘中,执行的时候读入内存并可以反复使用;解释型则是把源代码,或中间码逐条解释,执行时在内存中保存的是中间码。

当然JS是解释性语言,而js的虚拟机就是V8

回顾V8历史

远古时期

  • 2008年V8chrom一起推向市场。据V8团队自己说:同比当时的竞争对手,V8的速度可以快上10倍;而今天(当时2017年),比2008的时候要快上10倍。因为当时V8将JS源码直接编译(full codegen)的,并读入内存,一步到位,简单粗暴。所以首次运行速度,以及后续的执行速度都很快,但同时最大的问题也是内存占用太大。

优化编译器

  • 2010年,推出优化编译器Chrankshaft,在ATS树编译成机器码的过程中,Chrankshaft会生成一种中间码,无关底层机器架构(但不是字节码),然后基于分析这种中间码进行优化与反优化。在执行的过程中减少了CPU的编译压力。
  • 2015年,推出另一种优化编译器TurboFun,用来拟补Chrankshaft的不足,因为后者不支持新的js语法(比如async/await)。并且优化了底层代码,提高了编译性能。

解释器与字节码

  • 2016年,这一年终于推出了解释器和字节码。以较小的执行速度点代价,从根本上解决了内存占用大的问题。V8团队文档中也说明,解释器的推出主要的三个目标:
    1. 减少内存大的问题(字节码远小于机器码)
    2. 更快的启动速度
    3. 加入字节码,分层简化了V8架构。
  • 2017年,彻底移除了服役7年之久的Chrankshaft编译器(之前为了兼容老架构)。最终以字节码解释器(Ignation)+编译优化器(Turbofun)的架构到今天。

image.png

编译过程

先来一段js源码,从头看

var a = 1;
var b = 2;
var c = a + b;

解析(parse)

1. 词法分析

拆分语句,形成key-value形式的数组

[
    // 只展示 var c = a + b
    {
        "type": "Keyword",
        "value": "var"
    },
    {
        "type": "Identifier",
        "value": "c"
    },
    {
        "type": "Punctuator",
        "value": "="
    },
    {
        "type": "Identifier",
        "value": "a"
    },
    {
        "type": "Punctuator",
        "value": "+"
    },
    {
        "type": "Identifier",
        "value": "b"
    },
]

2. 语法分析

将没有没有语言结构的一维数组形式,转换成具有一定语法的树形结构。供后面遍历获取字节码做准备。

{
  "body": [
    // 只展示 var c = a + b
    {
      "type": "VariableDeclaration",
      "declarations": [
        {
          "type": "VariableDeclarator",
          "id": {
            "type": "Identifier",
            "name": "c"
          },
          "init": {
            "type": "BinaryExpression",
            "operator": "+",
            "left": {
              "type": "Identifier",
              "name": "a"
            },
            "right": {
              "type": "Identifier",
              "name": "b"
            }
          }
        }
      ],
      "kind": "var"
    }
  ],
}

生成字节码

1. 字节码与机器码

利用node --print-bytecode test.js > bytecode,我们到bytecode.js中查看字节码:

image.png

利用node --print test.js > code,我们到code.js中查看字节码: image.png

可以简单的比较一下:

  1. 字节码比机器码所占内存小,这也是引入字节码的很大一个原因
  2. 机器码直面操作系统,比字节码更加抽象,强关联底层的机器CPU架构,比如(AMDX86,balabala),所以迁移性差;而字节码做了一层封装,屏蔽了底层机器的影响,面向的是V8的编译器TurboFun

2.生成总览

我们先整体的过一下字节码的生成过程

void BytecodeGenerator::VisitAddExpression(
    BinaryOperation* expr) {
  Register lhs = 
      VisitForRegisterValue(expr->left());
  VisitForAccumulatorValue(expr->right());
  builder()->AddOperation(lhs);
}

我们来分析一下,其实很简单:就是用后序遍历遍历ATS的操作二叉树(左值和寄存器相关,右值和累加器相关)然后根据每个节点输出对应的字节码。我们以上面的字节码为例,来说明生成过程。

  1. 首先访问ATS树的左节点a,并将的值1放入累加器,对应输出:LdaSmi.Wide [1]
  2. 将累加器中的值拿出来放到寄存器中,对应Star r0
  3. 访问右节点,重复1,2两个步骤
  4. 此时寄存器的r0,r1位置就保存了12的这两个值,之后开始累加
  5. r1寄存器的值放入累加器,对应Ldar r1
  6. r0的寄存器上的值与累加器上的值相加,此时累加器上的值为3
  7. 最后拿出累加器上的值存在寄存器r266579c9e-4883-30ce-bcec-c5b15190c488.gif

图片来源:www.iteye.com/blog/rednax…

3. 生成过程

下面我们仔细梳理一下,字节码生成过程主要做了什么。主要参考

寄存器的分配

我们可以看到,在字节码的每个函数中的最上面几行会标注当前函数需要的寄存器数量,Register count 3。比如我们上面的字节码中,申请了r0,r1,r2,用来保存表达式求值中的临时变量和函数内的局部变量。另外如果存在闭包,且闭包中使用了外层函数的变量,就会为Context对象分配寄存器空间。 image.png

Context链 下面我们就来仔细说说作用域链。

解释执行

生成字节码,不能被直接执行,需要Iterpreter处理。

1. Ignation

先来介绍一下V8的解释器,代号:Ignation。官方文档给的介绍:基于寄存器的,间接线性调度解释器。

先来解释基于寄存器:

解释器一般分为两种,基于栈结构的(JVM),基于寄存器结构的。栈结构的在执行指令的时候会讲操作数对特定的操作栈数组进行push,pop,所以不需要在指令后面跟上操作数的执行地址(零地址指令);而基于寄存器的则是需要在操作数后面跟上地址,比如 Star r0将累加器的值拿出存入寄存器r0

// java 的bytecode,基于栈结构的解释器
iconst_1  
iconst_2  
iadd  
istore_0  

// js 的bytecode,基于寄存器的解释器
LdaSmi [1]
Star r0
PushContext r1

再说说间接线性调度:

首先是间接,即不是直接的,连续的解释执行,而是执行完一个字节码解释器,在解释器函数内部尾调用下一个字节码执行函数,一个接一个。线性的意思我估计是每一个字节码指令,都线性的执行一个字节码处理程序。

image.png 图片来源

void Interpreter::DoAdd(InterpreterAssembler* assembler) {
  Node* reg_index = assembler->BytecodeOperandReg(0);
  Node* lhs = assembler->LoadRegister(reg_index);
  Node* rhs = assembler->GetAccumulator();
  Node* result = AddStub::Generate(assembler, lhs, rhs);
  assembler->SetAccumulator(result);
  // 尾部调用下一个字节码
  assembler->Dispatch();
}

2. 解释器的栈帧布局:

在解释执行时,内置的 InterpreterEntryTrampoline stub 会创建一个合适的栈帧。

首先是编译器Furbofun环境下的一些参数:调用者PC、帧指针、JSFunction、Context、字节码数组、字节码偏移

其次为字节码函数中需要的 寄存器分配空间。为之后字节码的解释执行使用。

image.png 图片来源

总结

至此,讲完了v8的编译关于解释器的一块,至于Turbofun编译优化部分下篇再讲