在C中,函数是程序的核心,函数不能嵌套定义但可以嵌套调用,最简单的方式就是在main函数中调用其它自定义函数或库函数,调用函数时,代码执行顺序会发生跳转(函数名是存储函数代码序列的起始地址),如何确保正确地回溯到原来的调用处是编译器所需要考量的,C 编译器的做法是维护一个栈来确保函数的调用和返回。就如果去某一个地方,经过了若干路口,用一张纸记下路口及路口间的标志性景观,回程时,按倒序回溯即可。
1 顺序存储与堆栈平衡内存存储是一个线性结构,数据和代码在内存中都是顺序存储,以字节(8个位)为单位进行顺序编址。
栈是程序设计中的一种经典数据结构,每个程序都拥有自己的程序栈,每个函数都有自己的栈帧。很重要的一点是,栈是向下生长的(由编译器计算函数栈帧所需内存空间)。所谓向下生长是指从内存高地址向低地址的路径延伸,栈有栈底和栈顶,那么栈顶的地址要比栈底低。对x86体系的CPU而言,其中:
ESP:栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶。
EBP:基址指针寄存器(extended base pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部。
函数栈帧:ESP和EBP之间的内存空间为当前栈帧,EBP标识了当前栈帧的底部,ESP标识了当前栈帧的顶部。
在C和C 语言中,函数的临时变量分配在栈中,临时变量拥有函数级的生命周期,即“在当前函数中有效,在函数外无效”。这种现象就是函数调用过程中的参数压栈,堆栈平衡所带来的。
堆栈平衡这个概念指的是函数调完成后,要返还所有使用过的栈空间(也就是将esp、ebp恢复到原来的位置)。
函数栈的push和pop操作隐含改变esp的指向:
push ebx ↔ *(esp-4)=ebx
pop ebx ↔ ebx=*(esp 4)
正如同迷宫探索一样,需确保从一个路径逐层深入后能够回溯回来,这就是栈的机制,通过维护一个栈顶和栈顶指针来确保平衡。
2 代码顺序执行数据和代码顺序存储,代码顺序执行。CPU使用一个寄存器(程序计数器)存放下一条指令所在单元的地址。
当执行一条指令时,首先需要根据PC中存放的指令地址,将指令由内存取到指令寄存器中,此过程称为“取指令”。与此同时,PC中的地址或自动加1或由转移指针给出下一条指令的地址。此后经过分析指令,执行指令。完成第一条指令的执行,而后根据PC取出第二条指令的地址,如此循环,执行每一条指令。
可以看到,程序计数器是一个cpu执行指令代码过程中的关键寄存器:它指向了当前计算机要执行的指令地址,CPU总是从程序计数器取出当前指令来执行。当指令执行后,程序计数器的值自动增加,指向下一条将要执行的指令。
在x86汇编中,执行程序计数器功能的寄存器被叫做EIP,也叫作指令指针寄存器。
指令寄存器(extended instruction pointer), 其内存放着一个指针,该指针永远指向下一条待执行的指令地址。
当然,在控制结构中有选择和循环结构,通过一个产生逻辑结果的比较表达式或逻辑表达式来决定程序的跳转地址,比较表达式通过一个减法表达式来计算,影响标志寄存器的值。所以,选择和循环结果的实质也在一个顺序的代码序列中进行跳转而已。
3 函数的参数传递和调用约定函数的参数传递是一个参数压栈的过程。函数的所有参数,都会依次被push到栈中。那调用约定又是什么呢?
C和C 程序员应该对所谓的调用约定有一定的印象,就像下面这种代码:
void __stdcall add(int a,int b);
函数声明中的__stdcall就是关于调用约定的声明。其中标准C函数的默认调用约定是__stdcall,C 全局函数和静态成员函数的默认调用约定是__cdecl,类的成员函数的调用约定是__thiscall。剩下的还有__fastcall,__naked等。
为什么要用所谓的调用约定?调用约定其实是一种约定方式,它指明了函数调用中的参数传递方式和堆栈平衡方式。
3.1 参数传递方式
int func(int n,int *out)
{
int sum=0;
for(int i=0;i<=n;i )
sum =i;
*out = sum;
return sum;
}
func函数有2个参数,int n,int *out。这两个参数,入栈的顺序谁先谁后?
其实是从左到右入栈还是从右到左入栈都可以,只要函数调用者和函数内部使用相同的顺序存取参数即可。在上述的所有调用约定中,参数总是从右到左压栈,也就是最后一个参数先入栈。
14: int b = 5;
0040DE68 mov dword ptr [ebp-4],5
15: int c = 0;
0040DE6F mov dword ptr [ebp-8],0
16: int d = func(b,&c);
0040DE76 lea eax,[ebp-8] //
0040DE79 push eax
0040DE7A mov ecx,dword ptr [ebp-4]
0040DE7D push ecx
0040DE7E call @ILT 15(ff) (00401014)
0040DE83 add esp,8
0040DE86 mov dword ptr [ebp-0Ch],eax
其实从这里我们就可以理解为什么在函数内部,不能改变函数外部参数的值:因为函数内部访问到的参数其实是压入栈的变量值,对它的修改只是修改了栈中的"副本"。指针和引用参数才能真正地改变外部变量的值。
3.2 堆栈平衡方式
因为函数调用过程中,参数需要压栈,所以在函数调用结束后,用于函数调用的压栈参数也需要退栈。那这个工作是交给调用者完成,还是在函数内部自己完成?其实两种都可以。调用者负责平衡堆栈的主要好处是可以实现可变参数,因为在参数可变的情况下,只有调用者才知道具体的压栈参数有几个。
下面列出了常见调用约定的堆栈平衡方式:
调用约定 堆栈平衡方式
__stdcall 函数自己平衡
__cdecl 调用者负责平衡
__thiscall 调用者负责平衡
__fastcall 调用者负责平衡
__naked 编译器不负责平衡,由编写者自己负责
在C 中,堆栈平衡的代码由编译器自动生成(调试时,可以调出反汇编窗口查看)。
4 函数调用过程函数调用过程主要由参数传递、地址跳转、局部变量分配和赋初值、执行函数体、结果返回、堆栈平衡等几个步骤组成。
a 参数入栈:将实参值从右向左依次压入系统栈中;
b 返回地址入栈:将当前代码区调用指令的下一条指令地址压入栈中,供函数返回时继续执行,一般由call指令完成;
c 代码区跳转:处理器从当前代码区跳转到被调用函数的入口处,一般也是由call指令完成;
d 栈帧调整:具体包括:
① 保存当前栈帧状态值,以便在后面恢复本栈帧时使用(EBP入栈);
② 将当前栈帧切换到新栈帧。(将ESP值装入EBP,更新栈帧底部);
③ 给新栈帧分配空间。(把ESP减去所需空间的大小,抬高栈顶);
以下以下面的实例对应的汇编来分析:
#include <stdio.h>
int func(int n,int *out)
{
int sum=0;
for(int i=0;i<=n;i )
sum =i;
*out = sum;
return sum;
}
int main()
{
int b = 5;
int c = 0;
int d = func(b,&c);
printf("%d %d",c,d); //15 15
getchar();
return 0;
}
4.1 从main()函数开始
12: int main()
13: {
0040DE50 push ebp
0040DE51 mov ebp,esp
0040DE53 sub esp,50h
0040DE56 push ebx
0040DE57 push esi
0040DE58 push edi
0040DE59 lea edi,[ebp-50h]
0040DE5C mov ecx,14h
0040DE61 mov eax,0CCCCCCCCh
0040DE66 rep stos dword ptr [edi]
14: int b = 5;
0040DE68 mov dword ptr [ebp-4],5
15: int c = 0;
0040DE6F mov dword ptr [ebp-8],0
debug跟release在初始化变量时所做的操作是不同的,debug是将每个字节位都赋成0xcc,而release不会。上述汇编的rep stos即重复将50h字节的栈空间的每一字节填充0xCC。
4.2 调用func(),包括压参、函数跳转、堆栈平衡和值返回。
实参传递给形参。在底层实现上,即是实参按照函数调用规定压入堆栈。参数传递完成后就通过CALL指令由当前程序跳转到子程序处。
jmp修改EIP的值实现跳转。
返回地址压栈。
16: int d = func(b,&c);
0040DE76 lea eax,[ebp-8]
0040DE79 push eax
0040DE7A mov ecx,dword ptr [ebp-4]
0040DE7D push ecx
0040DE7E call @ILT 15(ff) (00401014)
在进行call操作之后,会自动将call的下一条语句作为函数的返回地址保存在栈中。
call指令会把它的下一条指令的地址作为函数的返回地址压入堆栈中,然后跳转到它调用函数的开头处。而单纯的jmp是不会这样做的。
call的本质相当于push jmp。
4.3 跳转进入函数代码块
相关寄存器压栈,编译器计算并分配函数所需栈帧空间并将空间进行初始化:
3: int func(int n,int *out)
4: {
00401080 push ebp
00401081 mov ebp,esp
00401083 sub esp,48h
00401086 push ebx
00401087 push esi
00401088 push edi
00401089 lea edi,[ebp-48h]
0040108C mov ecx,12h
00401091 mov eax,0CCCCCCCCh
00401096 rep stos dword ptr [edi]
上面汇编代码对应上图的③-⑥。
4.4 局部变量分配并赋值
函数的"{"被认为是分配局部变量空间的时机。在汇编层面局部变量分配体现为堆栈中以EBP寄存器为基址向低地址端分配的一个连续区域,通过EBP寄存器的相对寻址方式来寻址函数内的局部变量。由于堆栈增长的方向是高地址端到低地址端,因此函数中先定义的局部变量地址较大,后定义的变量地址逐渐变小,相邻定义的变量其地址一定相邻。由于全局数据和局部数据定义在不用的数据区而并不与局部变量相邻,根据程序局部性原理,相邻的数据会被缓存,因此对相同的运算,局部变量作为操作数的运算效率就可能高于有全局变量参与的运算。同时,局部变量分配和回收只需要移动堆栈指针ESP,因此效率最高。
4.5 寻址函数的参数
参数存放在以EBP为基址的高地址端。对参数的访问同样是通过EBP寄存器相对寻址操作来实现。
4.6 执行函数体内的语句
函数内和具体功能相关的语句被转化成一系列汇编语句。
4.7 返回值
return语句将返回值返回到主调函数。在底层,参数是通过EAX寄存器或EDX寄存器传递给主调函数。或浮点计算单元的寄存器(浮点数返回值),或在主调函数中预先开辟栈空间来保存被调函数的返回值(自定义函数返回值)。
5: int sum=0;
00401098 mov dword ptr [ebp-4],0
6: for(int i=0;i<=n;i )
0040109F mov dword ptr [ebp-8],0
004010A6 jmp func 31h (004010b1)
004010A8 mov eax,dword ptr [ebp-8]
004010AB add eax,1
004010AE mov dword ptr [ebp-8],eax
004010B1 mov ecx,dword ptr [ebp-8]
004010B4 cmp ecx,dword ptr [ebp 8]
004010B7 jg func 44h (004010c4)
7: sum =i;
004010B9 mov edx,dword ptr [ebp-4]
004010BC add edx,dword ptr [ebp-8]
004010BF mov dword ptr [ebp-4],edx
004010C2 jmp func 28h (004010a8)
8: *out = sum;
004010C4 mov eax,dword ptr [ebp 0Ch]
004010C7 mov ecx,dword ptr [ebp-4]
004010CA mov dword ptr [eax],ecx
9: return sum;
004010CC mov eax,dword ptr [ebp-4]
10: }
4.8 堆栈平衡
堆栈平衡指的是将函数调用前压入堆栈的参数弹出堆栈,使堆栈恢复到其调用前的状态。由于函数调用完成后,参数就是无用的数据了,因此需要将其移出堆栈。
在C语言中不需要进行堆栈平衡(由编译器自动生成堆栈平衡代码)。而在汇编层面上却根据调用约定来确定由主调函数或是被调函数完成堆栈平衡。
10: }
004010CF pop edi
004010D0 pop esi
004010D1 pop ebx
004010D2 mov esp,ebp
004010D4 pop ebp
004010D5 ret
ret会自动弹出栈顶的返回地址,修改EIP的值,从而实现近转移。ret的本质相当于pop jmp。
4.9 返回主调函数
函数的"}"被解释为函数体已经执行完。遇到"}"时,会将堆栈中的局部变量、程序中压入堆栈的寄存器的值全部弹出,将之前CALL指令执行时压入堆栈的函数返回地址弹到指令指针寄存器EIP,从而返回到主调函数。
0040DE83 add esp,8
0040DE86 mov dword ptr [ebp-0Ch],eax
上面的8代表两个参数所需要的字节数,如果被调函数是int func(int n,int *out,double d); 则汇编代码应该是add esp, 10h,其中的10h即是十进制的16。
在函数调用过程中,所有调用信息(返回地址、参数)以及自动变量都是放在栈中的。若函数的声明与实现不同(参数、返回值、调用方式),就会产生错误――――但 Debug 方式下,栈的访问通过 EBP 寄存器保存的地址实现,如果没有发生数组越界之类的错误(或是越界“不多”),函数通常能正常执行;Release 方式下,优化会省略 EBP 栈基址指针,这样通过一个全局指针访问栈就会造成返回地址错误使程序崩溃。
-End-
Copyright © 2024 妖气游戏网 www.17u1u.com All Rights Reserved