函数栈帧的创建与销毁
文章目录
前言
在上C语言的函数部分的时,每个老师都会强调"形参是实参的一份临时拷贝",当时我只是简单地记住了它,并把它认为是理所当然的事.在接触了初级数据结构一段时间以后(包括学习Java语法的过程中),随着使用函数次数的增加,对其了解也越来越深.以前有了解过"函数栈帧",但没能完全弄明白,最近因为要开始学二叉树等需要用到递归和函数调用的地方,所以重新了解了函数栈帧,体会颇深
本篇文章力求简单易懂,读完它我们能知道:
- 局部变量是如何创建的?
- 为什么未初始化的局部变量是"随机值"?
- 函数如何传参?顺序?
- 形参与实参的关系?
- 函数是如何被调用的?
- 函数被调用结束后如何返回?
1. 理解"函数栈帧"
用一个简单的例子理解"栈帧":
#include <stdio.h>
//加法函数
int Add(int x, int y)
{
return x + y;
}
//减法函数(并调用了Add)
int Sub(int x, int y)
{
return x - Add(x, y);
}
//main函数
int main()
{
int a = 10;
int b = 20;
//这是一种函数间的链式访问
printf("%d\n", Sub(a, b));
return 0;
}
- 何为函数间的链式访问?
将上一个函数的结果作为当前函数的参数
注意:
main函数也是被另一个函数调用的,下面会提到
- 上面的例子中,Sub函数调用了Add函数,那么加上printf函数和main函数后,这四个函数被调用的顺序如何呢?
- main函数被调用
- printf函数被main函数调用
- Sub函数被printf函数调用
- Add函数被Sub函数调用
而函数返回值的顺序则相反.知道"栈"这种数据结构的同学知道,函数被调用和返回的过程其实就是一个栈的结构,即先进后出结构.
这就是"栈帧"的"栈".
把每个函数视为栈中的元素,调用函数是入栈,返回函数是出栈.每个函数在栈中所占空间则称为"帧".(就像影片中的帧一样)
2. 寄存器和汇编指令的介绍
2.1 寄存器
寄存器(Register)是中央处理器内用来暂存指令、数据和地址的电脑存储器。寄存器的存贮容量有限,读写速度非常快。在计算机体系结构里,寄存器存储在已知时间点所作计算的中间结果,通过快速地访问数据来加速计算机程序的执行。
–维基百科
我们只需要知道寄存器是独立于系统和内存之外的、集成于CPU中用来存储数据的的硬件即可.
本文中需要了解的几类寄存器:
- 一般寄存器
名称 | 功能 |
---|---|
eax(accumulator) | 累加器 |
ebx(base) | 存地址 |
ecx(counter) | 计数器 |
edx(data) | 存数据 |
- 堆叠、基底寄存器
名称 | 功能 |
---|---|
esp(Extended Stack Pointer) | 栈指针寄存器,存放函数栈顶地址 |
ebp(Extended Base Pointer) | 帧指针寄存器,存放函数栈底地址 |
- 索引(变址)寄存器
名称 | 功能 |
---|---|
esi | 存放源变址 |
edi | 存放目的变址 |
小结:
- esp和ebp维护的是当前被调用函数的栈帧
- 重点记忆esp和ebp
- 了解四个一般寄存器的功能即可
- esp是会随着栈顶数据的变化而变化的,也就是说esp始终指向栈顶元素
2.2 汇编指令
在此仅列出本文所需的汇编指令
指令 | 功能 |
---|---|
push x | 将x压入栈中 |
pop x | 将x弹出栈中 |
mov a, b | 将b赋值给a,即b指向a |
sub a, num | a的值减去num,即a向低地址移动 |
lea(load effective adress) | 加载有效地址(在示例中理解) |
注:不同平台,不同编译器对应的汇编语句是不同的,寄存器的名字也有所不同,但逻辑相同,下面皆以visual studio 2019 x86平台为例
提醒:如果在后文有遇到上文提到的内容但记不起来,记得返回查阅
3. 函数栈帧创建
以一个简单的程序为例,其中将代码分得足够细,以致于能够清晰地理解计算机中底层是如何创建函数栈帧的.
#include <stdio.h>
int Add(int x, int y)
{
int z = x + y;
return z;
}
int main()
{
int a = 10;
int b = 20;
int c = Add(a, b);
return 0;
}
下面将以汇编代码逐语句分析.
3.1 main函数栈帧的创建
事实上,main函数也是被另一个函数调用的,在此以main函数栈帧的创建为例
int main()
{
push ebp
mov ebp,esp
sub esp,0E4h
push ebx
push esi
push edi
lea edi,[ebp-24h]
mov ecx,9
mov eax,0CCCCCCCCh
rep stos dword ptr es:[edi]
int a = 10;
mov dword ptr [ebp-8],0Ah
int b = 20;
mov dword ptr [ebp-14h],14h
int c = Add(a, b);
mov eax,dword ptr [ebp-14h]
push eax
mov ecx,dword ptr [ebp-8]
push ecx
call 011C10B4
add esp,8
mov dword ptr [ebp-20h],eax
return 0;
xor eax,eax
}
pop edi
pop esi
pop ebx
add esp,0E4h
cmp ebp,esp
call 011C1235
mov esp,ebp
pop ebp
ret
以上是main函数对应的汇编代码,虽然看起来有些多,但都是一些简单的工作,耐心看下去还是很有意思的.
下面将汇编代码以C语句为分区解释其作用.
push ebp
mov ebp,esp
sub esp,0E4h
push ebx
push esi
push edi
- 前6句:
- 首先将ebp的值压入栈顶
- 将esp移动到ebp的位置
- 将esp向低地址移动0E4h个字节的位置
- 暂时无需理会ebx,esi和edi
注意:
- 局部变量和函数栈帧占用的内存是在栈区开辟的.记住栈区的地址是从高到低.(暂时先记住它好了,附程序在内存中运行的奥秘)
- 既然是栈区,那么它符合栈的特点,即先进后出.
- 关于最后的ebx,esi,edi,只需知道它们的存在即可
lea edi,[ebp-24h]
mov ecx,9
mov eax,0CCCCCCCCh
rep stos dword ptr es:[edi]
- 后6句:
- 将[ebp-24h]存入edi中
- 将9存入ecx中
- 将0CCCCCCCCh存入eax中
- 将edi的值对应的地址处开始,将高于该地址共ecx个单位的值置为0CCCCCCCCh
int a = 10;
mov dword ptr [ebp-8],0Ah
int b = 20;
mov dword ptr [ebp-14h],14h
int c = Add(a, b);
mov eax,dword ptr [ebp-14h]
push eax
mov ecx,dword ptr [ebp-8]
push ecx
call 011C10B4
add esp,8
mov dword ptr [ebp-20h],eax
- 1-9句:
- 为临时变量a,b,c创建空间,并同时将它们的值存入
- 将上面b和a的值分别放入eax和ecx中,并将它们先后压入栈中
- 12句:
- 记录当前语句的下一条语句的地址(即add esp, 8的地址),以便后续调用完函数以后能继续从原地开始执行
- 调用Add函数
- 13-14句:
- 先进入Add函数后才会跳回来执行
至此,为main函数开辟的函数栈帧已经创建完毕,回顾并小结
3.1.1 小结
- 函数栈帧的创建就是元素入栈的过程
- esp始终指向栈顶元素,即esp会随着栈顶元素的增加而移动.所以main函数的栈帧所占内存会根据esp的指向而变化
- 在该函数中的临时变量会根据先后顺序,从低地址开始向高地址存放.(注意:各临时变量的相对距离由编译器和平台决定,但相对位置是一定的)
- 实际上main函数的栈帧开辟完成后,还要传参.也就是说:函数传参并不是在被调用函数中进行的,而是在为main函数创建栈帧进行的
3.2 Add函数的栈帧的创建
从main函数的call指令开始,Add函数被调用,Add函数的栈帧开始被创建
下面给出其C语句对应的汇编代码:
int Add(int x, int y)
{
push ebp
mov ebp,esp
sub esp,0CCh
push ebx
push esi
push edi
lea edi,[ebp-0Ch]
mov ecx,3
mov eax,0CCCCCCCh
rep stos dword ptr es:[edi]
mov ecx,11CC003h
call 011C130C
int z = x + y;
mov eax,dword ptr [ebp+8]
add eax,dword ptr [ebp+0Ch]
mov dword ptr [ebp-8],eax
return z;
mov eax,dword ptr [ebp-8]
}
pop edi
pop esi
pop ebx
add esp,0CCh
cmp ebp,esp
call 011C1235
mov esp,ebp
pop ebp
ret
可以发现:在call指令之前的所有指令都与main函数栈帧的创建别无二致.
- 16-18
- 通过ebp的值加上一个值,找到刚刚在main函数中复制的参数.
- 将参数运算以后的值存入eax
- 再通过ebp的值减去一个值,找到在Add函数中的z,并将eax的值存入
- 20
- 将z位置的值存入eax中
3.2.1 小结
- 在为函数栈帧开辟好内存空间以后,首先会为函数中的临时变量找到位置
- 函数的形参并不会在该函数内部创建
- 函数在返回后,z的值并不会消失,因为eax是独立于内存和系统之外的硬件
- ebp起着非常重要的作用,通过它才能向上和向下查找变量的位置
4. 函数栈帧的销毁
由于函数栈帧的销毁过程大致一样,所以下面仅演示Add函数栈帧的销毁过程
pop edi
pop esi
pop ebx
add esp,0CCh
- 1-4:
- 将edi,esi和ebx弹出栈.
- 将esp下移
call 011C1235
mov esp,ebp
pop ebp
ret
- 1-4:
- 跳转到该地址对应的语句处.实际上已经回到main函数了
- 将esp和ebp指向同一个位置,即Add的函数栈帧被销毁
- 弹出ebp
- 将之前压入的地址弹出,并跳转到该地址对应的语句
4.1 注意事项
- 内存空间的销毁,是针对某个对象的"销毁",而不是将某些内存块的值改成某些数字.对于函数栈帧,ebp和esp维护的内存范围就是该函数的栈帧,如果想要销毁某部分,只需要让这两个指针的范围发生相应的变化即可.就像在顺序表链表中我们要删除某个元素,只需要将计数器或者指针发生变化即可.
- 同一段代码在不同编译器和平台上对应的汇编指令可能是不同的,但其逻辑是不变的
总结
- 函数栈帧的创建和销毁是两个(大致)互逆的过程。都需要做前期准备。
- esp和ebp指针维护的内存范围即为该函数的栈帧,esp会随着栈顶元素的增加而变化。
- call指令的重要性:它是进入函数的入口。也是从a函数回到调用a函数的函数的入口,因为call指令在调用函数之前,把当前语句的下一个语句的地址压入栈中。当调用函数完毕后,被调用函数的栈帧被销毁,栈顶一定会遇到之前保存的地址,通过该地址就能回到之前调用函数的地方,继续执行语句。
- epb的重要性:这里强调的是不同函数对应的epb的值,而不是epb这个指针(当然它也很重要)。第5点会举例。
- 函数传参实际上在上一个函数就已经传递完毕,形参实质上就是实参在原函数中的一份拷贝,它们都处于原函数的栈帧中。第4点的举例:前一句话说明:被调用函数的形参不是在该函数的栈帧中创建的,是通过ebp减去某个值,找到上一个函数中的拷贝。而被调用的函数的运算结果需要通过ebp加上某个值,回到被调用函数的栈帧中,存放在创建的临时变量中。
- 函数返回值(如果有返回值)在被调用完毕后是不会随着栈帧的销毁而消失的。因为返回值在函数栈帧被销毁之前被存放在某个寄存器中,而寄存器是独立于内存和系统之外的硬件,上一个函数直接取得该寄存器中的值即能得到返回值。
至此,你能回答文章开头的问题吗?
5/17/2022