探究C语言函数栈帧的创建和销毁

引言

在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函数返回值的操作。

我们知道在一个函数中创建的局部变量,在函数销毁的时候也会同步销毁,而返回值的时候函数已经执行完毕被销毁掉了,那么用函数中的变量返回一个值,是怎么做到的呢?就是通过不随函数销毁而销毁的寄存器完成的,先把局部变量的值放入寄存器,通过寄存器将值返回,就避免了返回失败的问题。

  • 28
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值