前言
最近在学习C语言的过程中遇到了一些问题,在询问老师和查询相关资料的基础上了解到了函数栈帧的相关概念,对下列问题也有了答案。
- 局部变量是如何创建的?
- 未初始化的局部变量为什么是随机值?(如果给一个变量未初始化,打印该变量中的内容就会出现一些没有实际意义的文字或字母)
- 函数是如何调用的?(过程是什么样的?)
- 函数在调用过程中是如何传参的?
- 形参和实参有什么联系和区别?(这两者有什么关系?)
如果你在学习过程中也产生了和我相同的疑惑,请阅读这篇文章,或许对你有所启发。
一、预备知识
在正式了解函数栈帧以前,我们需要先了解一些预备知识。
1.内存区域的划分和分配
内存按照内存地址从高(0xffffffff)到低(0x00000000)的顺序排列,可以分为五个大分区:
栈区堆区全局静态区常量区代码区。
大致分布如图:
我们本次所要了解的栈帧属于栈区。(因此本次只对栈区进行介绍,其他部分之后遇到了会进行补充)
栈区内容简介:
- 栈区的内存空间由系统管理和分配,即方法调用开始时开辟空间,方法调用结束时回收空间。
- 栈区是从高地址向低地址扩展,是一块连续的内存区域,遵循先进后出,后进先出(FILO)原则,使用效率高。
- 方法的入参,内部定义的局部变量等,都存放在栈区。
2.栈帧简介
- 栈帧也叫过程活动记录,是编译器用来实现过程/函数调用的一种数据结构。
- 函数的每次调用,都有它自己独立的栈帧。栈帧中维持着函数调用所需要的各种信息,包括函数的入参、函数的局部变量、函数执行完成后下一步要执行的指令地址、寄存器信息等。
- 栈帧使用了栈这一数据结构,达到了后进先出(First In Last Out)的内存管理原则。不管是插入数据还是删除数据,都是在栈顶进行的。栈顶和栈底都有指针,栈顶指针是esp,栈底指针是ebp,即esp指向栈顶,ebp指向栈底。
3.寄存器简介
我们所说的寄存器是CPU内部用来存放数据的一些小型存储区域,用来暂时存放参与运算的数据和运算结果。简单来说就是用来存储二进制代码的机器。
通常能了解到的eax,ebx,ecx,edx,esi,edi,edp,esp都是X86汇编语言中CPU上的32位的通用寄存器,如果站在C语言的角度,也可以将他们看做变量。举个例子:
add eax,-2;//可以认为是给变量eax加上-2这个值。
下面简单介绍几个寄存器:
- EAX是"累加器"(accumulator), 它是很多加法乘法指令的缺省(默认)寄存器;
- EBX 是"基地址"(base)寄存器, 在内存寻址时存放基地址;
- ECX 是计数器(counter), 是重复(REP)前缀指令和LOOP指令的内定计数器;
- EDX 则总是被用来放整数除法产生的余数。
- EBP是"基址指针"(BASE POINTER), 它最经常被用作高级语言函数调用的"框架指针"(frame pointer)。在破解的时候,经常可以看见⼀个标准的函数起始代码:
push edp //保存当前edp mov edp,esp //EBP设为当前栈指针 sub esp,xxx //预留xxx字节给函数临时变量 ……
- 这样⼀来,EBP 构成了该函数的⼀个框架, 在EBP上方分别是原来的EBP, 返回地址和参数,BP下方则是临时变量。函数返回时作
mov esp,ebp pop ebp ret
- 即可。
- ESP 专门用作堆栈指针,被形象地称为栈顶指针,堆栈的顶部是地址小的区域,压入堆栈的数据越多,ESP也就越来越小。在32位平台上,ESP每次减少4字节。
其中EBP和ESP需要重点了解一下。
二、函数栈帧介绍
每个函数被调用时都会建立栈帧,在接下来的调试过程中我将会进一步解释。(本次的代码调试我使用的环境是VS2013版,其他版本可能会有细微差别,但大体步骤和内容是类似的)
1.源代码
为了演示这次函数栈帧的创建与销毁,我们将以一次简单的程序来作为范例。
代码如下:
#include<stdio.h> int ADD(int x, int y) { int z = 0; z = x + y; return z; } int main() { int a = 15; int b = 30; int c = 0; c = ADD(a, b); printf("%d\n", c); return 0; }
2.如何查看汇编代码
为了理解每一行代码是怎样被计算机执行的,它的原理是什么,我们必须从源代码转化的汇编代码着手去了解(汇编语言相较于高级语言,更面向机器,底层逻辑更完善。通过了解汇编语言有助于我们了解代码的真正运行过程)。
将源代码转为汇编代码的步骤(以本例题为例):
- 在main函数的第一行设置断点;
- 按F10(Ctrl+Fn+F10)进入调试;
- 鼠标右击选择转到反汇编;
- 为了方便观察,在出现反汇编代码后,可以选择取消显示符号名。
3.函数栈帧的创建与销毁(重点)
该程序的汇编代码如下:(注释有每一步的原理)
--- d:\c语言\函数栈帧hszz\函数栈帧hszz\hszz.c -------------------------------------------- int main() { 00E91410 push ebp //把edp压入栈顶,此时esp指向ebp的地址 00E91411 mov ebp,esp //把esp的值赋值给ebp,(寄存器放了谁的地址就指向谁)即ebp所指向的地址和esp相同 00E91413 sub esp,0E4h //将esp所指向的地址减0E4h位字节,这一步是为mian函数在栈区预创建空间 00E91419 push ebx //把ebx压入栈中 00E9141A push esi //把esi压入栈中 00E9141B push edi //把edi压入栈中 00E9141C lea edi,[ebp+FFFFFF1Ch] //lea(load Effective Adress的简称)取有效地址,将ebp+FFFFFF1Ch的有效地址传给edi。 00E91422 mov ecx,39h //把39h赋值给ecx 00E91427 mov eax,0CCCCCCCCh //把0CCCCCCCCh赋值给eax 00E9142C rep stos dword ptr es:[edi] //dword ptr(double word pointer的缩写)双字指针,(一个字是两个字节,两个字就是四个字节)。stos指令是指把eax的值拷贝到es:[edi]指向的地址(edi所指向的地址到edp所指向的地址)ecx是重复的次数。 int a = 15; 00E9142E mov dword ptr [ebp-8],0Fh //给变量a赋值,a = 15;这里a变量的地址为ebp-8;要注意的是变量a为双字变量,所以a占四个字节 int b = 30; 00E91435 mov dword ptr [ebp-14h],1Eh //同上,变量b地址为ebp-14h int c = 0; 00E9143C mov dword ptr [ebp-20h],0 //同上,变量c地址为ebp-20h c = ADD(a, b); 00E91443 mov eax,dword ptr [ebp-14h] //把b的值也就是30赋值给eax 00E91446 push eax //把eax压入栈顶 00E91447 mov ecx,dword ptr [ebp-8] //把a的值也就是15赋值给ecx 00E9144A push ecx //把ecx压入栈顶 00E9144B call 00E91127 //call指令开始调用函数,同时push call指令的下一条地址(00E91127)。这样调用完函数以后可以找到call指令的下一条地址,继续执行程序 int ADD(int x, int y) { 00E913C0 push ebp //把ebp压入栈顶(从这里开始是为ADD函数开辟栈内空间,过程和给main函数创建栈内空间类似,以下不进行赘述) 00E913C1 mov ebp,esp //同main函数 00E913C3 sub esp,0CCh //同main函数 00E913C9 push ebx //同main函数 00E913CA push esi //同main函数 00E913CB push edi //同main函数 00E913CC lea edi,[ebp+FFFFFF34h] //同main函数 00E913D2 mov ecx,33h //同main函数 00E913D7 mov eax,0CCCCCCCCh //同main函数 00E913DC rep stos dword ptr es:[edi] //同main函数 int z = 0; 00E913DE mov dword ptr [ebp-8],0 //建立z变量赋值为0 z = x + y; 00E913E5 mov eax,dword ptr [ebp+8] //将a的值也就是15赋值给eax(由此可见,函数传参时并不是把实参变量直接传给函数,而是通过寄存器将变量的值进行了临时拷贝并且传给函数,即形参是实参的临时拷贝) 00E913E8 add eax,dword ptr [ebp+0Ch] //将a+b的值赋值给eax 00E913EB mov dword ptr [ebp-8],eax //把eax的值也就是a+b的值赋值给变量z return z; 00E913EE mov eax,dword ptr [ebp-8] 把c的值赋值给eax(eax在main函数的栈帧中) } 00E913F1 pop edi //把edi从栈中弹出(删除) } 00E913F2 pop esi //把esi从栈中弹出(删除) 00E913F3 pop ebx //把ebx从栈中弹出(删除) 00E913F4 mov esp,ebp //把ebp的值赋值给esp,即esp指向ebp的地址 00E913F6 pop ebp //把ebp从栈中弹出(删除) 00E913F7 ret //ADD函数调用结束返回main函数 00E91450 add esp,8 //给esp+8,也就是让esp朝栈底方向移动8个字节(因为我们把两个存着双字变量的值的寄存器eax和ecx也压入栈了,现在函数调用结束,我们就不需要那两个寄存器存储双字变量的值了,所以esp+8) 00E91453 mov dword ptr [ebp-20h],eax //把eax的值也就是z的值赋值给变量c printf("%d\n", c); 00E91456 mov esi,esp //后面的内容是销毁main函数,过程和销毁ADD函数类似,因此以下不再赘述 00E91458 mov eax,dword ptr [ebp-20h] 00E9145B push eax 00E9145C push 0E95858h 00E91461 call dword ptr ds:[00E99114h] 00E91467 add esp,8 00E9146A cmp esi,esp 00E9146C call 00E9113B return 0; 00E91471 xor eax,eax } 00E91473 pop edi 00E91474 pop esi 00E91475 pop ebx } 00E91476 add esp,0E4h 00E9147C cmp ebp,esp 00E9147E call 00E9113B 00E91483 mov esp,ebp 00E91485 pop ebp //把ebp从栈中弹出(删除) 00E91486 ret
三、小彩蛋
main函数可以调用别的函数,它自己也是可以被调用的。
main __tmainCRTStartup __mainCRTSartup
总结
以上就是今天要讲的内容,本文用一个范例介绍了函数栈帧的创建与销毁,文章开头所提出的问题也在文章正文中做出了解答。
本文的作者也只是一个正在学习C语言等编程知识的萌新,若这篇文章中有哪些不正确的内容,请在评论区向作者指出(也可以私信作者),欢迎大佬们指点,也欢迎其他正在学习C语言的萌新和作者进行交流。
最后,如果本篇文章对你有所启发的话,也希望可以支持支持作者,后续作者也会定期更新学习记录。谢谢大家!