函数调用栈

429 阅读9分钟

1. 程序是如何运行起来的?

​ 高级语言(C/C++/...)

​ > 编译(gcc)

​ 汇编语言(AT&T/intel)

​ > 汇编器

​ 机器码(计算机指令:存储在存储器或内存中)

2. CPU是如何执行指令的?

2.1 CPU 的组成

​ CPU:它的功能主要是解释计算机指令以及处理计算机软件中的数据。其构成如下:

  • 运算器:可以执行定点或浮点算术运算操作、移位操作以及逻辑操作,也可执行地址运算和转换。
  • 控制器:主要是负责对指令译码,并且发出为完成每条指令所要执行的各个操作的控制信号。其结构有两种:一种是以微存储为核心的微程序控制方式;一种是以逻辑硬布线结构为主的控制方式。
  • 寄存器:是集成电路中非常重要的一种存储单元,通常由触发器和锁存器组成。
2.2 指令的执行过程
  1. 提取指令:从存储器中检索指令的内存地址并存储到指令寄存器中。由程序计数器(Program Counter)指定存储器的位置。
  2. 指令解码:由控制器完成
  3. 执行指令:由运算器完成
  4. 访存取数:根据指令地址码,得到操作数在主存中的地址,并从主存中读取该操作数用于运算。
  5. 回写结果:将执行指令阶段的结果写到缓存或者内存、磁盘

参考:www.jianshu.com/p/05c6c1d73…

2.3 寄存器的类型
  • PC 寄存器:也叫指令地址寄存器,存放下一条需要被执行的指令的内存地址
  • 指令寄存器:用来存放当前正在执行的指令
  • 条件码寄存器:用里面的一个一个标记位(Flag),存放 CPU 进行算术或者逻辑计算的结果。
  • ...
img
2.4 总结

​ 实际上,一个程序执行的时候,CPU 会根据 PC 寄存器里的地址,从内存里面把需要执行的指令读取到指令寄存器里面执行,然后根据指令长度自增,开始顺序读取下一条指令。可以看到,一个程序的一条条指令,在内存里面是连续保存的,也会一条条顺序加载。而一些特殊的指令,可以跳转到特殊的内存地址。

// function
#include <stdio.h>
int main()
{
   0:	55                   	push   rbp
   1:	48 89 e5             	mov    rbp,rsp
    int a = 0;
   4:	c7 45 fc 00 00 00 00 	mov    DWORD PTR [rbp-0x4],0x0
    for (int i = 0; i < 3; i++)
   b:	c7 45 f8 00 00 00 00 	mov    DWORD PTR [rbp-0x8],0x0
  12:	eb 0a                	jmp    1e <main+0x1e>
    {
	a += i;
  14:	8b 45 f8             	mov    eax,DWORD PTR [rbp-0x8]
  17:	01 45 fc             	add    DWORD PTR [rbp-0x4],eax
    for (int i = 0; i < 3; i++)
  1a:	83 45 f8 01          	add    DWORD PTR [rbp-0x8],0x1
  1e:	83 7d f8 02          	cmp    DWORD PTR [rbp-0x8],0x2
  22:	7e f0                	jle    14 <main+0x14>
  24:	b8 00 00 00 00       	mov    eax,0x0
    }
}
  29:	5d                   	pop    rbp
  2a:	c3                   	ret

参考:

3. 为什么需要程序调用栈

3.1 函数调用
// function
#include <stdio.h>
int static add(int a, int b, int c)
{
    return a + b + c;
}

int main()
{
    int x = 5;
    int y = 6;
    int z = 7;
    int u = add(x, y, z);
}

​ 不采用函数栈时,其汇编代码如下:

0000000000000000 <main>:
{
    int x = 5;
    int y = 6;
    int z = 7;
    int u = add(x, y, z);
}
   0:	f3 c3                	repz ret

​ 因此,当函数被调用的次数特别多时,如果全部采用展开的方式,那么就会被展开多次,程序的空间占用就会很大。

3.2 函数调用栈

​ 而采用函数栈时,其汇编如下:

0000000000000000 <add>:
// function
#include <stdio.h>
int static add(int a, int b, int c)
{
   0:	55                   	push   rbp
   1:	48 89 e5             	mov    rbp,rsp
   4:	89 7d fc             	mov    DWORD PTR [rbp-0x4],edi
   7:	89 75 f8             	mov    DWORD PTR [rbp-0x8],esi
   a:	89 55 f4             	mov    DWORD PTR [rbp-0xc],edx
    return a + b + c;
   d:	8b 45 f8             	mov    eax,DWORD PTR [rbp-0x8]
  10:	8b 55 fc             	mov    edx,DWORD PTR [rbp-0x4]
  13:	01 c2                	add    edx,eax
  15:	8b 45 f4             	mov    eax,DWORD PTR [rbp-0xc]
  18:	01 d0                	add    eax,edx
}
  1a:	5d                   	pop    rbp
  1b:	c3                   	ret

000000000000001c <main>:

int main()
{
  1c:	55                   	push   rbp
  1d:	48 89 e5             	mov    rbp,rsp
  20:	48 83 ec 10          	sub    rsp,0x10
    int x = 5;
  24:	c7 45 fc 05 00 00 00 	mov    DWORD PTR [rbp-0x4],0x5
    int y = 6;
  2b:	c7 45 f8 06 00 00 00 	mov    DWORD PTR [rbp-0x8],0x6
    int z = 7;
  32:	c7 45 f4 07 00 00 00 	mov    DWORD PTR [rbp-0xc],0x7
    int u = add(x, y, z);
  39:	8b 55 f4             	mov    edx,DWORD PTR [rbp-0xc]
  3c:	8b 4d f8             	mov    ecx,DWORD PTR [rbp-0x8]
  3f:	8b 45 fc             	mov    eax,DWORD PTR [rbp-0x4]
  42:	89 ce                	mov    esi,ecx
  44:	89 c7                	mov    edi,eax
  46:	e8 b5 ff ff ff       	call   0 <add>
  4b:	89 45 f0             	mov    DWORD PTR [rbp-0x10],eax
}
  4e:	c9                   	leave
  4f:	c3                   	ret

​ 在函数调用场景下,如果函数调用已经完成了,此时需要继续执行接下来的指令,但此时指令已经不能跳转到函数调用前的地址了。此种场景不同于条件判断时的跳转,条件判断的跳转,如果匹配,不需要返回顺序执行就可以,而函数调用完成后,则需要跳转回之前的位置继续执行。

​ 为了解决上述问题,可以采用增加寄存器的方式来解决这个问题,比如增加一个返回地址寄存器,在函数调用完成时,读取返回地址寄存器的地址并跳转过去。但是,函数调用嵌套过多的场景下,所需要的寄存器也就越来越多,而寄存器属于 CPU 元件,其个数是很有限的,对于x86-64架构,64位的通用寄存器仅有16个。

​ 为此,便有了函数调用栈的概念。在内存地址中专门开辟出来一段连续空间,采用栈这个后进先出(LIFO,Last In First Out)的数据结构。函数调用时,先进行压栈,调用结束时再出栈回到原来调用的地方,每一个函数在函数栈中,都是一个栈帧,并且有连续的内存地址。而我们当前正在操作的函数帧,则永远处于栈顶。这种情况下,仅需要一个寄存器记录栈顶地址,另一个寄存器记录当前栈帧的起始位置,就可以完成函数调用以及内存地址的返回。

img

​ 在这种调用链下,顶层函数则一定处于栈底,而被调用函数的内存地址在一直变小。(可能是因为被调用函数需要先编译,其内存地址也靠前。)

3.3 函数调用时所使用的通用寄存器
img
  • 每个寄存器的用途并不是单一的。
  • %rax 通常用于存储函数调用的返回结果,同时也用于乘法和除法指令中。在imul 指令中,两个64位的乘法最多会产生128位的结果,需要 %rax 与 %rdx 共同存储乘法结果,在div 指令中被除数是128 位的,同样需要%rax 与 %rdx 共同存储被除数。
  • %rsp 是堆栈指针寄存器,通常会指向栈顶位置,堆栈的 pop 和push 操作就是通过改变 %rsp 的值即移动堆栈指针的位置来实现的。
  • %rbp 是栈帧指针,用于标识当前栈帧的起始位置
  • %rdi, %rsi, %rdx, %rcx,%r8, %r9 六个寄存器用于存储函数调用时的6个参数(如果有6个或6个以上参数的话)。
  • 被标识为 “miscellaneous registers” 的寄存器,属于通用性更为广泛的寄存器,编译器或汇编程序可以根据需要存储任何数据。
  • “Caller Save” 和 ”Callee Save” 寄存器,即寄存器的值是由”调用者保存“ 还是由 ”被调用者保存“。
  • 操作低32位的寄存器是,寄存器的名称都是以 e 开头。
3.4 函数调用栈切换原理
函数调用过程:
img
  1. 父函数将调用参数从后向前压栈;
  2. PC 寄存器中要执行的下一条指令的地址压栈,即返回地址压栈保存;
  3. 将子函数的地址复制到指令指针寄存器,即跳转到子函数的起始地址执行;
  4. 子函数将父函数的栈帧起始地址(%rbp)压栈
  5. 将 %rbp 的值设置为当前 %rsp 的地址(此时当前帧的起始地址就是 %rsp 的地址),即当前栈顶地址(随着当前帧内容不断增多,%rsp也在不断移动);
  6. 在当前栈中预留足够空间,供函数本身使用
函数返回过程
img
  1. 得到函数的返回值并存放在结果寄存器(%rax)中;
  2. 从函数栈中,推出当前栈帧的起始地址往后的内容;
  3. 将父函数帧的返回地址弹回到指令地址寄存器;
0000000000000000 <add>:
// function
#include <stdio.h>
int static add(int a, int b, int c)
{
   0:	55                   	push   rbp
   1:	48 89 e5             	mov    rbp,rsp
   4:	89 7d fc             	mov    DWORD PTR [rbp-0x4],edi
   7:	89 75 f8             	mov    DWORD PTR [rbp-0x8],esi
   a:	89 55 f4             	mov    DWORD PTR [rbp-0xc],edx
    return a + b + c;
   d:	8b 45 f8             	mov    eax,DWORD PTR [rbp-0x8]
  10:	8b 55 fc             	mov    edx,DWORD PTR [rbp-0x4]
  13:	01 c2                	add    edx,eax
  15:	8b 45 f4             	mov    eax,DWORD PTR [rbp-0xc]
  18:	01 d0                	add    eax,edx
}
  1a:	5d                   	pop    rbp
  1b:	c3                   	ret

000000000000001c <main>:

int main()
{
  1c:	55                   	push   rbp
  1d:	48 89 e5             	mov    rbp,rsp
  20:	48 83 ec 10          	sub    rsp,0x10
    int x = 5;
  24:	c7 45 fc 05 00 00 00 	mov    DWORD PTR [rbp-0x4],0x5
    int y = 6;
  2b:	c7 45 f8 06 00 00 00 	mov    DWORD PTR [rbp-0x8],0x6
    int z = 7;
  32:	c7 45 f4 07 00 00 00 	mov    DWORD PTR [rbp-0xc],0x7
    int u = add(x, y, z);
  39:	8b 55 f4             	mov    edx,DWORD PTR [rbp-0xc]
  3c:	8b 4d f8             	mov    ecx,DWORD PTR [rbp-0x8]
  3f:	8b 45 fc             	mov    eax,DWORD PTR [rbp-0x4]
  42:	89 ce                	mov    esi,ecx
  44:	89 c7                	mov    edi,eax
  46:	e8 b5 ff ff ff       	call   0 <add>
  4b:	89 45 f0             	mov    DWORD PTR [rbp-0x10],eax
}
  4e:	c9                   	leave
  4f:	c3                   	ret

参考:

zhuanlan.zhihu.com/p/27339191

c.biancheng.net/view/3537.h…

3.5 stackoverflow

​ 可以发现,函数栈的整个调用过程以及6个之外的参数、函数中的变量申请等都需要栈帧的内存空间,而栈的大小是有限的,因此当函数调用层数太多、递归调用、创建巨大的临时变量等,往栈里压入它存不下的内容,程序在执行的过程中就会遇到栈溢出的错误。