Maple's Story

CSAPP 学习笔记:函数调用及栈帧原理

字数统计: 2.1k阅读时长: 7 min
2020/04/18 Share

我们知道一个函数调用有以下三个基本过程:

  • 调用参数的传入
  • 局部变量的空间管理
  • 函数返回

函数的调用必须是高效的,而数据存放在 CPU通用寄存器 或者 RAM 内存中无疑是最好的选择。以传递调用参数为例,我们可以选择使用 CPU通用寄存器 来存放参数。但是通用寄存器的数目都是有限的,当出现函数嵌套调用时,子函数再次使用原有的通用寄存器必然会导致冲突。因此如果想用它来传递参数,那在调用子函数前,就必须先 保存原有寄存器的值,然后当子函数退出的时候再 恢复原有寄存器的值 。

函数的调用参数数目一般都相对少,因此通用寄存器是可以满足一定需求的。但是局部变量的数目和占用空间都是比较大的,再依赖有限的通用寄存器未免强人所难,因此我们可以采用某些 RAM 内存区域来存储局部变量。但是存储在哪里合适?既不能让函数嵌套调用的时候有冲突,又要注重效率。

这种情况下,栈无疑提供很好的解决办法。一、对于通用寄存器传参的冲突,我们可以再调用子函数前,将通用寄存器临时压入栈中;在子函数调用完毕后,在将已保存的寄存器再弹出恢复回来。二、而局部变量的空间申请,也只需要向下移动下栈顶指针;将栈顶指针向回移动,即可就可完成局部变量的空间释放;三、对于函数的返回,也只需要在调用子函数前,将返回地址压入栈中,待子函数调用结束后,将函数返回地址弹出给 PC 指针,即完成了函数调用的返回;

于是上述函数调用的三个基本过程,就演变记录一个栈指针的过程。每次函数调用的时候,都配套一个栈指针。即使循环嵌套调用函数,只要对应函数栈指针是不同的,也不会出现冲突。


栈区(Stack)

如下图,栈区为一段从高地址向低地址增长的连续内存区域,所以栈底(高地址)和栈的最大容量都是系统预先规定的。每个进程在用户态下对应有一个调用栈结构;用于存放函数的参数、返回值,以及函数的局部变量。(不包括静态局部变量,它们和全局变量一起放在静态存储区,)

"CSAPP P164 :X86-64下的栈帧结构"

寄存器(Register)

"X86-64下的16个通用寄存器"

由上图可知,16个寄存器的功能并不是单一的。

我们重点关注以下几个寄存器:

  • %rsp 是堆栈指针寄存器,通常会指向栈顶位置
  • %rbp 是栈帧指针,用于标识当前栈帧的起始位置
  • %rdi, %rsi, %rdx, %rcx, %r8, %r9 六个寄存器用于存储函数调用时的6个参数(如果有6个或6个以上参数的话)

其中后面6个是64位x86 CPU与32位的主要区别,函数的前6个参数均通过寄存器直接传递,而不需要放置在栈帧中,观察上图也可发现,其只保存了参数7~n。

但看到不少网上的资料提到64位同样会将前6个参数保存在栈帧内,与CSAPP所述不同,需待考证。

x86-64 下函数调用及栈帧原理:
前6个参数会保存到寄存器中,多于6个的参数会保存到堆栈中。但是,由于在子程序中可能会取参数的地址,而保存在寄存器中的前6个参数是没有内存地址的,因而我们可以猜测,保存在寄存器中的前6个参数,在子程序中也会被压入到堆栈中,这样才能取到这6个参数的内存地址。

由于被调用函数也会使用到寄存器,所以调用函数的寄存器中的值需要预先保存下来,以便返回后恢复。寄存器的保存分为调用者保存与被调用者保存两类,具体可在上表右侧一列看到。

栈帧(Stack Frame)

函数调用经常是嵌套的,在同一时刻,堆栈中会有多个函数的信息,每个未完成运行的函数占用一个独立连续区域(包含这个函数涉及的参数,局部变量,返回地址等相关信息),称为栈帧。

当调用函数时,就要压入一个新的栈帧,发起调用函数的栈帧成为调用者栈帧,被调用函数的栈帧则称为当前栈帧(rsprbp 之间的内存空间);被调用的函数运行结束后回收栈帧,回到调用者栈帧。这一过程都是自动的,由系统分配与销毁,无需手动调度。

"栈帧结构"

当前栈帧由%rsp%rbp两个指针所指向的范围指定,以便在函数返回时,可以很好地找到调用者栈帧的位置。

函数调用过程(未优化)

  1. 调用函数将超过6个部分的参数从后向前压入栈中
  2. 将返回地址(调用指令的下一条指令地址)压入栈中
  3. 跳转至被调用函数起始地址开始执行
  4. 将父函数栈帧起始地址(%rpb) 压栈
  5. %rbp的值设置为当前 %rsp 的值
  6. 通过subq &xx, %rsp分配临时数据区,用于存储需保持的寄存器值、局部变量。

函数返回

函数返回时,我们只需要得到函数的返回值(保存在 %rax 中),之后就需要将栈的结构恢复到函数调用之差的状态,并跳转到父函数的返回地址处继续执行。由于函数调用时已经保存了返回地址和父函数栈帧的起始地址,要恢复到子函数调用之前的父栈帧,我们只需要执行以下两条指令:

1
2
movq %rbp, %rsp    # 使 %rsp 和 %rbp 指向同一位置,即子栈帧的起始处
popq %rbp # 将栈中保存的父栈帧的 %rbp 的值赋值给 %rbp,并且 %rsp 上移一个位置指向父栈帧的结尾处

为了便于栈帧恢复,x86-64 架构中提供了 leave 指令来实现上述两条命令的功能。执行 leave 后,前面图中函数调用的栈帧结构如下:

"返回时栈帧结构"

可以看出,调用 leave 后,%rsp 指向的正好是返回地址,x86-64 提供的 ret 指令,其作用就是从当前 %rsp 指向的位置(即栈顶)弹出数据,并跳转到此数据代表的地址处,在 leave 执行后,%rsp 指向的正好是返回地址,因而 ret 的作用就是把 %rsp 上移一个位置,并跳转到返回地址执行。可以看出,leave 指令用于恢复父函数的栈帧,ret 用于跳转到返回地址处,leaveret 配合共同完成了子函数的返回。当执行完成 ret 后,%rsp 指向的是父栈帧的结尾处,父栈帧尾部存储的调用参数由编译器自动释放。

编译器优化

在64位CPU下,gcc在优化编译选项-o2以上的优化级别上,都会不再使用%rbp作为帧指针,而省下来做其它用途。由于所有空间在函数开始处就预分配好,不需要栈帧指针;通过%rsp的偏移就可以访问所有的局部变量。

所以对比文章开头CS APP的栈帧结构图,会发现与上图的栈帧结构不同,缺少%rbp的位置,个人认为是CSAPP考虑了优化之后不再使用这一指针的缘故。

参考学习资料

《深入理解计算机系统》
x86-64 下函数调用及栈帧原理
栈指针&& 帧指针详解
函数调用栈
C++函数调用堆栈详解-(2)/)
x86_64汇编器中RBP寄存器的用途是什么?

CATALOG
  1. 1. 栈区(Stack)
  2. 2. 寄存器(Register)
  3. 3. 栈帧(Stack Frame)
  4. 4. 函数调用过程(未优化)
  5. 5. 函数返回
  6. 6. 编译器优化
  7. 7. 参考学习资料