深入了解运行时栈(C语言)

运行时栈

栈是一种数据结构:我们可以向这种结构中存入数据,也可以在这个结构中弹出数据,这个结构的特点是:后压入的数据先弹出,先压入的数据后弹出。

在计算机系统种,栈是一个具有以上属性的动态内存区域,程序可以将数据压入栈中,也可以将数据从栈中弹出,压栈操作时栈增大,弹出操作是栈减小

C语言过程调用中也使用了这样的内存管理原则–先进后出

计算机系统中的内存布局:

在这里插入图片描述

可以看出,栈是由高地址向低地址增长的,栈顶在低地址处,栈底在高地址处。

函数的栈帧

程序在每一次调用函数的时候就会在栈区创建一块空间,这块空间就被称为该函数的栈帧

这块空间中一般包括了下面一些信息:

  1. 函数的返回地址和参数
  2. 临时变量:包括函数的非静态局部变量以及编译器生成的其他临时变量
  3. 保存的寄存器

在这里插入图片描述

当运行中的程序调用另一个函数时,就要进入一个新的栈帧,原来函数的栈帧称为调用者的帧,新的栈帧称为当前帧。 被调用的函数运行结束后当前帧全部回收,回到调用者的帧。

例如:当函数A调用函数B的时候,会把返回地址压入栈中,我们把返回地址当做A函数栈帧的一部分,因为它存放的是与A相关的状态

注意:esp寄存器一直指向的是当前栈帧的栈顶(esp保存栈顶的地址)。

在这里插入图片描述

可以看到 这种把返回值压入栈的机制可以让函数在稍后返回到程序中正确的位置。

小知识:
我们在调试的时候有时会出现“烫”,这是上什么原因呢?
其实是因为我们在debug情况下创建函数栈帧后会将这块栈帧赋值为0xcccccccc,
而两个连续的0xcc的汉字编码就是烫,所以0xcccc被当作文本就是“烫”。

寄存器与机器指令

要了解函数的栈帧,就应该了解一些关于寄存器和简单机器指令的概念。

寄存器:

一个CPU中包含了一组寄存器,这些寄存器可以存储整形数据和指针,每个寄存器都有特殊的用途

在IA32位系统上面,这些寄存器可以存储32位的值,在64位系统上,这些寄存器可以存储64位的值

下面是32位情况下的寄存器

名字 用途
eax 返回值
ebp 栈底指针
esp 栈指针
rdi
rsi
rdx
rcx

上面的寄存器中,最特别的是esp,这个寄存器被称为栈指针,也叫栈顶指针(保存了栈顶的地址),用来指明运行时栈的结束位置。

ebp寄存器用来指向栈帧的栈底(保存栈底的地址)。可以称它为栈底指针。

机器指令

这时我们需要了解一些简单的机器指令和汇编代码(与栈帧有关的部分)

算术操作

指令 效果 描述
ADD S,D D = D + S 加法
SUB S,D D = D - S 减法
lea S,D D <-- &S 加载有效地址
MOV S,D D <-- S 传送(复制)

弹出和压入栈数据:

指令 效果
push 压栈
pop 弹出栈

push指令的功能是把数据压入到栈上,同时改变栈指针:

每一次push操作都会让栈指针减少

32位系统上:每一次push都会让栈指针减4

64位系统上,每一次push会让栈指针减8

pop指令的功能是从栈中弹出数据,同时改变帧指针

么一次弹出都会让栈指针增加

32位系统:每一次pop会让栈指针加4

64位系统:每一次pop会让栈指针加8

在这里插入图片描述

程序计数器

通常称为PC,给出将要执行的下一条指令在内存中的地址

在这里插入图片描述

控制转移

其实控制转移就是指我们在一个函数中调用另一个函数之后,开始执行被调函数的操作。也可以理解为将PC的值设置为被调函数第一条语句的地址。

控制转移需要用到的指令有:

指令 效果
call 调用函数
ret 从函数中返回

指令call—调用函数

第一步:将将要执行的下一条指令的地址压入栈中

第二步,将程序计数器设置为被调函数的起始位置

在这里插入图片描述

指令ret—从函数中返回

从栈中弹出地址,并且向pc的值设置为该地址
在这里插入图片描述

可以发现:这种将返回地址压入栈的机制可以让函数在稍后返回到程序中正确的位置

数据传送

当我们调用一个过程的时候,往往还需要将一些数据作为参数传递给这个过程,而该过程还有可能会返回一个值。我们怎样让两个过程之间的数据产生联系呢?

参数的传递

当一个过程调用另一个过程的时候,第一个过程中的代码必须先将参数赋值到适当的寄存器中。

返回值的传递

函数的返回值通常是有寄存器eax来保存的

当后一个过程返回到前一个过程的时候,会将要返回的内容保存在寄存器eax中,该函数调用结束后,前一个过程中的代码可以通过访问寄存器eax中的值来得到返回值

举例:函数栈帧创建和销毁的全过程

我们利用下面这个代码段来分析函数栈帧的创建和销毁

这个代码段中,main函数调用了一个求和函数ADD:

#include<stdio.h>

int ADD(int x,int y)
{
    int z =  x + y;
    return z;
}

int main()
{
    int a = 10;
    int b = 20;
    int sum = 0;
    sum = ADD(a,b);
    return 0;
}

这是这两个函数的汇编代码:
在这里插入图片描述

我们要知道的是:主函数也是被其他函数调用出来的,所以在栈中还没有为main函数创建栈帧的时候,esp寄存器指向的是前一个函数的栈顶:
在这里插入图片描述

调用main函数后开始为main函数创建函数栈帧:

ebp的地址压入栈中,然后开辟出一块空间:

在这里插入图片描述

接着在将需要保存的寄存器压入栈中、为新开辟出来的赋值。

在这里插入图片描述

这样main函数的函数栈帧就创建好了,然后开始执行main函数中的语句:

int a = 10;
int b = 20;
int sum = 0;
sum = ADD(a,b);

在这里插入图片描述

当执行到sum = ADD(a,b)的时候要先传递参数和将返回地址压入栈中:

在这里插入图片描述

压入返回值:

在这里插入图片描述

之后就可以创建ADD函数的函数栈帧了(准备流程和创建main函数时很相似):

在这里插入图片描述

执行ADD函数中的操作:

在这里插入图片描述

调用ADD结束后就会销毁该函数的栈帧

在这里插入图片描述

之后main函数得到返回值:执行计算和存储然后main函数调用完毕,销毁main函数的栈帧
在这里插入图片描述

小结

我们利用一些问题来反映运行时栈中的细节:

局部变量是怎么创建的?

局部变量的创建首先是位所在函数分配好栈帧空间,栈帧空间里面会初始化一部分空间,然后给局部变量在栈帧中分配一点空间。

为什么局部变量不初始化的时候值是随机的?

在创建所在函数的栈帧的时候,那一部分初始化的的空间里面存放的是随机值,如果在创建局部变量的时候不去初始化,那么该空间的随机值不会改变,如果创建的时候初始化了局部变量,那么初始化的值会将随机值覆盖。

函数是怎么传参的?传参的顺序是怎么样的?

当还没有调用函数的时候,就已经将实参从右向左开始压栈,在真正进入形参函数的时候,在函数里面的指针偏移量找回来找到了形参。

形参 和实参的关系?

形参是实参的一份临时拷贝,改变形参不会影响实参。

函数调用的结果是怎么返回的?

在 调用之前就已经把call指令的下一条指令的地址记住了,掉哦那个这个函数的上一个函数的ebp已经存进去了,当函数使用完要返回的时候,弹出ebp就可以找到上一个函数调用的ebp,然后指针向下走的时候 就可以找到esp的地址,这样就已经回到韩国一个函数的栈帧空间,又因为调用之前就记住了call下一条指令的地址,让我们函数调用的时候可以返回,返回值通过保存在寄存器里面被带回来。

本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
THE END
分享
二维码
< <上一篇
下一篇>>