查看源代码 beam_makeops 脚本

本文档介绍了 beam_makeops 脚本。

简介

beam_makeops Perl 脚本在编译时由编译器和运行时系统使用。给定一些输入文件(所有文件都带有 .tab 扩展名),它将生成 Erlang 编译器和运行时系统用来加载和执行 BEAM 指令的源文件。

本质上,这些 .tab 文件定义了

  • 外部通用 BEAM 指令。它们是编译器和运行时系统都知道的指令。通用指令在版本之间是稳定的。在主要版本中可以添加编号高于先前指令的新通用指令。OTP 20 版本有 159 个外部通用指令。

  • 内部通用指令。它们仅为运行时系统所知,并且可以随时更改,而不会出现兼容性问题。它们由转换规则(如下所述)创建。

  • 将一个或多个通用指令转换为其他通用指令的规则。转换规则允许组合、拆分和删除指令,以及打乱操作数。由于转换规则,运行时可以有许多仅为运行时系统所知的内部通用指令。

  • 特定 BEAM 指令。特定指令是运行时系统实际执行的指令。它们可以随时更改,而不会导致兼容性问题。加载器将通用指令转换为特定指令。通常,对于每个通用指令,都存在一系列特定指令。OTP 20 版本有 389 个特定指令。

  • 传统 BEAM 解释器的特定指令实现。对于 OTP 24 中引入的 BeamAsm JIT,指令的实现定义在 C++ 编写的发射器函数中。

通用指令具有类型化的操作数。以下是 move/2 的一些操作数示例

{move,{atom,id},{x,5}}.
{move,{x,3},{x,0}}.
{move,{x,2},{y,1}}.

当这些指令被加载时,加载器会将它们重写为特定指令

move_cx id 5
move_xx 3 0
move_xy 2 1

对于每个通用指令,都存在一系列特定指令。特定指令的实例可以处理的类型编码在指令名称中。例如,move_xy 将 X 寄存器号作为第一个操作数,将 Y 寄存器号作为第二个操作数。move_cx 将带标记的 Erlang 术语作为第一个操作数,将 X 寄存器号作为第二个操作数。

一个示例:move 指令

move 指令为例,我们将快速浏览一下 beam_makeops 的主要功能。

compiler 应用程序中,在 genop.tab 文件中,有以下行

64: move/2

这是外部通用 BEAM 指令的定义。最重要的是,它指定操作码为 64。它还定义了它有两个操作数。BEAM 汇编器在创建 .beam 文件时将使用该操作码。编译器实际上不需要元数,但是它会在汇编 BEAM 代码时将其用作内部健全性检查。

让我们看一下 erts/emulator/beam/emu 中的 ops.tab,其中定义了特定的 move 指令。以下是其中的一些

move x x
move x y
move c x

每个特定指令都是通过在指令名称后跟每个操作数的类型来定义的。操作数类型是单个字母。例如,x 表示 X 寄存器,y 表示 Y 寄存器,c 表示“常量”(带标记的术语,例如整数、原子或文字)。

现在让我们看一下 move 指令的实现。在 erts/emulator/beam/emu 目录中有多个包含指令实现的文件。move 指令在 instrs.tab 中定义。它看起来像这样

move(Src, Dst) {
    $Dst = $Src;
}

指令的实现很大程度上遵循 C 语法,但函数头中的变量没有任何类型。标识符之前的 $ 表示宏展开。因此,$Src 将扩展为获取指令的源操作数的代码,而 $Dst 将扩展为获取目标寄存器的代码。

我们将依次查看每个特定指令的代码。为了使代码更容易理解,我们首先看一下指令 {move,{atom,id},{x,5}} 的内存布局

     +--------------------+--------------------+
I -> |                 40 |       &&lb_move_cx |
     +--------------------+--------------------+
     |                        Tagged atom 'id' |
     +--------------------+--------------------+

本文档中的此示例和所有其他示例都假设为 64 位体系结构,并且 C 代码的指针适合 32 位。

BEAM 虚拟机中的 I 是指令指针。当 BEAM 执行指令时,I 指向指令的第一个字。

&&lb_move_cx 是实现 move_cx 的 C 代码的地址。它存储在字的较低 32 位中。在较高的 32 位中是 X 寄存器的字节偏移量;寄存器号 5 已乘以字大小 8。

在下一个字中,存储了带标记的原子 id

有了这个背景,我们可以看一下 beam_hot.h 中为 move_cx 生成的代码

OpCase(move_cx):
{
  BeamInstr next_pf = BeamCodeAddr(I[2]);
  xb(BeamExtraData(I[0])) = I[1];
  I += 2;
  ASSERT(VALID_INSTR(next_pf));
  GotoPF(next_pf);
}

我们将依次浏览每一行。

  • OpCase(move_cx): 定义了指令的标签。OpCase() 宏在 beam_emu.c 中定义。它会将这行展开为 lb_move_cx:

  • BeamInstr next_pf = BeamCodeAddr(I[2]); 获取要执行的下一条指令的代码指针。BeamCodeAddr() 宏从指令字的较低 32 位中提取指针。

  • xb(BeamExtraData(I[0])) = I[1];$Dst = $Src 的展开。BeamExtraData() 是一个宏,它将从指令字中提取较高的 32 位。在此示例中,它将返回 40,它是 X 寄存器 5 的字节偏移量。xb() 宏将字节指针转换为 Eterm 指针并取消引用它。= 右侧的 I[1] 获取一个 Erlang 术语(在本例中为原子 id)。

  • I += 2 将指令指针前进到下一条指令。

  • 在调试编译的模拟器中,ASSERT(VALID_INSTR(next_pf)); 确保 next_pf 是有效的指令(也就是说,它指向 beam_emu.c 中的 process_main() 函数内)。

  • GotoPF(next_pf); 将控制权转移到下一条指令。

现在让我们看一下 move_xx 的实现

OpCase(move_xx):
{
  Eterm tmp_packed1 = BeamExtraData(I[0]);
  BeamInstr next_pf = BeamCodeAddr(I[1]);
  xb((tmp_packed1>>BEAM_TIGHT_SHIFT)) = xb(tmp_packed1&BEAM_TIGHT_MASK);
  I += 1;
  ASSERT(VALID_INSTR(next_pf));
  GotoPF(next_pf);
}

我们将浏览与 move_cx 相比是新的或已更改的行。

  • Eterm tmp_packed1 = BeamExtraData(I[0]); 获取打包到指令字较高 32 位中的两个 X 寄存器号。

  • BeamInstr next_pf = BeamCodeAddr(I[1]); 预先获取下一条指令的地址。请注意,由于两个 X 寄存器操作数都适合指令字,因此下一条指令就在紧挨着的下一个字中。

  • xb((tmp_packed1>>BEAM_TIGHT_SHIFT)) = xb(tmp_packed1&BEAM_TIGHT_MASK); 将源复制到目标。(对于 64 位体系结构,BEAM_TIGHT_SHIFT 为 16,BEAM_TIGHT_MASK0xFFFF。)

  • I += 1; 将指令指针前进到下一条指令。

move_xymove_xx 几乎相同。唯一的区别是使用 yb() 宏而不是 xb() 来引用目标寄存器

OpCase(move_xy):
{
  Eterm tmp_packed1 = BeamExtraData(I[0]);
  BeamInstr next_pf = BeamCodeAddr(I[1]);
  yb((tmp_packed1>>BEAM_TIGHT_SHIFT)) = xb(tmp_packed1&BEAM_TIGHT_MASK);
  I += 1;
  ASSERT(VALID_INSTR(next_pf));
  GotoPF(next_pf);
}

转换规则

接下来,让我们看一下如何使用转换规则进行一些优化。对于像 move/2 这样的简单指令,指令调度开销可能相当大。一个简单的优化是将常见的指令序列组合为单个指令。一种常见的序列是将 X 寄存器移动到 Y 寄存器的多个 move 指令。

使用以下规则,我们可以将两个 move 指令组合为 move2 指令

move X1=x Y1=y | move X2=x Y2=y => move2 X1 Y1 X2 Y2

箭头的左侧 (=>) 是一个模式。如果模式匹配,则匹配的指令将被右侧的指令替换。模式中的变量必须以大写字母开头,就像在 Erlang 中一样。模式变量后面可以跟 = 和一个或多个类型字母,以将匹配限制为这些类型之一。在左侧绑定的变量可以在右侧使用。

我们还需要定义一个特定指令和一个实现

# In ops.tab
move2 x y x y

// In instrs.tab
move2(S1, D1, S2, D2) {
    Eterm V1, V2;
    V1 = $S1;
    V2 = $S2;
    $D1 = V1;
    $D2 = V2;
}

当加载器找到匹配项并替换匹配的指令时,它将针对转换规则匹配新指令。因此,我们可以定义 move3/6 指令的规则如下

move2 X1=x Y1=y X2=x Y2=y | move X3=x Y3=y =>
      move3 X1 Y1 X2 Y2 X3 Y3

(为了便于阅读,一个长转换行可以在 |=> 运算符之后断开。)

也可以像这样定义它

move X1=x Y1=y | move X2=x Y2=y | move X3=x Y3=y =>
     move3 X1 Y1 X2 Y2 X3 Y3

但如果那样,则必须在 move2/4 的规则之前定义它,因为将应用第一个匹配的规则。

必须注意不要创建无限循环。例如,如果出于某种原因我们要反转 move 指令的操作数顺序,则不能这样做

move Src Dst => move Dst Src

加载器将永远交换操作数。为了避免循环,我们必须重命名指令。例如

move Src Dst => assign Dst Src

这结束了 beam_makeops 功能的快速浏览。

解释器指令加载的简短概述

为了给本文档的其余部分提供一些背景信息,下面简要概述了指令的加载方式。

  • 加载器从 BEAM 代码中一次读取和解码一条指令,并创建一个通用指令。许多转换规则必须查看多个指令,因此加载器会将多个通用指令保存在链表中。

  • 加载器尝试对链接列表中的通用指令应用转换规则。如果规则匹配,则会移除匹配的指令,并用从转换的右侧构造的新通用指令替换。

  • 如果转换规则匹配,加载器会再次应用转换规则。

  • 如果没有转换规则匹配,加载器将开始将第一个通用指令重写为特定指令。

  • 首先,加载器会搜索所有操作数类型都与通用指令类型匹配的特定操作。会选择第一个匹配的指令。beam_makeops 已对特定指令进行排序,使得具有更具体操作数的指令位于具有不太具体操作数的指令之前。例如,move_nxmove_cx 更具体。如果第一个操作数是 [] (NIL),则会选择 move_nx

  • 给定选定的特定指令的操作码,加载器会查找指向该指令的 C 代码的指针,并将其存储在正在加载的模块的代码区域中。

  • 加载器会将每个操作数转换为机器字,并将其存储在代码区域中。所选特定指令的操作数类型会指导转换。例如,如果类型为 e,则操作数的值是一个外部函数数组的索引,将被转换为指向要调用的函数的导出条目的指针。如果类型为 x,则 X 寄存器的编号将乘以字大小以产生字节偏移。

  • 加载器运行打包引擎,将多个操作数打包到单个字中。打包引擎由一个小型程序控制,该程序是一个字符串,其中每个字符都是一条指令。例如,打包 move_xy 的操作数的代码是 "22#"(在 64 位机器上)。该程序会将两个寄存器的字节偏移打包到与 C 代码指针相同的字中。

BeamAsm 指令加载的简短概述

  • 选择特定指令之前的步骤与解释器描述的相同。特定指令的选择更简单,因为在 BeamAsm 中,大多数通用指令只有一个对应的特定指令。

  • 加载器会调用所选特定指令的发射器函数。发射器函数将指令转换为机器代码。

运行 beam_makeops

beam_makeops 位于 $ERL_TOP/erts/emulator/utils 中。选项以连字符 (-) 开头。选项后跟输入文件的名称。按照惯例,所有输入文件都具有扩展名 .tab,但 beam_makeops 不强制执行此操作。

-outdir 选项

选项 -outdir Directory 指定生成文件的输出目录。默认为当前工作目录。

为编译器运行 beam_makeops

给出选项 -compiler 以生成编译器的输出文件。以下文件将被写入输出目录

  • beam_opcodes.erl - 主要由 beam_asmbeam_diasm 使用。

  • beam_opcode.hrl - 由 beam_asm 使用。它包含用于编码指令操作数的标签定义。

输入文件应仅包含 BEAM_FORMAT_NUMBER 和外部通用指令的定义。(其他所有内容都将被忽略。)

为模拟器运行 beam_makeops

给出选项 -emulator 以生成模拟器的输出文件。以下输出文件将在输出目录中生成。

  • beam_opcodes.c - 定义加载器 (beam_load.c) 使用的静态数据,提供有关通用和特定指令以及所有转换规则的 C 代码的信息。

  • beam_opcodes.h - 杂项预处理器定义,主要由 beam_load.c 使用,但也由 beam_{hot,warm,cold}.h 使用。

对于传统的 BEAM 解释器,还会生成以下文件

  • beam_hot.hbeam_warm.hbeam_cold.h - 指令的实现。包含在 beam_emu.c 中的 process_main() 函数内。

对于 BeamAsm,还会生成以下文件

  • beamasm_emit.h - 调用发射器函数的粘合代码。

  • beamasm_protos.h - 所有发射器函数的原型。

可以给出以下选项

  • wordsize 32|64 - 定义字大小。默认为 32。

  • code-model Model - GCC 的 -mcmodel 选项给出的代码模型。默认为 unknown。如果代码模型是 small(并且字大小是 64 位),则 beam_makeops 会将操作数打包到指令字的较高 32 位中。

  • DSymbol=0|1 - 定义符号的值。该符号可以在 %if%unless 指令中使用。

.tab 文件的语法

注释

任何以 # 开头的行都是注释,将被忽略。

包含 // 的行也是注释。建议仅在定义指令实现的文件中使用这种样式的注释。

长转换行可以在 => 运算符之后和 | 运算符之后断开。自 OTP 25 以来,这是中断转换行的唯一方法。在读取较旧的源代码时,您可能会看到 \ 用于此目的,但我们将其删除,因为它仅与 =>| 一起出现。

变量定义

变量定义将变量绑定到 Perl 变量。只有在同时更新 beam_makeops 以使用该变量时,添加新定义才有意义。变量定义如下所示

name=value[;]

其中 namebeam_makeops 中 Perl 变量的名称,value 是要赋予该变量的值。该行可以选择以 ; 结尾(以避免在 Emacs 中搞乱 C 缩进模式)。

以下是对所定义变量的描述。

BEAM_FORMAT_NUMBER

genop.tab 具有以下定义

BEAM_FORMAT_NUMBER=0

它定义了指令集的版本(将包含在 BEAM 代码中的代码头中)。理论上,可以增加版本,并更改所有指令。在实践中,我们至少需要在运行时系统中支持两个版本的指令集,所以这在实践中可能永远不会发生。

GC_REGEXP

macros.tab 中,有一个 GC_REGEXP 的定义。将在后面的章节中描述。

FORBIDDEN_TYPES

asm/ops.tab 中,有一个指令禁止特定指令中的某些类型

FORBIDDEN_TYPES=hQ

特别是对于 BeamAsm,所有内置类型可能没有意义,因此 FORBIDDEN_TYPES 可以强制执行不应使用某些类型。

特定指令将在后面的章节中描述。

指令

有一些指令可以根据特定指令的使用频率对其进行分类

  • %hot - 实现将放在 beam_hot.h 中。频繁执行的指令。

  • %warm - 实现将放在 beam_warm.h 中。二进制语法指令。

  • %cold - 实现将放在 beam_cold.h 中。跟踪指令和不常用的指令。

默认值为 %hot。这些指令将应用于后续的特定指令的声明。这是一个例子

%cold
is_number f? xy
%hot

条件编译指令

如果条件为真,则 %if 指令会包含一系列行。例如

%if ARCH_64
i_bs_get_integer_32 x f? x
%endif

特定指令 i_bs_get_integer_32 将仅在 64 位机器上定义。

可以使用 %unless 而不是 %if 来反转条件

%unless NO_FPE_SIGNALS
fcheckerror p => i_fcheckerror
i_fcheckerror
fclearerror
%endif

也可以添加 %else 子句

%if ARCH_64
BS_SAFE_MUL(A, B, Fail, Dst) {
    Uint64 res = ($A) * ($B);
    if (res / $B != $A) {
        $Fail;
    }
    $Dst = res;
}
%else
BS_SAFE_MUL(A, B, Fail, Dst) {
    Uint64 res = (Uint64)($A) * (Uint64)($B);
    if ((res >> (8*sizeof(Uint))) != 0) {
        $Fail;
    }
    $Dst = res;
}
%endif

在指令中定义的符号

始终定义以下符号。

  • ARCH_64 - 对于 64 位机器为 1,否则为 0。
  • ARCH_32 - 对于 32 位机器为 1,否则为 0。

构建模拟器的 Makefile 当前通过在 beam_makeops 的命令行上使用 -D 选项来定义以下符号。

  • USE_VM_PROBES - 如果运行时系统编译为使用 VM 探针(支持 dtrace 或 systemtap),则为 1,否则为 0。

定义外部通用指令

编译器和运行时系统都知道外部通用 BEAM 指令。它们在版本之间保持稳定。新的主要版本可能会添加更多外部通用指令,但不得更改先前定义的指令的语义。

外部通用指令的语法如下

opcode: [-]name/arity

opcode 是大于或等于 1 的整数。

name 是以小写字母开头的标识符。arity 是表示操作数数量的整数。

name 可以选择以 - 开头,以指示它已被废弃。不允许编译器生成使用已废弃指令的 BEAM 文件,并且加载器将拒绝加载使用已废弃指令的 BEAM 文件。

仅在 lib/compiler/src 中的文件 genop.tab 中定义外部通用指令才有意义,因为编译器必须知道它们才能使用它们。

新指令必须添加到文件的末尾,编号要高于之前的指令。

定义内部通用指令

内部通用指令仅为运行时系统所知,可以随时更改,而不会出现兼容性问题。

有两种方法可以定义内部通用指令

  • 当定义特定指令时隐式定义。这是最常见的方法。每当创建特定指令时,如果内部通用指令之前不存在,beam_makeops 会自动创建它。

  • 显式定义。仅当通用指令用于转换,但没有任何对应的特定指令时,才需要这样做。

内部通用指令的语法如下

名称/arity

name 是以小写字母开头的标识符。arity 是表示操作数数量的整数。

关于通用指令的概述

每个通用指令都有一个操作码。操作码是一个大于等于 1 的整数。对于外部通用指令,必须在 genop.tab 中显式给出,而内部通用指令由 beam_makeops 自动编号。

通用指令的标识是其名称与其 arity 的组合。这意味着允许定义两个具有相同名称但 arity 不同的不同通用指令。例如

move_window/5
move_window/6

通用指令的每个操作数都带有其类型标签。通用指令可以具有以下类型之一

  • x - X 寄存器。

  • y - Y 寄存器。

  • l - 浮点寄存器号。

  • i - 带标签的字面整数。

  • a - 带标签的字面原子。

  • n - NIL([],空列表)。

  • q - 不适合一个字的字面值,即存储在堆上的对象,例如列表或元组。支持任何堆对象类型,即使是没有真实字面值的类型,例如外部引用。

  • f - 非零失败标签。

  • p - 零失败标签。

  • u - 适合机器字的未标记整数。它用于许多不同的目的,例如 test_heap/2 中的活动寄存器数量,作为 call_ext/2 的导出引用,以及二进制语法指令的标志操作数。当通用指令转换为特定指令时,特定操作中操作数的类型将告诉加载器如何处理该操作数。

  • o - 溢出。如果 u 操作数的值不适合机器字,则操作数的类型将更改为 o(没有关联的值)。目前仅在加载器中用于保护约束函数 binary_too_big()

  • v - Arity 值。仅在加载器内部使用。

定义特定指令

特定指令仅为运行时系统所知,并且是实际执行的指令。它们可以随时更改,而不会导致兼容性问题。

如果特定指令所属的指令族有多个成员,则特定指令最多可以有 6 个操作数。如果一个指令族中只有一个特定的指令,则操作数的数量没有限制。

特定指令的定义首先给出其名称,然后给出每个操作数的类型。例如

 move x y

在内部,例如在生成的代码中以及来自 BEAM 反汇编程序的输出中,指令 move x y 将被称为 move_xy

特定指令的名称是以小写字母开头的标识符。类型是小写或大写字母。

具有给定名称的所有特定指令必须具有相同数量的操作数。也就是说,以下情况是不允许的

 move x x
 move x y x y

以下是与通用指令类型或多或少直接对应的类型字母。

  • x - X 寄存器。将作为相对于 X 寄存器数组基址的 X 寄存器的字节偏移量加载。(可以与其他操作数打包。)

  • y - Y 寄存器。将作为相对于堆栈帧的 Y 寄存器的字节偏移量加载。(可以与其他操作数打包。)

  • r - X 寄存器 0。一个隐式操作数,不会存储在加载的代码中。(未在 BeamAsm 中使用。)

  • l - 浮点寄存器号。(可以与其他操作数打包。)

  • a - 带标签的原子。

  • n - NIL 或空列表。(不会存储在加载的代码中。)

  • q - 带标签的 CONS 或 BOXED 指针。也就是说,诸如列表或元组之类的项。支持任何堆对象类型,即使是没有真实字面值的类型,例如外部引用。

  • f - 失败标签(非零)。分支或调用指令的目标。

  • p - 0 失败标签,表示如果指令失败则应引发异常。(不会存储在加载的代码中。)

  • c - 任何字面量项;也就是说,诸如 SMALL 之类的立即字面量,以及指向字面量的 CONS 或 BOXED 指针。(可以用于通用指令中操作数具有 ianq 类型的情况。)

以下类型在运行时对操作数执行类型测试;因此,就运行时而言,它们通常比前面描述的类型更昂贵。但是,需要这些操作数类型来避免特定指令的数量和 process_main() 的总体代码大小出现组合爆炸。

  • s - 带标签的源:X 寄存器、Y 寄存器或字面量项。将在运行时测试标签,以从 X 寄存器、Y 寄存器检索值,或者仅将该值用作带标签的 Erlang 项。(实现说明:X 寄存器标记为 pid,Y 寄存器标记为端口。因此,字面量项不得包含端口或 pid。)

  • S - 带标签的源寄存器(X 或 Y)。将在运行时测试标签,以从 X 寄存器或 Y 寄存器检索值。比 s 稍微便宜一些。

  • d - 带标签的目标寄存器(X 或 Y)。将在运行时测试标签,以设置指向目标寄存器的指针。如果指令执行垃圾回收,则必须使用 $REFRESH_GEN_DEST() 宏来刷新指针,然后再存储到其中(稍后会有更多详细信息)。

  • j - 失败标签(fp 的组合)。如果分支目标为 0,则如果指令失败将引发异常,否则控制将转移到目标地址。

以下类型都应用于具有 u 类型的操作数。

  • t - 适合 12 位(0-4096)的未标记整数。它可以与一个字中的其他操作数打包。最常用于 test_heap 之类的指令中的活动寄存器数量。

  • I - 适合 32 位的未标记整数。在 64 位系统上,它可以与一个字中的其他操作数打包。

  • W - 未标记的整数或指针。不能与其他操作数打包。

  • e - 指向导出条目的指针。由调用其他模块的调用指令使用,例如 call_ext

  • L - 标签。仅由 label/1 指令使用。

  • b - 指向 BIF 的指针。在 BIF 指令(例如 call_bif)中使用。

  • F - 指向 fun 条目的指针。用于 make_fun2 及其变体。

  • A - 带标签的 arity 值。用于测试元组 arity 的指令。

  • P - 元组的字节偏移量。

  • Q - 堆栈的字节偏移量。用于更新帧指针寄存器。可以与其他操作数打包。

  • * - 此操作数必须是最后一个操作数。它表示后面有可变数量的操作数。当指令具有可变数量的操作数时,对于 BeamAsm,必须使用它;请参见处理可变数量的操作数。它可以用于解释器作为文档,但它不会对代码生成产生任何影响。

当加载器将通用指令转换为特定指令时,它将选择最适合类型的特定指令。考虑以下两个指令

move c x
move n x

c 操作数可以编码任何字面量值,包括 NIL。n 操作数仅适用于 NIL。如果我们有通用指令 {move,nil,{x,1}},加载器会将其转换为 move_nx 1,因为 move n x 更具体。move_nx 可能更快或更小(取决于架构),因为 [] 不会作为操作数显式存储。

特定指令的语法糖

可以为每个操作数指定多个类型字母。这是一个例子

move cxy xy

这是语法糖,等效于

move c x
move c y
move x x
move x y
move y x
move y y

请注意 move c xymove c d 之间的区别。请注意 move c xy 等效于以下两个定义

move c x
move c y

另一方面,move c d 是一个单指令。在运行时,会测试 d 操作数,以确定它是否引用 X 寄存器或 Y 寄存器,并设置指向该寄存器的指针。

“?” 类型修饰符

字符 ? 可以添加到操作数的末尾,表示该操作数并非每次执行指令时都会被使用。例如

allocate_heap t I t?
is_eq_exact f? x xy

allocate_heap 中,最后一个操作数是活动寄存器的数量。只有当堆空间不足并且必须执行垃圾回收时才会使用它。

is_eq_exact 中,失败地址(第一个操作数)仅在两个寄存器操作数不相等时才会使用。

知道某个操作数并非总是被使用,可以改进某些指令的打包方式。

对于 allocate_heap 指令,如果没有 ?,打包方式将如下所示

     +--------------------+--------------------+
I -> |       Stack needed | &&lb_allocate_heap +
     +--------------------+--------------------+
     |        Heap needed | Live registers     +
     +--------------------+--------------------+

“所需堆栈” 和 “所需堆” 总是被使用,但它们位于不同的字中。因此,即使并非总是使用 “活动寄存器”,运行时 allocate_heap 指令也必须从内存中读取这两个字。

使用 ? 后,操作数将以如下方式打包

     +--------------------+--------------------+
I -> |     Live registers | &&lb_allocate_heap +
     +--------------------+--------------------+
     |        Heap needed |       Stack needed +
     +--------------------+--------------------+

现在 “所需堆栈” 和 “所需堆” 位于同一个字中。

定义转换规则

转换规则用于将通用指令重写为其他通用指令。转换规则会重复应用,直到没有规则匹配为止。此时,结果指令序列中的第一条指令将转换为特定指令,并添加到正在加载的模块的代码中。然后,以相同的方式运行剩余指令的转换规则。

规则由其右箭头识别:=>。箭头左侧是一个或多个指令模式,以 | 分隔。箭头右侧是零个或多个指令,以 | 分隔。如果来自 BEAM 代码的指令与左侧的指令模式匹配,它们将被右侧的指令替换(如果右侧没有指令,则删除)。

定义指令模式

我们将开始查看箭头左侧的模式。

指令的模式由其名称组成,后跟每个操作数的模式。操作数模式用空格分隔。

最简单的模式是变量。就像在 Erlang 中一样,变量必须以大写字母开头。与 Erlang 不同,变量**不能**重复。

在左侧绑定的变量可以在右侧使用。例如,此规则将把所有 move 指令重写为操作数交换的 assign 指令

move Src Dst => assign Dst Src

如果我们只想匹配特定类型的操作数,我们可以使用类型约束。类型约束由一个或多个小写字母组成,每个字母指定一个类型。例如

is_integer Fail an => jump Fail

如果第二个操作数是原子或 NIL(空列表),则第二个操作数模式 an 将匹配。如果匹配,则 is_integer/2 指令将被 jump/1 指令替换。

操作数模式可以通过在变量后面加上 = 和约束来同时绑定变量和约束类型。例如

is_eq_exact Fail=f R=xy C=q => i_is_eq_exact_literal Fail R C

这里,is_eq_exact 指令被替换为仅比较字面量的专用指令,但前提是第一个操作数是寄存器,第二个操作数是字面量。

删除指令

可以通过在转换的右侧使用 _ 符号来删除模式左侧的指令。例如,可以像这样删除没有任何实际行号信息的 line 指令

line n => _

(在 OTP 25 之前,这是通过将右侧留空来实现的。)

进一步约束模式

除了指定类型字母外,还可以指定该类型的实际值。例如

move C=c x==1 => move_x1 C

这里,move 的第二个操作数被约束为 X 寄存器 1。

在指定原子约束时,原子将按照在 C 源代码中的方式编写。也就是说,它需要 am_ 前缀,并且必须在 atom.names 中列出。例如,可以像这样删除冗余的 is_boolean 指令

is_boolean Fail=f a==am_true => _
is_boolean Fail=f a==am_false => _

有几个约束可用于测试对 BIF 或函数的调用。

约束 u$is_bif 将测试给定的操作数是否引用 BIF。例如

call_ext u Bif=u$is_bif => call_bif Bif
call_ext u Func         => i_call_ext Func

call_ext 指令可用于调用 Erlang 中编写的函数以及 BIF(或更准确地称为 SNIF)。如果操作数引用 BIF(即,如果它在文件 bif.tab 中列出),则 u$is_bif 约束将匹配。请注意,u$is_bif 仅应应用于已知包含 BEAM 文件中导入表块索引的操作数(此类操作数在对应的特定指令中具有类型 be)。如果应用于其他 u 操作数,则最多会返回无意义的结果。

如果操作数未引用 BIF(未在 bif.tab 中列出),则 u$is_not_bif 约束匹配。例如

move S X0=x==0 | line Loc | call_ext_last Ar Func=u$is_not_bif D =>
     move S X0 | call_ext_last Ar Func D

u$bif:Module:Name/Arity 约束测试给定的操作数是否引用特定的 BIF。请注意,Module:Name/Arity **必须**是 bif.tab 中定义的现有 BIF,否则会出现编译错误。当对特定 BIF 的调用应替换为指令时,它很有用,如本示例所示

gc_bif2 Fail Live u$bif:erlang:splus/2 S1 S2 Dst =>
     gen_plus Fail Live S1 S2 Dst

此处,对 GC BIF '+'/2 的调用将替换为指令 gen_plus/5。请注意,必须对 BIF 使用与 C 源代码中相同的名称,在本例中为 splus。它在 bit.tab 中定义如下

ubif erlang:'+'/2 splus_2

u$func:Module:Name/Arity 将测试给定的操作数是否为特定函数。这是一个例子

bif1 Fail u$func:erlang:is_constant/1 Src Dst => too_old_compiler

is_constant/1 很久以前是一个 BIF。转换会将调用替换为 too_old_compiler 指令,该指令在加载程序中经过特殊处理,以产生比默认情况下缺少保护 BIF 会产生的错误消息更友好的错误消息。

模式中允许的类型约束

以下是转换规则左侧允许的所有类型字母。

  • u - 适合机器字的未标记整数。

  • x - X 寄存器。

  • y - Y 寄存器。

  • l - 浮点寄存器号。

  • i - 带标签的字面整数。

  • a - 带标签的字面原子。

  • n - NIL([],空列表)。

  • q - 不适合一个字,如列表或元组之类的字面量。

  • f - 非零失败标签。

  • p - 零失败标签。

  • j - 任何标签。等同于 fp

  • c - 任何字面量术语。等同于 ainq

  • s - X 寄存器、Y 寄存器或任何字面量术语。等同于 xyc

  • d - X 或 Y 寄存器。等同于 xy。(在模式中,d 将匹配源寄存器和目标寄存器。作为特定指令中的操作数,它只能用于目标寄存器。)

  • o - 溢出。不适合机器字的未标记整数。

谓词

如果到目前为止描述的约束不够,可以在 C 中实现其他约束,并将其作为转换左侧的保护函数调用。如果保护函数返回一个非零值,则规则的匹配将继续,否则匹配将失败。此类保护函数在下文中称为*谓词*。

最常用的保护约束是 equal()。它可以用于删除冗余的 move 指令,如下所示

move R1 R2 | equal(R1, R2) => _

或删除冗余的 is_eq_exact 指令,如下所示

is_eq_exact Lbl Src1 Src2 | equal(Src1, Src2) => _

在编写本文时,所有谓词都在多个目录中名为 predicates.tab 的文件中定义。$ERL_TOP/erts/emulator/beam 中直接在 predicates.tab 中的谓词包含传统模拟器和 JIT 实现使用的谓词。仅由模拟器使用的谓词可以在 emu/predicates.tab 中找到。

关于谓词实现的一个简要说明

详细描述如何实现谓词超出了本文档的范围,因为它需要了解内部加载程序数据结构,但这里快速查看一下名为 literal_is_map() 的简单谓词的实现。

以下是它如何使用的示例

ismap Fail Lit=q | literal_is_map(Lit) =>

如果 Lit 操作数是一个字面量,则调用 literal_is_map() 谓词来确定它是否是一个映射字面量。如果是,则不需要该指令,可以删除。

literal_is_map() 的实现如下所示(在 emu/predicates.tab 中)

pred.literal_is_map(Lit) {
    Eterm term;

    ASSERT(Lit.type == TAG_q);
    term = beamfile_get_literal(&S->beam, Lit.val);
    return is_map(term);
}

pred. 前缀告诉 **beam_makeops** 此函数是一个谓词。如果没有该前缀,则会将其解释为指令的实现(在 **定义实现** 中描述)。

谓词函数有一个名为 S 的魔术变量,它是指向状态结构的指针。在示例中,beamfile_get_literal(&S->beam, Lit.val); 用于检索字面量的实际术语。

在编写本文时,**beam_makeops** 生成的展开的 C 代码如下所示

static int literal_is_map(LoaderState* S, BeamOpArg Lit) {
  Eterm term;

  ASSERT(Lit.type == TAG_q);
  term = S->literals[Lit.val].term;
  return is_map(term);;
}

处理具有可变数量操作数的指令

某些指令(如 select_val/3)本质上具有可变数量的操作数。此类指令在 BEAM 汇编代码中,将其最后一个操作数作为 {list,[...]} 操作数。例如

{select_val,{x,0},
            {f,1},
            {list,[{atom,b},{f,4},{atom,a},{f,5}]}}.

加载程序会将 {list,[...]} 操作数转换为 u 操作数,该操作数的值是列表中的元素数量,后跟列表中的每个元素。上面的指令将转换为以下通用指令

{select_val,{x,0},{f,1},{u,4},{atom,b},{f,4},{atom,a},{f,5}}

要匹配可变数量的参数,我们需要使用特殊的操作数类型 *,如下所示

select_val Src=aiq Fail=f Size=u List=* =>
    i_const_select_val Src Fail Size List

此转换将具有常量源操作数的 select_val/3 指令重命名为 i_const_select_val/3

在右侧构造新指令

右侧最常见的操作数是在匹配左侧模式时绑定的变量。例如

trim N Remaining => i_trim N

操作数也可以是类型字母,以构造该类型的操作数。每种类型都有一个默认值。例如,类型 x 的默认值为 1023,它是最高的 X 寄存器。这使得右侧的 x 成为临时 X 寄存器的方便快捷方式。例如

is_number Fail Literal=q => move Literal x | is_number Fail x

如果 is_number/2 的第二个操作数是字面量,它将被移动到 X 寄存器 1023。然后,is_number/2 将测试存储在 X 寄存器 1023 中的值是否为数字。

当操作数很少能成为寄存器之外的其他任何东西时,这种转换非常有用。在 is_number/2 的情况下,除非禁用编译器优化,否则第二个操作数始终是寄存器。

如果默认值不适用,可以在类型字母后跟 = 和一个值。大多数类型采用整数值。原子类型的值的写法与 C 源代码中相同。例如,原子 false 写成 am_false。原子必须在 atom.names 中列出。

以下示例展示了如何指定值

bs_put_utf32 Fail=j Flags=u Src=s =>
    i_bs_validate_unicode Fail Src |
    bs_put_integer Fail i=32 u=1 Flags Src

右侧的类型字母

下面列出了所有允许在转换规则右侧构造的指令的操作数中使用的类型。

  • u - 构造一个无标记的整数。默认值为 0。

  • x - X 寄存器。默认值为 1023。这使得 x 方便用作临时 X 寄存器。

  • y - Y 寄存器。默认值为 0。

  • l - 浮点寄存器编号。默认值为 0。

  • i - 带标记的字面整数。默认值为 0。

  • a - 带标记的原子。默认值为空原子 (am_Empty)。

  • p - 零失败标签。

  • n - NIL([],空列表)。

右侧的函数调用

此处描述的规则语言无法描述的转换可以使用 C 语言中的生成器函数实现,并从转换的右侧调用。转换的左侧将执行匹配并将操作数绑定到变量。然后可以将变量传递给右侧的生成器函数。例如

bif2 Fail=j u$bif:erlang:element/2 Index=s Tuple=xy Dst=d =>
    element(Jump, Index, Tuple, Dst)

此转换规则匹配对 BIF element/2 的调用。操作数将被捕获,并且将调用生成器函数 element()

生成器 element() 将根据 Index 生成两个指令之一。如果 Index 是 1 到最大元组大小范围内的整数,则将生成指令 i_fast_element/2,否则将生成指令 i_element/4。相应的特定指令如下

i_fast_element xy j? I d
i_element xy j? s d

指令 i_fast_element/2 更快,因为元组已经是无标记的整数。它还知道索引至少为 1,因此不必对此进行测试。指令 i_element/4 必须从寄存器中获取索引,测试它是否为整数,然后取消标记该整数。

在编写本文时,所有生成器函数都在几个目录(与 predicates.tab 文件相同的目录)中名为 generators.tab 的文件中定义。

详细描述如何编写生成器函数不在本文档的范围之内,但这是 element() 的实现

gen.element(Fail, Index, Tuple, Dst) {
    BeamOp* op;

    $NewBeamOp(S, op);

    if (Index.type == TAG_i && Index.val > 0 &&
        Index.val <= ERTS_MAX_TUPLE_SIZE &&
        (Tuple.type == TAG_x || Tuple.type == TAG_y)) {
        $BeamOpNameArity(op, i_fast_element, 4);
        op->a[0] = Tuple;
        op->a[1] = Fail;
        op->a[2].type = TAG_u;
        op->a[2].val = Index.val;
        op->a[3] = Dst;
    } else {
        $BeamOpNameArity(op, i_element, 4);
        op->a[0] = Tuple;
        op->a[1] = Fail;
        op->a[2] = Index;
        op->a[3] = Dst;
    }

    return op;
}

前缀 gen. 告诉 beam_makeops 此函数是一个生成器。如果没有前缀,它将被解释为指令的实现(在 定义实现 中描述)。

生成器函数有一个名为 S 的魔法变量,它是指向状态结构的指针。在该示例中,S 用于调用 NewBeamOp 宏。

定义实现

对于传统的 BEAM 解释器,指令的实际实现也在 beam_makeops 处理的 .tab 文件中定义。有关如何为 BeamAsm 生成代码的简要介绍,请参阅 BeamAsm 的代码生成

出于实际原因,指令定义存储在几个文件中,在编写本文时,存储在以下文件中(在 beam/emu 目录中)

bif_instrs.tab
arith_instrs.tab
bs_instrs.tab
float_instrs.tab
instrs.tab
map_instrs.tab
msg_instrs.tab
select_instrs.tab
trace_instrs.tab

还有一个只包含宏定义的文件

macros.tab

每个文件的语法类似于 C 代码。实际上,大多数内容 C 代码,其中散布着宏调用。

为了允许 Emacs 自动缩进代码,每个文件都以以下行开头

// -*- c -*-

为了避免弄乱缩进,所有注释都写为 C++ 样式的注释 (//) 而不是 #。请注意,注释必须从行首开始。

指令定义文件的核心是宏定义。我们之前已经见过此宏定义

move(Src, Dst) {
    $Dst = $Src;
}

宏定义必须从行的开头开始(不允许有空格),左花括号必须在同一行,而右花括号必须在行的开头。建议正确缩进宏主体。

按照惯例,头部中的宏参数都以大写字母开头。在主体中,可以通过在它们前面加上 $ 来扩展宏参数。

名称和元数与特定指令系列匹配的宏定义被假定为该指令的实现。

也可以从另一个宏中调用宏。例如,move_deallocate_return/2 通过调用 $deallocate_return() 作为宏来避免重复代码

move_deallocate_return(Src, Deallocate) {
    x(0) = $Src;
    $deallocate_return($Deallocate);
}

这是 deallocate_return/1 的定义

deallocate_return(Deallocate) {
    //| -no_next
    int words_to_pop = $Deallocate;
    SET_I((BeamInstr *) cp_val(*E));
    E = ADD_BYTE_OFFSET(E, words_to_pop);
    CHECK_TERM(x(0));
    DispatchReturn;
}

move_deallocate_return 的扩展代码将如下所示

OpCase(move_deallocate_return_cQ):
{
  x(0) = I[1];
  do {
    int words_to_pop = Qb(BeamExtraData(I[0]));
    SET_I((BeamInstr *) cp_val(*E));
    E = ADD_BYTE_OFFSET(E, words_to_pop);
    CHECK_TERM(x(0));
    DispatchReturn;
  } while (0);
}

在扩展宏时,除非 beam_makeops 清楚地看到不需要包装器,否则 beam_makeops 会将扩展包装在 do/while 包装器中。在这种情况下,需要包装器。

请注意,宏的参数不能是复杂的表达式,因为参数是通过 , 分割的。例如,以下内容将不起作用,因为 beam_makeops 会将表达式拆分为两个参数

$deallocate_return(get_deallocation(y, $Deallocate));

代码生成指令

在宏定义中,// 注释通常不会被特殊处理。它们将与主体中的其余代码一起复制到包含生成的代码的文件中。

但是,有一个例外。在宏定义中,以空格开头后跟 //| 的行会被特殊处理。该行的其余部分被假定为包含控制代码生成的指令。

目前,可以识别两个代码生成指令

  • -no_prefetch
  • -no_next
-no_prefetch 指令

为了了解 -no_prefetch 的作用,我们首先看一下默认的代码生成。以下是为 move_cx 生成的代码

OpCase(move_cx):
{
  BeamInstr next_pf = BeamCodeAddr(I[2]);
  xb(BeamExtraData(I[0])) = I[1];
  I += 2;
  ASSERT(VALID_INSTR(next_pf));
  GotoPF(next_pf);
}

请注意,首先要做的就是获取下一条指令的地址。原因是它通常可以提高性能。

仅作为演示,我们可以将 -no_prefetch 指令添加到 move/2 指令中

move(Src, Dst) {
    //| -no_prefetch
    $Dst = $Src;
}

我们可以看到不再执行预取

OpCase(move_cx):
{
  xb(BeamExtraData(I[0])) = I[1];
  I += 2;
  ASSERT(VALID_INSTR(*I));
  Goto(*I);
}

在实践中,我们什么时候需要关闭预取?

在并非总是执行下一条指令的指令中。例如

is_atom(Fail, Src) {
    if (is_not_atom($Src)) {
        $FAIL($Fail);
    }
}

// From macros.tab
FAIL(Fail) {
    //| -no_prefetch
    $SET_I_REL($Fail);
    Goto(*I);
}

is_atom/2 可能会执行下一条指令(如果第二个操作数是原子),或者分支到失败标签。

生成的代码如下所示

OpCase(is_atom_fx):
{
  if (is_not_atom(xb(I[1]))) {
    ASSERT(VALID_INSTR(*(I + (fb(BeamExtraData(I[0]))) + 0)));
    I += fb(BeamExtraData(I[0])) + 0;;
    Goto(*I);;
  }
  I += 2;
  ASSERT(VALID_INSTR(*I));
  Goto(*I);
}
-no_next 指令

接下来,我们将看看何时可以使用 -no_next 指令。以下是 jump/1 指令

jump(Fail) {
    $JUMP($Fail);
}

// From macros.tab
JUMP(Fail) {
    //| -no_next
    $SET_I_REL($Fail);
    Goto(*I);
}

生成的代码如下所示

OpCase(jump_f):
{
  ASSERT(VALID_INSTR(*(I + (fb(BeamExtraData(I[0]))) + 0)));
  I += fb(BeamExtraData(I[0])) + 0;;
  Goto(*I);;
}

如果我们删除 -no_next 指令,代码将如下所示

OpCase(jump_f):
{
  BeamInstr next_pf = BeamCodeAddr(I[1]);
  ASSERT(VALID_INSTR(*(I + (fb(BeamExtraData(I[0]))) + 0)));
  I += fb(BeamExtraData(I[0])) + 0;;
  Goto(*I);;
  I += 1;
  ASSERT(VALID_INSTR(next_pf));
  GotoPF(next_pf);
}

最后,C 编译器可能会将此代码优化为与第一个版本相同的本机代码,但是第一个版本肯定更易于人类读者阅读。

macros.tab 文件中的宏

文件 macros.tab 包含许多有用的宏。在实现新指令时,最好浏览 macros.tab 以查看是否可以使用任何现有宏,而不是重新发明轮子。

我们将在此处描述一些最有用的宏。

GC_REGEXP 定义

以下行定义了一个正则表达式,该表达式将识别对执行垃圾回收的函数的调用

 GC_REGEXP=erts_garbage_collect|erts_gc|GcBifFunction;

目的是 beam_makeops 可以验证执行垃圾回收且具有 d 操作数的指令是否使用了 $REFRESH_GEN_DEST() 宏。

如果需要定义一个新的执行垃圾回收的函数,则应为其指定前缀 erts_gc_。如果不可能,则应更新正则表达式,使其与您的新函数匹配。

FAIL(Fail)

分支到 $Fail。将禁止预取 (-no_prefetch)。典型用法

is_nonempty_list(Fail, Src) {
    if (is_not_list($Src)) {
        $FAIL($Fail);
    }
}
JUMP(Fail)

分支到 $Fail。禁止生成下一条指令的调度 (-no_next)。典型用法

jump(Fail) {
    $JUMP($Fail);
}
GC_TEST(NeedStack, NeedHeap, Live)

$GC_TEST(NeedStack, NeedHeap, Live) 测试给定数量的堆栈空间和堆空间是否可用。如果不可用,它将执行垃圾回收。典型用法

test_heap(Nh, Live) {
    $GC_TEST(0, $Nh, $Live);
}
AH(NeedStack, NeedHeap, Live)

AH(NeedStack, NeedHeap, Live) 分配堆栈帧并可选地分配额外的堆空间。

预定义宏和变量

beam_makeops 定义了几个内置宏和预绑定变量。

NEXT_INSTRUCTION 预绑定变量

NEXT_INSTRUCTION 是一个预绑定变量,可在所有指令中使用。它扩展为下一条指令的地址。

这是一个例子

i_call(CallDest) {
    //| -no_next
    $SAVE_CONTINUATION_POINTER($NEXT_INSTRUCTION);
    $DISPATCH_REL($CallDest);
}

在调用函数时,返回地址首先存储在 E[0] 中(使用 $SAVE_CONTINUATION_POINTER() 宏),然后控制权转移给被调用方。这是生成的代码

OpCase(i_call_f):
{
    ASSERT(VALID_INSTR(*(I+2)));
    *E = (BeamInstr) (I+2);;

    /* ... dispatch code intentionally left out ... */
}

我们可以看到 $NEXT_INSTRUCTION 已扩展为 I+2。这是有道理的,因为 i_call_f/1 指令的大小为两个字。

IP_ADJUSTMENT 预绑定变量

$IP_ADJUSTMENT 通常为 0。在一些组合指令中(如下所述),它可以是非零的。它在 macros.tab 中像这样使用

SET_I_REL(Offset) {
    ASSERT(VALID_INSTR(*(I + ($Offset) + $IP_ADJUSTMENT)));
    I += $Offset + $IP_ADJUSTMENT;
}

避免直接使用 IP_ADJUSTMENT。使用 SET_I_REL() 或调用诸如 FAIL()JUMP() 之类的宏(在 macros.tab 中定义)。

预定义宏函数

IF() 宏

$IF(Expr, IfTrue, IfFalse) 计算 Expr,它必须是有效的 Perl 表达式(对于简单的数值表达式,其语法与 C 相同)。如果 Expr 的计算结果为 0,则整个 IF() 表达式将替换为 IfFalse,否则将替换为 IfTrue

有关示例,请参见 OPERAND_POSITION() 的描述。

OPERAND_POSITION() 宏

如果 Expr 是一个未打包的操作数,则 $OPERAND_POSITION(Expr) 返回 Expr 的位置。第一个操作数位于位置 1。

否则返回 0。

为了共享代码,可以使用此宏,如下所示

FAIL(Fail) {
    //| -no_prefetch
    $IF($OPERAND_POSITION($Fail) == 1 && $IP_ADJUSTMENT == 0,
        goto common_jump,
        $DO_JUMP($Fail));
}

DO_JUMP(Fail) {
    $SET_I_REL($Fail);
    Goto(*I));
}

// In beam_emu.c:
common_jump:
   I += I[1];
   Goto(*I));

$REFRESH_GEN_DEST()

当某个特定指令具有 d 操作数时,在指令执行的早期,会初始化一个指针,指向相关的 X 或 Y 寄存器。

如果在结果存储之前发生垃圾回收,堆栈将会移动,并且如果 d 操作数引用的是 Y 寄存器,则该指针将不再有效。(Y 寄存器存储在堆栈上。)

在这种情况下,必须调用 $REFRESH_GEN_DEST() 来重新设置指针。beam_makeops 会注意到是否有对执行垃圾回收的函数的调用,而 $REFRESH_GEN_DEST() 没有被调用。

以下是一个完整的示例。 new_map 指令的定义如下

new_map d t I

其实现如下

new_map(Dst, Live, N) {
    Eterm res;

    HEAVY_SWAPOUT;
    res = erts_gc_new_map(c_p, reg, $Live, $N, $NEXT_INSTRUCTION);
    HEAVY_SWAPIN;
    $REFRESH_GEN_DEST();
    $Dst = res;
    $NEXT($NEXT_INSTRUCTION+$N);
}

如果我们忘记了 $REFRESH_GEN_DEST(),则会出现类似于以下的消息

pointer to destination register is invalid after GC -- use $REFRESH_GEN_DEST()
... from the body of new_map at beam/map_instrs.tab(30)

可变数量的操作数

以下示例说明如何处理解释器中具有可变数量操作数的指令。以下是 emu/ops.tab 中的指令定义

put_tuple2 xy I *

对于解释器,* 是可选的,因为它不会以任何方式影响代码生成。但是,建议将其包括在内,以便让人类读者清楚地知道存在可变数量的操作数。

使用 $NEXT_INSTRUCTION 宏来获取指向第一个可变操作数的指针。

以下是实现

put_tuple2(Dst, Arity) {
Eterm* hp = HTOP;
Eterm arity = $Arity;
Eterm* dst_ptr = &($Dst);

//| -no_next
ASSERT(arity != 0);
*hp++ = make_arityval(arity);

/*
 * The $NEXT_INSTRUCTION macro points just beyond the fixed
 * operands. In this case it points to the descriptor of
 * the first element to be put into the tuple.
 */
I = $NEXT_INSTRUCTION;
do {
    Eterm term = *I++;
    switch (loader_tag(term)) {
    case LOADER_X_REG:
    *hp++ = x(loader_x_reg_index(term));
    break;
    case LOADER_Y_REG:
    *hp++ = y(loader_y_reg_index(term));
    break;
    default:
    *hp++ = term;
    break;
    }
} while (--arity != 0);
*dst_ptr = make_tuple(HTOP);
HTOP = hp;
ASSERT(VALID_INSTR(* (Eterm *)I));
Goto(*I);
}

组合指令

问题:对于频繁执行的指令,我们希望使用 “快速” 操作数类型,例如 xy,而不是 sS。为了避免代码大小的爆炸式增长,我们希望在指令之间共享大部分实现。以下是 i_increment/5 的特定指令

i_increment r W t d
i_increment x W t d
i_increment y W t d

i_increment 指令的实现如下

i_increment(Source, IncrementVal, Live, Dst) {
    Eterm increment_reg_source = $Source;
    Eterm increment_val = $IncrementVal;
    Uint live;
    Eterm result;

    if (ERTS_LIKELY(is_small(increment_reg_val))) {
        Sint i = signed_val(increment_reg_val) + increment_val;
        if (ERTS_LIKELY(IS_SSMALL(i))) {
            $Dst = make_small(i);
            $NEXT0();
        }
    }
    live = $Live;
    HEAVY_SWAPOUT;
    reg[live] = increment_reg_val;
    reg[live+1] = make_small(increment_val);
    result = erts_gc_mixed_plus(c_p, reg, live);
    HEAVY_SWAPIN;
    ERTS_HOLE_CHECK(c_p);
    if (ERTS_LIKELY(is_value(result))) {
        $REFRESH_GEN_DEST();
        $Dst = result;
        $NEXT0();
    }
    ASSERT(c_p->freason != BADMATCH || is_value(c_p->fvalue));
    goto find_func_info;
}

将会有三个几乎相同的代码副本。考虑到代码的大小,这可能会付出太高的代价。

为了避免代码的三份副本,我们可以只使用一个特定的指令

i_increment S W t d

(与上述相同的实现将起作用。)

这减小了代码大小,但速度较慢,因为 S 表示会有额外的代码来测试操作数是指向 X 寄存器还是 Y 寄存器。

解决方案:我们可以使用“组合指令”。组合指令是从指令片段组合而成的。大部分代码可以共享。

在这里,我们将展示如何将 i_increment 实现为组合指令。我们首先展示每个单独的片段,然后展示如何将它们连接在一起。首先,我们需要一个变量,可以在其中存储从寄存器获取的值

increment.head() {
    Eterm increment_reg_val;
}

名称 increment 是该片段所属的组的名称。请注意,它不需要与指令的名称相同。组名称后面跟 . 和片段的名称。名称 head 是预定义的。其中的代码将放置在块的开头,以便组中的所有片段都可以访问它。

接下来,我们定义将从第一个操作数中获取寄存器值的片段

increment.fetch(Src) {
    increment_reg_val = $Src;
}

我们称这个片段为 fetch。此片段将被复制三次,每个第一个操作数的值(rxy)各一次。

接下来,我们定义代码的主要部分,该部分执行实际的递增操作。

increment.execute(IncrementVal, Live, Dst) {
    Eterm increment_val = $IncrementVal;
    Uint live;
    Eterm result;

    if (ERTS_LIKELY(is_small(increment_reg_val))) {
        Sint i = signed_val(increment_reg_val) + increment_val;
        if (ERTS_LIKELY(IS_SSMALL(i))) {
            $Dst = make_small(i);
            $NEXT0();
        }
    }
    live = $Live;
    HEAVY_SWAPOUT;
    reg[live] = increment_reg_val;
    reg[live+1] = make_small(increment_val);
    result = erts_gc_mixed_plus(c_p, reg, live);
    HEAVY_SWAPIN;
    ERTS_HOLE_CHECK(c_p);
    if (ERTS_LIKELY(is_value(result))) {
        $REFRESH_GEN_DEST();
        $Dst = result;
        $NEXT0();
    }
    ASSERT(c_p->freason != BADMATCH || is_value(c_p->fvalue));
    goto find_func_info;
}

我们称这个片段为 execute。它将处理其余的三个操作数(W t d)。此片段只有一个副本。

现在我们已经定义了片段,我们需要通知 beam_makeops 它们应该如何连接

i_increment := increment.fetch.execute;

:= 的左侧是应该由片段实现的特定指令的名称,在本例中为 i_increment:= 的右侧是带有片段的组的名称,后跟一个 .。然后,按它们应该执行的顺序列出组中片段的名称。请注意,head 片段未列出。

该行以 ; 结尾(以避免在 Emacs 中弄乱缩进)。

(请注意,在实践中,:= 行通常放置在片段之前。)

生成的代码如下所示

{
  Eterm increment_reg_val;
  OpCase(i_increment_rWtd):
  {
    increment_reg_val = r(0);
  }
  goto increment__execute;

  OpCase(i_increment_xWtd):
  {
    increment_reg_val = xb(BeamExtraData(I[0]));
  }
  goto increment__execute;

  OpCase(i_increment_yWtd):
  {
    increment_reg_val = yb(BeamExtraData(I[0]));
  }
  goto increment__execute;

  increment__execute:
  {
    // Here follows the code from increment.execute()
    .
    .
    .
}
关于组合指令的一些注意事项

不同的操作数必须位于指令的开头。最后一个片段中的所有操作数在特定指令的所有变体中都必须具有相同的操作数。

例如,以下特定指令不能实现为组合指令

i_times j? t x x d
i_times j? t x y d
i_times j? t s s d

我们必须更改操作数的顺序,以便将两个不同的操作数放在前面

i_times x x j? t d
i_times x y j? t d
i_times s s j? t d

然后我们可以定义

i_times := times.fetch.execute;

times.head {
    Eterm op1, op2;
}

times.fetch(Src1, Src2) {
    op1 = $Src1;
    op2 = $Src2;
}

times.execute(Fail, Live, Dst) {
    // Multiply op1 and op2.
    .
    .
    .
}

多个指令可以共享一个组。例如,以下指令具有不同的名称,但最终它们都创建了一个二进制文件。最后两个操作数对于它们来说是通用的

i_bs_init_fail       xy j? t? x
i_bs_init_fail_heap s I j? t? x
i_bs_init                W t? x
i_bs_init_heap         W I t? x

这些指令的定义如下(为了清晰起见,格式化为额外的空格)

i_bs_init_fail_heap := bs_init . fail_heap . verify . execute;
i_bs_init_fail      := bs_init . fail      . verify . execute;
i_bs_init           := bs_init .           .  plain . execute;
i_bs_init_heap      := bs_init .               heap . execute;

请注意,前两个指令有三个片段,而其他两个只有两个片段。以下是片段

bs_init_bits.head() {
    Eterm num_bits_term;
    Uint num_bits;
    Uint alloc;
}

bs_init_bits.plain(NumBits) {
    num_bits = $NumBits;
    alloc = 0;
}

bs_init_bits.heap(NumBits, Alloc) {
    num_bits = $NumBits;
    alloc = $Alloc;
}

bs_init_bits.fail(NumBitsTerm) {
    num_bits_term = $NumBitsTerm;
    alloc = 0;
}

bs_init_bits.fail_heap(NumBitsTerm, Alloc) {
    num_bits_term = $NumBitsTerm;
    alloc = $Alloc;
}

bs_init_bits.verify(Fail) {
    // Verify the num_bits_term, fail using $FAIL
    // if there is a problem.
.
.
.
}

bs_init_bits.execute(Live, Dst) {
   // Long complicated code to a create a binary.
   .
   .
   .
}

这些指令的完整定义可以在 bs_instrs.tab 中找到。生成的代码可以在 beam_warm.h 中找到。

BeamAsm 的代码生成

对于 BeamAsm 运行时系统,每个指令的实现由 C++ 中编写的发射器函数定义,这些函数为每个指令发射汇编代码。每个特定指令系列都有一个发射器函数。

例如,以 move 指令为例。在 beam/asm/ops.tab 中,为 move 定义了一个特定的指令,如下所示

move s d

该实现在 beam/asm/instr_common.cpp 中找到

void BeamModuleAssembler::emit_move(const ArgVal &Src, const ArgVal &Dst) {
    mov_arg(Dst, Src);
}

mov_arg() 辅助函数将处理源操作数和目标操作数的所有组合。例如,指令 {move,{x,1},{y,1}} 将被翻译为

mov rdi, qword [rbx+8]
mov qword [rsp+8], rdi

{move,{integer,42},{x,0}} 将被翻译为

mov qword [rbx], 687

可以定义多个特定指令,但仍然只有一个发射器函数。例如

fload S l
fload q l

通过像这样定义 fload,源操作数必须是 X 寄存器、Y 寄存器或文字。否则,加载将被中止。如果指令改为像这样定义

fload s l

尝试加载无效指令(例如 {fload,{atom,clearly_bad},{fr,0}})将导致崩溃(在加载时或执行指令时)。

无论该系列中有多少个特定指令,都只允许有一个 emit_fload() 函数

void BeamModuleAssembler::emit_fload(const ArgVal &Src, const ArgVal &Dst) {
    .
    .
    .
}

处理可变数量的操作数

以下示例说明如何处理具有可变数量操作数的指令。其中一个这样的指令是 select_val/3。以下是它在 BEAM 代码中的外观示例

{select_val,{x,0},
            {f,1},
            {list,[{atom,b},{f,4},{atom,a},{f,5}]}}.

加载器会将 {list,[...]} 操作数转换为 u 操作数,该操作数的值是列表中的元素数,后跟列表中的每个元素。上面的指令将被转换为以下指令

{select_val,{x,0},{f,1},{u,4},{atom,b},{f,4},{atom,a},{f,5}}

该指令的特定指令的定义如下所示

select_val s f I *

作为最后一个操作数的 * 将确保可变操作数作为 ArgValSpan 传递(在 C++20 及更高版本中将为 std::span)。以下是发射器函数

void BeamModuleAssembler::emit_select_val(const ArgVal &Src,
                                          const ArgVal &Fail,
                                          const ArgVal &Size,
                                          const Span<ArgVal> &args) {
    ASSERT(Size.getValue() == args.size());
       .
       .
       .
}