创建一个栈帧的最重要步骤是主调函数如何向栈中传递函数参数。主调函数必须精确存储这些参数,以便被调函数能够访问到它们。函数通过选择特定的调用约定,来表明其希望以特定方式接收参数。此外,当被调函数完成任务后,调用约定规定先前入栈的参数由主调函数还是被调函数负责清除,以保证程序的栈顶指针完整性。
函数调用约定通常规定如下几方面内容:
1) 函数参数的传递顺序和方式
最常见的参数传递方式是通过堆栈传递。主调函数将参数压入栈中,被调函数以相对于帧基指针的正偏移量来访问栈中的参数。对于有多个参数的函数,调用约定需规定主调函数将参数压栈的顺序(从左至右还是从右至左)。某些调用约定允许使用寄存器传参以提高性能。
2) 栈的维护方式
主调函数将参数压栈后调用被调函数体,返回时需将被压栈的参数全部弹出,以便将栈恢复到调用前的状态。该清栈过程可由主调函数负责完成,也可由被调函数负责完成。
3) 名字修饰(Name-mangling)策略
又称函数名修饰(Decorated Name)规则。编译器在链接时为区分不同函数,对函数名作不同修饰。
若函数之间的调用约定不匹配,可能会产生堆栈异常或链接错误等问题。因此,为了保证程序能正确执行,所有的函数调用均应遵守一致的调用约定。
常见调用约定
下面分别介绍常见的几种函数调用约定。
1. cdecl调用约定
又称C调用约定,是C/C++编译器默认的函数调用约定。所有非C++成员函数和未使用stdcall或fastcall声明的函数都默认是cdecl方式。函数参数按照从右到左的顺序入栈,函数调用者负责清除栈中的参数,返回值在EAX中。由于每次函数调用都要产生清除(还原)堆栈的代码,故使用cdecl方式编译的程序比使用stdcall方式编译的程序大(后者仅需在被调函数内产生一份清栈代码)。但cdecl调用方式支持可变参数函数(即函数带有可变数目的参数,如printf),且调用时即使实参和形参数目不符也不会导致堆栈错误。对于C函数,cdecl方式的名字修饰约定是在函数名前添加一个下划线;对于C++函数,除非特别使用extern “C”,C++函数使用不同的名字修饰方式。
【扩展阅读】可变参数函数支持条件
若要支持可变参数的函数,则参数应自右向左进栈,并且由主调函数负责清除栈中的参数(参数出栈)。
首先,参数按照从右向左的顺序压栈,则参数列表最左边(第一个)的参数最接近栈顶位置。所有参数距离帧基指针的偏移量都是常数,而不必关心已入栈的参数数目。只要不定的参数的数目能根据第一个已明确的参数确定,就可使用不定参数。例如printf函数,第一个参数即格式化字符串可作为后继参数指示符。通过它们就可得到后续参数的类型和个数,进而知道所有参数的尺寸。当传递的参数过多时,以帧基指针为基准,获取适当数目的参数,其他忽略即可。若函数参数自左向右进栈,则第一个参数距离栈帧指针的偏移量与已入栈的参数数目有关,需要计算所有参数占用的空间后才能精确定位。当实际传入的参数数目与函数期望接受的参数数目不同时,偏移量计算会出错!
其次,调用函数将参数压栈,只有它才知道栈中的参数数目和尺寸,因此调用函数可安全地清栈。而被调函数永远也不能事先知道将要传入函数的参数信息,难以对栈顶指针进行调整。
C++为兼容C,仍然支持函数带有可变的参数。但在C++中更好的选择常常是函数多态。
2. stdcall调用约定(微软命名)
Pascal程序缺省调用方式,WinAPI也多采用该调用约定。stdcall调用约定主调函数参数从右向左入栈,除指针或引用类型参数外所有参数采用传值方式传递,由被调函数负责清除栈中的参数,返回值在EAX中。stdcall调用约定仅适用于参数个数固定的函数,因为被调函数清栈时无法精确获知栈上有多少函数参数;而且如果调用时实参和形参数目不符会导致堆栈错误。对于C函数,stdcall名称修饰方式是在函数名字前添加下划线,在函数名字后添加@和函数参数的大小,如_functionname@number。
3. fastcall调用约定
stdcall调用约定的变形,通常使用ECX和EDX寄存器传递前两个DWORD(四字节双字)类型或更少字节的函数参数,其余参数按照从右向左的顺序入栈,被调函数在返回前负责清除栈中的参数,返回值在 EAX 中。因为并不是所有的参数都有压栈操作,所以比stdcall和cdecl快些。编译器使用两个@修饰函数名字,后跟十进制数表示的函数参数列表大小(字节数),如@function_name@number。需注意fastcall函数调用约定在不同编译器上可能有不同的实现,比如16位编译器和32位编译器。另外,在使用内嵌汇编代码时,还应注意不能和编译器使用的寄存器有冲突。
4. thiscall调用约定
C++类中的非静态函数必须接收一个指向主调对象的类指针(this指针),并可能较频繁的使用该指针。主调函数的对象地址必须由调用者提供,并在调用对象非静态成员函数时将对象指针以参数形式传递给被调函数。编译器默认使用thiscall调用约定以高效传递和存储C++类的非静态成员函数的this指针参数。
thiscall调用约定函数参数按照从右向左的顺序入栈。若参数数目固定,则类实例的this指针通过ECX寄存器传递给被调函数,被调函数自身清理堆栈;若参数数目不定,则this指针在所有参数入栈后再入栈,主调函数清理堆栈。thiscall不是C++关键字,故不能使用thiscall声明函数,它只能由编译器使用。
注意,该调用约定特点随编译器不同而不同,g++中thiscall与cdecl基本相同,只是隐式地将this指针当作非静态成员函数的第1个参数,主调函数在调用返回后负责清理栈上参数;而在VC中,this指针存放在%ecx寄存器中,参数从右至左压栈,非静态成员函数负责清理栈上参数。
5. naked call调用约定
对于使用naked call方式声明的函数,编译器不产生保存(prologue)和恢复(epilogue)寄存器的代码,且不能用return返回返回值(只能用内嵌汇编返回结果),故称naked call。该调用约定用于一些特殊场合,如声明处于非C/C++上下文中的函数,并由程序员自行编写初始化和清栈的内嵌汇编指令。注意,naked call并非类型修饰符,故该调用约定必须与__declspec同时使用,如VC下定义求和函数:
代码示例如下(Windows采用Intel汇编语法,注释符为;):
__declspec(naked) int __stdcall function(int a, int b) {
;mov DestRegister, SrcImmediate(Intel) vs. movl $SrcImmediate, %DestRegister(AT&T)
__asm mov eax, a
__asm add eax, b
__asm ret 8
}
注意,__declspec是微软关键字,其他系统上可能没有。
6. pascal调用约定
Pascal语言调用约定,参数按照从左至右的顺序入栈。Pascal语言只支持固定参数的函数,参数的类型和数量完全可知,故由被调函数自身清理堆栈。pascal调用约定输出的函数名称无任何修饰且全部大写。
Win3.X(16位)时支持真正的pascal调用约定;而Win9.X(32位)以后pascal约定由stdcall约定代替(以C约定压栈以Pascal约定清栈)。
上述调用约定的主要特点如下表所示:
调用方式 | stdcall(Win32) | cdecl | fastcall | thiscall(C++) | naked call |
---|---|---|---|---|---|
参数压栈顺序 | 从右至左 | 从右至左 | 从右至左,Arg1在ecx,Arg2在edx | 从右至左,this指针在ecx | 自定义 |
参数位置 | 栈 | 栈 | 栈 + 寄存器 | 栈,寄存器ecx | 自定义 |
负责清栈的函数 | 被调函数 | 主调函数 | 被调函数 | 被调函数 | 自定义 |
支持可变参数 | 否 | 是 | 否 | 否 | 自定义 |
函数名字格式 | _name@number | _name | @name@number | 自定义 | |
参数表开始标识 | “@@YG” | “@@YA” | “@@YI” | 自定义 | |
注:C++因支撑函数重载、命名空间和成员函数等语法特征,采用更为复杂的名字修饰策略。 C++函数修饰名以”?”开始,后面紧跟函数名、参数表开始标识和按照类型代号拼出的返回值参数表。 例如,函数int Function(char *var1,unsigned long)对应的stdcall修饰名为”?Function@@YGHPADK@Z”。 |
Windows下可直接在函数声明前添加关键字stdcall、cdecl或fastcall等标识确定函数的调用方式,如int stdcall func()。Linux下可借用函数attribute 机制,如int attribute((stdcall)) func()。
代码示例如下:
int __attribute__((__cdecl__)) CalleeFunc(int i, int j, int k){
// int __attribute__((__stdcall__)) CalleeFunc(int i, int j, int k){
//int __attribute__((__fastcall__)) CalleeFunc(int i, int j, int k){
return i+j+k;
}
void CallerFunc(void){
CalleeFunc(0x11, 0x22, 0x33);
}
int main(void){
CallerFunc();
return 0;
}
被调函数CalleeFunc分别声明为cdecl、stdcall和fastcall约定时,其汇编代码比较如下表所示:
cdecl | stdcall | fastcall | |
---|---|---|---|
主调函数职责 | sub $0xc,%esp movl $0x33,0x8(%esp) movl $0x22,0x4(%esp) movl $0x11,(%esp) call 8048354 |
sub $0xc,%esp movl $0x33,0x8(%esp) movl $0x22,0x4(%esp) movl $0x11,(%esp) call 8048354 sub $0xc,%esp |
sub $0x4,%esp movl $0x33,(%esp) mov $0x22,%edx mov $0x11,%ecx call 8048354 sub $0x4,%esp |
被调函数职责 | push %ebp mov %esp,%ebp mov 0xc(%ebp),%eax add 0x8(%ebp),%eax add 0x10(%ebp),%eax pop %ebp ret |
push %ebp mov %esp,%ebp mov 0xc(%ebp),%eax add 0x8(%ebp),%eax add 0x10(%ebp),%eax pop %ebp ret $0xc //执行ret指令并清理参数占用的堆栈(栈顶指针上移参数个数*4=12个字节,以释放压栈的参数) |
push %ebp mov %esp,%ebp sub $0x8,%esp mov %ecx,0xfffffffc(%ebp) mov %edx,0xfffffff8(%ebp) mov 0xfffffff8(%ebp),%eax add 0xfffffffc(%ebp),%eax add 0x8(%ebp),%eax leave ret $0x4 //ret <压栈参数字节数>。若参数不超过两个,则ret指令不带立即数,因为无参数被压栈 |