Berry 指令设计

Berry 脚本源代码需要被编译为字节码指令流才能被 Berry 虚拟机执行。本文将详细地讲解 Berry 字节码指令(下面简称指令)的设计和实现。为了达到这个目的,本文由 3 部分构成:第 1 小节会描述指令的组成,以便让读者了解 Berry 字节码的二进制编码方式;第 2 小节简述在脚本的执行过程中,字节码如何生成并被执行;第 3 小节则通过对 Berry 源代码的分析说明字节码指令如何编码以及解码。解释器源码中对指令的定义可以参考 be_opcode.h 文件。

指令的构成

一条指令占用 4 个字节,也就是一个 32 位的整数,准确地说应该是 32 位的无符号整数。一条指令由操作码(Operation Code)和若干操作数构成,不同操作码的指令可以有不同的操作数成分。任何情况下,指令可能是以下几种情况:

Mode 1 Mode 2 Mode 3 Mode 4 Mode 5
OP: 6 bits OP: 6 bits OP: 6 bits OP: 6 bits OP: 6 bits
A: 8 bits A: 8 bits A: 8 bits sAx: 26 bits Ax: 26 bits
B: 9 bits sBX: 18 bits Bx: 18 bits --- ---
C: 9 bits --- --- --- ---

其中模式 1 和模式 2 比较常用,模式 4 和模式 5 目前没有用到。在所有几种模式中,一条指令的 32 位被分为不同的字段,例如模式 1 中被分为 6 位的 OP 字段,8 位的操作数 A 字段,9 位的操作数 B 字段以及 9 位的操作数 C 字段。

每种字段都有一个名字,以下是各字段名称的含义:

字段 Bits 说明
OP 31:26 操作码(下面简称 opcode 或者 OP),最多可容纳 64 种操作码
A 25:18 无符号操作数 A,一般用于表示寄存器的索引,取值范围为 0 ~ 255
B 17:9 无符号操作数 B,一般用于表示寄存器或者常量的索引,取值范围为 0 ~ 511
C 8:0 无符号操作数 C,一般用于表示寄存器或者常量的索引,取值范围为 0 ~ 511
sBx 17:0 有符号操作数 sBx,取值范围为 -13072 ~ 13071
Bx 17:0 无符号操作数 Bx,取值范围为 0 ~ 262143
sAx 25:0 有符号操作数 sAx,取值范围为 -33554432 ~ 33554431
Ax 25:0 无符号操作数 Ax,取值范围为 0 ~ 67108863

注意:指令字段并不是任意组合的,只能按上面表中的模式 1 到模式 5 中的 5 种方式组合。

BC 指令具有 9 bit 的位宽,这个特性使得它们可以用来索引所有的寄存器(Berry 虚拟机中最多有 256 个寄存器)和前 256 个常量,这在一些操作中非常实用。

现在我们通过一个简单的例子来说明指令的二进码:假设一条指令的 opcode 为 12,操作数 A 为 50,操作数 B 为 32,操作数 C 为 1,则该指令的编码计算过程为:

ins = (12 << 26) | (50 << 18) | (32 << 9) | 1
    = 0x30C84001
    = 00000001,11011000,11000111,01000001 (bin)

该指令的操作数组合是 ABC,因此它是一个模式 1 指令。这种模式非常常见,例如所有的二元运算(binary operation)指令都是模式 1 指令。

后面,我们将使用下列方式来描述一条完整的指令:

OP A B C  ; 表示 Mode 1 指令
OP A sBx  ; 表示 Mode 2 指令
OP A Bx   ; 表示 Mode 3 指令

OP 可以用具体的操作码来代替,例如加法的 ADD 操作码。在说明了指令格式的前提下,也可以使用实际值带入 ABC 等操作数。由于 Mode 4 和 Mode 5 几乎没有实际使用,我们也没有必要说明 😃。

解释器如何使用指令 / 字节码

Berry 解释器可以使用文本格式的源文件作为输入,并执行由源文件所给出的“程序”。既然 Berry 是一种“解释型”语言(更准确地说应该是“动态”语言),那是不是意味着 Berry 解释器会一行一行地读取源文件并立即解释执行这些代码呢?答案是不会,事实上,解释器在读取到文本形式的源文件之后,会将其编译为一种称为字节码的二进制形式,然后由 Berry 虚拟机执行。这个过程类似于使用 C 编译器编译 C 源文件为二进制的可执行文件,然后由物理机器来执行程序。

字节码的设计

与直接将源代码编译为机器码的静态语言不同的是,Berry 虚拟机比真实机器更加抽象,例如它没有数据类型和位宽的概念,例如一个整数或一个字符串对于 Berry 虚拟机来说都是一个值,它们具有完全平等的操作地位,而真实机器上通常不是这样。Berry 虚拟机这种高级的抽象特性使得字节码的设计能够比真实机器的指令集简化很多。很明显,除了条件跳转、算术运算等真实机器同样具备的指令,我们完全可以为 Berry 设计功能十分复杂的指令,例如异常处理指令。为动态语言设计功能复杂的指令实际上有很多好处:

  • 复杂性集中在运行时的指令实现上,编译器的代码生成工作得以简化
  • 减少指令数量,复杂的操作由 C 运行时来实现,提高时间、空间效率
  • 提高指令功能的抽象层次有利于理解设计,降低开发难度

Berry 指令的设计思路是根据早期的语言文法来得到一个大致的执行流,再通过这些执行流来规划所需的指令。由于 Berry 使用寄存器式的虚拟机,指令数量会显著少于堆栈式虚拟机(而寄存器式虚拟机的代码生成难度稍大)。指令的设计围绕以下语言元素来展开:

  • 表达式,常见的有二元表达式和一元表达式
  • 控制流,例如 if 语句 while 语句等
  • 变量定义语句、函数定义等

基于表达式的指令设计

到目前为止,大部分的指令用于实现表达式的求值,而表达式则主要分为一元表达式和二元表达式。一元表达式具有一个操作数和一个返回值,因此指令设计中需要用到一个源索引和一个目的索引;二元表达式需要两个操作数和一个返回值,因此它们的指令需要两个源索引和一个目标。针对这种情况,我们可以这样设计指令:

UNOP    A B
BINOP   A B C

A 操作数为目的寄存器的索引,BC 操作数为源寄存器的索引。由于 BC 操作数可以索引所有寄存器以及前 256 个常量,而运算结果只能存储在寄存器中,A 操作数的索引范围也能满足需求。

基于控制流的指令设计

常见的控制流包括分支语句和迭代语句,这些语句的实现需要一系列的跳转指令。此外,布尔表达式有短路求值特性(某些情况下只求得某一项的值时便得到整个表达式的值),因此也需要跳转语句在确定了布尔表达式的值时跳过后续的项。我们把所有需要使用跳转语句的语法特征概括为控制流(布尔表达式和 if 语句的编译特性非常相似)。

实际上,只需要 3 种跳转指令即可以实现所有的控制流:无条件跳转、条件为真时跳转、条件为假时跳转。当然,只使用一种条件跳转指令(真跳转或者假跳转)也可以在配合其他指令的情况下实现所有控制流,但考虑实现代价并不划算。

这是 3 种跳转指令的定义:

JMP         sBx  ; 无条件跳转指令
JMPT    A   sBx  ; 条件为真时跳转
JMPF    A   sBx  ; 条件为假时跳转

三种相对跳转指令都是相对跳转,跳转偏移值为 sBx。对于条件跳转指令,A 操作数为待测试条件值的寄存器索引。

函数相关指令

对象生成相关指令

指令的编解码

由于一条指令总是被封装为 32 位的整数,为了生成这些指令并由 VM 执行,必须进行指令的编码和解码。be_opcode.h 文件中给出了一些用于指令编解码的宏定义。

文件头部的宏给出了每个字段的长度以及偏移:

/* define bits */
#define IOP_BITS                6u
...
#define IBx_BITS                (IRKC_BITS + IRKB_BITS)

这些宏的定义和前面表中给出的位宽及偏移值一致,这里不再复述。接下来的几个宏定用于定义字段的基本操作:

#define INS_MASK(pos, bits)     ((binstruction)((1 << (bits)) - 1) << (pos))
#define INS_GETx(i, mask, pos)  cast_int(((binstruction)(i) & (mask)) >> (pos))
#define INS_SETx(v, mask, pos)  (((binstruction)(v) << (pos)) & (mask))

INS_MASK 宏用于生成一个字段掩码,字段偏移为 pos,字段位宽为 bits。以 B 字段为例,其偏移值为 9,位宽为 9,则其掩码为:

IRKB_MASK = INS_MASK(9, 9)
          = ((1 << 9) - 1) << 9
          = 0x0003FE00

INS_GETx 用与获取某个字段的值,参数 i 为输入的指令,mask 为字段掩码,pos 为字段偏移。例如,从一条指令 ins 中读取 B 字段的值:

value = INS_GETx(ins, IRKB_MASK, 9)

INS_SETx 用于将某个值填充到指定的字段上,参数 v 为要填充的值,mask 为字段掩码,pos 为字段偏移。例如将值 24 填充到 B 字段:

result = INS_SETx(24, IRKB_MASK, 9)
       = 0x00003000

接下来的几个宏用于区分 BC 操作数是一个寄存器索引还是一个常量索引:

#define isK(v)                  (((v) & (1 << (IRKB_BITS - 1))) != 0)
#define setK(v)                 ((v) | (1 << (IRKB_BITS - 1)))
#define KR2idx(v)               ((v) & 0xFF)
#define isKB(v)                 (((v) & (1 << (IRA_POS - 1))) != 0)
#define isKC(v)                 (((v) & (1 << (IRKB_POS - 1))) != 0)
说明
isK 检测某个操作数(BC)作为索引时是寄存器索引还是常量索引。原理是检测操作数的最高位(bit8)的值,值为 0 时表示该操作数为寄存器索引,否则为常量索引。
setK 将个操作数(BC)设置为常量索引,也就是把该操作数的最高位标记为 1。
KR2idx 获取寄存器索引或者常量索引的索引值,也就是取低 8 位。
isKB 用于检测指令中的 B 操作数是否为常量索引。
isKC 用于检测指令中的 C 操作数是否为常量索引。

下面一组宏定义了一些掩码值:

#define IOP_MASK                INS_MASK(IOP_POS, IOP_BITS)
// ...
#define IsBx_MAX                cast_int(IBx_MASK >> 1)
#define IsBx_MIN                cast_int(-IsBx_MAX - 1)

类似 IOP_MASK 的宏定义了指令各个字段的掩码,只在后面的字段编解码宏中使用。而 IsBx_MAXIsBx_MIN 分别定义了 sBx 字段能表示的最大值和最小值,在很多使用 sBx 字段的场合都会用到。

字段编解码

用于字段编解码宏定义方式为为:

/* get field */
#define IGET_OP(i)              cast(bopcode, INS_GETx(i, IOP_MASK, IOP_POS))
// ...
/* set field */
#define ISET_OP(i)              INS_SETx(i, IOP_MASK, IOP_POS)
// ...

下表给出了每个宏的功能:

说明 说明
IGET_OP 获取 OP 字段的值 ISET_OP 设置 OP 字段的值
IGET_RA 获取 A 字段的值 ISET_RA 设置 A 字段的值
IGET_RKB 获取 B 字段的值 ISET_RKB 设置 B 字段的值
IGET_RKC 获取 C 字段的值 ISET_RKC 设置 C 字段的值
IGET_Bx 获取 Bx 字段的值 ISET_Bx 设置 Bx 字段的值
IGET_sBx 获取 sBx 字段的值 ISET_sBx 设置 sBx 字段的值
posted @ 2019-10-30 22:23  Skiars  阅读(611)  评论(0)    收藏  举报