浅谈函数栈帧

先说说函数栈帧的概念,函数栈帧又叫函数运行时堆栈,栈帧也叫过程活动记录,是编译器用来实现函数调用的一种数据结构。这个该概念说起来比较抽象,简单的说就是函数在被调用时的一块空间,这个空间由 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 也就不足为奇了!

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