见:http://topic.csdn.net/u/20090930/11/c89306fa-8c55-4706-8535-777febc299ec.html
http://topic.csdn.net/u/20111016/16/7e2b3d10-75cb-4b43-b081-81aa224ac8fe.html
1.3 C函数的参数传递过程
基础知识
函数调用的本质将在这里得到阐明。首先请读者理解堆栈的操作。函数和堆栈的关系密切,这是因为:C语言程序通过堆栈把参数从函数外部传入到函数内部。此外,在堆栈中划分区域来容纳函数的内部变量。
调用push和pop指令的时候,寄存器esp用于指向栈顶的位置--栈顶总是栈中地址最小的位置。push执行的结果,esp总是减少,pop则增加。对于C程序默认的调用方式,堆栈总是调用方把参数反序(从右到左)地压入堆栈中,被调用方把堆栈复原(这些我们会在后面见到)。这些参数对齐到机器字长,16位、32位、64位CPU下分别对齐到2、4、8个字节。这种调用是C编译器默认的C方式。
函数调用规则
在一个编写高级语言的程序员的观念中,函数(或者没有返回值的过程)是必不可少的基础单元。C语言的程序完全由函数构成,所有的代码都在某一个函数中。Pascal区分函数和过程,但是本质依然是类似的。对计算机硬件而言,这种区分毫无必要,因为CPU只关心一条一条的指令,并不关心它们是以怎样的结构组织的。
call指令和ret指令只是为了调用的方便而已,绝不是函数存在的绝对证据。即使我们仅仅使用jmp并自己操作堆栈,也一样可以实现函数的功能。因此,一种高级语言如何实现函数调用,并没有法律的约束,所以出现了各种不同的函数调用规则。
但是毫无疑问,如果一个第三方提供的函数要能被使用,那么必须有约定的函数调用规则。
函数调用规则指的是调用者和被调用函数间传递参数及返回参数的方法,在Windows上,常用的有Pascal方式、WINAPI方式(_stdcall)、C方式(_cdecl)。
_cdecl C调用规则:
(1)参数从右到左进入堆栈;
(2)在函数返回后,调用者要负责清除堆栈,所以这种调用常会生成较大的可执行程序。
_stdcall又称为WINAPI,其调用规则:
(1)参数从右到左进入堆栈;
(2)被调用的函数在返回前自行清理堆栈,所以生成的代码比cdecl小。
Pascal调用规则:
Pascal调用规则主要用在Win16函数库中,现在基本不用。
(1)参数从左到右进入堆栈;
(2)被调用的函数在返回前自行清理堆栈。
(3)不支持可变参数的函数调用。
此外,在Windows内核中还常见有快速调用方式(_fastcall);在C++编译的代码中有this call方式(_thiscall)。这些会在后面的章节中详细阐明。
技术细节
在用C语言所写的程序中,堆栈用于传递函数参数。写一个简单的函数如下:
void myfunction(int a,int b) { int c = a+b; } |
这是标准的C函数调用方式。其过程是:
调用者把参数反序地压入堆栈中。
调用函数。
调用者把堆栈清理复原。
这就是C编译器默认的_cdecl方式,而Windows API一般采用的_stdcall则是被调用者恢复堆栈(可变参数函数调用除外)。
至于返回值都是写入eax中,然后返回的。
在Windows中,不管哪种调用方式都是返回值放在eax中,然后返回。外部从eax中得到返回值。
_cdecl方式下被调用函数需要做以下一些事情。
(1)保存ebp。ebp总是被我们用来保存这个函数执行之前的esp的值。执行完毕之后,我们用ebp恢复esp;同时,调用此函数的上层函数也用ebp做同样的事情。所以先把ebp压入堆栈,返回之前弹出,避免ebp被我们改动。
(2)保存esp到ebp中。
上面两步的代码如下:
;保存ebp,并把esp放入ebp中,此时ebp与esp同 ;都是这次函数调用时的栈顶 push ebp mov ebp,esp |
(3)在堆栈中腾出一个区域用来保存局部变量,这就是常说的所谓局部变量是保存在栈空间中的。方法是:把esp减少一个数值,这样就等于压入了一堆变量。要恢复时,只要把esp恢复成ebp中保存的数据就可以了。
(4)保存ebx、esi、edi到堆栈中,函数调用完后恢复。
对应的代码如下:
;把esp往下移动一个范围,等于在堆栈中放出一片新 ;的空间用来存局部变量 sub esp,0cch push ebx ;下面保存三个寄存器:ebx、esi、edi push esi push edi |
(5)把局部变量区域初始化成全0cccccccch。0cch实际是int 3指令的机器码,这是一个断点中断指令。因为局部变量不可能被执行,如果执行了,必然程序有错,这时发生中断来提示开发者。这是VC编译Debug版本的特有操作。相关代码如下:
lea edi,[ebp-0cch] ;本来是要mov edi,ebp-0cch,但是mov不支持-操作 ;所以对ebp-0cch取内容,而lea把内容的地址,也就 ;是ebp-0cch加载到edi中。目的是把保存局部变量 ;的区域(从ebp-0cch开始的区域)初始化成全 ;部0cccccccch mov ecx,33h mov eax,0cccccccch rep stos dword ptr [edi] ;串写入 |
(6)然后做函数里应该做的事情。参数的获取是ebp+8字节为第一个参数,ebp+12为第二个参数,依次增加。ebp+4字节处是要返回的地址。
(7)恢复ebx、esi、edi、esp、ebp,最后返回。代码如下:
pop edi ;恢复edi、esi、ebx pop esi pop ebx mov esp,ebp ;恢复原来的ebp和esp,让上一个调 ;用的函数正常使用 pop ebp ret |
为了简单起见,我的函数没有返回值。如果要返回值,函数应该在返回之前,把返回值放入eax中。外部通过eax得到返回值。
代码分析
用VC 2003编译Debug版本,完整的反汇编代码如下:
void myfunction(int a,int b) { push ebp ;保存ebp,并把esp放入ebp中。此时ebp与esp同 mov ebp,esp ;都是这次函数调用时的栈顶 sub esp,0cch ;把esp往上移动一个范围,等于在堆栈中放出一片新 ;的空间用来存储局部变量 push ebx ;下面保存三个寄存器:ebx、esi、edi push esi push edi lea edi,[ebp-0cch] ;本来是要"mov edi,ebp-0cch",但是mov不支持 ;"-"操作,所以对ebp-0cch取内容,而lea把内容 ;的地址,也就是ebp-0cch加载到edi中。目的是 ;把保存局部变量的区域(从ebp-0cch开始的区域) ;初始化成全部0cccccccch mov ecx,33h mov eax,0cccccccch rep stos dword ptr [edi] ;写入0cch指令(中断) int c = a+b; mov eax,dword ptr [a] ;简单的相加操作。这里从堆栈中取得从外部 ;传入的参数。那么,a和b到底是怎么取得的呢 add eax,dword ptr[b] ;通过ida反汇 ;编可以看到,其实这两条指令是 ;mov eax, [ebp+8],add eax, [ebp+0Ch] ;参数是通过ebp从堆栈中取得的。这里看到 ;的是VC调试器的显示结果,为了阅读方 ;便,直接加上了参数名 mov dword ptr[c],eax } pop edi ;恢复edi、esi、ebx pop esi pop ebx mov esp,ebp ;恢复原来的ebp和esp,让上一个调用的函数 ;正常使用 pop ebp ret |
主程序中对这个函数的调用方式是:
mov eax,dword ptr[b] ;把b、a两个参数压入堆栈 push eax mov ecx,dword ptr[a] push ecx call myfunction ;调用函数myfunction add esp,8 ;恢复堆栈 |
这样一来,函数调用的过程就很清楚了。在下一章开始,进一步介绍各种各样的C语言程序,变成了怎样的汇编指令。
重点观察那些涉及call、ret、push和pop,操作ebp和esp的指令,就能看到C语言函数的调用过程。