前言
大家好,我是Anmory,很高兴与大家一同探讨有关函数栈帧的底层逻辑。无论您使用的是何种编程语言,今天的话题都具有普适性,因为它触及到计算机程序运行机制的本质层面,对于深入理解函数调用机制将大有裨益。
在日常编写函数的过程中,我们或许会遇到诸多疑问:局部变量究竟是如何在内存中被创建和管理的?为什么未经初始化的局部变量其内容往往呈现随机状态?函数调用时,参数是以何种方式传递的?传递参数遵循什么样的顺序规则?函数形参与实参的具体实例化过程又是怎样的?函数执行完毕后,其返回值是如何安全地回传至调用者处的?
相信通过今天的分享与解析,上述种种问题都将逐一揭开神秘面纱,变得清晰易懂。现在,让我们一起勇往直前,深入探索函数栈帧及函数调用的世界吧!
什么是栈?
栈(stack)是现代计算机程序里面最为重要的概念之一,几乎每个程序都用到了栈,没有栈就没有函数,没有局部变量,也就没有我们今天看到的所有计算机语言。
在计算机科学的基础理论中,栈被精炼地定义为一种特殊的内存容器,它允许用户执行两种基本操作:将数据元素压入栈中(即入栈,Push),以及从栈顶移除已压入的数据(即出栈,Pop)。这一数据结构的核心特征在于其遵循严格的“后进先出”(Last In, First Out,简称LIFO)原则。形象地说,就好比一本本图书堆叠起来的情景,最新放入堆顶的书最先取出,而最先放入底部的书则最后才能接触到。
在实际的计算机系统内部实现中,栈是一个动态调整大小的内存区域,专门为满足上述LIFO逻辑而设计。程序执行时,每当有新的数据通过压栈操作被添加到栈内,栈的容量便会相应增长;反之,每执行一次弹出操作移除栈顶元素,栈的高度随之缩减,反映的是对之前压栈行为的逆序撤销。
在经典操作系统中,栈总是向下增长的(由高地址向低地址)
那么在正式开始之前,我们还需要了解一些简单的寄存器和汇编指令,以VS2019的C语言为例
认识相关寄存器和汇编指令
相关寄存器
eax:通用寄存器,保存临时数据,常用于返回值
ebx:通用寄存器,保存临时数据
ebp:栈底寄存器
esp:栈顶寄存器
eip:指令寄存器,保存当前指令的下一条指令的地址
相关汇编命令
mov:数据转移指令
push:数据入栈,同时esp栈顶寄存器也要发生改变
pop:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变
sub:减法命令
add:加法命令
call:函数调用,1.压入返回地址 2.转入目标函数
jump:通过修改eip,转入目标函数,进行调用
ret:恢复返回地址,压入eip,类似pop eip指令。
准备知识
在深入探讨函数栈帧之前,我们有必要先了解栈区的管理和维护机制。已知栈遵循后进先出(LIFO)原则,这意味着每当数据使用完毕后,通过pop操作将数据从栈顶弹出,从而释放栈空间。
在这其中,有两个至关重要的指针承担着维持函数栈帧秩序的关键角色,它们就是ebp和esp。ebp,即基址指针(Base Pointer),充当栈底的标识;而esp,则是栈顶指针(Stack Pointer),负责实时指向当前栈顶位置。
形象地比喻,esp就像一家忙碌餐厅的服务员,每当有新的顾客(数据)入座时,它就会迅速调整到队列最前端,确保新来的“餐牌”被妥善放置。与此相对,ebp则如同记录餐厅入口处最近一张餐桌位置的前台接待员,它固定了栈帧的基础地址,使得我们可以通过固定的偏移量精准定位到栈中各变量的位置,确保访问和操作的准确性。
每个函数调用都会在栈区上分配一块专用的空间,这块空间被称为函数栈帧。理解了这些基本原理之后,我们现在就正式开启对函数栈帧更深层次的学习之旅吧!
函数栈帧的创建和销毁
函数的调用堆栈
我们用下面的代码来进行示例
#include <stdio.h>
int Add(int x,int y)
{
int z = 0;
z = x + y;
return z;
}
int main()
{
int a = 3;
int b = 5;
int c = 0;
c = Add(a,b);
printf("%d\n",c);
}
此时,我们在VS中启用调试,在视图中转到调用堆栈,并勾选显示外部代码,便能看到以下信息:
函数调用堆栈作为反映函数调用顺序与逻辑的结构,清晰地揭示了main函数执行前的调用过程:首先是由invoke_main函数负责发起对main函数的调用。在此阶段,我们暂时将invoke_main函数之前的调用层级搁置不谈。
可以确定的是,invoke_main函数自身拥有独立的栈帧空间以维护其内部变量和计算状态。同样地,当main函数被调用时,它也会创建并维护自己的栈帧;而进一步在main函数中调用Add函数时,Add函数也将构建自身的栈帧环境。每个函数栈帧都配备有ebp(基址指针)和esp(栈顶指针),以便精准管理各自栈帧内的存储区域。接下来,我们将从main函数栈帧的创建机制开始逐步展开讲解。
main函数栈帧的创建以及功能的实现
调试到main函数的第一行,鼠标右击转到反汇编,有以下内容:
int main()
{
//函数栈帧的创建
00BE1820 push ebp
00BE1821 mov ebp,esp
00BE1823 sub esp,0E4h
00BE1829 push ebx
00BE182A push esi
00BE182B push edi
00BE182C lea edi,[ebp-24h]
00BE182F mov ecx,9
00BE1834 mov eax,0CCCCCCCCh
00BE1839 rep stos dword ptr es:[edi]
//main函数中的核心代码
int a = 3;
00BE183B mov dword ptr [ebp-8],3
int b = 5;
00BE1842 mov dword ptr [ebp-14h],5
int c = 0;
00BE1849 mov dword ptr [ebp-20h],0
c = Add(a, b);
00BE1850 mov eax,dword ptr [ebp-14h]
00BE1853 push eax
00BE1854 mov ecx,dword ptr [ebp-8]
00BE1857 push ecx
00BE1858 call 00BE10B4
00BE185D add esp,8
00BE1860 mov dword ptr [ebp-20h],eax
printf("%d\n", c);
00BE1863 mov eax,dword ptr [ebp-20h]
00BE1866 push eax
00BE1867 push 0BE7B30h
00BE186C call 00BE10D2
00BE1871 add esp,8
return 0;
00BE1874 xor eax,eax
}
我们一行一行地分解这些汇编语言代码。
00BE1820 push ebp //压入一个ebp的寄存器,至于为什么要先压入一个ebp,后面就会知道了,这里面都是有严密的逻辑的
00BE1821 mov ebp,esp //mov是赋值的意思,也就是把esp的值赋给ebp,此时ebp就到了esp的位置
00BE1823 sub esp,0E4h //将esp的地址减去0E4h,也就是228个字节,为什么是减?因为栈区是由高地址指向低地址,这一步是为main函数开辟空间,那么为什么是228个字节呢?这个空间的大小由系统计算,无需我们担心
00BE1829 push ebx
00BE182A push esi
00BE182B push edi //在此压入了三个寄存器,我们不用管他,最后他们会弹出的,注意这三个寄存器每一个都是4个字节
//上面3条指令保存了3个寄存器的值在栈区,这3个寄存器的在函数随后执行中可能会被修改,所以先保存寄
存器原来的值,以便在退出函数时恢复。
00BE182C lea edi,[ebp-24h] //lea的意思是load effective address,即加载有效地址,这句代码的意思是为edi寄存器加载一个24字节的地址
00BE182F mov ecx,9 //把9赋值给ecx以便后续循环使用
00BE1834 mov eax,0CCCCCCCCh //将0CCCCCCCCh赋值给eax,以便后续初始化main函数栈帧的地址,初始化后main函数栈帧的地址将全部变为cc
00BE1839 rep stos dword ptr es:[edi] //初始化main函数栈帧,即将edi循环填充eax的内容,至此,main函数栈帧的初始化已经完成
考虑到无法通过电脑绘图工具进行辅助说明,我选择手写并拍照的方式来帮助大家更直观地理解相关概念。虽然图片中展示的某些具体数值可能因实际情况而有所差异,但这些细节并不会影响我们对核心原理的理解与掌握。敬请各位谅解,并期待您能透过手绘示意图更好地领悟其中的知识点。
下面就是main函数中的核心代码:
//main函数中的核心代码
int a = 3;
00BE183B mov dword ptr [ebp-8],3
//此段代码将a=3的值放在了ebp-8这个地址中
int b = 5;
00BE1842 mov dword ptr [ebp-14h],5
//此段代码将b=5的值放在了ebp-14h的地址中
int c = 0;
00BE1849 mov dword ptr [ebp-20h],0
//这段代码将c=0的值放在了ebp-20h的地址中
//注意这三个值的地址间的间隙都是相等的
c = Add(a, b);
00BE1850 mov eax,dword ptr [ebp-14h]
//这段代码将ebp-14h的值也就是b的值放入eax寄存器中
00BE1853 push eax
//压入一个eax寄存器
00BE1854 mov ecx,dword ptr [ebp-8]
//将ebp-8的值也就是a的值放入ecx中
00BE1857 push ecx
//压入exc寄存器
00BE1858 call 00BE10B4
//此时我们按F11,便能跳转到Add函数
00BE185D add esp,8
//记住add前面的地址,后面会用到
00BE1860 mov dword ptr [ebp-20h],eax
下面依旧是手写笔记以助于理解:
值得注意的是,如果程序员在声明变量时未对其进行初始化,那么该变量的初始值将是其所在内存区域中未经修改的原有内容。在某些情况下,尤其是对于字符变量,如果不初始化,它可能恰好存储着字符'c'的ASCII码。因此,在打印这样的未初始化字符变量时,输出结果可能会是汉字“烫”。这就是为何我们有时会看到未初始化变量打印出一系列不可预期的“烫”字的原因。
另外,关于寄存器EDI、ESI和EBX的初始化问题,在main函数栈帧的上下文中,它们通常并不会被默认初始化。这是因为这些寄存器在程序执行过程中常常用于临时数据传递或保存关键信息,并不属于特定函数栈帧的局部变量范围。作为CPU的通用寄存器,它们在函数调用前后需要根据具体情况进行手动赋值与清理,以确保其正确反映当前的程序状态。
。
Add函数栈帧的创建以及功能的实现
我们已经跳转到了Add函数的反汇编中来
int Add(int x, int y)
{
00BE1760 push ebp //压入ebp寄存器
00BE1761 mov ebp,esp //赋值,将esp的值赋给ebp
00BE1763 sub esp,0CCh //为Add函数开辟空间
00BE1769 push ebx //压入ebx寄存器
00BE176A push esi //压入esi寄存器
00BE176B push edi //压入edi寄存器
int z = 0;
00BE176C mov dword ptr [ebp-8],0 //将0赋值给z,保存在ebp-8的地址里
z = x + y;
00BE1773 mov eax,dword ptr [ebp+8] //将ebp+8的值放入eax寄存器中
00BE1776 add eax,dword ptr [ebp+0Ch] //将ebp+0Ch的值放入eax中
00BE1779 mov dword ptr [ebp-8],eax 将eax的值放入ebp-8的地址中
return z;
00BE177C mov eax,dword ptr [ebp-8] //将ebp-8的值赋给eax寄存器,也就是将z值放进eax寄存器来做到返回值,因为如果不这么做,一会Add函数销毁之后z值就没了,所以需要提前将z值放入一个寄存器中保存
}
00BE177F pop edi
00BE1780 pop esi
00BE1781 pop ebx //弹出三个寄存器
00BE1782 mov esp,ebp //将ebp的值赋给esp,此时Add函数的栈帧没有维护,也就销毁了
00BE1784 pop ebp //弹出ebp,此时弹出ebp后ebp需要找到一个位置,之前我们一开始就压入了一个ebp,所以ebp回到了刚刚那个ebp的位置,也就是重新开始维护main函数的栈帧
00BE1785 ret//返回,意思是跳到栈顶,而我们的栈顶现在是那个call的下一个地址,也就是继续执行call下一个地址的命令,逻辑非常严谨
Add函数的销毁与返回main函数
00BE1871 add esp,8 //此时我们再来看我们的这个函数,将esp的值加8,也就是刚好回到了edi,也就是上面的x和y均被销毁,至此,形参也被销毁,逻辑完整
EBP寄存器在程序的栈帧管理中扮演着关键的角色,就好比物理学中的参考点对于计算重力势能的重要性一样。EBP寄存器所指向的位置就如同一条栈帧内的基准线,通过在其基础上进行相对偏移量的加减操作,可以精准地定位到函数内部局部变量和其他栈上数据的位置。
在函数调用过程中,形参并非在被调用函数内部新创建的变量,而是由实参传递其值后的临时副本。因此,对形参所做的任何修改都不会直接影响到原始实参的值。
ESP寄存器作为栈顶指针,与EBP共同维护栈的操作。它随着入栈和出栈操作动态变化地址,遵循后进先出(LIFO)原则。ESP并不存储具体的数据内容,而是指向当前栈顶元素所在的位置。这样,即使在栈区内频繁移动,也不会违背栈结构的基本逻辑。
同时值得注意的是,并非所有CPU寄存器都专门用于存放地址。像EAX、EBX、ECX和EDX等通用寄存器不仅可以保存地址,还能直接存储计算过程中的数值数据。
至于实参和形参的关系,在函数执行期间,虽然不能直接通过改变形参来影响实参,但在某些情况下,若函数通过指针形参间接访问实参所指向的内存区域,则可以通过修改该内存区的数据来间接改变实参原本的内容。而在函数返回时,也可以通过返回值机制将处理结果回传给实参所在的上下文环境。
结语
总结来说,函数栈帧是计算机程序执行过程中不可或缺的一部分,它承载了函数调用的完整生命周期。从栈帧的创建、局部变量的分配与初始化,到参数传递、计算过程,再到最后返回值的回传与栈帧的销毁,每一个环节都紧密相连且逻辑严谨。EBP和ESP寄存器作为栈帧管理的核心,分别扮演着基准线和栈顶指针的角色,确保了数据在内存中的正确存取。
通过深入剖析main函数与Add函数的栈帧构建与销毁流程,我们不仅揭开了函数调用背后的神秘面纱,也对局部变量的存储、形参实参的交互有了更为深刻的理解。同时,我们还了解到CPU寄存器如EAX、EBX等在函数调用中所承担的重要职责,它们既能保存地址也能存储数值,共同协作完成复杂的程序执行任务。
总之,在现代计算机科学的世界里,理解函数栈帧及其底层机制就如同掌握了一把解锁复杂程序行为的钥匙,帮助我们更好地编写高效且健壮的代码,更深层次地洞察计算机程序运行的本质规律。让我们携手勇攀知识的高峰,继续在探索函数栈帧与函数调用机制的道路上砥砺前行!