函数的调用过程,从汇编和内存的角度分析

函数的调用过程 —— 从汇编和内存的角度分析

程序的执行过程可看作连续的函数调用。当一个函数执行完毕时,程序要回到调用指令的下一条指令(紧接call指令)处继续执行。函数调用过程通常使用堆栈实现,每个用户态进程对应一个调用栈结构(call stack)。编译器使用堆栈传递函数参数、保存返回地址、临时保存寄存器原有值(即函数调用的上下文)以备恢复以及存储本地局部变量。

一、汇编语言基础知识回顾

1、X86汇编语言的差异


X86 下常用的汇编有 AT&T 与 Intel 两种,二者在语法上有一定的差异:

前缀/后缀

区别:

Inte语法 AT&T语法
寄存器 加前缀 %
加前缀 $
十六进制 加后缀h 数字前加 0x
二进制 加后缀b

例子:

Intel语法 AT&T语法
mov eax,8 movl $8,%eax
mov ebx,0ffffh movl $0xffff,%ebx
int 80h int $0x80
操作数的方向

  • 二者方向正好相反

  • Intel语法,第一个是目的操作数,第二个是源操作数。

  • AT&T中,第一个数是源操作数,第二个数是目的操作数。(更符合阅读习惯哈)

例子:

Intel语法(<==) AT&T语法(==>)
mov eax,[ecx] movl (%ecx),%eax
内存单元操作数
  • 在Intel的语法中,基寄存器用“[]”括起来
  • 而在AT&T中,用“()”括起来。
Intel AT&T
mov eax,[ebx+5] movl 5(%ebx),%eax
间接寻址方式
  • Intel的指令格式是segreg:[base+index*scale+disp]
  • AT&T的格式是%segreg:disp(base,index,scale)
Intel语法 AT&T语法
指令 foo,segreg:[base+index*scale+disp] 指令 %segreg:disp(base,index,scale),foo
mov eax,[ebx+20h] Movl 0x20(%ebx),%eax
add eax,[ebx+ecx*2h] Addl (%ebx,%ecx,0x2),%eax
lea eax,[ebx+ecx] Leal (%ebx,%ecx),%eax
sub eax,[ebx+ecx*4h-20h] Subl -0x20(%ebx,%ecx,0x4),%eax
操作码的后缀
  • 在AT&T的操作码后加后缀,“l”(long,32位),“w”(word,16位),“b”(byte,8位)
  • 在Intel的语法中,在操作数的前加byte ptr、 word ptr 或 dword ptr

例子:

Intel语法 AT&T语法
Mov al,bl movb %bl,%al
Mov ax,bx movw %bx,%ax
Mov eax,ebx movl %ebx,%eax
Mov eax, dword ptr [ebx] movl (%ebx),%eax

2、X86 汇编语言中CPU上的通用寄存器简要说明

eax, ebx, ecx, edx, esi, edi, ebp, esp等都是X86 汇编语言中CPU上的通用寄存器的名称,是32位的寄存器。如果用C语言来解释,可以把这些寄存器当作变量看待。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2uewWudE-1619100547695)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20210422220308002.png)]

图1 IA32处理器寄存器

EAX :“累加器”(accumulator), 一般用于存放函数的返回值。

EBX:“基地址”(base)寄存器, 存放局部变量。

ECX :计数器(counter), 是重复(REP)前缀指令和LOOP指令的内定计数器。

EDX :总是被用来放整数除法产生的余数。

ESI/EDI:分别叫做"源/目标索引寄存器"(source/destination index),因为在很多字符串操作指令中, DS:ESI指向源串,而ES:EDI指向目标串.用来存放局部变量的值。

ESP:堆栈寄存器,存放当前线程的栈顶指针。

EBP:基址寄存器,存放当前线程的栈底指针。

EIP:指令指针寄存器,寄存器存放下一个CPU指令存放的内存地址,当CPU执行完当前的指令后,从EIP寄存器中读取下一条指令的内存地址,然后继续执行。我们cpu每次执行控制器读取完,相应的就在通过eip寄存器去进行下一次的读取指令工作。。每次cpu读取指令到指令缓冲区,相应的eip寄存器的值增加,增加大小的就是读取指令的字节大小(也可以说是长度)。。

3、寄存器使用约定

​ 程序寄存器组是唯一能被所有函数共享的资源。虽然某一时刻只有一个函数在执行,但需保证当某个函数调用其他函数时,被调函数不会修改或覆盖主调函数稍后会使用到的寄存器值。因此,IA32采用一套统一的寄存器使用约定,所有函数(包括库函数)调用都必须遵守该约定。

​ 根据惯例,寄存器%eax、%edx和%ecx为主调函数保存寄存器(caller-saved registers),当函数调用时,若主调函数希望保持这些寄存器的值,则必须在调用前显式地将其保存在栈中;被调函数可以覆盖这些寄存器,而不会破坏主调函数所需的数据。寄存器%ebx、%esi和%edi为被调函数保存寄存器(callee-saved registers),即被调函数在覆盖这些寄存器的值时,必须先将寄存器原值压入栈中保存起来,并在函数返回前从栈中恢复其原值,因为主调函数可能也在使用这些寄存器。此外,被调函数必须保持寄存器%ebp和%esp,并在函数返回后将其恢复到调用前的值,亦即必须恢复主调函数的栈帧。

当然,这些工作都由编译器在幕后进行。不过在编写汇编程序时应注意遵守上述惯例。

4、栈帧结构

​ 函数调用经常是嵌套的,在同一时刻,堆栈中会有多个函数的信息。每个未完成运行的函数占用一个独立的连续区域,称作栈帧(Stack Frame)栈帧是堆栈的逻辑片段,当调用函数时逻辑栈帧被压入堆栈, 当函数返回时逻辑栈帧被从堆栈中弹出栈帧存放着函数参数,局部变量及恢复前一栈帧所需要的数据等

​ 编译器利用栈帧,使得函数参数和函数中局部变量的分配与释放对程序员透明。编译器将控制权移交函数本身之前,插入特定代码将函数参数压入栈帧中,并分配足够的内存空间用于存放函数中的局部变量。使用栈帧的一个好处是使得递归变为可能,因为对函数的每次递归调用,都会分配给该函数一个新的栈帧,这样就巧妙地隔离当前调用与上次调用。但是每个进程的栈的容量有限,调用次数过多会使调用栈溢出。

​ 注意,栈帧是运行时概念,若程序不运行,就不存在栈和栈帧。但通过分析目标文件中建立函数栈帧的汇编代码(尤其是函数序和函数跋过程),即使函数没有运行,也能了解函数的栈帧结构。通过分析可确定分配在函数栈帧上的局部变量空间准确值,函数中是否使用帧基指针,以及识别函数栈帧中对变量的所有内存引用。

​ 栈帧的边界由栈帧基地址指针EBP和堆栈指针ESP界定(指针存放在相应寄存器中)。EBP指向当前栈帧底部(高地址),在当前栈帧内位置固定;ESP指向当前栈帧顶部(低地址),当程序执行时ESP会随着数据的入栈和出栈而移动。因此函数中对大部分数据的访问都基于EBP进行。

​ 为更具描述性,以下称EBP为帧基指针, ESP为栈顶指针。EBP是当前函数的存取指针,即存储或者读取数时的指针基地址;ESP就是当前函数的栈顶指针。每一次发生函数的调用(主函数调用子函数)时,在被调用函数初始时,都会把当前函数(主函数)的EBP压栈,以便从子函数返回到主函数时可以获取EBP。ESP 就是一直指向栈顶的指针,而 EBP 只是存取某时刻的栈顶指针,以方便对栈的操作,如获取函数参数、局部变量等

函数调用栈的典型内存布局如下图所示:

img

图2 函数调用栈的典型内存布局

​ 图中给出主调函数(caller)和被调函数(callee)的栈帧布局,"m(%ebp)“表示以EBP为基地址、偏移量为m字节的内存空间(中的内容)。该图基于两个假设:第一,函数返回值不是结构体或联合体,否则第一个参数将位于"12(%ebp)” 处;第二,每个参数都是4字节大小(栈的粒度为4字节)。在本文后续章节将就参数的传递和大小问题做进一步的探讨。 此外,函数可以没有参数和局部变量,故图中“Argument(参数)”和“Local Variable(局部变量)”不是函数栈帧结构的必需部分。

从图中可以看出,函数调用时入栈顺序为
实 参 n − 1 → 主 调 函 数 返 回 地 址 → 主 调 函 数 帧 基 指 针 e b p → 被 调 函 数 局 部 变 量 1 − n 实参n-1→主调函数返回地址→主调函数帧基指针ebp→被调函数局部变量1-n n1ebp1n
​ 其中,主调函数将参数按照调用约定依次入栈(上图中为从右到左),然后将指令指针EIP入栈以保存主调函数的返回地址(下一条待执行指令的地址)。进入被调函数时,被调函数将主调函数的帧基指针EBP入栈,并将主调函数的栈顶指针ESP值赋给被调函数的EBP(作为被调函数的栈底),接着改变ESP值来为函数局部变量预留空间。此时被调函数帧基指针(EBP)指向被调函数的栈底。以该地址为基准,向上(栈底方向)可获取主调函数的返回地址、参数值,向下(栈顶方向)能获取被调函数的局部变量值,而该地址处又存放着上一层主调函数的帧基指针值。**本级调用结束后,将EBP指针值赋给ESP,使ESP再次指向被调函数栈底以释放局部变量; ** 再将已压栈的主调函数帧基指针弹出到EBP,并弹出返回地址到EIP。 ESP继续上移越过参数,最终回到函数调用前的状态,即恢复原来主调函数的栈帧。如此递归便形成函数调用栈。

​ EBP指针在当前函数运行过程中(未调用其他函数时)保持不变。在函数调用前,ESP指针指向栈顶地址,也是栈底地址。在函数完成现场保护之类的初始化工作后,ESP会始终指向当前函数栈帧的栈顶,此时,若当前函数又调用另一个函数,则会将此时的EBP视为旧EBP压栈,而与新调用函数有关的内容会从当前ESP所指向位置开始压栈。

​ 若需在函数中保存被调函数保存寄存器(如ESI、EDI),则编译器在保存EBP值时进行保存,或延迟保存直到局部变量空间被分配。在栈帧中并未为被调函数保存寄存器的空间指定标准的存储位置。包含寄存器和临时变量的函数调用栈布局如下图所示:

img

图3 函数调用栈的内存布局

在多线程(任务)环境,栈顶指针指向的存储器区域就是当前使用的堆栈。切换线程的一个重要工作,就是将栈顶指针设为当前线程的堆栈栈顶地址。

以下代码用于函数栈布局示例:

1 //StackFrame.c
 2 #include <stdio.h>
 3 #include <string.h>
 4 
 5 struct Strt{
     
 6     int member1;
 7     int member2;
 8     int member3;
 9 };
10 
11 #define PRINT_ADDR(x)     printf("&"#x" = %p\n", &x)
12 int StackFrameContent(int para1, int para2, int para3){
     
13     int locVar1 = 1;
14     int locVar2 = 2;
15     int locVar3 = 3;
16     int arr[] = {
     0x11,0x22,0x33};
17     struct Strt tStrt = {
     0};
18     PRINT_ADDR(para1); //若para1为char或short型,则打印para1所对应的栈上整型临时变量地址!
19     PRINT_ADDR(para2);
20     PRINT_ADDR(para3);
21     PRINT_ADDR(locVar1);
22     PRINT_ADDR(locVar2);
23     PRINT_ADDR(locVar3);
24     PRINT_ADDR(arr);
25     PRINT_ADDR(arr[0]);
26     PRINT_ADDR(arr[1]);
27     PRINT_ADDR(arr[2]);
28     PRINT_ADDR(tStrt);
29     PRINT_ADDR(tStrt.member1);
30     PRINT_ADDR(tStrt.member2);
31     PRINT_ADDR(tStrt.member3);
32     return 0;
33 }
34 
35 int main(void){
     
36     int locMain1 = 1, locMain2 = 2, locMain3 = 3;
37     PRINT_ADDR(locMain1);
38     PRINT_ADDR(locMain2);
39     PRINT_ADDR(locMain3);
40     StackFrameContent(locMain1, locMain2, locMain3);
41     printf("[locMain1,2,3] = [%d, %d, %d]\n", locMain1, locMain2, locMain3);
42     memset(&locMain2, 0, 2*sizeof(int));
43     printf("[locMain1,2,3] = [%d, %d, %d]\n", locMain1, locMain2, locMain3);
44     return 0;
45 }
StackFrame

输出如下:

img

图4 StackFram输出

函数栈布局示例如下图所示。为直观起见,低于起始高地址0xbfc75a58的其他地址采用点记法,如0x.54表示0xbfc75a54,以此类推。

img

图5 StackFrame栈帧

内存地址从栈底到栈顶递减,压栈就是把ESP指针逐渐往地低址移动的过程。而结构体tStrt中的成员变量memberX地址=tStrt首地址+(memberX偏移量),即越靠近tStrt首地址的成员变量其内存地址越小。因此,结构体成员变量的入栈顺序与其在结构体中声明的顺序相反。

​ 函数调用以值传递时,传入的实参(locMain13)与被调函数内操作的形参(para13)两者存储地址不同,因此被调函数无法直接修改主调函数实参值(对形参的操作相当于修改实参的副本)。为达到修改目的,需要向被调函数传递实参变量的指针(即变量的地址)。

此外,"[locMain1,2,3] = [0, 0, 3]"是因为对四字节参数locMain2调用memset函数时,会从低地址向高地址连续清零8个字节,从而误将位于高地址locMain1清零。

5 堆栈操作

函数调用时的具体步骤如下:

  1. 主调函数将被调函数所要求的参数,根据相应的函数调用约定,保存在运行时栈中。该操作会改变程序的栈指针。

注:x86平台将参数压入调用栈中。而x86_64平台具有16个通用64位寄存器,故调用函数时前6个参数通常由寄存器传递,其余参数才通过栈传递。

  1. 主调函数将控制权移交给被调函数(使用call指令)。函数的返回地址(待执行的下条指令地址)保存在程序栈中(压栈操作隐含在call指令中)

  2. 若有必要,被调函数会设置帧基指针,并保存被调函数希望保持不变的寄存器值。

  3. 被调函数通过修改栈顶指针的值,为自己的局部变量在运行时栈中分配内存空间,并从帧基指针的位置处向低地址方向存放被调函数的局部变量和临时变量。

  4. 被调函数执行自己任务,此时可能需要访问由主调函数传入的参数。若被调函数返回一个值,该值通常保存在一个指定寄存器中(如EAX)。

  5. 一旦被调函数完成操作,为该函数局部变量分配的栈空间将被释放。这通常是步骤4的逆向执行。

  6. 恢复步骤3中保存的寄存器值,包含主调函数的帧基指针寄存器。

  7. 被调函数将控制权交还主调函数(使用ret指令)。根据使用的函数调用约定,该操作也可能从程序栈上清除先前传入的参数。

  8. 主调函数再次获得控制权后,可能需要将先前的参数从栈上清除。在这种情况下,对栈的修改需要将帧基指针值恢复到步骤1之前的值。

步骤3与步骤4在函数调用之初常一同出现,统称为函数序(prologue);步骤6到步骤8在函数调用的最后常一同出现,统称为函数跋(epilogue)。函数序和函数跋是编译器自动添加的开始和结束汇编代码,其实现与CPU架构和编译器相关。除步骤5代表函数实体外,其它所有操作组成函数调用。

以下介绍函数调用过程中的主要指令。

压栈(push):栈顶指针ESP减小4个字节;以字节为单位将寄存器数据(四字节,不足补零)压入堆栈,从高到低按字节依次将数据存入ESP-1、ESP-2、ESP-3、ESP-4指向的地址单元。

出栈(pop):栈顶指针ESP指向的栈中数据被取回到寄存器;栈顶指针ESP增加4个字节。

函数的调用过程,从汇编和内存的角度分析_第1张图片

图6 出栈入栈操作示意

可见,压栈操作将寄存器内容存入栈内存中(寄存器原内容不变),栈顶地址减小;出栈操作从栈内存中取回寄存器内容(栈内已存数据不会自动清零),栈顶地址增大。栈顶指针ESP总是指向栈中下一个可用数据。

调用(call):将当前的指令指针EIP(该指针指向紧接在call指令后的下条指令)压入堆栈,以备返回时能恢复执行下条指令;然后设置EIP指向被调函数代码开始处,以跳转到被调函数的入口地址执行。

离开(leave): 恢复主调函数的栈帧以准备返回。等价于指令序列movl %ebp, %esp(恢复原ESP值,指向被调函数栈帧开始处)和popl %ebp(恢复原ebp的值,即主调函数帧基指针)。

返回(ret):与call指令配合,用于从函数或过程返回。从栈顶弹出返回地址(之前call指令保存的下条指令地址)到EIP寄存器中,程序转到该地址处继续执行(此时ESP指向进入函数时的第一个参数)。若带立即数,ESP再加立即数(丢弃一些在执行call前入栈的参数)。使用该指令前,应使当前栈顶指针所指向位置的内容正好是先前call指令保存的返回地址。

二、具体函数调用过程分析

具体用来分析的代码,如下图:

#include 

int fun(int a, int b);

int main()
{
     
	int x = 1;
	int y = 2;
	int res;

	res= fun(x, y);
	printf("x+y = %d\n", res);
	
	return 0;
}

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

	c = a + b;
	return c;
}

​ 我们通过VS2015来进行调试,选择调试模式为 **Debug x86 ** 在 **return 0; **处打上断点,点击调试按钮,右键代码文件选择 转到反汇编 进入汇编代码。该程序完整的汇编代码如下两图所示。图一为 被调函数的源代码与汇编代码。图二是主调函数的源代码与汇编代码。

​ 从它们汇编指令的地址可以看出,fun函数的栈帧在低地址端(栈顶),main函数的栈帧在高地址端(栈底)。

函数的调用过程,从汇编和内存的角度分析_第2张图片

先分析fun函数的汇编代码。

00281770  push        ebp  
00281771  mov         ebp,esp  
00281773  sub         esp,0CCh  
00281779  push        ebx  
0028177A  push        esi  
0028177B  push        edi  
0028177C  lea         edi,[ebp-0CCh]  
00281782  mov         ecx,33h  
00281787  mov         eax,0CCCCCCCCh  
0028178C  rep stos    dword ptr es:[edi]  
    19: 	int c = 0;
0028178E  mov         dword ptr [c],0  
    20: 
    21: 	c = a + b;
00281795  mov         eax,dword ptr [a]  
00281798  add         eax,dword ptr [b]  
0028179B  mov         dword ptr [c],eax  
    22: 	return c;
0028179E  mov         eax,dword ptr [c]  
    23: }
002817A1  pop         edi  
002817A2  pop         esi  
002817A3  pop         ebx  
002817A4  mov         esp,ebp  
002817A6  pop         ebp  
002817A7  ret  
push        ebp  将基址寄存器ebp中的值压入fun函数栈帧中

mov         ebp,esp  将esp中的值传送到基址寄存器ebp中。ebp保存栈的基址,函数返回时被弹出。

sub         esp,0CCh   将esp向低地址移动CCh(10进制为204)字节,即为栈申请分配204字节空间。用于存储函数参数、返回地址和临时变量。

push  ebx  	 push  esi    push  edi   将ebx、esi、edi寄存器内的值压入函数栈帧中。

lea         edi,[ebp-0CCh]  将ebp-0CCh的地址传送到edi寄存器

mov         ecx,33h  将33h传送到 计数寄存器ecx,下面的rep每执行一次,ecx寄存器内的值就减1

mov         eax,0CCCCCCCCh  将CCCCCCCCh的值赋值给 eax寄存器, int 3中断对应的指令为CCh, eax为32位即将

rep stos    dword ptr es:[edi]    rep指令的目的是重复其上面的指令。 ECX的值是重复的次数。

将栈上从ebp-0xcc开始的位置向高地址方向的内存赋值0xCCCCCCCC,次数重复0x33(51)次. 注意0xCCCCCCCC代表着未被初始化.

REP能够引发其后的字符串指令被重复, 只要ecx的值不为0, 重复就会继续. 每一次字符串指令执行后, ecx的值都会减小.

mov      	dword ptr [c],0   将0 传送到 变量c 的地址

mov         eax,dword ptr [a]   将变量a 的值传送到累加寄存器 eax

add         eax,dword ptr [b]   将变量b 的值与eax 寄存器内的值相加并传送到 eax寄存器

mov         dword ptr [c],eax   将累加寄存器 eax的值传送到变量 c的地址 

mov         eax,dword ptr [c]   将变量c的值传送到 eax

上述操作完成了 c = a+b; 并且将和两个变量和的值存储到了eax寄存器中。

pop     edi    pop    esi  pop    ebx   将栈顶中的值弹出,依次保存在edi、esi、ebx寄存器中。(*恢复到函数调用前的状态*)

mov         esp,ebp   将ebp寄存器的值传送到esp,将栈申请的内存释放。 
pop         ebp 	  将栈顶的值弹出,保存在ebp寄存器中。
ret     结束fun函数,返回到调用源。(EIP寄存器保存函数调用点下一个CPU指令存放的地址,当CPU执行完当前指令之后,从EIP寄存器读出下一条指令的地址,然后继续执行。)

上述的操作为 fun函数的处理过程,将两个变量的值进行累加,并保存在 eax寄存器中。

函数的调用过程,从汇编和内存的角度分析_第3张图片

main函数到函数调用之前的内容和fun函数处理相同。我们重点来分析从 Fun函数开始的汇编代码。

mov         eax,dword ptr [y]  将变量 y的值传送到 eax寄存器 
push        eax  			   将 eax寄存器中的值压入栈中
mov         ecx,dword ptr [x]  将变量 x的值传送到 ecx寄存器
push        ecx  			   将 ecx寄存器中的值压入栈中
call        _fun (0281127h)    调用 fun函数,通过地址调用,跳转到fun函数中
add         esp,8  			   将 esp寄存器中的值向栈底移动8个自己
mov         dword ptr [res],eax  将eax寄存器中的值传送变量 res的地址处

上述为fun函数的调用指令,将变量x,y的值分别传送到eax和ecx寄存器,调用 fun函数 。 最先将res = fun(x, y);的下一条指令, 即printf()的第一条指令的地址(即002817FF)传送到EIP指针并压入函数的栈帧中,然后执行fun函数中的指令,调用完之后。 add esp,8将栈指针向上移动8个字节,弹出压入的 ecx和 eax的值,最后将 eax中保存的fun函数的返回值传送到 res变量的地址处。

mov         eax,dword ptr [res]  					将 res变量的值传送到eax寄存器中
push        eax  									将 eax寄存器中的值压入栈中
push        offset string "x+y = %d\n" (0286B30h)  	将字符串压入栈中,
call        _printf (028131Bh)  					调用printf函数
add         esp,8  									将压出的字符串和 eax寄存器的值依次弹出栈
xor         eax,eax 	将eax寄存器的值清零
pop         edi  
pop         esi  
pop         ebx  
add         esp,0E4h  
cmp         ebp,esp  
call        __RTC_CheckEsp (028110Eh)  
mov         esp,ebp  
ret  

pop edi pop esi pop ebx 将栈顶的值弹出,依次保存在edi、esi、ebi中

栈中,
call _printf (028131Bh) 调用printf函数
add esp,8 将压出的字符串和 eax寄存器的值依次弹出栈


```assembly
xor         eax,eax 	将eax寄存器的值清零
pop         edi  
pop         esi  
pop         ebx  
add         esp,0E4h  
cmp         ebp,esp  
call        __RTC_CheckEsp (028110Eh)  
mov         esp,ebp  
ret  

pop edi pop esi pop ebx 将栈顶的值弹出,依次保存在edi、esi、ebi中

将函数入栈时分配的 E4字节的空间释放掉,将 esp移到栈底。结束main函数。

你可能感兴趣的:(操作系统,C/C++,堆栈,栈,指针)