C语言中函数调用方式与栈原理
在一个C函数被调用时,一个帧栈是如何被建立,由如何被消除的。这些细节跟操作系统平台及编译器的实现有关,下面的描述是针对运行在Intel奔腾芯片上Linux的gcc编译器而言。c语言的标准并没有描述实现的方式,所以,不同的编译器,处理器,操作系统都可能有自己的建立栈帧的方式。
主要概念:
- ESP指向栈顶,EBP相当于基准指针,调用者传递的参数和函数内部的局部变量都是通过此基准指针加偏移量获取
- 被调用者允许使用EAX、ECX、EDX寄存器,如果调用者需要保存这些寄存器中原有的值,则需要在函数调用前显式地保存在栈中(注意由调用者保存);如果还想使用其他寄存器EBX、ESI、ESI,则被调用者需要在栈中保存这些额外使用的寄存器,并在调用返回前恢复他们(注意由被调用者保存和恢复);
- 传给函数的参数,可以用寄存器传递,也可以用栈传递。如果是栈传递,则后最后一个参数先进栈,第一个参数位于栈顶。
- 函数内部的局部变量和临时变量,被保存在栈上。
- C语言使用EAX寄存器保存返回值。
函数调用前
假设调用者为main,被调用者为foo(12, 15, 18)。在函数调用前,main正在用ESP和EBP寄存器指示它自己的栈帧。
假设使用寄存器传参,如需要保存寄存器中原值,将则EAX,ECX和EDX压栈(每次压栈和出栈,esp的值会自动变化),然后将参数值保存到寄存器中;假设使用栈传参,则加参数压入栈中。
接着使用call foo指令调用函数。call执行执行时,EIP指令指针寄存器内容压栈,其中保存的是下一条指令,因此就是返回地址压栈,处于栈顶。
被调用者的动作
call执行后,开始执行foo标记处指令,此时被调用者取得程序的控制权,它必须做3件事:建立它自己的栈帧,为局部变量分配空间,最后,如果需要,保存寄存器EBX,ESI和EDI的值。
(1)建立帧栈:EBP寄存器现在指向调用者帧栈的某个位置,要使用它,则需要将原值压栈保存,因此EBP进栈,然后将ESP赋值给EBP(即EBP现在保存此时栈顶,即函数刚进入时的位置),之后函数的参数就都可以根据EBP加偏移量来获取了。因此所有的c函数都由如下两个指令开始:
push ebp
mov ebp, esp // 将esp赋值给ebp
(2) 分配空间,局部变量和临时变量,通过简单移动esp指针。
sub esp, 20
因为变量都是紧挨着EBP,因此通过EBP加偏移量就能获得到他们。如果要使用其他寄存器,则将它们的原值保存在栈中。
现在foo函数体可以执行了,这其中也许有进栈、出栈的动作,栈指针ESP也会上下移动,但EBP是保持不变的。这意味这我们可以一直用【EBP+X】的形式一直找到实际的参数,而不管在函数中有多少进出栈的动作。函数foo的执行也许还会调用别的函数,甚至递归地调用foo本身。然而,只要EBP寄存器在这些子调用返回时被恢复,就可以继续用EBP加上偏移量的方式访问实际参数,局部变量和临时存储。
被调用者返回前的动作
C语言中返回值必须返回在EAX寄存器中,但当返回值占用多于4个或8个字节时,接收返回值的变量地址会作为一个额外的指针参数被传到函数中,而函数本身就不需要返回值了。这种情况下,被调用者直接通过内存拷贝把返回值直接拷贝到接收地址,从而省去了一次通过栈的中转拷贝。
其次,foo如果使用了额外寄存器,需要恢复这些寄存器的值。也就是将他们出栈。
接着,我们不再需要foo的局部变量和临时存储了,我们可以通过下面的指令消除栈帧:
mov esp, ebp // 将ebp赋值给esp,这样小于新的esp的内存都被废弃
pop ebp // 弹出ebp后,现在栈顶就是返回地址了
最后,弹出位于栈顶的返回地址,赋值给EIP寄存器。
i386指令集有一条“leave”指令,它与上面提到的mov和pop指令所作的动作完全相同。所以,C函数通常以这样的指令结束:
leave
ret
调用者在返回后的动作
程序控制权返回到调用者(也就是我们例子中的main)后,如果栈中有传递的参数(通过栈传递参数时),将参数出栈,只需要简单的移动rsp指针即可:
add esp, 12
如果栈中有原EAX,ECX和EDX寄存器的值,现在也把他们弹出。这个动作之后,栈顶就回到了我们开始整个函数调用过程前的位置。