调用约定_CSDN

原帖:http://blog.csdn.net/zjwoody/article/details/7887988


    在windows平台上的C++编程中经常会看到一些__stdcall, __cdecl, WINAPI, CALLBACK等等关键字在函数前面,在.NET中还有__clrcall, __thiscall等关键字,有时加不加它们都可以,但是有时必须加上,不然编译不过。本文要讨论的就是这些关键字:调用约定(Calling Convention),有时也叫做“函数调用约定”或者“调用规范”。本文采用MSDN的官方翻译:“调用约定”。

    那什么是调用约定呢?首先让我们看看一个函数调用到底需要经历哪几个过程,编译器到底为我们做了些什么。 
1. 把函数的参数压栈或者储存到寄存器 
2. 跳转到函数 
3. 把函数使用到的一些寄存器压栈 
4. 执行函数 
5. 处理函数返回值 
6. 对于第3步中压栈的那些寄存器,恢复它们原来的值 
7. 根据不同的调用约定,清除第1步中压栈的参数,然后返回,或者先返回然后清除。 

    可以看到第6步是第3步的逆操作,而第7步是第1,2步的逆操作,调用约定主要是定义了第1,7步骤中的规则:怎么去传递参数,谁负责去清除栈上的参数。

    在正式开始介绍各种调用约定之前,有必要说明一下:这些调用约定是和编译器相关的,所以这些关键字前都有两个下划线,不同的编译器有不同的实现。比如VC和C++ Builder对于__fastcall的定义很不一样,以至于C++ Builder引入了__msfastcall关键字来和VC的__fastcall兼容。本文将要介绍的是VC的各种调用约定,文中所有的代码在Windows 2003, Visual Studio2005中测试通过,反编译工具使用的是VS2005和WinDbg。(代码被编译成debug版本。因为在release版本中,编译器会作代码优化)


几乎我们写的每一个WINDOWS API函数都是__stdcall类型的,首先,需要了解两者之间的区别:WINDOWS的函数调用时需要用到栈(STACK,一种先入后出的存储结构)。当函数调用完成后,栈需要清除,这里就是问题的关键,如何清除?如果我们的函数使用了_cdecl,那么栈的清除工作是由调用者,用COM的术语来讲就是客户来完成的。这样带来了一个棘手的问题,不同的编译器产生栈的方式不尽相同,那么调用者能否正常的完成清除工作呢?答案是不能。如果使用__stdcall,上面的问题就解决了,函数自己解决清除工作。所以,在跨(开发)平台的调用中,我们都使用__stdcall(虽然有时是以WINAPI的样子出现)。那么为什么还需要_cdecl呢?当我们遇到这样的函数如fprintf()它的参数是可变的,不定长的,被调用者事先无法知道参数的长度,事后的清除工作也无法正常的进行,因此,这种情况我们只能使用_cdecl。到这里我们有一个结论,如果你的程序中没有涉及可变参数,最好使用__stdcall关键字。


1. __cdecl 
    这个是Visual C++中最最常用的调用约定,但是在代码里并不常见。为什么呢?原因就是它太常用了,VC把它作为了默认值,也就是说一个函数如果不声明任何的调用约定,那这个函数用的就是__cdecl。下面两句是等同的。
void f(int x); 
void __cdecl f(int x); 
现在让我们看看编译器到底怎么实现这种调用约定的。假设我们现在编译下面这段代码: 

   
   
   
   
// 调用函数f1 f1(1, 2, 3, 4); // 函数f1的实现 int __cdecl f1( int a, int b, int c, int d) { return a + b + c + d; }

编译后的反汇编是: 
;调用函数f1,4个参数分别是1,2,3和4 
00401093 push 4 ;参数从右到左开始压栈,先压最后一个 
00401095 push 3 ;第3个参数压栈 
00401097 push 2 ;第2个参数压栈 
00401099 push 1 ;第1个参数压栈 
0040109B call f1 (401005h) ;调用函数f1 
004010A0 add esp,10h ;清除栈上的4个参数 

;函数f1的实现 
push ebp ;保存寄存器ebp 
mov ebp,esp ;将当前栈指针赋值给ebp 
mov eax,dword ptr [ebp+8] ;eax为参数a 
add eax,dword ptr [ebp+0Ch] ;eax = eax + 参数b 
add eax,dword ptr [ebp+10h] ;eax = eax + 参数c 
add eax,dword ptr [ebp+14h] ;eax = eax + 参数d 
pop ebp ;恢复寄存器ebp的值 
ret ;函数返回,返回值是eax 

可以看到清除参数的工作是由caller(调用者,就是调用函数f1的地方)来负责。因为我们一共有4个int的参数,每个int是 4个byte,一共16个byte,换算成16进制是10h,所以上面粗体的反汇编(add esp,10h),通过直接把esp加10h来清除4个参数。(esp是指向栈顶的寄存器)

如果上面的反汇编有困难的话,可以记住这么一句话:__cdecl是由调用者来清除栈上的参数。


2. __stdcall 
    这个调用约定的使用也十分广泛,这也就是为什么它的名字是stdcall(standard call,标准调用)。WINAPI, CALLBACK实际上都是定义成__stdcall。Windows的大多数API函数都是采用这种调用约定。

;调用函数f2,4个参数分别是1,2,3和4 
push 4 ;和f1一样 
push 3 ;和f1一样 
push 2 ;和f1一样 
push 1 ;和f1一样 
call f2 (40100Ah) 

;函数f2的实现 
push ebp ;和f1一样 
mov ebp,esp ;和f1一样 
mov eax,dword ptr [ebp+8] ;和f1一样 
add eax,dword ptr [ebp+0Ch] ;和f1一样 
add eax,dword ptr [ebp+10h] ;和f1一样 
add eax,dword ptr [ebp+14h] ;和f1一样 
pop ebp ;和f1一样 
ret 10h ;函数返回,返回值是eax,并清除栈上的参数 

    通过比较,我们可以立刻发现__stdcall和__cdecl的反汇编有两个不同点: 
    a. __stdcall函数返回的时候使用了“ret 10h”,而__cdecl使用的是“ret”,这表明__stdcall函数在返回的时候就清除了4个参数(大小为10h),这个是函数实现部分来做的,而不是由调用者来做
    b. 正因为函数本身已经清除了栈上的参数,调用者不需要在"call f2"之后再使用“add esp,10h”了。 
    可以看到__stdcall把函数返回和清除栈上函数合二为一,用一句“ret xxx”搞定,比__cdecl方便很多,那为什么不全部使用__stdcall呢?

    这是因为 __stdcall有一个不足之处:它不能使用于那些可变参数个数的函数,比如printf, sprintf没有办法使用__stdcall。因为函数本身不知道每次调用时到底有几个参数,所以它无法确定ret后面的数字,这项工作只能让调用者自己去做。因此类似于printf, sprintf的函数都是使用__cdecl。注意:在VS2005中,如果给可变参数个数的函数用了__stdcall关键字,编译器不会报错,但是它实际上还是按照__cdecl调用约定进行编译,通过查看反汇编,然后和前面列出的反汇编进行比较,就会发现它用的是__cdecl。


3. __fastcall 
    在VC中这种调用约定和前两种比较起来,使用的比较少。(Borland C++的默认调用约定就是这个,但是和VC的实现有点不同。)还是让我们先看看编译器的工作。

   
   
   
   
// 调用函数f3 f3(1, 2, 3, 4); // 函数f3的实现 int __fastcall f3( int a, int b, int c, int d) { return a + b + c + d; }



;调用函数f3,4个参数分别是1,2,3和4,前两个参数放在ecx和edx寄存器中,后两个压栈 
push 4 ;参数从右到左开始压栈,先压第4个参数 
push 3 ;第3个参数压栈 
mov edx,2 ;第2个参数放在edx寄存器中 
mov ecx,1 ;第1个参数放在ecx寄存器中 
call f3 (401014h) ;调用函数 

;函数f3的实现 
push ebp ;和f1,f2一样 
mov ebp,esp ;和f1,f2一样 
sub esp,8 ;在栈上空出8个byte的空间,用来存放两个int的临时变量 
mov dword ptr [ebp-8],edx ;把第2个参数(edx)放到第2个变量 
mov dword ptr [ebp-4],ecx ;把第1个参数(ecx)放到第1个变量 
mov eax,dword ptr [ebp-4] ;eax = 第1个变量(第1个参数) 
add eax,dword ptr [ebp-8] ;eax = eax + 第2个变量(第2个参数) 
add eax,dword ptr [ebp+8] ;eax = eax + 参数c 
add eax,dword ptr [ebp+0Ch] ;eax = eax + 参数d 
mov esp,ebp ;清除临时变量 
pop ebp ;和f1,f2一样 
ret 8 ;函数返回,返回值是eax,并清除栈上的参数 

从上面最后一行反汇编"ret 8"可以看到,__fastcall和__stdcall一样,也是函数本身来清除栈上的参数,这也就意味着__fastcall也有__stdcall的缺点:不支持可变参数个数的函数。
和__stdcall不同的是,__fastcall把第一,第二个参数放到了寄存器中,而不是压栈,因为寄存器的读写速度比栈快很多,这也就是为什么它叫快速调用(fast call。注意:在VC中的某些情况下,__fastcall比__stdcall和__cdecl慢)。
再介绍其他调用约定之前,让我们先回顾一下__cdecl,__stdcall和__fastcall。并且补充一些它们之前的区别(这些区别不太重要,所以上面没有讨论,只在这里列出) 

  __cdecl __stdcall __fastcall
 压栈顺序 从右到左 从右到左 从右到左,前两个参数放在ecx, edx
谁清除栈上参数 调用者(caller) 函数(被调用者callee) 函数(被调用者callee)
默认调用约定的编译器参数 /Gd /Gz /Gr
可变参数个数的函数 支持 不支持 不支持
C的函数名修饰规范Name-decoration convention 加下划线前缀,如:_func 下划线开头,函数名,然后@符号,最后是参数的总byte数。如:int f(int a, double b ),名字为_f@12 以@开头,其他和__stdcall一样。如:@f@12

4. 一些过时的调用约定 
    __pascal, __fortran 和__syscall是三种已经过时的调用约定,MSDN的建议是使用WINAPI宏,也就是__stdcall来代替原来的PASCAL和 __far __pascal。

5. thiscall 
    在VS2005之前,这种调用约定仅仅应用于C++的成员函数:把this指针存放于CX寄存器,参数从右到左压栈,函数运行后,由函数来负责清除参数。我们前面已经讨论过由函数本身来清除参数的缺点:不支持可变参数个数的函数。所以对于那些可变参数个数的成员函数,C++使用的还是__cdecl调用约定。如下面这个class 
  
  
    f()成员函数使用的是thiscall调用约定,而v()成员函数使用的是__cdecl调用约定。还有一点需要注意,在VS2005之前,thiscall不能在程序中指定,因为它不是C++关键词。
在VS2005里,包括以后的VS版本中,__thiscall可以在托管VC程序中指定,它表明函数可以被原生代码调用。 

6. __clrcall 
    看名字CLR call就知道这个调用约定和.NET Framework有关系,的确,使用__clrcall调用约定表明函数只能被托管代码(managed code)调用。如果您有一些VC++.NET的经验,可能会想:函数不是既可以被托管代码调用也可以被非托管代码(unmanaged code)调用吗?为什么要指定它只能被托管代码调用呢?
为了解释这个问题,必须介绍Double Thunking问题。先看下面一段代码,然后想想运行后会打印出什么结果。 

   
   
   
   
#include <stdio.h> struct T { T(){}; T( const T&) { printf("copy constructor\n"); } ~T() { printf("destructor\n"); } }; struct S { virtual void f(T t) {}; } s; int main() { S* pS = &s; T t; printf("BEGIN\n"); pS->f(t); printf("END\n"); }
    
    
    想好运行结果了吗?想好后让我们新建一个C++项目,输入这段代码,然后编译运行。有一点需要注意,我们先使用“No Common Language Runtime support”编译选项,这个选项告诉编译器按照我们以前的C++风格(比如:VC6,非托管的C++)进行编译。 
    (图片已丢失)


看到运行结果了吗? 

   
   
   
   
BEGIN copy constructor destructor END destructor

 请注意BEGIN和END之间的输出:一个"copy constructor"和一个"destructor",现在让我们改变一下刚才那个编译选项,改成"Common Language Runtime Support(/clr)"[1],然后再编译,运行,看到运行结果了吗? 
 

   
   
   
   
BEGIN copy constructor copy constructor destructor destructor END destructor
    竟然有了两个"copy constructor"和两个"destructor",在BEGIN和END之间明明只有一个函数调用,为什么会有两个copy constructor?这个就是由Double Thunking 引起的问题[3]。(题外话:以后各位面试C++职位的时候要看清题目了,在不同的编译环境下,有很大的不同)

    当我们使用/clr选项(不是/clr:pure)进行编译的时候,一个托管函数(managed function),会导致编译器生成一个托管的入口点(managed entry point)和一个原生的入口点(native entry point),这样可以使得托管函数既可以被托管代码调用,也可以被原生代码调用。但是,当一个原生的入口点存在的时候,它将成为所有调用的入口点。也就是说如果调用者是托管的,它还是会先去调用原生入口点,然后原生的入口点再去调用托管的入口点,这就意味着调用了两次函数入口点(Double Thunking)。 
为了解决这个问题,VS2005中引入了__clrcall调用约定,它表明一个函数只能被托管代码调用,这样编译器就不会生成原生的入口点了,也就不会有Double Thunking问题了。让我们把
struct S { 
virtual void f(T t) {}; 
} s; 
改成 
struct S { 
virtual void __clrcall f(T t) {}; 
} s; 
然后编译运行,是不是就没有Double Thunking问题了? 
VC2005里还有一个编译选项"Pure MSIL Common Language Runtime Support (/clr:pure)",它会把所有的函数都按照__clrcall调用约定来编译。

7. Naked 函数调用 
这是VC 里一种给高级用户使用的调用约定,它实际上就是没有规范,用户可以通过内嵌汇编来实现任意想要得调用约定。由于我们平时编程时基本上不会去使用它,所以我在这里不再展开了。可以参考[2]。
我们已经迎来了64bit时代,虽然大多数人装的操作系统还是32bit,用的还是32bit的软件,玩的还是32bit的游戏,但是我们的技术知识应该开始向64bit延伸了。调用约定在64bit的CPU上有些变化,总体来说64bit的调用约定和__fastcall类似,主要通过寄存器来传递参数。大家可以在intel网站上得到帮助信息。
参考文献: 
[1] Common Language Runtime Compilation. http://msdn2.microsoft.com/en-us/library/k8d11d4s(VS.80).aspx
[2] Naked Function Calls. http://msdn2.microsoft.com/en-us/library/5ekezyy2(VS.80).aspx
[3] Double Thunking. http://msdn2.microsoft.com/en-us/library/ms235292(VS.80).aspx

你可能感兴趣的:(调用约定_CSDN)