当前位置: 首页 > ai >正文

C语言----函数栈帧讲解

目录

1.函数栈帧是什么?

2. 理解函数栈帧能解决什么问题 

3、函数栈帧的创建和销毁具体过程

3.1 什么是栈

3.2 认识相关寄存器和汇编指令

3.3函数栈帧的创建和销毁

3.3.1 预备知识

3.3.2 函数的调用堆栈

3.3.3 准备环境

3.3.4 转到反汇编

3.3.5 函数栈帧的创建

3.3.6 函数栈帧的销毁

相关概念知识(辅助理解):

1.栈(Stack)

2. esp 和 ebp 的作用

3. 寄存器

1.通用寄存器(General-Purpose Registers)

2. 段寄存器(Segment Registers)

3. 控制寄存器(Control Registers)

4. 关键寄存器详解:


1.函数栈帧是什么?

在C语言中书写代码时, 我们通常会把一个独立的功能用函数来实现, 不同的函数用来实现不同的功能,  所以C程序是以函数为基本单位的。  那函数是如何被调用的?  函数的返回值又是如何待会的?  ​​​​​函数的形参和实参是如何传递的?  这些问题都和函数栈帧有关系。

函数栈帧(Stack Frame) 是函数运行时在内存栈(Stack)中占用的一个独立空间,用来存储该函数运行所需的所有临时数据。

独立空间所存放的数据包括:

函数参数和函数返回的地址(函数返回值)

旧的基指针(保存调用者(Caller)的栈帧基址(EBP/RBP))

局部变量和临时数据

2. 理解函数栈帧能解决什么问题 

只要理解了函数栈帧的创建和销毁,就能大概弄懂一下的问题

  • 局部变量是如何创建的?
  • 为什么局部变量不初始化内容是随机的?
  • 函数调用时参数时如何传递的?传参的顺序是怎样的?
  • 函数的形参和实参分别是怎样实例化的?
  • 函数的返回值是如何带会的?

3、函数栈帧的创建和销毁具体过程

3.1 什么是栈

栈(Stack)是现代计算机程序的核心基础之一,几乎所有程序都依赖它运行。

简单来说,栈就像一个严格遵守"后来先出"规则的容器:数据像叠盘子一样被压入(push)栈顶,取出时也只能从最上面弹出(pop)。

在计算机中,栈是一块特殊的内存区域,由CPU通过栈指针寄存器(如x86架构的ESP/RSP)自动管理,随着数据压入栈顶指针向低地址移动(栈向下增长),弹出时则向高地址回退。

正是这个精巧的设计,使得函数调用、局部变量存储、参数传递等关键功能得以实现,可以说没有栈就没有现代编程语言中的函数概念。

3.2 认识相关寄存器和汇编指令

相关寄存器:

1.eax:通用寄存器,保留临时数据,常用于返回值

2.ebx:通用寄存器,保留临时数据

3.ebp:栈底寄存器

4.esp:栈顶寄存器

5.eip:指令寄存器,保存当前指令的下一条指令的地址

汇编指令:

1.call:保存下一条指令地址(返回地址)到栈顶,并跳转到目标函数

2.ret:从栈顶弹出返回地址,跳转回调用位置继续执行。     

3.push:将数据压入栈顶,栈指针下移(栈向低地址增长)。 

4.pop:从栈顶弹出数据,栈指针上移。

5.enter:建立新栈帧(保存旧帧指针,分配局部变量空间)。 

6.leave:撤销当前栈帧(恢复旧帧指针和栈指针)。                               

7.mov (ebp/esp):直接操作帧指针(ebp)或栈指针(esp),用于调整栈结构。

8.sub/add (esp):动态调整栈空间(如分配/释放局部变量)。

3.3函数栈帧的创建和销毁

3.3.1 预备知识

首先我们达成一些预备知识才能有效的帮助我们理解,函数栈帧的创建和销毁。

1.每一次函数调用,都要为本次函数调用开辟空间,就是函数栈帧的空间。

2.这块空间的维护是使用了2个寄存器: esp 和 ebp , ebp 记录的是栈底的地址, esp 记录的是栈顶的地址。                                     

3. 函数栈帧的创建和销毁过程,在不同的编译器上实现的方法大同小异。

如图:

3.3.2 函数的调用堆栈

演示代码:

#define _CRT_SECURE_NO_WARNINGS 
#include<stdio.h>
int Mystrlen(char* arr)
{if (*arr != '\0')return	1+Mystrlen(arr+1);elsereturn 0;
}
int main()
{char arr1[10] = "abcdedg";int len = Mystrlen(arr1);printf("%d", len);return 0;
}

这段代码,如果我们在VS2022编译器上调试,调试进入Mystrlen函数后,我们就可以观察到函数的调用堆栈(右击勾选【显示外部代码】)如下图:

打开方法:

通过菜单栏打开: 启动调试(按 F5 或点击 调试 >)

开始调试在调试状态下,点击菜单栏的 调试 (Debug)选择 窗口 (Windows) > 调用堆栈 (Call Stack)。

快捷键:Ctrl + Alt + C(默认)。

函数调用堆栈是反馈函数调用逻辑的,那我们可以清晰的观察到, main 函数调用之前,是由 invoke_main 函数来调用main函数。在 invoke_main 函数之前的函数调用我们就暂时不考虑了。那我们可以确定, invoke_main 函数应该会有自己的栈帧, main 函数和 Add 函数也会维护自己的栈帧,每个函数栈帧都有自己的 ebp 和 esp 来维护栈帧空间。那接下来我们从main函数的栈帧创建开始讲解:

3.3.3 准备环境

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

3.3.4 转到反汇编

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

int main()
{
//函数栈帧的创建
00007FF79DB71900  push        rbp  
00007FF79DB71902  push        rdi  
00007FF79DB71903  sub         rsp,148h  
00007FF79DB7190A  lea         rbp,[rsp+20h]  
00007FF79DB7190F  lea         rdi,[rsp+20h]  
00007FF79DB71914  mov         ecx,2Ah  
00007FF79DB71919  mov         eax,0CCCCCCCCh  
00007FF79DB7191E  rep stos    dword ptr [rdi]  
00007FF79DB71920  mov         rax,qword ptr [__security_cookie (07FF79DB7D000h)]  
00007FF79DB71927  xor         rax,rbp  
00007FF79DB7192A  mov         qword ptr [rbp+118h],rax  
00007FF79DB71931  lea         rcx,[__167BB7BA_源@c (07FF79DB82008h)]  
00007FF79DB71938  call        __CheckForDebuggerJustMyCode (07FF79DB71370h)  
00007FF79DB7193D  nop  
//main函数中的核心代码char arr1[10] = "abcdedg";
00007FF79DB7193E  mov         rax,qword ptr [string "abcdedg" (07FF79DB7AC70h)]  
00007FF79DB71945  mov         qword ptr [arr1],rax  
00007FF79DB71949  lea         rax,[rbp+60h]  
00007FF79DB7194D  mov         rdi,rax  
00007FF79DB71950  xor         eax,eax  
00007FF79DB71952  mov         ecx,2  
00007FF79DB71957  rep stos    byte ptr [rdi]  int len = Mystrlen(arr1);
00007FF79DB71959  lea         rcx,[arr1]  
00007FF79DB7195D  call        Mystrlen (07FF79DB713DEh)  
00007FF79DB71962  mov         dword ptr [len],eax  printf("%d", len);
00007FF79DB71968  mov         edx,dword ptr [len]  
00007FF79DB7196E  lea         rcx,[string "%d" (07FF79DB7ACB4h)]  
00007FF79DB71975  call        printf (07FF79DB7119Ah)  
00007FF79DB7197A  nop  return 0;
00007FF79DB7197B  xor         eax,eax  
}
3.3.5 函数栈帧的创建

这里我看到 main 函数转化来的汇编代码如上所示。接下来我们就一行行拆解汇编代码:

00007FF79DB71900  push        rbp  
//将调用者(如invoke_main)的栈基址rbp压栈保存,esp自动-8(x64下指针占8字节)
//此时rsp指向栈顶,保存了旧的rbp值00007FF79DB71902  push        rdi  
//保存rdi寄存器的值到栈中(x64调用约定中rdi可能被调用者修改),esp再-800007FF79DB71903  sub         rsp,148h  
//给main函数分配栈空间:rsp减去0x148字节(328字节)
//现在rsp指向main函数栈帧的顶部,与后续的rbp构成栈帧范围00007FF79DB7190A  lea         rbp,[rsp+20h]  
//设置main函数的栈基址rbp = rsp + 0x20
//这样rbp到rsp之间保留0x20字节(可能用于调试或局部变量)00007FF79DB7190F  lea         rdi,[rsp+20h]  
//将rdi指向栈初始化区域的起始地址(rbp的位置),准备填充0xCC00007FF79DB71914  mov         ecx,2Ah  
//设置循环次数ecx = 0x2A(42次),每次处理4字节,共初始化42*4=168字节00007FF79DB71919  mov         eax,0CCCCCCCCh  
//用调试模式填充值0xCCCCCCCC初始化栈空间(未初始化内存的标记)00007FF79DB7191E  rep stos    dword ptr [rdi]  
//从rdi指向的地址开始,重复填充eax的值(0xCCCCCCCC)到内存,共ecx次
//相当于初始化[rbp-0x20]到[rbp+0xA8]的范围(168字节)00007FF79DB71920  mov         rax,qword ptr [__security_cookie (07FF79DB7D000h)]  
// 从全局变量加载安全cookie(栈溢出保护值)到rax00007FF79DB71927  xor         rax,rbp  
//将安全cookie与当前栈基址rbp异或,生成唯一校验值00007FF79DB7192A  mov         qword ptr [rbp+118h],rax  
//将校验值存入栈中[rbp+0x118]的位置(函数返回时会验证是否被篡改)00007FF79DB71931  lea         rcx,[__167BB7BA_源@c (07FF79DB82008h)]  
//加载调试信息符号地址到rcx(用于"Just My Code"调试功能)00007FF79DB71938  call        __CheckForDebuggerJustMyCode (07FF79DB71370h)  
//调用VS调试器检查函数,确认是否在调试模式下运行00007FF79DB7193D  nop  
//空指令(用于对齐或预留调试断点位置)

上面的这段代码,等价于下面的伪代码:

void main() {// 1. 保存调用者的栈基址和寄存器push(rbp);          // 保存invoke_main的rbppush(rdi);          // 保存可能被修改的rdi// 2. 分配栈空间(x64下更大)rsp -= 0x148;       // 分配328字节空间// 3. 设置新的栈基址(跳过预留区域)rbp = rsp + 0x20;   // rbp指向有效栈帧起始处// 4. 初始化栈空间(填充0xCC)rdi = rbp;          // 初始化起始地址ecx = 42;           // 循环次数(42次×4字节=168字节)eax = 0xCCCCCCCC;memset(rdi, eax, ecx * 4); // 填充168字节// 5. 栈溢出保护(x64特有)rax = __security_cookie;      // 加载安全cookierax ^= rbp;                   // 与栈基址异或加密*(rbp + 0x118) = rax;         // 存储校验值// 6. 调试检查(VS特有)if (IsDebuggerPresent()) {    // 检查调试器__CheckForDebuggerJustMyCode(); // 调试钩子}
}

小知识 : 烫烫烫烫烫烫烫烫烫烫烫烫

出现 “烫烫烫……” 的原因是:在 Windows 下,未初始化的栈内存可能会被初始化为 0xCC ,而 0xCC 对应的字符在当前字符编码下显示为 “烫” 。

接下来我们再分析main函数中的核心代码:

1. 初始化字符数组 arr1[10] = "abcdedg";

00007FF79DB7193E  mov         rax, qword ptr [string "abcdedg" (07FF79DB7AC70h)]  
//从全局数据段(地址 `07FF79DB7AC70h`)加载字符串 `"abcdedg"` 的前 8 字节到 `rax`。
//由于 `"abcdedg"` 是 7 字节(含 `\0`),rax 会包含'a','b','c','d','e','d','g','\0'00007FF79DB71945  mov         qword ptr [arr1], rax  
//将 `rax` 的值(即字符串的前 8 字节)存储到 `arr1` 的起始地址(`[arr1]`)。
//此时 `arr1` 的前 8 字节已填充为 `"abcdedg\0"`。00007FF79DB71949  lea         rax, [rbp+60h]  
//计算 `arr1` 的剩余部分地址(`rbp+60h`)
//即 `arr1[8]` 的位置(因为 `arr1` 是 `char[10]`,前 8 字节已填充,剩余 2 字节)。00007FF79DB7194D  mov         rdi, rax  
//将目标地址 `rbp+60h` 存入 `rdi`(`stos` 指令的目的寄存器)。00007FF79DB71950  xor         eax, eax  
//清零 `eax`,即 `al = 0`(`\0` 字符)。00007FF79DB71952  mov         ecx, 2  
//设置循环次数 `ecx = 2`(剩余 2 字节需要填充 `\0`)。00007FF79DB71957  rep stos    byte ptr [rdi]  
//从 `rdi` 指向的地址开始,重复填充 `al`(`0`)到内存,共 `ecx` 次(2 次)。
//相当于 `arr1[8] = '\0'; arr1[9] = '\0';`,确保数组完全以 `\0` 结尾。

2. 调用 Mystrlen(arr1) 计算字符串长度

00007FF79DB71959  lea         rcx, [arr1]  
//将 `arr1` 的地址加载到 `rcx`(x64 调用约定:第一个参数用 `rcx` 传递)。00007FF79DB7195D  call        Mystrlen (07FF79DB713DEh)  
//调用 `Mystrlen` 函数,返回值存储在 `eax` 中。00007FF79DB71962  mov         dword ptr [len], eax  
//将返回值(字符串长度)存入局部变量 `len`。

3. 调用 printf 打印长度

00007FF79DB71968  mov         edx, dword ptr [len]  
//将 `len` 的值(`7`)存入 `edx`(x64 调用约定:第二个参数用 `edx` 传递)。00007FF79DB7196E  lea         rcx, [string "%d" (07FF79DB7ACB4h)]  
//加载格式字符串 `"%d"` 的地址到 `rcx`(第一个参数)。00007FF79DB71975  call        printf (07FF79DB7119Ah)  
//调用 `printf`,输出 `7`。00007FF79DB7197A  nop  
//空指令(对齐或占位)。

4. 返回 0

00007FF79DB7197B  xor         eax, eax  
//将 `eax` 清零(`return 0;` 的常见优化写法)。
3.3.6 函数栈帧的销毁

当函数调用要结束返回的时候,前面创建的函数栈帧也开始销毁。那具体是怎么销毁的呢?我们看一下反汇编代码。

00007FF773182288  lea     rsp, [rbp+0C8h]  
//将 rsp 直接设置为 rbp + 0C8h,相当于回收整个函数的栈空间(esp = ebp + 分配的大小)  
//此时 rsp 指向调用者栈帧的栈顶(函数调用前的 rsp 值)  00007FF77318228F  pop     rdi  
//从栈顶弹出一个值,存放到 rdi 中(恢复调用者的 rdi 寄存器),rsp + 8(x64 下指针占 8 字节)  00007FF773182290  pop     rbp  
//从栈顶弹出一个值,存放到 rbp 中,此时栈顶的值就是调用者的 rbp(恢复调用者的栈基址),rsp + 8  00007FF773182291  ret  
//ret 指令的执行:  
//1. 从栈顶弹出一个值(此时栈顶的值就是 call 指令下一条指令的地址),rsp + 8  
//2. 跳转到该地址,继续执行调用者的代码  

这样之后就会跳转到main函数内继续执行代码

本章结束 以上就是函数栈帧创建和销毁

以下是一些概念知识 需要的可自行阅读


相关概念知识(辅助理解):

1.栈(Stack)

栈是一种后进先出(LIFO)的数据结构,在内存中从高地址向低地址增长。在函数调用时,栈用于:

  • 存储函数参数(由调用者压栈)
  • 保存返回地址(call指令自动压入)
  • 保存调用者的ebp(被调函数保存)
  • 分配局部变量
  • 存储临时数据(如运算中间结果)

2. esp 和 ebp 的作用

寄存器全称作用
espExtended Stack Pointer始终指向栈的当前顶部(最低可用地址),随push/pop动态变化
ebpExtended Base Pointer指向当前函数栈帧的基地址,用于定位局部变量和参数

esp 的特点

  • 动态变化,每次pushpopsub esp, N(分配空间)或add esp, N(释放空间)都会改变。
  • 在函数调用时,esp会调整以容纳新的栈帧。

ebp 的特点

  • 在函数执行期间固定,作为局部变量和参数的基准。
  • 通过[ebp + offset]访问参数,[ebp - offset]访问局部变量。

3. 寄存器

寄存器(Registers)是CPU内部的高速存储单元,用于临时存放数据、地址和控制信息。在函数调用和栈帧管理中,关键的寄存器包括 通用寄存器段寄存器 和 控制寄存器

1.通用寄存器(General-Purpose Registers)

这些寄存器可用于计算、寻址和数据传输,主要分为:

寄存器名称主要用途
eaxAccumulator存放函数返回值、算术运算
ebxBase数据存储(较少用于计算)
ecxCounter循环计数(如rep指令)
edxData辅助eax(如乘法/除法的高位结果)
esiSource Index字符串/数组操作的源指针
ediDestination Index字符串/数组操作的目标指针
espStack Pointer指向栈顶(动态变化)
ebpBase Pointer指向当前栈帧基址(固定)
2. 段寄存器(Segment Registers)

用于内存分段(现代操作系统已较少使用):

寄存器名称用途
csCode Segment代码段基址
dsData Segment数据段基址
ssStack Segment栈段基址(esp/ebp默认在此段)
esfsgsExtra Segments附加数据段
3. 控制寄存器(Control Registers)
寄存器名称用途
eipInstruction Pointer指向下一条要执行的指令(不可直接修改)
eflagsFlags存储状态标志(如零标志ZF、进位标志CF
4. 关键寄存器详解:

(1)esp(Stack Pointer)

  • 作用:始终指向栈的当前顶部(即最后入栈的数据地址)。
  • 变化规则
    • push 时:esp 减小(栈向低地址增长)。
    • pop 时:esp 增大
    • 函数调用时,esp 会动态调整以分配/释放栈空间。

(2)ebp(Base Pointer)

  • 作用:指向当前函数栈帧的基地址,用于:
    • 定位局部变量([ebp - offset])。
    • 访问函数参数([ebp + offset])。
  • 特点
    • 在函数执行期间固定不变(除非手动修改)。
    • 通过 mov ebp, esp 在函数开头建立栈帧。

本博客借鉴于:函数栈帧的创建与销毁(超详解)-CSDN博客

http://www.xdnf.cn/news/2030.html

相关文章:

  • Flink 系列之七 - Data Stream API的源算子原理
  • Codeforces Round 1020 (Div. 3)
  • 17.ArkUI Slider的介绍和使用
  • 免费的车牌势识别系统
  • 电商数据中台架构:淘宝 API 实时采集与多源数据融合技术拆解
  • Chrmo手动同步数据
  • 在虚拟机中安装Linux详细教程
  • PyQt6基础_QTableWidget
  • MSVCP140.dll丢失的解决方法:详细修复指南进一步了解MSVCP140.dll
  • SCI论文结构笔记
  • 【鸿蒙HarmonyOS】深入理解一端开发,多端部署
  • WINDOWS 下Maven 安装及配置教程
  • javaweb-cook-会话
  • 力扣热题——统计最大组的数目
  • 黑马Redis(三)黑马点评项目
  • 【昇腾】【训练】800TA2-910B使用LLaMA-Factory训练Qwen
  • 系统架构师2025年论文《微服务架构3》
  • 软件开发管理制度,项目研发制度,项目管理制度
  • 解决Spring Boot多模块自动配置失效问题
  • 如何把两个视频合并成一个视频?无需视频编辑器即可搞定视频合并
  • 【Java面试笔记:进阶】19.Java并发包提供了哪些并发工具类?
  • linux基础操作1------(文件命令)
  • STM32系列官方标准固件库的完整下载流程
  • MySql 数据 结构 转为SqlServer (简单)
  • WSL2-自定义安装
  • LLM数学推导——Transformer问题集——注意力机制——稀疏/高效注意力
  • Kafka与Spark-Streaming
  • 7.0 sharpScada的sql数据的安装
  • Oracle Recovery Tools修复ORA-00742、ORA-600 ktbair2: illegal inheritance故障
  • ubuntu使用dify源码安装部署教程+避坑指南