首先本文关于函数调用约定部分来自转载和整理,参考文章:
C/C++函数调用约定
函数调用约定解析
一:函数调用约定;
函数调用约定是函数调用者和被调用的函数体之间关于参数传递、返回值传递、堆栈清除、寄存器使用的一种约定;
它是需要二进制级别兼容的强约定,函数调用者和函数体如果使用不同的调用约定,将可能造成程序执行错误,必须把它看作是函数声明的一部分;
二:常见的函数调用约定;
VC6中的函数调用约定;
调用约定 堆栈清除 参数传递
__cdecl 调用者 从右到左,通过堆栈传递
__stdcall 函数体 从右到左,通过堆栈传递
__fastcall 函数体 从右到左,优先使用寄存器(ECX,EDX),然后使用堆栈
thiscall 函数体 this指针默认通过ECX传递,其它参数从右到左入栈
__cdecl是C/C++的默认调用约定; VC的调用约定中并没有thiscall这个关键字,它是类成员函数默认调用约定;(后来的VC 版本是可以使用这个关键字的)
C/C++中的main(或wmain)函数的调用约定必须是__cdecl,不允许更改;
默认调用约定一般能够通过编译器设置进行更改,如果你的代码依赖于调用约定,请明确指出需要使用的调用约定;
Delphi6中的函数调用约定;
调用约定 堆栈清除 参数传递
register 函数体 从左到右,优先使用寄存器(EAX,EDX,ECX),然后使用堆栈
pascal 函数体 从左到右,通过堆栈传递
cdecl 调用者 从右到左,通过堆栈传递(与C/C++默认调用约定兼容)
stdcall 函数体 从右到左,通过堆栈传递(与VC中的__stdcall兼容)
safecall 函数体 从右到左,通过堆栈传递(同stdcall)
Delphi中的默认调用约定是register,它也是我认为最有效率的一种调用方式,而cdecl是我认为综合效率最差的一种调用方式;
VC中的__fastcall调用约定一般比register效率稍差一些;
C++Builder6中的函数调用约定;
调用约定 堆栈清除 参数传递
__fastcall 函数体 从左到右,优先使用寄存器(EAX,EDX,ECX),然后使用堆栈 (兼容Delphi的register)
(register与__fastcall等同)
__pascal 函数体 从左到右,通过堆栈传递
__cdecl 调用者 从右到左,通过堆栈传递(与C/C++默认调用约定兼容)
__stdcall 函数体 从右到左,通过堆栈传递(与VC中的__stdcall兼容)
__msfastcall 函数体 从右到左,优先使用寄存器(ECX,EDX),然后使用堆栈(兼容VC的__fastcall)
常见的函数调用约定中,只有cdecl约定需要调用者来清除堆栈;
C/C++中的函数支持参数数目不定的参数列表,比如printf函数;由于函数体不知道调用者在堆栈中压入了多少参数,
所以函数体不能方便的知道应该怎样清除堆栈,那么最好的办法就是把清除堆栈的责任交给调用者;
这应该就是cdecl调用约定存在的原因吧;
VB一般使用的是stdcall调用约定;(ps:有更强的保证吗)
Windows的API中,一般使用的是stdcall约定;(ps: 有更强的保证吗)
建议在不同语言间的调用中(如DLL)最好采用stdcall调用约定,因为它在语言间兼容性支持最好;
三:函数返回值传递方式
其实,返回值的传递从处理上也可以想象为函数调用的一个out形参数; 函数返回值传递方式也是函数调用约定的一部分;
有返回值的函数返回时:一般int、指针等32bit数据值(包括32bit结构)通过eax传递,(bool,char通过al传递,short通过ax传递),特别的__int64等64bit结构(struct) 通过edx,eax两个寄存器来传递(同理:32bit整形在16bit环境中通过dx,ax传递); 其他大小的结构(struct)返回时把其地址通过eax返回;(所以返回值类型不是1,2,4,8byte时,效率可能比较差)
参数和返回值传递中,引用方式的类型可以看作与传递指针方式相同;
float/double(包括Delphi中的extended)都是通过浮点寄存器st(0)返回;
========================================================================
本文环境使用 VC 2013 release win32 编译,禁用优化,非特别说明都是这个环境。
debug release,是否优化,win32 和 x64 选项都对汇编有影响,一定要注意。
先看整个代码:
int __stdcall FunStdcall(int a, int b, int c)
{
return a + b + c;
}
int __cdecl FunCdecl(int a, int b, int c)
{
return a + b + c;
}
int __fastcall FunFastcall(int a, int b, int c)
{
return a + b + c;
}
int __vectorcall FunVectorcall(int a, int b, int c)
{
return a + b + c;
}
int _tmain(int argc, _TCHAR* argv[])
{
if (1)
{
int a = FunStdcall(1, 2, 3);
int b = FunCdecl(1, 2, 3);
int c = FunFastcall(1, 2, 3);
int d = FunVectorcall(1, 2, 3);
if (a < b)
{
return 1;
}
}
}
在我的VC 版本上发现了这四种约定, thiscall 不讨论。
StdCall
首先看 stdcall 调用部分,也就是 A函数调用B函数中的A。
首先参数按照从右到左的顺序依次 push 压入栈中,然后调用函数,调用结束后将 eax 的值拷贝给指针 ebp-4 处。可以猜测 ebp - 4 就是变量 a 的地址。
不妨就在这里直接看一下,在监视器里面看看:
显然,ebp-4 就是 a 的地址,那么谁能保证函数执行完的时候他们依然相等呢?
可以说,这就是函数调用约定的一部分。只有约定俗成,达成规范,才能保证这一点。
上图中也可以看到 esp 的值是 0xa30(前面部分不关心,除非刚需要关心)。执行三次 push看看:
发现 esp 变成了 0xa24,比 0xa30 减小了0xC(注意是16进制)。也正是三个 int 的大小。
也就是栈的变化是从大到小,压栈会导致栈顶地址减小。记住现在的大小 0xa24
接下来就是 call 了。
对比之前的图,发现 a esp 都变化了 ebp 没变。
a 变化的原因是这里的a 是函数里面,也就是参数 a,而不是之前的a了。同时会发现这里的a 的地址就是之前提到的 esp !
因为 push c push b puch a 三个指令执行后,当然 a 地址就是 esp。(这里 abc 说的是三个参数)。
因此这里解释了为什么 a会变化并且恰好就是之前的 esp。
至于为什么 esp 会变化?参考:
http://www.360doc.com/content/15/0602/00/12129652_474998519.shtml
也就是说,如果当次函数调用是段内偏移,Call Func 等价于:
push eip
jump Func
因此导致 esp 变化,减小了 4。现在验证一下,也就是这时候栈顶存的应该就是 eip。
可以看到 eip = 0xeb38a0。
esp = 0x 5dfa20。跳转到这个内存地址看看:
看到内容是 0xeb38fa 和 0xeb38a0 很像,为什么不一样???再仔细想一想,往回看一下:
可以看到 地址 0xeb38a0 其实是被调用函数的地址,而 0xeb38fa 是 call 之后代码的地址!而打断点的时候已经进入函数内部了,这时候的 eip (指令指针)已经变化了,随意他们不一致。
所以这里代码可以猜测一下应该是:
push 3;
push 2;
push 1;
push eip;// eip = 0xeb38fa
jmp 0xeb38a0;
// 执行函数
…
// 将 eax 的值给 a
mov dword ptr [ebp-4],eax
进入函数后执行两条指令:
这里是把 ebp 压栈保存,然后将最新的 esp 当作 ebp。
接下来三条指令就是做加法了,等效于: eax = a + b + c。
等等,这个 abc 哪里来的?之前 push 3 push 2 push 1 有啥用? 上面两条有关 ebp 的指令有啥用?
看图:
可以看到这里有个选项,再右上角,有个显示符号名。也就是 abc 其实是方便我们查看的,并不是本身就是 abc。如果不勾选:变成了 ebp + 8 (0ch/10h)了。回顾整理之前的代码:
push 3;
push 2;
push 1;
push eip;// eip = 0xeb38fa
jmp 0xeb38a0;
// 执行函数
push ebp;
mov ebp, esp;
eax = a + b + c;// 这里是用意思代表实际操作
pop ebp;
ret 0ch;
// 将 eax 的值给 a
mov dword ptr [ebp-4],eax
可以看到当前的栈顶 esp 被 ebp保存了。从栈顶往下的数据分别是:
原来ebp的值 原来eip的值 1 2 3。
因此 ebp + 8/0ch/10h 分别就是 1 2 3。
而 ebp 的一个作用也正是方便去使用参数。
接下来看只有2 条指令:
pop ebp;
ret 0ch;
参考之前的入栈操作:
push 3;
push 2;
push 1;
push eip;// eip = 0xeb38fa
push ebp;
除了做了一条 ebp 的逆操作,无法还原栈!
显然重点就在 ret 0Ch这里了。
看几张图:
可以看到 pop ebp 前后导致 esp 增加了 4。下一句指令就是 ret 0ch了,先记住当前 esp 的值:0x5dfa20
执行语句后:
可以看到 esp 变成l 0x5dfa30。刚好恢复到了 push 3 之前的状态。
也就是这一条指令做了很多事情,参考:
http://www.360doc.com/content/15/0602/00/12129652_474998519.shtml
其实就是:
pop eip;
pop 3;
pop 2;
pop 1;
// 注意这里的 pop 3 只是一种逻辑意义,也就是弹出之前压入的三个参数。
ret 0ch。稍微准确点是:
pop eip;
add esp 0ch;
看当前状态:
执行一条语句:
可以看到 确实把 eax 的值给 a了,变成了6。本函数完成!
有了上面的一个详细的分析,可以大概看一下调用约定的一些差异了:
图中框出来的是调用方对四次调用的相关代码,可以看到 push 参数 3 2 1。顺序都是一样的。
也就是最开始说的从右到左的顺序。
push 完之后都是一句 call 。再款选的最后都是 mov xxx eax;其实就是对应的四条赋值语句。
同时看到 stdcall cdecl 都是 push 3 2 1。三个参数直接入栈。
而 fastcall vectorcall 是把第1 2 个参数分别放到了 ecx edx,第三个参数入栈。
同时看到 cdecl 是调用者做的平衡堆栈:
add esp, 0ch。
猜想:其他都是被调用者做的:
可以看到 cdecl 函数里面是一句 ret。而其他的是 ret 0ch ret 4(这后面的数据是根据入栈参数个数而定的)。同时发现没有 vectorcall 对应的函数汇编!因为他和 fastcall 是一个东西,至少这里是这样的:
接下来对比一下,用一个 FunStdcall(double, double, double)测试一下:
可以看到执行了三次图中方框中的类似操作,不难猜测,这其实就是三次:
push double。至少 double 不能直接 push 而已。为例验证,我们看到最后一次是逻辑意义:
push ds:[4108F8h]。也就是 push 第一个参数 push 1.0f。
观察监视发现:
*(double*)0x4108F8 确实就是 1.0f
同样*(double*)esp 是 1.0f。因为刚好把 1.0f 入栈了。
本文只粗略提供一些思路,也是备忘。后面再深入学习了。