汇编角度看函数堆栈调用

下面以主函数调用求和函数分析函数堆栈调用

带着以下一个问题来探索:
(1)形参的内存空间的开辟和清理是由调用方还是由被调用方执行的?
(2)主函数调用函数结束后,主函数从哪里开始执行?从头开始还是从调用之后开始?
(3)返回值是如何带出来的?

用于验证的代码如下:

#include<srtio.h>

int sum(int a,int b)
{
    int res = 0;
    res = a+b;
    return res;
}

int main()
{
    int a = 10;
    int b = 20;
    int ret = 0;
    ret = sum(a,b);
    printf("ret = %d\n",ret);
    return 0;
}

实验环境:vc++ 6.0 和 Win10操作系统

注意:linux操作系统采用的汇编指令是AT&T,而Windows采用的是intel x86
最简易区分它们的规则是:intel x86从左向右读,而AT&T是从右往左读。
反汇编代码如下:

1:    #include<stdio.h>
2:
3:    int sum(int a,int b)
4:    {
00401020   push        ebp
00401021   mov         ebp,esp
00401023   sub         esp,44h
00401026   push        ebx
00401027   push        esi
00401028   push        edi
00401029   lea         edi,[ebp-44h]
0040102C   mov         ecx,11h
00401031   mov         eax,0CCCCCCCCh
00401036   rep stos    dword ptr [edi]
5:        int res = 0;
00401038   mov         dword ptr [ebp-4],0
6:        res = a+b;
0040103F   mov         eax,dword ptr [ebp+8]
00401042   add         eax,dword ptr [ebp+0Ch]
00401045   mov         dword ptr [ebp-4],eax
7:        return res;
00401048   mov         eax,dword ptr [ebp-4]
8:    }
0040104B   pop         edi
0040104C   pop         esi
0040104D   pop         ebx
0040104E   mov         esp,ebp
00401050   pop         ebp
00401051   ret 

int main()
12:   {
00401060   push        ebp
00401061   mov         ebp,esp
00401063   sub         esp,4Ch
00401066   push        ebx
00401067   push        esi
00401068   push        edi
00401069   lea         edi,[ebp-4Ch]
0040106C   mov         ecx,13h
00401071   mov         eax,0CCCCCCCCh
00401076   rep stos    dword ptr [edi]
13:       int a = 10;
00401078   mov         dword ptr [ebp-4],0Ah
14:       int b = 20;
0040107F   mov         dword ptr [ebp-8],14h
15:       int ret = 0;
00401086   mov         dword ptr [ebp-0Ch],0
16:       ret = sum(a,b);
0040108D   mov         eax,dword ptr [ebp-8]
00401090   push        eax
00401091   mov         ecx,dword ptr [ebp-4]
00401094   push        ecx
00401095   call        @ILT+0(_sum) (00401005)
0040109A   add         esp,8
0040109D   mov         dword ptr [ebp-0Ch],eax
17:
18:       printf("ret = %d\n",ret);
004010A0   mov         edx,dword ptr [ebp-0Ch]
004010A3   push        edx
004010A4   push        offset string "ret = %d\n" (0042201c)
004010A9   call        printf (004010e0)
004010AE   add         esp,8
19:
20:       return 0;
004010B1   xor         eax,eax
21:   }
004010B3   pop         edi
004010B4   pop         esi
004010B5   pop         ebx
004010B6   add         esp,4Ch
004010B9   cmp         ebp,esp
004010BB   call        __chkesp (00401160)
004010C0   mov         esp,ebp
004010C2   pop         ebp
004010C3   ret

可以看到在主函数和求和函数中首先出现的反汇编代码,我们以求函数举例,其实它们的功能是相同的,就是开辟栈帧

00401060   push        ebp
00401061   mov         ebp,esp
00401063   sub         esp,4Ch
00401066   push        ebx
00401067   push        esi
00401068   push        edi
00401069   lea         edi,[ebp-4Ch]
0040106C   mov         ecx,13h
00401071   mov         eax,0CCCCCCCCh
00401076   rep stos    dword ptr [edi]

要看懂以上的汇编代码,首先我们必须具备的基础知识是几条简单的汇编指令寄存器的功能和作用以及通常用的几个寄存器。

1.常用的intelx86汇编指令。

[push  寄存器]  功能:将一个寄存器中的数据入栈。包含两个动作:将寄存器中的数据入栈,栈顶指针向上(低地址)偏移。
[pop 寄存器]  功能:出栈,以一个寄存器接受出栈的数据。包含两个动作:将栈中的数据保存在寄存器中,同时栈顶指针向下(高地址)偏移。
[add 寄存器,数据] 如:add ax,8    //相当于ax += 8;
[sub 寄存器,数据] 如:sub bx,4    //相当于bx -= 4;
常见的几种mov指令:
[mov 寄存器,寄存器] 如:move ax,8
[mov 寄存器,数据] 如:move ax,bx
[mov 寄存器,内存单元] 如:move ax,[0]
[mov 内存单元,寄存器] 如:move [0],ax
[mov 段寄存器,寄存器] 如:move ds,ax
call指令:call指令有两个动作
(1)将下一行指令地址压栈
(2)跳转
[lea ax,[]]  功能:将有效地址放[]到指定寄存器中。
[rep stos ]
如:
lea     edi,[ebp-0C0h] 
mov     ecx,30h 
mov     eax,0CCCCCCCCh 
rep stos dword ptr [edi]
rep指令的目的是重复其上面的指令。ecx寄存器中的值是重复的次数。
stos指令的作用是将eax寄存器中的值拷贝到[edi]指向的地址。

2.常用的寄存器。

eax:累加寄存器,它是许多加法乘法指令的缺省寄存器。(缺省即默认defalut)
ebx:基地址寄存器,在内存寻址是存放基地址。
ecx:计数器,是重复前缀指令res和loop指令的内定计数器。
edx:总是被用来存放整数产生的余数。
esp:专门用作堆栈指针,被形象的称为栈顶指针,堆栈的顶部是地址小的区域,压入堆栈的数据越多,esp就越来越小。在32位平台上,esp每次减少4个字节。
ebp:堆栈的栈底指针。
esi/edi:"分别叫做源/目标索引寄存器"(source/destination index),因为在很多字符串操作指令中,DS:ESI指向源串,而ES:EDI指向目标串。

具备上边常用的intelx86汇编指令以及常用寄存器的功能。开始分析函数栈帧开辟的过程:

00401060   push        ebp
00401061   mov         ebp,esp
00401063   sub         esp,4Ch
00401066   push        ebx
00401067   push        esi
00401068   push        edi
00401069   lea         edi,[ebp-4Ch]
0040106C   mov         ecx,13h
00401071   mov         eax,0CCCCCCCCh
00401076   rep stos    dword ptr [edi]
1.压栈,并将值保存于ebp寄存器中,即ebp指向该块内存区域。

2.使得esp和ebp指向同一块内存区域,虽然esp和ebp是寄存器,但由于其内保存的是地址,所以在此我们也可以形象的将esp和ebp看做指针,便于理解。

3.sub esp,4Ch,对照上边的汇编指令,这里做的操作是 -= ,即esp = esp-4ch,我们都知道,指针进行加减还是指针。所以栈顶指针向上(低地址)移动76个字节,为什么会移动76个字节。我们可以认为,编译认为主函数栈帧开辟76个字节大小完全足够使用。在这里还需要注意的一点是,虚拟地址空间中栈的生长方向是从高地址到低地址,所以我们看到的是esp-4ch。

4.由于接下来的三条汇编指令   入栈
00401066   push        ebx
00401067   push        esi
00401068   push        edi
与后边的出栈指令呼应,相当于没有入栈,在此不做赘述。
004010B3   pop         edi
004010B4   pop         esi
004010B5   pop         ebx

5.lea edi [ebp-4ch],将[ebp-4ch]的地址存放在地址寄存器中。

6.mov ecx,13h
7.mov eax,CCCCCCCCh
8.rep stos dword ptr [edi]
以上三条指令构成循环拷贝指令,循环次数13.拷贝的内容,CCCCCCCCh,即汉字"烫烫"。也就是说开辟栈帧结束后,对其做初始化。这就是为什么当我们访问未初始化内存中的内容,看到的是如下图的情况。
简单小程序验证一下:
#include<stdio.h>
int main()
{
    int ch;
    putchar(ch);
    return;
}

//执行程序会报错,但可以通过调试查看内存获取内容。
这里写图片描述
下面将开辟栈帧之后的图展示一下,以便理解:
这里写图片描述

下面分析栈帧开辟完成之后的汇编指令:

13:       int a = 10;
00401078   mov         dword ptr [ebp-4],0Ah
14:       int b = 20;
0040107F   mov         dword ptr [ebp-8],14h
15:       int ret = 0;
00401086   mov         dword ptr [ebp-0Ch],0
16:       ret = sum(a,b);
0040108D   mov         eax,dword ptr [ebp-8]
00401090   push        eax
00401091   mov         ecx,dword ptr [ebp-4]
00401094   push        ecx
00401095   call        @ILT+0(_sum) (00401005)
0040109A   add         esp,8
0040109D   mov         dword ptr [ebp-0Ch],eax
17:
18:       printf("ret = %d\n",ret);
004010A0   mov         edx,dword ptr [ebp-0Ch]
004010A3   push        edx
004010A4   push        offset string "ret = %d\n" (0042201c)
004010A9   call        printf (004010e0)
004010AE   add         esp,8

纵观上边列出的指令,可以看到。布局变量并没有表现出来,它是通过ebp栈底指针的偏移量来表示的

13:       int a = 10;
00401078   mov         dword ptr [ebp-4],0Ah
14:       int b = 20;
0040107F   mov         dword ptr [ebp-8],14h
15:       int ret = 0;
00401086   mov         dword ptr [ebp-0Ch],0

1.mov dword ptr [ebp-4],0Ah,dword表示双字,即四字节。即将0Ah放入[ebp-4]指向的四字节内存块中。
2.mov dword ptr [ebp-8],14h,将14h放入[ebp-8]指向的四字节内存块中。
3. mov dword ptr [ebp-0Ch],0,将0放入[ebp-0Ch]指向的四字节内存块中。

这里写图片描述

16:       ret = sum(a,b);
0040108D   mov         eax,dword ptr [ebp-8]
00401090   push        eax
00401091   mov         ecx,dword ptr [ebp-4]
00401094   push        ecx
1.将[ebp-8]指向内存块中的值存入寄存器eax中,并进行压栈。同时栈顶指针向上偏移(低地址)。
2.将[ebp-4]指向内存块中的值存入寄存器ecx中,并进行压栈。同时栈顶指针向上偏移(低地址)。
在这两行代码中,可以得到以下的结论:
(1)形参的内存是由调用方开辟的。

这里写图片描述

00401095   call        @ILT+0(_sum) (00401005)

这条指令是尤为重要的,前面已经讲过call指令有两个动作。
(1)将下一条指令的地址压栈。
(2)跳转

如何确定call指令是否执行了上述的动作,我们使用反汇编代码进行调试。
这里写图片描述
黄箭头表示此条指令还未执行,那么我们查看此时栈底指针esp的地址,查看内存中的内容。
这里写图片描述
查看0x0019fee0对应的内存块。
这里写图片描述
可见上述实参1020已经入栈,并且栈顶指针指向10(0A)所在的内存块。

下面执行call指令我们看看会发生什么?
首先栈顶指针向上偏移(低地址)。
这里写图片描述
重点:下一条指令的地址被压栈
这里写图片描述

由于intelx86体系的机器是小端模式,读取0x0019fedc内存块的内容,0040109A,正是call指令下一条指令的地址。

00401095   call        @ILT+0(_sum) (00401005)
0040109A   add         esp,8

这里写图片描述

跳转到被调用函数中,首先也是开辟栈帧并作初始化,在此不做赘述。但是值得注意的是栈帧开辟的时候进行push ebp的操作。
这里写图片描述

5:        int res = 0;
00401038   mov         dword ptr [ebp-4],0
6:        res = a+b;
0040103F   mov         eax,dword ptr [ebp+8]
00401042   add         eax,dword ptr [ebp+0Ch]
00401045   mov         dword ptr [ebp-4],eax
7:        return res;
00401048   mov         eax,dword ptr [ebp-4]
1.在求和函数中,将0存入[ebp-4]指向的内存块中。
2.将[ebp+8]指向的内存块中的值放入eax寄存器中,而[ebp+8]指向的内存块对应的正是实参`10`。
3.将[eb[ebp+0Ch]指向的内存块中的值放入eax寄存器中,而[ebp+0Ch]指向的内存中对应的正是实参`20`。
4.将eax寄存器中的值压栈,存放到[ebp-4]指向的内存块中,此时eax寄存器中的值为`30`。

这里写图片描述

7:        return res;
00401048   mov         eax,dword ptr [ebp-4]


0040104E   mov         esp,ebp
00401050   pop         ebp
00401051   ret 

1.mov    eax,dword ptr [ebp-4]可知,函数的返回值(小于等于四个字节)是由寄存器带回的。

2.0040104E   mov       esp,ebp使得被调用函数栈帧回退。此时栈帧空间的内容还存在。

这里写图片描述

3.pop   ebp 两个动作,出栈,并将出栈的值赋给ebp。在这里,即ebp==0x100。栈顶指针向下回退四个字节(高地址)。

这里写图片描述

//下面看主函数调用求和函数执行的指令
0040109A   add         esp,8
0040109D   mov         dword ptr [ebp-0Ch],eax

1.add   esp,8 相当于esp = esp + 8,是主函数压实参的栈帧回退。所以形参内存是由调用方清理的。
2.将eax寄存器中的值`30`放入[ebp-0Ch]指向的四字节内存块中。

这里写图片描述

到这里,函数堆栈调用的过程就完全展示出来了。现在回答最开始我们提出的几个题:

(1)形参的内存空间的开辟和清理是由调用方还是由被调用方执行的?
(2)主函数调用函数结束后,主函数从哪里开始执行?从头开始还是从调用之后开始?
(3)返回值是如何带出来的?

答:
(1)形参的内存空间的开辟和清理是由调用方执行的。
(2)主函数调用函数后执行执行调用之后的代码,是因为调用方在进行调用的过程中,将下一行指令的地址压栈。所以调用完成之后是从调用之后开始,不会从头开始。
(3)返回值是由累加寄存器eax带出来的(当返回值的字节数小于等于四个自己时)。

猜你喜欢

转载自blog.csdn.net/asjbfjsb/article/details/80031965