基础知识
进程中的栈区(stack)用于维护函数调用的上下文,没有栈就没法实现函数调用。栈用于存放函数(包括main函数)里的局部变量,函数调用时要传递的参数等。
在进程的内存空间中,栈是向下生长的,即栈底在高地址,栈顶在低地址,栈底固定住,栈从内存高地址->低地址的路径延伸。
栈帧(stack frame):每一次函数调用时,都会在栈上为这次函数调用维护一个独立的栈帧。一个栈帧由两个寄存器来界定:
- ebp:指向栈底的指针,称为帧指针(Frame Pointer)
- esp:指向栈顶的指针,称为栈指针(Stack Pointer)
进程的用户空间中存放数据的地方主要包括数据段(静态变量和全局变量)、堆和栈。
其中只有数据段是可以在编译期间就明确计算出来所需空间大小的,并且不会再改变,因此它们可以直接存放在可执行文件的特定的节里(而且包含初始化的值),程序运行时直接将这个节加载到特定的段中,不必在程序运行期间用额外的代码来产生这些变量。参考关于书上说的“编译的时候分配内存”。
堆和栈的内存空间都是在程序运行期间分配的。
栈帧结构图
![image-20210902213330592](https://i-blog.csdnimg.cn/blog_migrate/2d2e91435a3d759a6209d585d374bc24.png)
函数调用和返回
以一个程序为例子:
#include<stdio.h>
int sum(int *a, int *b) {
int c;
c = *a + *b;
return c;
}
int main() {
int a, b;
a = 16; b = 32;
sum(&a, &b);
printf("%d\n", (a-b));
return (a-b);
}
对应的栈帧结构图:
![这里写图片描述](https://i-blog.csdnimg.cn/blog_migrate/d354e0d245426a68846f143fd2da11d0.png)
main函数运行过程中栈帧变化:
- 压栈局部变量:按照定义顺序依次将局部变量a、b压栈。
- 压栈函数参数:接下来准备调用函数sum,依次将变量b和a的地址压栈。
- 压栈返回地址:函数参数压栈完毕后,再将返回地址压栈,这里的返回地址就是main函数里这一条跳转指令的下一条指令的地址,以便函数调用完毕之后拿出这个返回地址继续往下运行。
这里局部变量和函数参数的压栈顺序也有讲究
- 函数里的局部变量的压栈顺序:编译器无溢出保护机制时正向顺序压栈,有溢出保护机制时则相反,先定义的后压栈。参考局部变量申请栈空间时的入栈顺序
- 函数调用时参数的压栈顺序:也跟编译器的实现方式有关,Pascal语言从左往右入栈,C语言从右往左入栈,主要是因为前者不支持可变长参数,而后者支持函数可变长参数(就是后面参数是省略号的形式),如果C语言不支持这个可变长参数的特色,那么其实也可以左序入栈。参考C语言中函数参数入栈的顺序
至此,main函数的栈帧就到界了,接下来开始构建sum函数的栈帧。
- 压栈ebp:将帧指针ebp入栈(main栈帧的基地址)
- 更新ebp:将帧指针ebp更新为当前的栈指针esp,此时帧指针ebp指向sum栈帧的基地址。
- 执行函数操作:压栈局部变量c,可以通过esp指针的偏移来读到传过来的参数,然后运行加法等。
- 保存返回值:将返回值保存到eax寄存器中。
开始函数返回
- 更新esp:栈指针esp到值设置为帧指针ebp的值(即当前函数栈帧的基地址),销毁sum函数栈帧。
- 更新ebp:pop出main栈帧的ebp值,将帧指针ebp寄存器更新为这个旧栈帧的基地址。
- ret:此时返回地址暴露了出来,pop出来并且后续会跳转到返回地址处继续执行那一条指令(比如说从eax寄存器取出返回值值赋给一个变量之类的)。
- 清理函数参数:此时main栈帧里给sum函数存储的参数已经不需要了,因此将esp上移,将这些参数出栈。
此时main栈帧又恢复了函数调用前的状态,可以继续往下运行了。
others
相关汇编指令:
- push:压栈
- pop:出栈
- call:把返回地址(程序中紧随调用指令call后面一条指令的地址)入栈,并跳转到被调用函数开始处执行。
- ret:(经过多次pop之后,esp指向调用者栈桢的顶部,也就是存放返回地址的地方)此时调用ret指令可弹出栈顶处的地址并跳转到该地址处。ret实际上是由汇编指令leave来实现的。
leave指令由下面两个指令组成
mov %ebp, %esp # 恢复原esp的值(esp=ebp,esp此时指向被调用者栈桢的开始处)
pop %ebp # 恢复原ebp的值(esp=esp+4,它执行了调用者的返回地址处,ebp也指向了调用者的栈底)