函数调用约定就是函数调用者和被调用者之间的约定。函数调用约定决定了参数怎么传递以及由谁来平衡堆栈。
不同的编译器实现不一样。调用约定不显示指定的话默认是__cdecl。
这里主要讨论__cdecl和__stdcall
- 共同点:__cdecl和__stdcall参数入栈顺序都是从右到左
- 不同点:
- __cdecl由调用者清理堆栈,__stdcall由被调用者清理堆栈。
- 由于条件1的关系,约定是__cdecl的函数的参数可以是不定参数,约定是__stdcall的函数的参数不能是不定参数。
先看一个demo了解可变参数的实现:
#include <windows.h>
#include <tchar.h>
#include <stdio.h>
int CDECL MessageBoxPrintf(TCHAR * szCaption, TCHAR * szFormat, ...)
{
TCHAR szBuffer[1024];
// typedef char* va_list;
va_list pArgList;
// The va_start macro (defined in STDARG.H) is usually equivalent to:
// pArgList = (char *) &szFormat + sizeof (szFormat) ;
wprintf(L"pArgList= %d", &szFormat);
va_start(pArgList, szFormat);
wprintf(L"pArgList= %d", pArgList);
// The last argument to wvsprintf points to the arguments
_vsntprintf(szBuffer, sizeof(szBuffer) / sizeof(TCHAR), szFormat, pArgList);
// The va_end macro just zeroes out pArgList for no good reason
va_end(pArgList);
return MessageBox(NULL, szBuffer, szCaption, 0);
}
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
PSTR szCmdLine, int iCmdShow)
{
int cxScreen, cyScreen;
cxScreen = GetSystemMetrics(SM_CXSCREEN);
cyScreen = GetSystemMetrics(SM_CYSCREEN);
MessageBoxPrintf(TEXT("ScrnSize"),
TEXT("The screen is %i pixels wide by %i pixels high."),
cxScreen, cyScreen);
return 0;
}
MessageBoxPrintf是__cdecl,参数是可变的。
如下图,在内存中,栈地址是从高地址向地地址扩展的,该demo中,TEXT2,TEXT1,szFormat,szCaption先后入栈。通过va_start(pArgList, szFormat);也就是szFormat+(szFormat)我们可以得到第一个不定参数也就是TEXT1(TEXT("ScrnSize")的地址,随后对这个地址偏移打印所有的不定参数。
为什么可变参数的函数只能用__cdecl?
这个目前还不明白。感觉是因为__cdecl是由调用者清理堆栈。猜测是__cdecl函数的调用者知道有多少个参数,所以可以正确清理堆栈。
接下来我们再来看看什么是被调用者清理堆栈,什么是调用者清理堆栈。
先看一个demo:
void __cdecl __cdecl_callee(int a, int b)
{
cout << "__cdecl, a = " << a << ", b = " << b << endl;
}
void __stdcall __stdcall_callee(int a, int b)
{
cout << "__stdcall, a = " << a << ", b = " << b << endl;
}
函数__cdecl_callee是__cdecl,函数__stdcall_callee是__stdcall,先来模拟它们的汇编实现:
void __cdecl_caller()
{
int x = 1;
int y = 2;
__asm
{
push y
push x
call __cdecl_callee
}
}
void __stdcall_caller()
{
int x = 1;
int y = 2;
__asm
{
push y
push x
call __stdcall_callee
}
}
分别运行代码发现,__stdcall_caller正常输出,__cdecl_caller则抛出如下异常:
原因是__stdcall_caller中堆栈由__stdcall_callee自己清理了。而__cdecl_caller中,调用者也就是__cdecl_caller本身没有释放堆栈,所以抛出该异常。
为了正确运行,我们对__cdecl_caller做出修改,pop x和y,程序正常输出:
void __cdecl_caller()
{
int x = 1;
int y = 2;
__asm
{
push y
push x
call __cdecl_callee
pop x
pop y
}
}
或者可以这么改,恢复esp寄存器的位置:
void __cdecl_caller_v2()
{
unsigned long dwEsp;
int x = 1;
int y = 2;
__asm
{
mov dwEsp, esp
push y
push x
call __cdecl_callee
mov esp, dwEsp
}
}
这里以VS2015编译器为例,我们来看看__stdcall和__cdecl的汇编实现有什么区别:
如下图我在这四个地方分别打断点:
汇编代码如下图:
可以看到__cdecl在__cdecl_disassembly_caller中通过add esp,8 恢复堆栈。
而__stdcall则在__stdcall_disassembly_callee中通过ret 8 恢复堆栈。
个人认为恢复堆栈可以看作esp寄存器的位置恢复,add esq,8和ret 8都是将esq的地址加上8,即sizeof(a)+sizeof(b)。
到这里总算是对__cdecl和__stdcall有个基本的认识了。
在实际开发中,函数调用的时候要看清楚调用约定。比如函数caller的参数是个__stdcall的函数指针,如果我传个__cdecl的函数的地址给caller则会出现上面的异常。