目录
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
的作用
寄存器 全称 作用 esp
Extended Stack Pointer 始终指向栈的当前顶部(最低可用地址),随 push
/pop
动态变化ebp
Extended Base Pointer 指向当前函数栈帧的基地址,用于定位局部变量和参数
esp
的特点
- 动态变化,每次
push
、pop
、sub esp, N
(分配空间)或add esp, N
(释放空间)都会改变。- 在函数调用时,
esp
会调整以容纳新的栈帧。
ebp
的特点
- 在函数执行期间固定,作为局部变量和参数的基准。
- 通过
[ebp + offset]
访问参数,[ebp - offset]
访问局部变量。
3. 寄存器
寄存器(Registers)是CPU内部的高速存储单元,用于临时存放数据、地址和控制信息。在函数调用和栈帧管理中,关键的寄存器包括 通用寄存器、段寄存器 和 控制寄存器
1.通用寄存器(General-Purpose Registers)
这些寄存器可用于计算、寻址和数据传输,主要分为:
寄存器 名称 主要用途 eax
Accumulator 存放函数返回值、算术运算 ebx
Base 数据存储(较少用于计算) ecx
Counter 循环计数(如 rep
指令)edx
Data 辅助 eax
(如乘法/除法的高位结果)esi
Source Index 字符串/数组操作的源指针 edi
Destination Index 字符串/数组操作的目标指针 esp
Stack Pointer 指向栈顶(动态变化) ebp
Base Pointer 指向当前栈帧基址(固定)
2. 段寄存器(Segment Registers)
用于内存分段(现代操作系统已较少使用):
寄存器 名称 用途 cs
Code Segment 代码段基址 ds
Data Segment 数据段基址 ss
Stack Segment 栈段基址( esp
/ebp
默认在此段)es
,fs
,gs
Extra Segments 附加数据段
3. 控制寄存器(Control Registers)
寄存器 名称 用途 eip
Instruction Pointer 指向下一条要执行的指令(不可直接修改) eflags
Flags 存储状态标志(如零标志 ZF
、进位标志CF
)
4. 关键寄存器详解:
(1)
esp
(Stack Pointer)
- 作用:始终指向栈的当前顶部(即最后入栈的数据地址)。
- 变化规则:
push
时:esp
减小(栈向低地址增长)。pop
时:esp
增大。- 函数调用时,
esp
会动态调整以分配/释放栈空间。(2)
ebp
(Base Pointer)
- 作用:指向当前函数栈帧的基地址,用于:
- 定位局部变量(
[ebp - offset]
)。- 访问函数参数(
[ebp + offset]
)。- 特点:
- 在函数执行期间固定不变(除非手动修改)。
- 通过
mov ebp, esp
在函数开头建立栈帧。
本博客借鉴于:函数栈帧的创建与销毁(超详解)-CSDN博客