DiuR21Laonnu

函数调用的实质-结合栈帧探讨(Stack Frame)

2017/09/24

函数调用的实质-结合栈帧探讨(Stack Frame)

前言

​ 在撰写 函数调用的实质 时,接触到了栈帧的概念。

​ 为了更好的理解 函数调用的实质,故对该概念也进行了相关的整理。

栈帧是什么

​ 众所周知,栈是一种数据结构。其符合FILO(First in last out) 的原则,该原则在计算机世界中十分的常见。因而栈作为一种数据结构被广泛的使用于计算机中。

​ 在计算机中程序中,调用栈( Call Stack ) 为程序正在使用的栈空间,由多个嵌套函数所使用的栈帧组成。

栈帧( Stack Frame) 即为单个函数所分配的栈空间。

​ 对于栈帧,计算机使用两个寄存器对其进行跟踪:sp(stack pointer) & bp(based pointer)

​ ps:在32位机器中为 esp & ebp,64位为 rsp & rbp

​ 对于程序而言,栈从高地址向低地址延伸,堆从低地址从高地址延伸。堆栈之间有大块空闲存储区。

​ 寄存器bp指向栈帧的底部(高地址),寄存器sp指向栈帧的顶部(低地址)

准备工作

​ 准备一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int foo( int arg1, int arg2 )
{
return arg1 + arg2;
}
int foo2( int var1, int var2 )
{
return var1 + var2;
}
int main(void)
{
foo1( 1, 2 );
foo2( 3, 4 );
return 0;
}

通过 g++ 编译输出 汇编代码

如下【省略无关紧要的代码】,请先不要阅读以下代码。翻过它,跟着文章的分析部分进行理解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
__Z3fooii: ## @_Z3fooii
## BB#0:
pushl %ebp
movl %esp, %ebp
subl $8, %esp
movl 12(%ebp), %eax
movl 8(%ebp), %ecx
movl %ecx, -4(%ebp)
movl %eax, -8(%ebp)
movl -4(%ebp), %eax
addl -8(%ebp), %eax
addl $8, %esp
popl %ebp
retl
__Z4foo2ii: ## @_Z4foo2ii
## BB#0:
pushl %ebp
movl %esp, %ebp
subl $8, %esp
movl 12(%ebp), %eax
movl 8(%ebp), %ecx
movl %ecx, -4(%ebp)
movl %eax, -8(%ebp)
movl -4(%ebp), %eax
addl -8(%ebp), %eax
addl $8, %esp
popl %ebp
retl
_main: ## @main
## BB#0:
pushl %ebp
movl %esp, %ebp
subl $40, %esp
movl $1, %eax
movl $2, %ecx
movl $0, -4(%ebp)
movl $1, (%esp)
movl $2, 4(%esp)
movl %eax, -8(%ebp) ## 4-byte Spill
movl %ecx, -12(%ebp) ## 4-byte Spill
calll __Z3fooii
movl $3, %ecx
movl $4, %edx
movl $3, (%esp)
movl $4, 4(%esp)
movl %eax, -16(%ebp) ## 4-byte Spill
movl %ecx, -20(%ebp) ## 4-byte Spill
movl %edx, -24(%ebp) ## 4-byte Spill
calll __Z4foo2ii
xorl %ecx, %ecx
movl %eax, -28(%ebp) ## 4-byte Spill
movl %ecx, %eax
addl $40, %esp
popl %ebp
retl

代码分析

首先,我们对main函数进行剖析

1
2
3
pushl %ebp
movl %esp,%ebp
subl $40,%esp

在前文,我们指出bp,sp寄存器分别为基址指针寄存器与栈指针寄存器。调用main函数的时候,main函数对ebp指针进行压栈操作,保存上一个栈帧的栈顶。并且将esp的值初始化为当前的栈顶值【当前栈顶即为栈底】。

而后,subl语句。对esp寄存器进行了减40操作,为main函数创建了大小为40个字节的栈帧


接下来考虑如下代码

1
2
3
4
5
6
7
movl $1,$eax
movl $2,$ecx
movl $0,-4($ebp)
movl $1,(%esp)
movl $2,4(%esp)
movl $eax,-8(%ebp)
movl $ecx,-12(%ebp)

​ 回归c++代码,调用函数为foo(1,2)。因而,该汇编代码向我们展示了以下几个事实:

  • main函数初始参数为0
  • 调用函数前,函数参数从右向左被推入栈中。movl $1,(%esp)将参数1推入main栈帧帧底,movl $2, 4(%esp)将参数2推入main栈帧帧底加4个字节。【栈从高往低延伸】
  • 局部变量一开始被寄存于寄存器,而后因为溢出,而被存放到了内存【十分不合理的感觉,望高人赐教】

接下来,我们进入第一个函数foo,在汇编代码中其为__Z3fooii

1
2
3
4
5
6
7
8
9
10
11
12
13
14
__Z3fooii: ## @_Z3fooii
## BB#0:
pushl %ebp
movl %esp, %ebp
subl $8, %esp
movl 12(%ebp), %eax
movl 8(%ebp), %ecx
movl %ecx, -4(%ebp)
movl %eax, -8(%ebp)
movl -4(%ebp), %eax
addl -8(%ebp), %eax
addl $8, %esp
popl %ebp
retl
  • 保存main函数的基指针,也就是保存main函数栈帧的栈底 [pushl %ebp]
  • 设定当前栈帧的栈底为上一栈帧的栈顶 [movl %esp, %ebp]
  • 获取局部变量,并放置于寄存器中。这里我们回忆一下main函数中,局部变量应当是放置于栈顶处以及栈顶地址+4个字节的地址处。而在fooii函数中,+8以及+12是因为当main函数调用fooii函数时在其栈顶处压入了下一指令的执行地址,而在32位系统中这恰好是4个字节。 [movl 12(%ebp), %eax];[movl 8(%ebp), %ecx]
  • 保存变量在该栈帧中,而后根据指针偏移量进行addl操作。
  • 移动esp指针,完成退栈,运算结果保存在eax寄存器中。弹出上一栈帧的栈基指针。推出函数

在讨论完栈帧的生成以及退出后,想必已经对函数调用有了很直观的感受了。

我在此总结如下:

​ 1.函数调用实质是对函数地址的调用。当我们使用calll指令调用函数时,实质是把函数地址置入eip寄存器中。【指令】

​ 2.函数调用伴随调用栈的管理,调用函数生成栈帧,退出函数销毁栈帧。

​ 3.调用函数保存局部变量于栈顶,且保存自己的下一条指令地址。

​ 4.当被调用函数退出,调用函数的下一条指令地址位于当前调用栈的栈顶,弹出后eip回到原函数继续执行calll指令以下的指令。