浅谈函数栈帧

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

main函数的调用

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

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

 1#include<stdio.h>
 2
 3int Add(int x, int y){
 4	int z = x + y;
 5	return z;
 6}
 7
 8int main(void)
 9{
10	int a = 10;
11	int b = 20;
12	int c = Add(a, b);
13	printf("c = %d\n",c);
14	return 0;
15}

下面是其反汇编代码:

 1int Add(int x, int y){
 2009613D0  push        ebp  
 3009613D1  mov         ebp,esp  
 4009613D3  sub         esp,0CCh  
 5009613D9  push        ebx  
 6009613DA  push        esi  
 7009613DB  push        edi  
 8009613DC  lea         edi,[ebp-0CCh]  
 9009613E2  mov         ecx,33h  
10009613E7  mov         eax,0CCCCCCCCh  
11009613EC  rep stos    dword ptr es:[edi]  
12	int z = x + y;
13009613EE  mov         eax,dword ptr [x]  
14009613F1  add         eax,dword ptr [y]  
15009613F4  mov         dword ptr [z],eax  
16	return z;
17009613F7  mov         eax,dword ptr [z]  
18}
19009613FA  pop         edi  
20009613FB  pop         esi  
21009613FC  pop         ebx  
22009613FD  mov         esp,ebp  
23009613FF  pop         ebp  
2400961400  ret  
25------------------- main.c ------------------------
26
27int main(void){
2800961410  push        ebp  
2900961411  mov         ebp,esp  
3000961413  sub         esp,0E4h  
3100961419  push        ebx  
320096141A  push        esi  
330096141B  push        edi  
340096141C  lea         edi,[ebp-0E4h]  
3500961422  mov         ecx,39h  
3600961427  mov         eax,0CCCCCCCCh  
370096142C  rep stos    dword ptr es:[edi]  
38	int a = 10;
390096142E  mov         dword ptr [a],0Ah  
40	int b = 20;
4100961435  mov         dword ptr [b],14h  
42	int c = Add(a, b);
430096143C  mov         eax,dword ptr [b]  
440096143F  push        eax  
4500961440  mov         ecx,dword ptr [a]  
4600961443  push        ecx  
4700961444  call        _Add (09610E6h)  
4800961449  add         esp,8  
490096144C  mov         dword ptr [c],eax  
50	printf("c = %d\n",c);
510096144F  mov         esi,esp  
52	printf("c = %d\n",c);
5300961451  mov         eax,dword ptr [c]  
5400961454  push        eax  
5500961455  push        965858h  
560096145A  call        dword ptr ds:[969118h]  
5700961460  add         esp,8  
5800961463  cmp         esi,esp  
5900961465  call        __RTC_CheckEsp (0961140h)  
60	system("pause");
610096146A  mov         esi,esp  
620096146C  push        965864h  
6300961471  call        dword ptr ds:[969110h]  
6400961477  add         esp,4  
650096147A  cmp         esi,esp  
660096147C  call        __RTC_CheckEsp (0961140h)  
67	return 0;
6800961481  xor         eax,eax  
69}
7000961483  pop         edi  
7100961484  pop         esi  
7200961485  pop         ebx  
7300961486  add         esp,0E4h  
740096148C  cmp         ebp,esp  
750096148E  call        __RTC_CheckEsp (0961140h)  
7600961493  mov         esp,ebp  
7700961495  pop         ebp  
7800961496  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寄存器来维护这块空间,这块空间就叫函数栈帧

 100961410  push        ebp  
 200961411  mov         ebp,esp  
 300961413  sub         esp,0E4h  
 400961419  push        ebx  
 50096141A  push        esi  
 60096141B  push        edi  
 70096141C  lea         edi,[ebp-0E4h]  
 800961422  mov         ecx,39h  
 900961427  mov         eax,0CCCCCCCCh  
100096142C  rep stos    dword ptr es:[edi]

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

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

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

1int a = 10;
20096142E  mov         dword ptr [a],0Ah  
3int b = 20;
400961435  mov         dword ptr [b],14h  
5int c = Add(a, b);
60096143C  mov         eax,dword ptr [b]  
70096143F  push        eax  
800961440  mov         ecx,dword ptr [a]  
900961443  push        ecx

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

也可以得出结论:

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

Add函数的函数的函数栈帧

100961444  call        _Add (09610E6h)  
200961449  add         esp,8

call指令先将下一条指令语句的给地址存起来了,方便在函数执行完毕之后执行下一条语句,这一点至关重要,一个函数执行完毕一定要跳到下一条语句的地方继续执行才可以。Add函数栈帧的创建于main函数栈帧的创建与初始化操作是一样的,同样的道理Add函数也是采用同样的方式初始化

 1009613D0  push        ebp  
 2009613D1  mov         ebp,esp  
 3009613D3  sub         esp,0CCh  
 4009613D9  push        ebx  
 5009613DA  push        esi  
 6009613DB  push        edi  
 7009613DC  lea         edi,[ebp-0CCh]  
 8009613E2  mov         ecx,33h  
 9009613E7  mov         eax,0CCCCCCCCh  
10009613EC  rep stos    dword ptr es:[edi]
 1//接下来就是创建Z变量并且执行加法操作将结果返回
 200DE13EE  mov         eax,dword ptr [x]  
 300DE13F1  add         eax,dword ptr [y]  
 400DE13F4  mov         dword ptr [z],eax  
 500DE13F7  mov         eax,dword ptr [z]
 600DE13FA  pop         edi  
 700DE13FB  pop         esi  
 800DE13FC  pop         ebx  
 900DE13FD  mov         esp,ebp  
1000DE13FF  pop         ebp  
1100DE1400  ret

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

100961449  add         esp,8  
20096144C  mov         dword ptr [c],eax

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

VC6.0之坑

 1#include <stdio.h>
 2
 3void fun(){
 4	int tmp = 10;
 5	int *p = (int*)(* (&tmp+1));
 6	* (p-1) = 20;
 7}
 8
 9int  main(void){
10	int a = 0;
11	fun();
12	printf("a = %d\n",a);
13	return 0;
14}

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

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