首页 > 代码库 > 函数的调用过程(栈帧)
函数的调用过程(栈帧)
1、什么是栈帧?
栈帧也叫过程活动记录,是编译器用来实现函数调用过程的一种数据结构。C语言中,每个栈帧对应着一个未运行完的函数。从逻辑上讲,栈帧就是一个函数执行的环境:函数调用框架、函数参数、函数的局部变量、函数执行完后返回到哪里等等。栈是从高地址向低地址延伸的。每个函数的每次调用,都有它自己独立的一个栈帧,这个栈帧中维持着所需要的各种信息。寄存器ebp指向当前的栈帧的底部(高地址),寄存器esp指向当前的栈帧的顶部(低地址)。
2、Add()函数的调用过程
我们以Add()函数为例深入的研究一下函数的调用过程。
先看一段简单的代码:
1 #include <stdio. h> 2 int Add(int x, int y) 3 { 4 int z = 0; 5 z = x + y; 6 return z; 7 } 8 int main() 9 {10 int a = 10;11 int b = 20;12 int ret = Add(a, b) ;13 printf("ret = %d\n", ret) ;14 return 0;15 }
当讲程序调试的时候, 查看【调用堆栈】(按F10进入调试-窗口-调用堆栈,或按快捷键ctrl+alt+C) ,用VS2015调试 如下图:
如果用版本更老的,或其他如VC6.0等编辑器则可以看到更多信息,VS2008调试如图:
我们发现其实main函数在 __tmai nCRTStartup 函数中调用的,而 __tmai nCRTStartup 函数是在 mai nCRTStartup 被调用的。我们知道每一次函数调用都是一个过程。这个过程我们通常称之为: 函数的调用过程。这个过程要为函数开辟栈空间, 用于本次函数的调用中临时变量的保存、 现场保护。 这块栈空间我们称之为函数栈帧。
而栈帧的维护我们必须了解ebp和esp两个寄存器。 在函数调用的过程中这两个寄存器存放了维护这个栈的栈底和栈顶指针。比如:调用main函数, 我们为main函数分配栈帧空间, 那么栈帧维护如下:
ebp存放了指向函数栈帧栈底的地址。esp存放了指向函数栈帧栈顶的地址。
注意:ebp指向当前位于系统栈最上边一个栈帧的底部,而不是系统栈的底部。严格说来,“栈帧底部”和“栈底”是不同的概念;ESP所指的栈帧顶部和系统栈的顶部是同一个位置。
1 . 从main函数的地方开始, 要展开main函数的调用就得为main函数创建栈帧, 那我们先来看main函数栈帧的创建。转到反汇编可以更清晰的看到过程:
过程分析:
a.首先mainCRTStartup(),__mainCRTStartup()函数的调用,调main()函数;
b.将ebp压栈处理,保存指向栈底的ebp的地址(方便函数返回之后的现场恢复),此时esp指向新的栈顶位置;
c.将esp的值赋给ebp,产生新的ebp;
d.给esp减去一个16进制数0E4H(为main函数预开辟空间);
e.push ebx、esi、edi;
f.lea指令,加载有效地址;
g.初始化预开辟的空间为0xcccccccc;
h.创建变量a与b。
2. 接下来是Add函数的调用。
参数传递过程:
过程分析:
a.将b存入寄存器eax,再将将eax压栈;(传参过程,从左向右传递)
b.将a存入寄存器ecx,再将将ecx压栈;
c.call指令的调用,先要压栈call指令下一条指令的 地址,然后跳转(push+jmp)到Add()函数的地方(__cdecl调用约定)。执行call指令的时候按F11 , 来到了这里。
再按F11 就进入Add函数的执行代码处。Add函数栈帧的创建:
过程分析:
a.首先将main()函数ebp压栈处理,保存指向main()函数栈帧底部的ebp的地址(方便函数返回之后的现场恢复),此时esp指向新的栈顶位置;
b.将esp的值赋给ebp,产生新的ebp,即Add()函数栈帧的ebp;
c.给esp减去一个16进制数0E4H(为Add()函数预开辟空间);
d.push ebx、esi、edi;
e.lea指令,加载有效地址;
f.初始化预开辟的空间为0xcccccccc;
g.创建变量z;
h.获取形参的a和b再相加,将结果存储到z中;
i.将结果存储到eax寄存器,通过寄存器带回函数的返回值。
剩下的就是是函数返回部分:
过程分析:
a.pop3次,edi、esi、ebx依次出栈,esp 会向下移动;
b.将ebp赋给esp,使esp指向ebp指向的地方
c.ebp 出栈,将出栈的内容给ebp(即main()函数ebp),回到main()函数的栈帧;
d.ret 指令,出栈一次,并将出栈的内容当做地址,并跳转到该地址处(pop+jmp)。
注: 栈帧这部分内容在不同的编译器上实现存在差异, 但是思想都是一致的。
栈帧的一般总结:
1. 堆栈是C语言程序运行时必须的一个记录调用路径和参数的空间:
函数调用框架;
传递参数;
保存返回地址;
提供局部变量空间;
等等。
以x86体系结构为例
2. 堆栈寄存器和堆栈操作
堆栈相关的寄存器
esp,堆栈指针(stack pointer)
ebp,基址指针(base pointer)
堆栈操作
push 栈顶地址减少4个字节(32位)
pop 栈顶地址增加4个字节
ebp在C语言中用作记录当前函数调用基址
3. 利用堆栈实现函数调用和返回
其他关键寄存器
cs : eip:总是指向下一条的指令地址
● 顺序执行:总是指向地址连续的下一条指令
● 跳转/分支:执行这样的指令的时候, cs : eip的值会根据程序需要被修改
● call:将当前cs : eip的值压入栈顶, cs : eip指向被调用函数的入口地址
● ret:从栈顶弹出原来保存在这里的cs : eip的值,放在cs : eip中
● 发生中断时???
4. 函数堆栈框架的形成
call xxx
执行call之前;
执行call时,cs:eip原来的值指向call下一条指令,该值被保存到栈顶,然后cs:eip的值指向xxx的入口地址
进入xxx
第一条指令:pushl %ebp
第二条指令:movl %esp,%ebp
函数体中的常规操作,压栈,出栈等
退出xxx
movl %ebp,%esp
popl %ebp
ret
5. 堆和栈的关系
我们平时说的堆栈其实是指栈,而实际上堆和栈是两种不同的内存分配。简单罗列如下各方面的异同点。
1).堆需要用户在程序中显式申请,栈不用,由系统自动完成。申请/释放堆内存的API,在C中是malloc/free,在C++中是new/delete。申请与释放一定要配对使用,否则会造成内存泄漏(memory leak),久而久之系统就无内存可用了,出现OOM(Out Of Memory)错误。一般在return/exit或break/continue等语句时容易忘记释放内存,所以检查内存泄漏的代码时要关注这些语句,看它们前面是否有必要的释放语句free/delete。
2).堆的空间比较大,栈比较小。所以申请大的内存一般在堆中申请;栈上不要有较大的内存使用,比如大的静态数组;而且除非算法必要,否则一般不要使用较深的迭代函数调用,那样栈消耗内存会随着迭代次数的增加飞涨。
3).关于生命周期。栈较短,随着函数退出或返回,本函数的栈就完成了使用;堆就要看什么时候释放,生命周期就什么时候结束。
我们发现解析Coredump还是跟栈的关系相对紧密,跟堆的关系是有一种产
生Coredump的原因是访问堆内存出错。
为什么研究栈帧?看一个题目 :
在VC6.0环境中, 下面代码的结果是什么?
1 #include <stdi o. h> 2 void fun() 3 { 4 int tmp = 10; 5 int *p = (int *) (*(&tmp+1) ) ; 6 *(p-1) = 20; 7 } 8 int main() 9 {10 int a =0;11 fun() ;12 printf("a = %d\n", a) ;13 return 0;14 }
事实上在不同平台下这段代码有不同的输出,可自行验证。
函数的调用过程(栈帧)