深入了解运行时栈(C语言)
运行时栈
栈是一种数据结构:我们可以向这种结构中存入数据,也可以在这个结构中弹出数据,这个结构的特点是:后压入的数据先弹出,先压入的数据后弹出。
在计算机系统种,栈是一个具有以上属性的动态内存区域,程序可以将数据压入栈中,也可以将数据从栈中弹出,压栈操作时栈增大,弹出操作是栈减小
C语言过程调用中也使用了这样的内存管理原则–先进后出
计算机系统中的内存布局:
可以看出,栈是由高地址向低地址增长的,栈顶在低地址处,栈底在高地址处。
函数的栈帧
程序在每一次调用函数的时候就会在栈区创建一块空间,这块空间就被称为该函数的栈帧
这块空间中一般包括了下面一些信息:
- 函数的返回地址和参数
- 临时变量:包括函数的非静态局部变量以及编译器生成的其他临时变量
- 保存的寄存器
当运行中的程序调用另一个函数时,就要进入一个新的栈帧,原来函数的栈帧称为调用者的帧,新的栈帧称为当前帧。 被调用的函数运行结束后当前帧全部回收,回到调用者的帧。
例如:当函数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下一条指令的地址,让我们函数调用的时候可以返回,返回值通过保存在寄存器里面被带回来。