前言
在写C语言代码时,我们经常会把一个个功能封装成函数来实现,通过功能分解实现函数的模块化程序设计。函数是C语言中模块化程序设计最小的单位,既可以把每一个函数都看作一个模块,也可以把多个函数集合看成一个模块,这样不仅使程序看起来更容易理解,也更容易调试和维护。但是在学习实现的过程中我们是否思考过:
局部变量是怎样创建的?
为什么局部变量的值是随机的?
函数是怎么传参的?
形参和实参是怎么样的关系?
函数调用是怎么做到的?
函数调用完是怎么带回来返回值的?
…
今天的文章我们就走进函数栈帧,详细探究一下函数调用的底层逻辑。
什么是函数栈帧
认识栈
栈(stack)是限定仅在表尾进行插入或删除操作的线性表,系统或者数据结构栈中数据内容的读取与插入(压入)PUSH和删除POP,压入是增加数据,弹出是删除数据 ,这些操作只能从栈顶即最低地址作为约束的接口界面入手操作并向高地址增长的。就好比存储货物或供旅客住宿的地方,可引申为仓库、中转站,所以引入到计算机领域里,就是指数据暂时存储的地方,所以才有进栈、出栈的说法。
在封装函数时,函数的局部变量都是在栈区创建的,函数调用结束后这些储存单元会被自动释放。栈区主要负责存放运函数的局部变量,函数参数,返回值等。在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。在i386机器或者x86-64位机器中,栈顶由称为esp的寄存器和ebp寄存器栈底的地址进行定位。压栈的操作使得栈顶的地址减小,弹出的操作使得栈顶的地址增大。
栈在程序的运行中有着举足轻重的作用。最重要的是栈保存了一个函数调用时所需要的维护信息,这常常称之为堆栈帧或者活动记录。堆栈帧一般包含如下几方面的信息:
1.函数的返回地址和参数
2. 临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量。
函数栈帧
函数栈帧就是每一个函数在调用过程中,都要在栈区开辟出一块空间。为哪个函数开辟了空间,这个空间就称作该函数的函数栈帧。如图所示,栈顶由称为esp的寄存器和ebp寄存器栈底的地址进行定位。压栈的操作使得栈顶的地址减小,弹出的操作使得栈顶的地址增大。
认识寄存器
这里我们介绍一些相关的寄存器
eax:通用寄存器,实现乘除运算,用来缓存结果
ebx:基地址寄存器,存储器指针
ecx:计数器寄存器,实现循环控制
edx:通用寄存器,一般用来存放整数除法产生的余数
ebp:记录栈底的地址
esp:记录栈顶的地址
edi:目标索引寄存器
…
认识汇编指令
MOV:传送字或字节
PUSH:把字压入堆栈
POP: 把字弹出堆栈
ADD: 加法。
SUB: 减法。
CMP: 比较
JMP: 无条件转移指令。
CALL: 过程调用。
RET/RETF: 过程返回。
解析函数栈帧的创建和销毁
演示代码
我们这里给一个简单的代码:该程序是在VS2022的编译器运行的。
#define _CRT_SECURE_NO_WARNINGS
#include<stdio.h>
int Add(int x, int y)
{
int z = 0;
z = x + y;
return z;
}
int main()
{
int a = 10;
int b = 20;
int c = 0;
c = Add(a, b);
printf("%d\n", c);
return 0;
}
按F10调试起来,点击调用堆栈,我们来深度剖析一下:
我们可以看到这里的main函数是被invoke_main函数调用的__scrt_common_main()调用了__scrt_common_main_seh(),__scrt_common_main_seh()又调用了invoke_main(),invoke_main()才调用main函数,可以看出来这个调用过程很复杂,但不同的编译器调用的方法不一样。得知main函数也是被别人调用,所以main函数肯定也有自己的函数栈帧,而我们自己实现的Add函数也由自己的函数栈帧。
代码反汇编
这里我们看到代码反汇编,逐句解读一下含义:
函数栈帧的创建
000B18D0 push ebp //把ebp寄存器中的值进行压栈,此时的ebp中存放的是invoke_main函数栈帧的ebp,esp-4
000B18D1 mov ebp,esp //move指令会把esp的值存放到ebp中,相当于产生了main函数的ebp,这个值就是invoke_main函数栈帧的esp
000B18D3 sub esp,0D8h //sub会让esp中的地址减去一个16进制数字0D8h,产生新的esp,此时的esp是main函数栈帧的esp,此时结合上一条指令的ebp和当前的esp,ebp和esp之间维护了一个块栈空间,这块栈空间就是为main函数开辟的,就是main函数的栈帧空间,这一段空间中将存储main函数中的局部变量,临时数据已经调试信息等。
000B18D9 push ebx //将寄存器ebx的值压栈,esp-4
000B18DA push esi //将寄存器esi的值压栈,esp-4
000B18DB push edi //将寄存器edi的值压栈,esp-4
//上面3条指令保存了3个寄存器的值在栈区,这3个寄存器的在函数随后执行中可能会被修改,所以先保存寄存器原来的值,以便在退出函数时恢复。
//压入的三个值这里无需关心,最后这三个值也会弹出。
//下面的代码是在初始化main函数的栈帧空间。
000B18DC lea edi,[ebp-3Ch] //先把ebp-3Ch的地址,放在edi中
000B18DF mov ecx,9 //把9放在ecx中
000B18E4 mov eax,0CCCCCCCCh //把0xCCCCCCCC放在eax中
000B18E9 rep stos dword ptr es:[edi]//将从edi到ebp这一段的内存的每个字节都初始化为0xCC
以上就是在为我们main函数准备栈帧做的工作,这里为什么要初始化呢?从edi到ebp这一段的内存的每个字节都初始化为0xCC,若不初始化产生的随机值是我们不知道的。就向我们给一个数组char arr1[10],不初始化里面的元素,当main函数被调用时候,arr1这个数组刚好也在这片空间上建立,而main函数内存的每个字节都初始化为0xCC,0xCC对应的汉字编码就是“烫”,这里打印出来就是“烫烫烫”.
Add函数的函数栈帧
这里进入Add函数内部,可以看到和main函数开辟栈帧步骤相同,我们这里快速浏览一下:调用Add函数。就是把参数压入到开辟的栈帧中,也就是函数的传参,将从edi到ebp这一段的内存的每个字节都初始化为0xCC。我们逐句分析一下Add函数内部到底是怎么运行的。main函数的函数栈帧开辟好,就可以继续写我们的代码,我们定义两个局部变量a,b,定义c来接受我们Add函数的返回值.
int a = 10;
000B18F5 mov dword ptr [ebp-20h],10 //将3存储到ebp-20h的地址处,ebp-20h的位置其实就是a变量
int b = 20;
000B18FC mov dword ptr [ebp-2Ch],20 //将5存储到ebp-2Ch的地址处,ebp-2Ch的位置其实是b变量
int c = 0;
000B1903 mov dword ptr [ebp-38h],0 //将0存储到ebp-38h的地址处,ebp-38h的位置其实是c变量
//以上汇编代码表示的变量a,b,c的创建和初始化,这就是局部的变量的创建和初始化
//其实是局部变量的创建时在局部变量所在函数的栈帧空间中创建的
//调用Add函数
c = Add(a, b);
//调用Add函数时的传参
//其实传参就是把参数push到栈帧空间中
000B190A mov eax,dword ptr [ebp-2Ch] //传递b,将ebp-2Ch处放的20放在eax寄存器
中
000B190D push eax //将eax的值压栈,esp-4
000B190E mov ecx,dword ptr [ebp-20h] //传递a,将ebp-20h处放的10放在ecx寄存器中
000B1911 push ecx //将ecx的值压栈,esp-4
//跳转调用函数
000B1912 call 000B13CA
000B1917 add esp,8
000B191A mov dword ptr [ebp-38h],eax
这里call指令是逻辑过程调用,在执行call指令时候,会去调用Add函数,先会把call指令的下一条指令的地址进行压栈操作,这个操作是为了解决当函数调用结束后要回到call指令的下一条指令的地方,继续往后执行。这里按下F11进到函数内部,逐语句调试。
Add函数的反汇编:
int Add(int x, int y)
{
000B1850 push ebp // 将 main 函数栈帧的 ebp 保存 ,esp-4
000B1851 mov ebp , esp // 将 main 函数的 esp 赋值给新的 ebp , ebp 现在是 Add 函数的 ebp
000B1853 sub esp , 0CCh // 给 esp-0xCC ,求出 Add 函数的 esp
000B1859 push ebx // 将 ebx 的值压栈 ,esp-4
000B185A push esi // 将 esi 的值压栈 ,esp-4
000B185B push edi // 将 edi 的值压栈 ,esp-4
int z = 0 ;
000B1875 mov dword ptr [ ebp - 8 ], // 将 0 放在 ebp-8 的地址处,其实就是创建 z
z = x + y ;
// 接下来计算的是 x+y ,结果保存到 z 中
000B187C mov eax , dword ptr [ ebp + 8 ] // 将 ebp+8 地址处的数字存储到 eax 中
000B187F add eax , dword ptr [ ebp + 0 Ch ] // 将 ebp+12 地址处的数字加到 eax 寄存中
000B1882 mov dword ptr [ ebp - 8 ], eax // 将 eax 的结果保存到 ebp-8 的地址处,其实 就是放到z 中
return z ;
000B1885 mov eax , dword ptr [ ebp - 8 ] // 将 ebp-8 地址处的值放在 eax 中,其实就是把z 的值存储到 eax 寄存器中,这里是想通过 eax 寄存器带回计算的结果,做函数的返回值。
}
内存中整体的调用逻辑:
函数的返回值:
当函数调用完后又是如何把返回值带回来的呢?我们看到这里回到了call指令的下一条指令的地方:
000B1917 add esp,8 //esp直接+8,相当于跳过了main函数中压栈的a和b
000B191A mov dword ptr [ebp-38h],eax //将eax中值,保存到ebp-38h的地址处,其实就是存储到main函数中c变量中,而此时eax中就是Add函数中计算的x和y的和,可以看出来,本次函数的返回值是由eax寄存器带回来的。程序是在函数调用返回之后,在eax中去读取返回值的。
函数栈帧的销毁
我们函数调用完,之前开辟出的函数栈帧也会销毁,这片空间用完了就会被回收,我们可以看到反汇编代码里pop弹出来三个值,esp值也发生了改变:
000B1888 pop edi //在栈顶弹出一个值,存放到edi中,esp+4
000B1889 pop esi //在栈顶弹出一个值,存放到esi中,esp+4
000B188A pop ebx //在栈顶弹出一个值,存放到ebx中,esp+4
000B1898 mov esp,ebp //再将Add函数的ebp的值赋值给esp,相当于回收了Add函数的栈帧空间
000B189A pop ebp //弹出栈顶的值存放到ebp,栈顶此时的值恰好就是main函数的ebp,esp+4,此时恢复了main函数的栈帧维护,esp指向main函数栈帧的栈顶,ebp指向了main函数栈帧的栈底。
000B189B ret //ret指令的执行,首先是从栈顶弹出一个值,此时栈顶的值就是call指令下一条指令的地址,此时esp+4,然后直接跳转到call指令下一条指令的地址处,继续往下执行。
结语
讲到这里就到尾声了,我们现在在回过头看看我们最初思考的问题
局部变量是怎样创建的?
为什么局部变量的值是随机的?
函数是怎么传参的?
形参和实参是怎么样的关系?
函数调用是怎么做到的?
函数调用完是怎么带回来返回值的?
…
局部变量是怎样创建的?
要创建局部变量首先为函数分配好函数栈帧空间,然后栈帧空间里面初始化好一部分空间,就会给局部变量分配一定的空间。
为什么局部变量的值是随机的?
这里其实我们已经分析过了,如果不初始化,这里都是随机值0xcc,随机值是也是我们随机放进去的,初始化就相当于把这块的随机值都覆盖成了我们初始的值。
函数是怎么传参的?
在调用函数时候,会把这两个参数从右向左进行压栈,在Add函数栈帧里面通过指针的偏移量找回了行参
形参和实参是怎么样的关系?
行参是在压栈时候开辟出来的空间,和实参值是相同的,空间上各自独立,行参只是实参的一份临时拷贝。
函数调用是怎么做到的?
这个问题我们通篇已经细致的解释了。
函数调用完是怎么带回来返回值的?
在函数栈帧的销毁中讲到弹出栈顶的值存放到ebp,栈顶此时的值恰好就是main函数的ebp,这里弹出edp找到上一个函数调用的ebp,通过指针就能找到esp的顶,之前call指令下一条指令地址已经记住了,从而回到栈帧空间,也把值带回到主函数里。
今天的分享就到这里了,欢迎大家一起来讨论!