往期地址:
本期主题:
栈
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 下函数总是这样调用的:
- 把所有或一部分参数压入栈中
- 把当前指令的下一条指令地址压入栈中,作为返回地址
- 跳转到函数体去进行执行
第三步,跳转到函数体之后,在 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),这样的调用惯例会约束以下几个方面:
-
函数参数的传递方式,简单来说就是栈传递的方式,压栈的顺序是从右到左,还是从左到右,有的还允许用寄存器传递参数。
-
名字修饰的策略,一般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启用反汇编调试,该代码可分为几个阶段:
- 在main函数栈帧,在调用test函数之前
- 调用test函数
- 返回的栈帧变化
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 总结
以上就用了一个简单的例子讲了函数多级调用的栈变化情况,更为复杂的调用关系也是一样的分析方式,主要是需要掌握:
- ebp、esp寄存器的变化情况
- 入栈时的顺序
- 出栈时的顺序