代码开发运行环境: VS2017+Win32+Debug
实现函数调用,除了需要根据函数名称获取函数的入口地址外,还要向函数传递合适的参数以及结束时清理堆栈。这些可以有不同的实现方式,为了能够让函数主调方顺利完成对被调方的调用,二者需要遵守相同的约定,这样的约定被称为调用约定(Calling Convention)或调用规范。C/C++中常见的调用约定有__cdecl、__stdcall、__fastcall和__thiscall。
称为 C 调用约定,是 C/C++ 默认的函数调用约定,语法是:
int function (int a ,int b) // 不加修饰就是C调用约定
int __cdecl function(int a,int b) // 明确指出C调用约定
约定的内容有:
(1)参数从右向左入栈;
(2)在被调用函数 (Callee) 返回后,由调用方 (Caller)调整堆栈;
(3)函数名修饰规则:下划线+函数名。
C调用约定允许函数的参数个数不固定,这也是C语言的一大特色,因为每个调用的地方都需要生成一段清理堆栈的代码,所以最后生成的目标文件较__stdcall、__fastcall调用方式要大。
称为标准调用约定,语法是:
int __stdcall function(int a,int b)
约定的内容有:
(1)参数从右向左入栈;
(2)函数自身清理堆栈;
(3)函数名修饰规则:下划线+函数名+@+参数的字节数。如函数int foo(int a,double b)的修饰名是_func@12。
称为快速调用方式,语法是:
int __fastcall function(int a,int b);
和 __stdcall 类似,它约定的内容有:
(1) 函数的第一个和第二个DWORD(4字节)参数(或者尺寸更小的)通过ecx和edx传递,其他参数从右向左入栈;
(2)被调用者清理堆栈;
(3)函数名修饰规则:@+函数名+@+参数的字节数。
注意,不同编译器编译的程序规定的寄存器不同。在 Intel 386 平台上,使用 ECX 和 EDX 寄存器。使用__fastcall 方式无法用作跨编译器的接口。
__thiscall 用于C++类成员函数的调用约定。因为 __thiscall 不是关键字,所以不能被显示指明。由于成员函数调用还有一个this指针,因此必须特殊处理。__thiscall 在不同平台有着不同的实现。在Visual C++中,this 指针存放于ecx寄存器,参数从右向左入栈。在 GNU C++ 中,__thiscall
和 __cdecl
完全一样,只是将 this 看作函数的第一个参数。
在Visual C++的函数调用规范中,如果函数的任何一个参数表达式包含自增(自减)运算,所有这些运算会在第一个push操作之前全部完成,然后再完成其他的运算并将结果入栈。考察如下程序。
#include
using namespace std;
int main(int argc,char* argv[])
{
int i=10;
cout<<++i<<--i<
按照正常思维,标准输出操作符 << 是从左向右结合的,所以应该依次计算表达式 ++i,–i 和 i++ 的值,那么最终应该依次输出11,10,和10。但是在 Visual C++ 中运行结果是 11,11 和 10。考察此程序的汇编代码,发现语句 cout<<++i<<--i< 所对应的汇编代码是:
00EF6ED5 mov eax,dword ptr [i]
00EF6ED8 mov dword ptr [ebp-0D0h],eax //保存i的值
00EF6EDE mov ecx,dword ptr [i]
00EF6EE1 add ecx,1 //变量i自增1
00EF6EE4 mov dword ptr [i],ecx
00EF6EE7 mov edx,dword ptr [i]
00EF6EEA sub edx,1 //变量i自减1
00EF6EED mov dword ptr [i],edx
00EF6EF0 mov eax,dword ptr [i]
00EF6EF3 add eax,1 //变量i自增1
00EF6EF6 mov dword ptr [i],eax
00EF6EF9 mov esi,esp
00EF6EFB mov ecx,dword ptr [ebp-0D0h]
00EF6F01 push ecx //将保存的数值10入栈
00EF6F02 mov edi,esp
00EF6F04 mov edx,dword ptr [i]
00EF6F07 push edx //将变量i入栈
00EF6F08 mov ebx,esp
00EF6F0A mov eax,dword ptr [i]
00EF6F0D push eax //将变量i入栈
//获取cout对象地址,this指针通过ecx传递
00EF6F0E mov ecx,dword ptr [_imp_?cout@std@@3V?$basic_ostream@DU?$char_traits@D@std@@@1@A (0F002E0h)]
//cout<<++i;
00EF6F14 call dword ptr [__imp_std::basic_ostream >::operator<< (0F002E8h)]
00EF6F1A cmp ebx,esp
00EF6F1C call __RTC_CheckEsp (0EF12DFh)
//cout<<--i;
00EF6F21 mov ecx,eax
00EF6F23 call dword ptr [__imp_std::basic_ostream >::operator<< (0F002E8h)]
00EF6F29 cmp edi,esp
00EF6F2B call __RTC_CheckEsp (0EF12DFh)
//cout< >::operator<< (0F002E8h)]
00EF6F38 cmp esi,esp
00EF6F3A call __RTC_CheckEsp (0EF12DFh)
这段汇编代码比较复杂,先解释关键的地方。首先,虽然<<运算符是从左向右结合,但在<<运算符构成的链式操作中,各表达式的入栈顺序还是从右向左,只有这样才能实现<<运算从左向右进行。所以,先计算的是表达式 i++ 的值。因为 i 自增之后无法提供入栈的值,所以另外开辟了一个内存单元 dword ptr [ebp-0D0h]
来存放第一个入栈的表达式的值。
接着计算 --i
的值,自减运算完成之后,编译器认为i的值可以直接作为参数入栈,所以并没有开辟别的内存单元存放这一个入栈参数的值。
再接下来计算++i情形跟计算- -i类似。这些操作完成之后,分别将 dword ptr [ebp-0D0h]
处的值、最终的i和i入栈。再三次调用 cout.operator<<
函数将它们输出。所以程序的最终结果是11,11,10。
汇编代码中cmp ebx,esp
和call __RTC_CheckEsp (0EF12DFh)
表示VC编译器提供了运行时刻对程序正确性/安全性的一种动态检查,可以在项目属性的C++选项中打开来启用Runtime Check。开启与打开步骤如下图:
在程序中 cout.operator<<
执行完后,会将对象 cout 的地址存放在寄存器 eax 中作为该函数的返回值。由于在 Visual C++中,调用对象的成员函数之前会先将对象的地址存放在寄存器 ecx 中,所以在下一次调用cout.operator<<
之前,会先将 eax 的值送入 ecx 中。
如果生成 Release 版本,发现输出结果变成10,10和10。这是编译器对代码所做的优化导致的结果。
从上面的程序中,我们可以看出,自增(自减)运算虽然可以使表达式更为紧凑,但很容易带来副作用。过分追求小的技巧正式很多程序缺陷的缘由,应该编写哪些可读性较好的代码,避免那些看似简单但蕴藏危机的表达式。
假设 i 的值是 10,执行语句 i=i++;
之后,i 的值是多少呢?其实,这样的代码在不同的编译器中有着不同的实现,输出结果是不一样的,所以,尽量避免编写类似存在二义性的代码。
[1] 陈刚.C++高级进阶教程[M].武汉:武汉大学出版社,2008. C3.2函数参数是如何传递的.P94-P97
[2] 百度百科.__stdcall
[3] 百度百科.__cdecl
[4] 程序员的自我修养[M].机械工业出版社.C10.2.2调用惯例.P293-P299