学习笔记:函数栈帧的创建与销毁
在学习 C/C++ 的过程中,我们经常会将独立的功能抽象为函数,可以说 C 程序就是以函数为基本单位的。但你是否想过:
函数调用时参数是如何传递的?传参的顺序是怎样的?
形参和实参到底是什么关系?
为什么局部变量不初始化,打印出来的值是随机的(或者是经典的“烫烫烫”)?
函数的返回值又是如何带回的?
想要拔高编程内功,彻底弄透这些问题,我们必须沉下心来,走进底层内存,去看看函数栈帧(Stack Frame)到底是怎么回事。
1. 什么是函数栈帧与核心寄存器
函数栈帧,就是函数调用过程中在程序的调用栈(Call Stack)里所开辟的专属内存空间。这块空间主要用来存放:
函数参数和函数返回值。
临时变量(包括函数的非静态局部变量以及编译器自动生成的其他临时变量)。
保存上下文信息(包括在函数调用前后需要保持不变的寄存器)。
在经典的计算机操作系统中,栈总是向下增长(由高地址向低地址)的。
为了维护这块动态的栈帧空间,CPU 主要依赖两个极其关键的寄存器:
ebp(栈底寄存器):记录当前栈帧底部的地址。
esp(栈顶寄存器):记录当前栈帧顶部的地址。
2. 揭秘:main 函数栈帧的创建与“烫烫烫”的由来
每一次函数调用,都要为本次函数调用开辟空间。我们以 VS2019 环境下的一段简单代码为例,从main函数的视角切入。
实际上,在main函数执行之前,是由一个名为invoke_main的函数来调用main函数的。因此,在进入main之前,栈区已经有了invoke_main的栈帧。
进入main函数后,编译器会执行以下反汇编代码来创建新栈帧:
代码段
// main函数栈帧的创建 00BE1820 push ebp // 把ebp寄存器中的值(invoke_main的栈底)进行压栈保存 00BE1821 mov ebp, esp // 将esp的值赋给ebp,相当于产生了main函数的ebp 00BE1823 sub esp, 0E4h // esp减去一个16进制数字0xe4,为main函数开辟出一块全新的栈帧空间 00BE1829 push ebx // 保存 ebx 寄存器原值 00BE182A push esi // 保存 esi 寄存器原值 00BE182B push edi // 保存 edi 寄存器原值紧接着,编译器会执行一段神操作——初始化栈帧空间。它会将新开辟的从ebp-0x24到ebp的这一段内存,每个字节都强制初始化为0xCC。
💡硬核科普:为什么会输出“烫烫烫”?如果我们在代码中定义了一个数组
char arr[20];但没有初始化,它恰好分配在这块被填满0xCC的空间上。两个连续排列的0xCC(即0xCCCC)在汉字编码中对应的字符刚好就是“烫”!这就是那个困扰无数初学者的乱码的物理真相。
3. 核心实战:Add 函数的传参汇编全过程
搞懂了栈帧的创建,我们来看看局部变量是怎么传递给另一个函数的。假设我们在main函数中定义了a=3和b=5,并调用ret = Add(a, b);。
在底层汇编中,参数的传递其实就是把参数 push 到栈帧空间中。请看下面这段极其精彩的汇编流解:
代码段
// 1. 局部变量的创建与初始化 00BE183B mov dword ptr [ebp-8], 3 // 将 3 存储到 ebp-8 的地址处(变量 a) 00BE1842 mov dword ptr [ebp-14h], 5 // 将 5 存储到 ebp-14h 的地址处(变量 b) 00BE1849 mov dword ptr [ebp-20h], 0 // 将 0 存储到 ebp-20h 的地址处(变量 ret) // 2. 调用 Add 函数时的传参(从右向左入栈) 00BE1850 mov eax, dword ptr [ebp-14h] // 传递b,将存放的 5 拿出来放到 eax 寄存器中 00BE1853 push eax // 将 eax 的值(5)压栈,栈顶 esp 自动 -4 00BE1854 mov ecx, dword ptr [ebp-8] // 传递a,将存放的 3 拿出来放到 ecx 寄存器中 00BE1857 push ecx // 将 ecx 的值(3)压栈,栈顶 esp 自动 -4 // 3. 跳转调用函数 00BE1858 call 00BE10B4 // 执行 call 指令跳转在这里我们要特别注意call指令。当执行call指令去调用Add函数时,系统在跳转之前,会把call指令的下一条指令的物理地址进行压栈操作。这就像是在森林里留下一个面包屑标记,确保Add函数执行完之后,CPU 还能顺着地址找回来继续往下执行。
这也从底层证明了一个核心结论:在进行值传递调用时,形参其实是实参的一份拷贝,位于完全不同的物理内存地址上,对形参的修改绝对不会影响实参。
4. 栈帧的销毁与结果带回
天下没有不散的宴席,当Add函数执行到return时,它所占据的栈帧也迎来了销毁时刻。
带回结果:
Add函数会将计算结果(如 x+y 的和)存入通用的eax寄存器中,作为返回值带回。恢复现场:连续执行
pop edi、pop esi、pop ebx,将在函数开头保存的寄存器原值弹出来恢复。空间回收:执行
mov esp, ebp,将栈顶指针直接拉回到当前栈底,瞬间回收掉Add函数的整个栈帧空间。归还掌控权:执行
pop ebp,将栈顶弹出的值赋给ebp,由于之前压栈的就是main函数的ebp,此时便完美恢复了main函数的栈帧维护。最后执行ret指令,弹出之前留下的“面包屑”返回地址,程序跳转回去继续执行。
回到main函数后,编译器会执行add esp, 8,直接将esp向下移动 8 个字节,相当于销毁了刚才传参时压入栈中的形参a和b的拷贝。紧接着,执行mov dword ptr [ebp-20h], eax,将eax寄存器里历经千辛万苦带回来的计算结果,存入ret变量中。至此,一次完美的函数调用彻底落幕。
总结
学习编程,语法只是皮毛,内存才是灵魂。只有真正看懂了函数栈帧的创建与销毁,我们才能从底层的上帝视角去审视代码,彻底明白变量的生老病死与数据的流转奥秘。这对于后续深入学习复杂数据结构以及 C++ 的高级特性,是不可或缺的底层基石。
