x86函数调用约定
以下摘自《IDA Pro》,貌似有一些细节之处没有交代清楚呢,需要进一步思考、实践。
了解栈帧的基本概念后,接下来详细介绍它们的结构。下面的例子涉及x86体系结构和与常见的x86编译器(如Microsoft Visual C/C++或GNU的gcc/g++)有关的行为。创建栈帧的最重要的步骤是,通过调用函数将函数存入栈中。调用函数必须存储被调用函数所需要的参数,否则可能导致严重的问题。各个函数会选择并遵照某一特定的调用约定,以表明他们希望以何种方式接收参数。
调用约定指定调用方放置函数所需参数的具体位置。调用预定可能需求将参数放置在特定的寄存器、程序栈、或者寄存器和栈中。同样重要的是,在传递参数时,程序栈还要决定:被调用函数完成其操作后,由谁负责从栈中删除这些参数。一些调用约定规定,由调用方负责删除它放置在栈中的参数,而另一些调用约定则要求被调用函数负责删除栈中的参数。遵照指定的调用约定对于维护程序栈指针的完整性尤为重要。
1.C调用约定
x86体系结构的许多C编译器使用默认调用约定叫做C调用约定。如果默认的调用约定被重写,则C/C++程序中常用的_cdecl修饰符会迫使编译器利用C调用约定。自现在开始,我们把这种调用约定叫做cdecl调用约定。cdecl调用约定规定:调用方按从右到左的顺序将函数参数放入栈中,在被调用的函数完成其操作时,调用方(而不是被调用方)负责从栈中清除参数。
从右到左在栈中放入参数的一个结果是,如果函数被调用,最左边的(第一个)参数将始终位于栈顶。这样无论该函数需要多少个参数,我们都可轻易找到第一个参数。因此,cdecl调用约定非常适用于那些参数数量可变的函数(如printf)。
要求调用函数从栈中删除参数,意味着你将经常看到:指令在由被调用的函数返回后,会立即对程序栈指针进行调整。如果函数能够接受数量可变的参数,则调用方非常适于进行这种调整,因为它清楚地知道,它像函数传递了多少个参数,因而能够轻松做出正确的调整。而被调用的函数事先无法知道自己会收到多少个参数,因而很难对栈做出必要的调整。
在下面的例子中,我们调用一个拥有以下原型的函数:
void demo_cdecl(int w, int x, int y, int z)
默认情况下,这个函数将使用cdecl调用约定,并希望你按从右到左的顺序压入4个参数,同时要求调用方清除栈中的参数,编译器可能会为这个函数的调用生成以下代码:
; demo_cdecl(1, 2, 3, 4);//programer calls demo_cdecl 1. push 4 ;push parameter z push 3 ;push parameter y push 2 ;push parameter x push 1 ;push parameter w call demo_cdecl ;call the function 2. add esp, 16 ;adjust esp to its former value
从1开始的4个push操作使程序栈指针(ESP发生)16个字节(在32位体系结构上为4*sizeof(int))的变化,从demo_cdecl返回后,它们在2处被撤销。如果demo_cdecl被调用50次,那么,每次调用之后,都会发生类似于2处的调整。下面的例子同样遵照cdecl调用约定,但是,在每次调用demo_cdecl后,调用方不需要删除栈中的参数。
; demo_cdecl(1, 2, 3, 4) //programemr calls demo_cdecl mov [esp+12], 4 ; move parameter z to fourth position on stack mov [esp+8], 3 ; move parameter y to third position on stack mov [esp+4], 2 ; move parameter x to second position on stack mov [esp], 1 ; move parameter w to top of stack call demo_cdecl ; call the function
在这个例子中,在函数的“序言”阶段,编译器已经在栈顶为demo_cdecl的参数预先分配了存储空间。在demo_cdecl的参数放到栈上时,并不需要修改程序栈指针,因此,在调用demo_cdecl结束后,也就不需要调整栈指针。GNU编译器(gcc和g++)正是利用这种技巧将函数参数放到栈上的。注意,无论采用哪一种方法,在调用函数时,栈指针都会指向最左边的参数。
2.标准调用约定
这里的标准似乎有些用词不当,因为它是微软为自己的调用约定所起的名称。这种约定在函数声明中使用了修饰符_stdcall,如下所示:
void _stdcall demo_stdcall(int w, int x, int y);
为避免标准一词引起混淆,在本书的剩余部分,我们将这种调用约定称为stdcall调用约定。
和cdecl调用约定一样,stdcall调用约定按从右到左的顺序将函数参数放在程序栈上。使用stdcall调用约定的区别在于:函数结束执行时,应由被调用的函数负责删除栈中的函数参数。对被调用的函数而言,要完成这个任务,它必须清楚知道栈中有多少个参数,这只有在函数接受的参数数量固定不变时才有可能。因此,printf这种接受数量可变的参数的函数不能使用stdcall调用约定。例如,demo_stdcall函数需要3个整数参数,在栈上共占用12个字节(在32位体系结构上为3*sizeof(int))的空间。x86编译器能够使用RET指令的一种特殊形式,同时从栈顶提取返回地址,并给栈指针加上12,以清除函数参数。demo_stdcall可能会使用以下指令返回到调用方:
ret 12 ; return and clear 12 bytes from the stack
使用stdcall的主要优点在于,在每次函数调用之后,不需要通过代码从栈中清除参数,因而能够生成体积稍小、速度稍快的程序。根据惯例,微软对所有由共享库(DLL)文件输出的参数数量固定的函数使用stdcall约定。如果你正尝试为某个共享库组件生成函数原型或二进制兼容的替代者,请一定记住这一点。
3.x86 fastcall约定
fastcall约定是stdcall约定的一个变体,它向CPU寄存器(而非程序栈)最多传递两个参数。Microsoft Visual C/C++和GNU gcc/g++(3.4及更低(应该是更高吧?--shijianyujingshen)版本)编译器能够识别函数声明中的fastcall修饰符。如果指定使用fastcall约定,则传递给函数的前两个参数将分别位于ECX和EDX寄存器中。剩余的其他参数则以类似于stdcall约定的方式从右到左放入栈上。同样与stdcall约定类似的是,在返回其调用方时,fastcall函数负责从栈中删除参数。下面的声明中即使用了fastcall修饰符:
void fastcall demo_fastcall(int w, int x, int y, int z)
为调用demo_fastcall,编译器可能会生成以下代码:
; demo_fastcall(1, 2, 3, 4); //programer calls demo fastcall push 4 ; move parameter z to second position on stack push 3 ; move parameter y to tio position on stack mov edx, 2 ; move parameter x to edx mov ecx, 1 ; move parameter w to ecx call demo_fastcall ; call the function
注意,调用demo_fastcall返回后,并不需要调整栈,因为demo_fastcall负责在返回到调用方时从栈中清除参数y和z。由于有两个参数被传递到寄存器中,被调用的函数仅仅需要从栈中清除8字节,即使该函数拥有4个参数也是如此,理解这一点很重要。
4.C++调用约定
C++类中的非静态成员函数与标准函数不同,它们需要使用this指针,该指针指向用于调用函数的对象。用于调用函数的对象的地址必须由调用方提供,因此,它在调用非静态成员函数时作为参数提供。C++语言标准并未规定应如何向非静态成员函数传递this指针,因此,不同编译器使用不同的技巧来传递this指针,这点也就不足为奇。
Microsoft Visual C++提供thiscall调用约定,它将this指针传递到ECX寄存器,并且和在stdcall中一样,它要求非静态成员函数清除栈中的参数。GNU g++编译器将this看成是任何非静态成员函数的第一个隐含参数,而在所有其他方面与使用cdecl约定相同。因此,对使用g++编译的代码来说,在调用非静态成员函数之前,this被放置到栈顶,且调用方负责在函数返回时删除栈中参数(至少有一个参数)。已编译的C++代码的其他特性将在第8章中讨论。
5.其他调用约定
要完整地介绍现有的每一个调用约定,可能需要写一本书。调用约定通常是特定于语言、编译器和CPU的。如果遇到更少见的编译器生成的代码,可能需要你自己进行一番研究。但是,以下这些情况需要特别注意:优化代码、定制汇编语言代码和系统调用。
如果输出函数(如库函数)是为了供其他程序员使用,那么,它必须遵照主流的调用约定,以便程序员能够轻松调用这些函数。另外如果函数仅供内部程序使用,则该函数需要采用只有函数的程序才了解的调用约定。在这类情况下,优化编译器会选择使用备用的调用约定,以生成运行速度更快的代码。这样的例子包括:在Microsoft Visual C++中使用/GL选项,以及在GNU gcc/g++中使用regparm关键字。
如果程序员不怕麻烦,使用了汇编语言,那么,他们就能够完全控制如何向他们创建的函数传递参数。除非他们希望创建供其他程序员使用的函数,否则,汇编语言程序员能够以任何他们认为适当的方式传递参数。因此,在分析自定义汇编代码时,请格外小心。在模糊例程(obfuscation routine)和shellcode中经常可以看到自定义汇编代码。
系统调用是一种特殊的函数调用,用于请求一项操作系统服务。通常,系统调用会造成状态转换,由用户模式进入内核模式,以便操作系统内核执行用户的请求。启动系统调用的方式因操作系统和CPU而异。例如,Linux x86系统调用使用int 0x80指令或sysenter指令启动,而其他x86操作系统可能只使用sysenter指令。在许多x86系统(Linux是一个例外)上,系统调用的参数位于运行时栈上,并在启动系统调用之前,在EAX寄存器中放入一个系统调用编号。Linux系统调用接受位于特定寄存器中的参数,有时候,如果可用寄存器无法存储所有的参数,它也接受位于内存中的参数。