不要感到震惊。这是很多文字,但恐怕如果不提供一些详细信息,我将无法真正展示这一切的全部内容(并且可能会得到很多并不能真正解决我的问题的答案)。这绝对不是一项任务(正如某人在他的评论中荒谬地声称的那样)。
先决条件
由于这个问题可能根本无法回答,除非至少设置了一些先决条件,所以这里是先决条件:
- 应解释虚拟机代码。不禁止可能存在 JIT 编译器,但设计应针对解释器。
- VM 应该是基于寄存器的,而不是基于堆栈的。
- 答案可能既不假设有一组固定的寄存器,也不假设它们的数量是无限的,两者都可能是这种情况。
此外,我们需要更好地定义“更好”。有几个属性必须考虑:
- VM 代码在磁盘上的存储空间。当然,您总是可以在这里放弃所有优化并仅压缩代码,但这对(2)有负面影响。
- 解码速度。如果将代码转换为可以直接执行的东西需要太长时间,那么存储代码的最佳方式是无用的。
- 内存中的存储空间。此代码必须在有或没有进一步解码的情况下直接可执行,但如果涉及进一步解码,则在执行期间和每次执行指令时完成此编码(在加载代码时仅完成一次解码计入第 2 项)。
- 代码的执行速度(考虑到常见的解释器技术)。
- VM 的复杂性以及为其编写解释器的难度。
- VM 自身需要的资源量。(如果 VM 运行的代码大小为 2 KB 并且执行速度比眨眼快,这不是一个好的设计,但是它需要 150 MB 来执行此操作,并且它的启动时间远高于代码的运行时间它执行)
现在举例说明我实际上所说的或多或少的操作码。看起来实际上设置了操作码的数量,因为每次操作需要一个操作码。然而它并不那么容易。
同一操作的多个操作码
您可以进行类似的操作
ADD R1, R2, R3
将 R1 和 R2 的值相加,将结果写入 R3。现在考虑以下特殊情况:
ADD R1, R2, R2
ADD R1, 1, R1
这些是您可以在许多应用程序中找到的常见操作。您可以使用已经存在的操作码来表达它们(除非您需要不同的操作码,因为最后一个操作码具有 int 值而不是寄存器)。但是,您也可以为这些创建特殊的操作码:
ADD2 R1, R2
INC R1
和之前一样。优势在哪里?ADD2 只需要两个参数,而不是 3,INC 甚至只需要一个。因此,这可以在磁盘和/或内存中进行更紧凑的编码。由于将任何一种形式转换为另一种形式也很容易,因此解码步骤可以在两种方式之间转换以表达这些陈述。不过,我不确定这两种形式会在多大程度上影响执行速度。
将两个操作码组合成一个
现在让我们假设您有一个 ADD_RRR(R 代表寄存器)和一个 LOAD 来将数据加载到寄存器中。
LOAD value, R2
ADD_RRR R1, R2, R3
您可以拥有这两个操作码并始终在整个代码中使用这样的结构......或者您可以将它们组合成一个新的操作码,名为 ADD_RMR(M 代表内存)
ADD_RMR R1, value, R3
数据类型与操作码
假设您有 16 位整数和 32 位整数作为本机类型。寄存器是 32 位的,因此任何一种数据类型都适合。现在,当您添加两个寄存器时,您可以将数据类型设为参数:
ADD int16, R1, R2, R3
ADD int32, R1, R2, R3
例如,有符号和无符号整数也是如此。这样 ADD 可以是一个短操作码,一个字节,然后你有另一个字节(或者可能只是 4 位)告诉 VM 如何解释寄存器(它们是 16 位还是 32 位值)。或者您可以废弃类型编码,而使用两个操作码:
ADD16 R1, R2, R3
ADD32 R1, R2, R3
有人可能会说两者完全相同 - 只需将第一种方式解释为 16 位操作码即可。是的,但是一个非常天真的解释器可能看起来完全不同。例如,如果每个操作码有一个函数并使用 switch 语句进行调度(不是最好的方法,函数调用开销,switch 语句也可能不是最优的,我知道),两个操作码可能如下所示:
case ADD16: add16(p1, p2, p3); break; // pX pointer to register
case ADD32: add32(p1, p2, p3); break;
每个功能都以某种添加为中心。第二个可能看起来像这样:
case ADD: add(type, p1, p2, p3); break;
// ...
// and the function
void add (enum Type type, Register p1, Register p2, Register p3)
{
switch (type) {
case INT16: //...
case INT32: // ...
}
}
将子交换机添加到主交换机或将子调度表添加到主调度表。当然,无论类型是否显式,解释器都可以做任何一种方式,但根据操作码设计,任何一种方式都会让开发人员感觉更原生。
元操作码
由于没有更好的名字,我会这样称呼他们。这些操作码本身没有任何意义,它们只是改变了后面的操作码的含义。就像著名的 WIDE 运算符:
ADD R1, R2, R3
WIDE
ADD R1, R2, R3
例如,在第二种情况下,寄存器是 16 位的(因此您可以添加更多),在第一种情况下只有 8 个。或者,您不能有这样的元操作码,并且有一个 ADD 和一个 ADD_WIDE 操作码。像 WIDE 这样的元操作码避免使用 SUB_WIDE、MUL_WIDE 等,因为您始终可以在所有其他正常操作码之前添加 WIDE(始终只有一个操作码)。缺点是单独的操作码变得毫无意义,您必须始终检查它之前的操作码是否是元操作码。此外,VM 必须为每个线程存储一个额外的状态(例如,我们现在是否处于宽模式)并在下一条指令之后再次删除该状态。甚至 CPU 也有这样的操作码(例如 x86 LOCK 操作码)。
如何找到一个好的权衡???
当然,您拥有的操作码越多,开关/调度表就会变得越大,在磁盘或内存中表达这些代码所需的位数就越多(尽管您可以更有效地将它们存储在数据不存在的磁盘上必须由 VM 直接执行);此外,VM 将变得更加复杂,代码行数也更多——另一方面,操作码越强大:您越来越接近每个表达式,即使是复杂的表达式,都将在一个操作码中结束的地步。
选择小的操作码可以很容易地对 VM 进行编码,并且我猜会导致非常紧凑的操作码 - 另一方面,这意味着您可能需要大量的操作码来执行简单的任务,并且每个不经常使用的表达式都必须成为某种(本机)函数调用,因为它不能使用任何操作码。
我在 Internet 上阅读了很多关于各种 VM 的信息,但没有任何消息来源能够真正做出良好且公平的权衡。设计 VM 就像设计 CPU,有些 CPU 的操作码很少,它们速度很快,但您也需要很多这样的 CPU。并且有许多操作码的 CPU,有些非常慢,但你需要更少的操作码来表达相同的代码。看起来“越多越好”的CPU完全赢得了消费市场,而“越少越好”的CPU只能在服务器市场或超级计算机业务的某些部分生存。虚拟机呢?