鹦鹉虚拟机/运行核心和操作码
我们之前已经讨论过运行核心,但在本章中,我们将深入讨论它们。在这里,我们将讨论操作码,以及将操作码转换为标准 C 代码的特殊操作码编译器。我们还将看看这些操作码是如何被操作码编译器翻译成不同形式的,以及我们将看到执行这些操作码的不同运行核心。
操作码使用一种非常特殊的语法编写,该语法混合了 C 和特殊关键字。操作码由操作码编译器 tools/dev/ops2c.pl
转换为不同运行核心所需的格式。
鹦鹉的核心操作码都在 src/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 | 处理词法和全局变量的操作 |
操作使用 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 和一个整数,并返回一个数字。请注意参数没有名称。相反,它们对应于数字
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 传递给需要以 null 结尾的 C 字符串的库函数。以下操作无效
#include <string.h> op my_str_length(out INT, in STR) { $1 = strlen($2); // WRONG! }
当我们在上面谈论参数类型时,我们没有完全完整。以下是在您的操作中可以使用的方向限定符列表
方向 | 意义 | 示例 |
---|---|---|
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
指令加载到鹦鹉中。
运行核心是解码和执行 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 调试核心
- 调试器核心
- 分析核心
- 跟踪核心