栈其实就是计算机系统内存中的一小块。栈是一块特殊的内存区域,栈在内存中的增长方向是向低地址扩展,%rsp寄存器存储栈的最低地址,即栈顶元素的地址。这种栈结构在程序中的应用有助于实现函数调用、局部变量的管理以及递归等功能。
Push和Pop指令
内存中中的栈可以进行push和pop指令,如果你学过数据结构中的栈,基本就是类似的。
Pushq
pushq Src
操作包括以下步骤:
- 从
Src
获取操作数。 - 将%rsp寄存器的值减8(因为x86-64架构中,一个二进制字长度为8字节)。
- 将操作数写入%rsp寄存器指向的地址。
通过pushq
操作,我们将数据压入栈顶,并更新了栈顶指针%rsp
popq
popq Dest
操作包括以下步骤:
- 读取%rsp寄存器指向的地址处的值。
- 将%rsp寄存器的值加8。(越往上地址越高,这里是向上缩小了)
- 将读取到的值存储在
Dest
(通常是一个寄存器)。
关于pop要注意的是,我们仅仅是把原来的值copy了一份给了Dest, %rsp的值虽然加了8往上移动了,但是原来%rsp所指的值依然存在在内存的那个位置中。
程序控制流Control Flow
在程序执行过程中,栈结构被用于支持函数的调用和返回。
函数调用:call指令
当需要调用一个函数时,使用call label
指令。它执行以下操作:
- 将返回地址(return address)压入栈中。(返回地址是紧跟在
call
指令后的下一条指令的地址。这一点很好理解,执行完call后就要回来继续往下执行嘛。) - 跳转(jump)至
label
指定的函数起始位置。
函数返回:ret指令
当函数执行完毕,需要返回到调用它的位置时,使用ret
指令。它执行以下操作:
- 从栈中弹出地址(pop address from stack)。
- 跳转(jump)至弹出的地址。
下面放一个例子,我配上了注释说明,应该很好理解。图中%rip存的是当前执行指令地址,即instruction pointer。
栈存储额外参数
在我们调用函数时,如果函数的参数少于等于6个,我们可以用%rdi %rsi %rdx %rcx %r8 %r9这几个寄存器分别存储这六个参数,但如果参数多于6个,从第七个开始,我们就会把他们放到栈里。
Stack Frames栈帧
啥是栈帧?当调用一个函数时,会使用 call
指令。call
指令会将返回地址压入栈中,然后跳转到目标函数的起始位置。此时,新的栈帧会被分配和设置,用于存储目标函数的参数、局部变量等。
返回地址属于调用者(上一个函数)的栈帧。当一个函数被调用时,它的返回地址会被call
指令压入到当前栈帧的顶部,紧接着就是下一个(被调用)函数的栈帧。
基于栈的编程语言通常支持递归,例如C、Pascal和Java等。这些编程语言的代码必须是"可重入的"(Reentrant),这意味着单个函数可以拥有多个同时进行的实例。为了实现这种功能,我们需要某种方式来存储每个函数实例的状态,包括参数、局部变量和返回指针等。栈规则(Stack Discipline)是为了实现上述需求而采用的策略。对于给定的函数实例,我们只需要在有限的时间内维护其状态,即从函数被调用到函数返回的这段时间。此外,被调用函数(callee)在调用函数(caller)返回之前就需要返回。
为了实现栈规则,我们将栈分配为栈帧。每个栈帧代表单个函数实例的状态。当一个函数被调用时,将为其分配一个新的栈帧,用于存储其参数、局部变量和返回指针等。当函数返回时,将释放其栈帧,从而为其他函数实例提供空间。
栈帧是用于存储函数实例状态的数据结构。每个栈帧包含以下内容:
- 返回信息:包括返回地址(return address)和可能的保存的寄存器值。
- 局部存储(如果需要的话):用于存储函数内的局部变量。
- 临时空间(如果需要的话):用于存储函数执行期间可能需要的临时数据。
栈帧的分配和回收在函数的调用和返回过程中完成:
- 分配空间:当进入函数时,先将返回地址压入栈中,再会为其分配一个栈帧。这个过程包括
pushq
指令(由call
指令执行)。 - 回收空间:当函数返回时,会释放其栈帧。这个过程包括
popq
指令(由ret
指令执行)将返回地址从栈中弹出。
上面的caller frame就是调用者的栈帧,caller又调用了另一个函数,于是有了新的栈帧。
Caller Saved 和Callee Saved 寄存器
最后再补充两个概念:Caller Saved 和Callee Saved。
当一个函数掉用另一个函数时,另一个函数进行计算时同样会用到寄存器来存储数据,那么难免会影响到前一个函数在寄存器上面的值,因此有这么一种规范或者约定,来帮助程序合理地使用寄存器而不会彼此冲突。
调用方保存寄存器(caller-saved register)也叫( “Call-Clobbered”)
这个术语的意思是,在调用过程(函数或子程序)之前,调用方有责任保存这些寄存器的值。这是因为被调用方可能会修改这些寄存器的值,而不需要负责恢复它们。换句话说,如果调用方关心这些寄存器的值,它需要在调用过程之前将这些值保存到内存中,在调用过程完成并返回后再将它们恢复到寄存器中。
属于caller-saved register的寄存器有:%rdi %rsi %rdx %rcx %r8 %r9 %r10 %r11
被调用方保存寄存器(callee-saved register)也叫(“Call-Preserved”)
当一个函数或子程序(被调用方)计划使用这些寄存器时,它负责在执行过程中保留这些寄存器的初始值,并在过程结束前恢复它们。这样,调用方可以确信在调用过程返回后,这些寄存器的值保持不变。被调用方保存寄存器的目的是减轻调用方的负担,因为它无需为这些寄存器执行保存和恢复操作。因此被调用方保存寄存器和调用方保存寄存器是互补的。
属于callee-saved register的寄存器有:%rbx, %r12, %r13, %r14
简单来说,caller-saved register是会被子函数改变的,调用者要自己通过提前存储的方式保存下来;而callee-saved register是可以保证调用结束后值不变的,调用者不用操心它们~