函数栈帧的创建和销毁
全文目录
- 前言
- 寄存器
- main函数的调用
- 调用main函数的函数
- main函数的栈帧如何开辟的
- `push`(保存调用方的`ebp`)
- `move`(维护新开栈帧的栈底)
- `sub`(维护新开栈帧的栈顶)
- 三连`push`(添加栈帧的信息的变量)
- `lea` (存放栈顶地址)
- `rep stos`(初始化栈帧)
- add函数的执行
- 创建变量
- 传参
- `call` (函数调用 )
- 参数的使用
- 函数的返回值和栈帧的销毁
- 形参的销毁
- 总结
前言
前面在使用函数时一直说到函数栈帧的创建与销毁,但也只是云里雾里的,今天就来讲讲关于函数栈帧的知识。实验环境:VS2013,系统环境X86
我们通过一段简单的代码来了解函数的栈帧:
#include <stdio.h>
int add(int x, int y)
{
int z = 0;
z = x + y;
return z;
}
int main()
{
int a = 10;
int b = 20;
int c = 0;
c = add(a, b);
printf("%d\n", c);
}
寄存器
再了解函数栈帧前,我们需要先知道一些前景知识,首先来了解一下寄存器。再VS2013中,我们可以通过调试窗口中的寄存器窗口查看有哪些寄存器:
可以看到有下面几种寄存器:
通用寄存器(数据存放数据使用):
eax
ebx
ecx
edx
变址寄存器(偏移量):
esi
edi
指令寄存器(下一条指令的地址):
eip
指针寄存器(地址,维护函数栈帧):
esp(堆栈指针寄存器,用于存放栈顶指针的位置)
ebp(基址指针寄存器,用于寻找栈内的元素)
标志性寄存器(不知道什么东西):
efl
寄存器的详细知识可以点这里:汇编——寄存器的分类和功能
main函数的调用
每一个函数的调用都需要在栈区上开辟一块开空间,在栈上一块专门为函数开辟的空间就是函数的栈帧。这么一块栈帧其实是由两个寄存器维护的,根据上面寄存器的介绍,大概能猜到是esp, ebp
两个寄存器维护的了:
调用main函数的函数
main函数也是函数,所以我们可以在调试中通过函数调用堆栈来看一下main函数是由谁调用的:
由此我们可以很明显得看到main函数的调用关系:
调用main函数的函数__tmainCRTStartup
也是需要栈帧的,同样的是由esp, ebp
来维护
main函数的栈帧如何开辟的
然后我们可以通过反汇编来看一下main函数是怎么调用的:
push
(保存调用方的ebp
)
当执行第一个反汇编指令时相当于将ebp
的值放到__tmainCRTStartup
的栈帧的顶部:
那么esp
的值,就相当于减去了 4 ,我们可以通过监视来看一下
执行前:
执行完push
指令后:
至于为什么是减4,是因为在32为系统下指针的大小是4字节
这样之后的函数返回后就可以快速找到调用方的栈底,从而继续维护调用方。
move
(维护新开栈帧的栈底)
move
指令相当于将 esp的值复制给ebp
:
通过监视窗口可以看到ebp
值的变化:
执行前:
执行完move
指令后:
这样ebp
就是新的函数栈帧的栈底,继续干着栈底指针的老本行
sub
(维护新开栈帧的栈顶)
执行sub
命令相当于esp
向下走了 0E4h
,那么esp ~ ebp
中间的空间就是main函数的栈帧。
执行前:
执行完sub
命令后:
相当于:
三连push
(添加栈帧的信息的变量)
push
前:
push
后:
注意每次压栈后都
esp
的值都会变化
相当于:
这三个新压栈的寄存器在后面将会起到大作用。
lea
(存放栈顶地址)
lea
,全称 load effictive address
,翻译一下就是加载有效地址。可以看到ebp - 0E4h
就是三连 push
前main函数的栈顶,相当于将该地址放到 edi
中。
后面两个move
等价于:ecx = 39h, eax = 0CCCCCCCCh
rep stos
(初始化栈帧)
执行前:
dword
:
d: double
word: 一个word是2字节
dword: 4字节
整条指令就是相当于将edi (ebp - 0E4h)
往下,每次操作4字节,操作 ecx (39h)
次,全部初始化为 eax (0xcccccccc)
也就是:
至此,一个函数的栈帧的创建就完成了,这也是为什么局部变量没有初始化的时候,它的值会是随机数。
add函数的执行
创建变量
函数栈帧创建完成之后就是正常的执行代码了,后面三条语句就是普通的初始化
赋值后:
也就是相当于:
可以看到在VS2013 中,是隔了两个整型的大小来进行初始化的,但是在不同的编译器下可能实现的方式不同。
传参
接下来就是万众瞩目的传参的过程了:
将a和b的值依次传给eax和ecx压栈
也就是:
正好印证了形参是实参的一份临时拷贝
call
(函数调用 )
执行完传参后,就是函数调用了,call
指令会将下一条指令压栈,让函数返回时,正常往下执行
然后跳到指定的地址
再通过jmp
命令进行跳到add
函数内部
跳到add
函数内部之后,就开始函数的创建和初始化等一系列操作,
参数的使用
参数使用时通过ebp
找到对应的参数,然后将计算的结果返回回去
也就是:
可以发现参数在传递的时候是从右向左传,在使用形参时是从左向右取,刚好跟参数传递的顺序一致
函数的返回值和栈帧的销毁
返回时,先将返回值放到寄存器 eax
中
然后依次销毁函数栈帧,现将顶上的三个寄存器弹出
也就是:
然后通过move
指令将栈顶指向栈底
也就是:
然后通过pop
命令将ebp
指向main函数的栈底,那么ebp, esp
又重新开始维护main函数的栈帧。
也就是:
可以看到esp
指向的就是调用函数之后的下一条指令的地址,ret
指令就是通过pop
回到调用函数的下一条指令
执行ret
指令后回到下一条指令的位置:
esp
也向下走了一个位置:
形参的销毁
执行该命令后,形参的栈帧也就销毁了,意味着返回到了main函数中,add函数的栈帧彻底销毁了
之后的就是正常的进行计算等等,各种函数的调用,其栈帧的创建和销毁基本上都是一样的。
总结
自己总结的草图:栈帧的创建和销毁都是一一对应的,怎么创建,就怎么反着销毁。