2.函数调用以及运行栈

2.函数调用以及运行栈

Posted by ZhaoLe on February 12, 2021

一些有用的基础知识点

寄存器:%rip , %rbp ,%rsp,
指令:push pop call ret
栈结构:栈的结构,栈帧

寄存器 程序计数器 (X86-64中用%rip表示) 用于表示将要执行的下一条指令的内存地址。 堆栈指针 (X86-64中用%rsp表示) 用于指向栈顶。 栈帧指针 (X86-64中用%rbp表示) 用于表示当前栈帧的开始位置。

指令 放到文章后面解释

栈结构 栈数据结构提供后进先出的管理原则,程序可以用栈来管理过程中所需要的存储空间,栈和寄存器存放着传递控制和数据,分配内存所需要的空间。x86-64的栈向低地址方向增长,%rsp指向栈顶元素,pushq和popq指令分别将数据推入或者弹出栈

什么是栈帧
函数在执行时候,需要传输一些参数数据,参数数据在寄存器不够用的时候会压入栈中,整个函数A在栈中占用的所有内存空间就是栈帧

运行时栈

现在说下当函数间发生调用栈中是怎么运作的,为什么过程的调用适合栈这种后进先出的结构。寄存器的概念和指令的介绍在这篇文章里就不说了,具体可以看《深入理解计算机系统(第三版)》第三章的内容吧 1

下面是一个简单的C程序,调用一个add()方法

1
2
3
4
5
6
7
8
9
10
11
//demo.c
int static add(int a, int b)
{
    return a+b;
}
int main()
{
    int x = 5;
    int y = 10;
    int u = add(x, y);
}

gcc -g -c demo.c
objdump -d demo.o

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//main方法,省略部分代码
0000000000000000 _main:
       0: 55                           	pushq	%rbp
       1: 48 89 e5                     	movq	%rsp, %rbp
...
      1c: e8 00 00 00 00               	callq	0 <_main+0x21>
...
      2d: c3                           	retq
      2e: 66 90                        	nop
//add 函数
0000000000000030 _add:
      30: 55                           	pushq	%rbp
      31: 48 89 e5                     	movq	%rsp, %rbp
      34: 89 7d fc                     	movl	%edi, -4(%rbp)
      37: 89 75 f8                     	movl	%esi, -8(%rbp)
      3a: 8b 45 fc                     	movl	-4(%rbp), %eax
      3d: 03 45 f8                     	addl	-8(%rbp), %eax
      40: 5d                           	popq	%rbp
      41: c3                           	retq

函数调用

首先看代码,就是在main方法中调用了add()这个函数,传入两个变量。然后到汇编中对应第6行callq这个指令,它的作用是把下一条指令的地址压入栈中,并且跳到被调用函数的第一行。

详细说明:

  1. 把第7行的地址压入栈,这么做是为了方便后面从子函数返回时可以继续执行下去。注意返回的地址是在main函数所属的栈帧中的。
  2. 至于为什么知道下一条指令的地址,那因为pc寄存器指向的就是下一条指令的内存地址。

进入了子函数,在汇编中第12行-16行,第12行将%rbp压入栈中,并且紧接着第13行将%rbp的位置设置为当前%rsp的值,也就是新的栈帧开始位置,

详细说明:

  1. 由于%rbp是堆栈指针它是指向栈帧开始的位置,所以在第12行指令执行前这个%rbp一直指向的是main函数栈帧中的开始位置。也就是说当子方法add()刚被调用往它堆栈中压入的是父函数(当前是main)栈帧中的%rbp地址。这么做是为了后面退出而设计的。
  2. 第13行的 赋值的行为也就可以理解为上一步把父函数的起始位置记录下,紧接着开辟了自己栈帧的新起始位置。

函数调用的几个关节点已经说完了,稍微总结下父函数调用子函数的基本过程就是:

  1. 父函数调用前先将返回(下一步)地址压栈.
  2. 跳转到到子函数中。
  3. 子函数中先将父函数栈帧的起始位置%rbp压栈。
  4. 将%rbp位置重新设置为子函数栈帧的起始位置。

2

函数返回

add()函数执行完后要返回了,因为在前面函数调用是已经保存了返回地址和父类栈帧起始位置。在汇编代码第18,19行。在18行中弹出堆栈指针,当前%rbp又从新回到了父函数栈帧的起始地址,紧接着19行的retq将%rsp返回子到函数调用前的后面一条指令。

详细说明:

  1. 在汇编代码的14-17行是在通用寄存器中赋值运算,%rbp中的地址并没有发生变化,所以在第18行可以直接popq出栈.
  2. 一般在一个被调用的函数参数超过6个的话,剩下的参数会被压入运行栈中。

3

资料引用