在这里插入图片描述
🏠 个人主页: Marathon_X

📚 个人专栏:

专栏名称 专栏主题简述
《C语言》 C语言基础、语法解析与实战应用
《数据结构》 线性表、树、图等核心数据结构详解
《题解思维》 算法思路、解题技巧与高效编程实践
《排序详解》 算法思路、解题技巧与高效编程实践

在这里插入图片描述

在 C 语言编程中,函数调用、局部变量存储、参数传递等底层逻辑,都离不开 “函数栈帧” 的支撑。理解函数栈帧的工作机制,能帮我们彻底搞懂局部变量初始化、函数传参顺序、返回值传递等常见问题。本文结合 VS2019 编译器实例,详细拆解函数栈帧的核心概念、创建销毁流程及实际应用价值。

一、什么是函数栈帧?

函数栈帧(stack frame)是函数调用过程中,在程序调用栈(call stack)上开辟的专属内存空间,主要用于存储三类数据

  • 函数参数与返回值
  • 临时变量(包括非静态局部变量、编译器自动生成的临时数据);
  • 上下文信息(如函数调用前后需保持不变的寄存器值)。

简单来说,每个函数调用都会创建独立的栈帧,栈帧由栈底寄存器(ebp)和栈顶寄存器(esp)共同维护 ——ebp 固定指向栈帧底部,esp 随数据入栈(push)和出栈(pop)动态移动,指向栈帧顶部。

二、理解函数栈帧的核心价值

掌握函数栈帧原理后,以下这些前期学习百思而不得其解的经典问题便能迎刃而解:

  1. 局部变量是如何在内存中创建的?
  2. 为什么未初始化的局部变量值是随机的?
  3. 函数参数的传递顺序和底层逻辑是什么?
  4. 函数返回值是通过什么方式带回主调函数的?
  5. 形参和实参的实例化关系(为何形参修改不影响实参)?

三、基础预备知识

3.1 栈与栈帧基础概念

  1. 什么是栈 (Stack)?
    • 定义: 栈是一种后进先出 (LIFO) 的数据结构,常见的操作为压栈push、出栈pop。在程序运行过程中,主要用于存储局部变量、函数参数、返回地址等信息。
    • 内存区域: 栈位于进程的高地址区域,向低地址方向增长
  2. 什么是函数栈帧 (Stack Frame)?
    • 定义: 每次函数调用时,都会在栈上分配一块独立的内存区域,这块区域被称为该函数的栈帧。
    • 作用: 它为函数的运行提供了所需的存储空间和上下文信息。

3.2 栈帧的核心组成要素(以X86为例)

组成部分 作用 存储方向
EBP/RBP 始终指向当前栈帧的底部(高地址),用于稳定访问局部变量和函数参数 高地址
ESP/RSP 始终指向栈的顶部(低地址),随着数据入栈/出栈而变动 低地址
EIP/RIP 程序控制:存储下一条待执行指令的地址,控制程序流程
EAX/RAX 数据传递:函数调用结束后,传递返回值
返回地址 函数执行完毕后,程序跳回此地址继续执行 EBP/RBP下方
函数参数 调用者传递给被调用函数的参数 EBP/RBP上方
局部变量 函数内部定义的非静态变量 ESP/RSP上方

在C语言程序运行的底层,函数调用和局部变量的管理完全依赖于栈(Stack)机制,而控制这一机制的核心就是两个关键的CPU寄存器:栈顶指针(ESP/RSP)栈底指针(EBP/RBP)

3.1.1 基础概念与术语速查

概念 32位系统 (X86) 64位系统 (X64) 内存增长方向
栈顶指针 ESP (Extended Stack Pointer) RSP (Register Stack Pointer) 向低地址增长
栈底指针 EBP (Extended Base Pointer) RBP (Register Base Pointer) 向低地址增长

3.2.2 ESP/RSP:动态的边界控制器(栈顶指针)

ESP/RSP 是一个动态且活跃的寄存器,它始终标识着栈的当前操作位置。

1. 核心作用
  • 动态边界: 始终指向栈中有效数据的最低地址(即栈的顶部)。
  • 内存分配与回收: 它是控制栈空间变化的核心。
    • 入栈 (PUSH): ESP/RSP 的值会减小,为新数据腾出空间。
    • 出栈 (POP): ESP/RSP 的值会增大,释放被取出的数据空间。
  • 指令依赖: 许多汇编指令(如 PUSH, POP, CALL, RET)都会自动隐式地操作 ESP/RSP
2. 在栈帧中的角色

ESP/RSP 标识着当前函数栈帧的动态低地址边界。函数内的局部变量、为函数调用准备的参数等,都会使得 ESP/RSP 不断向低地址移动。

【形象比喻】 ESP/RSP 就像一个水位线,随着数据(水)的加入和取出而不断变化。

3.2.3 EBP/RBP:稳定的参考基准(栈底指针)

EBP/RBP 是一个固定且稳定的寄存器,它为函数提供了可靠的内存访问基准。

1. 核心作用
  • 稳定定位: 始终指向当前函数栈帧的固定高地址边界(通常是保存调用者EBP/RBP的位置)。
  • 访问参数和变量: EBP/RBP 作为基准点,通过固定的偏移量来访问数据:
    • 访问函数参数: 通常使用正偏移量,如 [EBP + 8]
    • 访问局部变量: 通常使用负偏移量,如 [EBP - 4]
  • 上下文链接: 在栈帧的创建过程中,旧的 EBP/RBP 值会被压入栈中保存,新函数的 EBP/RBP 指向这个保存位置,从而形成一条栈帧链,便于函数返回时恢复调用者的上下文。
2. 在栈帧中的角色

EBP/RBP 是访问函数数据的“定海神针”。无论 ESP/RSP 如何变化(因为可能存在临时变量的入栈/出栈),EBP/RBP 都保持不变,确保了对局部变量和参数的可靠访问。

【形象比喻】 EBP/RBP 就像一把尺子,它的零点被固定在栈帧的底部,方便测量所有数据的精确位置。

3.2.4 ESP/RSP 与 EBP/RBP 的协作(函数调用生命周期)

栈帧的创建和销毁就是这两个寄存器协同工作的过程。

步骤 汇编操作示例 (伪代码) EBP/RBP 的变化 ESP/RSP 的变化
栈帧建立 (Prolog) PUSH EBP
MOV EBP, ESP
保存旧值,然后被赋予新的 ESP 值,保持固定 先减小(保存EBP),然后将新值赋给EBP,继续减小以分配局部变量。
函数执行中 MOV EAX, [EBP - 4] 保持不变,作为定位基准。 随着临时的 PUSH/POP 操作而动态变化
栈帧销毁 (Epilog) MOV ESP, EBP
POP EBP
从栈中恢复旧值,回到调用者状态。 先被赋予 EBP 的值(清理局部变量),然后增大(弹出旧EBP)。

总结来说就是:

  • ESP/RSP (Stack Pointer): 动态指针,控制栈的读写和大小,保证栈的 LIFO 属性。
  • EBP/RBP (Base Pointer): 静态指针,提供稳定寻址,用于访问局部变量和函数参数。

以下给出在x86与x64环境下的示例:
在这里插入图片描述

在这里插入图片描述

3.3 常用汇编指令

汇编指令 格式 核心作用 关键特点/栈帧应用
MOV MOV dest, src 将源操作数的值复制到目的操作数。 基本数据传输。 如:MOV EBP, ESP (建立新栈帧)。
PUSH PUSH src 将数据压入栈中。 ESP/RSP 减小。如:PUSH EBP (保存调用者EBP)。
POP POP dest 将栈顶数据弹出到指定位置。 ESP/RSP 增大。如:POP EBP (恢复调用者EBP)。
LEA LEA reg, addr 计算源操作数的有效地址,并将其传送到寄存器。 不访问内存。常用于快速计算地址或高效乘法。
ADD ADD dest, src 将两个操作数相加,结果存入目的操作数。 栈帧清理:ADD ESP, N (释放函数参数空间)。
SUB SUB dest, src 将目的操作数减去源操作数,结果存入目的操作数。 栈帧分配:SUB ESP, N (为局部变量分配空间)。
JMP JMP target 无条件跳转到目标地址。 不涉及栈操作,用于简单的流程转移。
CALL CALL func_name 执行函数调用。 1. 压入返回地址。 2. JMP 到目标函数。
RET RET 结束函数执行并返回调用者。 1. POP 返回地址。 2. 将返回地址赋给 EIP/RIP

上述这些知识做一个简单了解,我们只需要关注栈帧怎么创建,怎么调用函数,怎么在地址中存储就可以。

四、函数栈帧的创建与销毁示例(X86)

示例代码

#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 ret = 0;
	ret = Add(a, b);
	printf("%d\n", ret);
	return 0;
}

4.1 环境准备

为了让我们研究函数栈帧的过程足够清晰,不要太多干扰,我们可以关闭下面的选项,让汇编代码中排除一些编译器附加的代码:
在这里插入图片描述

4.2 转至反汇编

Ctrl+F11开始调试,main函数开始执行的第一行,右击鼠标转到反汇编。
注:VS编译器每次调试都会为程序重新分配内存,示例中的反汇编代码是一次调试代码过程中数据,每次调试略有差异。

int main()
{
00141820  push        ebp  
00141821  mov         ebp,esp  
00141823  sub         esp,0E4h  
00141829  push        ebx  
0014182A  push        esi  
0014182B  push        edi  
0014182C  lea         edi,[ebp-24h]  
0014182F  mov         ecx,9  
00141834  mov         eax,0CCCCCCCCh  
00141839  rep stos    dword ptr es:[edi]  
	int a = 3;
0014183B  mov         dword ptr [a],3  
	int b = 5;
00141842  mov         dword ptr [b],5  
	int ret = 0;
00141849  mov         dword ptr [ret],0  
	ret = Add(a, b);
00141850  mov         eax,dword ptr [b]  
00141853  push        eax  
00141854  mov         ecx,dword ptr [a]  
00141857  push        ecx  
00141858  call        _Add (01410B4h)  
0014185D  add         esp,8  
00141860  mov         dword ptr [ret],eax  
	printf("%d\n", ret);
00141863  mov         eax,dword ptr [ret]  
00141866  push        eax  
00141867  push        offset string "%d\n" (0147B30h)  
0014186C  call        _printf (01410D2h)  
00141871  add         esp,8  
	return 0;
00141874  xor         eax,eax  
}

为了重点要去观察具体的地址、内存的布局,方便下面的分析和观察,做这样一个设置:
右击鼠标把显示符号名取消勾选
在这里插入图片描述
这样就可以更方便看具体的地址、内存的布局了。

4.3 函数的调用堆栈

调试开启后打开监视窗口就可以看见函数的调用堆栈了。
在这里插入图片描述
在这里插入图片描述
在做完上述设置后,就能清晰看见main函数的调用堆栈:
在这里插入图片描述
函数调用堆栈是反馈函数调用逻辑的,那我们可以清晰的观察到,main函数调用之前,是由
invoke_main函数来调用main函数。那么在invoke_main函数之前的函数调用就暂时不考虑了。
那就可以确定,invoke_main函数应该会有自己的栈帧,main函数和Add函数也会维护自己的栈
帧,每个函数栈帧都有自己的ebpesp来维护栈帧空间。
那接下来我们从main函数的栈帧创建开始讲解:

4.4 详解函数栈帧的创建与销毁

4.4.1 main函数栈帧的创建与销毁

int main()
{
00141820  push        ebp              //将invoke_main函数的ebp压入栈中
00141821  mov         ebp,esp          //把esp的值存放到ebp中,相当于产生了main函数的ebp,这个值就invoke_main函数栈帧的esp
00141823  sub         esp,0E4h         //sub会让esp中的地址减去一个16进制数字0xe4,产生新的esp,此时的esp是main函数栈帧的esp,
                                       //此时结合上一条指令的ebp和当前的esp,ebp和esp之间维护了一个块栈空间,这块栈空间就是为main函数开辟的,就是main函数的栈帧空间。
}

这里也可以打开监视窗口进行查看当前esp中的地址
在这里插入图片描述
这里也可以明显看见esp指向更低的一个地址空间,那也就代表当前main函数的栈帧空间已经创建完成。其中大致的创建过程图如下所示:
在这里插入图片描述

这里需要说明的是,图里写的这么多esp、ebp实际是为了方便理解,并不是就真有这么多ebp、esp。因为我们目前在main函数中,ebp和esp维护的就是main函数的栈帧。
在函数栈帧的整个生命周期中,只有一个寄存器,它是持续不断地变化,负责维护栈的动态和大小的。

00141829  push        ebx   //将寄存器ebx的值压栈,esp-4
0014182A  push        esi   //将寄存器esi的值压栈,esp-4
0014182B  push        edi   //将寄存器edi的值压栈,esp-4
                            //push完之后esp的值会相应进行变化

将这三个寄存器的值压入栈中。
在这里插入图片描述

//下面的代码是在初始化main函数的栈帧空间。
//1. 先把ebp-24h的地址,放在edi中
//2. 把9放在ecx中
//3. 把0xCCCCCCCC放在eax中
//4. 将从edp-0x2h到ebp这一段的内存的每个字节都初始化为0xCC
0014182C  lea         edi,[ebp-24h]  
0014182F  mov         ecx,9  
00141834  mov         eax,0CCCCCCCCh  
00141839  rep stos    dword ptr es:[edi]  

这里真正起作用的其实就是rep stos dword ptr es:[edi]这一句:
也就是从edi开始,向下放的ecx的值,这么多个dword的数据,一个word是两个字节,dword表示双字,也就是四个字节,ecx为9,也就是每次初始化四个字节,初始化ecx次,也就是9次,全部初始化为eax中的值,即CCCCCCCC
在这里插入图片描述
这里左边给出在编译器调试监视内存时,初始化完成后的内存空间数据。

4.4.2 main函数中核心代码的执行

当执行到这里的时候,我们就可以看见我们自己写的C语言代码了,代表就开始真正执行main函数里面的代码:

	int a = 3;
0014183B  mov         dword ptr [ebp-8],3    //将3存储到ebp-8的地址处,ebp-8的位置其实就是a变量,减8代表偏移8个字节。
	int b = 5;
00141842  mov         dword ptr [ebp-14h],5  //将5存储到ebp-14h的地址处,ebp-14h的位置其实是b变量
	int ret = 0;
00141849  mov         dword ptr [ebp-20h],0  //将0存储到ebp-20h的地址处,ebp-20h的位置其实是ret变量

在这里插入图片描述
以上汇编代码表示的变量a,b,ret的创建和初始化,这就是局部的变量的创建和初始化。
其实是局部变量的创建时在局部变量所在函数的栈帧空间中创建的。

4.4.3 调用 Add() 函数

	ret = Add(a, b);
//调用Add函数时的传参
//其实传参就是把参数push到栈帧空间中
00141850  mov         eax,dword ptr [ebp-14h]    //传递b,将ebp-14h处放的5放在eax寄存器中
00141853  push        eax                        //将eax的值压栈,esp-4
00141854  mov         ecx,dword ptr [ebp-8]      //传递a,将ebp-8处放的3放在ecx寄存器中
00141857  push        ecx                        //将ecx的值压栈,esp-4

这是调用函数前的一个必要步骤,函数传参,这里也形象的展示了,形参是实参的一个拷贝,这里有个小细节,就是函数传参时是从右往左传递的。

//跳转调用函数
00141858  call        001410B4  
0014185D  add         esp,8  
00141860  mov         dword ptr [ebp-20h],eax  

执行call语句之后,会去调用Add函数,但是在调用Add函数之前,会把call指令的下一条指令的地址做压栈操作,这里是因为,当你执行完Add函数时,需要返回吧,这时候返回到哪呢?就是这里call指令的下一条指令。

执行call语句时,需要按F11进行逐语句调试。
在这里插入图片描述

在这里插入图片描述

这些汇编指令完成后,就会进入Add函数,那接着看看Add函数如何进行栈帧创建。

4.4.4 Add() 函数栈帧创建与销毁

这里就真正进入Add函数,在跳转入Add函数的汇编指令后,可以发现竟然与main函数差不多:

int Add(int x, int y)
{
00141760  push        ebp  
00141761  mov         ebp,esp  
00141763  sub         esp,0CCh  
00141769  push        ebx  
0014176A  push        esi  
0014176B  push        edi  
	int z = 0;
0014176C  mov         dword ptr [ebp-8],0  
	z = x + y;
00141773  mov         eax,dword ptr [ebp+8]  
00141776  add         eax,dword ptr [ebp+0Ch]  
00141779  mov         dword ptr [ebp-8],eax  
	return z;
0014177C  mov         eax,dword ptr [ebp-8]  
}

那这里就直接对函数栈帧进行创建,只对稍微不一样的地方进行解释:

  • ebp进行压栈,实际上是ebp-main
  • esp的值给ebp
  • esp减去0CCh,即给Add函数创建空间,esp往更低的地址去了。
  • ebx寄存器压栈。
  • esi寄存器压栈。
  • edi寄存器压栈。
  • 这里VS2019没有将初始化Add函数栈帧的部分表现出来,实际上也有一步把从edi开始,向下到ebp中间所有的空间内容初始化成CCCCCCCC

在这里插入图片描述

这里之后终于要执行核心Add代码了:

  • 创建临时变量z,将0放在ebp-8地址上。
  • ebp+8的值放在eax中,这时候就可以由上图看到,ebp+8的位置刚好就是ecx的位置,也就是对实参变量a的拷贝。
  • 然后再给eax中的值增加上ebp+0Ch,也就是对实参变量b的拷贝。
  • 加起来之后再把eax的值放到ebp-8当中去,也就是变量z

在这里插入图片描述
这里呢,更好的演示了形参是实参的临时拷贝,且函数内部对形参的作用不会改变实参,因为使用的参数是在维护main函数的栈帧中,压入的拷贝的变量,在Add函数中找到的也是压栈的拷贝的变量,而不是实参。
现在计算完后需要往回带,有人可能就好奇了,z是在Add函数内部的临时变量,在函数销毁时,z就应该失效了,为什么能成功返回值呢?

这里就可以仔细看一下汇编指令了,mov eax,dword ptr [ebp-8]这表示的是将ebp-8的值放在eax寄存器中,而这个寄存器是不会程序退出就销毁的,那么也就能成功返回值了。

4.4.5 Add函数栈帧的销毁

这里呢就会出现很多pop操作,而pop操作之后会跟一次地址++的操作,就是将esp指针向高地址进行移动

00BE177F  pop         edi       //在栈顶弹出一个值,存放到edi中,esp+4
00BE1780  pop         esi       //在栈顶弹出一个值,存放到esi中,esp+4
00BE1781  pop         ebx       //在栈顶弹出一个值,存放到ebx中,esp+4
00BE1782  mov         esp,ebp   //再将Add函数的ebp的值赋值给esp,相当于回收了Add函数的栈帧空间
00BE1784  pop         ebp       //弹出栈顶的值存放到ebp,栈顶此时的值恰好就是main函数的ebp,esp+4,此时恢复了main函数的栈帧维护,esp指向main函数栈帧的栈顶,ebp指向了main函数栈帧的栈底。
00BE1785  ret                   //ret指令的执行,首先是从栈顶弹出一个值,此时栈顶的值就是call指令下一条指令的地址,此时esp+4,然后直接跳转到call指令下一条指令的地址处,继续往下执行。

此时经过一系列操作后,栈帧就是这样一个状态,这时候也就代表Add函数栈帧已经被销毁了。
在这里插入图片描述
这时ebpesp均指向ebp-main,虽然main函数的栈底指针不好找,但是之前已经将它压入栈中,现在只需要将它弹出到ebp中,即可恢复main函数的ebp指针。此时就又回到了main函数的栈帧中。
在这里插入图片描述

4.4.6 返回main函数栈帧

现在只是回到了栈帧空间,那应该从哪执行呢?call的下一句指令,现在的栈顶刚好也就是call指令的下一句指令的地址F10进入下一步,刚好到达call的下一句指令。
在这里插入图片描述

0014185D  add         esp,8                    //esp直接+8,相当于跳过了main函数中压栈的拷贝的a和b
00141860  mov         dword ptr [ebp-20h],eax  //将eax中值,存档到ebp-20h的地址处,其实就是存储到main函数中ret变量中,而此时eax中就是Add函数中计算的x和y的和,
                                               //可以看出来,本次函数的返回值是由eax寄存器带回来的。程序是在函数调用返回之后,在eax中去读取返回值的。
	printf("%d\n", ret);
00141863  mov         eax,dword ptr [ebp-20h]      //将内存地址 [ebp - 20h] 处(即变量 ret 的存储位置)的 4 字节 (dword ptr) 值,移动到 EAX 寄存器中。获取要打印的变量 ret 的值。
00141866  push        eax                          //将 EAX 寄存器中的值(即 ret 的值)压入栈中。
//参数压栈 1: 准备 printf 的第二个参数(变量的值)。
00141867  push        147B30h                      //将立即数 147B30h(这是一个内存地址)压入栈中。
//参数压栈 2: 准备 printf 的第一个参数——格式字符串 "\%d\n" 所在的地址。
0014186C  call        001410D2                     //调用地址为 001410D2 的函数(即 printf)。
//流程控制: 1. 将下一条指令的地址(00141871)压入栈中作为返回地址。 
//          2. 跳转到 printf 函数体执行。
00141871  add         esp,8  
//将 ESP 寄存器的值增加 8。
//栈清理: 根据 cdecl 调用约定,调用者 (main 函数) 负责清理栈上的参数。因为压入了两个 4 字节(dword)的参数,所以需要增加 $2 \times 4 = 8$ 字节,将 ESP 恢复到调用 printf 之前的状态。
	return 0;
00141874  xor         eax,eax  
//对 EAX 寄存器执行异或操作 (EAX XOR EAX)。	
//设置返回值: 任何值与其自身异或的结果都是 0。这条指令的作用是将 EAX 寄存器清零,作为 return 0 的返回值。这是编译器生成 MOV EAX, 0 的更高效指令。

在这里插入图片描述
已经打印出结果,后续main函数的栈帧销毁跟Add函数类似,就不一步一步去展示了。


码字不易,若用图请标明文章链接!

在这里插入图片描述

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐