首页 > C > 函数 阅读:57,774

调用约定

函数调用约定(Calling Convention),是一个重要的基础概念,用来规定调用者和被调用者是如何传递参数的,既调用者如何将参数按照什么样的规范传递给被调用者。

在参数传递中,有两个很重要的问题必须得到明确说明:

1.当参数个数多于一个时,按照什么顺序把参数压入堆栈;

2.函数调用后,由谁来把堆栈恢复原装。

假如在C语言中,定义下面这样一个函数:

int func(int x,int y, int z)

然后传递实参给函数func()就可以使用了。但是,在系统中,函数调用中参数的传递却是一门学问。因为在CPU中,计算机没有办法知道一个函数调用需要多少个、什么样的参数,也没有硬件可以保存这些参数。也就是说,计算机不知道怎么给这个函数传递参数,传递参数的工作必须由函数调用者和函数本身来协调。为此,计算机用栈来支持参数传递。

函数调用时,调用者依次把参数压栈,然后调用函数,函数被调用以后,在堆栈中取得数据,并进行计算。函数计算结束以后,或者调用者、或者函数本身修改堆栈,使堆栈恢复原装。

在高级语言中,通过函数调用约定来说明参数的入栈和堆栈的恢复问题。常见的调用约定有:

l         stdcall

l         cdecl

l         fastcall

l         thiscall

l         naked call

    不同的调用规约,在参数的入栈顺序,堆栈的恢复,函数名字的命名上就会不同。在编译后的代码量,程序执行效率上也会受到影响。

7.9.1 stdcall调用规约

stdcall调用约定声明的格式:

int __stdcall func(int x,int y)

stdcall的调用约定意味着:

参数入栈规则:参数从右向左压入堆栈

还原堆栈者:被调用函数自身修改堆栈

函数名自动加前导的下划线,后面紧跟一个@符号,其后紧跟着参数的尺寸。

在微软WindowsC/C++编译器中,常常用Pascal宏来声明这个调用约定,类似的宏还有WINAPICALLBACK

int __stdcall func1(int x, int y)

{

         return x+y;

}

int __stdcall func1(int x, int y)//采用stdcall

{

42D640 push ebp

0042D641 mov ebp,esp

0042D643 sub esp,0C0h

0042D649 push ebx

0042D64A push esi

0042D64B push edi

0042D64C lea edi,[ebp-0C0h]

0042D652 mov ecx,30h

0042D657 mov eax,0CCCCCCCCh

0042D65C rep stos dword ptr es:[edi]

   return x+y;

0042D65E mov eax,dword ptr [x]

0042D661 add eax,dword ptr [y]

 

}

0042D664 pop edi

0042D665 pop esi

0042D666 pop ebx

0042D667 mov esp,ebp //ebp(调用前的栈顶)放入esp中,然后出栈,恢复老ebp

0042D669 pop ebp

0042D66A ret 8 //被调用者负责栈平衡,ret 8,esp += 8;

7.9.2 cdecl调用规约

cdecl调用约定又称为C调用约定,是C语言缺省的调用约定,它的定义语法是:

int func (int x ,int y)          //默认的C调用约定

int __cdecl func (int x,int y)     //明确指出C调用约定

该调用约定遵循下面的规则:

参数入栈顺序:从右到左

还原堆栈者:调用者修改堆栈

函数名:前加下划线:_func

由于每次函数调用都要由编译器产生还原堆栈的代码,所以使用__cdecl方式编译的程序比使用__stdcall方式编译的程序要大很多,但是 __cdecl调用方式是由函数调用者负责清除栈中的函数参数,所以这种方式支持可变参数,比如printf()WindowsAPI wsprintf()就是__cdecl调用方式。

由于参数按照从右向左顺序压栈,因此最开始的参数在最接近栈顶的位置,因此当采用不定个数参数时,第一个参数在栈中的位置肯定能知道,只要不定的参数个数能够根据第一个后者后续的明确的参数确定下来,就可以使用不定参数了。

int __cdecl func2(int x, int y)

{

         return x+y;

}

11:   int __cdecl func2(int x, int y)

12:   {

00401070 55                   push        ebp

00401071 8B EC                mov         ebp,esp

00401073 83 EC 40              sub         esp,40h

00401076 53                   push        ebx

00401077 56                   push        esi

00401078 57                   push        edi

00401079 8D 7D C0             lea         edi,[ebp-40h]

0040107C B9 10 00 00 00         mov         ecx,10h

00401081 B8 CC CC CC CC        mov         eax,0CCCCCCCCh

00401086 F3 AB                rep stos    dword ptr [edi]

13:       return x+y;

00401088 8B 45 08             mov         eax,dword ptr [ebp+8]

0040108B 03 45 0C             add         eax,dword ptr [ebp+0Ch]

14:   }

0040108E 5F                   pop         edi

0040108F 5E                   pop         esi

00401090 5B                   pop         ebx

00401091 8B E5                mov         esp,ebp

00401093 5D                   pop         ebp

00401094 C3                   ret;直接返回,由调用者负责平衡栈


cdecl与stdcall的调用栈示意图

7.9.3 fastcall调用规约

fastcall的声明语法为:

int fastcall func (int x,int y)

该调用约定遵循下面的规则:

参数入栈顺序:函数的第一个和第二个参数通过ecxedx传递,剩余参数从右到左入栈

还原堆栈者:被调用者修改堆栈

函数名自动加前导的下划线,后面紧跟一个@符号,其后紧跟着参数的尺寸

fastcall声明执行的函数,具有较快的执行速度,因为部分参数通过寄存器来进行传递的。

int __fastcall func3(int x, int y,int z)

{

         return x+y+z;

}

 

16:   int __fastcall func3(int x, int y, int z)

17:   {

004010A0 55                   push        ebp

004010A1 8B EC                mov         ebp,esp

004010A3 83 EC 48              sub         esp,48h

004010A6 53                   push        ebx

004010A7 56                   push        esi

004010A8 57                   push        edi

004010A9 51                   push        ecx

004010AA 8D 7D B8             lea         edi,[ebp-48h]

004010AD B9 12 00 00 00        mov         ecx,12h

004010B2 B8 CC CC CC CC        mov         eax,0CCCCCCCCh

004010B7 F3 AB                rep stos    dword ptr [edi]

004010B9 59                   pop         ecx

004010BA 89 55 F8             mov         dword ptr [ebp-8],edx

004010BD 89 4D FC             mov         dword ptr [ebp-4],ecx

18:       return x+y+z;

004010C0 8B 45 FC             mov         eax,dword ptr [ebp-4]

004010C3 03 45 F8             add         eax,dword ptr [ebp-8]

004010C6 03 45 08             add         eax,dword ptr [ebp+8]

19:   }

004010C9 5F                   pop         edi

004010CA 5E                   pop         esi

004010CB 5B                   pop         ebx

004010CC 8B E5     mov        esp,ebp

004010CE 5D       pop         ebp

004010CF C2 04 00  ret 4  ;返回时,被调用者做栈平衡,x,y在寄存器中,所以清空4个字节

注意,在X64平台,默认使用了fastcall调用约定,其规则如下:

1,一个函数在调用时,前四个参数是从左至右依次存放于RCXRDXR8R9寄存器里面,剩下的参数从右至左顺序入栈;栈的增长方向为从高地址到低地址

2,浮点前4个参数传入XMM0XMM1XMM2  XMM3 中。其他参数传递到堆栈中。

3,调用者负责在栈上分配32字节的“shadow space”,用于存放那四个存放调用参数的寄存器的值(亦即前四个调用参数);小于64(bit)的参数传递时高位并不填充零(例如只传递ecx),大于64位需要按照地址传递;

4,调用者负责栈平衡;

5,被调用函数的返回值是整数时,则返回值会被存放于RAX;浮点数返回在xmm0中

6,RAXRCXRDXR8R9R10R11是“易挥发”的,不用特别保护(所谓保护就是使用前要push备份),其余寄存器需要保护。(x86下只有eax, ecx, edx是易挥发的)

7,栈需要16字节对齐,“call”指令会入栈一个8字节的返回值(注:即函数调用前原来的RIP指令寄存器的值),这样一来,栈就对不齐了(因为RCXRDXR8R9四个寄存器刚好是32个字节,是16字节对齐的,现在多出来了8个字节)。所以,所有非叶子结点调用的函数,都必须调整栈RSP的地址为16n+8,来使栈对齐。比如sub rsp,28h

8,对于 R8R15 寄存器,我们可以使用 r8, r8d, r8w, r8b 分别代表 r8 寄存器的64位、低32位、低16位和低8位。

                                                                                     X64调用栈示意图

 

使用了上述调用约定的main函数对应汇编分析:

int main(int argc, char* argv[])

{

 

         func1(1, 2);

         func2(1, 2);

         func3(1, 2);

         printf("Hello World!\n");

         return 0;

}

//func1采用stdcall,参数2,1从右往左,依次入栈:先push 2,push 1

//栈平衡由被调用者自己完成

24:       func1(1, 2);

004010F8 6A 02                push        2

004010FA 6A 01                push        1

004010FC E8 1D FF FF FF         call         @ILT+25(func1) (0040101e)

 

//func2采用cdecl,参数从右往左依次入栈,先push 2,push 1

//栈平衡由调用者负责完成:add esp, 8

25:       func2(1, 2);

00401101 6A 02                push        2

00401103 6A 01                push        1

00401105 E8 0F FF FF FF         call          @ILT+20(func2) (00401019)

0040110A 83 C4 08             add          esp,8//栈平衡在调用者这边完成

 

//func3采用fastcall,参数前2个进入ecxedx,剩下的从右往左依次入栈

//栈平衡由被调用者完成

26:       func3(1, 2, 3);

0040110D 6A 03                push         3;第三个参数入栈

0040110F BA 02 00 00 00         mov         edx,2;第二个参数入edx

00401114 B9 01 00 00 00         mov         ecx,1;第一个参数入ecx

00401119 E8 0A FF FF FF          call          @ILT+35(func3) (00401028)

7.9.4 naked call调用规约

这是一个不常用的调用约定,编译器不会给这种函数增加初始化和清理代码,也不能用return语句返回值,只能程序员控制,插入汇编返回结果。因此它一般用于驱动程序设计,比如inline hook等。假设定义一个求减的减法程序,可以定义为:

 

__declspec(naked) int sub(int a,int b)

{

   __asm mov eax,a

   __asm sub eax,b

   __asm ret

}

 

上面讲解了函数的各种调用规约。那么如果定义的约定和使用的约定不一致,会出现什么样的问题呢?结果就是:则将导致栈被破坏。最常见的调用规约错误是:

1. 函数原型声明和函数体定义不一致

2. DLL导入函数时声明了不同的函数约定

 

此外,thiscallC++类成员函数缺省的调用约定,但它没有显示的声明形式。因为在C++类中,成员函数调用还有一个this指针参数,因此必须特殊处理,thiscall意味着:

参数入栈:参数从右向左入栈

this指针入栈:如果参数个数确定,this指针通过ecx传递给被调用者;如果参数个数不确定,this指针在所有参数压栈后被压入堆栈。

堆栈恢复:对参数个数不定的,调用者清理堆栈,否则函数自己清理堆栈。

7.9.5 C语言活动记录(栈帧)

下面来研究C语言的活动记录,即它的栈帧。所谓的活动记录,就是在程序执行的过程中函数调用时栈上的内容变化。一个函数被调用,反映在栈上的与之相关的内容被称为一个帧,其中包含了参数,返回地址,老ebp值以及局部变量,以及espebp

图就是程序执行时的一个活动记录。C语言的默认调用规约为cdecl。因此C语言的活动记录中,参数是从右往左依次入栈。之后是函数的返回地址入栈,接着是ebp入栈。

 

 

                                                                 7-5 C语言活动记录

7-5非常重要,建议读者朋友们一定要对该图做到胸有成竹。可以用图7-5来分析很多实际问题。比如,可以用ebp+8取得第一个参数,然后依次取得第二个,第三个,第N个参数。也可以通过ebp-N来获得栈中的局部变量。

         1.分析下面程序运行情况,有什么问题呢?

 

1 #include <stdio.h>

 

2 void main(void)

3 {

4    char x,y,z;

5    int i;

6    int a[16];

 

7    for(i=0;i<=16;i++)

8    {

9        a[i]=0;

10       printf("\n");

11   }

12   return 0;

13 }

 

在分析程序执行时,一个重要的方法就是首先画出它的活动记录。根据它的活动记录,去分析它的执行。对于本题的问题,画出了图7-6的活动记录。

 

                                                     7-6 程序活动记录

结合该活动记录,通过对程序的执行分析,for循环中对数组的访问溢出了。那么溢出的后果是什么呢?通过图7-6 的活动记录,大家可以看出a[16]实际上对应的是变量i。因此循环的最后一次执行的时候,实际上a[16] = 0就是将i值重新设为了0,于是i永远也不会大于16。因此整个程序中for循环无法退出,程序陷入死循环。

 

2.考虑下面的C程序:

 

void main(void)

{

     char *cp1, *cp2;

 

     cp1 = "12345";

     cp2 = "abcdefghij";

     strcpy(cp1, cp2);

     printf("cp1 = %d\ncp2 = %s\n", cp1, cp2);

}

 

    该程序经某些C编译器的编译,其目标程序运行的结果是:

         cp1 = abcdefghij

         cp2 = ghij

    试分析,为什么cp2所指的串被修改了?

 

答案:因为常量串"12345""abcdefghij"连续分配在常数区,执行两个赋值语句后,cp1cp2分别等于字符1a所在的地址。如图7-7所示:

 

                                                        7-7 字符串拷贝时内存布局

    执行串拷贝语句后,常数区变成图中所示,cp1cp2所指的地址没有变,所以cp2所指的串被修改了。

 

3.一个C语言程序如下:

 

void func(void)

{

     char s[4];

 

     strcpy(s, "12345678");

     printf("%s\n", s);

}

void main(void)

{

     func();

     printf("Return from func\n");

}

 

该程序在X86/Linux操作系统上运行的结果如下:

         12345678

         Return from func

         Segmentation fault(core dumped)

试分析为什么会出现这样的运行错误。

答案:func()函数的活动记录如图7-8所示。在执行字符串拷贝函数之后,由于12345678”长度大于4个字节,而strcpy()并不检查字符串拷贝是否溢出,因此造成s[4]数组溢出。s[4]数组的溢出正好覆盖了老ebp的内容,但是返回地址并没被覆盖。所以程序能够正常返回。但由于老ebp被覆盖了,因此从main()函数返回后,出现了段错误。因此,造成该错误结果的原因就是func()函数中串拷贝时出现数组越界。

                                               7-8 func()函数的活动记录

 

4.下面程序在SPARC/SUN工作站(整数存放方式是高位优先)上运行陷入死循环,试说明原因。如果将第7行的long *p改成short *p,并且将第22long k改成short k后,loop中的循环体执行一次便停止了。试说明原因。

 

void main(void)

{

     addr();

     loop();

}

long *p;

 

void loop(void)

{

     long i, j;

 

     j = 0;

     for (i = 0; i < 10; i++)

     {

         (*p)--;

         j++;

     }

}

 

void addr(void)

{

     long k;

 

     k = 0;

     p =& k;

}

 

分析:

首先变量p是一个全局变量,因此在程序执行期间都有效。然后画出了addr() loop()的活动记录如图7-9所示。由addr()loop()的活动记录,可以看出,padd()执行结束之后,在loop()执行之时指向了i

                                                                 7-9 addr()loop()活动记录

当第一次执行循环时,i=0p的类型为long *(*p)--之后i-1,之后i++i0。所以i的值在0-1之间变化,但始终小于10,因此无法退出循环而造成了死循环。

而当p的类型为short*kshort类型时,p指向了i的高2字节。(*p)--运算前后i的值的情况如图7-107-11所示:

 

 


                                                        7-10 (*p)--之前i的值

 

                                                                 7-11 (*p)--之后i的值

    由于此时p short*类型,所以只有i的高2字节参加了运算,此时(*p)--后,i的高2字节为-1,即0xffff。所以对于long类型的i来说,由于系统是高位优先存储整数,那么它的值为:0x0000ffff,即4294901760,远远大于了10。因此循环执行了一次便停止了。

综上分析,程序运行时陷入死循环的原因是由于p指向分配给i的存储单元引起的。循环体执行一次便停止是由于p指向分配给i的高位引起的。可见,要解答好此题,必须要牢固掌握C语言的活动记录和整数的存储方式。

 

5.试分析下面程序在X86平台的输出结果:

//限定x86平台

#include "stdafx.h"

#include <windows.h>

#include <string.h>

 

int _tmain(int argc, _TCHAR* argv[])

{

    int x=5;

    float y=3.1f;

 

    printf("int x=%d,float x=%f,y=%f,y=%d\n",x,x,y,y);//注意,第二次打印x的时候,x被当做了float打印

 

    return 0;

}

输出为:int x=5,float x=-2.000000,y=-2.000000,y=1074318540

为什么y被赋值为3.1f,打印的结果却是-2.000000呢?

 

分析请参考:函数调用约定解释诡异程序输出

周哥教IT,分享编程知识,提高编程技能,程序员的充电站。跟着周哥一起学习,每天都有进步。

通俗易懂,深入浅出,一篇文章只讲一个知识点。

当你决定关注「周哥教IT」,你已然超越了90%的程序员!

IT黄埔-周哥教IT技术交流QQ群:213774841,期待您的加入!

二维码
微信扫描二维码关注