assembly 为什么堆栈上的参数顺序是这样的:第一个参数在最低地址,第二个在第二低地址,依此类推

xesrikrc  于 2022-11-13  发布在  其他
关注(0)|答案(1)|浏览(122)

我的计算机体系结构教授让我们解释一下为什么这些论点是以相反的顺序放在堆栈上的。
我的意思是,比如说,当我们想调用一个接受2个整数的普通函数时,我们将堆栈扩展至少24个字节,这样我们就可以为寄存器a0 - a3创建一个home段,这样我们就有足够的空间来存储ra寄存器的值,同时对齐堆栈,使sp位于8的倍数的地址上。
为什么寄存器a0位于sp + 0,寄存器a1位于sp + 4,依此类推?
我唯一想到的是,这只是纯粹的惯例,但如果这只是纯粹的惯例,就没有理由问我颠倒论点顺序背后的原因...

hc2pp10m

hc2pp10m1#

如果调用约定在寄存器中传递参数,则根本不需要将它们存储到堆栈中。在标准MIPS约定中,调用方在调用前保留“主空间”(有时称为“影子空间”),但被调用方选择如何使用它。

**当您将它们视为一个参数数组时,您所称的“逆序”实际上并不是相反的。**如果参数超过4个,则调用方在调用之前将第5个及后面的参数存储在堆栈中。被调用方可以将寄存器参数转储到主空间,以生成一个与第一个(最左边的)arg,继续到调用者在栈上直接在home空间之上传递的arg。

一个编译debug build的编译器通常会以这种方式把它的参数存储到内存中。但是它 * 可以 * 选择做任何它想做的事情,特别是在一个优化的编译器中,手写的asm也是如此。至少对于非可变参数函数是这样;像printf这样的函数需要迭代它们的args,通常只将寄存器args存储到主空间,而不包括“固定的”args,比如第一个args是格式字符串。
如果您需要在任何地方存储参数,“标准”主空间插槽是一个不错的选择。
C调用约定从左到右枚举参数。所以最左边的参数进入第一个传递参数的寄存器,以此类推。(有趣的事实:Pascal的做法正好相反。在Pascal约定中,最左边的args将位于最高地址。在x86这样的机器上,push指令,没有寄存器args的调用约定可以通过从左到右的顺序压入args来实现这一点,因此最后一个args将位于最低地址。在大多数机器上,堆栈向下增长,包括x86和标准MIPS调用约定。)
变量函数需要最左边的参数位于一致的位置(如$a0):ISO C要求printf("hello\n", 1, 2, 3, 4);安全地忽略没有被格式字符串引用的args 1。printf不需要知道调用者在MIPS使用的标准4寄存器约定中放入堆栈的第五个args(值为4)。但是它确实需要找到格式字符串。(snprintf或fprintf有更多的非可变参数)。
有趣的事实:MIPS不 * 需要 *“主空间”来让函数生成一个连续的参数数组jal不会修改$sp,因此被调用方可以在堆栈参数的正下方腾出空间来存储传入的寄存器参数。您不会将其称为“home space,”因为就调试器在堆栈帧中查找传入的参数变量而言,它并不特殊。它不会保存小叶子函数的指令(无论什么原因都无法内联),尽管红色区域($sp以下的安全区域)同样适用于此。
这在Windows x64中是不同的,例如,call指令将返回地址压入堆栈,因此调用方 * 在调用 * 之前 * 保留home空间 * 是 * 重要的,这样被调用方就可以创建一个连续的参数数组,与不这样做的约定相比,简化了可变函数的实现。(如x86-64 System V。)
标准的MIPS约定 * 确实 * 让调用者保留home空间,并将其留给调用者来清理。我们可以从编译器的输出中看到这一点:

void bar();

void foo(void)
{         // reserves 32 bytes, home space + room to save/restore the return address
    bar();
    bar();
    return 1;   // make this *not* a tail-call
}

int use_home_space(volatile int x, volatile int y)
{
    // volatile function args get spilled even with optimization enabled.
    ++x;
}

Godbolt上使用GCC编译:

# GCC11.2 -O2 -march=mips3 -fno-delayed-branch
#   the default is mips1 which needs a load-delay 
foo:
        addiu   $sp,$sp,-32       # reserve 32 bytes, home space + return address
                                  # padded to keep SP 16-byte aligned?
        sw      $31,28($sp)       # save return address
        jal     bar
        nop

        lw      $31,28($sp)
        addiu   $sp,$sp,32
        j       bar                
        nop

GCC * 可以 * 将其返回地址保存到传入的主空间中,并在此函数中仅分配16个字节,而不是32个字节。如果这只是一个错过的优化,或者如果有ABI的原因不这样做,则IDK。

use_home_space:
        sw      $4,0($sp)     # store x ($a0) at the lowest address
        sw      $5,4($sp)     # store y ($a1) at the 2nd home-space slot

        lw      $3,0($sp)
        addiu   $3,$3,1       # ++x
        sw      $3,0($sp)

        jr      $31           # return
        nop

脚注1:安全地忽略多余参数的要求也意味着被调用方弹出约定不能用于变量函数。
例如,32位x86 stdcall不可用; x86 C实现对于大多数函数默认为stdcall,对于变量函数使用cdecl.
我所知道的MIPS上唯一的标准约定是GCC使用的,即caller-pop,即清除home空间和它为其分配空间的任何堆栈参数。被调用方返回的SP具有与它在入口时相同的值。
这对MIPS来说很有意义; x86具有push/pop等堆栈指令,而且许多传统x86调用约定不传递寄存器中的任何参数,因此push/push/call或其他操作是正常的,然后必须在每次调用返回后撤消这两次推送。或者使用普通存储(x86 mov)而不是push来为下一次调用设置参数。x86甚至有一个特殊形式的返回指令,它像往常一样弹出返回地址,然后将立即数加到堆栈指针以再弹出n个字节的空间。

MIPS没有那些使被调用方弹出有吸引力或有用的特性。只将sw参数放入堆栈内存比在每次存储之前也将addiu $sp, $sp, -4参数放入堆栈内存使其成为一个“推送”要 * 更 * 高效。但是在x86上,push eax是一个1字节指令,而mov [esp+4], eax是多个字节。(在现代的x86上,两者的速度几乎相同。)因此,在MIPS上,一个进行多个函数调用的函数只需为它所需的最大参数区域分配足够的堆栈空间(包括主空间),并在每次调用之前设置寄存器,可能还有一些sw指令。$sp在函数返回之前不会移动(当然,除非在被调用方内部,或者如果我们进行了分配...)
让一个被调用方释放home空间意味着你必须为下一次调用重新分配home空间。(同样有home空间的Windows x64也是一个类似的caller-pop约定,原因类似。即使当函数在Windows x64上超过4个参数时,也通常使用普通的mov存储来设置堆栈参数,而不是push。)
非叶MIPS函数也需要存储/重新加载它们的返回地址。如果它们像GCC那样保留堆栈空间(而不是使用调用者分配给它们自己的home空间),那么被调用者释放自己的home空间甚至不会撤销调用者的所有分配,所以调用者在返回之前仍然需要再次修改$sp
因此,MIPS上的被调用方弹出约定,尤其是在像标准约定这样的具有主空间的约定中,会产生反效果,在函数后记中花费更多的指令,* 和 * 在大多数调用站点上。通常不会节省任何东西。

相关问题