过程(procedures)通常指的是一系列的操作或者计算步骤,用于完成特定的任务或解决问题。

过程是软件中一种和重要的抽象。它提供了一种封装代码的方式,用一组指定的参数和一个可选的返回值实现了某种功能。

常见的过程:函数(function)、方法(method)、子例程(subroutine)、处理函数(handler)等等。

栈帧结构

程序可以用栈来管理它的过程所需要的存储空间,栈和程序寄存器存放着传递控制和数据、分配内存所需要的信息。当函数 P 调用 Q 时,控制和数据信息添加到栈尾。
当 P 返回时,这些信息会释放掉。

x86-64 的栈向低地址方向增长,而栈指针%rsp 指向栈顶元素。
pushqpopq 指令分别将数据存入栈和从栈中取出。
将栈指针减小一个适当的量可以为没有指定初始值的的数据在栈上分配空间,增加栈指针则可以释放空间。

机器用栈来传递过程参数、存储返回信息、保存寄存器用于以后回复,以及本地存储。
为单个过程分配的那部分栈称为栈帧(stack frame)。当 x86-64 过程需要的存储空间超过寄存器能够存放的大小时,
就会在栈上分配空间,这部分栈就是栈帧。

image-20230215102408876

当前正在执行的过程的帧总是在栈顶。

当过程 P 调用过程 Q 时,会把返回地址压入栈中,指明当 Q 返回时,要从 P 程序的哪个位置继续执行。

转移控制

将控制从函数 P 转移到函数 Q 只需要把程序计数器(PC)设置为 Q 的代码的起始位置。
当稍后从 Q 返回的时候,处理器必须记录好它需要继续 P 的执行的代码位置。

在 x86-64 中,这些操作使用指令 call Q 调用过程 Q。这个指令会将地址 A 压入栈中,并且将 PC 设置成 Q 的起始地址。
这里的地址 A 是返回地址,也就是在过程 P中的紧跟在 call 指令后的那条指令的地址。

下面是关于在 x86-64 下的操作的示例:

main 函数中,地址为 0x400563 的 call 指令调用函数 multstore。此时状态如第二个图中的 a。

执行完之后,因为 call 指令的效果会将返回地址 A 压入栈中,并将 PC 设置成 Q 的起始地址。
在这个例子中就是将 main 函数中的 call 指令后的 mov 指令的地址 0x400568 压入栈中,将 PC(%rip) 设置成 multstore 的起始地址。

函数multstore继续执行,直到遇到ret 指令。这条指令会从栈中弹出值 0x400568,然后跳转到这个地址,继续main函数的执行。

cs-procedure-1

栈指针 %rsp,程序计数器 (PC) %rip。程序计数器存储下一条执行的指令。

cs-procedure-2

过程的实现主要就是在于数据如何在调用者和被调用者之间传递,以及在被调用者当中局部变量内存的分配以及释放。

在下面的说明中是在 x86-64 的环境下,所以 %ebp 为帧指针,%esp 为栈指针。

而过程实现当中,参数传递以及局部变量内存的分配和释放都是通过以上介绍的栈帧来实现的,大部分情况下,我们认为过程调用当中做了以下几个操作。

  1. 备份原来的帧指针,调整当前的帧指针到栈指针的位置,这个过程就是我们经常看到的如下两句汇编代码做的事情。创建一个新的栈帧

    1
    2
    pushq	%rbp
    movq %rsp, %rbp

    此时栈指针 %rsp 指向新栈帧的底部,帧指针 %rbp 指向上一个栈帧的底部。

  2. 建立起来的栈帧就是为被调用者准备的,当被调用者使用栈帧时,需要给临时变量预留内存,这一步一般是经过下面这样的汇编代码处理。

    1
    subq	$8, %rsp
  3. 备份被调用者保存的寄存器当中的值,如果有值的话,备份的方式就是压入栈顶。

    1
    pushq	%rbx
  4. 使用建立好的栈帧,比如读取和写入,一般使用 mov,push 以及 pop 指令等等。

  5. 恢复被调用者寄存器当中的值,这一过程其实是从栈帧中将备份的值再恢复到寄存器,不过此时这些值可能已经不在栈顶了。因此在恢复时,大多数会使用 pop 指令,但也并非一定如此。

  6. 释放被调用者的栈帧,释放就意味着将栈指针加大,而具体的做法一般是直接将栈指针指向帧指针,因此会采用类似下面的汇编代码处理(也可能是 addl)。

    1
    movq    %rbp, %rsp
  7. 恢复调用者的栈帧,恢复其实就是调整栈帧两端,使得当前栈帧的区域又回到了原始的位置。因为栈指针已经在第六步调整好了,因此此时只需要将备份的原帧指针弹出到%ebp 即可。类似的汇编代码如下。

    1
    popq    %rbp
  8. 弹出返回地址,跳出当前过程,继续执行调用者的代码。此时会将栈顶的返回地址弹出到 PC(程序计数器),然后程序将按照弹出的返回地址继续执行。这个过程一般使用 ret 指令完成。

可以看看这个 UP 主对过程的解说,清楚易懂:【CSAPP-深入理解计算机系统】3-7. 过程(函数调用)

数据传送

x86-64 的栈向低地址方向增长,而栈指针 %rsp 指向栈顶元素。可以用 pushq 和 popq 指令将数据存入栈中或是从栈中去除。
将栈指针减小一个适当的量可以为没有指定初始值的数据在占上分配空间
类似地,可以通过增加栈指针来释放空间。而当 x86-64 过程需要的存储空间超出寄存器能够存放的大小时,就会在栈上分配空间。这个部分称为过程的栈帧(stack fram)。
一般 x86-64 寄存器的可以存储函数参数的前 6 个参数,如果超过 6 个参数,那么后面的参数就会存储在栈上。

函数的前 6 个参数使用的寄存器:

image-20230214182149549

寄存器传递函数参数

栈上的局部存储

有些时候,局部数据必须存放在内存中,常见的情况包括:

  • 寄存器不足够存放所有的本地数据。
  • 对一个局部变量使用地址运算符 &,因此必须能够为它产生一个地址。
  • 某些局部变量是数组或结构,因此必须能够跳过数组或结构引用被访问到。

寄存器中的局部存储空间

寄存器使用惯例

被调用者保存寄存器:%rbx,%rbp,%r12,%r13,%r14,%r15。

调用者保存寄存器:所有其他的寄存器,除了栈指针 %rsp,都分类为调用者保存寄存器。%rdi,%rsi,%rdx,%rcx,%r8,%r9,%rax,%r10,%r11。

寄存器组是唯一被所有过程共享的资源。所以要确保当一个过程(调用者)调用另一个过程(被调用者)时,被调用者不会覆盖调用者稍后会使用的寄存器值。所以有了前面所说的调用者保存寄存器和被调用者保存寄存器。

当过程 P 调用过程 Q 时,Q 必须保存这些寄存器的值,保证他们的值在 Q 返回到 P 时与 Q 被调用时时一样的。

可以这样来理解“调用者保存”这个名字:过程 P 在某个此类寄存器中有局部数据,然后调用过程 Q。因为 Q 可以随意修改这个寄存器,所以在调用之前首先保存好这个数据是 P(调用者)的责任。