美文网首页
Hermes字节码的设计

Hermes字节码的设计

作者: FingerStyle | 来源:发表于2021-08-29 19:20 被阅读0次

以下内容大部分翻译自官方文档(https://hermesengine.dev/docs/design),另外加了些自己的理解

一、字节码指令

Hermes采用了变长的指令,每一个指令都有固定的类型和宽度,被操作码所确定。例如Jmp指令是1个字节,而JmpLong指令是4个字节(一般的汇编指令由于不区分操作数类型,所以指令长度会变化,导致解码效率不高)。Hermes 指令是固定宽度的,这样在解码的时候会更高效,但增加了指令的个数。而我们为了避免指令爆炸,做了自动生成代码这事。 全部指令可以在 BytecodeList.def中查阅,这里有一些比较有趣的设计:

  1. 寄存器
    很少有指令使用的寄存器数量超过256个,所以可以用一个字节(2的8次方)来代表寄存器的序号,这适用于大多数情况。
  2. 常量
    对于像true,、false、 undefined、 null 这种有固定值的常量来说,我们引入了一些特殊的读取指令(例如LoadConstUndefined)。对于32位整数,通过LoadConstInt指令直接读取4个字节的整数值并加载到寄存器中。对于64位的双精度浮点数,可以通过LoadConstDouble来读取。而对于字符串来说,可以通过LoadConstString加上字符串表中的序号来读取对应的字符串。这样可以减少字节码的大小,不过增加的指令对解释器的效率会有一些影响。
  3. 非局部变量的读写
    局部变量是可以直接转移到寄存器的,但是对于非局部变量而言,则会涉及到作用域。一般情况下不做预编译的话,JS引擎在运行时会沿着作用域链查找离变量最近的作用域(逐层在作用域中搜寻该变量名)。如果有做预编译,则可以在编译时就决定每个变量的作用域,这样就避免了在运行时再去查找。在Hemes的编译后端,我们使用 delta (数学上表示微小的偏移)来表示变量定义时的作用域与当前作用域之间的层数差异,并通过这个来定位作用域。在这基础上,由于我们在编译时已经知道了每个作用域下面有多少个变量,因此还可以直接通过变量在该作用域中的序号来定位他,而不是通过变量名(Symbol)去查找。这样我们同时省略了作用域查找(知道在哪个作用域)和符号查找(知道是作用域中的第几个)两个过程,运行效率有很大的提升。

二、字节码文件的格式

字节码文件包含了字节码执行所必须的元数据和辅助的数据分区,结构如下(定义在BytecodeFileFormat.h中):

  1. 文件头
    文件头包含了魔数、当前格式的版本,以及诸如文件大小、函数头表的偏移值、字符串表的偏移值、全局代码的序号、函数个数这样的元数据。

2.函数头表(FUNCTION HEADER TABLE)
即函数头的列表,每个函数头都包含该函数的元数据,例如对应的函数字节码在文件中的偏移量、参数个数、字节码的长度等。每个函数都分配了一个序号,以便于虚拟机能更方便的读取到该函数。

  1. 字符串表和存储
    所有函数中使用的字符串都会存储在这个表里面,并且是唯一的以避免重复。这个分区里面包含了两部分数据:
    1) 裸的字符数据
    2) 一个列表。列表里每个元素都是一个二元结构,分别是字符串的偏移量,以及该字符串的长度,这个二元结构可以代表一个字符串。
    此外,每个字符串也都分配了一个序号,以便于虚拟机更方便的读取。

  2. 函数字节码
    这是字节码文件中最核心的部分,包含了一系列编译好的函数体。这些函数体包含了函数的可执行指令,以及这些指令用到的表,包括异常处理的表(发生异常时要跳转到哪里)、数组的缓冲区(用于初始化数组)等,以后可能还会加入正则表达式和调试信息之类的表。

三、序列化与反序列化

由于Hermes与虚拟机(真正执行JS代码的地方)是基于同样的编译后端代码,因此这部分代码是有机会共享的。具体来说,我们想要在编译后端(编译为字节码的阶段)序列化生成的目标对象结构可以直接被虚拟机反序列化。这就引入了两个问题:

  1. 怎样去共享这些数据,或者说代码,而不用在两个端之间进行过多的链接?
  2. 怎么去避免序列化和反序列化过程中的拷贝?

我们引入了两个设计理念来解决代码共享的问题:

  1. 生成器
    在序列化过程中,我们需要很多的额外数据和函数来纠正这一过程,虽然最后很多数据是不需要的。为了降低复杂度,并且在反序列化时能更高效的利用共享的数据或代码,我们使用了字节码模块生成器和一系列的字节码函数生成器。在这些生成器处理过后,他们最终会生成字节码模块和字节码函数,这些字节码包含了用于生成字节码文件所需要的最少的数据和函数。因此我们可以在编译后端和虚拟机之间共享这些少量的数据结构。
  2. 流向量(StreamVector)
    在序列化过程中,我们需要从生成器移动或者拷贝一部分额外的数据到共享的数据结构中。在反序列化过程中,我们又需要从文件中读出这部分数据结构。两个过程如果管理不好的话,都是代价很大的。为了尽可能的减少数据拷贝,我们引入了一个叫流向量的类。在序列化过程中,流向量允许我们从生成器中不经过拷贝(通过std::vector::swap)把数据移动出去。在反序列化过程中,流向量允许我们从文件中直接获取裸数据指针,并指向内存缓冲区,这同样也不用拷贝数据。

四、与虚拟机(VM)的交互

在运行阶段,VM会从文件中读出字节码反序列化并执行他。这里会涉及到几个组件:

  • 字节码模块(ByteCodeModule):这是整个字节码文件在内存中的表示,包含了所有的字节码函数。在序列化和反序列化过程中,这个数据结构会被生成,用来表示整个字节码文件。
  • 字节码函数(ByteCodeFunction):这是函数的字节码在内存中的表示
  • 运行时模块(RuntimeModule):这是字节码模块的动态版本,包含了解释字节码所必须的运行时信息
  • 代码块(CodeBlock): 这是字节码函数的动态版本,包含了执行函数所必须的运行时信息
  • 域(Domain):这是垃圾回收托管的中介,作为GC 堆和C++堆的桥梁,引用了一系列的运行时模块
  • JS函数(JSFunction):这是Javascript的函数对象

如何在运行时有效的管理这些对象的内存和所属关系是非常重要,也是很讲究技巧的。下面会通过例子来解释他。持有关系(Own)是指一个对象通过唯一指针(unique_ptr)管理另一个对象的内存,指向关系(Pointer)意味着一个对象有一个指向另一个对象的指针, 而没有持有关系。非直接持有是一种特殊的持有关系,这个稍后会解释。总结起来就是:

  • JS函数是一个Javascript对象,因此被堆或者垃圾收集器直接管理。
  • JS函数持有一个对垃圾回收器可见的指向某一个域的引用,以及一个指向对应的可执行代码块的指针
  • 代码块既包含了一个指向运行时模块的指针,以获取运行时信息;又包含了指向对应的字节码函数的指针,这个字节码函数内部有字节码运行所需要的静态信息
  • 字节码模块持有一系列的字节码函数
  • 运行时模块持有了一系列的代码块,以及对应的字节码模块
  • 域持有了一个或多个运行时模块,通过这种机制一个外部的JS函数可以使备份的字节码存活。

相关文章

网友评论

      本文标题:Hermes字节码的设计

      本文链接:https://www.haomeiwen.com/subject/bgtgiltx.html