函数参数入栈解析

栈:用于维护函数调用的上下文,通常在用户空间的最高地址处分配,增长方向向低地址增长。在i386下,栈顶由esp寄存器进行定位,压栈的操作使栈顶的地址减小,即esp减小;弹出的操作使栈顶地址增大,即esp增大。

栈保存了函数调用所需要的维护信息,这被称为堆栈帧(Stack Frame)或活动记录(Active Record),其包括如下内容:

  • 函数的返回地址和参数
  • 临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量
  • 保存的上下文:包括在函数调用前后需要保持不变的寄存器

在i386中,一个函数的活动记录用ebp和esp两个寄存器划定范围。esp寄存器始终指向栈的顶部,即指向当前函数的活动记录的顶部;而ebp寄存器这指向函数活动记录的一个固定位置,ebp寄存器有称为栈指针。一个典型的活动记录如下图:
函数参数入栈解析_第1张图片
这里的ebp所直接指向的数据是调用该函数前ebp的值,这样在函数返回时,ebp可以通过读取这个值恢复到调用之前的值。

在i386下,一个函数总是这样调用的:

  • 将函数参数压入栈中,若有其他参数没有入栈,则使用某些特定的寄存器传递
  • 把当前指令的下一条指令的地址压入栈中
  • 跳转到函数体执行
    其中第2步和第3步有指令call一起执行。i386函数体的一般开头如下:
  • push ebp: 把ebp压入栈中(称为old ebp)
  • mov ebp,esp:ebp = esp(这时ebp指向栈顶,即此时栈顶就是old ebp)
  • 【可选】sub esp,XXX: 在栈上分配XXX字节的临时空间
  • 【可选】push XXX:保存名为XXX寄存器

在函数返回时,所进行的操作如下:

  • 【可选】pop XXX:恢复保存过的寄存器
  • mov esp,ebp:恢复ESP的同时,回收局部变量空间
  • pop ebp:从栈中恢复保存的ebp的值
  • ret: 从栈中取得返回地址,并跳转到该位置

接下来进行测试,测试平台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行,在调试界面的局部变量窗口可看到如下信息:

常规显示 十六进制显示
函数参数入栈解析_第2张图片 函数参数入栈解析_第3张图片

这里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
函数参数入栈解析_第4张图片
这里可以看到,在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  

分析上述代码:

  1. 第3~4行。第一步,先保存ebp,然后将ebp指向当前的栈顶
00131790 55                   push        ebp  
00131791 8B EC                mov         ebp,esp
  1. 第5行。第二步,在栈上开辟一块空间,大小为D8h
00131793 81 EC D8 00 00 00    sub         esp,0D8h
  1. 第6~8行。第三步,保存ebx、esi、edi寄存器
00131799 53                   push        ebx  
0013179A 56                   push        esi  
0013179B 57                   push        edi 
  1. 第四步,加入调试信息,这里 e c x = 36 h = 54 , 54 ∗ 4 = 216 = D 8 h ecx=36h=54,54*4=216=D8h ecx=36h=54,544=216=D8h,即刚好是第二步在栈上分配出来的空间
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]  
  1. 第五步,初始化变量sum
001317AE C7 45 EC 00 00 00 00 mov         dword ptr [sum],0
  1. 第六步,计算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
  1. 第七步,通过eax寄存器传递返回值
001317BE 8B 45 EC             mov         eax,dword ptr [sum]
  1. 第八步,从栈上恢复ebx、esi、edi寄存器
001317C1 5F                   pop         edi  
001317C2 5E                   pop         esi  
001317C3 5B                   pop         ebx 
  1. 第九步,恢复进入函数前的esp和ebp
001317C4 8B E5                mov         esp,ebp  
001317C6 5D                   pop         ebp  
  1. 第十步,使用ret指令返回
001317C7 C3                   ret 

至此整个函数调用过程结束。

函数调用惯例

在上述例子中,有一个问题未加说明:即函数的调用方在传递参数时是先压入参数x,还是先压入参数y。这即是调用惯例所解决的问题,调用惯例规定如下内容:

  • 函数参数的传递顺序和方式,即函数调用方将参数压栈的顺序:是从左至右,还是从右至左
  • 栈的维护方式,即参数入栈之后,弹出操作是由函数的调用方完成还是有函数本身来完成
  • 名字修饰的策略

几种调用惯例如下:

调用惯例 出栈方 参数传递 名字修饰
cdecl 函数调用方 从右至左的顺序压参数入栈 下划线+函数名
stdcall 函数本身 从右至左的顺序压参数入栈 下划线+函数名+@+参数的字节数
fastcall 函数本身 头两个DWORD(4字节)类型或更少字节的参数被放入寄存器,剩下的参数从右至左的顺序压参数入栈 @+函数名+@+参数字节数
pascal 函数本身 从左至右的顺序压参数入栈 较为复杂,详情见文档

在C语言中,默认的调用惯例是cdecl,因此在该调用惯例下函数被修饰为:_函数名,即上述例子中的_func,则函数的堆栈操作如下:

  • 将y压入栈
  • 将x压入栈
  • 调用_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] 

查看其内存如下:
函数参数入栈解析_第5张图片
划线的地址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的压栈的区别)

你可能感兴趣的:(C++,操作系统)