函数栈帧的创建和销毁
在不同的编译器下,函数调用过程中栈帧的创建是略有差异的,具体取决于编译器的实现!
且需要注意的是,越高级的编译器越不容易观察到函数栈帧的内部的实现;
关于函数栈帧的维护这里我们要重点介绍两个寄存器:ebp和esp;
这两个寄存器存放的是用来维护函数栈帧的地址!
每一个函数调用,都要在栈区创建一个空间!
问题:什么是函数栈帧?
函数栈帧实际上就是函数运行时栈上的一块空间!用于存储相对应的临时数据!
接下来讲解以x86系统为例:
这里对函数栈帧管理就是靠这两个寄存器:
- ebp:被称为基址指针,指向栈帧的底部,(高地址处,且是固定位置!);
- esp:被称为栈顶指针,指向栈帧的顶部,(低地址处,地址可变);
这里需要注意的是,对于栈来说,是优先使用高地址的(栈的特性!)!
问题:那么函数的栈帧中都存放了哪些数据?
从低地址到高地址依次存放了:
- 调用栈帧的基址指针(ebp)--- 用于函数调用后恢复栈帧状态;
- 下一条指令的地址 --- 执行完该函数后跳转到下一条指令;
- 被调用的函数的参数(形参) --- 需要注意的是会从右到左进行压栈;
- 被调用函数的局部变量和临时数据;
- 寄存器的上下文(例如调用函数期间,使用的ebx、esi、edi等寄存器);
问题:main函数会被其他函数调用吗?
需要注意的是,main函数也是可以被其他函数调用的:
即__tmainRTStartup会调用main函数!
问题:那么哪个谁调用 __tmainRTStartup这个函数?
__tmainRTStartup会被mainCRTStartup这个函数调用!
所以,这里我们总结一下:
因此,假如说当前我们的main函数里面调用了一个简单的add函数,那么:
中间绿色的框是我们对应的main函数的调用堆栈,而在调用main函数之前,会先调用__tmainRTStartup和mainRTStartup这两个函数!
而add函数在main函数上面,也就是对应的压栈!
问题:但是函数栈帧中具体是怎么进行相关操作的?
示例代码操作
这里我们以一个简单的代码为例,讲解一下对应的相关操作:
其对应的汇编代码如下所示:
需要注意的是,在调用main函数之前,调用main函数的那两个函数的栈帧已经被创建好了!
这里我们对上面出现的汇编指令做一些简单的解释:
- push实际上就是压栈,将对应的数据压入栈中;
- mov:实际是就是赋值,这里mov ebp esp实际上就是把esp赋值给ebp;
- sub:减去对应的地址;
- lea(load effecitive address):计算内存地址并存入到寄存器当中(不访问内存,仅计算结果);
这里实际上lea到rep stos这四行汇编代码的作用就是将对应的栈的空间的数据都初始化为cc!
- 压栈:在栈顶上放一个元素;
- 出栈:从栈顶删除一个元素;
截止到现在,做的都是初始化相关的任务,此时才开始到函数体内执行对应的任务;
假设每一行代表4个字节:
表示的就是将10这个值放到ebp-8的位置处;
可以看到,也就是在ebp-8的位置上放10!
需要注意的是,上面这里我们是把10放进入了,如果没有把10放进入,此时就是默认提供的随机值,因此在C语言中,如果我们没有进行初始化经常会打印出一堆烫烫烫烫(此时就是对应的内存栈上放的是一堆cccc);
此时可以看到对应的内存对其进行了修改(小端存储)
接下来我们再看int b = 20;这条汇编代码:
dword ptr [ebp-14h], 14h
这段代码实际上就是在ebp-14h这个地址处,填充数字20;
对应的示意图如下所示:
接下来我们再把int c = 0;也是在对应的栈上进行初始值:
当我们定义好对应的变量时,此时我们会调用add函数:
接下来我们按照对应的汇编代码进行分析:
- 这里eax指向[ebp -14h],也就是让eax指向b;
- 然后对eax进行压栈;
- 接下来让ecx的值指向[ebp-8],也就是ecx指向a;
- 然后在对ecx进行压栈;
即截止到现在,我们进行的任务就是我们对应的传参工作!
接下来这里我们要调用对应的call指令:
call指令此时会跳转地址,即这里会跳转到我们对应的红色线框对应的地址!
这里需要注意的是,call完成了两个任务:
- 将下一条指令的地址(00C21450)进行压栈,压入到栈中;
- 跳转到对应的地址执行函数体;
接下来就跳转到对应的函数体当中:
其中,上面一堆的逻辑和main函数一样,都是开辟对应的空间,然后进行初始化;
实际上代码逻辑和我们上面讲的是一样的;
此时,我们依然假设每一行是4个字节,即此时每一行可以代表一个整形:
接下来我们依次看对应的汇编代码:
int z = 0;
mov dword ptr [ebp-8,0]
这里实际上就是把ebp-8指向的这个空间初始化为0;
然后这里把[ebp+8]的值赋值给eax当中:
这里[ebp+8]的值实际上就是之前我们的ecx的值也就是10!
然后再加上[ebp+och]的值,och换算为10进制为12,也就是这里我们之前eax的值!
加完之后,再把算出来的结果返回到ebp-8当中,也就是z!
问题:我们在函数栈帧中有创建对应的形参吗?
没有!在我们call进入函数体之前,我们就通过形参压栈到对应的栈帧当中!
并且参数的压栈顺序是从右向左!
问题:如何再理解形参是实参的一份临时拷贝呢?
这里我们在梳理下逻辑:
- 在调用add函数之前,会对形参从右到左进行一份拷贝;
- 而形参是调用函数之前,从main函数里面拷贝的实参!
可以看到上面的ecx和eax是对应的a和b的一份临时拷贝!
所以改变形参不会改变对应的实参!
接下来我们再回到上面对应的代码当中:上面我们只是把计算的值写入到了z当中;
这里我们重点看return z的汇编代码:
mov eax,dword ptr[ebp-8]
这里是把对应的返回值存入到了eax寄存器当中;
需要注意的是寄存器的值不会随着函数栈帧被销毁而丢失!
接下来执行pop对应的汇编代码,这里也就是出栈,对应的esp栈顶指针会进行移动:
由于此时结果已经运行出来,保存到了eax寄存器当中,所以这里接下来直接对栈帧销毁即可!
这里直接进行:
mov ebp,esp
此时栈顶指针直接指向栈底指针!
然后让栈底指针进行出栈:
pop ebp
实际上就是将add函数的栈底指针出栈,恢复到main函数当中;
pop:不仅对应的空间进行出栈,此时esp还需要+4个字节的地址;
ret
ret指令实际上就是从栈顶跳出之前call的下一条指令的地址,然后跳转过去;
需要注意的是:ebp和esp维护的是当前执行的函数的栈帧空间,而不是整个程序的栈帧空间!
接下来就返回到执行call之后的部分:
这里执行对esp执行add,实际上就是将对应的压栈的形参进行销毁;
此时上面压栈的两个形参也会被销毁掉;
然后将eax的值赋值给[ebp - 20h]这个位置!
那么这个ebp-20是什么呢?
实际上就是我们对应的参数c的值,这里把计算返回的值交到c当中!
讲到这里,我们就实现了从计算值然后从函数栈帧返回出来的处理;
main函数的函数栈帧和add的大同小异,所以这里我们就不再过多介绍了!
所以接下来我们就可以回答一些问题了:
问题:为什么局部变量不初始化的时候是随机值呢?
因为这里的局部变量是我们按要求设置的,例如vsstudio都初始化为cc;
问题:函数是如何进行传参的?
实际上当我们还没有调用函数的时候,此时形参就进行从右到左依次进行压栈处理(临时拷贝一份);
问题:函数调用的结果怎么返回?
值保存到寄存器当中,例如eax当中;且call会对下一条指令的地址进行压栈,运行结束后再取出地址;