引言
在C语言程序中,每当一个函数被调用时,系统都会在栈上为该函数分配一块内存空间,这块内存空间就被称为栈帧。
栈帧中包含了函数执行所需的所有信息,如局部变量、参数、返回地址等。栈帧的创建和销毁是函数调用的核心部分,它们确保了函数能够正确地执行和返回。
本文将在VS2013环境下,通过实践操作,对比较简单的C语言程序进行调试的基础上,对C语言函数栈帧的创建和销毁过程进行详细的论述,并探讨函数中局部变量的创建、参数的传递、形参的引用以及返回值等过程。
一、概念
我们在写c语言程序时,通常会把独立的功能封装为一个个函数,所以C语言程序是以函数为基本单位的。而函数的传参、调用和返回值等问题都和函数栈帧有关。
1.栈
栈是一个线性数据结构,遵循先进后出的规则,可将数据从栈顶压入,也可将数据从栈顶弹出。在windows操作系统中,栈是由高地址向低地址使用的。
2.函数栈帧
每一个函数调用,都要在栈区上开辟一块空间,这块空间就是函数栈帧。
这块空间用来存放:
(1)函数参数和返回值;
(2)局部变量;
(3)保存上下文信息。
3.寄存器
(1)eax:通用寄存器,保留临时数据,常用于返回值;
(2)ebx:通用寄存器,保留临时数据;
(3)ebp:栈底寄存器;
(4)esp:栈顶寄存器;
(5)eip:指令寄存器,保存当前指令的下一条指令的地址。
ebp和esp是维护函数栈帧的两个寄存器,哪个函数被调用,它们就指向哪个函数的栈帧空间进行维护,来标识哪个函数正在被使用,为对这个函数的操作提供支持。
4.汇编命令
(1)mov:数据转移指令;
(2)push:数据入栈;
(3)pop:数据弹出至指定位置;
(4)sub:减法命令;
(5)add:加法命令;
(6)call:函数调用,压入返回地址,转入目标函数;
(7)jump:通过修改eip,转入目标函数,进行调用;
(8)ret:恢复返回地址。
5.使用的C程序
二、main函数的运行
1.main函数被调用过程
main函数并不是最初被程序调用的函数,main函数也是通过被其他函数调用而被使用的。调用main函数的具体过程,是由编译器决定的。
main函数由__tmainCRTstartup函数调用,而__tmainCRTstartup函数又由mainCRTstartup函数调用。
在栈区内存中,空间是由高地址向低地址使用的,所以程序开始运行后,mainCRTstartup函数先在较高地址处创建栈帧,然后调用__tmainCRTstartup函数并创建栈帧,最后再调用main函数并为其创建相应的栈帧空间。
2.main函数栈帧的创建
main函数在运行时也会在栈区内存上开辟一块空间,这个空间由ebp和esp两个寄存器来维护。ebp指向栈底的较高地址,esp指向栈顶的较低地址,两个寄存器记录的地址之间就是内存划分给main函数,供main函数使用调配的空间。
可以经过反编译,得到main函数栈帧创建的汇编代码:
为main函数创建栈帧的时候,ebp和esp正在维护__tmainCRTstartup函数创建的栈帧。
为main函数创建栈帧的过程如下:
(1)00C21410 push ebp
push指令进行压栈,把ebp中的地址压入栈顶,此时维护栈顶的指针esp值减小4,把刚刚压入的元素纳入__tmainCRTstartup函数的栈帧空间;
(2)00C21411 mov ebp,esp
mov指令将栈底指针移动到栈顶;
(3)00C21423 sub esp,0E4h
sub指令让esp中的值减去一个数,来到更低地址的位置,此时esp和ebp两个寄存器就离开了原先__tmainCRTstartup函数的栈帧空间,指向了一块新的栈区空间,这块空间就是为main函数预申请的栈帧空间;
(4)00C21419 push ebx
00C2141A push esi
00C2141B push edi
push指令将ebx、esi、edi的值压入栈;
(5)00C2141C lea edi,[ebp+FFFFFF1Ch]
lea指令把ebp-0E4h加载进edi中,这其实就是压入ebx、esi、edi三个元素前esp的地址;
(6)00C21422 mov ecx,39h
00C21427 mov eax,0CCCCCCCCh
mov指令将39h、0CCCCCCCCh两个值分别放入ecx和eax两个寄存器当中;
(7)00C2142C rep stos dword ptr es:[edi]
rep stos指令,在这里是将从edi中的地址开始,向下39h次,每次改变dword(4个字节)的空间,全部改为eax的值,这个操作把为main函数开辟的栈帧空间中的值全部初始化为cccccccc(每4个字节)。
3.main函数中局部变量的创建
(1)00C2142E mov dword ptr [ebp-8],0Ah
00C21435 mov dword ptr [ebp-14h],14h
00C2143C mov dword ptr [ebp-20h],0
mov指令将值10放入ebp-8的位置,创建了整型变量a;将值20放入ebp-14h的位置,创建了整型变量b;将值0放入ebp-20h的位置,创建了整型变量c.
注意到整型变量a创建在ebp-8的位置上,而随后的整型变量b创建在ebp-14h(ebp-20)的位置上,之间相隔8个字节,共2个整型的空间,这就是平时我们会观察到,前后紧邻创建的两个变量,在内存空间上却并不是紧邻的根本原因。
在代码中因为越界或其他因素访问到没有被初值初始化的内存空间,打印出随机值时,经常出现烫烫烫烫的字样,就是因为函数栈帧中初始存放的值是cccccccc(4个字节),这些值在打印的时候被译为烫烫烫烫。
4.main函数中为Add函数传参
(1)00C21443 mov eax,dword ptr [ebp-14h]
00C21446 push eax
00C21447 mov ecx,dword ptr [ebp-8]
00C2144A push ecx
mov和push指令在这里把ebp-14h中的值20,也就是整型变量b的值,放入到了寄存器eax中,然后压入栈顶,把栈顶指针减少;之后又把ebp-8中的值10,也就是整型变量a中的值放入了寄存器ecx中,然后压入栈顶,把栈顶指针减少。这两步操作其实就是在传参,把main函数中实参部分,整型变量a和整型变量b的值,传递给Add函数中的形参整型变量x和整型变量y,也就是说,这里的寄存器eax和ecx就相当于放着整型变量x和整型变量y。
另外从这个传参过程中也可以很清楚的看到,函数参数的传递是从右向左进行的的,先传递的整型变量b,再传递的整型变量a。
5.创建Add函数的函数栈帧
call指令将call指令的下一条指令的地址压入栈中,自己通过jmp指令来找到Add函数。这里就是在开始调用Add函数,同时把下一条指令的地址记录下来,方便调用完成后返回到应当执行的位置,从而使得main函数完成对Add函数的调用后,接下来的代码能准确运行。
6.Add函数中对形参的引用
为Add函数创建好栈帧,并且创建好整型变量z变量后,开始执行z=x+y对应的汇编代码。
可以看到在这之前是没有创建出变量x和y的,这里调用x和y的时候,使用的是ebp+8和ebp+0Ch的值。从上面可以知道,这两个地址中的值其实就是上面传参时压入main函数栈帧的,存放在寄存器eax和ecx中的值。
所以说,平时我们传递参数的时候,如果采用传值调用,那么因为在被调函数中实际访问和操作的是寄存器eax和ecx中的值,而与传值过去的变量无关,所以在被调函数中对形参的操作不影响原函数中变量的值。
而如果采用传址调用,那么寄存器eax和ecx中存放的就是原函数中变量的地址,程序通过这个地址,就找到原函数中的变量,从而使得被调函数中的操作,改变原函数中变量的值。
在Add函数还未调用的时候,参数就已经先传递过去了,是在main函数的堆栈中,然后才是对Add函数栈帧的创建与初始化。
所以有一句非常形象且正确的话:形参是实参的一份临时拷贝。
7.Add函数返回值,程序返回main函数
返回值的时候,mov指令把ebp-8地址处的值,也就是整型变量z的值,放入寄存器eax当中,这里需要注意的是,寄存器是不会随函数的销毁而销毁的。
edi、esi、ebx三个元素也是Add函数栈帧的一部分,pop指令把edi、esi、ebx三个元素从栈顶依次弹出。
mov指令把寄存器ebp中的值赋给寄存器esp。
pop指令把ebp处的元素弹出并且赋给ebp,这里存放的值就是调用Add的函数,也就是main函数原先的栈底地址。所以经过这次pop,栈顶指针和栈底指针,又指向了main函数的栈顶和栈底,返回对main函数的维护。
ret指令返回的时候,其实相当于从栈顶指针处pop掉一个元素,而这个元素就是上面call指令压入的地址,是call指令下一条指令的地址。所以通过ret这个指令,就使内存上返回到了原先调用Add时的状态,并且走到了调用完成后的下一步。
此时传递的形参还在,之后执行的add指令使esp增加8个字节,弹出了传递的两个形参。
mov指令将eax中的值放入ebp-20h中,也就是变量c中,到这里完成了Add函数返回值的操作。
我们知道在一个函数中创建的局部变量,在函数销毁的时候也会同步销毁,而返回值的时候函数已经执行完毕被销毁掉了,那么用函数中的变量返回一个值,是怎么做到的呢?就是通过不随函数销毁而销毁的寄存器完成的,先把局部变量的值放入寄存器,通过寄存器将值返回,就避免了返回失败的问题。