从汇编的角度分析函数调用过程
本文从汇编的视角分析了函数的调用方式,掌握该知识对使用 OllyDbg/x64dbg 等调试工具会大有裨益。
函数参数传递形式
函数的参数传递有 2 种方式:
- 堆栈方式,通过内存堆栈传递参数,需要定义函数参数在堆栈中的传递顺序,并约定函数被调用之后,是由函数来平衡堆栈,还是由调用者来平衡堆栈。
- 寄存器方式,通过 CPU 寄存器传递参数,需要确定参数存放在哪个寄存器中。
我们在开发中经常遇到调用约定类型,如__cdecl、stdcall、PASCAL、fastcall,这些调用约定类型就用来指定函数参数的传递方式的。
<原文出自: jiangxueqiao.com,请尊重原创>
上面几种约定类型,除了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 是不会随着入栈/出栈而变化的。
调用过程分析
本节介绍的是 32 位应用程序,对于 x64 应用程序,前 4 个参数始终通过 rcx、rdx、r8、r9 寄存器传递,从第 5 个参数开始才通过堆栈来传递,详见下面章节。
1 |
|
<原文出自: jiangxueqiao.com,请尊重原创>
我们使用 Visual Studio 2017 以 x86 架构编译上面代码,并在在工程配置中将函数调用约定设置为__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,巧妙之处在于此时栈顶存储的刚好是函数返回地址,因此可以继续执行函数下面的语句。
x64 架构下的函数调用约定
上面章节讨论的内容主要针对 32 位(x86)应用程序。在 64 位(x64)架构下,特别是遵循 Microsoft x64 调用约定时,规则有显著不同,其核心特点是优先使用寄存器传递参数。
关键规则如下:
整数或指针参数:前 4 个参数分别通过
RCX、RDX、R8、R9这四个寄存器传递。- 第 1 个参数 ->
RCX - 第 2 个参数 ->
RDX - 第 3 个参数 ->
R8 - 第 4 个参数 ->
R9
- 第 1 个参数 ->
浮点参数:前 4 个浮点参数通过
XMM0、XMM1、XMM2、XMM3寄存器传递。额外参数:从第 5 个参数开始,通过堆栈传递,顺序依然是从右到左。调用者还必须在堆栈上为前 4 个参数预留空间(每个参数至少占 8 字节),这部分空间称为影子空间或主帧空间。
堆栈平衡:由调用者负责平衡堆栈,这一点类似于
__cdecl约定。函数内部使用ret指令返回。
示例对比:
对于函数调用 result = calculate(a, b, c, d, e, f);
在 x86 (使用
__cdecl) 下:1
2
3
4
5
6
7
8push f ; 第6个参数
push e ; 第5个参数
push d ; 第4个参数
push c ; 第3个参数
push b ; 第2个参数
push a ; 第1个参数
call calculate
add esp, 24 ; 调用者平衡堆栈 (6个参数 * 4字节 = 24字节)在 x64 (Microsoft ABI) 下:
1
2
3
4
5
6
7
8
9mov r9, d ; 第4个参数 -> r9
mov r8, c ; 第3个参数 -> r8
mov rdx, b ; 第2个参数 -> rdx
mov rcx, a ; 第1个参数 -> rcx
sub rsp, 28h ; 为影子空间(20h)和对齐(8h)调整栈指针
push f ; 第6个参数入栈
push e ; 第5个参数入栈
call calculate
add rsp, 38h ; 调用者平衡堆栈 (2个栈参数*8字节=10h + 影子空间20h + 对齐8h = 38h)