本文从汇编的视角分析了函数的调用方式,掌握该知识对使用 OllyDbg 等动态调试工具会大有裨益。
一、 函数参数传递形式
函数的参数传递有 2 种方式:
- 堆栈方式
- 寄存器方式
如果是堆栈方式传递的,就需要定义函数参数在堆栈中的传递顺序,并约定函数被调用之后,是由函数来平衡堆栈,还是由调用者来平衡堆栈;
如果是寄存器方式传递的,就需要确定参数存放在哪个寄存器中。
两种方式都有其优缺点,而且与使用的编程语言有关系。
我们在开发中经常遇到调用约定类型,如__cdecl
、stdcall
、PASCAL
、fastcall
,这些调用约定类型就用来指定函数参数的传递方式的。
上面几种约定类型,除了fastcall
是使用寄存器方式传递参数外,其他的都是使用堆栈传递参数的。
Visual Studio 中的 C++工程,可以
C++
–>高级
–>调用约定
中进行调用约定的设置:
二、使用堆栈方式传递函数参数
堆栈是一种“后进先出”的数据结构,ESP
寄存器始终指向栈顶。
ESP:栈指针寄存器(Extended Stack Pointer),永远指向栈顶。
栈中数据的地址从底部到顶部依次减小,也就是说,栈底对应高地址,栈顶对应低地址。
调用函数时,调用者依次把参数压栈,然后调用函数,函数被调用之后,在堆栈中取得参数数据。函数调用结束以后,堆栈需要恢复到函数调用之前的样子,而到底是由调用者来恢复还是函数自身来恢复,根据不同的调用约定类型采用的方式不同。
约定类型 | __cdecl | stdcall | PASCAL | fastcall |
---|---|---|---|---|
参数传递顺序 | 从右到左 | 从右到左 | 从左到右 | 使用寄存器 |
堆栈平衡者 | 调用者 | 函数自身 | 函数自身 | 函数自身 |
__cdcel
是 C/C++/MFC 程序默认的调用约定。stdcall
是绝大多数 Win32 API 函数的约定方式,也有少部分使用__cdcel
约定方式(如 wsprintf 等)。
在 Windows C/C++开发中常用的就是__cdecl
和stdcall
这两种调用约定。
举例说明,按照不同的“调用约定”调用函数int add(int a, int b)
。从调用者的视角来看,其汇编代码分别表示如下:
__cdecl
1 | push b ;参数按从右到左传递 |
stdcall
1 | push b ;参数按从右到左传递 |
在函数调用过程中,参数入栈的过程如图:
上图中,EBP
和函数返回地址ret
都是 32 位地址,堆栈中备份了老的EBP值,在函数调用完之后会将EBP恢复为备份在堆栈中的老EBP值,所以从调用者角度来看,在函数的调用开始前和结束后,EBP是不会变化的。
通常函数中的前2条指令都是:
1 | push ebp |
通常使用新的EBP
获取函数各个参数的值:
1 | 参数a = EBP + 0x8 |
当然使用ESP获取参数值也是可以的,但使用EBP会更加方便,因为ESP一直指向栈顶,因此会随着局部变量的压栈而变化,而ESP是不会随着入栈/出栈而变化的。
三、调用过程分析
1 |
|
我们使用Visual Studio 2017编译上面代码,并在在工程配置中将函数调用约定设置为__cdecl
。
在程序调试过程中,可以在Visual Studio的“反汇编窗口”中看到 C++代码对应的汇编代码,以及寄存器窗口中看到各个寄存器的值。
main
函数的反汇编代码如下:
1 | int main() |
执行进入add
函数后,add
函数内的汇编代码如下:
1 | int add(int a, int b) { |
在上面代码的注释中已经包含了详细的解释,特别值得注意的几个地方是:
rep stos dword ptr es:[edi]
结合edi
,ecx
来初始化局部存储区域。- 函数
call
指令之前的参数压栈顺序。 CALL
指令相当于执行一条PUSH
指令加一条JMP
指令,PUSH
指令用于压入该指令的下一条指令地址到栈中,用于执行完子函数之后返回来。JMP
指令用于跳转到子函数所在位置开始执行子函数。- 因为是
__cdecl
调用约定,因此在函数调用完之后,调用方需要使用add esp,8
指令来平衡堆栈。 ret
指令等同于:弹出此时栈顶的值给 eip,巧妙之处在于此时栈顶存储的刚好是函数返回地址,因此可以继续执行函数下面的语句。