代码开发运行环境: VS2017+Win32+Debug
1.调用约定简介
实现函数调用,除了需要根据函数名称获取函数的入口地址外,还要向函数传递合适的参数以及结束时清理堆栈。这些可以有不同的实现方式,为了能够让函数主调方顺利完成对被调方的调用,二者需要遵守相同的约定,这样的约定被称为调用约定(Calling Convention)或调用规范。C/C++ 中常见的调用约定有 __cdecl、__stdcall、__fastcall 和 __thiscall。
1.1 __cdecl
__cdecl 称为 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 调用方式要大。
1.2 __stdcall
__stdcall 为标准调用约定,语法是:
int __stdcall function(int a,int b)
约定的内容有:
(1)参数从右向左入栈;
(2)函数自身清理堆栈;
(3)函数名修饰规则:下划线+函数名+@+参数的字节数
。如函数int foo(int a, double b)
的修饰名是_foo@12
。
1.3 __fastcall
__fastcall 称为快速调用方式,语法是:
int __fastcall function(int a, int b);
和 __stdcall 类似,它约定的内容有:
(1) 函数的第一个和第二个DWORD(4字节)参数(或者尺寸更小的)通过 ecx 和 edx 传递,其他参数从右向左入栈;
(2)被调用者清理堆栈;
(3)函数名修饰规则:@+函数名+@+参数的字节数
。
注意,不同编译器编译的程序规定的寄存器不同。在 Intel 386 平台上,使用 ECX 和 EDX 寄存器。使用__fastcall 方式无法用作跨编译器的接口。
1.4 __thiscall
__thiscall 用于 C++ 类成员函数的调用约定。因为 __thiscall 不是关键字,所以不能被显示指明。由于成员函数调用还有一个 this 指针,因此必须特殊处理。__thiscall 在不同平台有着不同的实现。在 Visual C++ 中,this 指针存放于 ecx 寄存器,参数从右向左入栈。在 GNU C++ 中,__thiscall
和 __cdecl
完全一样,只是将 this 看作函数的第一个参数。
2.cout<<++i<<- -i<< i++; 输出结果的讨论
在Visual C++的函数调用规范中,如果函数的任何一个参数表达式包含自增(自减)运算,所有这些运算会在第一个push操作之前全部完成,然后再完成其他的运算并将结果入栈。考察如下程序。
#include <iostream>
using namespace std;
int main(int argc,char* argv[]) {
int i=10;
cout<<++i<<--i<<i++;
return 0;
}
按照正常思维,标准输出操作符 << 是从左向右结合的,所以应该依次计算表达式 ++i,–i 和 i++ 的值,最终应该依次输出11,10 和10。但是在 Visual C++ 中运行结果是 11,11 和 10。
考察此程序的汇编代码,发现语句 cout<<++i<<--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<char,std::char_traits<char> >::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<char,std::char_traits<char> >::operator<< (0F002E8h)]
00EF6F29 cmp edi,esp
00EF6F2B call __RTC_CheckEsp (0EF12DFh)
//cout<<i++;
00EF6F30 mov ecx,eax
00EF6F32 call dword ptr [__imp_std::basic_ostream<char,std::char_traits<char> >::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