1. 程序是如何运行起来的?
高级语言(C/C++/...)
> 编译(gcc)
汇编语言(AT&T/intel)
> 汇编器
机器码(计算机指令:存储在存储器或内存中)
2. CPU是如何执行指令的?
2.1 CPU 的组成
CPU:它的功能主要是解释计算机指令以及处理计算机软件中的数据。其构成如下:
- 运算器:可以执行定点或浮点算术运算操作、移位操作以及逻辑操作,也可执行地址运算和转换。
- 控制器:主要是负责对指令译码,并且发出为完成每条指令所要执行的各个操作的控制信号。其结构有两种:一种是以微存储为核心的微程序控制方式;一种是以逻辑硬布线结构为主的控制方式。
- 寄存器:是集成电路中非常重要的一种存储单元,通常由触发器和锁存器组成。
2.2 指令的执行过程
- 提取指令:从存储器中检索指令的内存地址并存储到指令寄存器中。由程序计数器(Program Counter)指定存储器的位置。
- 指令解码:由控制器完成
- 执行指令:由运算器完成
- 访存取数:根据指令地址码,得到操作数在主存中的地址,并从主存中读取该操作数用于运算。
- 回写结果:将执行指令阶段的结果写到缓存或者内存、磁盘
2.3 寄存器的类型
- PC 寄存器:也叫指令地址寄存器,存放下一条需要被执行的指令的内存地址
- 指令寄存器:用来存放当前正在执行的指令
- 条件码寄存器:用里面的一个一个标记位(Flag),存放 CPU 进行算术或者逻辑计算的结果。
- ...
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)的数据结构。函数调用时,先进行压栈,调用结束时再出栈回到原来调用的地方,每一个函数在函数栈中,都是一个栈帧,并且有连续的内存地址。而我们当前正在操作的函数帧,则永远处于栈顶。这种情况下,仅需要一个寄存器记录栈顶地址,另一个寄存器记录当前栈帧的起始位置,就可以完成函数调用以及内存地址的返回。
在这种调用链下,顶层函数则一定处于栈底,而被调用函数的内存地址在一直变小。(可能是因为被调用函数需要先编译,其内存地址也靠前。)
3.3 函数调用时所使用的通用寄存器
- 每个寄存器的用途并不是单一的。
- %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 函数调用栈切换原理
函数调用过程:
- 父函数将调用参数从后向前压栈;
- 将 PC 寄存器中要执行的下一条指令的地址压栈,即返回地址压栈保存;
- 将子函数的地址复制到指令指针寄存器,即跳转到子函数的起始地址执行;
- 子函数将父函数的栈帧起始地址(%rbp)压栈;
- 将 %rbp 的值设置为当前 %rsp 的地址(此时当前帧的起始地址就是 %rsp 的地址),即当前栈顶地址(随着当前帧内容不断增多,%rsp也在不断移动);
- 在当前栈中预留足够空间,供函数本身使用
函数返回过程
- 得到函数的返回值并存放在结果寄存器(%rax)中;
- 从函数栈中,推出当前栈帧的起始地址往后的内容;
- 将父函数帧的返回地址弹回到指令地址寄存器;
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
参考:
3.5 stackoverflow
可以发现,函数栈的整个调用过程以及6个之外的参数、函数中的变量申请等都需要栈帧的内存空间,而栈的大小是有限的,因此当函数调用层数太多、递归调用、创建巨大的临时变量等,往栈里压入它存不下的内容,程序在执行的过程中就会遇到栈溢出的错误。