• 0 票 - 平均分 0
  • 1
  • 2
  • 3
  • 4
  • 5
[修仙者篇] AMX 汇编文档
#2

调用栈与调用约定



在本节中,我们假设一个单元格为 4 字节。

局部变量与函数帧



对于每个声明的局部变量,通过简单地将零(或提供的初始化值)压栈,在栈上为其预留空间。

代码:
f()
{
    new local1,     // PUSH.C 0
        local2 = 5; // PUSH.C 5

    local1 = local2 + 50; // 直接访问栈中的 'local1' 和 'local2'
}

编译器分别添加 PUSH.C 0PUSH.C 5 指令以响应 local1local2 的声明。对变量的所有访问和修改都将直接访问和修改栈中的相应单元格。然而,直接使用局部变量的地址进行读写是不可能的,因为最终地址只能在运行时知道;例如,考虑递归函数的局部变量。

解决方案是放弃使用地址,而是使用相对于栈中特定参考位置的偏移量。调用函数后栈顶被用作参考点。正式地,这个参考点是当前函数帧的开始,相应的地址称为函数的帧地址。当调用一个函数时,它会将前一个函数的帧地址保存在栈上,并将 FRM 寄存器设置为指向当前函数的帧。

函数帧在函数被调用后是空的;相对于帧地址的栈顶偏移为零。当遇到局部变量时,会依次在帧中为其预留空间。注意栈向下增长;因此,最近压入的项的地址将小于其前一项的地址。这意味着压入栈中的项的偏移量是负数。在上面的例子中,local1local2 的偏移量分别是 -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
}

当声明局部数组时,通过将栈指针向下移动存储数组所需的字节数来为数组预留空间。然后用零或提供的初始化列表填充数组。在上面的代码片段中,local1local2local3 的偏移量分别是 -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
.                      .
.                      .
.                      .
低地址

进行函数调用的步骤:
  1. 以相反顺序推送参数(最后一个参数先推)
  2. 推送参数数量(以总字节数为单位)
  3. 推送返回地址
  4. CIP 设置为指向被调用者的起始处
  5. 保存 FRM 寄存器的值(调用者的帧地址)在栈上
  6. FRM 设置为 STK(被调用者的帧地址)
  7. 执行函数体
  8. 将栈恢复到函数调用后刚结束时的状态(即移除局部变量和临时变量)
  9. 将返回值放入主寄存器
  10. 弹出调用者的帧地址并设置 FRM 寄存器
  11. 弹出返回地址并设置 CIP 寄存器
  12. 从栈中移除参数

步骤 1 和 2 必须手动完成。步骤 3 和 4 都由 CALL 指令(及其 CALL.pri 变体)一起完成。
步骤 5 和 6 都由 PROC 指令一起完成。步骤 8 使用 STACK 指令完成。
步骤 10 和 11 由 RET 指令一起完成。RETN 指令可用于同时完成步骤 10、11 和 12。如果使用 RET 指令,则调用者负责清理栈。由于 STACKRETRETN 指令不会改变主寄存器的内容,因此返回值不受影响。

代码:
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] 处的值

多维数组作为参数传递给函数时,通过传递其间接表的地址来实现。
  回复


此主题中的消息
[修仙者篇] AMX 汇编文档 - 由 小鸟unsigned - 03-21-2026, 11:19 PM
RE: [修仙者篇] AMX 汇编文档 - 由 小鸟unsigned - 03-21-2026, 11:20 PM

论坛跳转:


浏览此主题的用户: 1 位客人