我试图围绕函数调用的概念,因为它们与堆栈相关。这个问题是在低级语言而不是高级语言的背景下提出的。
据我所知,当调用函数时,局部变量和参数会存储在堆栈上的堆栈帧中。每个堆栈帧都与单个函数调用相关联。我不太清楚的部分是谁负责创建框架?我的程序是否应该查看程序中的函数声明并手动将局部变量复制到堆栈上的新框架?
是的...
假设你有一种像 C 这样允许递归的语言。为此,函数的每个实例都必须独立于该函数的其他实例。堆栈是完美的地方,因为代码可以在不知道物理地址的情况下“分配”和引用分配中的项目,它都是通过引用访问的。您所关心的只是在函数的上下文中跟踪该引用并将堆栈指针恢复到您进入函数时的位置。
现在您必须有一个调用约定,一个适合递归等。两个流行的选择(使用简化模型)是寄存器传递和堆栈传递。您可以拥有并且实际上将拥有混合(基于寄存器的您将用完寄存器并且必须恢复到堆栈以获取剩余参数)。
假设我正在谈论的虚构硬件神奇地处理返回地址,而不会弄乱寄存器或堆栈。
注册通过。定义一组特定的硬件/处理器寄存器来保存参数,假设 r0 始终是第一个参数,r1 第二个,r2 第三个。并假设返回值为 r0 (这是简化的)。
堆栈传递。让我们定义你压入堆栈的第一件事是最后一个参数,然后是最后一个参数。当您返回时,可以说返回值是堆栈上的第一件事。
为什么要声明调用约定?这样调用者和被调用者都确切地知道规则是什么以及在哪里可以找到参数。寄存器传递在表面上看起来很棒,但是当您用完寄存器时,您必须将内容保存在堆栈中。当您想从被调用者变为另一个函数的调用者时,您可能必须在调用寄存器中保留项目,以免丢失这些值。你在堆栈上。
int myfun ( int a, int b, int c)
{
a = a + b;
b+=more_fun(a,c)
return(a+b+c);
}
a、b 和 c 在调用 more_fun 之后使用,more_fun 至少需要 r0 和 r1 来传递参数 a 和 c,因此您需要将 r0 和 r1 保存在某处以便您可以 1) 使用它们来调用 more_fun () 和 2),这样您就不会丢失从 more_fun() 返回后需要的值 a 和 b。您可以将它们保存在其他寄存器中,但是如何保护这些寄存器不被调用函数修改。最终,东西被保存在堆栈上,堆栈是动态的,通过引用而不是物理地址访问。所以
有人想给 myfun 打电话,我们正在使用注册密码。
r0 = a
r1 = b
r2 = c
call myfun
;return value in r0
myfun:
r0 = r0 + r1 (a = a + b)
;save a and b so we dont lose them
push r0 (a)
push r1 (b)
r0 = r0 (a) (dead code, can be optimized out)
r1 = r2 (c)
call more_fun
;morefun returns something in r0
pop r1 (recover b)
r1 = r1 + r0 (b = b+return value)
pop r0 (recover a)
;r0 is used for returning a value from a function
r0 = r0 + r1 (= a+b)
r0 = r0 + r2 (=(a+b)+c)
return
调用函数(caller)知道在r0、r1、r2中准备三个参数,并在r0中取一个返回值。被调用者知道接受 r0,r1,r2 作为传入参数并在 r0 中返回,并且它知道当它成为某个其他函数的调用者时必须保留一些东西。
如果我们使用堆栈通过调用约定传递参数
int myfun ( int a, int b, int c)
{
a = a + b;
b+=more_fun(a,c)
return(a+b+c);
}
现在我们必须制定一些寄存器规则,我们是否定义调用规则来说明 1)您可以销毁任何寄存器(但 sp 和 pc 和 psr),2)您必须保留每个寄存器,以便当您返回调用函数永远不会看到它的寄存器发生变化,或者你是否定义了 3)有些寄存器是临时的,可以随意修改,有些如果使用则必须保留。我要说的是,为了简单起见,您可以销毁除 sp、pc 和 spr 之外的寄存器。
我们还有一个问题要解决。谁清理堆栈?当我调用 morefun 时,堆栈中有两个项目进入,只有退出时的返回值,谁清理堆栈。两种选择,调用者清理,被调用者清理,我选择调用者清理。这意味着被调用者必须以找到它的方式从带有堆栈的函数返回,它将任何东西留在堆栈上,并且不会从堆栈中取出太多东西。
呼叫者:
push c
push b
push a
call myfun
pop result
pop and discard
pop and discard
假设使用此硬件,堆栈指针 sp 指向堆栈上的当前项
myfun:
;sp points at a
load r0,[sp+0] (get a)
load r1,[sp+1] (get b)
add r0,r1 (a = a+b)
store [sp+0],r0 (the new a is saved)
;prepare call to more_fun
load r0,[sp+2] (get c)
load r1,[sp+0] (get a)
push r0 (c)
push r1 (a)
call more_fun
;two items on stack have to be cleaned, top is return value
pop r0 (return value)
pop r1 (discarded)
;we have cleaned the stack after calling more_fun, our offsets are as
;they were when we were called
load r1,[sp+1] (get b)
add r1,r0 (b = b + return value)
store [sp+1],r1
load r0,[sp+0] (get a)
load r1,[sp+1] (get b)
load r2,[sp+2] (get c)
add r0,r1 (=a+b)
add r0,r2 (=(a+b)+c)
store [sp+0],r0 (return value)
return
所以我在飞行中写了所有这些,可能有一个错误。所有这一切的关键是你必须定义一个调用约定,如果每个人(调用者和被调用者)都遵循调用约定,它会使编译变得容易。诀窍是制定有效的调用约定,正如您在上面看到的,我们必须修改约定并添加规则以使其即使对于这样一个简单的程序也能正常工作。
堆栈帧呢?
int myfun ( int a, int b)
{
int c;
c = a + b;
c+=more_fun(a,b)
return(c);
}
使用基于堆栈的
呼叫者
push b
push a
call myfun
pop result
pop and discard
被调用者
;at this point sp+0 = a, sp+1 = b, but we need room for c, so
sp=sp-1 (provide space on stack for local variable c)
;sp+0 = c
;sp+1 = a
;sp+2 = b
load r0,[sp+1] (get a)
load r1,[sp+2] (get b)
add r0,r1
store [sp+0],r0 (store c)
load r0,[sp+1] (get a)
;r1 already has b in it
push r1 (b)
push r0 (a)
call more_fun
pop r0 (return value)
pop r1 (discarded to clean up stack)
;stack pointer has been cleaned, as was before the call
load r1,[sp+0] (get c)
add r1,r0 (c = c+return value)
store [sp+0],r1 (store c)(dead code)
sp = sp + 1 (we have to put the stack pointer back to where
;it was when we were called
;r1 still holds c, the return value
store [sp+0],r1 (place the return value in proper place
;relative to callers stack)
return
被调用者,如果它使用堆栈并移动堆栈指针,它必须把它放回它被调用时的位置。您可以通过在堆栈上添加正确数量的东西以进行本地存储来创建堆栈框架。您可能有局部变量,并且通过编译过程您可能提前知道您还必须保留一定数量的寄存器。最简单的方法是将所有这些加起来,并将整个函数的堆栈指针移动一次,然后在返回之前将其放回一次。您可以变得更聪明,并在调整偏移量的过程中不断移动堆栈指针,这会更难编码并且更容易出错。像 gcc 这样的编译器倾向于将堆栈指针移动到函数中并在离开之前返回它。
一些指令集在调用时将内容添加到堆栈中,并在返回时将其删除,您必须相应地调整偏移量。同样,您围绕调用另一个函数的创建和清理可能需要处理与堆栈的硬件使用相关的处理(如果有)。
假设您进行调用时的硬件将返回值推送到堆栈顶部。
int onefun ( int a, int b )
{
return(a+b)
}
onefun:
;because of the hardware
;sp+0 return address
;sp+1 a
;sp+2 b
load r0,[sp+1] (get a)
load r1,[sp+2] (get b)
add r1,r2
;skipping over the hardware use of the stack we return on what will be the
;top of stack after the hardware pops the return address
store [sp+1],r1 (store a+b as return value)
return (pops return address off of stack, calling function pops the other two
;to clean up)
一些处理器在调用函数时使用寄存器来保存返回值,有时硬件会指示哪个寄存器,有时编译器会选择一个并将其用作约定。如果您的函数没有调用任何其他函数,您可以不使用返回地址寄存器并将其用于返回,或者您可以在某个时刻将其压入堆栈,然后在返回之前将其弹出然后使用它返回. 如果您的函数确实调用了另一个函数,则您必须保留该返回地址,以便对下一个函数的调用不会破坏它并且您无法找到回家的路。因此,如果可以,您可以将其保存在另一个寄存器中,也可以将其放入堆栈
使用我们定义的上述寄存器调用约定,加上一个名为 rx 的寄存器,当进行调用时,硬件会为您将返回地址放在 rx 中。
int myfun ( int a, int b)
{
return(some_fun(a+b));
}
myfun:
;rx = return address
;r0 = a, first parameter
;r1 = b, second parameter
push rx ; we are going to make another call we have to save the return
; from myfun
;since we dont need a or b after the call to some_fun we can destroy them.
add r0,r1 (r0 = a+b)
;we are all ready to call some_fun first parameter is set, rx is saved
;so the call can destroy it
call some_fun
;r0 is the return from some_fun and is going to be the return from myfun,
;so we dont have to do anything it is ready
pop rx ; get our return address back, stack is now where we found it
; one push, one pop
mov pc,rx ; return
通常,处理器供应商或第一家为处理器开发流行语言编译器的公司将定义函数调用者在调用函数之前应该做什么(堆栈上应该有什么,各种寄存器应该包含什么等)以及被调用者应该做什么。函数应该在它返回之前执行(包括恢复某些寄存器的值,如果它们已被更改等)。对于某些处理器,多种约定已变得流行,并且确保任何给定函数的代码将使用调用代码所期望的约定通常非常重要。
在具有少量寄存器的 8088/8086 上,出现了两个主要约定:C 约定,它指定调用者应在调用函数之前将参数压入堆栈,然后将其弹出(意味着唯一的被调用的函数应该从堆栈中弹出是返回地址),以及 Pascal 约定,它指定被调用的函数除了弹出返回地址之外,还应该弹出所有传递的参数。在 8086 上,Pascal 约定通常允许代码稍微小一些(因为堆栈清理只需要对每个可调用函数进行一次,而不是对每个函数调用一次,并且因为 8086 包含一个 RET 版本,它向之后的堆栈指针弹出返回地址。Pascal 约定的一个缺点是它要求被调用函数知道要传递多少字节的参数。如果调用的函数没有弹出正确的字节数,堆栈损坏几乎肯定会发生。
在许多较新的处理器上,具有少量固定参数的例程通常不会将其参数压入堆栈。相反,编译器供应商将指定在调用函数之前将前几个参数放入寄存器中。这通常比使用基于堆栈的参数实现更好的性能。但是,具有许多参数或可变参数列表的例程仍必须使用堆栈。
为了扩展 supercat 的答案,设置堆栈帧是调用和被调用函数的共同责任。堆栈帧通常指的是例程的特定调用本地的所有数据。然后,调用例程首先将任何基于堆栈的参数压入堆栈,然后通过调用例程将返回地址压入堆栈,从而构建外部堆栈帧。然后,被调用的例程通过(通常)将当前帧指针压入(保存)堆栈,并设置一个指向下一个空闲堆栈槽的新指针来构建堆栈帧的其余部分(内部堆栈帧)。然后它为堆栈上的局部变量保留堆栈,并且根据所使用的语言,此时也可能会初始化它们。然后可以使用帧指针来访问基于堆栈的参数和局部变量,一个带有负数,另一个带有正偏移量。从例程退出时,旧堆栈帧被恢复,本地数据和参数被弹出,如 supercat 所述。