操作系统系列(四)——栈与函数调用关系

往期地址:

本期主题:



1.什么是栈

是现代计算机中最为重要的概念之一,几乎每一个程序都使用到了栈。

1.1 栈的定义和特点:

栈被定义为一个特殊的容器,用户可以将数据压入栈中(称为入栈 push),也可将数据跳出栈中(出栈 pop),栈中数据的顺序按照 先进后出的顺序(FILO,First In Last Out)。
在经典的操作系统中,栈的增长方向是向下增长的。

在i386(i386是32位微处理器的统称)下,栈顶被由称为 esp 的寄存器进行定位,栈底是 ebp 寄存器,如下图所示:
在这里插入图片描述
上图展示的是一个
1.栈底地址为 0xbfff ffff,栈顶地址为 0xbffff fff4的栈
2.当出栈(pop)时,栈顶 esp 地址增大,当入栈(push)时,栈顶地址减小。

1.2 堆栈帧

除了上述描述的一些局部数据外,更为重要的是,栈还保存了一个函数调用所需要的信息,这被称为 堆栈帧(stack frame) 或者 活动记录(activate record)

堆栈帧一般包括如下几方面的内容:

  • 函数的返回地址和参数
  • 临时变量:包括函数的 非静态局部变量以及其他局部变量
  • 保存的上下文:包括在函数调用前后需要保持不变的寄存器

在i386中,一个函数的活动记录使用ebp和esp寄存器来记录
esp寄存器始终指向栈的顶部,当前函数的活动记录的顶部。
ebp寄存器指向栈的底部,记录了函数活动的固定位置,又被称为栈指针。
一个常见的活动记录图如下所示:
在这里插入图片描述
几个关键点:

  • ebp寄存器始终指向当前函数的底部,是固定不变的
  • ebp所指向的数据 old ebp,指得是调用该函数前的ebp值即调用者帧的ebp值,这样在函数返回时,ebp就能读这个值恢复到调用前的状态
  • ebp之前就是调用者帧的返回地址和参数,因此ebp + 4就是函数的返回地址

之所以会形成这样的结构,主要是因为在 i386 下函数总是这样调用的:

  1. 把所有或一部分参数压入栈中
  2. 把当前指令的下一条指令地址压入栈中,作为返回地址
  3. 跳转到函数体去进行执行

第三步,跳转到函数体之后,在 i386下的标准操作是这样的:

  • push ebp :先是把ebp压入栈中,这是调用者的ebp,称为old ebp
  • mov ebp, esp :ebp = esp,将esp的值赋给ebp,前面esp始终指向栈顶,此时esp所指向的值就是刚压入栈的 old ebp
  • 【非必须】sub esp, XXX:在栈上分配XXX 字节的临时空间
  • 【非必须】push xxx:将xxx寄存器保存
  • 函数内部操作…
  • 开始返回
  • mov esp, ebp:恢复esp,同时回收局部变量
  • pop ebp:从栈中恢复保存的ebp值
  • ret:从栈中取得返回地址,并跳转

重点: 关注跳转到函数之后的开头与结尾返回,刚好是相互对称的。

2.调用惯例

我们通过上面的讲解,已经大致明白了,一个函数的调用过程,也可以理解到 函数的调用方和被调用方对于函数的调用应该有着一个统一的理解,应该一致认同函数参数按照某个方式压入栈中
这样的调用规则被称为 调用惯例(calling convention),这样的调用惯例会约束以下几个方面:

  1. 函数参数的传递方式,简单来说就是栈传递的方式,压栈的顺序是从右到左,还是从左到右,有的还允许用寄存器传递参数。

  2. 名字修饰的策略,一般c语言中默认的调用惯例是 cdecl,没有显示指定调用惯例的都是默认cdecl 惯例,例如对于foo的声明,完整形式是 int _cdecl foo(int n, int m);

总结一下 cdecl的调用惯例:

参数传递出栈方名字修饰
从右至左的顺序压参数入栈函数调用方直接在函数名称加下划线_

举个例子,调用前面提到的 int _cdecl foo(int n, int m) 函数,按照cdecl的参数传递方式进行调用,具体的入栈顺序:

  • 将参数m压入栈
  • 将参数n压入栈
  • 调用_foo
    • 将返回地址(调用_foo之后的下一条指令地址入栈)
    • 跳转到_foo执行

foo函数栈布局如下图所示:
在这里插入图片描述

3.实例讲解

写一个非常简单的函数调用代码,实际调试看各个阶段的栈变化情况:
代码如下:

#include <stdio.h>
int test(int a, int b)
{
	return (a + b);
}

int main(void)
{
	int i = 0x12345;
	test(111, 222);
	return 0;
}

在visual studio IDE上,使用ALT+F8启用反汇编调试,该代码可分为几个阶段:

  1. 在main函数栈帧,在调用test函数之前
  2. 调用test函数
  3. 返回的栈帧变化

3.1 main函数栈帧

1.在main函数还未开始执行之前
在这里插入图片描述
此时ebp、esp寄存器情况如下图所示:
在这里插入图片描述
2.main函数开始执行
仔细看此时反汇编的代码中,有一个 sub esp,0CCh 的操作,个人理解这里是提前分配了一些栈的空间,为了能够存放一些局部变量以及寄存器,运行之后,ebp、esp寄存器的情况变化为:
在这里插入图片描述

3.2 调用test函数

同样的,按照两个阶段来分析,在进入test函数之前以及之后:
1.进入test函数之前:
调用test函数按照预期应该要做两个事情:

  • 将test的参数入栈
  • 将test的下一条指令地址作为返回地址压入栈

我们实际调试看一下栈变化情况:
在这里插入图片描述
可以看出确实符合预期,此时的栈帧情况 esp寄存器往下增加了12个字节,放入了3个参数,分别为test函数的两个参数以及返回地址,此时栈帧情况实际为:
在这里插入图片描述
2.进入test函数之后:
再看此时的test函数的栈帧变化情况
在这里插入图片描述
ebp和esp寄存器发生了变化,同时ebp指向的是main栈帧的ebp寄存器,此时的变化情况如下图:
在这里插入图片描述

3.3 返回的栈帧变化

在这里插入图片描述
返回时将ebp、esp都出栈,此时的堆栈情况变为:
在这里插入图片描述
同时esp还要加8,是为了将两个函数参数也出栈。

3.4 总结

以上就用了一个简单的例子讲了函数多级调用的栈变化情况,更为复杂的调用关系也是一样的分析方式,主要是需要掌握:

  1. ebp、esp寄存器的变化情况
  2. 入栈时的顺序
  3. 出栈时的顺序
  • 4
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值