65.9K
CodeProject 正在变化。 阅读更多。
Home

深入了解调用约定

starIconstarIconstarIcon
emptyStarIcon
starIcon
emptyStarIcon

3.26/5 (17投票s)

2005年2月1日

CPOL

6分钟阅读

viewsIcon

50069

downloadIcon

334

不同调用约定之间的区别。

引言

大家好!在这篇文章中,我将尝试解释所有的调用约定,它们的优缺点。我还会解释为什么C语言可以传递可变数量的参数而C++不能,以及介绍快速调用(fast call)。

背景

了解汇编编程会有帮助,但我会尽量解释清楚,所以不用担心,放松就好。

使用代码

所以,相信你们在职业生涯中一定遇到过“调用约定”这个术语。如果没有,那么请准备好应对运行时错误,例如函数调用之间 ESP 值的保存不当。那么,这到底是怎么回事呢?别担心,你的所有疑问都会得到解答。为什么 C 语言可以向函数传递可变数量的参数,而 C++ 不能?什么是快速调用?准备好了吗?

我们将从 **Cdecl 调用约定** 或 C 声明风格开始。请看下面的简单代码。

#include "stdio.h"

int __cdecl Function(int a, int b, int c)
{
    return a + b -c;
}

void main()
{
    int r = Function(1, 2, 3);
}

只是一个简单的函数和一个 `main` 函数。现在,我们开始研究汇编。对于那些不懂汇编的人来说,别担心,托微软的福,这也不是什么难事。我们将使用 VC++ 自带的 Dumpbin 工具来反汇编代码并获取汇编。首先,使用命令行编译代码:**cl Cdecltest.c**,输出将是 *obj* 和 *exe* 文件。现在,我们将在命令行中使用 Dumpbin 工具。

dumpbin /disam Cdecltest.obj > Cdecltest.txt

并将输出重定向到一个文本文件。输出是:

Dump of file CdeclTest.obj

File Type: COFF OBJECT

_Function:
  00000000: 55                 push        ebp
  00000001: 8B EC              mov         ebp,esp
  00000003: 8B 45 08           mov         eax,dword ptr [ebp+8]
  00000006: 03 45 0C           add         eax,dword ptr [ebp+0Ch]
  00000009: 2B 45 10           sub         eax,dword ptr [ebp+10h]
  0000000C: 5D                 pop         ebp
  0000000D: C3                 ret
_main:
  0000000E: 55                 push        ebp
  0000000F: 8B EC              mov         ebp,esp
  00000011: 51                 push        ecx
  00000012: 6A 03              push        3
  00000014: 6A 02              push        2
  00000016: 6A 01              push        1
  00000018: E8 00 00 00 00     call        0000001D
  0000001D: 83 C4 0C           add         esp,0Ch
  00000020: 89 45 FC           mov         dword ptr [ebp-4],eax
  00000023: 8B E5              mov         esp,ebp
  00000025: 5D                 pop         ebp
  00000026: C3                 ret

其中 `main` 函数的前两行是序言(prolog),始终包含 Opcode 55 8B EC。在从 `main` 向函数传递参数时,我们是将它们压栈(push)的。参数从右到左压入,即 `push 3`, `push 2` 和 `push 1`,最后是执行完函数后需要返回的地址。现在看一下堆栈图。

最初,堆栈指针指向 98H,即较高的内存地址。当我们不断地 `push` 时,堆栈会变成上面所示的样子。现在,函数调用开始出现问题。如果我们假设压入了三个参数,然后 `pop` 三个条目,那么它将 `pop` 返回地址,然后它将不知道应该返回到哪个地址。所以,请看函数调用的汇编代码,并尝试理解它是如何 `pop` 元素而不干扰返回地址的。可以看到,它首先在其 `ebp`(基址指针)的地址 80H 处压栈,然后将堆栈指针移入 `ebp`。

现在看指令 `mov eax,dword ptr [ebp+8]`,也就是地址 88H,即压入的第一个元素 1。下一个指令是 `add eax,dword ptr [ebp+0Ch]`,即 ebp+0Ch 给出第二个元素,即 2,然后将它们相加。类似地,它获取第三个元素。然后它 `pops Ebp` 地址 80H,即堆栈变回图 1 中的样子,然后返回,这是正确的返回地址,即 84H。

这就是我们所需的全部汇编编程。所以,回到我们的主要讨论。抱歉让您偏离了主题。但现在有一个问题是堆栈没有清理,也就是说,我们还没有弹出压入堆栈的三个元素。所以需要有人来清理它,会是谁呢……???? 回到汇编... 在 `main` 函数中,调用之后,有一条指令 `add esp,0Ch** **,它清理了堆栈,0CH 因为压入了三个元素。如果压入了两个元素,它将是……你猜对了…… `add esp,08h`。

**就是这样。** 所以在 CDecl 调用约定中,调用函数的人主要负责清理堆栈。

StdCall (std 调用约定 __stdcall)

#include "stdio.h"

int __stdcall Function(int a, int b, int c)
{
    return a + b -c;
}

void main()
{
    int r = Function(1, 2, 3);
}

同样,让我们看一下汇编代码:**cl stdcalltest.c** 然后 **dumpbin /disasm stdcalltest.obj > stdcalltest.txt**。

Dump of file StdcallTest.obj

File Type: COFF OBJECT

_Function@12:
  00000000: 55                 push        ebp
  00000001: 8B EC              mov         ebp,esp
  00000003: 8B 45 08           mov         eax,dword ptr [ebp+8]
  00000006: 03 45 0C           add         eax,dword ptr [ebp+0Ch]
  00000009: 2B 45 10           sub         eax,dword ptr [ebp+10h]
  0000000C: 5D                 pop         ebp
  0000000D: C2 0C 00           ret         0Ch
_main:
  00000010: 55                 push        ebp
  00000011: 8B EC              mov         ebp,esp
  00000013: 51                 push        ecx
  00000014: 6A 03              push        3
  00000016: 6A 02              push        2
  00000018: 6A 01              push        1
  0000001A: E8 00 00 00 00     call        0000001F
  0000001F: 89 45 FC           mov         dword ptr [ebp-4],eax
  00000022: 8B E5              mov         esp,ebp
  00000024: 5D                 pop         ebp
  00000025: C3

从汇编代码可以看出,**在 stdcall 中,由被调用的函数清理堆栈**。也就是说,函数最后一行的 `ret 0Ch`。

所以,如果你使用一个函数,它被调用了二十次,那么清理代码将只在被调用的函数中出现一次,如果使用了 `__stdcall`。但如果使用了 `__cdecl`,那么在代码中就会出现二十次,也就是说,在 `main` 函数中每次函数调用之后都会出现。如果我们有一个文件包含五十个函数,每个函数被调用二十次,那么 CDecl 中 EXE 的大小将会很大。但是,`__cdecl` 的优势是什么呢……这只有 C 语言有,而 C++ 甚至都没有。

**在 __cdecl 调用约定中,你可以传递可变数量的参数**。请记住省略号 (...)。但这在 C++ 使用的 `__stdcall` 中是不可能的。我们来看看如何实现。正如在 `__stdcall` 中看到的,函数清理代码只在函数中出现一次,并且值是 `ret 传入的参数数量。也就是说,如果传递了三个参数,那么就是 0Ch,这是固定的,所以我们不能向函数传递可变数量的参数。而在 `__cdecl` 中,清理代码的次数与函数被调用的次数相同,所以可以进行清理,因为它知道每次传递的参数数量。明白了吗?如果不明白,请参考上面的解释和汇编代码。**同时观察两种情况下汇编代码中的函数名修饰(name mangling)**。函数名以 `_` 作为前缀。但在 `__stdcall` 的情况下包含 `@somevalue`,这个值就是传入堆栈以供清理的元素数量的大小。所以,在本例中,它是……是的,你猜对了。

对于 `__cdecl`,调用 `_Function`;对于 `__stdcall`,调用 `_Function@12`。

无论如何,再举一个例子来澄清。

下面是使用 __cdecl 调用约定并且可以编译的代码。

#include "stdio.h"

int __cdecl Function(int a,...)
{
    //do some processing with all arguments
    return 1;
}

void main()
{
    int r = Function(1, 2, 3);
}

下面是使用 __stdcall 调用约定但无法编译的代码。

#include "stdio.h"

int __stdcall Function(int a, ...)
{
    //do some processing with all arguments
    return a;
}

void main()
{
    int r = Function(1, 2, 3);
    int x = Function(1, 2);
}

**最后但同样重要的是,快速调用(fast call)。** 让我们看一下汇编代码。

#include "stdio.h"

int __fastcall Function(int a, int b, int c)
{
    return a + b -c;
}

void main()
{
    int r = Function(1, 2, 3);
}
Microsoft (R) COFF Binary File Dumper Version 6.00.8168
Copyright (C) Microsoft Corp 1992-1998. All rights reserved.


Dump of file FastCallTest.obj

File Type: COFF OBJECT

@Function@12:
  00000000: 55                 push        ebp
  00000001: 8B EC              mov         ebp,esp
  00000003: 83 EC 08           sub         esp,8
  00000006: 89 55 F8           mov         dword ptr [ebp-8],edx
  00000009: 89 4D FC           mov         dword ptr [ebp-4],ecx
  0000000C: 8B 45 FC           mov         eax,dword ptr [ebp-4]
  0000000F: 03 45 F8           add         eax,dword ptr [ebp-8]
  00000012: 2B 45 08           sub         eax,dword ptr [ebp+8]
  00000015: 8B E5              mov         esp,ebp
  00000017: 5D                 pop         ebp
  00000018: C2 04 00           ret         4
_main:
  0000001B: 55                 push        ebp
  0000001C: 8B EC              mov         ebp,esp
  0000001E: 51                 push        ecx
  0000001F: 6A 03              push        3
  00000021: BA 02 00 00 00     mov         edx,2
  00000026: B9 01 00 00 00     mov         ecx,1
  0000002B: E8 00 00 00 00     call        00000030
  00000030: 89 45 FC           mov         dword ptr [ebp-4],eax
  00000033: 8B E5              mov         esp,ebp
  00000035: 5D                 pop         ebp
  00000036: C3                 ret

在快速调用的情况下,从 `main` 的汇编代码可以看出,只压入了一个变量,而两个变量是通过寄存器传递的。也就是说,在快速调用的情况下,第一个和第二个参数通过寄存器传递,其余的按常规方式,即通过堆栈传递。由于使用了寄存器传递,速度更快,但最多只能通过寄存器传递两个参数。还要注意名称修饰,函数以 `@` 开头并包含传递的参数数量,也就是说,在本例中是 ` @Function@12`。

快速回顾

调用约定 堆栈清理责任 名称修饰 优点
__stdcall 被调用函数(即清理代码仅一次) _FunctionName@4* 传递的参数 EXE 体积小
__cdecl 调用函数(每次调用函数时) _Function 可以传递可变数量的参数
__fastcall 被调用函数(即清理代码仅一次) @FunctionName@4* 传递的参数 通过使用寄存器实现快速调用

注意

我想感谢 Sameer Vasani 先生,我的团队,以及我的朋友 Rahul Bhamre,我从他们身上学到了很多,并且仍在学习新东西。

© . All rights reserved.