调用函数时栈帧的创建和销毁(反汇编)

23 篇文章 0 订阅
19 篇文章 0 订阅

本期博客将为大家讲解平时在写程序调用函数时栈帧的创建和销毁,将带大家走进真正的底层,初次见到一位新朋友:反汇编,通过反汇编了解栈帧的创建和销毁。

那么首先先提出一些在初学变成时都会遇到的比较头疼但又不好解释的问题:

(1)局部变量是怎么创建的?

(2)为什么局部变量的值是随机值?

(3)函数是怎么传参的?传参的顺序是怎样的?

(4)形参和实参是什么关系?

(5)函数调用是做什么的?

(6)函数调用在结束后是怎么返回的?

由于不同的编译器对于函数调用栈帧会有不同,越高级的编译器反倒不适合用来学习,所以选择以vs2013为例。

1.一些小铺垫:

①寄存器:(关于寄存器深层的知识不用过多了解,对于今天的知识只需要了解它是作为存储数据的机器即可)一般用到的寄存器:eax、ebx、ecx、edx、ebp、esp等,而今天最多用到的就是ebp和esp这两个寄存器,而这两个寄存器是用来维护函数栈帧的。每一个函数的调用,都会在栈区产生一块内存空间。例如:

#include <stdio.h>
int Add(int x, int y)
{
    int z = 0;
    z = x + y;
    return z;
}
int main()
{
    int a = 0;
    int b = 0;
    int c = 0;
    c = Add(a, b);
    printf("%d\n", c);
    return 0;
}

这段代码对于大家来说肯定如同砍瓜切菜一般简单,就算给出题目让现场讲解它的运作流程肯定也没有问题,当我们写了这段代码到我们的编译器中运行时大家可以猜到在栈区空间是如何开辟的吗?

无论什么程序都要有主函数对吧,当我们运行了主函数后它在栈区就会自动生成一段空间单独作为main函数的栈帧,而这段空间就是由两个指针来维护的,分别是ebp指针和esp指针,这两个指针又分别叫做栈底指针和栈顶指针,而栈区使用空间的规则是从高地址向低地址使用,所以main函数接下来调用的任何接口函数都要向上开辟内存空间。

这时呢,可以通过vs编译器直观地看到调用堆栈的详细情况,通过F10->调试->窗口->调用堆栈,然后进行一步步地调试,当main函数运行完后,会发现在main函数上方有一个__tmainCRTStartup的函数调用了main函数

通过观察汇编代码可以发现__tmainCRTStartup函数也是由一个函数名为mainCRTStratup函数调用,所以不难发现在vs2013中main函数也是由其他编译器调用的。

函数在调用后就会在栈区上产生空间,那么上面的这两个函数虽然是调用main函数的但是也应该有空间的分配,由main函数调用的函数应该在main函数上方分配空间,而__tmainCRTStartup和mainCRTStartup函数应该在main函数的下方产生空间。

有了这些准备工作后,接下来就可以进入主题了,Add函数在调用时会有什么变换呢?

2.通过反汇编学习函数栈帧

还是刚刚的方法,在刚进入main函数后右击鼠标进入反汇编,我们就可以看到main函数对应的反汇编代码,一条条向下执行的细节。

学过初阶数据结构的我们对于push pop肯定不会陌生,在这里就理解为压栈,出栈,那么首先ebp指针和esp指针在维护__taminCRTStartup函数,首先push ebp,把ebp压栈进入,然后mov ebp,esp把ebp移动到esp位置,但我们知道ebp指针指向的是__taminCRTStartup栈底的位置,所以相当于ebp指针向上移动到esp的位置,此时如果观察地址的话,ebp和esp的地址是一样的,那么紧接着sub esp,0E4h,0E4h是一个八进制数字,转到十进制数字是228,虽然还是摸不清楚减去228是多少,但是可以猜出esp指针肯定是向上移动了一段空间,地址也进行了相应的改变。到这时,准备工作完成,是时候为main函数开辟空间了,那么现阶段的ebp~esp指针维护的空间就是栈区为main函数新开辟的空间:

 然后又push了三个值,ebx、esi、edi,我们不必关注push这三个值的意义何在,先加进去即可,随着push这三个值后,esp也要向上挪动三个值的一段空间,而后面的lea其实是load effective address(加载有效地址),那么lea edi,[ebp-0E4h]就是将ebp-0E4h的地址加载到ebp中,而根据上面的图可以发现这个地址其实就是esp的地址,然后后面mov ecx,39h和mov eax,0CCCCCCCCh和rep stos dword ptres:[edi]要看成一个操作:把从ecx向下39h空间的内容全部改成eax的内容,全部改成0CCCCCCCCCh(dword是double word,一个word是两个字节)。

 完成这些操作后,才能看到main函数中的第一条代码:int a  = 10;现在才知道原来main函数在进行运行代码前要有这么多准备工作。

接下来就要运行下面的三条赋值语句了,可以发现执行赋值语句时的反汇编都差不多,dword ptr [ebp-8],0Ah;dword知道是代表四个字节,而现在ebp指针指向的是栈区为main函数开辟的空间的起始位置,所以ebp-8就是ebp自己向上移动八个字节,所以这段代码的意思就是把0Ah这个八进制数字放到ebp-8的位置上,而0Ah转换成十进制数字就是10,就是我们要存入的数字。dword ptr [ebp-14h],14h;将14h放进ebp-14h,而14h对应的十进制数是20,而放进10的空间是ebp-8,放20的位置却变成了ebp-14h,差了两个整形,所以可以发现不同的编译器对于数据的存放有自己的规则,并不一定就是连续的。dword ptr [ebp-20h],0;和存放14h的规则一样,又是差了两个整形,ebp再向上挪动两个整形,将0存进。好了,完成这些操作,我们所定义的变量终于都完成赋值了,接下来就该调用Add函数了。

那么,c = Add(a, b);对应的反汇编的前两条又是比较相似的内容,就放在一起说吧,mov eax/ecx, dword ptr [ebp-14h/8] 移动到eax/ecx中,然后再分别push eax和ecx到栈中,意为将10和20分别存入两个寄存器中,然后压进栈,有没有发现和函数调用时要传参的操作比较像,函数时如果是值传递,那么形参是实参的一份临时拷贝,这时eax和ecx就是a和b的拷贝。然后再进行下面的指令,call 00C210E1,call指令是在调用函数,那是在调用谁呢?调用的就是call指令下面的一条地址为00C21450,所执行的操作就是将00C21450这个地址压栈进入栈区,如果通过调试-窗口-内存查看的话,在eax和ecx上面就会变成这个地址,那为什么call指令要记住它下一条指令的地址呢?因为接下来就要进行跳转,跳转到Add函数中,当Add函数结束时还要回到原来的位置,所以要用这个地址记住原来的地址。

如果上面执行指令按的是F10,那么走到这步要记住按F11,进入到函数内部观察,可以发现Add函数内部前面几条指令也像main函数中的指令一样也是做了一些准备工作,目的就就是为了为Add函数准备栈帧,那么还是从第一条开始看,push ebp,ebp现在指向的是main函数的底部,将ebp的地址压栈,esp向上挪动,然后mov ebp,esp将ebp指向esp,然后sub esp,0CCh再为esp减去0CCh,也就是esp再向上挪动一段位置,然后紧接着push了三个值,esp也要进行相应的移动,通过刚刚main函数进行的这些操作可以发现这就是在为新函数开辟栈帧,然后下面的三条指令和main函数一样,将下面的新开辟好的空间中的内容初始化成CC CC CC CC。然后在执行创建z的语句,和上面一样,在ebp-8的位置将0放进去,然后再执行z = x + y;可是x和y在哪里呢?不要忘记刚刚在执行c = Add(a, b);的反汇编指令时将10和20的的值存入了两个寄存器中,那么当执行mov eax,dword ptr [ebp+8]; add eax,dword ptr [ebp+0Ch]; mov dword ptr [ebp-8],eax;就是将ebp+8中的值,也就是10移到eax中,然后和ebp+0Ch中的值相加起来,然后将30移到ebp-8的位置上,也就是栈区为Add中的变量z开辟的空间,这时进行的操作就是将x和y这两个形参相加并放到z中。(注意在main函数中执行c = Add(a, b)的反汇编指令时是先压b再压a,然后Add函数内部中拿时是先拿a,后拿b,所以形参并不在Add函数的栈帧中,而是在创建Add函数之前就已经为其中涉及到的变量开辟好了空间。)

然后执行return z;的反汇编,mov eax,dword ptr [ebp-8]将ebp-8的值移到eax中,而ebp-8中放的值就是刚刚的30,函数在返回后所创建的栈帧就会销毁,这次就知道原来在返回之前是用一个临时的寄存器存入了要返回的值,然后pop了三个值,esp随之向下挪动,这时Add函数的功能就已经完成,要对Add函数的栈帧进行回收,那么mov esp,ebp,将ebp的值赋给esp,也就是esp会向下移动到ebp的位置,原来ebp和esp之间是为Add开辟的函数栈帧,现在随着它们两个的赋值操作,Add的函数栈帧也就销毁了,然后pop ebp,在调用Add函数之前先压栈了一个ebp,这个ebp原来是指向main函数的栈底的,是为了Add函数才进行的压栈,当pop之后,ebp就回到原来的main函数的栈底,到此为Add函数开辟的所有栈帧就已经完成它们的工作并且已经销毁,此时esp和ebp又开始重新维护main函数,然后执行ret,ret到哪里呢?不要忘记之前的call指令,它可是记住了call指令下一条指令的地址,那么ret就是回到那个地址所对应的位置。

当call回来以后,首先就要执行add esp,8 将esp向下挪动两个位置,也就是Add函数创建的,存入了10和20的eax和ecx进行了回收,然后mov dword ptr [ebp-20h],eax将eax中的值,也就是刚刚销毁Add栈帧前委托eax存入的z的值,放到ebp-20h处,而ebp-20h就是为c申请的栈帧,所以现在c中存入的就是通过Add函数加工好并返回的30。

printf("%d\n", c);后面的反汇编指令就不多说了,关于函数栈帧的创建和销毁到此就讲的差不多了,那回过头再看上面的几个问题:

(1)局部变量是怎么创建的?

答:在为函数创建、初始化好的栈帧中通过指针的偏移量找到指定的空间为其赋值。

(2)为什么局部变量的值是随机值?

答:因为在创建函数栈帧中就会自动初始化好,不同编译器下初始化的内容可能会不一样,所以在未为局部变量赋值时就进行访问的话访问到的就可能是上文中讲的CCCC....

(3)函数是怎么传参的?传参的顺序是怎样的?

答:在调用函数前使用额外的寄存器对函数涉及到的形参进行拷贝,并且是创建在main函数和待调用函数外部的,创建时是从右到左,使用时是从左到右。

(4)形参和实参是什么关系?

答:如果有函数需要调用实参的话,就会额外使用寄存器对实参中的值进行拷贝,操作也是通过ebp和esp两个指针的偏移量找到对应的空间,对外部的寄存器中的值进行操作,所以不会对main函数内部的实参有任何改变,所以改变函数内部的形参并不会改变外部实参的值,形参是实参的一份临时拷贝。

(5)函数调用是做什么的?

答:为了完成指定的功能,实际上就是栈帧的创建的销毁,每一次函数的调用都会有相应的栈帧进行创建,用完返回后也会有栈帧的销毁。

(6)函数调用在结束后是怎么返回的?

答:在调用之前就已经通过call指令记下了要返回处的地址,然后ebp指针和esp指针就可以进行移动,在完成调用函数的操作后回到call指令下一条指令的地址处。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值