栈:用于维护函数调用的上下文,通常在用户空间的最高地址处分配,增长方向向低地址增长。在i386下,栈顶由esp寄存器进行定位,压栈的操作使栈顶的地址减小,即esp减小;弹出的操作使栈顶地址增大,即esp增大。
栈保存了函数调用所需要的维护信息,这被称为堆栈帧(Stack Frame)或活动记录(Active Record),其包括如下内容:
在i386中,一个函数的活动记录用ebp和esp两个寄存器划定范围。esp寄存器始终指向栈的顶部,即指向当前函数的活动记录的顶部;而ebp寄存器这指向函数活动记录的一个固定位置,ebp寄存器有称为栈指针。一个典型的活动记录如下图:
这里的ebp所直接指向的数据是调用该函数前ebp的值,这样在函数返回时,ebp可以通过读取这个值恢复到调用之前的值。
在i386下,一个函数总是这样调用的:
在函数返回时,所进行的操作如下:
接下来进行测试,测试平台win7+VS2015 Debug x86模式。测试代码如下:
int func(int x, int y)
{
int z;
int sum = 0;
sum = x + y;
return sum;
}
int main()
{
char p[4];
int d[2];
int a = 4, b = 3, c = 0;
c = func(a, b);
printf("%d,%d,%d,%d", a++, ++a, ++a, a++);
getchar();
}
这里我将断点放在13行,在调试界面的局部变量窗口可看到如下信息:
常规显示 | 十六进制显示 |
---|---|
![]() |
![]() |
这里char数组p显示的值是两个烫,而int类型的数据abcd,显示的值是-858993460,因为断点在13行,此时abc还未初始化。在十六进制下,所有的值均是0xcc或者0xcccccccc。这是由于在Debug模式下,VS2015的编译器将所有的分配出来的栈空间的每一个字节都初始化为0xcc。而int是有符号的,故0xcccccccc表示为-858993460,而0xcccc(两个连续排列的0xcc)的汉字编码即烫,所以p显示的值是两个烫。
分析上述代码的汇编代码:
16: int main()
17: {
001317E0 55 push ebp
001317E1 8B EC mov ebp,esp
001317E3 81 EC 08 01 00 00 sub esp,108h
001317E9 53 push ebx
001317EA 56 push esi
001317EB 57 push edi
001317EC 8D BD F8 FE FF FF lea edi,[ebp-108h]
001317F2 B9 42 00 00 00 mov ecx,42h
001317F7 B8 CC CC CC CC mov eax,0CCCCCCCCh
001317FC F3 AB rep stos dword ptr es:[edi]
18: char p[4];
19: int d[2];
20: int a = 4, b = 3, c = 0;
001317FE C7 45 DC 04 00 00 00 mov dword ptr [a],4
00131805 C7 45 D0 03 00 00 00 mov dword ptr [b],3
0013180C C7 45 C4 00 00 00 00 mov dword ptr [c],0
21:
22: c = func(a, b);
00131813 8B 45 D0 mov eax,dword ptr [b]
00131816 50 push eax
00131817 8B 4D DC mov ecx,dword ptr [a]
0013181A 51 push ecx
0013181B E8 A6 FA FF FF call _func (01312C6h)
00131820 83 C4 08 add esp,8
00131823 89 45 C4 mov dword ptr [c],eax
23: printf("%d,%d,%d,%d", a++, ++a, ++a, a++);
00131826 8B 45 DC mov eax,dword ptr [a]
00131829 89 85 FC FE FF FF mov dword ptr [ebp-104h],eax
0013182F 8B 4D DC mov ecx,dword ptr [a]
00131832 83 C1 01 add ecx,1
00131835 89 4D DC mov dword ptr [a],ecx
00131838 8B 55 DC mov edx,dword ptr [a]
0013183B 83 C2 01 add edx,1
0013183E 89 55 DC mov dword ptr [a],edx
00131841 8B 45 DC mov eax,dword ptr [a]
00131844 83 C0 01 add eax,1
23: printf("%d,%d,%d,%d", a++, ++a, ++a, a++);
汇编代码下,每行表示的名称依次为代码地址,代码字节(在内存中的值),汇编指令,汇编变量或值。这里重点分析函数func,第25行的call指令表示调用函数_func (0x01312C6h),在汇编中找到该地址0x01312C6h
这里可以看到,在call函数的时候,均是调用jmp跳转指令,接着找到func (00131790),其代码如下:
int func(int x, int y)
6: {
00131790 55 push ebp
00131791 8B EC mov ebp,esp
00131793 81 EC D8 00 00 00 sub esp,0D8h
00131799 53 push ebx
0013179A 56 push esi
0013179B 57 push edi
0013179C 8D BD 28 FF FF FF lea edi,[ebp-0D8h]
001317A2 B9 36 00 00 00 mov ecx,36h
001317A7 B8 CC CC CC CC mov eax,0CCCCCCCCh
001317AC F3 AB rep stos dword ptr es:[edi]
7: int z;
8: int sum = 0;
001317AE C7 45 EC 00 00 00 00 mov dword ptr [sum],0
9:
10: sum = x + y;
001317B5 8B 45 08 mov eax,dword ptr [x]
001317B8 03 45 0C add eax,dword ptr [y]
001317BB 89 45 EC mov dword ptr [sum],eax
11: return sum;
001317BE 8B 45 EC mov eax,dword ptr [sum]
12:
13: }
001317C1 5F pop edi
12:
13: }
001317C2 5E pop esi
001317C3 5B pop ebx
001317C4 8B E5 mov esp,ebp
001317C6 5D pop ebp
001317C7 C3 ret
分析上述代码:
00131790 55 push ebp
00131791 8B EC mov ebp,esp
00131793 81 EC D8 00 00 00 sub esp,0D8h
00131799 53 push ebx
0013179A 56 push esi
0013179B 57 push edi
0013179C 8D BD 28 FF FF FF lea edi,[ebp-0D8h]
001317A2 B9 36 00 00 00 mov ecx,36h
001317A7 B8 CC CC CC CC mov eax,0CCCCCCCCh
001317AC F3 AB rep stos dword ptr es:[edi]
001317AE C7 45 EC 00 00 00 00 mov dword ptr [sum],0
001317B5 8B 45 08 mov eax,dword ptr [x]
001317B8 03 45 0C add eax,dword ptr [y]
001317BB 89 45 EC mov dword ptr [sum],eax
001317BE 8B 45 EC mov eax,dword ptr [sum]
001317C1 5F pop edi
001317C2 5E pop esi
001317C3 5B pop ebx
001317C4 8B E5 mov esp,ebp
001317C6 5D pop ebp
001317C7 C3 ret
至此整个函数调用过程结束。
函数调用惯例
在上述例子中,有一个问题未加说明:即函数的调用方在传递参数时是先压入参数x,还是先压入参数y。这即是调用惯例所解决的问题,调用惯例规定如下内容:
几种调用惯例如下:
调用惯例 | 出栈方 | 参数传递 | 名字修饰 |
---|---|---|---|
cdecl | 函数调用方 | 从右至左的顺序压参数入栈 | 下划线+函数名 |
stdcall | 函数本身 | 从右至左的顺序压参数入栈 | 下划线+函数名+@+参数的字节数 |
fastcall | 函数本身 | 头两个DWORD(4字节)类型或更少字节的参数被放入寄存器,剩下的参数从右至左的顺序压参数入栈 | @+函数名+@+参数字节数 |
pascal | 函数本身 | 从左至右的顺序压参数入栈 | 较为复杂,详情见文档 |
在C语言中,默认的调用惯例是cdecl,因此在该调用惯例下函数被修饰为:_函数名
,即上述例子中的_func
,则函数的堆栈操作如下:
_func
,这里先将返回地址(即调用_func
之后的下一条指令的地址)压入栈,然后跳转到_func
执行。[外链图片转存失败(img-atYvHfo0-1564927790920)(参数入栈.png)]
这里可以看到参数b先被压入,参数a再被压入。func函数栈的布局如下:
[外链图片转存失败(img-2GEExp51-1564927790921)(函数栈布局.png)]
在VS中查看内存分布如下图:
[外链图片转存失败(img-3zZXs7Dk-1564927790921)(函数栈内存分布.png)]
这里x=4,y=3,可以看出y是先被压入的;前面的0x00131820即返回地址,前面main函数第26行的add语句;0x0036f92即是old ebp。
00131820 83 C4 08 add esp,8
下面分析a++和++a的压栈的区别
接着上面main函数的汇编代码:
23: printf("%d,%d,%d,%d", a++, ++a, ++a, a++);
00131826 8B 45 DC mov eax,dword ptr [a]
00131829 89 85 FC FE FF FF mov dword ptr [ebp-104h],eax
0013182F 8B 4D DC mov ecx,dword ptr [a]
00131832 83 C1 01 add ecx,1
00131835 89 4D DC mov dword ptr [a],ecx
00131838 8B 55 DC mov edx,dword ptr [a]
0013183B 83 C2 01 add edx,1
0013183E 89 55 DC mov dword ptr [a],edx
00131841 8B 45 DC mov eax,dword ptr [a]
00131844 83 C0 01 add eax,1
00131847 89 45 DC mov dword ptr [a],eax
0013184A 8B 4D DC mov ecx,dword ptr [a]
0013184D 89 8D F8 FE FF FF mov dword ptr [ebp-108h],ecx
00131853 8B 55 DC mov edx,dword ptr [a]
00131856 83 C2 01 add edx,1
00131859 89 55 DC mov dword ptr [a],edx
0013185C 8B 85 FC FE FF FF mov eax,dword ptr [ebp-104h]
00131862 50 push eax
00131863 8B 4D DC mov ecx,dword ptr [a]
00131866 51 push ecx
00131867 8B 55 DC mov edx,dword ptr [a]
0013186A 52 push edx
0013186B 8B 85 F8 FE FF FF mov eax,dword ptr [ebp-108h]
00131871 50 push eax
00131872 68 30 6B 13 00 push offset string "%d,%d,%d,%d" (0136B30h)
00131877 E8 AE FA FF FF call _printf (013132Ah)
0013187C 83 C4 14 add esp,14h
24: getchar();
从上面的程序可以看出,参数先经过计算再压入栈中。按照同样的方法找到printf函数的入口,其部分汇编代码如下:
00131920 55 push ebp
00131921 8B EC mov ebp,esp
00131923 81 EC D8 00 00 00 sub esp,0D8h
00131929 53 push ebx
0013192A 56 push esi
0013192B 57 push edi
0013192C 8D BD 28 FF FF FF lea edi,[ebp-0D8h]
00131932 B9 36 00 00 00 mov ecx,36h
00131937 B8 CC CC CC CC mov eax,0CCCCCCCCh
0013193C F3 AB rep stos dword ptr es:[edi]
查看其内存如下:
划线的地址0x0013187C即main函数中的第28行的代码:
0013187C 83 C4 14 add esp,14h
然后参考前面的func函数栈的参数分布,即可推出,按照栈地址增长的方向,参数依次为7、8、8、4,最后打印出来的结果即是:
因此printf与getchar之间的代码即处理a++, ++a, ++a, a++过程。注意这里计算不等于输出,也就是实际上传给_printf
的是7、8、8、4。简单来讲:a++是先将a保存为参数,然后计算a++的值,而++a则是先计算++a,然后传给函数的参数是关于a的所有表达式最后计算的结果。
参考资料:
1、《程序员的自我修养——链接、装载与库》
2、printf计算参数是从右到左压栈的原理(a++和++a的压栈的区别)