从汇编语言看,函数的调用就是CALL和RET两条指令。其中CALL负责将返回地址(即CALL指令下一个指令的PC或者CS+PC)压入堆栈,然后跳转到CALL指令中所指定的PC开始执行。而RET则相反,从堆栈中弹出返回地址(PC或者CS+PC),并重新跳转到返回地址开始执行。因此从汇编语言层面看,所有函数参数和返回值的传递,寄存器的保护,以及局部变量空间的分配都需要显式地通过堆栈来完 成 。尤其是 对函数参数和返回值,函数及其调用者都必须遵循相同的规则在堆栈中 存放上述信息 ,从而保证这些信息在两者之间传递的正确性。这个规则就是Calling Convention。在汇编语言层面,这个规则很灵活,只要这个函数和其调用者约定好就可以了。它们可以通过寄存器,或者是堆栈的特定空间来传递。而且不同的函数可以采用完全不同的约定。对于寄存器的保护,这个规则约定了那些寄存器由调用者来保护,函数本身可以直接使用它们,而无需额外地保护。当函数返回后,也是由调用者来恢复这些寄存器的值。其他的寄存器,如果函数需要使用,则必须先保护后使用,并且在函数返回前恢复成原来的值。
但到了高级语言,所有函数的这些规则将由编译器生成的汇编语言来完成。程序员将不再需要显式地关注这些工作的实现。对于编译器而言,为了实现简单,它通常采用统一的标准的规则来处理所有的函数调用(除非有些优化算法会对此采用不同的优化)。这些规则(Calling Convention,称为调用约定)包含了参数压栈的顺序,由谁来清理调用栈中的参数(调用者或者被调用者),以及函数的修饰名方法(针对C++程序)。在不同的CPU平台上,有一个或多个业界标准的调用约定。不同的编译器往往能够支持程序员去选择相应的调用约定来生成目标代码。
首先介绍几个通用的调用约定:
- _cdecl是C/C++默认的调用约定。所有的参数从右到左依次入栈,并且由调用者清除。这样被调用的函数无需知道调用者到底传递了几个参数。这一点由于可变个数的参数传递尤为重要。这时如果调用者传递的参数个数不对,或者类型不同,都无法在编译阶段发现。
- _stdcall是win API函数所使用的调用约定。所有的参数也是从右到左依次入栈,但是是由被调用的函数在返回的同时清除(使用RETN X,CPU会在RET之后自动清除X个字节的堆栈空间)。因此作为被调用的函数,必须知道参数的个数以及类型。但是这种约定无法支持可变个数的参数传递。
- _fastcall是一种快速函数调用方式,针对函数参数比较少的情况。因此规定由寄存器来传递参数,而不同通过堆栈。如果参数过于所使用的寄存器个数,剩余的参数仍然通过堆栈来进行传递,返回方式沿用_stdcall的。不同的编译器和CPU所规定的寄存器以及个数可能不同。SPARC平台上一般caller使用寄存器窗口中的o0-o5来向callee传递前6个参数(在callee中这6个寄存器变成了i0-i5,这是通过SPARC平台上寄存器窗口轮转实现的)。而x86平台上使用ECX和EDX来传递前2个参数。
- _thiscall是为了面向对象编程中类成员函数调用而引入的。它要求把this参数放在特定的寄存器中传递,而其他的参数还是沿用_stdcall的方式。这个寄存器是由编译器指定的。
由于_fastcall和_thiscall都和编译器的实现有关,不能用在跨编译器的接口。
在x86平台上,callee通过寄存器EAX向caller传递返回值。而在SPARC平台上,callee通过寄存器i0向caller传递返回值(在caller中变成了寄存器o0)。
在介绍调用栈之前,在明确一下堆栈是从高地址向低地址向下增长的,在PUSH时,栈底先向低地址移动一个字宽(此处的字是指处理器的宽度)。然后在将PUSH的内容填入当前栈底的字中。在POP时,先将栈底的一个字取出,然后将栈底向高地址移动一个字宽。
要了解调用栈帧的组织形式,最好的方式是对照函数调用约定,看编译器生成的汇编指令。整个函数调用的过程可以分成以下几个步骤:
1. 调用者按照从右向左的顺序将所有的参数压入堆栈,所以参数在保存在调用者的栈帧内。
2. 调用者执行CALL指令,将CALL指令的下一个指令地址压入堆栈,并将CPU的控制权转移到被调函数的入口(function prologue)。
3. 在被调函数入口处,栈帧(stack frame)的基地址被压入堆栈,然后更新栈帧基地址为当前的栈底。所以当前栈帧的基地址所指向的内容实际上是上一个栈帧的基地址。
push ebp
mov ebp, esp
4. 分配局部变量的存储空间和间隔空间(0x40个字节,即64个字节)。一般局部变量也是按照4字节对齐的凡是来分配,所以其局部变量空间的大小应该是4字节的整数倍。
sub esp, 40+X // X是局部变量所需空间的大小,是4的整数倍
到这里可以看出栈帧的基地址的重要作用。8(%ebp)存放的是第一个参数值,12(%ebp)存放的是第二个参数值,-4(%ebp)存放的是第一个局部变量值, -8(%ebp)存放的是第二个局部变量值。通过当前栈帧的基地址,既可以访问函数的入参,又可以访问函数内的局部变量。
5. 被调函数保存当前寄存器,以便在被调函数内容使用这些寄存器。
函数的返回采用和函数调用相反的顺序。所需要注意的是
a. EAX寄存器被用来传递返回值,因此在被调函数内部没有必要保存寄存器EAX。反而应该由调用者根据需要在调用前保存EAX寄存器。
b. 被调函数通过执行RETN Y来清除调用者栈帧中的所有参数,而调用者通过ADD esp, Y来清除自己栈帧中的所有参数,其中Y是所有参数所占空间的大小(也是4字节的整数倍)。