任何编程语言,函数都是很重要的一个概念。
算法要借助函数来实现。
面向过程编程的函数是其基本模块。
面向对象编程的函数(方法)是类或对象对其属性(数据的处理)。
函数式编程自然更是以函数为核心。
通常,程序的控制结构会确保一个入口、一个出口,支持函数的嵌套调用。如何确保代码的正确流动(包括正确返回原函数调用处)?编译器会维护一个栈的内存结构。
另外,关于堆栈平衡,可由调用函数负责,也可由被调函数负责。当有多个参数时,参数按什么顺序计算?这些都可由调用约定来进行规定,如:
void __stdcall add(int a,int b);
函数声明中的__stdcall就是关于调用约定的声明。其中标准C函数的默认调用约定是__stdcall,C 全局函数和静态成员函数的默认调用约定是__cdecl,类的成员函数的调用约定是__thiscall。剩下的还有__fastcall,__naked等。
调用约定指明了函数调用中的参数传递方式和堆栈平衡方式。
调用约定 堆栈平衡方式
__stdcall 函数自己平衡
__cdecl 调用者负责平衡
__thiscall 调用者负责平衡
__fastcall 调用者负责平衡
__naked 编译器不负责平衡,由编写者自己负责
简单的一个函数调用语句,其实对于编译器来说,是一个比较复杂的过程。
以下是一个函数嵌套调用的实例:
#include <iostream>
using namespace std;
int combinations(int n, int k);
int fact(int n);
int main()
{
int n, k;
cout << "Enter the number of objects (n): ";
cin >> n;
cout << "Enter the number to be chosen (k): ";
cin >> k;
cout << "C(n, k) = " << combinations(n, k) << endl; // 在这里设一断点
return 0;
}
int combinations(int n, int k) // C(n, k)
{
return fact(n) / (fact(k) * fact(n - k));
}
int fact(int n) // factorial of n
{
int result = 1;
for (int i = 1; i <= n; i )
result *= i;
return result;
}
整体流程如下:
编译后在上述备注处插入一断点(F9)→运行(F5),按提示输入:
Enter the number of objects (n): 6
Enter the number to be chosen (k): 2
运行至断点处,调出反汇编调试窗口,跟踪fact(6)的内部流程:
此时的调用堆栈是:
1 参数压栈(传参时,可能存在隐式类型转换)
push eax,表示将eax的值压和栈、内存栈,具体位置由寄存器esp给出。
寄存器ebp(base pointer )可称为“帧指针”或“基址指针”,其实语意是相同的。ebp指向了本次函数调用开始时的栈顶指针,它也是本次函数调用时的“栈底”(这里的意思是,在一次函数调用中,ebp向下是函数的临时变量使用的空间)。在函数调用开始时,我们会使用mov ebp,esp,把当前的esp保存在ebp中。
寄存器esp(stack pointer)可称为“ 栈指针”。esp指向当前的栈顶,它是动态变化的,随着我们申请更多的临时变量,esp值不断减小(栈是向下生长的)。函数调用结束,我们使用mov esp,ebp,来还原之前保存的esp。
在函数调用过程中,ebp和esp之间的空间被称为本次函数调用的“栈帧”。函数调用结束后,处于栈帧之前的所有内容都是本次函数调用过程中分配的临时变量,都需要被“返还”。这样在概念上,给了函数调用一个更明显的分界。
2 call fact(6)
0040193C call @ILT 165(fact) (004010aa)
00401941 add esp,4
call 相当于 push jmp。
2.1 push 返回地址00401941
2.2 jmp (fact) (004010aa)
004010AA jmp fact (004019a0)
也就是,首先把call指令的下一条指令地址作为本次函数调用的返回地址压栈,然后使用jmp指令修改指令指针寄存器EIP,使cpu执行 fact函数的指令代码。
指令指针寄存器也叫程序计数器,是用于存放下一条指令所在单元的地址的地方。
当执行一条指令时,首先需要根据PC中存放的指令地址,将指令由内存取到指令寄存器中,此过程称为“取指令”。与此同时,PC中的地址或自动加1或由转移指针给出下一条指令的地址。此后经过分析指令,执行指令。完成第一条指令的执行,而后根据PC取出第二条指令的地址,如此循环,执行每一条指令。
可以看到,程序计数器是一个cpu执行指令代码过程中的关键寄存器:它指向了当前计算机要执行的指令地址,CPU总是从程序计数器取出当前指令来执行。当指令执行后,程序计数器的值自动增加,指向下一条将要执行的指令。
在x86汇编中,执行程序计数器功能的寄存器被叫做EIP,也叫作指令指针寄存器。
3 一些寄存器压栈,保存其状态信息
004019A0 push ebp
004019A1 mov ebp,esp
004019A3 sub esp,48h
004019A6 push ebx
004019A7 push esi
004019A8 push edi
4 栈帧分配,并初始化
004019A9 lea edi,[ebp-48h]
004019AC mov ecx,12h
004019B1 mov eax,0CCCCCCCCh
004019B6 rep stos dword ptr [edi]
rep指令的目的是重复其上面的指令,ECX的值是重复的次数。
STOS指令的作用是将eax中的值拷贝到ES:EDI指向的地址。
5 局部变量压栈
004019B8 mov dword ptr [ebp-4],1
26: for (int i = 1; i <= n; i )
004019BF mov dword ptr [ebp-8],1
6 返回值(或地址)保存到寄存器eax
可以察看此时寄存器调试窗口:
返回值返回时,可能存在隐式数据类型转换。
7 一些寄存器值从栈上恢复
004019E8 pop edi
004019E9 pop esi
004019EA pop ebx
004019EB mov esp,ebp
004019ED pop ebp
8 ret
ret = pop jmp
004019EE ret
00401941 add esp,4
表示取出当前栈顶值,作为返回地址,并将指令指针寄存器EIP修改为该值,实现函数返回。
7 堆栈平衡00401941 add esp,4
9 中间值存储到寄存器
以下是combinations(int n, int k)整体的汇编代码(不包括函数调用时的进入):
18: int combinations(int n, int k) // C(n, k)
19: {
00401920 push ebp
00401921 mov ebp,esp
00401923 sub esp,40h
00401926 push ebx
00401927 push esi
00401928 push edi
00401929 lea edi,[ebp-40h]
0040192C mov ecx,10h
00401931 mov eax,0CCCCCCCCh
00401936 rep stos dword ptr [edi]
20: return fact(n) / (fact(k) * fact(n - k));
00401938 mov eax,dword ptr [ebp 8]
0040193B push eax
0040193C call @ILT 165(fact) (004010aa)
00401941 add esp,4
00401944 mov esi,eax
00401946 mov ecx,dword ptr [ebp 0Ch]
00401949 push ecx
0040194A call @ILT 165(fact) (004010aa)
0040194F add esp,4
00401952 mov edi,eax
00401954 mov edx,dword ptr [ebp 8]
00401957 sub edx,dword ptr [ebp 0Ch]
0040195A push edx
0040195B call @ILT 165(fact) (004010aa)
00401960 add esp,4
00401963 imul edi,eax
00401966 mov eax,esi
00401968 cdq
00401969 idiv eax,edi
21: }
0040196B pop edi
0040196C pop esi
0040196D pop ebx
0040196E add esp,40h
00401971 cmp ebp,esp
00401973 call __chkesp (00422890)
00401978 mov esp,ebp
0040197A pop ebp
0040197B ret
-End-
,