前言:本文旨在让无汇编基础的人也能够理解调用约定,而理解函数调用约定最重要的就是理解函数调用过程中系统栈上发生了什么,本文便是着眼于此。
函数调用约定是什么
函数调用约定,是指当一个函数被调用时,函数的参数会被传递给被调用的函数和返回值会被返回给调用函数。函数的调用约定就是描述参数是怎么传递和由谁平衡堆栈的,当然还有返回值。 —《百度百科》
函数调用约定,从字面上来看,这是一个函数被调用时应该遵循的协议,应该被遵循的规范,那么谁来遵循呢?编译器。所谓函数调用约定指的就是当一个函数被调用时的一系列活动,包括参数入栈顺序,控制权的转交等。其中最最重要的就是栈的变化,系统栈中记录的正是函数的信息。我们甚至可以狭义的理解为函数调用约定即调用一个函数时,系统栈是如何变化的。
补充知识(有基础可跳过)
栈
栈是一种线性表的一种。仅能在栈的一端进行插入(入栈push)和删除(出栈pop)操作,这端被称为栈顶;与之相对的另一端,则被称为栈底,且不容许进行任何操作。
想象一下,线性表类比数组,元素一个挨一个,而一段操作受限制,另一端不能进行任何操作。你能想到什么?看到这不妨思考下,主动思考更加受益。
没错,我们可以把栈空间画出如下这样:开口的一端为栈顶,封闭一段为栈底。那么问题来了,栈底很显然我们能够轻易看出,但栈顶呢?我们如何界定栈顶?这个问题前人替我们解决了,他们用top指针标记栈顶的位置,并且top指针会随着push和pop操作而动态更新。
显然这2张图形象的表达了什么是栈,以及当栈进行push操作时,top是如何更新的,而pop则可类比。
push:
1.调整栈顶:top向上移一个单位,预留一个单位的空间,为元素入栈准备,同时更新栈顶
2.PUSH:top指针做好预备工作,元素入栈,填充预留空间,入栈完成。pop:
1.POP:将栈顶指向的元素弹出栈中。
2.调整栈顶:top下移,更新栈顶。
总结:由上面从我们如何确定栈的形状,到栈空间的动态变化中,可以得到以下结论。
1.由于栈只能在栈顶(top处)进行操作,从而导致先进后出的现象
2.当栈空时栈顶与栈底位于同一处。
3.push和pop操作如何动态更新栈(上面已经给出)。
4.栈底以及栈顶确定一段有效的栈空间。
汇编与系统栈的关系
汇编语言中存在与栈相关的指令以及寄存器。下面以x86汇编例子说明。
指令:
push a 将a放入栈中
pop b 将当前栈顶指向的元素出栈,并放入b中
call 等同2个指令:push EIP --> jmp [目标函数地址]
retn 等同pop EIP
call和retn都对EIP进行操作,这是与程序执行控制权的转交有关。
寄存器:
EBP:栈底指针
ESP:栈顶指针,相当于top。
EIP:用于标记下一条(是下一条,不是当前)应该被执行汇编指令或者说机器指令。EIP的指向位置,用来告诉CPU下一步应该怎么办,控制着程序执行流程,十分重要!通常情况下EIP是顺序移动,而上面以及下面所说控制权转交,就是主动更改EIP中的地址,类似于高级语言中分支和跳转。
其他相关寄存器:ESI, EDI,EBX
系统底层中用,EBP和ESP可以确定一段栈空间。
前面我们说过,系统栈就是用来记录函数调用时函数的一系列变化,而每个函数都有一段独属于自己的栈空间,这段栈空间是系统栈的子集,我们称为栈帧。其实这种思想还更好的体现在其他方面,例如每一个进程被创建的时候都会有独属于该进程的虚拟地址空间,这就如同函数调用时与栈帧的确立。
从宏观上看,系统栈可以认为以栈帧为基本单位。微观上,我需要了解栈帧有什么。
虽然每个栈帧的内容不尽相同,但幸运的是栈帧的确立有轨迹可循,每个栈帧都有以下内容:
1.保存母函数(也称为调用者caller)中重要信息,用于子函数(被调用者callee)调用结束时控制权转交给母函数和母函数现场恢复。
2.子函数信息。
现在不理解没关系,下面会纤细的说。
函数调用约定的详细活动
我将以C语言默认的Cdecl调用约定作为案例来分析,理解cdecl之后,其他调用约定殊途同归.
高级语言层面:
我用一个demo从汇编的角度来看,归纳函数调用约定发生了什么。这里我以main为上述母函数,func作为子函数来讲解。最后我们会将这个例子中蕴含的规律推而广之。
汇编层面:
下面的内容请务必结合我所绘制的系统栈结合观看。注意自己动态脑补操作。
我们先看哪里进行了影响系统栈的操作。我们先不去理会第一个红框,现在关注于第二个红框,也就是母函数main调用子函数func时栈空间如何变化,栈帧如何确立。
关注call func这条指令。我们发现在这之前进行了一系列push操作,而push的内容正是母函数main传入给func的三个参数。显然,不难看出,他们的入栈顺序是从右先左。这是我们看到,那么我们能否就cdecl调用约定推而广之呢?事实证明这正确的不能再正确了。
函数参数入栈顺序是从右先左。
调用call指令,控制权转交给子函数。这里暗含push EIP。
下面我们看看母函数main调用子函数func栈空间如何变化:
我们只需要关注红框即可,我会逐一解释。
1.创立新栈帧,开辟局部变量空间,保存母函数部分信息。
如何理解保存母函数信息?首先无论是ESP还是EBP这样有名字的寄存器都只有一个!我知道大家如果没学过汇编可以疑惑,寄存器入栈是什么鬼?我当年也是,其实这里不是寄存器入栈,入栈的是寄存器中的数据!为什么要这样做?没看过汇编程序会十分苦恼,有必要吗?确实,有些确实没必要,但有些必须要保持,比如母函数栈底信息,如果我们不操作,而是直接将EBP中的数据更新为func的栈底,那func调用结束我们怎么找回母函数的栈底,没有栈底母函数在栈帧中的数据就丢失了!大家都写过交换两变量数据的代码,通常我们会用一个临时变量tmp保持其中一个变量的数据就是这个道理,是为了防止丢失数据!简而言之,因为执行对应功能的寄存器只有一个,因此我们必须得要保存上一个函数使用EBP等寄存器的数据,以便恢复。
push ebp 母函数栈底信息入栈
mov ebp,esp 更新栈底指针,此时相当于我们上面说的空栈,esp = ebp。
sub esp, 0c0h 开辟局部变量空间
push 三个寄存器 保存母函数信息,与保存ebp同理。
2 and 3:函数调用结束阶段,恢复母函数数据,销毁func函数栈帧(esp以下的部分才是有效栈空间,只要esp下降了那么esp上面的部分就意味着失效,因此并不是抹除数据而是使其丢出栈空间有效作用域之外,这些非有效空间的数据会随着栈空间的活动而被覆盖)。至于2 和3红框之间的部分,也涉及了esp和ebp,这是一种保护机制,用来Check栈空间是否异常不用理会。
调用ret 这里等同于 pop eip将栈中保持的在母函数执行时的EIP数据返回给EIP寄存器,控制权转交给母函数。(什么控制权转交,pop push都是在栈顶段操作我都说了,不要看到这又懵逼了,没懂返回上面重新看,看补充知识。)
那么是时候解答最后一个疑问了。想必认真阅读本文的你已经发现,前我说栈帧是由每个函数的ebp和esp之间区域组成,那么我绘制的系统栈图中main esp不应该指向func栈底的下面一个单元吗?为什么把参数归于func栈帧呢?的确如此,按前面所说应该这样,那么现在我来解释为什么。(以下为个人理解和观察)
我们先看func的栈帧,func是最新的一个栈帧,在这func之上没有任何栈帧了,我们发现func栈顶的界限和main栈顶的界限完美符合。基于这个观察得出第一个点。
我们再来看看栈帧底部。func栈帧底部多了些参数,而mian栈帧底部没有,我想这才是大家困惑所在。main之所以没有参数,是因为我写的这个main没有参数!而fun有3个参数!也就是说是子函数是否有参数决定的,那么显然把参数划分到子函数栈帧十分合情合理,尽管子函数参数在子函数栈帧的栈底之下。
在和最后,main函数中紧接着call之后执行了一条add esp,0ch。这条指令的目的是为了栈平衡,用来销毁func参数。由于是main对func函数的参数进行销户,我们称之为调用者进行栈平衡,与之相对的被调用者进行栈平衡就是在子函数执行类似语意的指令。
那么我已经详细的说明了demo中func函数的栈帧的创建乃至销毁的过程,并适当的解释其中一些需要讲解的操作,避开不必要部分的说明。这个过程可以推广至其他函数,这就是调用约定主要内容,其他不同调用约定只是有席位区别。
我所画图中,什么main esp,main ebp只是为了便于各位理解才这样写的,有些会写old ebp,意之上一个函数的栈底,也就是母函数栈底信息。
函数调用约定的种类
x86平台
cdecl
C语言默认调用约定.
参数入栈顺序:从右向左
栈平衡:调用者(母函数)
stdcall
win32 API调用约定
参数入栈顺序:从右向左
栈平衡:被调用者(子函数)
fastcall
调用效率高效
参数入栈顺序:从右向左,其中Windows平台前2个参数进入寄存器,Linux平台前4个参数进入寄存器,并且会保留一片shadow space 空间
栈平衡:被调用者(子函数)
x64平台
fastcall
x64平台统一使用这个调用约定。
参数入栈顺序:从右向左,其中Windows平台前4个参数进入寄存器,Linux平台前6个参数进入寄存器,并且会保留一片shadow space 空间
栈平衡:调用者(母函数)