本文从汇编的视角分析了函数的调用方式,掌握该知识对使用 OllyDbg 等动态调试工具会大有裨益。

一、 函数参数传递形式

函数的参数传递有 2 种方式:

  • 堆栈方式
  • 寄存器方式

如果是堆栈方式传递的,就需要定义函数参数在堆栈中的传递顺序,并约定函数被调用之后,是由函数来平衡堆栈,还是由调用者来平衡堆栈;

如果是寄存器方式传递的,就需要确定参数存放在哪个寄存器中。

两种方式都有其优缺点,而且与使用的编程语言有关系。

我们在开发中经常遇到调用约定类型,如__cdeclstdcallPASCALfastcall,这些调用约定类型就用来指定函数参数的传递方式的。

上面几种约定类型,除了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++开发中常用的就是__cdeclstdcall这两种调用约定。

举例说明,按照不同的“调用约定”调用函数int add(int a, int b)。从调用者的视角来看,其汇编代码分别表示如下:

__cdecl

1
2
3
4
push b     ;参数按从右到左传递
push a
call add
add esp, 8 ;调用者在函数外部平衡堆栈

stdcall

1
2
3
push b     ;参数按从右到左传递
push a
call add ;函数自己内部平衡堆栈,调用者不需要平衡堆栈

在函数调用过程中,参数入栈的过程如图:

上图中,EBP函数返回地址ret都是 32 位地址,堆栈中备份了老的EBP值,在函数调用完之后会将EBP恢复为备份在堆栈中的老EBP值,所以从调用者角度来看,在函数的调用开始前和结束后,EBP是不会变化的。

通常函数中的前2条指令都是:

1
2
push        ebp 
mov ebp,esp

通常使用新的EBP获取函数各个参数的值:

1
2
参数a = EBP + 0x8
参数b = EBP + 0xC

当然使用ESP获取参数值也是可以的,但使用EBP会更加方便,因为ESP一直指向栈顶,因此会随着局部变量的压栈而变化,而ESP是不会随着入栈/出栈而变化的。

三、调用过程分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>

int add(int a, int b) {
int c = 0;

c = a + b;

return c;
}

int main()
{
int r = add(1, 2);
return 0;
}

我们使用Visual Studio 2017编译上面代码,并在在工程配置中将函数调用约定设置为__cdecl

在程序调试过程中,可以在Visual Studio的“反汇编窗口”中看到 C++代码对应的汇编代码,以及寄存器窗口中看到各个寄存器的值。

main函数的反汇编代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int main()
{
00DD1720 push ebp // 参见add函数中关于这一部分的解析
00DD1721 mov ebp,esp
00DD1723 sub esp,0CCh
00DD1729 push ebx
00DD172A push esi
00DD172B push edi
00DD172C lea edi,[ebp-0CCh]
00DD1732 mov ecx,33h
00DD1737 mov eax,0CCCCCCCCh
00DD173C rep stos dword ptr es:[edi]
int r = add(1, 2);
00DD173E push 2 // 参数b入栈
00DD1740 push 1 // 参数a入栈
// 调用add函数。
// CALL指令相当于执行一条PUSH指令加一条JMP指令,PUSH指令用于压入该指令的下一条指令地址到栈中,用于执行完子函数之后返回来。
// JMP指令用于跳转到子函数所在位置开始执行子函数。
00DD1742 call add (0DD1276h)
00DD1747 add esp,8 // 因为是__cdecl,所以由调用者来平衡堆栈.
00DD174A mov dword ptr [r],eax
return 0;
00DD174D xor eax,eax
}

执行进入add函数后,add函数内的汇编代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
int add(int a, int b) {
00DD16D0 push ebp // ebp入栈,相当于备份ebp的值
00DD16D1 mov ebp,esp // 将esp赋值给ebp,在该函数之后的执行过程中不会再改变ebp的值。
00DD16D3 sub esp,0CCh // 在栈上分配0xCC大小的局部变量存储区域
00DD16D9 push ebx // 暂存ebx
00DD16DA push esi // 暂存esi
00DD16DB push edi // 暂存edi
00DD16DC lea edi,[ebp-0CCh] //下面4行代码(含该条)实现将0xCC大小的局部变量存储区域全部赋值为0xCC
00DD16E2 mov ecx,33h // ecx存储循环次数,结合rep指令使用。为什么是0x33次了?因为是按照4个字节赋值的,0x33 * 0x4 = 0xCC
00DD16E7 mov eax,0CCCCCCCCh
00DD16EC rep stos dword ptr es:[edi] // 循环赋值
int c = 0;
00DD16EE mov dword ptr [c],0 // 将局部变量c赋值为0

c = a + b;
00DD16F5 mov eax,dword ptr [a]
00DD16F8 add eax,dword ptr [b]
00DD16FB mov dword ptr [c],eax

return c;
00DD16FE mov eax,dword ptr [c] // 将结果存储到eax中。在函数调用中返回结果都是存储在eax中的。
}
01191701 pop edi // 将edi的值还原到函数调用前
01191702 pop esi // 将esi的值还原到函数调用前
01191703 pop ebx // 将ebx的值还原到函数调用前
01191704 mov esp,ebp // 移动栈顶到ebp位置,从而跳过了局部变量存储区域
01191706 pop ebp // 将ebp的值还原到函数调用前
01191707 ret // ret指令等同于:弹出此时栈顶的值给eip,
// 因为此时栈顶存储的刚好是函数返回地址,所以相当于将返回地址赋值给eip,从而实现了返回到函数调用的地方。

在上面代码的注释中已经包含了详细的解释,特别值得注意的几个地方是:

  1. rep stos dword ptr es:[edi]结合edi, ecx来初始化局部存储区域。
  2. 函数call指令之前的参数压栈顺序。
  3. CALL指令相当于执行一条PUSH指令加一条JMP指令,PUSH指令用于压入该指令的下一条指令地址到栈中,用于执行完子函数之后返回来。JMP指令用于跳转到子函数所在位置开始执行子函数。
  4. 因为是__cdecl调用约定,因此在函数调用完之后,调用方需要使用add esp,8指令来平衡堆栈。
  5. ret指令等同于:弹出此时栈顶的值给 eip,巧妙之处在于此时栈顶存储的刚好是函数返回地址,因此可以继续执行函数下面的语句。