浅谈函数栈帧

原文:https://zouchanglin.github.io/2018/05/11/2018051101/

前言

先说说函数栈帧的概念,函数栈帧又叫函数运行时堆栈,栈帧也叫过程活动记录,是编译器用来实现函数调用的一种数据结构。这个该概念说起来比较抽象,简单的说就是函数在被调用时的一块空间,这个空间由esp寄存器和ebp寄存器共同维护。首先应该明白,栈是从高地址向低地址延伸的。每个函数的每次调用,都有它自己独立的一个栈帧,这个栈帧中维持着所需要的各种信息。寄存器ebp指向当前的栈帧的底部(高地址),寄存器esp指向当前的栈帧的顶部(低地址)。

main函数的调用

程序的入口必须是main吗?

每当我们在点击一个(或者在命令行打开)一个C程序的时候,程序立马就能运行起来,从刚开始学习C语言的时候我们都直到main函数是程序的入口。至于为什么这么说完全是因为ANSIC就是这么规定的,一个程序的执行并不一定需要main函数,但是一定需要一个入口,做过单片机的同学应该深有体会。起来从CPU角度来看,将要执行的指令地址放在程序计数器里,程序需要执行必然需要一个入口地址。通用的可执行文件格式总会指定一个入口地址,这样操作系统才可以调度这样一个程序执行指令。所谓的main函数,就是执行时把这个程序装入任务调度器中,调度器执行调度的入口函数而已,而main函数只是个程序员和调度器之间的约定。

mainCRTStartup函数

扯得有点远了,在VC++下,连接器对控制台程序设置的入口函数是 mainCRTStartup,mainCRTStartup再调用main函数,mainCRTStartup所做的初始化准备工作,例如获取命令行参数、获取环境变量值,是通过调用相应的Windows系统调用来实现的。

函数栈帧

首先开发环境是VisualStudio2013,以下面这段代码进行函数栈帧的相关说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#define _CRT_SECURE_NO_WARNINGS
#include<stdio.h>
#include<stdlib.h>

int Add(int x, int y){
	int z = x + y;
	return z;
}

int main(void)
{
	int a = 10;
	int b = 20;
	int c = Add(a, b);
	printf("c = %d\n",c);
	system("pause");
	return 0;
}

首先我们打开反汇编查看汇编代码

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
--- d:\workspaces\vs2013\53_从汇编看函数栈帧\53_从汇编看函数栈帧\main.c ------------------------
#define _CRT_SECURE_NO_WARNINGS
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<assert.h>
int Add(int x, int y){
009613D0  push        ebp  
009613D1  mov         ebp,esp  
009613D3  sub         esp,0CCh  
009613D9  push        ebx  
009613DA  push        esi  
009613DB  push        edi  
009613DC  lea         edi,[ebp-0CCh]  
009613E2  mov         ecx,33h  
009613E7  mov         eax,0CCCCCCCCh  
009613EC  rep stos    dword ptr es:[edi]  
	int z = x + y;
009613EE  mov         eax,dword ptr [x]  
009613F1  add         eax,dword ptr [y]  
009613F4  mov         dword ptr [z],eax  
	return z;
009613F7  mov         eax,dword ptr [z]  
}
009613FA  pop         edi  
009613FB  pop         esi  
009613FC  pop         ebx  
009613FD  mov         esp,ebp  
009613FF  pop         ebp  
00961400  ret  
--- d:\workspaces\vs2013\53_从汇编看函数栈帧\53_从汇编看函数栈帧\main.c ------------------------

int main(void){
00961410  push        ebp  
00961411  mov         ebp,esp  
00961413  sub         esp,0E4h  
00961419  push        ebx  
0096141A  push        esi  
0096141B  push        edi  
0096141C  lea         edi,[ebp-0E4h]  
00961422  mov         ecx,39h  
00961427  mov         eax,0CCCCCCCCh  
0096142C  rep stos    dword ptr es:[edi]  
	int a = 10;
0096142E  mov         dword ptr [a],0Ah  
	int b = 20;
00961435  mov         dword ptr [b],14h  
	int c = Add(a, b);
0096143C  mov         eax,dword ptr [b]  
0096143F  push        eax  
00961440  mov         ecx,dword ptr [a]  
00961443  push        ecx  
00961444  call        _Add (09610E6h)  
00961449  add         esp,8  
0096144C  mov         dword ptr [c],eax  
	printf("c = %d\n",c);
0096144F  mov         esi,esp  
	printf("c = %d\n",c);
00961451  mov         eax,dword ptr [c]  
00961454  push        eax  
00961455  push        965858h  
0096145A  call        dword ptr ds:[969118h]  
00961460  add         esp,8  
00961463  cmp         esi,esp  
00961465  call        __RTC_CheckEsp (0961140h)  
	system("pause");
0096146A  mov         esi,esp  
0096146C  push        965864h  
00961471  call        dword ptr ds:[969110h]  
00961477  add         esp,4  
0096147A  cmp         esi,esp  
0096147C  call        __RTC_CheckEsp (0961140h)  
	return 0;
00961481  xor         eax,eax  
}
00961483  pop         edi  
00961484  pop         esi  
00961485  pop         ebx  
00961486  add         esp,0E4h  
0096148C  cmp         ebp,esp  
0096148E  call        __RTC_CheckEsp (0961140h)  
00961493  mov         esp,ebp  
00961495  pop         ebp  
00961496  ret

由此也可以看出编程语言的进化历程,要是现在还是由我们来写这些汇编代码,想想都难受…

汇编指令简述

数据传送指令

这部分指令包括通用数据传送指令MOV、条件传送指令CMOVcc、堆栈操作指令PUSH/PUSHA/PUSHAD/POP/POPA/POPAD、交换指令XCHG/XLAT/BSWAP、地址或段描述符选择子传送指令LEA/LDS/LES/LFS/LGS/LSS等。注意,CMOVcc不是一条具体的指令,而是一个指令簇,包括大量的指令,用于根据EFLAGS寄存器的某些位状态来决定是否执行指定的传送操作。

在本例中,可以将MOV指令理解为赋值语句,例如 mov ebp,esp就是将esp中的值赋给ebp;push指令理解为压栈,例如 push ebx 就是将ebx寄存器压栈(入栈),同样的道理,pop ebx 就是将ebx寄存器弹栈(出栈),例如 lea edi,[ebp-0E4h] 就是取源操作数地址的偏移量,并把它传送到目的操作数所在的单元

整数和逻辑运算指令

这部分指令用于执行算术和逻辑运算,包括加法指令ADD/ADC、减法指令SUB/SBB、加一指令INC、减一指令DEC、比较操作指令CMP、乘法指令MUL/IMUL、除法指令DIV/IDIV、符号扩展指令CBW/CWDE/CDQE、十进制调整指令DAA/DAS/AAA/AAS、逻辑运算指令NOT/AND/OR/XOR/TEST等。

在本例中用到了 add和sub 指令,例如add esp,8,就是给esp寄存器中的值加上8,执行加法运算;还有一个是cmp指令,cmp是比较指令,cmp的功能是相当于减法指令,只是不保存结果.cmp指令执行后,将对标志寄存器产生影响.其他相关指令通过识别这些被影响的标志寄存器来得知比较结果.

串操作指令

这部分指令用于对数据串进行操作,包括串传送指令MOVS、串比较指令CMPS、串扫描指令SCANS、串加载指令LODS、串保存指令STOS,这些指令可以有选择地使用REP/REPE/REPZ/REPNE和REPNZ的前缀以连续操作。

本例中使用到了rep stos等等
lea edi,[ebp-0C0h]
mov ecx,30h
mov eax,0CCCCCCCCh
rep stos dword ptr es:[edi]
rep指令的目的是重复其上面的指令.ECX的值是重复的次数.
STOS指令的作用是将eax中的值拷贝到ES:EDI指向的地址.
LEA叫做loadEffectiveAddress,加载有效的地址

其他指令在本例中未出现,故在此不再赘述

图解函数栈帧

看完上述的汇编指令简述,再次看到汇编语言应该就不那么陌生了,接下来正式进入函数栈帧的分析环节

main的函数栈帧

由前面我们已经知道,是mainCRTStartup函数调用的main函数,main函数终究也是一个函数,是函数调用就肯定需要空间的,由esp寄存器和ebp寄存器来维护这块空间,这块空间就叫函数栈帧

栈帧的初始化

1
2
3
4
5
6
7
8
9
10
00961410  push        ebp  
00961411  mov         ebp,esp  
00961413  sub         esp,0E4h  
00961419  push        ebx  
0096141A  push        esi  
0096141B  push        edi  
0096141C  lea         edi,[ebp-0E4h]  
00961422  mov         ecx,39h  
00961427  mov         eax,0CCCCCCCCh  
0096142C  rep stos    dword ptr es:[edi]

首先将ebp寄存器压栈,然后将ebp的位置移向了esp的位置,esp有向上移动了04E个位置,然后将ebx、esi、edi寄存器压栈,然后让edi寄存器加载红线所示的地址,从edi的位置开始copy,重复拷贝ecx次数据,一次拷贝的大小是双字节(4个字节),拷贝到eax里面,如下图所示:

0CCCCCCCC在被解析的时候就是乱码,对应的ACCSI码也就是”烫”,所以我们在使用未初始化的变量的时候又时会打印出”烫烫烫…”这也就不足为奇了

变量的创建以及函数调用时参数拷贝

1
2
3
4
5
6
7
8
9
int a = 10;
0096142E  mov         dword ptr [a],0Ah  
int b = 20;
00961435  mov         dword ptr [b],14h  
int c = Add(a, b);
0096143C  mov         eax,dword ptr [b]  
0096143F  push        eax  
00961440  mov         ecx,dword ptr [a]  
00961443  push        ecx

可以看出,首先创建了a和b变量并做了相应的初始化操作,然后将b的值放入到eax寄存器中,然后将eax寄存器压栈,同样把a变量放入ecx寄存器中,然后将ecx压栈,结果如图所示:

由此图也可以得出结论:

  • C语言中所有的参数传递都是值传递,形式参数只是实参的一份临时拷贝
  • 后面的参数先被压入栈中,即参数传递的顺序是右向左的

Add函数的函数的函数栈帧

1
2
00961444  call        _Add (09610E6h)  
00961449  add         esp,8

call指令先将下一条指令语句的给地址存起来了,方便在函数执行完毕之后执行下一条语句,这一点至关重要,一个函数执行完毕一定要跳到下一条语句的地方继续执行才可以。

Add函数栈帧的创建于main函数栈帧的创建与初始化操作是一样的,同样的道理Add函数也是采用同样的方式初始化:

1
2
3
4
5
6
7
8
9
10
009613D0  push        ebp  
009613D1  mov         ebp,esp  
009613D3  sub         esp,0CCh  
009613D9  push        ebx  
009613DA  push        esi  
009613DB  push        edi  
009613DC  lea         edi,[ebp-0CCh]  
009613E2  mov         ecx,33h  
009613E7  mov         eax,0CCCCCCCCh  
009613EC  rep stos    dword ptr es:[edi]

接下来就是创建Z变量并且执行加法操作将结果返回

1
2
3
4
5
6
7
8
9
10
00DE13EE  mov         eax,dword ptr [x]  
00DE13F1  add         eax,dword ptr [y]  
00DE13F4  mov         dword ptr [z],eax  
00DE13F7  mov         eax,dword ptr [z]
00DE13FA  pop         edi  
00DE13FB  pop         esi  
00DE13FC  pop         ebx  
00DE13FD  mov         esp,ebp  
00DE13FF  pop         ebp  
00DE1400  ret


首先将x的值放入eax寄存器中,然后执行加法运算将结果放入eax寄存器中,然后把eax寄存器中的值放入变量z,然后将z的值存入eax寄存器作为返回,接着将edi、esi、ebx三个寄存器分别弹栈,又将ebp移向了esp的位置,此时Add函数的栈帧销毁,代表整个Add函数的结束!当pop ebp的时候,将ebp弹栈,此时下面正好就是Call指令下一条指令的地址,这样就能接着Add的函数的结束继续执行下面的代码了!

1
2
00961449  add         esp,8  
0096144C  mov         dword ptr [c],eax

然后把eax寄存器中的值放入变量c,这样就拿到了Add函数的返回值,所以说返回值是寄存器带回来的!

VC6.0之坑

先看看下面这一段代码

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

void fun(){
	int tmp = 10;
	int *p = (int*)(* (&tmp+1));
	* (p-1) = 20;
}

int  main(void){
	int a = 0;
	fun();
	printf("a = %d\n",a);
	return 0;
}

在VC6.0的编译器下打印结果是20(VC6.0是多么可怕从这里也就可以看出了…),只要了解函数栈帧的话本题就不难解释,tmp的地址加一再解引用刚好就是main函数的ebp的地址,然后p-1得到的就是main函数中a的地址,这样的话打印出20也就不足为奇了!

结语

函数栈帧的理解还是很重要的,其中涉及到函数栈帧的初始化,参数传递,返回值传递等等问题,学习函数栈帧就是明白函数的整个调用过程,还是非常重要的一部分!
多画图,依照图片来理解某些抽象的问题往往事半功倍!

猜你喜欢

转载自blog.csdn.net/m0_38032942/article/details/81108962