函数调用约定与相关指令
函数调用约定描述了函数传递参数方式和栈协同工作的技术细节。不同的操作系统、不同的语言、不同的编译器在实现函数调用时的原理虽然基本相同,但具体的调用约定还是有差别的。这包括参数传递方式,参数入栈顺序是从右向左还是从左向右,函数返回时恢复堆栈平衡的操作在子函数中进行还是在母函数中进行。表4-1-1列出了几种调用方式之间的差异。
表4-1-1 调用方式之间的差异
| C | SysCall | StdCall | BASIC | FORTRAN | PASCAL |
参数入栈顺序 | 右→左 | 右→左 | 右→左 | 左→右 | 左→右 | 左→右 |
恢复栈平衡操作的位置 | 母函数 | 子函数 | 子函数 | 子函数 | 子函数 | 子函数 |
调用约定的声明 | 参数入栈顺序 | 恢复栈平衡的位置 |
__cdecl | 右→左 | 母函数 |
__fastcall | 右→左 | 子函数 |
__stdcall | 右→左 | 子函数 |
如果要明确使用某一种调用约定,只需要在函数前加上调用约定的声明即可,否则默认情况下,VC会使用__stdcall的调用方式。本篇中所讨论的技术在不加额外说明的情况下,都是指这种默认的__stdcall调用方式。
除了上边的参数入栈方向和恢复栈平衡操作位置的不同之外,参数传递有时也会有所不同。例如,每一个C++类成员函数都有一个this指针,在Windows平台中,这个指针一般是用ECX寄存器来传递的,但如果用GCC编译器编译,这个指针会作为最后一个参数压入栈中。
注意:同一段代码用不同的编译选项、不同的编译器编译链接后,得到的可执行文件会有很多不同。因此,请您在进行后续实验前务必注意实验环境的描述,否则所得结果可能会与实验指导有所差异。
函数调用大致包括以下几个步骤。
(1)参数入栈:将参数从右向左依次压入系统栈中。
(2)返回地址入栈:将当前代码区调用指令的下一条指令地址压入栈中,供函数返回时继续执行。
(3)代码区跳转:处理器从当前代码区跳转到被调用函数的入口处。
(4)栈帧调整:具体包括。
保存当前栈帧状态值,已备后面恢复本栈帧时使用(EBP入栈)。
将当前栈帧切换到新栈帧(将ESP值装入EBP,更新栈帧底部)。
给新栈帧分配空间(把ESP减去所需空间的大小,抬高栈顶)。
对于__stdcall调用约定,函数调用时用到的指令序列大致如下。
;调用前 |
![]() |
图4.1.7 函数调用时系统栈的变化过程 |
题外话:关于栈帧的划分,不同参考书中有不同的约定。有的参考文献中把返回地址和前栈帧EBP值做为一个栈帧的顶部元素,而有的则将其做为栈帧的底部进行划分。在后面的调试中,您会发现OllyDbg在栈区标示出的栈帧是按照前栈帧EBP值进行分界的,也就是说,前栈帧EBP值既属于上一个栈帧,也属于下一个栈帧,这样划分栈帧后,返回地址就成为了栈帧顶部的数据。出于前后概念一致的目的,在本书中将坚持按照EBP与ESP之间的部分做为一个栈帧的原则进行划分。这样划分出的栈帧如图4.1.7最后一幅图所示,栈帧的底部存放着前栈帧EBP,栈帧的顶部存放着返回地址。划分栈帧只是为了更清晰地了解系统栈的运作过程,并不会影响它实际的工作。
类似地,函数返回的步骤如下。
(1)保存返回值:通常将函数的返回值保存在寄存器EAX中。
(2)弹出当前栈帧,恢复上一个栈帧。
具体包括:
在堆栈平衡的基础上,给ESP加上栈帧的大小,降低栈顶,回收当前栈帧的空间。
将当前栈帧底部保存的前栈帧EBP值弹入EBP寄存器,恢复出上一个栈帧。
将函数返回地址弹给EIP寄存器。
(3)跳转:按照函数返回地址跳回母函数中继续执行。
还是以C语言和Win32平台为例,函数返回时的相关的指令序列如下。
add esp, xxx ; 降低栈顶,回收当前的栈帧 |
按照这样的函数调用约定组织起来的系统栈结构如图4.1.8所示。
题外话:Win32平台下有很多寄存器,Intel指令集中的指令也有很多,现在立刻一一介绍它们无疑相当于给已经满头雾水的您再浇一桶冷水。虽然这里仅仅列出了3个寄存器和几条指令的作用,但只要您完全理解它们,就一定能顺利理解本书的后续章节,因为它们是栈溢出利用的关键,也是计算机架构的核心所在。当然,入门以后要想提高到一个新的层次,用《IBM X86汇编》或者《Win32 汇编》恶补一下汇编知识是非常必要的。
![]() |
图4.1.8 函数调用的实现 |