C语言 ——— 函数栈帧的创建和销毁
目录
寄存器
mian 函数是被谁调用的
通过汇编了解函数栈帧的创建和销毁
转汇编后(Add函数之前的部分)
转汇编后(进入Add函数之前的部分)
转汇编后(正式进入Add函数的部分)
编辑
总结
局部变量是怎么创建的?
函数是怎么传参的?
形参和实参是什么关系?
函数调用的结果是怎么返回的?
寄存器
在深入了解函数栈帧之前,需要先熟悉两个关键的寄存器。在常见的寄存器如 eax、ebx、ecx、edx、ebp、esp 中,ebp 和 esp 是理解函数栈帧的基础
寄存器本质上是 CPU 内部用于存储数据的高速存储单元,而 ebp 和 esp 这两个寄存器专门用于存放内存地址,其存储的地址在函数栈帧的维护中起着至关重要的作用
在程序运行时,每一次函数调用都会在栈区分配一块独立的内存空间,这个空间被称为函数栈帧,用于存放函数执行过程中的局部变量、参数、返回地址等信息。ebp 和 esp 就负责对这个函数栈帧的空间进行维护
以 main 函数为例,当程序开始执行 main 函数时,系统会在栈区为其开辟一块特定的内存空间。其中,ebp 是指向该栈帧底部的指针,它标记了 main 函数栈帧的起始位置,负责维护 main 函数栈帧的底部边界;而 esp 则是指向栈区为 main 函数开辟的栈帧顶部的指针,它会随着栈内数据的压入和弹出而动态变化,实时反映栈帧的当前顶部位置。通过 ebp 和 esp 这两个指针的协同工作,系统能够准确地管理函数栈帧的空间,确保函数的正常执行和数据的正确存储与访问
需要注意的是:
在不同编译器与架构中,管理函数栈帧的寄存器命名会随架构升级而变化:
- VS2013(x86 32 位架构):使用
ebp
(基指针,指向栈帧底部)和esp
(栈指针,指向栈帧顶部)。 - VS2022(x86-64 64 位架构):
ebp
对应重命名为rbp
(基指针),esp
对应重命名为rsp
(栈指针),功能与原寄存器一致,仅因架构升级调整命名,仍用于维护栈帧的底部与顶部边界
mian 函数是被谁调用的
在 VS2013 编译器环境下,通过对调用堆栈的分析可知,main
函数并非程序执行入口,而是由__tmainCRTStartup()
函数触发调用
__tmainCRTStartup()
函数作为 C 运行时库(CRT)的关键部分,承担着初始化运行环境、设置全局变量、处理命令行参数等前置工作,为main
函数的执行提供必要条件。在此之前,__tmainCRTStartup()
函数本身由mainCRTStartup()
函数调用。mainCRTStartup()
函数负责更底层的初始化任务,包括启动 C 运行时库、加载程序依赖项等,是程序从操作系统控制权转移到用户代码执行的关键桥梁
这种调用层级设计体现了 C 运行时库对程序执行流程的精细化管理,确保main
函数在稳定、初始化完成的环境中运行,从而提升程序执行的可靠性与兼容性
通过汇编了解函数栈帧的创建和销毁
代码演示:
int Add(int x, int y)
{int z = 0;z = x + y;return z;
}
int main()
{int a = 10;int b = 20;int sum = 0;sum = Add(a, b);printf("%d\n", sum);return 0;
}
接下来通过这段代码的汇编形式了解函数的栈帧
转汇编后(Add函数之前的部分)
int main()
{
00C21410 push ebp
00C21411 mov ebp,esp
00C21413 sub esp,0E4h
00C21419 push ebx
00C2141A push esi
00C2141B push edi
00C2141C lea edi,[ebp-0E4h]
00C21422 mov ecx.39h
00C21427 mov eax,0CCCCCCCCh
00C2142C rep stos dwrd ptr es:[edi]int a= 10;
00C2142E mov dword ptr [ebp-8],0Ahint b=20;
00C21435 mov dword ptr [ebp-14h],14hint c=0;
00C2143C mov dword ptr [ebp-20h],0
第一条指令:【push ebp】
在程序运行过程中,main
函数由 __tmainCRTStartup
函数调用。在调用 main
函数之前,rbp
(基指针)和 rsp
(栈指针)指向 __tmainCRTStartup
函数的栈帧,这表明当前程序执行环境处于 __tmainCRTStartup
函数的上下文
当开始调用 main
函数时,main
函数的第一条汇编指令通常是 push rbp
。在汇编语言里,push
指令用于执行压栈操作。这里执行 push rbp
时,会将当前 rbp
的值压入栈区,具体位置是在 __tmainCRTStartup
函数栈帧的上方
这一操作具有重要意义,它是为 main
函数开辟独立栈空间的关键步骤。通过将当前 rbp
压栈,可以保存之前函数(即 __tmainCRTStartup
函数)的栈帧基地址,后续在 main
函数执行过程中,就可以基于这个保存的值恢复之前的栈帧状态。同时,后续还会有其他操作来进一步调整栈指针,从而完成 main
函数栈空间的完整开辟,以存放 main
函数执行过程中的局部变量、临时数据等
示意图:
第二条指令: 【mov ebp,esp】
其中,mov
指令的作用是数据移动,它会把源操作数的值复制到目标操作数所在的位置
当执行 mov ebp, esp
这条指令时,esp
寄存器的值会被赋值给 ebp
寄存器。这就意味着,原本指向栈顶的 esp
所保存的地址值,现在被复制到了 ebp
寄存器中。其结果是,ebp
和 esp
这两个寄存器会同时指向 esp
原本所指向的位置。此时,ebp
和 esp
重合,这一操作往往是函数栈帧初始化过程中的重要步骤,它为后续在函数栈帧内进行数据的存储和访问奠定了基础
示意图:
第三条指令:【sub esp,0E4h】
sub
指令用于执行减法运算,该指令的功能是将 esp
寄存器(栈顶指针)的值减去十六进制常数 0E4h
(十进制为 228)
由于内存中的栈空间遵循 “向下增长” 的规则(即栈顶指针向低地址方向移动),当对 esp
执行减法操作时,栈顶指针会向低地址方向移动 0E4h
字节的距离。这一过程实际上是在为 main
函数在栈区开辟一段新的存储空间
通过 sub esp, 0E4h
指令,程序在栈顶指针原位置的基础上,向下扩展出 0E4h
字节的空间,用于存放当前函数的局部变量、临时数据或其他需要在栈帧中存储的信息。这是函数栈帧构建过程中的关键步骤,确保函数在执行时有独立的内存区域可供使用
示意图:
第四、五、六条指令:【push ebx】 【push esi】 【push edi】
在 esp
指针为 main
函数成功开辟好栈空间之后,这里依次将 ebx
、esi
、edi
压入栈,会使得栈指针 esp
相应地向低地址方向移动,每个寄存器占用一定的栈空间。这样做的好处是,在 main
函数执行完毕后,可以通过出栈操作恢复这些寄存器的原始值,保证程序上下文的完整性,让程序能够正常地继续执行后续代码
示意图:
第七条指令:【lea edi,[ebp-0E4h]】
lea 指令的作用是将ebp
寄存器的值减去0E4h
(十进制 228)后的地址加载到edi
寄存器中
edi
作为目标操作数指针,指向当前函数栈帧的起始位置(即esp
开辟的空间底部)。此时ebp
指向栈帧底部,ebp-0E4h
正好是esp
在执行sub esp, 0E4h
后指向的位置
第八条指令:【mov ecx.39h】
将十六进制数39h
(十进制 57)赋值给ecx
寄存器
ecx
作为计数器,用于控制后续重复操作的次数。由于每次stos
指令处理 4 字节(DWORD),因此39h * 4 = 228
字节,恰好等于之前开辟的栈空间大小0E4h
第九条指令:【mov eax,0CCCCCCCCh】
将0CCCCCCCCh
赋值给eax
寄存器,eax
作为源操作数,存储要填充的值。在调试模式下,0xCCCCCCCC
是微软编译器常用的未初始化内存标记值(对应 C++ 中的int 3
断点指令),用于帮助检测未初始化变量的使用
第十条指令:【rep stos dwrd ptr es:[edi]】
rep
:重复执行后续指令,直到ecx
为 0stos
:将eax
的值存储到edi
指向的内存位置,并根据方向标志(DF)调整edi
dwrd ptr es:[edi]
:明确操作数为双字(4 字节),使用附加段寄存器es
将eax
中的0xCCCCCCCCh
重复填充到edi
开始的内存区域,共填充ecx
次(即 228 字节)。每次填充后,edi
自动增加 4 字节(因 DF 默认值为 0,即正向增长)
总结七、八、九、十条指令:
这四条指令的组合实现了对main
函数栈空间的初始化,将其全部填充为0xCCCCCCCCh
。这样做的目的是:
- 调试辅助:未初始化的变量会被自动填充为
0xCC
,当程序意外访问这些内存时会触发异常(INT 3
断点),帮助开发者快速定位问题 - 内存清理:覆盖栈空间中的旧数据,确保函数执行环境的一致性
解析:
int a= 10;
00C2142E mov dword ptr [ebp-8],0Ahint b=20;
00C21435 mov dword ptr [ebp-14h],14hint c=0;
00C2143C mov dword ptr [ebp-20h],0
-
int a = 10;
该语句将整数 10 赋值给变量 a。对应的汇编指令将数值 0Ah(即十进制 10)写入到栈帧中相对于基址指针 ebp 偏移量为 8 字节的位置。这表明变量 a 在栈中的存储位置是 ebp-8,编译器为其分配了 4 字节空间(32 位整数),并初始化为 10 -
int b = 20;
该语句将整数 20 赋值给变量 b。对应的汇编指令将数值 14h(即十进制 20)写入到 ebp-14h(即偏移 20 字节)的位置。这说明变量 b 在栈中的位置是 ebp-20,同样被分配 4 字节空间,并初始化为 20 -
int c = 0;
该语句将整数 0 赋值给变量 c。对应的汇编指令将数值 0 写入到 ebp-20h(即偏移 32 字节)的位置。这表明变量 c 在栈中的位置是 ebp-32,分配 4 字节空间并初始化为 0
转汇编后(进入Add函数之前的部分)
解析:
mov eax, dword ptr [ebp - 14h]
:将栈帧中ebp - 14h
地址处存储的双字数据(即变量b
的值20
)读取到eax
寄存器push eax
:将eax
中的值(b
的值)压入栈,作为Add
函数的第二个参数mov ecx, dword ptr [ebp - 8]
:将栈帧中ebp - 8
地址处存储的双字数据(即变量a
的值10
)读取到ecx
寄存器push ecx
:将ecx
中的值(a
的值)压入栈,作为Add
函数的第一个参数call 00C210E1
:调用Add
函数。该指令会先将返回地址压入栈,然后跳转到00C210E1
处执行函数代码add esp, 8
:调用函数后,栈中保留了两个参数(各占 4 字节,共 8 字节),通过add esp, 8
调整栈指针,清理这两个参数占用的栈空间mov dword ptr [ebp - 20h], eax
:将Add
函数执行后的返回结果(存于eax
寄存器)存储到栈帧中ebp - 20h
地址处,即变量c
的存储空间
解析:
在执行完上面的第五条 call 代码后,就会跳转到此条代码,执行时会直接跳转到 00C213C0
地址处。在函数调用流程中,它可能用于跳转到 Add
函数的实际代码位置,是编译器在生成代码时实现函数跳转的一种方式
转汇编后(正式进入Add函数的部分)
解析:
-
将上一层函数的基址指针push ebp
ebp
压入栈中保存,用于后续恢复上一层栈帧 -
将当前栈顶指针mov ebp, esp
esp
的值赋给ebp
,建立Add
函数新的栈帧基址,为后续栈内变量访问提供基准 -
通过减法操作,使栈顶指针sub esp, 0CCh
esp
向低地址方向移动0CCh
(十进制 204)字节,为Add
函数在栈区开辟所需的局部变量存储空间 -
依次将通用寄存器push ebx
/push esi
/push edi
ebx
、esi
、edi
的值压入栈中保存。这是为了在函数执行过程中保护这些寄存器的原始值,避免影响函数外的程序逻辑 -
计算并加载目标内存地址到lea edi, [ebp+FFFFF34h]
edi
寄存器。ebp+FFFFF34h
等价于ebp - 0CCh
(十六进制补码运算),即指向刚开辟的栈空间起始位置,为后续内存初始化做准备 -
将循环次数mov ecx, 33h
33h
(十进制 51)赋值给计数器寄存器ecx
。由于每次操作处理 4 字节(双字),33h × 4 = 0CCh
字节,恰好匹配开辟的栈空间大小 -
将特定值mov eax, 0CCCCCCCCh
0CCCCCCCCh
赋值给eax
寄存器。在调试模式下,该值常用于标记未初始化的内存,便于检测程序错误(如访问未初始化变量) -
重复执行rep stos dword ptr es:[edi]
stos
操作,直到ecx
为 0。每次操作将eax
中的0CCCCCCCCh
存储到edi
指向的内存位置,并使edi
自动递增 4 字节(正向增长)。最终效果是将Add
函数的栈空间初始化为0CCCCCCCCh
-
将数值mov dword ptr [ebp - 8], 0
0
存储到ebp - 8
地址处,即初始化局部变量z
为0
。ebp - 8
表示从栈帧基址ebp
向低地址方向偏移 8 字节的位置,是为z
分配的内存空间 -
将mov eax, dword ptr [ebp + 8]
ebp + 8
地址处的双字数据(即函数参数x
的值)读取到eax
寄存器。在 x86 调用约定中,x
作为第一个参数,被压入栈后位于ebp + 8
位置 -
将add eax, dword ptr [ebp + 0Ch]
ebp + 0Ch
地址处的双字数据(即函数参数y
的值)与eax
中的值(x
)相加,结果存回eax
。ebp + 0Ch
是第二个参数y
在栈中的位置 -
将mov dword ptr [ebp - 8], eax
eax
中的值(x + y
的结果)存储到ebp - 8
地址处,即把计算结果赋给局部变量z
解析:
-
将局部变量mov eax, dword ptr [ebp - 8]
z
的值(x + y
的结果)读取到eax
寄存器。由于函数返回值通常通过eax
传递,这一步为函数返回做准备 -
依次从栈中弹出数据,恢复寄存器pop edi
/pop esi
/pop ebx
edi
、esi
、ebx
的原始值,确保函数外的程序逻辑不受影响 -
将栈顶指针mov esp, ebp
esp
恢复为栈帧基址ebp
的值,释放Add
函数开辟的栈空间,为栈帧销毁做准备 -
从栈中弹出数据,恢复上一层函数的基址指针pop ebp
ebp
,还原上一层栈帧结构 -
从当前函数返回。该指令会从栈中弹出返回地址(即调用ret
Add
函数时压入的下一条指令地址),并跳转到该地址继续执行程序
总结
局部变量是怎么创建的?
局部变量的创建,需在为函数分配栈帧空间并完成初始化之后,才会在栈帧内为其分配特定存储位置
函数是怎么传参的?
函数传参时,在调用函数前,参数会被存入存储器保存,同时相关寄存器压栈。传参遵循从右至左的顺序,这种方式便于函数通过指针偏移量定位参数,且在函数内部从左至右依次使用参数
形参和实参是什么关系?
形参在函数栈帧开辟前通过压栈分配空间,与实参值相同但存储空间独立。形参本质是实参的临时拷贝,其改变不会影响实参
函数调用的结果是怎么返回的?
函数调用结果的返回,依赖于调用 call
指令前对下一条指令地址的预先记录,以此确保能准确回到调用处。返回值通过寄存器传递,因函数执行完毕后,其栈帧及参数空间需释放归还操作系统,故提前将返回值存入寄存器,确保调用方获取结果