6.2 各种调用方式的考察

6.1 节介绍了栈结构的相关知识,进入函数时会打开栈空间,退出函数时会还原栈空间。在VC++中,通常使用栈来传递函数参数,因此传递函数的栈也属于被调用函数栈空间中的一部分。那么它又是如何平衡的呢?汇编过程中通常使用“ret xxxx”来平衡参数所使用的栈空间,当函数的参数为不定参数时,函数自身无法确定参数所使用的栈空间的大小,因此无法由函数自身执行平衡操作,需要此函数的调用者执行平衡操作。为了确定参数的平衡者,以及参数的传递方式,于是有了函数的调用约定。VC++环境下的调用约定有三种:_cdecl、_stdcall、_fastcall。这3种调用约定的解释如下:

_cdecl:C\C++默认的调用方式,调用方平衡栈,不定参数的函数可以使用。

_stdcall:被调方平衡栈,不定参数的函数无法使用。

_fastcall:寄存器方式传参,被调方平衡栈,不定参数的函数无法使用。

当函数参数个数为0时,无需区分调用方式,使用_cdecl和_stdcall都一样。而大部分函数都是有参数的,那么该如何分析出它们的调用方式呢?通过查看平衡栈即可还原对应的调用方式。那么_cdecl与_stdcall这两种调用方式又有什么区别呢?我们通过代码清单6-3对二者进行分析对比,找出其中的差别。

代码清单6-3_cdecl与_stdcall的对比—Debug版


//C++源码说明:_cdecl、_stdcall两种调用方式的区别

void_stdcall ShowStd(int nNumber){//使用_stdcall调用方式,被调方平衡栈

printf("%d\r\n",nNumber);

}

void_cdecl ShowCde(int nNumber){//使用_cdecl调用方式,调用方平衡栈

printf("%d\r\n",nNumber);

}

void main(){

ShowStd(5);//不会有平衡栈操作

ShowCde(5);//函数调用结束后,对esp平衡4

}

//C++源码于对应汇编代码讲解

//C++源码对比,_stdcall调用方式

void_stdcall ShowStd(int nNumber)

;略去部分代码

{

//printf函数实现略

printf("%d\r\n",nNumber);

}

;略去部分代码

00401059 ret 4;结束后平衡栈顶4,等价esp+=4

//C++源码对比,_cdecl调用方式

void_cdecl ShowCde(int nNumber)

;略去部分代码

{

//printf函数实现略

printf("%d\r\n",nNumber);

}

//printf函数实现略

004010A9 ret;没有平衡操作

//C++源码对比,使用_stdcall方式调用函数ShowStd

ShowStd(5);

0040B7C8 push 5;函数传参,使用push指令esp-4

0040B7CA call@ILT+10(show)(0040100f);没有对esp操作的指令

//C++源码对比,使用_cdecl方式调用函数ShowCde

ShowCde(5);

0040B7CF push 5;函数传参,使用push指令esp-4

0040B7D1 call@ILT+15(ShowCde)(00401014)

0040B7D6 add esp,4;esp+=4,平衡栈顶


通过对代码清单6-3的分析可以得知,_cdecl调用方式在函数内没有任何平衡参数操作,而在退出函数后对esp执行了加4操作,从而实现栈平衡,_stdcall调用方式则与之相反。那么,是不是只要检查ret处是否有平衡操作即可得知函数的调用方式呢?由于汇编语言灵活多变,这种方法无法保证分析结构的正确性。在函数的结尾处,很有可能会有其他汇编指令间接地对esp做加法,如pop ecx这样的指令也可达到栈平衡效果,而且指令周期较短。因此,还需要结合函数在执行过程中使用的栈空间,与函数调用结束时的栈平衡数进行对比,以判断是否实现参数平衡。

C语言中经常使用的printf函数就是典型的_cdecl调用方式,由于printf的参数可以有多个,所以只能以_cdecl方式调用。那么,当printf函数被多次使用后,会在每次调用结束后进行栈平衡操作吗?在Debug版下,为了匹配源码会这样做。而经过O2选项的优化后,会采取复写传播优化,将每次参数平衡的操作进行归并,一次性平衡栈顶指针esp。示例如代码清单6-4所示。

代码清单6-4_cdecl参数平衡代码的复写传播优化—Release版


//C++源码说明:复写传播

void main(){

printf("Hello");//函数调用结束后,执行eps+4平衡参数

printf("World");//同上

printf("C++");//同上

printf("\r\n");//同上,经过优化后,会将4次平衡归并为1次

}

;Release版的反汇编代码信息

push offset Format;"Hello"

call_printf;调用结束后没有平衡栈

push offset aWorld;"World"

call_printf;调用结束后没有平衡栈

push offset aC;"C++"

call_printf;调用结束后没有平衡栈

push offset asc_406030;"\r\n"

call_printf

add esp,10h;一次性对esp加16,正好平衡了之前的4个参数

retn


通过以上分析发现,_cdecl与_stdcall只在参数平衡上有所不同,其余部分都一样。但经过优化后,_cdecl调用方式的函数在同一作用域内多次使用,会在效率上比_stdcall高一点,这是因为_cdecl可以使用复写传播,而_stdcall都在函数内平衡参数,无法使用复写传播这种优化方式。在这三种调用方式中,_fastcall调用方式的效率最高,其他两种调用方式都是通过栈传递参数,唯独_fastcall可以利用寄存器传递参数。但由于寄存器数目很少,而参数相比可以很多,只能量力而行,故_fastcall调用方式只使用了ecx和edx,分别传递第一个参数和第二个参数,其余参数传递则转换成栈传参方式。示例如代码清单6-5所示。

代码清单6-5_fastcall调用方式示例


//C++源码说明:_fastcall调用方式

void__fastcall ShowFast(int nOne, int nTwo, int nThree, int nFour){

printf("%d%d%d%d\r\n",nOne, nTwo, nThree, nFour);

}

void main(){

ShowFast(1,2,3,4);

//C++源码与对应汇编代码讲解

//C++源码对比,函数调用

ShowFast(1,2,3,4);

004012A8 push 4;使用栈方式传递参数

004012AA push 3;使用栈方式传递参数

004012AC mov edx,2;使用edx传递第二个参数2

004012B1 mov ecx,1;使用ecx传递第一个参数1

004012B6 call@ILT+15(ShowFast)(00401014)

//C++源码对比,函数说明

void_fastcall ShowFast(int nOne, int nTwo, int nThree, int nFour){

004010F0 push ebp

004010F1 mov ebp, esp

004010F3 sub esp,48h

004010F6 push ebx

004010F7 push esi

004010F8 push edi

;由于ecx即将被赋值作为循环计数器使用,在此将ecx原值保存

004010F9 push ecx

004010FA lea edi,[ebp-48h]

004010FD mov ecx,12h

00401102 mov eax,0CCCCCCCCh

00401107 rep stos dword ptr[edi]

00401109 pop ecx;还原ecx

;使用临时变量保存edx(参数2)

0040110A mov dword ptr[ebp-8],edx

;使用临时变量保存ecx(参数1)

0040110D mov dword ptr[ebp-4],ecx

//C++源码对比,printf函数调用

printf("%d%d%d%d\r\n",nOne, nTwo, nThree, nFour);

;使用ebp相对寻址取得参数4

00401110 mov eax, dword ptr[ebp+0Ch]

00401113 push eax;将eax压栈,作为参数

;使用ebp相对寻址取得参数3

00401114 mov ecx, dword ptr[ebp+8]

00401117 push ecx;将ecx压栈,作为参数

;在ebp-8中保存edx,即参数2

00401118 mov edx, dword ptr[ebp-8]

0040111B push edx;将edx压栈,作为参数

;在ebp-4中保存ecx,即参数1

0040111C mov eax, dword ptr[ebp-4]

0040111F push eax;将eax压栈,作为参数

00401120 push offset string"%d%d%d%d\r\n"(00422024)

00401125 call printf(004012e0);调用printf函数

0040112A add esp,14h;平衡pirntf使用的5个参数

}

;Debug还原环境,栈检测部分略

0040113D ret 8;此函数有4个参数,ret指令对其平衡


此段代码的Release版将更加简洁明了,这里就不再讲解了。