0%

浅谈函数栈帧

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

main函数的调用

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include<stdio.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);
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
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
------------------- 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]
1
2
3
4
5
6
7
8
9
10
11
//接下来就是创建Z变量并且执行加法操作将结果返回
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也就不足为奇了!

函数栈帧的理解还是很重要的,其中涉及到函数栈帧的初始化,参数传递,返回值传递等等问题,学习函数栈帧就是明白函数的整个调用过程,还是非常重要的一部分!

  • 本文作者: Tim
  • 本文链接: https://zouchanglin.cn/3368183231.html
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!