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