AMX Version: 3.0
AMX File Version: 8
This document is an unofficial documentation of the assembly of the Abstract Machine eXecutor. The contents of this document may not be accurate and are subject to changes. Not all the terms defined in the Terminology section are in the official manual. They have been introduced for this document as supporting terms to help the reader.
The target audience for the documentation is first-time assembly programmers. They must visit the hyperlinks as and when they appear to gather background information. Advanced users may skip through sections or use the document as a quick reference guide.
- Prerequisites
- Terminology
- Basic Concepts
- File and memory layout
- Instruction Set
- Pawn Inline Assembly Syntax
- Instruction Table
- Load/Store Instructions
- Indexing Instructions
- Arithmetic Instructions
- Logical Instructions
- Relational Instructions
- Stack Manipulation Instructions
- Heap Instructions
- Control Register Manipulation Instructions
- Control Flow Instructions
- Switch-case Instructions
- Call stack and calling convention
- Multi-dimensional arrays
- Pawn Scripting Language
- Binary Number System
- Hexadecimal Number System
- Memory Addresses (understanding pointers is a plus)
- Stack (as an abstract data type)
Abstract Machine eXecutor (AMX): AMX is the abstract machine (or a virtual machine) which is of interest to us.
AMX Assembly: The assembly language for AMX.
AMX Binary: AMX binary is the executable file (usually have the .amx
extension) that is produced after compiling. The term AMX program may refer to AMX binary in some contexts.
AMX Instance: Every AMX binary loaded exists independently. The loaded program is known as AMX Instance. The term AMX program may refer to AMX instance in some contexts.
Host Program: The program that embeds the AMX is known as the host program. In the case of San Andreas Multiplayer (SA-MP) or open.mp, the server is the host program.
Extension Module: An extension module is an independent module linked (usually dynamically linked) to the host program which provides additional native functions. Extension modules are commonly known as plugins.
-
Pawn is a typeless language: there are no data types. All data is stored as raw binary data in a cell or a collection of cells. We will use signed two's complement representation to communicate the contents of a cell(s) rather than explicitly writing it down in binary.
new a = 5, b = 'A';
The above code creates two separate cells identified by
a
andb
. Cella
will contain the value5
, and cellb
will contain the value65
(the ASCII equivalent of the character 'A').To compensate for the lack of data types, Pawn provides the ability to tag cells. These tags are mere compile-time helpers and are not directly1 present in the AMX binary.
new Float:x = 5.0, Float:y = 10.0, Float:z; z = x + y;
The above code creates three tagged cells identified by
x
,y
, andz
. They are initialized with the value5.0
,10.0
and0.0
(default initialized to binary zero, which is also0.0
) respectively in the correct binary representation of those floating-point numbers. The compiler remembers the tags of the variables it encounters and ensures that the correct code 2 is generated wherever the variables are used. In the above case, the compiler emits the correct code to add two floating-point numbers,x
andy
, as it processes the second line. Remember that integer addition and floating-point addition are carried out differently. The compiler uses the tag associated with the cells to figure out what kind of addition operation must be carried out on the operands.The information about the tag of the variables is lost after compilation. The produced AMX binary will contain instructions to carry out floating-point addition on the data stored in
x
andy
and store the result inz
. During execution, the floating-point addition occurs without caring whether the tag ofx
andy
in the script was originallyFloat
.[1] The Pawn language provides
tagof
operator that returns the compile-time id associated with a tag that can be stored. The compiler also creates a list of publicly accessible tags in the tag table in the AMX binary.[2] The Pawn language allows operator overloading based on tags. The compiler is aware of all the operator overloads that are present and invokes the correct overload based on the tag(s) of the operand(s). If no overload exists for the given operand(s), the compiler defaults to the operators of untagged cells (which is integer arithmetic).
-
The AMX follows a flat byte-addressable memory model, i.e. the AMX memory can be thought of as a linear collection (flat memory) of bytes with each byte having a unique address (byte addressable).
new a[5];
The above code creates an array of five cells identified by
a
. These cells are stored contiguously, i.e., they are stored one after another. Assuming the size of cells to be 4 bytes, if the location's address wherea[0]
is stored is1000
, the address of the location wherea[1]
is stored would be1004
.data: a[0] | a[1] | a[2] | a[3] | a[4] address: 1000 1004 1008 1012 1016
Required: base addresses, relative addresses
Required: Array data structure, Element identifier and addressing formulas
See also: Endianness
-
Every AMX instance consists of an internal program stack (also known as the call stack) and program heap. These are regions of memory in the AMX program that are specialized for a set of tasks.
The stack is responsible for:
- storing local variables
- storing function arguments
- storing function call information (such as the number of arguments)
- providing temporary storage
The heap is responsible for:
- providing memory for dynamic allocation
- storing default arguments that are taken as reference/array
- storing constants and expressions which are not lvalue and are passed as variable arguments
f(argc, ...) { } main () { new a, b, c[100]; f(1, 25); }
In the above snippet, the variables
a
,b
,c
, and the constant1
are allocated space on the stack. The constant25
is allocated space on the heap. References and variable arguments are passed as addresses to the function. Since the literal25
is a constant and does not have an address, a temporary cell is allocated on the heap to store the value25
. The address of this cell is passed to the function. -
Instructions are discrete atomic units of execution. Most of the instructions carry out simple tasks such as basic arithmetic. The high-level constructs such as loops and conditional statements can be reduced to a series of simple instructions.
For example, the instructions that push and then pop a value to and from the call stack could be:
push 100 pop
The binary code generated by an assembler for the above snippet could be:
push-opcode | 100 | pop-opcode
0x0013 0x0064 0x0014
or maybe0000 0000 0000 0000 0000 0000 0001 0011 0000 0000 0000 0000 0000 0000 0110 0100 0000 0000 0000 0000 0000 0000 0001 0100
Yes, it is just a series of bits that the CPU (or the abstract machine in our case) knows how to interpret. This representation is known as machine code (or native code) when assembled for execution on physical hardware. In the case of code assembled for execution on hypothetical CPUs (like AMX), the representation is called p-code.
See also: bytecode
-
The idea that code resides in memory just like data is known as the stored program concept. It also implies that every instruction in memory has an address, just like data. The idea that both code and data reside in the same memory is a principle of the von Neumann architecture. It also implies that all the code and data share an address space.
The AMX uses a common memory to store both the code and data.
-
The executable binary and the program's in-memory structure are separated into logically different regions depending on what they contain. A typical binary executable consists of at least a header, a code segment, and a data segment. The header provides information about the program itself, such as program entry point.
HEADER ------- CODE ------- DATA
The code segment stores the code in its binary form (machine code/p-code), while the data segment stores data such as global and static local variables.
The program's in-memory structure is usually different. In addition to the code and data segments, it generally contains a region for the program stack and the program heap.
HEADER ------- CODE ------- DATA ------- HEAP | STACK
AMX binaries are organized into three sections: prefix, code, and data (in order). The prefix section contains information about the AMX binary, like offsets to the start of the code and the data section in the binary. The code section stores the p-code, and the data segment stores global and static local variables.
The heap-stack region is created using the information in the prefix section after the AMX binary is loaded into memory.
See also: memory segmentation, data segment, code segment, ELF format
-
It is essential to know that the processor and memory are at different places.
|-------| |------------------| | CPU | =============== | MEMORY | |-------| BUS |------------------|
The memory stores data, and the CPU operates on the data. The processor has to bring the operands from memory and store them temporarily inside the CPU for every operation. These temporary storage locations are known as registers. These registers can hold a tiny amount of data in the range of a few bytes (typically 2 or 4 or 8 bytes).
Suppose the processor has to add two variables,
x
andy
, and store the result in variablez
. A typical processor would do the following:- bring the value of
x
from memory (i.e., read fromx
's address) and store it in a register, sayR1
- bring the value of
y
from memory (i.e., read fromy
's address) and store it in a register, sayR2
- add the contents of
R1
andR2
and store the result in some register, sayR3
- store the contents of
R3
inz
(write toz
's address)
A processor consists of different types of registers serving different purposes. Some registers are specialized for a specific task, and some can be used for any purpose. The latter kind of registers, used in the example above, belong to the class of general-purpose registers.
The AMX's register set consists of the following registers:
- Primary Register (PRI): general-purpose register (frequently used as an accumulator register)
- Alternate Register (ALT): general-purpose register (frequently used as an address register)
- Code Segment Register (COD): absolute address to the start of the code segment in memory
- Data Segment Register (DAT): absolute address to the start of the data segment in memory
- Current Instruction Pointer (CIP): address (relative to the COD register) of the next instruction to be executed
- Stack Top Register (STP): address (relative to the DAT register) to the top of the stack
- Stack Index Register (STK): address (relative to the DAT register) to the current location on the stack
- Frame Pointer Register (FRM): address (relative to the DAT register) of the start of the current function's frame in the stack (explained later)
- Heap Pointer (HEA): address (relative to the DAT register) to the top of the heap
While writing assembly code, the addresses are usually not absolute; they are relative to a segment or frame pointer register. The addresses of global variables and strings are relative to the DAT register. The addresses of the functions, instructions and labels are relative to the COD register. That means the absolute address of a variable or an instruction is the relative address plus the value of the DAT or the COD register, respectively. The addresses of local variables are relative to the frame pointer register. This is a bit complex and will be explained later.
Henceforth, when we talk about code addresses and data addresses, unless explicitly stated, it is implied that the addresses are relative to the COD register and the DAT register, respectively.
- bring the value of
All non-debug AMX binary files are organized as follows:
START OF BINARY FILE
| ---------------------- |
PREFIX
| ---------------------- |
CODE
| ---------------------- |
DATA
| ---------------------- |
END OF BINARY FILE
- Prefix: contains essential information about the program
- Code: contains the code
- Data: contains the data
Note: The prefix section may be padded to align the code and data sections based on the compiler options used.
Note: The binary image of scripts compiled with debug enabled will contain the symbolic debug information appended at the end. Refer to the Pawn Implementer Guide for more details.
The in-memory structure adopts a similar structure as the binary file but in addition contains a stack and a heap, which is set up using the information contained in the prefix.
LOW ADDRESS (0)
| ---------------------- |
PREFIX
| ---------------------- |
CODE
| ---------------------- |
DATA
| ---------------------- |
HEAP
| |
| |
FREE SPACE
| |
| |
STACK
| ---------------------- |
HIGH ADDRESS
The heap and stack share a common region of memory. They start from opposite ends of the region and grow in opposite directions. The heap grows towards the higher address, and the stack grows towards the lower address. Since they share the same memory region, they could potentially overwrite each other (when the heap pointer and the stack pointer collide). When this happens, the AMX aborts with an error message as shown below:
Script[gamemodes/TEST.amx]: Run time error 3: "Stack/heap collision (insufficient stack size)"
Based on the information the compiler has, it estimates and indirectly sets the heap-stack size in the prefix section of the AMX binary. However, the programmer may provide their estimate of the required size of the heap-stack region using the #pragma dynamic [estimated number of cells]
directive.
Type | Size | Description |
---|---|---|
size | 4 | size of the memory image; excluding the stack and heap |
magic | 2 | indicates the format and cell size |
file version | 1 | the format version |
amx version | 1 | required minimum version of the abstract machine |
flags | 2 | presence of symbolic debug information, compact encoding, etc. |
defsize | 2 | size of a record in the tables |
cod | 4 | offset to the start of the code section |
dat | 4 | offset to the start of the data section |
hea | 4 | initial value of the HEA register (marks the end of the data section) |
stp | 4 | initial value of the STP register (indicates the total memory requirement) |
cip | 4 | initial value of the CIP register (address of the main function; -1 if it does not exist) |
publics | 4 | offset to the start of the public function table |
natives | 4 | offset to the start of the native function table |
libraries | 4 | offset to the start of the libraries table |
pubvars | 4 | offset to the start of the public variables table |
tags | 4 | offset to the start of the tags table |
name table | 4 | offset to the start of name table |
overlays | 4 | offset to the start of the overlays table |
publics | variable | list of public functions |
natives | variable | list of native functions |
libraries | variable | list of libraries |
pubvars | variable | list of public variables |
tags | variable | list of public tags |
overlays | variable | list of overlays |
name table | variable | list of symbol names |
Note: For detailed information about how information is encoded in the magic, version, and flags fields; refer to the Pawn Implementer Guide.
Note: All the multi-byte fields in the prefix are stored in the little endian format irrespective of the platform on which the AMX binary was produced or will be executed.
It can be seen that the prefix consists of a fixed part that is followed by a series of tables. Every record in the tables except the name table consists of two fields as shown below:
Field | Size | Description |
---|---|---|
variable | cell size | variable |
name string offset | 4 bytes | offset to a string in the name table from the start of prefix |
The size of each record in the tables (except the name table) is given by the defsize
field in the prefix: defsize
= 4 + cell size.
An index number is assigned to every record in the tables. The first record of each table is assigned the index zero, and subsequent records' index number is one plus the index of the record preceding it.
Name Table: The records in the tables (other than the name table itself) contain a pointer to a string. These strings are stored in the name table as null-terminated C strings. The size of a record in this table is the size of the string it holds.
Public Function Table: A record is created for every public function that is defined in the script. The records contain the address of the public function (address of the first instruction of the function) and the offset to the name of the corresponding public function. The index of a record in this table is the index of the corresponding public function.
Native Function Table: A record is created for every native function that is called in the script. The first field of the record is set to zero by the compiler in the binary file, but the host program initializes this field to the function's address (in the host program's address space) when the binary is loaded. The second field contains an offset to the name of the native function. The index of a record in this table is the index of the corresponding native function.
Library Table:
Pawn language provides a pragma directive #pragma directive [library name]
to inform the compiler that some of the native functions called require an extension module. A record is created in the library table for libraries whose native functions have been called in the script. The purpose is to inform the host program that the AMX program depends on an extension module. The first field of the records is used internally and is set to zero in the AMX binary. The other field contains an offset from the start of the prefix to the library's name in the name table.
Public Variable Table: A record is created for every public variable that is declared in the script. The record contains the address of the public variable and the offset to the name of the public variable. The index of a record in this table is the index of the public variable.
Tag Table:
The tags used with the sleep
and exit
statement and the tags used with the tagof
operator are exported. A record is created for each of those tags. The first field contains the tag id number, and the second field stores an offset to the tag's name.
Most of the AMX assembly instructions as accepted by the Pawn compiler can be expressed in the following format:
mnemonic[.prefix][.register suffix] operand
SHL.C.pri 3
ZERO.alt
ADD.C 100
LIDX
The mnemonic gives an idea of what the instruction does. The optional prefix indicates the type of operand the instruction takes. The optional suffix indicates on which register the instruction mainly acts.
List of prefixes:
- .C = constant
- .S = stack
- .I = indirection
- .B = variant of the one without B[needs better description]
- .ADR = address
- .R = repeat
List of register suffixes:
- .pri = primary register
- .alt = alternate register
Every instruction in its binary form requires a cell to store the opcode and an additional cell for every operand. A vast majority of instructions have implied registers as operands. This reduces the number of explicit operands needed, thereby decreasing the size of the code segment and improving the performance.
- [address] refers to the value stored at the location
DAT + address
- the operators used in the sematics column perform the same operation as they do in Pawn
opcode | mnemonic | operand | semantics |
---|---|---|---|
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 |
The compiler substitutes the address of the variable when a global variable is used as an operand. The first load instruction in the code below effectively becomes #emit LOAD.pri 1288
if the address of some_global
was 1288. Note that the address substituted by the compiler is the offset from the start of the data segment; hence, the address is relative to the DAT register.
new some_global = 10, another_global = 25;
main()
{
#emit LOAD.pri some_global // load the value of 'some_global' into the primary register
#emit LOAD.alt another_global // load the value of 'another_global' into the alternate register
}
The compiler substitutes the offset from the start of the function frame when a local variable is used. (explained later)
main()
{
static s_local = 20; // static local variables are stored in the data segment
new some_local = 10, another_local = 25; // local variables are stored in the stack
#emit LOAD.S.pri some_local // load the value of 'some_local' into the primary register
#emit LOAD.S.alt another_local // load the value of 'another_local' into the alternate register
#emit LOAD.pri s_local // note that static local variables behave like global variables
}
Given that the compiler substitutes the address for global variables, CONST.pri/CONST.alt
can be used to obtain the address of those variables.
new some_global;
main()
{
#emit CONST.pri 10 // put 10 in to the alternate register
#emit CONST.alt 50 // put 50 in to the alternate register
#emit CONST.pri some_global // store the address of 'some_global' in the primary register
#emit CONST.alt some_global // store the address of 'some_global' in the alternate register
}
new some_global;
main()
{
new some_local;
#emit ZERO.pri // store zero in the primary register
#emit STOR.pri some_global // set 'some_global' to the value stored in primary register (zero in this case)
#emit CONST.alt 125
#emit STOR.S.alt some_local // set 'some_local' to the value stored in the alternate register (125 in this case)
}
new global_arr[10];
main ()
{
#emit CONST.alt global_arr // load the address of 'global_arr' (address of the first element of 'global_arr') into the alternate register
#emit CONST.pri 2 // set the index of the elment of 'global_arr' that we are interested in
#emit LIDX // the primary register now has the value stored at 'global_arr[2]`
#emit CONST.alt global_arr // load the address of 'global_arr' (address of the first element) into the alternate register
#emit CONST.pri 2 // set the index of the elment of 'global_arr' that we are interested in
#emit IDXADDR // the primary register now has the address of 'global_arr[2]`
}
main ()
{
#emit CONST.pri 4
#emit CONST.alt 5
#emit SMUL // the primary register now has 20 (SMUL does signed multiplication; UMUL does unsigned multiplication)
#emit ADD // adds 5 to the 20 (the primary register was storing 20 because of the previous instruction)
#emit ADD.C 10 // adds 10 to the primary register; now the primary register holds 35
#emit SUB.alt // subtract 35 from 5; the primary register now has -30
#emit SMUL.C 2 // multiply -30 by 2; this gives -60
}
main ()
{
#emit CONST.pri 5 // .. 0000 0101
#emit CONST.alt 3 // ... 0000 0011
#emit AND // primary register will now contain ... 0000 0001
#emit XOR // primary register will now contain ... 0000 0110
#emit INVERT // take one's complement of the value stored in the primary register
#emit NEG // take two's complement of the value stored in the primary register (essentially negation)
}
main ()
{
#emit CONST.pri 5
#emit CONST.alt 8
#emit EQ // set primary register to 1 if 5 == 8, otherwise zero
#emit LESS // set primary register to 1 if 0 < 8, otherwise zero
}
The local variables are stored on the stack. A detailed explanation will be provided in a later section, but for now, we assume that using CONST.pri some_local
gives some offset that when added to the base address stored in the frame register gives the data address.
main ()
{
new some_local = 25;
#emit ADDR.alt some_local // computes the address of 'some_local'
#emit CONST.pri 100
#emit STOR.I // store 100 in 'some_local'
#emit CONST.pri 100
#emit STOR.S.pri some_local //a better way to achieve what the above code did
#emit CONST.pri some_local // mysteriously equivalent to CONST.pri -4 (explained later)
}
main ()
{
#emit PUSH.C 100 // pushes the value 100 onto the call stack
#emit POP.pri // pops a value from the stack and stores the result in the primary register
#emit PUSH.pri // push the value of the primary register
#emit PUSH.alt // push the value of the alternate register
#emit POP.pri
#emit POP.alt // the last 4 instructions effectively swap the contents of the primary register and the alternate register
#emit XCHG // a better way to swap the contents of the primary register and the alternate register
}
The local arrays are on the stack. Hence, ADDR.alt/ADDR.pri
must be used to obtain the full address before using the indexing instructions.
main ()
{
new local_array[10];
#emit ADDR.alt local_array // loads the value stored in 'local_array' which is the address of the array
#emit CONST.pri 5
#emit LIDX // effectively stores the value of 'local_array[5]' in the primary register
}
The heap pointer (the HEA
register) points to the top of the heap. By moving the heap pointer ahead by x
bytes, we effectively
reserve x
bytes on the heap.
main ()
{
#emit HEAP 16 // make room for four cells (assuming cells are 4 bytes)
// note that the HEAP instruction had also set the alternate register to the start of our reserved memory
// ALT = HEA, HEA += 16
#emit CONST.pri 50
#emit STOR.I // effectively stores the value 50 in the first cell of our reserved area of the heap
#emit HEAP -16 // return the reserved memory back
}
The contents of specialized registers can be directly read and modified using the LCTRL
and SCTRL
instructions, respectively.
mnemonic | operand | description |
---|---|---|
LCTRL | index | PRI = value contained in the selected register; 0=COD, 1=DAT, 2=HEA, 3=STP, 4=STK, 5=FRM, 6=CIP |
SCTRL | index | selected register = PRI; 2=HEA, 4=STK, 5=FRM, 6=CIP |
main ()
{
new cod, dat;
#emit LCTRL 0 // store the value of the COD segment register in the primary register
#emit STOR.S.pri cod
#emit LCTRL 1 // store the value of the DAT segment register in the primary register
#emit STOR.S.pri dat
printf("%d %d", cod, dat);
}
When a function name is used as an operand, the compiler substitutes the function's address in its place. The address substituted is relative to the COD register.
f()
{
print("f() was called.");
}
main ()
{
#emit PUSH.C 0 // number of bytes taken up by arguments (explained later)
#emit LCTRL 6 // get the value of CIP which is the address of the next instruction (ADD.C in our case)
#emit ADD.C 28 // compute the address of the instruction to be executed after 'f' (note that each opcode and operand requires a cell)
#emit PUSH.pri // push the return address so that 'f' knows where to return to (explained later)
#emit CONST.pri f // store the address of the function 'f' in pri
#emit SCTRL 6 // set the current instruction pointer to the value stored in the primary register
// the function 'f' executes
// after the function 'f' returns, the next instruction (NOP in our case) will begin to execute
#emit NOP // instruction that does nothing
}
main()
{
#emit JUMP check // jump to the check label
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 // if the value of the primary register is equal to that of the alternate register, then jump to 'equal'
#emit JUMP not_equal // if we ended up here, it implies that the values of the two registers were not equal; hence, jump to 'not_equal'
}
A switch-case block is implemented using a case table. A case table is merely a list of tuples consisting of a case number and its corresponding jump address. For a given case value, the AMX searches the case table and jumps to the corresponding jump address. The case table records are arranged in ascending order of the case numbers. This allows the AMX to perform a binary search on the case table, but it may also do a linear search.
The SWITCH
instruction marks the beginning of a switch-case block. It takes an offset to the case table (which is also present in the code segment) as an operand. The case table formally begins with a CASETBL
opcode (just a marker and is functionally unused) followed by a series of CASE
records, each taking two parameters. The parameters of the first case record have special meanings: the first parameter is the number of cases in the case table and the second parameter is the offset to the default case. If a default case is not provided, the second parameter contains the offset to the instruction coming after the switch-case block. The rest of the CASE
records contain the case value and its corresponding jump address.
switch(expression)
{
case 2: {}
case 4: {}
case 3: {}
case 7: {}
case 5: {}
}
The compiler adds the instructions to evaluate the given expression
whose result is stored in the primary register. A SWITCH
instruction immediately follows, which causes the AMX to search through the case table for the case whose value matches the value stored in the primary register. If a match is found, the execution jumps to the address pointed by the matching record; otherwise, it jumps to the default address.
; the compiler makes it easier to read assembly output by using labels instead of real offsets/addresses
; every label begins with the prefix "l."
switch 0 ; note that the zero is a label here
l.2 ; label 2
jump 1 ; jump to label 1
l.3
jump 1
l.4
jump 1
l.5
jump 1
l.6
jump 1
l.0
casetbl
case 5 1 ; number of records, default jump address (label 1 in this case)
case 2 2 ; real first record
case 3 4 ; case value: 3, jump label: 4
case 4 3 ; the compiler writes correct addresses in place of the labels in the actual binary
case 5 6 ;
case 7 5 ; the last record
l.1
; rest of the code
We assume that a cell is 4 bytes for this section.
For every local variable declared, a room is made for it on the stack by simply pushing zero (or the initialization value provided).
f()
{
new local1, // PUSH.C 0
local2 = 5; // PUSH.C 5
local1 = local2 + 50; // directly access the 'local1' and 'local2' in the stack
}
The compiler adds PUSH.C 0
and PUSH.C 5
instructions in response to local1
and local2
declarations, respectively. All accesses and modifications to the variables will directly access and modify the appropriate cells in the stack. However, using the address of the locals directly to read or write is not possible since the final address can be known only at run-time; for example, think of a local variable of a recursive function.
The solution is to abandon using addresses and instead use offsets from a particular reference position in the stack. The top of the stack just after calling the function is used as the reference point. Formally, this reference point is the beginning of the current function's frame, and the corresponding address is known as the frame address of the function. When a function is called, it saves the previous function's frame address on the stack and sets the FRM
register to point to the current function's frame.
The function's frame is empty just after a function is called; the top of the stack relative to the frame address is zero. Room is made for the local variables in the frame as and when they are encountered. Note that the stack grows downwards; hence, the address of the recently pushed item will be lesser than that of the item preceding it. This implies that the offsets of the items that are pushed onto the stack are negative. In the example above, the offsets of local1
and local2
are -4
and -8
, respectively.
f()
{
// function's frame is empty at this point
// add local variables to the function's frame
new local1,
// PUSH.C 0
local2[100],
// STACK -400 (assuming cells of 4 bytes)
// ADDR.alt -404
// ZERO.pri
// FILL 400
local3 = 32;
// PUSH.C 32
}
When a local array is declared, room is made for the array by moving the stack pointer down by the number of bytes required to store the array. The array is then filled with zeros or with the initializer list provided. In the above snippet, the offset for local1
, local2
and local3
is -4
, -404
and -408
, respectively.
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 (removes 'local2' and 'local3')
}
// STACK 4 (removes 'i')
}
The storage duration of local variables is restricted to the code block in which they were declared. Therefore, the local variables
must somehow be removed from the stack when they go out of scope. Notice that all the variables that were declared in the scope (and its subscopes) will be present one after another in the stack. Therefore, a single STACK
instruction to move the stack pointer up by the number of bytes of local symbols that are to be removed will clean up the stack.
The call convention defines the scheme for calling a function. The arguments are pushed onto the stack by the caller. Since they are already on the stack when the callee is called (which sets the FRM register), the offsets of the arguments relative to the callee's frame are positive. However, they do not start from 0; instead, the first argument is at offset 12 and the second is at 16, and so on. The first three cells above the current function's frame contain information about the function call itself.
The contents of the stack after a function call will have the following structure:
CONTENTS OF THE STACK:
HIGH ADDRESS
. . <= frame of caller
. .
. .
16 argument 2
12 argument 1
8 (number of arguments)
4 (return address)
0 (frame address of the caller)
-4 callee local variable 1 <= frame of callee begins from this position
-8 callee local variable 2
. .
. .
. .
LOW ADDRESS
procedure for making a function call:
- push the arguments in reverse order (the last argument is pushed first)
- push the number of arguments (in terms of the total size in bytes)
- push the return address
- set
CIP
to point to the beginning of the callee - save the value of the
FRM
register (caller's frame address) on the stack - set the
FRM
toSTK
(callee's frame address) - execute the function body
- restore the stack to the condition it was just after the function was called (i.e., remove local variables and temporaries)
- place the return value in the primary register
- pop the frame address of the caller and set
FRM
register - pop the return address and set
CIP
register - remove arguments from the stack
Steps 1 and 2 have to be done manually. Steps 3 and 4 are both done together by the CALL
instruction (and its CALL.pri
variant).
Steps 5 and 6 are both carried out together by the PROC
instruction. Step 8 is carried out using the STACK
instruction.
Steps 10 and 11 are done together by RET
instruction. The RETN
instruction can be used to do steps 10, 11, and 12 together. If the RET
instruction is used, the caller is responsible for cleaning up the stack. Since the STACK
, RET
, and RETN
instructions do not alter the contents of the primary register, the return value is unaffected.
new stk, stp, tmp; // globals are not involved in the stack
f(arg)
{
// compiler automatically adds a PROC instruction at the beginning of every function
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);
// prints the contents of the stack from top to bottom (lower address to higher address)
while(stk != stp) {
#emit LOAD.pri stk
#emit LOAD.I
#emit STOR.pri tmp
printf("%d", tmp);
stk += 4;
}
// compiler uses whatever information it has and adds instructions to correct the stack before returning
// if items have been pushed or popped manually using #emit, the compiler will not be aware of it
return 1234;
}
main () {
new a = 1;
f(101);
#emit STOR.S.pri a // store the value stored in the primary register (return value) in 'a'
printf("\nPRI: %d", a);
}
STP: 16500 STK: 16464
200 ; local variable 'x'
16488 ; frame address of 'main'
308 ; return address
4 ; 4 bytes of arguments were passed
101 ; argument 'arg' of function 'f'
1 ; local variable 'a'
0 ; unused frame address (there is no caller for main)
0 ; return to address 0, which has a 'halt 0' instruction
0 ; main does not take any arguments
PRI: 1234
Pass by value: The arguments passed by value have copies of the actual parameter pushed onto the stack. Since an independent copy exists on the stack, any modification done by the callee on the argument will not affect the actual parameter.
f(arg1, arg2)
{
#emit CONST.pri 10
#emit STOR.S.pri arg1 // this does not affect 'x' in 'main' since it modifies the copy of 'x' on the stack not 'x' itself
}
main ()
{
new x = 150;
// f(x, 10);
#emit PUSH.C 10 // push the
#emit PUSH.S x // effectively pushes the value contained in 'x' on to the stack
#emit PUSH.C 8 // two arguments were pushed; hence, the total size is 8 bytes
#emit CALL f
}
Pass by reference: The address of the actual parameter is passed. The callee uses the address to read and modify the argument. Therefore, any change made by the callee to the argument will affect the actual parameter.
new global;
f(&arg1, &arg2)
{
#emit CONST.pri 10
#emit SREF.S.pri arg1 // 'arg1' has address of 'x' and the write happens to that address => [[FRM + arg]] = PRI
}
main ()
{
new x = 150;
// f(x, global);
#emit PUSH.C global // push the address of 'global'
#emit PUSH.ADR x // push address of 'x', i.e: FRM + x
#emit PUSH.C 8
#emit CALL f
}
Passing arrays: Arrays are passed as reference, i.e., the base address of the array is passed. This avoids the cost of making a copy of the array, but any modifications made to the argument are transparent.
f(arr1[], arr2[])
{
#emit CONST.pri 2
#emit LOAD.S.alt arr1 // loads the address of the array 'arg1' points to in to the alternate register
#emit IDXADDR // calculate the address of arr1[2]
#emit MOVE.alt // move the address calculated by IDXADDR in to the alternate register
#emit CONST.pri 100
#emit STOR.I // store 100 at arr1[2]
}
g(arg[])
{
new x[100];
// f(x, arg);
#emit PUSH.S arg // 'arg' stores the address of the array; hence, PUSH.S which pushes the value stored at 'arg' (pushes [FRM + arg])
#emit PUSH.adr x // 'x' exists locally and directly points to the array; hence, we use PUSH.adr (pushes 'FRM + x')
#emit PUSH.C 8
#emit CALL f
}
The Pawn syntax allows passing an array from a particular index. This can be achieved by passing the address of the element at that index.
f(arr[]) { }
main()
{
new x[100];
// f(x[2]);
#emit ADDR.alt x // load the address of 'x' (FRM + x) in to the alternate register
#emit CONST.pri 2 // index 2
#emit IDXADDR // compute the address of x[2]
#emit PUSH.pri // push the address of x[2]
#emit PUSH.C 4
#emit CALL f
}
Passing variable arguments: The parameters passed to a variable argument list are passed by reference. If a constant or an expression which is not an lvalue has to be passed, the result must be temporarily stored on the heap, and its address must be passed.
f(arg, ...)
{
new argc;
#emit LOAD.S.pri 8 // offset 8 has the number of arguments in bytes
#emit CONST.alt 4
#emit UDIV // primary register has the number of arguments that were passed
#emit ADD.C -1 // subtract 1 because we are not interested in 'arg'
#emit STOR.S.pri argc // argc now has the number of arguments
// go through the arguments list in reverse order
while(argc--) {
new offset = 16 + argc*4;
#emit LOAD.S.pri offset
#emit LOAD.I // primary register has the address of the argument
// do something
}
}
main ()
{
new x;
//f(10, x, 25);
#emit HEAP 4 // allocate space on the heap for 25
#emit CONST.pri 25
#emit STOR.I // store 25 in the allocated space
#emit PUSH.alt // push the address of the allocated cell
#emit PUSH.adr x
#emit PUSH.C 10 // pass by value
#emit PUSH.C 12
#emit CALL f
#emit HEAP -4 // free the allocated space
}
The procedure for calling native functions is almost the same as that for any other function. The difference is that the SYSREQ.pri/SYSREQ.C
instruction is used in place of CALL/CALL.pri
, and the stack has to be cleaned up by the caller. The compiler creates an entry in the native function table for every native function that is used in the script. The records of the native function table are assigned an index in order starting with zero for the first record. This index is used as an operand for SYSREQ.pri/SYSREQ.C
instructions.
If the name of the native function is provided as an operand, the compiler substitutes its corresponding native function index.
native random();
native printf(const frmt[], ...);
main ()
{
static const str[] = "Random number: %d";
//printf(str, random());
#emit PUSH.C 0 // zero arguments
#emit SYSREQ.C random
#emit STACK 4 // must manually clean the stack (only one item was pushed onto the stack; hence, we move STK up by 4)
// random value was returned in the primary register
// prepare to push the random value
// note that 'printf' takes values as variable arguments: we need to push addresses
#emit HEAP 4 // make space for the random value in the heap
#emit STOR.I // store the value of the primary register in the newly allocated space
#emit PUSH.alt // push the address of the random value
#emit PUSH.C str // 'str' is static local string; hence, it is present in the data segment
#emit PUSH.C 8
#emit SYSREQ.C printf
#emit STACK 12 // total of three items were pushed; hence, pop 3 cells
#emit HEAP -4
}
Two-dimensional arrays consist of an indirection table in addition to the array data. The indirection table is a one-dimensional array of offsets to the sub-arrays. For a two dimensional 4x3 array, the structure would look like this:
address of [0] | address of [1] | address of [2] | address of [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])
The indirection table consists of 4 offsets pointing to their corresponding 3-element sub-array. These offsets are relative to the address of the cell they are read from. For example, to obtain the address of the sub-array [2], the address of element [2] in the indirection table must be added to the value it contains.
static const arr[][] = { {1, 2, 3}, {1, 2}, {4, 5, 6, 7} };
With the assumption that cells are of 4 bytes, the above array will be stored in its binary form as:
12 20 24 1 2 3 1 2 4 5 6 7
indirection table | sub-array [0] | sub-array [1] | sub-array[2] |
---|---|---|---|
12 20 24 | 1 2 3 | 1 2 | 4 5 6 7 |
The sub-array [0] starts 12 bytes away (the first 1
) from the beginning of the indirection table. The address of the sub-array [0] would
be the address of the first element in the indirection table plus the value stored at that address. The sub-array [1] starts 20 bytes away
from the second element of the indirection table. The address of the sub-array [1] would be the address of the second element of the indirection table plus the value stored at that address.
new arr[5][5];
// access arr[2][4]
#emit CONST.alt arr // load the address of the indirection table
#emit CONST.pri 2 // set the sub-array index to access (major dimension index)
#emit IDXADDR // address of the cell in the indirection table storing offset to sub-array [2] is in the primary register now
#emit MOVE.alt // keep a copy of the address
#emit LOAD.I // load the offset into the primary register
// currently ALT contains the address of the cell storing the offset of the sub-array [2]
// currently PRI contains the offset to the sub-array [2]
// the offset stored is relative to the address of the cell it was read from; hence, we add PRI and ALT together
#emit ADD
// PRI now has the address of the sub-array [2]
// the sub-array [2] can now be treated as a one-dimensional array
#emit MOVE.alt // copy the address of the sub-array [2]
#emit CONST.pri 4 // index of the minor dimension
#emit LIDX // loads [2][4] in to the primary register
Every N-dimensional array (N != 1) contains an indirection table with offsets pointing to the (N - 1)-dimensional sub-arrays. To access an element, the indirection tables must be recursively traversed until a one-dimensional array is obtained.
new arr[5][5][5];
// access arr[3][2][4]
#emit CONST.pri arr
// PRI contains the address of the indirection table for the three-dimensional array
#emit MOVE.alt
#emit CONST.pri 3
#emit IDXADDR
#emit MOVE.alt
#emit LOAD.I
#emit ADD
// PRI contains the address of the indirection table of the two-dimensional arr[3] sub-array
#emit MOVE.alt
#emit CONST.pri 2
#emit IDXADDR
#emit MOVE.alt
#emit LOAD.I
#emit ADD
// PRI contains the address of the one-dimensional arr[3][2] sub-array
#emit MOVE.alt
#emit CONST.pri 4
#emit LIDX
// PRI contains the value stored at arr[3][2][4]
A multi-dimensional array is passed as an argument to a function by passing the address of its indirection table.