在MIT6.828的lecture 2 中提到了GCC在x86下的calling机制,因此做一个梳理,记录一下汇编程序是怎样和C程序对接的,它又是怎样调用C函数的。
C代码的函数调用机制
所谓函数调用机制,就是调用函数与被调用函数在怎样在它们之间返回参数和值,函数是怎样利用自己的栈上达成一致,这种方法论就是函数的调用机制。
这里用Microsoft Visual C compiler作为例子,先描述两个习惯:
__cdecl:
由于它支持C语言的语义,因此它是最常见的一个convention。C语言支持可变变量函数(例如其中的printf函数),这就意味着caller必须在函数调用完之后清理栈。
__stdcall:
这就需要每个函数都有大量固定的参数,这就意味着被调用函数可以在一个地方清理argument。
call stack
In computer science, a call stack is a stack data structure that stores information about the active subroutines of a computer program. –From Wikipedia
也就是说,调用栈(call stack)在计算机科学中指的就是一个栈数据结构,该结构存储了一个计算机程序的活跃子程序的信息。而调用栈(call stack)最主要的目的是当每个活跃的子程序完成执行时,追踪它们应该返回控制的指针。
stack frame
什么是stack frame?
A call stack is composed of stack frames (also called activation records or activation frames). These are machine dependent and ABI-dependent data structures containing subroutine state information.
栈帧(stack frame)可以组成调用栈(call stack)。它们仅仅是在某类具体PC上的包含子程序状态信息的数据结构。
也可以参考这里。
栈帧里的寄存器
栈帧中会遇到三个寄存器,它们是:
- %ESP - Stack Pointer
栈指针寄存器可由一些CPU指令进行操作,例如PUSH,POP,CALL,RET等,它总是指向栈中的最后一个元素。需要 注意 的是栈顶是一个已经被占据的位置,而不是一个空位置,并且它是在内存地址的最低处。
- %EBP - Base Pointer
基址指针寄存器指向当前栈帧中的函数参数以及局部变量。它不像%esp有许多指令可以操作。
- %EIP - Instruction Pointer
指令指针寄存器保存着CPU下一条要执行的指令的地址
调用的__cdecl函数
最好的方法当然是在该惯例下去看清调用一个函数的每一步中的栈组织。接下来看一下它的机制:
- Push参数到栈,先右后左
push参数到栈,先push低地址位,再高地址位。调用函数(calling code)必须追踪多少自己的参数被push到了栈中,以便之后将其清理掉。
- 调用函数
处理器push %EIP(指令指针)的值到栈中,并且它指向CALL指令之后的第一个字节。一旦完成,那么该代码就失去控制权,移交给 被调用函数。这一步不会改变 %EBP的值。
- 保存并更新 %EBP
由于我们现在在一个新的函数里,因此我们需要一个新的局部的栈帧(new stack frame),通过保存当前的 %EBP(属于前函数的frame)就能完成。当然,还要使它指向栈顶。
push ebp
mov ebp , esp
一旦 %EBP被改变,它就能直接访问函数的参数,比如8(%ebp), 12(%ebp)。
-
分配局部变量
-
操作函数
此刻函数的栈帧就建立起来了,可以在图中看到,并且所有参数和局部变量的偏移如下:
16(%ebp) third function parameter
12(%ebp) second function parameter
8(%ebp) first function parameter
4(%ebp) old %EIP (the function's "return address")
0(%ebp) old %EBP (previous function's base pointer)
-4(%ebp) first local variable
-8(%ebp) second local variable
-12(%ebp) third local variable
-
释放局部存储
-
修复保存的寄存器 防止发生栈出错。
-
修复old基址指针
该函数已进入首先要做的就是保存调用者的 %EBP基址指针,并在现在修复它(pop)。
- 从函数返回
这是被调用函数要做的最后一步,RET指令从栈上POP出old %EIP并跳到那条指令。这样就重新将控制权还给调用函数(calling function)。 只有栈指针和指令指针在子程序的返回中变化过。
- 清理Push的参数
依照__cdecl的惯例需要清理栈。
这样就完成了从caller到callee再回到caller的过程。
参考资料