C++:调用协定

调用协定这个东西涉及到C语言函数实现的原理,大家都知道,调用函数时,保存当前执行环境,跳转到目标函数指令地址执行函数代码,执行完成后恢复调用前的执行环境。但实现的方式这么多,甚至可能不同的语言提供的接口有不同的方式,如何实现与目标函数相同的方式来调用呢?调用协定因此诞生。
可能有人觉得,这东西我从没听说过,但我写的C艹代码一样没问题,有必要研究吗?说实话这东西还是有必要的,主要在不同框架、语言间使用。比如BCB主要用__fastcall实现函数调用,C语言自身默认使用__cdecl实现函数调用,Windows平台又以__stdcall作为主要函数调用方式,而.NET Framework托管平台又以__clrcall实现函数调用,它们之间如何互相调用就是个问题。
Win32平台上可用函数调用协定有以下几种,后面的为别名:
__cdecl : _cdecl 、 cdecl 、 CDECL 、 WINAPIV
__pascal : pascal 、 PASCAL
__stdcall : WINAPI 、 APIENTRY 、 CALLBACK 、 APIPRIVATE
__fastcall
__clrcall
可能因为操作系统版本不同,导致函数定义有些许差异,在Win8.1上,__pascal已经被定义成了__stdcall,但并不妨碍我们对调用协定的学习。上面的关键字看不懂?没关系,我们一一来验证它们的作用。

1、__cdecl调用协定

首先__cdecl,C语言默认调用协定。首先我们先定义一个这样的函数,然后对其进行调用。

1
2
3
4
5
int __cdecl func_cdecl (int a, int b, int c) {
    return a + b + c;
}
//...
func_cdecl (1, 2, 3);

调用协定的定义方式为,将名称写在返回类型与函数名之间。写完之后,通过对齐进行Disassembly,显示如下结果:
20160910230845-cdecl
20160910230845-cdecl-call

我们在调用函数时,push了三个参数,push一次esp寄存器会减0x4,那么我们push了三次应该是减0xC,可以注意函数反汇编的最后一行,ret直接返回了,并没配平栈,反而是调用者执行了add esp, 0Ch,意味着调用者配平栈。为啥会这样呢?这个涉及到C语言的变长参数。比如printf函数,参数数量并不固定,甚至写上"%d"的格式化字符串之后,后面跟上十几个int类型数据也会成功运行。这儿我声明一点:函数代码并不能确定调用者传入了几个参数。因此,只有调用者知道自己传入了多少参数,所以这儿只能交由调用者自己来配平栈。
可能有人会问:为啥需要配平栈?不配平貌似也没啥问题啊。。。你可能忽略了一点,如果只push,不配平,那么调用了N次代码之后,栈顶指针只减不增,将会很快使用完所有内存,并且正在使用中的栈内存与没被使用的栈内存完全无法区分开,没有任何方法安全释放栈内存,因此需要配平。
首先我先说说栈的原理:栈在内存中,基地址(ebp)为高位,每push一次,栈顶指针(esp)减0x4;每pop一次,栈顶指针加0x4,如果栈顶指针等于基地址,那么代表栈中没有元素,同理,基地址与栈顶指针的差值代表栈中所存放的数据的总大小,其次栈顶指针始终小于等于基地址。从这儿可以看出,栈的生长方向为,由高地址向低地址生长。网上的一些不科学的砖家说什么栈的生长方向为向上向下啥啥啥生长,完全是误导。
另外我在说明汇编代码执行的原理: call func_cdecl 这句代码执行方式大致为,首先保存当前指令指针(IP)地址,有时候会保存当前环境比如正在使用的其他寄存器的值等等,然后跳转到函数所在地的地址,函数里面首先执行 push ebp 和 mov ebp, esp 两句代码,含义为保存栈基址指针,然后将当前栈顶指针的值移动至栈基址寄存器。然后一个 sub esp, xxx 代表将栈顶指针减多少多少,意味着当前函数分配了多大的栈空间,通常定义了一些局部变量时,就是在这儿减去相应大小就可以了。我这儿并没声明局部变量,另外这是Debug模式,减了这么多我不知道怎么解释,你们就理解为,Debug模式在检查栈是否配平时的需要,就行了(估计并不是这样)。
然后是 push ebx 、 push esi 、push edi ,这三句代码的含义为,保存当前执行环境。比如我们正在使用 ebx 寄存器,然后调用了一个函数,并且希望函数调用完成之后寄存器的值依旧为原来的值;但如果每次调用函数就保存所有寄存器的值那太影响执行速度,因此一个不太标准的调用方式是保存 ebx 、 esi 、 edi 三个寄存器的值。写过纯汇编代码的人估计也通过定义函数的伪代码知道一般保存这几个变量的值,同时也知道需要保存几个值完全可以自定义,一个都不保存,或者一次性保存所有寄存器的值,都没问题。
下面一小段是Debug专用代码,效果为,打印未初始化的字符串,显示为“烫烫烫烫烫烫烫烫烫烫烫烫烫烫烫……”而不是随机字符,方便调试时找出问题。
然后是函数的返回值。对于一个基本类型数据,返回值用eax变量存放,因此上面代码中在eax里面计算完这条代码后,就不管了。
然后是三个pop,恢复函数执行前的环境。
再然后是 mov esp,ebp 和 pop ebp 两句代码,用于恢复栈环境。
最后是ret,有时候后面会跟上一个数字,代表先 pop eip ,然后加上跟的这个数字。
还是不太懂?没关系,下面我再贴一个寄存器调试窗口。首先,我再调用函数之前查看寄存器的值,可以看到esp值为00DBF7BC(这个地址可能每次执行时都不一样,不要介意为啥和我的不一样):
20160911010200-reg-pre
然后,执行了三句 push 指令之后的寄存器的值:
20160911010200-reg-incall
可以看到eip与esp的值改变了。eip代表当前代码执行位置,32位系统每句push指令占两个字节,因此当前执行地址跟随了三句push之后,加上了0x6;esp嘛,执行push改的就是esp,32位系统上每个int类型占4字节,三次之后,esp减去0xC,因此值也变为了上面这个结果。然后我们执行call指令:
20160911010200-reg-after
由于跳转到另外一个地址因此eip变动的有点大;其次esp也减去了4,这4字节内存存放的即为调用函数前的eip值,用于在函数返回时,能找到调用者的位置。

2、__pascal调用协定

然后,由于在本机上__pascal调用协定被定义为__stdcall形式,与我所理解的__pascal不同,因此这儿不再解释,关于实现可以参考下面__stdcall调用协定。

3、__stdcall调用协定

1
2
3
4
5
int __stdcall func_stdcall (int a, int b, int c) {
    return a + b + c;
}
//...
func_stdcall (1, 2, 3);

函数与调用者的反汇编代码如图:
20160910231043-stdcall
20160910231043-stdcall-call
与__cdecl大同小异,相同的部分不再重新说明,我只说说不同的部分。首先是调用者在call之后并没配平栈,其次是函数在ret指令后面跟的有0Ch。含义为,调用者只管push一堆参数,并不管栈是否配平,配平栈的任务交给函数来完成。因此,你们是否发现了,Win32API里面没有边长参数API,就是这个原因。

4、__fastcall调用协定

这是一个神奇的调用协定,它将第一个参数与第二个参数分别放在 ecx 、 edx 两个寄存器中传递给函数,优点是执行速度更快,缺点是实现这种调用协定稍微要复杂一些。反汇编实现如下:
20160910230930-fastcall
20160910230930-fastcall-call
可以看到,函数内部将两个参数存放至栈空间,然后在函数结束时 ret 指令后面只跟了4。

5、__clrcall调用协定

这种调用协定我就不写代码了。这个主要用于在CLR托管平台,几个别名为 VC++.NET 、 C++/CLR 、 C++/CLI ,经测试,这种调用协定实现方式几乎与__fastcall相同。主要用于以下情况:比如非托管平台通过托管函数指针调用托管平台代码,那么需要通过定义 __clrcall 调用协定的函数指针。有一点比较意外的情况,如果将项目设置为托管项目,那么之前定义的几个函数全部变为了托管调用协定,这点让我很意外啊,。。

发布者

fawdlstty

又一只萌萌哒程序猿~~

发表评论

电子邮件地址不会被公开。 必填项已用*标注