函数栈帧的创建与销毁

我是目录

环境

集成环境:VS2022 x86

编辑语言:C

汇编语言:MASM

理解栈帧

为何函数创建需要栈帧?

我知道函数的代码段地址,直接调用就好啦,为什么还需要给他创建一个栈帧来管理呢?

  • 管理函数的局部变量和参数:每个函数都有自己独立的局部变量和参数 ,它们的生命周期仅在该函数执行期间有效。栈帧为每个函数分配一块内存空间,以便保存这些局部变量和参数。这样当函数调用结束后,栈帧会从栈中释放,局部变量的内存也会随之回收,不会影响到其他函数。
  • 保存返回地址:在函数调用过程中,需要记录调用者的返回地址 ,以便在函数执行完毕后能够返回到正确的位置继续执行。而栈帧中通常包含了调用函数的返回地址,确保调用关系能够正确恢复

以上两点是主要原因

为何要学习栈帧的创建与销毁?

大家都说,函数栈帧对我们而言是透明的,那为什么我们还要学习他的创建与销毁呢?

  • 掌握底层编程和逆向工程:在低级语言编程(如汇编、C语言)或逆向工程中,栈帧知识尤为重要。在汇编语言中,栈帧的创建和管理是编程者的直接责任。而逆向工程和安全领域的很多操作都基于栈帧的概念,比如分析函数调用关系、漏洞利用中的缓冲区溢出等。
  • 优化程序性能:栈帧分配和释放开销较小,但在高频函数调用中,栈帧的频繁创建与销毁会影响性能。理解栈帧结构可以帮助我们写出更高效的代码,尤其是在需要手动优化的场景中。比如在一些嵌入式系统中,栈空间有限,优化栈帧的使用能避免不必要的内存开销。

函数栈帧图

main函数的栈帧由esp、ebp负责管理。当调用函数时,被调用的函数会被压入栈帧(从高地址往低地址走)

预备知识

由于函数栈帧的创建与汇编代码紧密结合,所以必须要有一点汇编基础(一点就够啦),才能彻底看懂栈帧的创建与销毁

寄存器

通用寄存器:eax,ebx,ecx,edx,ebp,esp...。这些寄存器中可以存放数值(如:1、2、0aH、01001010B...)、也可存放地址(如:12345678H...)

其中ebp,esp是今天的主角:他们分别用来管理栈底 &栈顶地址,用来维护函数栈帧

MOV 指令

MOV 操作数1,操作数2

将操作数2的值赋给操作数1

SUB 指令

SUB 操作数1,操作数2

将操作数1的值减去操作数2

PUSH 指令

PUSH 操作数

将操作数的值压入栈顶 ,并且将当前栈顶指针esp的值-=操作数的占用空间(类型所占空间)

POP 指令

POP 操作数

将栈顶指针esp所指向的地址按照类型取值赋给操作数,然后esp的值+=操作数的占用空间(类型所占空间)(如:操作数是word类型,便取出esp所指向的两个字节赋给操作数)

LEA 指令

LEA 操作数1,操作数2

将操作数2的地址(而不是值)放入操作数1中(如:lea eax,buffer,让eax存放buffer的地址)

CALL 指令

CALL 操作数

调转到指定地址

REP STOS 指令

该指令结合了重复操作前缀 (REP) 和 存储字符串操作 (STOS),实现对一段内存的快速初始化。

一个简单的C程序

栈帧创建

我们将从一个最简单的C程序开始,学习栈帧的创建与销毁。

在这个程序中,没有形参、返回值

c 复制代码
#include<stdio.h>
void func()
{
	printf("i am func\n");
}
int main()
{
	func();
	return 0;
}

反汇编代码如下(只需关注func函数的创建、销毁过程):

assembly 复制代码
//main
int main()
{
//...
	func();
002A18F2  call        _func (02A1104h)  
//...
}

//func
void func()
{
002A1870  push        ebp  
002A1871  mov         ebp,esp  
002A1873  sub         esp,0C0h  
002A1879  push        ebx  
002A187A  push        esi  
002A187B  push        edi  
002A187C  mov         edi,ebp  
002A187E  xor         ecx,ecx  
002A1880  mov         eax,0CCCCCCCCh  
002A1885  rep stos    dword ptr es:[edi]  
002A1887  mov         ecx,offset _74B3F4D1_test@c (02AC008h)  
002A188C  call        @__CheckForDebuggerJustMyCode@4 (02A132Fh)  
002A1891  nop  
	printf("i am func\n");
002A1892  push        offset string "i am func\n" (02A7B30h)  
002A1897  call        _printf (02A10D2h)  
002A189C  add         esp,4  	
}
  1. **002A18F2 call _func (02A1104h) **

    main 函数中调用 func,执行 call 跳转指令,跳转到 func 函数的地址。这时开始准备创建新的栈帧。

  2. 002A1870 push ebp

    将当前的 EBP 寄存器值压入栈中。这一步的作用是在将来函数返回时能够恢复调用者的栈帧(即 main 函数的栈帧)。同时,ESP 会减少 4 字节(ESP = ESP - 4),指向栈顶的低 4 字节。需要注意的是,栈是从高地址向低地址生长的,因此栈顶的地址小于栈底。

    register before after
    esp 0x00cff81c 0x00cff818
    ebp 0x00cff8ec 0x00cff8ec
  3. 002A1871 mov ebp,esp
    002A1873 sub esp,0C0h

    将当前的 ESP 值赋给 EBP,使得 EBP 成为当前栈帧的基指针。此时,EBP 指向该函数栈帧的底部。

    ESP 向下移动 0C0h(192 字节),为该函数的局部变量和其他数据分配栈空间。此时,EBPESP 之间的空间就构成了该函数的栈帧,栈帧的大小是 192 字节。

  4. 002A1879 push ebx
    002A187A push esi
    002A187B push edi

    这三条指令用于将 EBXESIEDI 寄存器的值压入栈中。根据 x86 调用约定,非易失性寄存器 (如 EBXESIEDI)在函数调用后必须保持不变。如果函数需要修改这些寄存器的值,它必须在使用前将其保存,并在函数结束时恢复。因此,这三条指令将这些寄存器的值保存到栈中,确保它们在函数结束时能够恢复。注意:每次 push 操作时,ESP 的值也会随之变化。

  5. 002A187C mov edi,ebp

    将当前栈帧的基指针(EBP)的值存储到 EDI 寄存器中。这样做的目的是为了在访问局部变量时减少对 EBP 的多次访问。通过 EDI 寄存器和偏移量,可以直接定位局部变量的地址。

  6. 002A187E xor ecx,ecx

    通过 xor 操作将 ECX 的值置为 0。xor 是异或操作,ECX 和自身异或的结果是 0,类似于对 ECX 寄存器进行清零。

  7. 002A1880 mov eax,0CCCCCCCCh

    将值 0xCCCCCCCC 移动到 EAX 寄存器中。这个值通常用于在调试过程中标记未初始化的内存区域。

  8. 002A1885 rep stos dword ptr es:[edi]

    使用 REP STOS 指令将 EAX 的值(即 0xCCCCCCCC)以 dword(4 字节)大小填充到 EDI 寄存器指向的地址。由于 ECX 为 0,循环不会执行,因此这一步实际上并没有进行任何内存填充。

    这一点说明,虽然这条指令本应填充内存区域,但由于 ECX = 0,它实际上不会执行任何填充操作。代码可能认为这段内存不需要填充,因此直接跳过了该操作,从而节省了资源。

    可以确认,栈帧初始化部分并没有进行实际的内存填充,因此栈帧中的内存部分并未被初始化为 0xCCCCCCCC

到此为止,函数的栈帧已经完成初始化。接下来的步骤涉及其他操作,这些操作与栈帧的创建关系不大,属于函数执行过程中的其他额外步骤。

栈帧销毁

反汇编代码如下:

assembly 复制代码
002A189F  pop         edi  
002A18A0  pop         esi  
002A18A1  pop         ebx  
002A18A2  add         esp,0C0h  
002A18A8  cmp         ebp,esp  
002A18AA  call        __RTC_CheckEsp (02A1253h)  
002A18AF  mov         esp,ebp  
002A18B1  pop         ebp  
002A18B2  ret  
  1. 002A189F pop edi
    002A18A0 pop esi
    002A18A1 pop ebx

    这三条指令将栈顶的 EDIESIEBX 寄存器值从栈中弹出,恢复它们在函数调用前的值。每次执行 POP 指令时,栈指针 ESP 都会增加 4 字节(ESP = ESP + 4),因此总共会增加 12 字节,使 ESP 恢复到这些寄存器值被保存之前的位置。

  2. 002A18A2 add esp,0C0h
    002A18A8 cmp ebp,esp
    002A18AA call __RTC_CheckEsp (02A1253h)

    这一指令将 ESP 的值增加 0C0h(192 字节),恢复栈指针到之前为局部变量分配栈空间的位置。这步操作是为了清理当前函数栈帧的空间,确保栈指针正确指向调用函数(main)的栈顶。

    通过 CMP 指令比较 EBPESP 的值。EBP 保存了函数栈帧的底部地址,而 ESP 保存了栈顶的地址。如果栈帧没有被修改,EBPESP 的值应当相同。如果两者不相等,说明栈帧遭到了非法修改或者栈溢出,可能会导致数据损坏。

    如果 EBPESP 的值不同,就会执行 call 跳转到 Microsoft 运行时检查函数 __RTC_CheckEsp。该函数用于检测栈是否被非法修改或栈是否越界,通常用于调试时捕捉栈相关的错误。

  3. 002A18AF mov esp,ebp
    002A18B1 pop ebp
    002A18B2 ret

    无论是否发生栈溢出或非法修改,执行完运行时检查后,将 ESP 恢复到 EBP 的位置。这样做是为了保证栈指针在函数返回时处于正确的位置,从而避免栈损坏。

    恢复 EBP 寄存器的值,即恢复原来保存的 main 函数的栈帧基指针。通过这一步,栈指针恢复到 main 函数调用 func 前的状态。

    最后,RET 指令将控制权返回给调用者,即返回到 main 函数的后续代码。这也标志着当前函数(func)的执行结束,栈帧被清理。

如何传参

数值参数

让我们对代码进行一些小小的修改,以便验证如何使用栈帧进行传参的

c 复制代码
void func(int a)
{
	printf("i am func %d\n",a);
}

int main()
{
	func(1);
	return 0;
}

反汇编代码如下:

assembly 复制代码
int main()
{
//...
	func(1);
005318F2  push        1  
005318F4  call        _func (0531104h)  
//... 
}

void func(int a)
{
00531870  push        ebp  
00531871  mov         ebp,esp  
00531873  sub         esp,0C0h  
00531879  push        ebx  
0053187A  push        esi  
0053187B  push        edi  
0053187C  mov         edi,ebp  
0053187E  xor         ecx,ecx  
00531880  mov         eax,0CCCCCCCCh  
00531885  rep stos    dword ptr es:[edi]  
00531887  mov         ecx,offset _74B3F4D1_test@c (053C008h)  
0053188C  call        @__CheckForDebuggerJustMyCode@4 (053132Fh)  
00531891  nop  
	printf("i am func %d\n",a);
//...
}
  1. 005318F4 call _func (0531104h)

    call 指令之前,通常会有一条 push 指令将函数参数(例如 1)压入栈中。这是因为在 x86 架构中,函数的参数通常是通过栈传递的。栈的操作是从高地址到低地址依次压入参数。

  2. 中间过程与前面创建的一样

  3. 00531887 mov ecx,offset _74B3F4D1_test@c (053C008h)

    之前将 1 压入栈中后,指令 mov ecx, offset _74B3F4D1_test@c (053C008h)1 的值传递到 ecx 寄存器。这是 x86 调用约定中的一种常见做法,尤其是在使用快速调用约定(如 fastcall)时,函数的第一个参数通常会通过 ecx 寄存器传递。

变量参数

  1. 单个变量传递

    c 复制代码
    int main()
    {
    	int a=10;
    	func(a);
    	return 0;
    }
    assembly 复制代码
    009618FD  mov         eax,dword ptr [a]  
    00961900  push        eax  
    00961901  call        _func (0961104h)  

    这段代码将实参 a 的值加载到 eax 寄存器中,然后将其压入栈中,最终调用 func 函数。对于单个形参的函数,传递实参的过程非常简单,就是将实参的值拷贝到 eax 中,然后将其压入栈中。

    对于传值的方式,所有的传值都会涉及到将原始的值拷贝一次,然后传递给调用函数。这就是为什么在函数内部修改值,函数外部不会有任何效果,因为传递的是值的副本。

  2. 多个变量传递

    assembly 复制代码
    	func(a,b,c,d,e,f);
    01014700  mov         eax,dword ptr [f]  
    01014703  push        eax  
    01014704  mov         ecx,dword ptr [e]  
    01014707  push        ecx  
    01014708  mov         edx,dword ptr [d]  
    0101470B  push        edx  
    0101470C  mov         eax,dword ptr [c]  
    0101470F  push        eax  
    01014710  mov         ecx,dword ptr [b]  
    01014713  push        ecx  
    01014714  mov         edx,dword ptr [a]  
    01014717  push        edx  
    01014718  call        _func (01011104h)  

    在这段代码中,我们看到传递了多个参数。实参依然是从右向左依次压入栈中的,确保栈顶是最右边的参数(f),而栈底是最左边的参数(a)。

    形参和实参的对应关系:

    对于只有单个形参的函数,栈中只有一个参数,形参对应的地址可以直接访问。然而,对于多形参函数,如何确保每个形参与其对应的实参正确匹配呢?

    1. 参数的压栈顺序:
      根据调用约定(如 cdecl),栈中最先压入的就是最右侧的参数(即函数调用中的最后一个参数),最后压入的是最左侧的参数(即函数声明中的第一个形参)。这种顺序保证了形参与实参的正确对应。
    2. 栈帧结构:
      栈帧是由 ebp 指针标识的,它提供了一个固定的基准点。每个参数都会根据栈帧的布局,通过一个固定的偏移量来访问。具体来说,ebp 指向栈帧的底部,紧接着是函数的返回地址和保存的上一层 ebp,然后才是函数的各个形参。

    通过栈帧的结构,编译器能够准确地通过相对于 ebp 的偏移量来访问每个参数。例如,在调用 func(a, b, c, d, e, f) 时:

    • f 会存储在栈的最顶端,ebp+12
    • e 存储在 ebp+16
    • d 存储在 ebp+20
    • c 存储在 ebp+24
    • b 存储在 ebp+28
    • a 存储在 ebp+32

    这样,通过栈帧指针 ebp 和相应的偏移量,编译器可以确保形参和实参的正确对应。

  3. 指针传递

    指针也是变量,所以和变量的方法一样:压栈、压栈、压栈!!不过由于其是指针,所以形参声明的类型也是指针,这样在被调用的函数中就拥有访问变量地址以实现永久修改变量值的能力了

如何返回值

数值返回

c 复制代码
int func()
{
	printf("i am func\n");
	return 1;
}

int main()
{
	func();
	return 0;
}
assembly 复制代码
	return 1;
0081189F  mov         eax,1

将要返回的值放入eax寄存器中,然后在外面就可以通过访问该寄存器访问到返回值

变量返回

  1. 变量返回

    c 复制代码
    int func()
    {
    	printf("i am func\n");
    	int a=10;
    	return a;
    }
    
    int main()
    {
    	func();
    	return 0;
    }
    assembly 复制代码
    	return a;
    00C418AA  mov         eax,dword ptr [a] 

    将要返回的变量的值放入eax中,然后在外面就可以通过访问该寄存器访问到返回值

  2. 指针返回

    c 复制代码
    int* func()
    {
    	printf("i am func\n");
    	int* a = malloc(sizeof(int));
    	*a = 10;
    	return a;
    }
    
    int main()
    {
    	//int a=0, b=0, c=0,d=0,e=0,f=0;
    	func();
    	return 0;
    }
    assembly 复制代码
    	return a;
    008418B9  mov         eax,dword ptr [a] 

    和变量返回一样,不过由于声明的是指针,所以编译器和你都知道该通过解引用的方式访问。然后在外面就可以对eax中存储的地址进行访问,进而访问到返回值了

相关推荐
三小尛7 分钟前
归并排序(C语言)
c语言·数据结构·算法
ahadee1 小时前
蓝桥杯每日真题 - 第15天
c语言·vscode·算法·蓝桥杯
小吉在努力敲代码中1 小时前
c++实现B树(下)
开发语言·数据结构·c++·b树·算法
♡喜欢做梦1 小时前
【数据结构】栈和队列详解!!!--Java
java·开发语言·数据结构·链表
学习前端的小z1 小时前
C语言和C++的常量概念与区别分析
c语言·c++
最后一个bug2 小时前
分享一些关于 C 函数与 lua 交互的实际项目案例
linux·c语言·嵌入式硬件·lua
芋头莎莎2 小时前
单片机智能家居火灾环境安全检测
c语言·stm32·单片机·嵌入式硬件·51单片机
最后一个bug2 小时前
C函数如何返回参数lua使用
linux·c语言·开发语言·嵌入式硬件·lua
芋头莎莎2 小时前
STM32设计井下瓦斯检测联网WIFI加Zigbee多路节点协调器传输
c语言·数据库·stm32·单片机·嵌入式硬件·物联网
寻找码源3 小时前
【头歌实训:拆分单链表】
c语言·数据结构·算法·链表