[修仙者篇] AMX 汇编文档 - 小鸟unsigned - 03-21-2026
AMX 汇编
AMX 版本: 3.0
AMX 文件版本: 8
本文档是关于抽象机执行器(Abstract Machine eXecutor)汇编的非官方文档。本文档的内容可能不准确且可能随时更改。术语部分中定义的部分术语在官方手册中并未出现。它们是为了帮助读者理解而引入的辅助术语。
本文档的目标读者是首次接触汇编语言的程序员。他们应根据需要点击超链接以收集背景信息。高级用户可跳过部分章节或将本文档用作快速参考指南。
目录
先决条件
术语
<a name="term-amx"></a>抽象机执行器 (AMX):AMX 是我们感兴趣的抽象机(或虚拟机)。
<a name="term-amx-assembly"></a>AMX 汇编:AMX 的汇编语言。
<a name="term-amx-binary"></a>AMX 二进制:AMX 二进制文件是编译后生成的可执行文件(通常扩展名为 .amx)。术语 AMX 程序在某些上下文中可能指代 AMX 二进制文件。
<a name="term-amx-instance"></a>AMX 实例:每个加载的 AMX 二进制文件独立存在。加载后的程序称为 AMX 实例。术语 AMX 程序在某些上下文中可能指代 AMX 实例。
<a name="term-host-program"></a>宿主程序:嵌入 AMX 的程序称为宿主程序。以 San Andreas Multiplayer (SA-MP) 或 open.mp 为例,服务器就是宿主程序。
<a name="term-extension-module"></a>扩展模块:扩展模块是一个独立的模块,通常动态链接到宿主程序,提供额外的原生函数。扩展模块通常被称为插件。
基本概念
- ## <a name="concept-cell"></a> 单元格概念
Pawn 是一种无类型语言:没有数据类型。所有数据都作为原始二进制数据存储在一个单元格或一组单元格中。我们将使用有符号二进制补码表示法来传达一个或多个单元格的内容,而不是显式地用二进制写出来。
上述代码创建了两个独立的单元格,分别由 a 和 b 标识。单元格 a 将包含值 5,单元格 b 将包含值 65(字符 'A' 的 ASCII 等效值)。
为了弥补数据类型的缺失,Pawn 提供了为单元格添加标签的能力。这些标签仅仅是编译时的辅助工具,不会直接<sup>1</sup>出现在 AMX 二进制文件中。
代码: new Float:x = 5.0, Float:y = 10.0, Float:z;
z = x + y;
上述代码创建了三个带有标签的单元格,由 x、y 和 z 标识。它们分别被初始化为值 5.0、10.0 和 0.0(默认初始化为二进制零,也是 0.0),其二进制表示符合浮点数的正确表示。编译器会记住遇到的变量的标签,并确保在变量被使用时生成正确的代码 <sup>2</sup>。在上述例子中,编译器在处理第二行时,会生成正确的代码来执行两个浮点数 x 和 y 的加法。请记住,整数加法和浮点数加法的执行方式不同。编译器使用与单元格关联的标签来确定应对操作数执行哪种加法操作。
编译完成后,变量的标签信息会丢失。生成的 AMX 二进制文件将包含对 x 和 y 中存储的数据执行浮点数加法并将结果存储在 z 中的指令。
在运行时,浮点数加法会在不考虑脚本中 x 和 y 的标签原本是否为 Float 的情况下执行。
<a name="bc-cc-note-1"></a> [[1]](#bc-cc-note-1) Pawn 语言提供了 tagof 运算符,它返回与标签关联的编译时 id,该 id 可以被存储。编译器还会在 AMX 二进制文件的标签表中创建一个公开可访问的标签列表。
<a name="bc-cc-note-2"></a> [[2]](#bc-cc-note-2) Pawn 语言允许基于标签的运算符重载。编译器知道所有存在的运算符重载,并根据操作数的标签调用正确的重载。如果给定的操作数没有重载存在,编译器将默认使用无标签单元格的运算符(即整数算术)。
- ## <a name="concept-memory-addresses"></a> 内存地址
AMX 遵循平坦的字节可寻址内存模型,即 AMX 内存可以被认为是一个线性的字节集合
(平坦内存),每个字节都有一个唯一的地址(字节可寻址)。
上述代码创建了一个由五个单元格组成的数组,由 a 标识。这些单元格连续存储,即一个接一个地存储。假设单元格大小为 4 字节,如果存储 a[0] 的位置地址是 1000,那么存储 a[1] 的位置地址将是 1004。
代码: data: a[0] | a[1] | a[2] | a[3] | a[4]
address: 1000 1004 1008 1012 1016
所需知识:基地址,相对地址
所需知识:数组数据结构,元素标识符和寻址公式
另请参阅:字节序
- ## <a name="concept-stack-heap"></a> 栈和堆
每个 AMX 实例都包含一个内部 程序栈(也称为调用栈)和程序堆。这些是 AMX 程序中的内存区域,专用于一组任务。
栈负责:- 存储局部变量
- 存储函数参数
- 存储函数调用信息(例如参数个数)
- 提供临时存储
堆负责:- 为动态分配提供内存
- 存储作为引用/数组传递的默认参数
- 存储不是左值且作为可变参数传递的常量和表达式
###
代码: f(argc, ...) { }
main ()
{
new a, b, c[100];
f(1, 25);
}
在上面的代码片段中,变量 a、b、c 和常量 1 在栈上分配空间。常量 25 在堆上分配空间。引用和可变参数作为地址传递给函数。由于字面量 25 是一个常量且没有地址,因此在堆上分配了一个临时单元格来存储值 25。该单元格的地址被传递给函数。
- ## <a name="concept-instructions"></a> 指令
指令是离散的原子执行单元。大多数指令执行简单的任务,如基本算术。高级结构(如循环和条件语句)可以简化为一系列简单的指令。
例如,将值压入调用栈然后弹出的指令可能是:
汇编器为上述代码片段生成的二进制代码可能是:
push-opcode | 100 | pop-opcode
0x0013 0x0064 0x0014 或者可能是 0000 0000 0000 0000 0000 0000 0001 0011 0000 0000 0000 0000 0000 0000 0110 0100 0000 0000 0000 0000 0000 0000 0001 0100
是的,它只是一系列比特位,CPU(或者在我们这里是抽象机)知道如何解释这些比特位。当为物理硬件上的执行汇编时,这种表示被称为机器码(或原生码)。在为假想 CPU(如 AMX)上的执行汇编时,这种表示被称为 p-code。
另请参阅: 字节码
- ## <a name="concept-spc-vna"></a> 存储程序概念与冯·诺依曼体系结构
代码像数据一样驻留在内存中的想法被称为存储程序概念。这也意味着内存中的每条指令都有一个地址,就像数据一样。代码和数据都驻留在同一内存中的想法是冯·诺依曼体系结构的一个原则。这也意味着所有代码和数据共享一个地址空间。
AMX 使用一个公共内存来存储代码和数据。
- ## <a name="concept-hdc"></a> 头、数据段、代码段
可执行二进制文件及程序的内存结构根据其包含的内容被划分为逻辑上不同的区域。一个典型的二进制可执行文件至少包含一个头、一个代码段和一个数据段。头提供了程序本身的信息,例如程序入口点。
代码: HEADER
-------
CODE
-------
DATA
代码段以二进制形式(机器码/p-code)存储代码,而数据段存储数据,如全局变量和静态局部变量。
程序的内存结构通常不同。除了代码段和数据段外,它通常还包含程序栈和程序堆的区域。
代码: HEADER
-------
CODE
-------
DATA
-------
HEAP
|
STACK
AMX 二进制文件组织为三个部分:前缀、代码和数据(按顺序)。前缀部分包含有关 AMX 二进制文件的信息,例如代码和数据部分在二进制文件中的起始偏移量。代码部分存储 p-code,数据段存储全局变量和静态局部变量。
堆栈区域是在 AMX 二进制文件加载到内存后,使用前缀部分中的信息创建的。
另请参阅:内存分段,数据段,代码段,ELF 格式
- ## <a name="concept-registers"></a> 寄存器
了解处理器和内存位于不同的位置至关重要。
代码: |-------| |------------------|
| CPU | =============== | MEMORY |
|-------| BUS |------------------|
内存存储数据,CPU 对数据进行操作。对于每次操作,处理器都必须将操作数从内存中取出并临时存储在 CPU 内部。这些临时存储位置被称为寄存器。这些寄存器可以容纳少量数据,范围在几个字节(通常为 2、4 或 8 字节)。
假设处理器必须将两个变量 x 和 y 相加,并将结果存储在变量 z 中。一个典型的处理器会执行以下操作:
- 从内存中取出
x 的值(即从 x 的地址读取)并将其存储在一个寄存器中,比如 R1
- 从内存中取出
y 的值(即从 y 的地址读取)并将其存储在一个寄存器中,比如 R2
- 将
R1 和 R2 的内容相加,并将结果存储在某寄存器中,比如 R3
- 将
R3 的内容存储到 z 中(写入 z 的地址)
处理器包含不同类型的寄存器,用于不同目的。一些寄存器专用于特定任务,一些寄存器可用于任何目的。上例中使用的那类寄存器属于通用寄存器。
AMX 的寄存器集包括以下寄存器:- 主寄存器 (PRI):通用寄存器(常用作累加器寄存器)
- 辅助寄存器 (ALT):通用寄存器(常用作地址寄存器)
- 代码段寄存器 (COD):内存中代码段起始的绝对地址
- 数据段寄存器 (DAT):内存中数据段起始的绝对地址
- 当前指令指针 (CIP):下一条要执行指令的地址(相对于 COD 寄存器)
- 栈顶寄存器 (STP):栈顶的地址(相对于 DAT 寄存器)
- 栈指针寄存器 (STK):栈上当前位置的地址(相对于 DAT 寄存器)
- 帧指针寄存器 (FRM):栈中当前函数帧起始的地址(相对于 DAT 寄存器)(稍后解释)
- 堆指针 (HEA):堆顶的地址(相对于 DAT 寄存器)
在编写汇编代码时,地址通常不是绝对的;它们是相对于某个段或帧指针寄存器的。全局变量和字符串的地址相对于 DAT 寄存器。函数、指令和标签的地址相对于 COD 寄存器。这意味着变量或指令的绝对地址分别是相对地址加上 DAT 或 COD 寄存器的值。局部变量的地址相对于帧指针寄存器。这有点复杂,稍后会解释。
从现在开始,当我们讨论代码地址和数据地址时,除非明确说明,否则暗示这些地址分别相对于 COD 寄存器和 DAT 寄存器。
文件与内存布局
所有非调试 AMX 二进制文件按如下方式组织:
代码: START OF BINARY FILE
| ---------------------- |
PREFIX
| ---------------------- |
CODE
| ---------------------- |
DATA
| ---------------------- |
END OF BINARY FILE
- 前缀:包含程序的基本信息
- 代码:包含代码
- 数据:包含数据
注意:根据编译器的选项,前缀部分可能会被填充以使代码和数据部分对齐。
注意:启用调试编译的脚本的二进制映像将在末尾附加符号调试信息。更多详情请参阅 Pawn 实现者指南。
内存中的结构采用与二进制文件类似的结构,但额外包含一个栈和一个堆,这是使用前缀中的信息设置的。
代码: LOW ADDRESS (0)
| ---------------------- |
PREFIX
| ---------------------- |
CODE
| ---------------------- |
DATA
| ---------------------- |
HEAP
| |
| |
FREE SPACE
| |
| |
STACK
| ---------------------- |
HIGH ADDRESS
堆和栈共享一个公共的内存区域。它们从该区域的两端开始,并向相反方向增长。堆向高地址增长,栈向低地址增长。由于它们共享同一内存区域,因此可能会相互覆盖(当堆指针和栈指针碰撞时)。发生这种情况时,AMX 会中止并显示如下错误信息:
代码: Script[gamemodes/TEST.amx]: Run time error 3: "Stack/heap collision (insufficient stack size)"
根据编译器掌握的信息,它会估算并间接地在 AMX 二进制文件的前缀部分设置堆栈大小。然而,程序员可以使用 #pragma dynamic [estimated number of cells] 指令提供自己对所需堆栈区域大小的估计。
前缀部分的字段
| 类型 |
大小 |
描述 |
| size |
4 |
内存映像的大小;不包括栈和堆 |
| magic |
2 |
指示格式和单元格大小 |
| file version |
1 |
格式版本 |
| amx version |
1 |
抽象机所需的最低版本 |
| flags |
2 |
是否存在符号调试信息、紧凑编码等。 |
| defsize |
2 |
表中记录的大小 |
| cod |
4 |
代码部分起始的偏移量 |
| dat |
4 |
数据部分起始的偏移量 |
| hea |
4 |
HEA 寄存器的初始值(标记数据段结束) |
| stp |
4 |
STP 寄存器的初始值(指示总内存需求) |
| cip |
4 |
CIP 寄存器的初始值(main 函数的地址;如果不存在则为 -1) |
| publics |
4 |
公共函数表起始的偏移量 |
| natives |
4 |
原生函数表起始的偏移量 |
| libraries |
4 |
库表起始的偏移量 |
| pubvars |
4 |
公共变量表起始的偏移量 |
| tags |
4 |
标签表起始的偏移量 |
| name table |
4 |
名称表起始的偏移量 |
| overlays |
4 |
覆盖表起始的偏移量 |
| publics |
可变 |
公共函数列表 |
| natives |
可变 |
原生函数列表 |
| libraries |
可变 |
库列表 |
| pubvars |
可变 |
公共变量列表 |
| tags |
可变 |
公共标签列表 |
| overlays |
可变 |
覆盖列表 |
| name table |
可变 |
符号名称列表 |
注意:关于 magic、version 和 flags 字段中信息如何编码的详细信息,请参阅 Pawn 实现者指南。
注意:前缀中的所有多字节字段都以小端格式存储,无论 AMX 二进制文件是在哪个平台上生成或将在哪个平台上执行。
可以看出,前缀由一个固定部分组成,后面跟着一系列表。除了名称表之外,表中的每条记录都由两个字段组成,如下所示:
| 字段 |
大小 |
描述 |
| 变量 |
单元格大小 |
变量 |
| 名称字符串偏移量 |
4 字节 |
从前缀起始到名称表中字符串的偏移量 |
表中每条记录(除了名称表)的大小由前缀中的 defsize 字段给出:defsize = 4 + 单元格大小。
表中的每条记录都被分配了一个索引号。每个表的第一个记录被分配索引 0,后续记录的索引号是前一个记录索引加 1。
名称表:
除了名称表本身之外,其他表中的记录包含一个指向字符串的指针。这些字符串作为以空字符结尾的 C 字符串存储在名称表中。此表中记录的大小是其包含的字符串的大小。
公共函数表:
为脚本中定义的每个公共函数创建一条记录。这些记录包含公共函数的地址(函数第一条指令的地址)和指向相应公共函数名称的偏移量。表中记录的索引就是相应公共函数的索引。
原生函数表:
为脚本中调用的每个原生函数创建一条记录。记录的第一个字段由编译器在二进制文件中设置为 0,但宿主程序在加载二进制文件时会将该字段初始化为函数的地址(位于宿主程序的地址空间中)。第二个字段包含指向原生函数名称的偏移量。表中记录的索引就是相应原生函数的索引。
库表:
Pawn 语言提供了一个 pragma 指令 #pragma directive [library name],用于告知编译器调用的某些原生函数需要扩展模块。对于在脚本中调用了其原生函数的库,会在库表中创建一条记录。目的是告知宿主程序 AMX 程序依赖于一个扩展模块。记录的第一个字段供内部使用,在 AMX 二进制文件中设置为 0。另一个字段包含从前缀起始到名称表中库名称的偏移量。
公共变量表:
为脚本中声明的每个公共变量创建一条记录。该记录包含公共变量的地址和指向公共变量名称的偏移量。表中记录的索引就是公共变量的索引。
标签表:
与 sleep 和 exit 语句一起使用的标签以及与 tagof 运算符一起使用的标签会被导出。为每个这样的标签创建一条记录。第一个字段包含标签 id 号,第二个字段存储指向标签名称的偏移量。
指令集
<a name="pawn-inline-assembly"></a> Pawn 编译器接受的大部分 AMX 汇编指令可以用以下格式表示:
代码: mnemonic[.prefix][.register suffix] operand
SHL.C.pri 3
ZERO.alt
ADD.C 100
LIDX
助记符给出了指令功能的提示。可选的前缀指示指令操作数的类型。可选的尾缀指示指令主要作用于哪个寄存器。
前缀列表:- .C = 常量
- .S = 栈
- .I = 间接寻址
- .B = 无 B 变体的变体<sup>\[需要更好的描述\]</sup>
- .ADR = 地址
- .R = 重复
寄存器尾缀列表:
每条指令的二进制形式需要一个单元格来存储操作码,每个操作数还需要一个额外的单元格。绝大多数指令使用隐含寄存器作为操作数。这减少了所需显式操作数的数量,从而减小了代码段的大小并提高了性能。
<a name="instruction-table"></a> 阅读表格:
- [address] 指的是存储在位置
DAT + address 的值
- 语义列中使用的运算符执行与 Pawn 中相同的操作
| 1 |
LOAD.pri |
address |
PRI = [address] |
| 2 |
LOAD.alt |
address |
ALT = [address] |
| 3 |
LOAD.S.pri |
offset |
PRI = [FRM + offset] |
| 4 |
LOAD.S.alt |
offset |
ALT = [FRM + offset] |
| 5 |
LREF.pri |
address |
PRI = [[address]] |
| 6 |
LREF.alt |
address |
ALT = [[address]] |
| 7 |
LREF.S.pri |
offset |
PRI = [[FRM + offset]] |
| 8 |
LREF.S.alt |
offset |
ALT = [[FRM + offset]] |
| 9 |
LOAD.I |
|
PRI = [PRI] |
| 10 |
LODB.I |
number |
PRI = 'number' of bytes from [PRI] (read 1/2/4 bytes) |
| 11 |
CONST.pri |
value |
PRI = value |
| 12 |
CONST.alt |
value |
ALT = value |
| 13 |
ADDR.pri |
offset |
PRI = FRM + offset |
| 14 |
ADDR.alt |
offset |
ALT = FRM + offset |
| 15 |
STOR.pri |
address |
[address] = PRI |
| 16 |
STOR.alt |
address |
[address] = ALT |
| 17 |
STOR.S.pri |
offset |
[FRM + offset] = PRI |
| 18 |
STOR.S.alt |
offset |
[FRM + offset] = ALT |
| 19 |
SREF.pri |
address |
[[address]] = PRI |
| 20 |
SREF.alt |
address |
[[address]] = ALT |
| 21 |
SREF.S.pri |
offset |
[[FRM + offset]] = PRI |
| 22 |
SREF.S.alt |
offset |
[[FRM + offset]] = ALT |
| 23 |
STOR.I |
|
[ALT] = PRI (full cell) |
| 24 |
STRB.I |
number |
number of bytes at [ALT] = PRI (store 1/2/4 bytes) |
| 25 |
LIDX |
|
PRI = [ALT + (PRI x cell size)] |
| 26 |
LIDX.B |
shift |
PRI = [ALT + (PRI << shift)] |
| 27 |
IDXADDR |
|
PRI = ALT + (PRI x cell size) (calculate indexed address) |
| 28 |
IDXADDR.B |
shift |
PRI = ALT + (PRI << shift) (calculate indexed address) |
| 29 |
ALIGN.pri |
number |
Little Endian: PRI ^= cell size - number |
| 30 |
ALIGN.alt |
number |
Little Endian: ALT ^= cell size - number |
| 31 |
LCTRL |
index |
PRI = value contained in the selected register; 1=COD, 1=DAT, 2=HEA,3=STP, 4=STK, 5=FRM, 6=CIP |
| 32 |
SCTRL |
index |
selected register = PRI; 2=HEA, 4=STK, 5=FRM, 6=CIP |
| 33 |
MOVE.pri |
|
PRI = ALT |
| 34 |
MOVE.alt |
|
ALT = PRI |
| 35 |
XCHG |
|
Exchange contents of PRI and ALT |
| 36 |
PUSH.pri |
|
STK = STK - cell size, [STK] = PRI |
| 37 |
PUSH.alt |
|
STK = STK - cell size, [STK] = ALT |
| 38 |
PUSH.R |
number |
repeat (STK = STK - cell size, [STK] = PRI) 'number' times |
| 39 |
PUSH.C |
value |
STK = STK - cell size, [STK] = value |
| 40 |
PUSH |
address |
STK = STK - cell size, [STK] = [address] |
| 41 |
PUSH.S |
offset |
STK = STK - cell size, [STK] = [FRM + offset] |
| 42 |
POP.pri |
|
PRI = [STK], STK = STK + cell size |
| 43 |
POP.alt |
|
ALT = [STK], STK = STK + cell size |
| 44 |
STACK |
value |
ALT = STK, STK = STK + value |
| 45 |
HEAP |
value |
ALT = HEA, HEA = HEA + value |
| 46 |
PROC |
|
STK = STK - cell size, [STK] = FRM, FRM = STK |
| 47 |
RET |
|
FRM = [STK], STK = STK + cell size, CIP = [STK], STK = STK + cell size |
| 48 |
RETN |
|
FRM = [STK], STK = STK + cell size, CIP = [STK], STK = STK + cell size, STK = STK + [STK] + cell size |
| 49 |
CALL |
offset |
STK = STK − cell size, [STK] = CIP, CIP = offset |
| 50 |
CALL.pri |
|
STK = STK − cell size, [STK] = CIP, CIP = PRI |
| 51 |
JUMP |
offset |
CIP = offset |
| 53 |
JZER |
offset |
if PRI == 0 then CIP = offset |
| 54 |
JNZ |
offset |
if PRI != 0 then CIP = offset |
| 55 |
JEQ |
offset |
if PRI == ALT then CIP = offset |
| 56 |
JNEQ |
offset |
if PRI != ALT then CIP = offset |
| 57 |
JLESS |
offset |
if PRI < ALT (unsigned) then CIP = offset |
| 58 |
JLEQ |
offset |
if PRI <= ALT (unsigned) then CIP = offset |
| 59 |
JGRTR |
offset |
if PRI > ALT (unsigned) then CIP = offset |
| 60 |
JGEQ |
offset |
if PRI >= ALT (unsigned) then CIP = offset |
| 61 |
JSLESS |
offset |
if PRI < ALT (signed) then CIP = offset |
| 62 |
JSLEQ |
offset |
if PRI <= ALT (signed) then CIP = offset |
| 63 |
JSGRTR |
offset |
if PRI > ALT (signed) then CIP = offset |
| 64 |
JSGEQ |
offset |
if PRI >= ALT (signed) then CIP = offset |
| 65 |
SHL |
|
PRI = PRI << ALT |
| 66 |
SHR |
|
PRI = PRI >> ALT (without sign extension) |
| 67 |
SSHR |
|
PRI = PRI >> ALT (with sign extension) |
| 68 |
SHL.C.pri |
value |
PRI = PRI << value |
| 69 |
SHL.C.alt |
value |
ALT = ALT << value |
| 70 |
SHR.C.pri |
value |
PRI = PRI >> value |
| 71 |
SHR.C.alt |
value |
ALT = ALT >> value |
| 72 |
SMUL |
|
PRI = PRI * ALT (signed multiply) |
| 73 |
SDIV |
|
PRI = PRI / ALT (signed divide), ALT = PRI mod ALT |
| 74 |
SDIV.alt |
|
PRI = ALT / PRI (signed divide), ALT = ALT mod PRI |
| 75 |
UMUL |
|
PRI = PRI * ALT (unsigned multiply) |
| 76 |
UDIV |
|
PRI = PRI / ALT (unsigned divide), ALT = PRI mod ALT |
| 77 |
UDIV.alt |
|
PRI = ALT / PRI (unsigned divide), ALT = ALT mod PRI |
| 78 |
ADD |
|
PRI = PRI + ALT |
| 79 |
SUB |
|
PRI = PRI - ALT |
| 80 |
SUB.alt |
|
PRI = ALT - PRI |
| 81 |
AND |
|
PRI = PRI & ALT |
| 82 |
OR |
|
PRI = PRI | ALT |
| 83 |
XOR |
|
PRI = PRI ^ ALT |
| 84 |
NOT |
|
PRI = !PRI |
| 85 |
NEG |
|
PRI = -PRI |
| 86 |
INVERT |
|
PRI = ~PRI |
| 87 |
ADD.C |
value |
PRI = PRI + value |
| 88 |
SMUL.C |
value |
PRI = PRI * value |
| 89 |
ZERO.pri |
|
PRI = 0 |
| 90 |
ZERO.alt |
|
ALT = 0 |
| 91 |
ZERO |
address |
[address] = 0 |
| 92 |
ZERO.S |
offset |
[FRM + offset] = 0 |
| 93 |
SIGN.pri |
|
sign extend the byte in PRI to a cell |
| 94 |
SIGN.alt |
|
sign extend the byte in ALT to a cell |
| 95 |
EQ |
|
PRI = PRI == ALT ? 1 : 0 |
| 96 |
NEQ |
|
PRI = PRI != ALT ? 1 : 0 |
| 97 |
LESS |
|
PRI = PRI < ALT ? 1 : 0 (unsigned) |
| 98 |
LEQ |
|
PRI = PRI <= ALT ? 1 : 0 (unsigned) |
| 99 |
GRTR |
|
PRI = PRI > ALT ? 1 : 0 (unsigned) |
| 100 |
GEQ |
|
PRI = PRI >= ALT ? 1 : 0 (unsigned) |
| 101 |
SLESS |
|
PRI = PRI < ALT ? 1 : 0 (signed) |
| 102 |
SLEQ |
|
PRI = PRI <= ALT ? 1 : 0 (signed) |
| 103 |
SGRTR |
|
PRI = PRI > ALT ? 1 : 0 (signed) |
| 104 |
SGEQ |
|
PRI = PRI >= ALT ? 1 : 0 (signed) |
| 105 |
EQ.C.pri |
value |
PRI = PRI == value ? 1 : 0 |
| 106 |
EQ.C.alt |
value |
PRI = ALT == value ? 1 : 0 |
| 107 |
INC.pri |
|
PRI = PRI + 1 |
| 108 |
INC.alt |
|
ALT = ALT + 1 |
| 109 |
INC |
address |
[address] = [address] + 1 |
| 110 |
INC.S |
offset |
[FRM + offset] = [FRM + offset] + 1 |
| 111 |
INC.I |
|
[PRI] = [PRI] + 1 |
| 112 |
DEC.pri |
|
PRI = PRI - 1 |
| 113 |
DEC.alt |
|
ALT = ALT - 1 |
| 114 |
DEC |
address |
[address] = [address] - 1 |
| 115 |
DEC.S |
offset |
[FRM + offset] = [FRM + offset] - 1 |
| 116 |
DEC.I |
|
[PRI] = [PRI] - 1 |
| 117 |
MOVS |
number |
copy 'number' bytes of non-overlapping memory from [PRI] to [ALT] |
| 118 |
CMPS |
number |
compare 'number' bytes of non-overlapping memory at [PRI] with [ALT] |
| 119 |
FILL |
number |
fill 'number' bytes of memory from [ALT] with value in PRI (number must be multiple of cell size) |
| 120 |
HALT |
0 |
abort execution (exit value in PRI) |
| 121 |
BOUNDS |
value |
abort execution if PRI > value or if PRI < 0 |
| 122 |
SYSREQ.pri |
|
call system service, service number in PRI |
| 123 |
SYSREQ.C |
value |
call system service |
| 128 |
JUMP.pri |
|
CIP = PRI |
| 129 |
SWITCH |
offset |
compare PRI to the values in the case table (whose address is passed in the 'offset' argument) and jump to the associated address in the matching record |
| 130 |
CASETBL |
... |
a variable number of case records follows this opcode, where each record takes two cells |
| 131 |
SWAP.pri |
|
[STK] = PRI, PRI = [STK] |
| 132 |
SWAP.alt |
|
[STK] = ALT, ALT = [STK] |
| 133 |
PUSH.ADR |
offset |
STK = STK - cell size, [STK] = FRM + offset |
| 134 |
NOP |
|
no operation |
加载/存储指令
当使用全局变量作为操作数时,编译器会替换该变量的地址。下面代码中的第一条加载指令,如果 some_global 的地址是 1288,则实际上变成 #emit LOAD.pri 1288。请注意,编译器替换的地址是从数据段起始的偏移量;因此,该地址是相对于 DAT 寄存器的。
代码: new some_global = 10, another_global = 25;
main()
{
#emit LOAD.pri some_global // 将 'some_global' 的值加载到主寄存器
#emit LOAD.alt another_global // 将 'another_global' 的值加载到辅助寄存器
}
当使用局部变量时,编译器会替换从函数帧起始的偏移量。(稍后解释)
代码: main()
{
static s_local = 20; // 静态局部变量存储在数据段中
new some_local = 10, another_local = 25; // 局部变量存储在栈中
#emit LOAD.S.pri some_local // 将 'some_local' 的值加载到主寄存器
#emit LOAD.S.alt another_local // 将 'another_local' 的值加载到辅助寄存器
#emit LOAD.pri s_local // 注意静态局部变量的行为类似于全局变量
}
由于编译器会为全局变量替换地址,因此可以使用 CONST.pri/CONST.alt 来获取这些变量的地址。
代码: new some_global;
main()
{
#emit CONST.pri 10 // 将 10 放入主寄存器
#emit CONST.alt 50 // 将 50 放入辅助寄存器
#emit CONST.pri some_global // 将 'some_global' 的地址存储在主寄存器中
#emit CONST.alt some_global // 将 'some_global' 的地址存储在辅助寄存器中
}
代码: new some_global;
main()
{
new some_local;
#emit ZERO.pri // 将零存储在主寄存器中
#emit STOR.pri some_global // 将主寄存器中存储的值(本例中为零)设置给 'some_global'
#emit CONST.alt 125
#emit STOR.S.alt some_local // 将辅助寄存器中存储的值(125)设置给 'some_local'
}
索引指令
代码: new global_arr[10];
main ()
{
#emit CONST.alt global_arr // 将 'global_arr' 的地址('global_arr' 第一个元素的地址)加载到辅助寄存器中
#emit CONST.pri 2 // 设置我们感兴趣的 'global_arr' 元素的索引
#emit LIDX // 现在主寄存器中存储的是 'global_arr[2]` 处存储的值
#emit CONST.alt global_arr // 将 'global_arr' 的地址(第一个元素的地址)加载到辅助寄存器中
#emit CONST.pri 2 // 设置我们感兴趣的 'global_arr' 元素的索引
#emit IDXADDR // 现在主寄存器中存储的是 'global_arr[2]` 的地址
}
算术指令
代码: main ()
{
#emit CONST.pri 4
#emit CONST.alt 5
#emit SMUL // 现在主寄存器中是 20(SMUL 执行有符号乘法;UMUL 执行无符号乘法)
#emit ADD // 将 5 加到 20(由于上一条指令,主寄存器中存储的是 20)
#emit ADD.C 10 // 将 10 加到主寄存器;现在主寄存器中是 35
#emit SUB.alt // 用 5 减去 35;现在主寄存器中是 -30
#emit SMUL.C 2 // -30 乘以 2;得到 -60
}
逻辑指令
代码: main ()
{
#emit CONST.pri 5 // .. 0000 0101
#emit CONST.alt 3 // ... 0000 0011
#emit AND // 主寄存器现在包含 ... 0000 0001
#emit XOR // 主寄存器现在包含 ... 0000 0110
#emit INVERT // 对主寄存器中存储的值取反码
#emit NEG // 对主寄存器中存储的值取补码(本质上是取负)
}
关系指令
代码: main ()
{
#emit CONST.pri 5
#emit CONST.alt 8
#emit EQ // 如果 5 == 8,则将主寄存器设置为 1,否则为 0
#emit LESS // 如果 0 < 8,则将主寄存器设置为 1,否则为 0
}
栈操作指令
局部变量存储在栈上。详细说明将在后续章节中提供,但现在我们假设使用 CONST.pri some_local 会得到某个偏移量,该偏移量加上帧寄存器中存储的基地址即得数据地址。
代码: main ()
{
new some_local = 25;
#emit ADDR.alt some_local // 计算 'some_local' 的地址
#emit CONST.pri 100
#emit STOR.I // 将 100 存储到 'some_local'
#emit CONST.pri 100
#emit STOR.S.pri some_local // 实现上述代码功能的更好方法
#emit CONST.pri some_local // 神秘地等价于 CONST.pri -4(稍后解释)
}
代码: main ()
{
#emit PUSH.C 100 // 将值 100 压入调用栈
#emit POP.pri // 从栈中弹出一个值并将结果存储在主寄存器中
#emit PUSH.pri // 推送主寄存器的值
#emit PUSH.alt // 推送辅助寄存器的值
#emit POP.pri
#emit POP.alt // 最后 4 条指令有效地交换了主寄存器和辅助寄存器的内容
#emit XCHG // 交换主寄存器和辅助寄存器内容的更好方法
}
局部数组位于栈上。因此,在使用索引指令之前,必须使用 ADDR.alt/ADDR.pri 来获取完整的地址。
代码: main ()
{
new local_array[10];
#emit ADDR.alt local_array // 加载存储在 'local_array' 中的值,即数组的地址
#emit CONST.pri 5
#emit LIDX // 有效地将 'local_array[5]' 的值存储在主寄存器中
}
堆指令
堆指针(HEA 寄存器)指向堆的顶部。将堆指针向前移动 x 字节,我们就能有效地
在堆上预留 x 字节。
代码: main ()
{
#emit HEAP 16 // 为四个单元格预留空间(假设单元格为 4 字节)
// 注意 HEAP 指令也将辅助寄存器设置为我们预留内存的起始地址
// ALT = HEA, HEA += 16
#emit CONST.pri 50
#emit STOR.I // 有效地将值 50 存储在我们预留的堆区域的第一个单元格中
#emit HEAP -16 // 归还预留的内存
}
控制寄存器操作指令
可以使用 LCTRL 和 SCTRL 指令直接读取和修改专用寄存器的内容。
| 助记符 |
操作数 |
描述 |
| LCTRL |
index |
PRI = 所选寄存器中包含的值;0=COD, 1=DAT, 2=HEA, 3=STP, 4=STK, 5=FRM, 6=CIP |
| SCTRL |
index |
所选寄存器 = PRI;2=HEA, 4=STK, 5=FRM, 6=CIP |
代码: main ()
{
new cod, dat;
#emit LCTRL 0 // 将 COD 段寄存器的值存储在主寄存器中
#emit STOR.S.pri cod
#emit LCTRL 1 // 将 DAT 段寄存器的值存储在主寄存器中
#emit STOR.S.pri dat
printf("%d %d", cod, dat);
}
当函数名用作操作数时,编译器会用函数的地址替换它。替换的地址是相对于 COD 寄存器的。
代码: f()
{
print("f() was called.");
}
main ()
{
#emit PUSH.C 0 // 参数占用的字节数(稍后解释)
#emit LCTRL 6 // 获取 CIP 的值,即下一条指令(本例中的 ADD.C)的地址
#emit ADD.C 28 // 计算 'f' 之后要执行的指令的地址(注意每个操作码和操作数都需要一个单元格)
#emit PUSH.pri // 推送返回地址,以便 'f' 知道返回到哪里(稍后解释)
#emit CONST.pri f // 将函数 'f' 的地址存储在 pri 中
#emit SCTRL 6 // 将当前指令指针设置为存储在主寄存器中的值
// 函数 'f' 执行
// 函数 'f' 返回后,下一条指令(本例中的 NOP)将开始执行
#emit NOP // 不执行任何操作的指令
}
控制流指令
代码: main()
{
#emit JUMP check // 跳转到 check 标签
not_equal:
printf("1 is not equal to 2");
return 0;
equal:
print("1 is equal to 2");
return 0;
check:
#emit CONST.pri 1
#emit CONST.alt 2
#emit JEQ equal // 如果主寄存器的值等于辅助寄存器的值,则跳转到 'equal'
#emit JUMP not_equal // 如果执行到这里,意味着两个寄存器的值不相等;因此跳转到 'not_equal'
}
Switch case 指令
switch-case 块使用分支表实现。分支表仅仅是由 case 值及其对应的跳转地址组成的元组列表。对于给定的 case 值,AMX 会搜索分支表并跳转到相应的跳转地址。分支表中的记录按 case 值的升序排列。这允许 AMX 对分支表执行二分搜索,但它也可能执行线性搜索。
SWITCH 指令标记 switch-case 块的开始。它接受一个指向分支表(也位于代码段中)的偏移量作为操作数。分支表正式以 CASETBL 操作码(仅作为标记,功能上未使用)开始,后跟一系列 CASE 记录,每条记录占用两个参数。第一条 case 记录的参数具有特殊含义:第一个参数是分支表中的 case 数量,第二个参数是默认 case 的偏移量。如果没有提供默认 case,则第二个参数包含 switch-case 块之后指令的偏移量。其余的 CASE 记录包含 case 值及其对应的跳转地址。
代码: switch(expression)
{
case 2: {}
case 4: {}
case 3: {}
case 7: {}
case 5: {}
}
编译器会添加指令来计算给定的 expression,其结果存储在主寄存器中。紧接着是一条 SWITCH 指令,它使 AMX 搜索分支表,查找与主寄存器中存储的值匹配的 case 值。如果找到匹配项,则执行跳转到匹配记录指向的地址;否则,跳转到默认地址。
代码: ; 编译器通过在汇编输出中使用标签而不是实际的偏移量/地址,使阅读更容易
; 每个标签都以前缀 "l." 开头
switch 0 ; 注意这里的零是一个标签
l.2 ; 标签 2
jump 1 ; 跳转到标签 1
l.3
jump 1
l.4
jump 1
l.5
jump 1
l.6
jump 1
l.0
casetbl
case 5 1 ; 记录数量,默认跳转地址(本例中为标签 1)
case 2 2 ; 真正的第一条记录
case 3 4 ; case 值: 3, 跳转标签: 4
case 4 3 ; 编译器在实际二进制文件中会用正确的地址替换标签
case 5 6 ;
case 7 5 ; 最后一条记录
l.1
; 其余代码
RE: [修仙者篇] AMX 汇编文档 - 小鸟unsigned - 03-21-2026
调用栈与调用约定
在本节中,我们假设一个单元格为 4 字节。
局部变量与函数帧
对于每个声明的局部变量,通过简单地将零(或提供的初始化值)压栈,在栈上为其预留空间。
代码: f()
{
new local1, // PUSH.C 0
local2 = 5; // PUSH.C 5
local1 = local2 + 50; // 直接访问栈中的 'local1' 和 'local2'
}
编译器分别添加 PUSH.C 0 和 PUSH.C 5 指令以响应 local1 和 local2 的声明。对变量的所有访问和修改都将直接访问和修改栈中的相应单元格。然而,直接使用局部变量的地址进行读写是不可能的,因为最终地址只能在运行时知道;例如,考虑递归函数的局部变量。
解决方案是放弃使用地址,而是使用相对于栈中特定参考位置的偏移量。调用函数后栈顶被用作参考点。正式地,这个参考点是当前函数帧的开始,相应的地址称为函数的帧地址。当调用一个函数时,它会将前一个函数的帧地址保存在栈上,并将 FRM 寄存器设置为指向当前函数的帧。
函数帧在函数被调用后是空的;相对于帧地址的栈顶偏移为零。当遇到局部变量时,会依次在帧中为其预留空间。注意栈向下增长;因此,最近压入的项的地址将小于其前一项的地址。这意味着压入栈中的项的偏移量是负数。在上面的例子中,local1 和 local2 的偏移量分别是 -4 和 -8。
代码: f()
{
// 此时函数帧为空
// 将局部变量添加到函数帧
new local1,
// PUSH.C 0
local2[100],
// STACK -400 (假设单元格为 4 字节)
// ADDR.alt -404
// ZERO.pri
// FILL 400
local3 = 32;
// PUSH.C 32
}
当声明局部数组时,通过将栈指针向下移动存储数组所需的字节数来为数组预留空间。然后用零或提供的初始化列表填充数组。在上面的代码片段中,local1、local2 和 local3 的偏移量分别是 -4、-404 和 -408。
代码: f()
{
new local1;
// PUSH.C 0
// local1 = -4
for(new i = 0; i < 10; i++)
// PUSH.C 0
// i = -8
{
new local2,
// PUSH.C 0
// local2 = -12
local3[10];
// STACK -40
// ADDR.alt -52
// ZERO.pri
// FILL 40
// STACK 44 (移除 'local2' 和 'local3')
}
// STACK 4 (移除 'i')
}
局部变量的存储持续时间限制在它们被声明的代码块内。因此,局部变量
必须在离开作用域时以某种方式从栈中移除。请注意,在作用域(及其子作用域)中声明的所有变量在栈中是一个接一个连续存在的。因此,使用一条 STACK 指令将栈指针向上移动要移除的局部符号的总字节数即可清理栈。
调用约定
调用约定定义了调用函数的方案。参数由调用者压入栈中。由于当被调用者被调用(它会设置 FRM 寄存器)时,这些参数已经在栈上,因此参数相对于被调用者帧的偏移量是正数。但是,它们不是从 0 开始;相反,第一个参数在偏移量 12 处,第二个在 16 处,依此类推。当前函数帧上方的前三个单元格包含有关函数调用本身的信息。
函数调用后栈的内容将具有以下结构:
代码: 栈的内容:
高地址
. . <= 调用者的帧
. .
. .
16 参数 2
12 参数 1
8 (参数数量,单位:字节)
4 (返回地址)
0 (调用者的帧地址)
-4 被调用者局部变量 1 <= 被调用者的帧从此位置开始
-8 被调用者局部变量 2
. .
. .
. .
低地址
进行函数调用的步骤:
- 以相反顺序推送参数(最后一个参数先推)
- 推送参数数量(以总字节数为单位)
- 推送返回地址
- 将
CIP 设置为指向被调用者的起始处
- 保存
FRM 寄存器的值(调用者的帧地址)在栈上
- 将
FRM 设置为 STK(被调用者的帧地址)
- 执行函数体
- 将栈恢复到函数调用后刚结束时的状态(即移除局部变量和临时变量)
- 将返回值放入主寄存器
- 弹出调用者的帧地址并设置
FRM 寄存器
- 弹出返回地址并设置
CIP 寄存器
- 从栈中移除参数
步骤 1 和 2 必须手动完成。步骤 3 和 4 都由 CALL 指令(及其 CALL.pri 变体)一起完成。
步骤 5 和 6 都由 PROC 指令一起完成。步骤 8 使用 STACK 指令完成。
步骤 10 和 11 由 RET 指令一起完成。RETN 指令可用于同时完成步骤 10、11 和 12。如果使用 RET 指令,则调用者负责清理栈。由于 STACK、RET 和 RETN 指令不会改变主寄存器的内容,因此返回值不受影响。
代码: new stk, stp, tmp; // 全局变量不涉及栈操作
f(arg)
{
// 编译器在每个函数的开头自动添加一条 PROC 指令
new x = 200;
#emit LCTRL 3
#emit STOR.pri stp
#emit LCTRL 4
#emit STOR.pri stk
printf("STP: %d STK: %d\n", stp, stk);
// 从栈顶到底部(低地址到高地址)打印栈的内容
while(stk != stp) {
#emit LOAD.pri stk
#emit LOAD.I
#emit STOR.pri tmp
printf("%d", tmp);
stk += 4;
}
// 编译器使用它所拥有的任何信息,并在返回前添加指令来修正栈
// 如果使用 #emit 手动推送或弹出了项目,编译器将不会知道这些操作
return 1234;
}
main () {
new a = 1;
f(101);
#emit STOR.S.pri a // 将主寄存器中存储的值(返回值)存储到 'a'
printf("\nPRI: %d", a);
}
代码: STP: 16500 STK: 16464
200 ; 局部变量 'x'
16488 ; 'main' 的帧地址
308 ; 返回地址
4 ; 传递了 4 字节的参数
101 ; 函数 'f' 的参数 'arg'
1 ; 局部变量 'a'
0 ; 未使用的帧地址(main 没有调用者)
0 ; 返回到地址 0,那里有一条 'halt 0' 指令
0 ; main 不接受任何参数
PRI: 1234
参数传递方法
按值传递:
按值传递的参数会将实际参数的副本压入栈中。由于栈上存在一个独立的副本,被调用者对参数所做的任何修改都不会影响实际参数。
代码: f(arg1, arg2)
{
#emit CONST.pri 10
#emit STOR.S.pri arg1 // 这不会影响 'main' 中的 'x',因为它修改的是栈上 'x' 的副本,而不是 'x' 本身
}
main ()
{
new x = 150;
// f(x, 10);
#emit PUSH.C 10 // 推送
#emit PUSH.S x // 有效地将 'x' 中包含的值压入栈
#emit PUSH.C 8 // 推送了两个参数;因此,总大小为 8 字节
#emit CALL f
}
按引用传递:
传递实际参数的地址。被调用者使用该地址来读取和修改参数。因此,被调用者对参数所做的任何更改都会影响实际参数。
代码: new global;
f(&arg1, &arg2)
{
#emit CONST.pri 10
#emit SREF.S.pri arg1 // 'arg1' 包含 'x' 的地址,写入操作发生在该地址 => [[FRM + arg]] = PRI
}
main ()
{
new x = 150;
// f(x, global);
#emit PUSH.C global // 推送 'global' 的地址
#emit PUSH.ADR x // 推送 'x' 的地址,即:FRM + x
#emit PUSH.C 8
#emit CALL f
}
传递数组:
数组作为引用传递,即传递数组的基地址。这避免了复制数组的开销,但对参数所做的任何修改都是可见的。
代码: f(arr1[], arr2[])
{
#emit CONST.pri 2
#emit LOAD.S.alt arr1 // 将数组 'arg1' 指向的地址加载到辅助寄存器中
#emit IDXADDR // 计算 arr1[2] 的地址
#emit MOVE.alt // 将 IDXADDR 计算的地址移动到辅助寄存器
#emit CONST.pri 100
#emit STOR.I // 在 arr1[2] 处存储 100
}
g(arg[])
{
new x[100];
// f(x, arg);
#emit PUSH.S arg // 'arg' 存储数组的地址;因此,PUSH.S 推送存储在 'arg' 处的值(推送 [FRM + arg])
#emit PUSH.adr x // 'x' 局部存在并直接指向数组;因此,我们使用 PUSH.adr(推送 'FRM + x')
#emit PUSH.C 8
#emit CALL f
}
Pawn 语法允许从特定索引传递数组。这可以通过传递该索引处元素的地址来实现。
代码: f(arr[]) { }
main()
{
new x[100];
// f(x[2]);
#emit ADDR.alt x // 将 'x' 的地址(FRM + x)加载到辅助寄存器
#emit CONST.pri 2 // 索引 2
#emit IDXADDR // 计算 x[2] 的地址
#emit PUSH.pri // 推送 x[2] 的地址
#emit PUSH.C 4
#emit CALL f
}
传递可变参数:
传递给可变参数列表的参数是按引用传递的。如果必须传递常量或不是左值的表达式,则必须将结果临时存储在堆上,并传递其地址。
代码: f(arg, ...)
{
new argc;
#emit LOAD.S.pri 8 // 偏移量 8 处有参数数量(字节数)
#emit CONST.alt 4
#emit UDIV // 主寄存器中现在有传递的参数个数
#emit ADD.C -1 // 减 1,因为我们不关心 'arg'
#emit STOR.S.pri argc // argc 现在有参数数量(不包括 arg)
// 以相反顺序遍历参数列表
while(argc--) {
new offset = 16 + argc*4;
#emit LOAD.S.pri offset
#emit LOAD.I // 主寄存器中有参数的地址
// 执行某些操作
}
}
main ()
{
new x;
//f(10, x, 25);
#emit HEAP 4 // 在堆上为 25 分配空间
#emit CONST.pri 25
#emit STOR.I // 将 25 存储在分配的空间中
#emit PUSH.alt // 推送已分配单元格的地址
#emit PUSH.adr x
#emit PUSH.C 10 // 按值传递
#emit PUSH.C 12
#emit CALL f
#emit HEAP -4 // 释放分配的空间
}
系统请求
调用原生函数的过程几乎与调用任何其他函数相同。区别在于使用 SYSREQ.pri/SYSREQ.C 指令代替 CALL/CALL.pri,并且栈必须由调用者清理。编译器为脚本中使用的每个原生函数在原生函数表中创建一个条目。原生函数表中的记录按顺序分配索引,从第一条记录开始索引为零。该索引用作 SYSREQ.pri/SYSREQ.C 指令的操作数。
如果提供原生函数的名称作为操作数,编译器会替换其对应的原生函数索引。
代码: native random();
native printf(const frmt[], ...);
main ()
{
static const str[] = "Random number: %d";
//printf(str, random());
#emit PUSH.C 0 // 零个参数
#emit SYSREQ.C random
#emit STACK 4 // 必须手动清理栈(只有一项被压入栈;因此,将 STK 上移 4 字节)
// 随机值在主寄存器中返回
// 准备推送随机值
// 注意 'printf' 将值作为可变参数接收:我们需要推送地址
#emit HEAP 4 // 在堆中为随机值预留空间
#emit STOR.I // 将主寄存器的值存储在新分配的空间中
#emit PUSH.alt // 推送随机值的地址
#emit PUSH.C str // 'str' 是静态局部字符串;因此,它位于数据段中
#emit PUSH.C 8
#emit SYSREQ.C printf
#emit STACK 12 // 总共推送了三项;因此,弹出 3 个单元格
#emit HEAP -4
}
多维数组
二维数组除了数组数据外,还包含一个间接表。间接表是一个一维数组,包含指向子数组的偏移量。对于一个 4x3 的二维数组,其结构如下所示:
代码: [0] 的地址 | [1] 的地址 | [2] 的地址 | [3] 的地址
| | | |
| | | ([3][0] [3][1] [3][2])
| | ([2][0] [2][1] [2][2])
| ([1][0] [1][1] [1][2])
([0][0] [0][1] [0][2])
间接表包含 4 个偏移量,分别指向它们对应的 3 元素子数组。这些偏移量是相对于读取它们的单元格的地址的。例如,要获取子数组 [2] 的地址,必须将间接表中元素 [2] 的地址与其包含的值相加。
代码: static const arr[][] = { {1, 2, 3}, {1, 2}, {4, 5, 6, 7} };
假设单元格为 4 字节,上述数组将以二进制形式存储为:
代码: 12 20 24 1 2 3 1 2 4 5 6 7
| 间接表 |
子数组 [0] |
子数组 [1] |
子数组[2] |
| 12 20 24 |
1 2 3 |
1 2 |
4 5 6 7 |
子数组 [0] 从间接表起始处开始 12 字节处(第一个 1)。子数组 [0] 的地址将是间接表第一个元素的地址加上该地址存储的值。子数组 [1] 从间接表第二个元素开始 20 字节处。子数组 [1] 的地址将是间接表第二个元素的地址加上该地址存储的值。
代码: new arr[5][5];
// 访问 arr[2][4]
#emit CONST.alt arr // 加载间接表的地址
#emit CONST.pri 2 // 设置要访问的子数组索引(主维度索引)
#emit IDXADDR // 现在主寄存器中是间接表中存储指向子数组 [2] 的偏移量的单元格的地址
#emit MOVE.alt // 保留一份地址副本
#emit LOAD.I // 将偏移量加载到主寄存器
// 当前 ALT 包含存储子数组 [2] 偏移量的单元格的地址
// 当前 PRI 包含指向子数组 [2] 的偏移量
// 存储的偏移量是相对于读取它的单元格的地址的;因此,我们将 PRI 和 ALT 相加
#emit ADD
// PRI 现在包含子数组 [2] 的地址
// 现在可以将子数组 [2] 视为一维数组
#emit MOVE.alt // 复制子数组 [2] 的地址
#emit CONST.pri 4 // 次维度索引
#emit LIDX // 将 [2][4] 加载到主寄存器
每个 N 维数组(N != 1)都包含一个间接表,其中的偏移量指向 (N - 1) 维子数组。要访问一个
元素,必须递归地遍历间接表,直到获得一个一维数组。
代码: new arr[5][5][5];
// 访问 arr[3][2][4]
#emit CONST.pri arr
// PRI 包含三维数组间接表的地址
#emit MOVE.alt
#emit CONST.pri 3
#emit IDXADDR
#emit MOVE.alt
#emit LOAD.I
#emit ADD
// PRI 包含二维 arr[3] 子数组的间接表地址
#emit MOVE.alt
#emit CONST.pri 2
#emit IDXADDR
#emit MOVE.alt
#emit LOAD.I
#emit ADD
// PRI 包含一维 arr[3][2] 子数组的地址
#emit MOVE.alt
#emit CONST.pri 4
#emit LIDX
// PRI 包含存储在 arr[3][2][4] 处的值
多维数组作为参数传递给函数时,通过传递其间接表的地址来实现。
|