目录
探究栈帧的奥妙
引言
浅浅说一下栈
问问自己几个问题
什么是栈帧
栈帧的维护
汇编预备知识
小例子
访问栈帧里的数据
例子
栈帧是如何切换的
栈帧是如何处理参数和返回值的
探究栈帧的奥妙
作者申明:
文中有些名词可能不太官方,大部分是作者自己的理解,只是为了方便理解,所以用了一些不太专业的词汇,如果有错误,欢迎各位佬指正!
引言
本文我们要讲解的是栈帧,为了较好的引入它,本文使用的C语言函数作为载体,默认看文章的大家C语言函数的基本使用都没有问题啊,如何检测自己的C语言有没有问题呢?可以看看下面的代码,如何还行的话,那表示这篇文章对你是大有帮助的(只要不是太小白都可以的)。注意:需要读者知道内存分配栈区的概念!这很重要
#include <stdio.h> int Add(int x, int y) { int sum = x + y; return sum; } int main() { int a = 10; int b = 20; int sum = Add(a, b); printf("sum=%d\n", sum); return 0; }
十分简单的一段程序对吧,OK,那么现在就以这一小段函数为例来讲解一下栈帧
浅浅说一下栈
使用的内存是有分区的:
-
OS区
-
栈区
-
堆区
-
全局区
-
共享区
-
代码区
-
数据区
针对本文,只需了解栈区即可,我们分配的空间是有地址的,地址是有大小的,而栈区的特点是从高地址向低地址开始分配内存,即对于栈区来说,高地址是栈底,低地址是栈顶
堆栈相对而生,箭头是资源申请的方向
如图
问问自己几个问题
-
函数是如何调用的
-
参数是如何传入的
-
函数是如何返回的
-
函数返回后是怎么找到之前调用的地方的
如何你能回答上来,那么你的高度可能在这篇文之上,回答不上来,相信看了这篇文章就可以回答上来了,哈哈哈
什么是栈帧
在我们进行函数调用的时候,编译器都会在栈区为这个函数维护一个栈帧,即每一个函数对应着一个栈帧,这是概念,那么我们通俗的来讲讲什么事栈帧。首先明确一点,我们在进行变量创建的时候,编译器是给这个变量分配了对应的虚拟内存的,即创建变量时,需要一定的开销,而这个开销一部分就是体现在内存占用上,那么回到函数调用这里来,和创建变量类似,函数也是一个类型,在进行调用的时候同样也需要一定的内存开销,这个函数占用的内存空间就是这个函数的栈帧,即栈帧=占用内存。
栈帧的维护
介绍了什么是栈帧,系统是如何来得知这个函数的栈帧大小和所在栈区的位置呢,系统使用了两个寄存器来维护栈帧的范围:
-
EBP(extended base pointer) 基址指针寄存器,存放当前栈帧的底部地址
-
ESP(extended stack pointer) 栈指针寄存器,存放当前栈帧的顶部地址
注意:这两个寄存器的内容是动态变化的,同一时刻只会调用一个函数,即同一时刻只会一一个栈帧,用上面的例子来看,最开始是调用的main函数,此时ebp和esp里面存放的就是main函数的栈帧,之后调用了Add函数,这时ebp和esp里面存放的是Add函数的栈帧,Add返回main后,esp和esp又重新开始维护main函数的栈帧。
这里有几个细节,栈帧是如何创建的,ebp和esp在main和Add之间切换栈帧的时候,如何切换的,ebp和esp又是如何再一次的维护main栈帧的,这些都是值得探讨的问题。
汇编预备知识
-
mov:eax, ebx 将ebx的值赋值给eax
-
push:xxx 将xxx压入栈,esp-4
-
pop:xxx 将栈顶元素出栈,写入xxx,再让esp+4
-
call:函数名 将IP旧值压栈保存(栈顶低地址处),设置IP新值,新函数的指令执行地址
-
ret:从函数的栈帧顶部找到IP旧值,将其出栈并恢复IP寄存器
补充:IP寄存器存放的是程序下一次要执行的指令的地址
小例子
push eax:#将寄存器eax的值压栈 push 985:#将立即数985压栈 push [ebp+8]:#将主存地址[ebp+8]里的数据压栈 pop eax:#栈顶元素出栈,写入寄存器eax pop [ebp+8]:#栈顶元素出栈,写入主存地址[ebp+8]
访问栈帧里的数据
我们知道esp&ebp分别指向栈顶和栈底,那么我们可以直接通过mov指令来访问栈帧里面的数据,只需有esp和ebp分别+/-就可以获得栈帧中的地址
例子
sub esp,12 #栈顶指针-12 mov [esp+8],eax #将eax的值复制到主存[esp+8]
栈帧是如何切换的
这里要探讨的问题是,栈帧是如何切换的,也就是如何从main函数突然就执行Add函数的
main: push ebp mov ebp,esp ...... #省略不重要的部分 call Add #将当前IP值压栈,重新设置IP值 ...... Add: push ebp #把ebp的值压栈到栈顶 mov ebp,esp #将esp的值复制给ebp 这两个指令等价于enter指令 ...... leave #等价于 mov esp, ebp \ pop ebp ret
int add(int x, int y)
{
return x + y;
}
int main()
{
int x = 10;
int y = 20;
add(x, y);
return 0;
}
当执行add(x, y)时,底层汇编翻译的指令是
call add
当add即将返回时,底层汇编翻译的指令是
leave
ret
上面main和add的栈帧切换,当执行call add的时候,会先将当前的IP寄存器的值压入栈帧,然后再设置IP的值,使其下一次执行的指令跳到add函数栈帧中,再将main函数栈帧的ebp寄存器值压入栈帧,再将esp的值复制给ebp,再依次执行add函数体中指令,就完成了栈帧切换。
add函数执行完后,会执行leave指令将ebp的值复制给esp,然后在Pop ebp,即将栈顶元素出栈,再将其出栈的内容复制给ebp,而这里的内容刚好是main函数栈帧的基地址,然后再执行ret指令将IP的值恢复到原来的值,这样就完成了栈帧的切换。
栈帧是如何处理参数和返回值的
返回值处理,一般返回值只有一个,所以处理的时候,一般是用一个通用寄存器(eax)来临时记录一下值,然后再复值给需要的变量。
将栈帧划分为几个区域,分别负责存放函数中的局部变量,函数体的指令,函数的参数