鹦鹉虚拟机/运行核心和操作码
我们之前讨论过运行核心,但在本章中,我们将更深入地讨论它们。在这里,我们将讨论操作码以及将操作码转换为标准 C 代码的特殊操作码编译器。我们还将了解操作码编译器如何将这些操作码转换为不同的形式,以及执行这些操作码的不同运行核心。
操作码使用一种非常特殊的语法编写,它混合了 C 和特殊关键字。操作码由操作码编译器 tools/dev/ops2c.pl
转换为不同运行核心所需的格式。
鹦鹉的核心操作码都在 src/ops/
中定义,位于扩展名为 *.ops
的文件中。操作码根据其目的分为不同的文件
Ops 文件 | 目的 |
---|---|
bit.ops | 按位逻辑运算 |
cmp.ops | 比较操作 |
core.ops | 基本鹦鹉操作、私有内部操作、控制流、并发、事件和异常。 |
debug.ops | 用于调试鹦鹉和 HLL 程序的操作。 |
experimental.ops | 正在测试的操作,可能不稳定。不要依赖这些操作。 |
io.ops | 用于处理文件和终端的输入和输出的操作。 |
math.ops | 数学运算 |
object.ops | 用于处理面向对象细节的操作 |
obscure.ops | 用于模糊和专门的三角函数的操作 |
pic.ops | 多态内联缓存的私有操作码。不要使用这些。 |
pmc.ops | 用于处理 PMC、创建 PMC 的操作码。用于处理类似数组的 PMC(push、pop、shift、unshift)和类似哈希的 PMC 的通用操作 |
set.ops | 用于设置和加载寄存器的操作 |
stm.ops | 用于软件事务内存的操作,这是鹦鹉的线程间通信系统。实际上,这些操作不会被使用,而是使用 STMRef 和 STMVar PMC。 |
string.ops | 用于处理字符串的操作 |
sys.ops | 用于与底层系统交互的操作 |
var.ops | 用于处理词法变量和全局变量的操作 |
Ops 使用 op
关键字定义,其工作方式类似于 C 源代码。以下是一个例子
op my_op () { }
或者,我们也可以使用 inline
关键字
inline op my_op () { }
我们使用 in
和 out
关键字定义输入和输出参数,然后是输入类型。如果使用了输入参数但没有改变,可以将其定义为 inconst
。类型可以是 PMC
、STR
(字符串)、NUM
(浮点数)或 INT
(整数)。以下是一个函数原型示例
op my_op(out NUM, in STR, in PMC, in INT) { }
该函数接受一个字符串、一个 PMC 和一个 int,并返回一个 num。请注意参数没有名称。相反,它们对应于数字
op my_op(out NUM, in STR, in PMC, in INT) ^ ^ ^ ^ | | | | $1 $2 $3 $4
以下是一个示例,一个操作接收三个整数输入,将它们加在一起,并返回一个整数总和
op sum(out INT, in INT, in INT, in INT) { $1 = $2 + $3 + $4; }
Nums 被转换为普通的浮点数,因此可以直接传递给需要浮点数或双精度浮点数的函数。同样,INTs 只是基本整数,可以像这样对待。但是,PMC 和 STRING 是复杂的值。不能将鹦鹉 STRING 传递给需要以空字符结尾的 C 字符串的库函数。以下代码是错误的
#include <string.h> op my_str_length(out INT, in STR) { $1 = strlen($2); // WRONG! }
当我们谈论上面的参数类型时,我们并没有完全完整。以下是您可以在 op 中包含的指示限定符列表
方向 | 含义 | 例子 |
---|---|---|
in | 参数是输入 | op my_op(in INT) |
out | 参数是输出 | op pi(out NUM) { $1 = 3.14; } |
inout | 参数是输入和输出 | op increment(inout INT) { $1 = $1 + 1; } |- | inconst || The input parameter is constant, it is not modified | <pre> op double_const(out INT, inconst INT) { $1 = $2 + $2; } 以及在 PIR 中 $I0 = double_const 5 # numeric literal "5" is a constant |
invar | 输入参数是一个变量,例如 PMC | op my_op(invar PMC) |
参数的类型也可以是以下几种选项之一
类型 | 含义 | 例子 |
---|---|---|
INT | 整数值 | 42 或 $I0 |
NUM | 浮点数 | 3.14 或 $N3 |
STR | 字符串 | "Hello" 或 $S4 |
PMC | PMC 变量 | $P0 |
KEY | 哈希键 | ["name"] |
INTKEY | 整数索引 | [5] |
LABEL | 代码中要跳转到的位置 | jump_here |
只要参数不同,就可以有多个具有相同名称的操作。以下两个声明是正确的
op my_op (out INT, in INT) { }
op my_op (out NUM, in INT) { }
操作编译器将这些操作声明转换为类似于以下 C 函数声明的内容
INTVAL op_my_op_i_i(INTVAL param1) { }
NUMBER op_my_op_n_i(INTVAL param1) { }
请注意函数名称末尾的 "_i_i" 和 "_n_i" 后缀?这是鹦鹉确保函数名称在系统中唯一的机制,以防止编译器问题。这也可以轻松地查看函数签名并了解它接受哪些类型的操作数。
操作码可以确定执行完成后控制流将转移到哪里。对于大多数操作码,默认行为是转移到内存中的下一个指令。但是,有多种方法可以改变控制流,其中一些方法非常新颖且奇特。有几个关键字可以用来获取操作的地址。然后我们可以直接 goto
该指令,或者我们可以存储该地址并稍后跳转到它。
关键字 | 含义 |
---|---|
NEXT() | 跳转到内存中的下一个操作码 |
ADDRESS(a) | 跳转到 a 给出的操作码。a 的类型为 opcode_t* 。 |
OFFSET(a) | 跳转到当前偏移量 a 偏移量给出的操作码。a 通常是类型 in LABEL 。 |
POP() | 获取控制堆栈顶部的地址。此功能正在弃用,最终鹦鹉将在内部无堆栈。 |
操作码编译器位于 dev/build/ops2c.pl
中,但其大多数功能位于各种包含的库中,例如 Parrot::OpsFile
。Parrot::Ops2c::*
和 Parrot::OpsTrans::*
。
我们将在下一节中介绍不同的运行核心。需要说明的是,不同的运行核心要求操作码被编译成不同的格式才能执行。因此,操作码编译器的任务比较复杂:它必须读取操作码描述文件并以多种不同的输出格式输出语法正确的 C 代码。
到目前为止,我们一直在讨论的都是标准的内置操作。但是,这些并不是唯一可用的操作,鹦鹉还允许在运行时加载动态操作库。
dynops 是动态加载的操作库。它们几乎与常规的内置操作写法相同,但它们被单独编译成库,并在运行时使用 `loadlib` 指令加载到 Parrot 中。
运行核心是解码和执行 PBC 文件中操作码流的程序。在最简单的情况下,运行核心是一个循环,它接收每个字节码值,从 PBC 流中收集参数数据,并将控制权传递给操作码例程以执行。
有多种不同的操作核心。有些非常实用和简单,有些使用特殊技巧和编译器功能来优化速度。有些操作核心执行有用的辅助任务,如调试和分析。有些运行核心没有实际用途,只是为了满足一些基本的学术兴趣。
- 慢速核心
- 在慢速核心中,每个操作码都被编译成一个单独的函数。每个操作码函数接受两个参数:指向当前操作码的指针,以及 Parrot 解释器结构。操作码的所有参数都被解析并存储在解释器结构中,以便检索。顾名思义,这个核心非常慢。但是,它的概念非常简单,而且非常稳定。由于这个原因,慢速核心被用作我们将在后面讨论的一些专用核心的基础。
- 快速核心
- 快速核心与慢速核心完全相同,只是它没有执行慢速核心执行的边界检查和显式上下文更新。
- 切换核心
- 切换核心使用一个巨大的 C `switch { }` 语句来处理操作码调度,而不是使用单独的函数。好处是,不需要为每个操作码调用函数,从而节省了调用操作码所需的机器代码指令数量。
- JIT 核心
- Exec 核心
接下来我们将讨论的两个核心依赖于某些编译器的专用功能,称为**计算 goto**。在正常的 ANSI C 中,标签是控制流语句,不被视为一等公民。但是,支持计算 goto 的编译器允许将标签视为指针,存储在变量中,并间接跳转到它们。
void * my_label = &&THE_LABEL; goto *my_label;
计算 goto 核心将所有操作码编译成一个大的函数,每个操作码对应于函数中的一个标签。所有这些标签都存储在一个大型数组中
void *opcode_labels[] = { &&opcode1, &&opcode2, &&opcode3, ... };
然后,每个操作码值都可以作为此数组的偏移量,如下所示
goto *opcode_labels[current_opcode];
- 计算 goto 核心
- 计算 goto 核心使用上面描述的机制来调度不同的操作码。在执行完每个操作码后,会查找传入字节码流中的下一个操作码,并从那里进行调度。
- 预引用计算 goto 核心
- 在预计算 goto 核心中,字节码流被预处理以将操作码编号转换为相应的标签。这意味着它们不需要每次都查找,操作码可以直接跳转到,就像它是一个标签一样。请记住,调度机制必须在每个操作码之后使用,在大型程序中可能存在数百万个操作码。即使在操作码之间的机器代码指令数量上略有节省,也会对速度产生巨大影响。
- GC 调试核心
- 调试器核心
- 分析核心
- 跟踪核心