C语言笔记归纳23:函数栈帧的创建与销毁

函数栈帧的创建与销毁

目录

函数栈帧创建与销毁

1. 🌟 为什么要懂函数栈帧?(底层逻辑的核心)

2. 📌 核心基础:栈与关键寄存器(必备前提)

2.1 栈的特点(生长方向 + 操作方式)

2.2 两个核心寄存器:ebp(栈底指针)与 esp(栈顶指针)

2.3 补充:VS2013 中 main 函数的调用链

3. 🏗️ 函数栈帧创建与销毁全流程(反汇编实战)

3.1 示例代码(简化版)

3.2 Step1:main 函数栈帧创建(从__tmainCRTStartup 到 main)

反汇编指令 + 逐句解析

关键疑问:为什么未初始化的局部变量是 “随机值”?

3.3 Step2:main 函数局部变量赋值(a=10、b=20、c=0)

反汇编指令 + 解析

此时栈布局(局部变量位置):

3.4 Step3:函数传参(Add (a,b) 的参数传递)

反汇编指令 + 解析

此时栈布局(传参后):

关键知识点:形参和实参的关系

3.5 Step4:call 指令(保存返回地址 + 跳转到 Add 函数)

反汇编指令 + 解析

关键:返回地址的作用

3.6 Step5:Add 函数栈帧创建(重复 main 函数的栈帧逻辑)

反汇编指令 + 解析

此时 Add 函数的栈布局:

3.7 Step6:Add 函数运算(z=x+y)

反汇编指令 + 解析

关键:返回值的存储

3.8 Step7:Add 函数栈帧销毁(恢复现场 + ret 指令返回)

反汇编指令 + 解析

栈帧销毁后的栈布局:

3.9 Step8:main 函数接收返回值(c=Add (a,b))

反汇编指令 + 解析

4. ❓ 关键问题解答(对应开篇疑问)

问题 1:局部变量是怎么创建的?

问题 2:为什么未初始化的局部变量是 “随机值”?

问题 3:函数传参的顺序是怎样的?形参和实参是什么关系?

问题 4:函数调用后,CPU 是怎么回到原来的执行位置的?

问题 5:函数返回值是怎么传递的?

5. ⚠️ 常见易错点(栈帧相关的坑)

易错点 1:返回局部变量的地址(野指针)

易错点 2:形参修改影响实参(误解传参本质)

易错点 3:栈溢出(递归过深或局部变量过大)

6. ✅ 核心知识点总结


✨引言:

在 C 语言学习中,你可能会有这些疑问:

  • 局部变量是怎么 “凭空出现” 的?为什么未初始化的局部变量是 “随机值”?
  • 函数传参时,实参和形参是什么关系?为什么形参修改不影响实参?
  • 函数调用后,CPU 是怎么知道回到原来的执行位置的?返回值是怎么传递的?

这些问题的答案,都藏在「函数栈帧」的创建与销毁过程中。函数栈帧是 C 语言程序运行时的核心内存结构,是理解函数调用、局部变量、参数传递的底层基础。

本文将以 VS2013 环境为例,通过Add函数调用的完整反汇编代码,一步步拆解函数栈帧的创建、传参、运算、返回、销毁全过程,用 “工作间” 的通俗比喻 + 详细内存布局,帮你彻底吃透 C 语言的底层运行机制!


1. 🌟 为什么要懂函数栈帧?(底层逻辑的核心)

函数栈帧是 C 语言程序运行时,操作系统为每个函数调用在栈区分配的 “临时工作间”—— 所有局部变量、函数参数、返回地址等都存储在这个工作间里。

懂函数栈帧的意义:

  • 彻底理解局部变量的生命周期(创建于栈帧创建时,销毁于栈帧销毁时);
  • 明白参数传递的本质(形参是实参的拷贝,存储在栈帧中);
  • 解释 “返回局部变量地址报错” 的原因(栈帧销毁后,内存被回收);
  • 为后续学习递归、线程栈、栈溢出漏洞打下基础。

2. 📌 核心基础:栈与关键寄存器(必备前提)

在拆解栈帧之前,必须先掌握两个核心概念:栈的特点关键寄存器

2.1 栈的特点(生长方向 + 操作方式)

栈是内存中一块连续的区域,用于存储函数调用时的临时数据,核心特点:

  • 生长方向:从高地址低地址生长(栈顶地址越来越小);
  • 操作方式:只能在栈顶进行操作(“先进后出”,类似堆盘子);
    • 压栈(push):向栈顶添加数据,esp 指针向下(低地址)移动;
    • 出栈(pop):从栈顶删除数据,esp 指针向上(高地址)移动;
  • 空间管理:由编译器自动管理,函数调用时分配,函数返回时释放(无需手动操作)。

2.2 两个核心寄存器:ebp(栈底指针)与 esp(栈顶指针)

CPU 通过两个寄存器来维护函数栈帧的边界,这两个寄存器的值在函数运行期间相对稳定(esp 会随压栈 / 出栈移动,但 ebp 固定):

寄存器全称核心作用通俗比喻
ebpExtended Base Pointer栈底指针,指向当前函数栈帧的 “底部”(固定点)工作间的 “墙角”(固定不动)
espExtended Stack Pointer栈顶指针,指向当前函数栈帧的 “顶部”(动态点)工作间的 “门口”(有人进出就移动)

💡 关键:当前函数的所有数据(局部变量、参数),都通过ebp的偏移量访问(比如ebp-8ebp+8),因为 ebp 是固定的,偏移量固定就能精准找到数据位置。

2.3 补充:VS2013 中 main 函数的调用链

你可能以为 main 函数是程序的 “第一个函数”,但在 VS2013 环境中,main 函数会被其他系统函数调用,完整调用链:

mainCRTStartup → __tmainCRTStartup → main
  • mainCRTStartup:程序入口点,负责初始化环境(堆、I/O、命令行参数);
  • __tmainCRTStartup:适配宽字符 / 多字节字符的中间函数;
  • main:我们写的主函数,真正的业务逻辑入口。

这就是为什么 main 函数的栈帧之上,还有__tmainCRTStartup的栈帧 —— 后续反汇编会验证这一点。

3. 🏗️ 函数栈帧创建与销毁全流程(反汇编实战)

下面以一个简单的Add函数调用为例,结合 VS2013 的反汇编代码,一步步拆解栈帧的完整生命周期。

3.1 示例代码(简化版)

#define _CRT_SECURE_NO_WARNINGS 1
#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); // 输出30

    return 0;
}

3.2 Step1:main 函数栈帧创建(从__tmainCRTStartup 到 main)

当 CPU 执行到 main 函数时,首先会为 main 函数创建栈帧,核心是 “保存上一个函数的栈底→建立当前栈底→申请栈空间→初始化栈空间”,对应反汇编指令如下:

反汇编指令 + 逐句解析
; 1. 保存__tmainCRTStartup函数的ebp(栈底指针)到栈顶
00C21410 push  ebp  
; 此时栈布局(高地址→低地址):
; ———— <--ebp(__tmainCRTStartup的栈底)
; __tmainCRTStartup的栈帧
; ———— <--esp(栈顶,此时存储着__tmainCRTStartup的ebp值)

; 2. 将当前esp的值赋给ebp,建立main函数的栈底(ebp固定)
00C21411 mov   ebp, esp  
; 此时栈布局:
; ———— <--ebp(main的栈底)= esp(栈顶)
; __tmainCRTStartup的ebp值
; ———— <--esp(栈顶,与ebp指向同一位置)

; 3. 申请main函数的栈空间(esp向下移动0xE4字节,即228字节)
00C21413 sub   esp, 0E4h  
; 此时栈布局:
; ———— <--esp(栈顶,main函数的栈空间顶部)
; (228字节的空白栈空间,用于存储局部变量、临时数据)
; ———— <--ebp(main的栈底)
; __tmainCRTStartup的ebp值
; ———— <--__tmainCRTStartup的栈帧

; 4. 保存ebx、esi、edi寄存器(编译器约定,恢复函数时需还原)
00C21419 push  ebx  
00C2141A push  esi  
00C2141B push  edi  
; 此时栈布局:
; ———— <--esp(栈顶)
; edi(寄存器值)
; ————
; esi(寄存器值)
; ————
; ebx(寄存器值)
; ————
; (main函数的栈空间)
; ———— <--ebp(main的栈底)

; 5. 初始化main函数的栈空间为0xCCCCCCCC(调试模式下的填充值)
00C2141C lea   edi, [ebp+FFFFFF1Ch]  ; edi = ebp - 0xE4(栈空间起始地址)
00C21422 mov   ecx, 39h             ; ecx = 57(初始化的双字数量,57*4=228字节)
00C21427 mov   eax, 0CCCCCCCCh      ; eax = 0xCCCCCCCC(填充值)
00C2142C rep stos dword ptr es:[edi] ; 从edi开始,连续填充57个双字(228字节)为0xCCCCCCCC
; 此时栈布局:
; ———— <--esp(栈顶)
; edi
; esi
; ebx
; 0xCCCCCCCC(重复57次,main的栈空间)
; ———— <--ebp(main的栈底)
关键疑问:为什么未初始化的局部变量是 “随机值”?
  • 调试模式下,栈空间被填充为0xCCCCCCCC(十六进制),对应的十进制是-858993460,显示为 “随机值”;
  • Release 模式下,栈空间不填充,保留内存中的原始数据,所以是真正的随机值;
  • 本质:未初始化的局部变量只是占用了栈空间,但没有被赋值,所以值是栈空间的 “填充值” 或 “残留数据”。

3.3 Step2:main 函数局部变量赋值(a=10、b=20、c=0)

栈空间初始化后,CPU 会为局部变量abc赋值,通过ebp的偏移量定位变量位置(因为 ebp 固定,偏移量固定):

反汇编指令 + 解析
; 1. 给a赋值10(0x0A),a的位置:ebp-8
00C2142E mov   dword ptr [ebp-8], 0Ah  
; 2. 给b赋值20(0x14),b的位置:ebp-0x14(20)
00C21435 mov   dword ptr [ebp-14h], 14h  
; 3. 给c赋值0,c的位置:ebp-0x20(32)
00C2143C mov   dword ptr [ebp-20h], 0  
此时栈布局(局部变量位置):
———— <--esp(栈顶)
edi
esi
ebx
0xCCCCCCCC(栈空间其他区域)
———— <--ebp-20h(c的位置,值为0)
0xCCCCCCCC
———— <--ebp-14h(b的位置,值为0x14=20)
0xCCCCCCCC
———— <--ebp-8(a的位置,值为0x0A=10)
———— <--ebp(main的栈底)

3.4 Step3:函数传参(Add (a,b) 的参数传递)

接下来执行c = Add(a, b),首先要传递实参ab,C 语言的传参规则是从右向左压栈(先压b,再压a):

反汇编指令 + 解析
; 1. 先传递第二个实参b:将b的值(ebp-14h)存入eax,再压栈
00C21443 mov   eax, dword ptr [ebp-14h]  ; eax = b = 20
00C21446 push  eax                      ; 压栈b的值(20)
; 2. 再传递第一个实参a:将a的值(ebp-8)存入ecx,再压栈
00C21447 mov   ecx, dword ptr [ebp-8]    ; ecx = a = 10
00C2144A push  ecx                      ; 压栈a的值(10)
此时栈布局(传参后):
———— <--esp(栈顶)
10(a的值,第一个参数)
————
20(b的值,第二个参数)
————
edi
esi
ebx
0xCCCCCCCC(栈空间)
———— <--ebp-20h(c=0)
———— <--ebp-14h(b=20)
———— <--ebp-8(a=10)
———— <--ebp(main的栈底)
关键知识点:形参和实参的关系
  • 实参ab的值被拷贝到栈中,形参xy本质是栈中的两个内存单元(x对应栈顶的 10,y对应下一个的 20);
  • 形参是实参的 “拷贝”,修改形参不会影响实参(因为实参在 main 的栈帧,形参在 Add 的栈帧,是两个独立的内存单元);
  • 传参顺序从右向左的原因:支持可变参数函数(如printf),先压固定参数,再压可变参数,函数内部能通过 ebp 偏移找到固定参数,进而解析可变参数。

3.5 Step4:call 指令(保存返回地址 + 跳转到 Add 函数)

参数压栈后,执行call指令,核心作用是 “保存返回地址(main 函数中 call 的下一条指令地址)+ 跳转到 Add 函数”:

反汇编指令 + 解析
; call指令:1. 保存返回地址到栈顶;2. 跳转到Add函数的入口地址(00C210E1)
00C2144B call  00C210E1  
; 此时栈布局:
———— <--esp(栈顶)
00C21450(返回地址:main函数中call的下一条指令地址)
————
10(a的值,x的实参)
————
20(b的值,y的实参)
————
edi
esi
ebx
...(main的栈空间)
———— <--ebp(main的栈底)
关键:返回地址的作用
  • 返回地址是00C21450,对应 main 函数中call的下一条指令(add esp,8);
  • 当 Add 函数执行完后,CPU 会通过这个返回地址,精准跳回 main 函数的下一条指令,继续执行后续代码(接收返回值、给 c 赋值)。

3.6 Step5:Add 函数栈帧创建(重复 main 函数的栈帧逻辑)

CPU 跳转到 Add 函数后,会重复 main 函数的栈帧创建流程:保存 main 的 ebp→建立 Add 的栈底→申请栈空间→初始化栈空间→保存寄存器:

反汇编指令 + 解析
; 1. 保存main函数的ebp到栈顶
00C213C0 push  ebp  
; 2. 建立Add函数的栈底(ebp=esp)
00C213C1 mov   ebp, esp  
; 3. 申请Add函数的栈空间(esp向下移动0xCC字节)
00C213C3 sub   esp, 0CCh  
; 4. 保存ebx、esi、edi寄存器
00C213C9 push  ebx  
00C213CA push  esi  
00C213CB push  edi  
; 5. 初始化Add的栈空间为0xCCCCCCCC
00C213CC lea   edi, [ebp+FFFFFF34h]  
00C213D2 mov   ecx, 33h  
00C213D7 mov   eax, 0CCCCCCCCh  
00C213DC rep stos dword ptr es:[edi]  
此时 Add 函数的栈布局:
———— <--esp(栈顶)
edi
esi
ebx
0xCCCCCCCC(Add的栈空间)
———— <--ebp-8(z的位置,未赋值)
———— <--ebp(Add的栈底)
main函数的ebp值
————
00C21450(返回地址)
————
10(x=10,ebp+8的位置)
————
20(y=20,ebp+0Ch的位置)
———— <--esp(原main函数的栈顶,现在是Add函数的参数区)

3.7 Step6:Add 函数运算(z=x+y)

Add 函数栈帧创建后,执行核心运算z=x+y,通过 ebp 偏移访问形参xy和局部变量z

反汇编指令 + 解析
; 1. 给局部变量z赋值0,z的位置:ebp-8
00C213DE mov   dword ptr [ebp-8], 0  
; 2. 将x的值(ebp+8)存入eax(x=10)
00C213E5 mov   eax, dword ptr [ebp+8]  
; 3. 将y的值(ebp+0Ch)加到eax中(eax=10+20=30)
00C213E8 add   eax, dword ptr [ebp+0Ch]  
; 4. 将eax中的结果(30)存入z的位置(ebp-8)
00C213EB mov   dword ptr [ebp-8], eax  
关键:返回值的存储
  • Add 函数的返回值(30)最终存储在eax寄存器中(后续会看到,main 函数通过 eax 获取返回值);
  • 为什么用 eax 寄存器?因为寄存器访问速度远快于内存,且 C 语言约定:小于等于 4 字节的返回值通过 eax 传递,大于 4 字节的通过栈传递。

3.8 Step7:Add 函数栈帧销毁(恢复现场 + ret 指令返回)

Add 函数执行完return z后,开始销毁栈帧,核心是 “恢复寄存器→恢复 esp 和 ebp→跳回 main 函数”:

反汇编指令 + 解析
; 1. 恢复z的值到eax(确保返回值在eax中,冗余但安全)
00C213EE mov   eax, dword ptr [ebp-8]  
; 2. 弹出edi、esi、ebx寄存器(恢复到调用Add前的状态)
00C213F1 pop   edi  
00C213F2 pop   esi  
00C213F3 pop   ebx  
; 3. 恢复esp到ebp(释放Add函数的栈空间)
00C213F4 mov   esp, ebp  
; 4. 弹出main函数的ebp值,恢复main的栈底(ebp=原来的main的ebp)
00C213F6 pop   ebp  
; 5. ret指令:1. 弹出返回地址(00C21450);2. 跳转到该地址
00C213F7 ret  
栈帧销毁后的栈布局:

ret 指令执行后,esp 跳回 main 函数的参数区顶部,Add 函数的栈空间被释放(实际是 esp 移动,内存数据未清空,但后续会被覆盖):

———— <--esp(栈顶,指向参数a的值10)
20(参数b的值)
————
edi
esi
ebx
...(main的栈空间)
———— <--ebp(main的栈底,已恢复)

3.9 Step8:main 函数接收返回值(c=Add (a,b))

CPU 跳回 main 函数的返回地址(00C21450),继续执行后续指令,接收 Add 的返回值并给 c 赋值:

反汇编指令 + 解析
; 1. 释放参数占用的栈空间(esp向上移动8字节,跳过a和b的参数)
00C21450 add   esp, 8  
; 2. 将eax中的返回值(30)存入c的位置(ebp-20h)
00C21453 mov   dword ptr [ebp-20h], eax  
; 此时c的值为30,后续printf打印c,输出30

4. ❓ 关键问题解答(对应开篇疑问)

问题 1:局部变量是怎么创建的?

  • 函数调用时,通过sub esp, n申请栈空间(n 是局部变量 + 临时数据的总大小);
  • 局部变量的位置通过ebp的偏移量确定(如ebp-8);
  • 赋值时,CPU 通过偏移量将值写入对应的栈空间。

问题 2:为什么未初始化的局部变量是 “随机值”?

  • 调试模式下,栈空间被填充为0xCCCCCCCC(显示为 “随机值”);
  • Release 模式下,栈空间保留内存原始残留数据,所以是真正的随机值;
  • 本质:未初始化的局部变量只占用栈空间,未被赋值,值是栈空间的填充值或残留数据。

问题 3:函数传参的顺序是怎样的?形参和实参是什么关系?

  • 传参顺序:从右向左压栈(先压第二个实参,再压第一个实参);
  • 形参和实参的关系:形参是实参的拷贝,存储在被调用函数的栈帧中,修改形参不影响实参。

问题 4:函数调用后,CPU 是怎么回到原来的执行位置的?

  • call指令会将 “下一条指令的地址”(返回地址)压栈;
  • 被调用函数执行完后,ret指令弹出返回地址,CPU 跳转到该地址,回到调用函数的下一条指令。

问题 5:函数返回值是怎么传递的?

  • 小于等于 4 字节的返回值:通过eax寄存器传递;
  • 大于 4 字节的返回值:通过栈传递(调用函数先申请栈空间,被调用函数将返回值写入该空间)。

5. ⚠️ 常见易错点(栈帧相关的坑)

易错点 1:返回局部变量的地址(野指针)

int* Add(int x, int y) {
    int z = x + y;
    return &z; // 错误:z是局部变量,Add栈帧销毁后,z的地址被回收
}

int main() {
    int* p = Add(10, 20);
    printf("%d\n", *p); // 未定义行为:访问已释放的栈空间
    return 0;
}
  • 原因:局部变量z存储在 Add 的栈帧中,Add 返回后,栈帧销毁,z的地址变为 “野指针”,访问该地址可能导致程序崩溃或输出垃圾值。

易错点 2:形参修改影响实参(误解传参本质)

void Swap(int x, int y) {
    int temp = x;
    x = y;
    y = temp; // 只修改了形参x和y,实参a和b未变
}

int main() {
    int a = 10, b = 20;
    Swap(a, b);
    printf("%d %d\n", a, b); // 输出10 20(未交换)
    return 0;
}
  • 原因:形参是实参的拷贝,修改形参只是修改栈帧中的拷贝值,实参在调用函数的栈帧中,未被修改;
  • 解决:传递指针(void Swap(int* x, int* y)),通过指针访问实参的内存。

易错点 3:栈溢出(递归过深或局部变量过大)

void Recursion() {
    int arr[100000]; // 局部变量过大,占用大量栈空间
    Recursion(); // 递归过深,不断创建栈帧
}

int main() {
    Recursion();
    return 0;
}
  • 原因:栈空间大小有限(Windows 默认栈大小为 1MB~8MB),局部变量过大或递归过深,会导致栈空间耗尽,触发 “栈溢出” 错误(Stack Overflow)。

6. ✅ 核心知识点总结

  1. 函数栈帧的本质:栈区为函数调用分配的 “临时工作间”,存储局部变量、参数、返回地址等;
  2. 栈帧维护核心:ebp(栈底指针,固定)和 esp(栈顶指针,动态),通过 ebp 偏移量访问数据;
  3. 栈帧创建流程:push ebp → mov ebp, esp → sub esp, n → push 寄存器 → 初始化栈空间;
  4. 栈帧销毁流程:pop 寄存器 → mov esp, ebp → pop ebp → ret(弹出返回地址);
  5. 传参规则:从右向左压栈,形参是实参的拷贝;
  6. 返回值传递:≤4 字节用 eax 寄存器,>4 字节用栈;
  7. 关键禁忌:不返回局部变量的地址,不误解形参和实参的关系,避免栈溢出。

函数栈帧是 C 语言底层的核心,理解它能让你从 “会写代码” 升级到 “懂代码为什么能运行”。本文通过反汇编实战拆解了完整流程,建议结合 VS 的调试功能(查看寄存器、内存布局)亲自验证,加深理解!如果这篇博客帮到了你,欢迎点赞收藏🌟~ 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值