下面以主函数调用求和函数分析函数堆栈调用
带着以下一个问题来探索: (1)形参的内存空间的开辟和清理是由调用方还是由被调用方执行的? (2)主函数调用函数结束后,主函数从哪里开始执行?从头开始还是从调用之后开始? (3)返回值是如何带出来的?
用于验证的代码如下:
代码语言:javascript复制#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 = %dn",ret);
return 0;
}
实验环境:vc 6.0 和 Win10操作系统
注意:linux操作系统采用的汇编指令是AT&T,而Windows采用的是intel x86 最简易区分它们的规则是:intel x86从左向右读,而AT&T是从右往左读。 反汇编代码如下:
代码语言:javascript复制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 = %dn",ret);
004010A0 mov edx,dword ptr [ebp-0Ch]
004010A3 push edx
004010A4 push offset string "ret = %dn" (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
可以看到在主函数和求和函数中首先出现的反汇编代码,我们以求函数举例,其实它们的功能是相同的,就是开辟栈帧。
代码语言:javascript复制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汇编指令。
代码语言:javascript复制[push 寄存器] 功能:将一个寄存器中的数据入栈。包含两个动作:将寄存器中的数据入栈,栈顶指针向上(低地址)偏移。
[pop 寄存器] 功能:出栈,以一个寄存器接受出栈的数据。包含两个动作:将栈中的数据保存在寄存器中,同时栈顶指针向下(高地址)偏移。
代码语言:javascript复制[add 寄存器,数据] 如:add ax,8 //相当于ax = 8;
[sub 寄存器,数据] 如:sub bx,4 //相当于bx -= 4;
代码语言:javascript复制常见的几种mov指令:
[mov 寄存器,寄存器] 如:move ax,8
[mov 寄存器,数据] 如:move ax,bx
[mov 寄存器,内存单元] 如:move ax,[0]
[mov 内存单元,寄存器] 如:move [0],ax
[mov 段寄存器,寄存器] 如:move ds,ax
代码语言:javascript复制call指令:call指令有两个动作
(1)将下一行指令地址压栈
(2)跳转
代码语言:javascript复制[lea ax,[]] 功能:将有效地址放[]到指定寄存器中。
代码语言:javascript复制[rep stos ]
如:
lea edi,[ebp-0C0h]
mov ecx,30h
mov eax,0CCCCCCCCh
rep stos dword ptr [edi]
rep指令的目的是重复其上面的指令。ecx寄存器中的值是重复的次数。
stos指令的作用是将eax寄存器中的值拷贝到[edi]指向的地址。
2.常用的寄存器。
代码语言:javascript复制eax:累加寄存器,它是许多加法乘法指令的缺省寄存器。(缺省即默认defalut)
ebx:基地址寄存器,在内存寻址是存放基地址。
ecx:计数器,是重复前缀指令res和loop指令的内定计数器。
edx:总是被用来存放整数产生的余数。
esp:专门用作堆栈指针,被形象的称为栈顶指针,堆栈的顶部是地址小的区域,压入堆栈的数据越多,esp就越来越小。在32位平台上,esp每次减少4个字节。
ebp:堆栈的栈底指针。
esi/edi:"分别叫做源/目标索引寄存器"(source/destination index),因为在很多字符串操作指令中,DS:ESI指向源串,而ES:EDI指向目标串。
具备上边常用的intelx86汇编指令以及常用寄存器的功能。开始分析函数栈帧开辟的过程:
代码语言:javascript复制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]
代码语言:javascript复制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,即汉字"烫烫"。也就是说开辟栈帧结束后,对其做初始化。这就是为什么当我们访问未初始化内存中的内容,看到的是如下图的情况。
代码语言:javascript复制简单小程序验证一下:
#include<stdio.h>
int main()
{
int ch;
putchar(ch);
return;
}
//执行程序会报错,但可以通过调试查看内存获取内容。
下面将开辟栈帧之后的图展示一下,以便理解:
下面分析栈帧开辟完成之后的汇编指令:
代码语言:javascript复制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 = %dn",ret);
004010A0 mov edx,dword ptr [ebp-0Ch]
004010A3 push edx
004010A4 push offset string "ret = %dn" (0042201c)
004010A9 call printf (004010e0)
004010AE add esp,8
纵观上边列出的指令,可以看到。布局变量并没有表现出来,它是通过ebp栈底指针的偏移量来表示的。
代码语言:javascript复制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]指向的四字节内存块中。
代码语言:javascript复制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)形参的内存是由调用方开辟的。
代码语言:javascript复制00401095 call @ILT 0(_sum) (00401005)
这条指令是尤为重要的,前面已经讲过call指令有两个动作。
(1)将下一条指令的地址压栈。
(2)跳转
如何确定call指令是否执行了上述的动作,我们使用反汇编代码进行调试。
黄箭头表示此条指令还未执行,那么我们查看此时栈底指针esp的地址,查看内存中的内容。
查看0x0019fee0
对应的内存块。
可见上述实参10
和20
已经入栈,并且栈顶指针指向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]
代码语言:javascript复制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`。
代码语言:javascript复制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使得被调用函数栈帧回退。此时栈帧空间的内容还存在。
代码语言:javascript复制3.pop ebp 两个动作,出栈,并将出栈的值赋给ebp。在这里,即ebp==0x100。栈顶指针向下回退四个字节(高地址)。
代码语言:javascript复制//下面看主函数调用求和函数执行的指令
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带出来的(当返回值的字节数小于等于四个自己时)。