03-21-2026, 11:20 PM
(此帖子最后修改于: 03-21-2026, 11:39 PM 由 小鸟unsigned.)
调用栈与调用约定
在本节中,我们假设一个单元格为 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] 处的值多维数组作为参数传递给函数时,通过传递其间接表的地址来实现。

