函数栈帧详解

时间:2023-03-01 22:57:04

写在前面

这个模块临近C语言的边界,学起来需要一定的时间,不过当我们知道这些知识后,在C语言函数这块我们看到的不仅仅是表象了,可以真正了解函数是怎么调用的。不过我的能力有限,下面的的知识若是不当,还请各位斧正。

知识点储备

  • 初步了解函数( 这里的所说的函数我们默认为自定义函数)
  • 了解C程序地址空间
  • 基本的寄存器
  • 知道一些汇编语言

函数的概念

函数大家应该都很熟悉了,这里就不细说了。我们看看就行

ret_type fun_name(para1, * )
{
statement; //语句项
}

ret_type 返回类型
fun_name 函数名
para1 函数参数

虚拟地址空间

我们一直说 :“全局变量的生命周期是所在的整个程序”、“static修饰的变量的生命周期变长了”、以及“最重要的临时变量出函数就要被销毁”。不过我们要知道这是因为什么.在C语言中我们所创建的每一个变量都会有自己空间的的存储类别,就比如汽车一般不会停在高楼那样,每一个事物都会有自己的集合,计算机的数据存储也是如此,我们这看一下.

在计算机中,我们把内存分为若干的区间(这里暂时这样理解),每一个区间保存特定的数据,我们先来看C语言程序地址空间,也是虚拟地址空间.

函数栈帧详解

上面不做解析,我们在后面会学到,大家把这个图给记住就可以了.看一下代码,来验证一下.可以看出局部变量存储在栈上且栈空间是沿着向低地址方向开辟的,堆区与之相反.

#include<stdio.h>                                                                             
#include<stdlib.h>

int g_val1 = 10;
int g_val2 = 10;
int g_val3;
int g_val4;

int main()
{
const char* str = "abcdef";

printf("code: %p\n", main);

printf("read only : %p\n", str);

printf("init g_val1 : %p\n", &g_val1);
printf("init g_val2 : %p\n", &g_val2);
printf("uninit g_val2 : %p\n", &g_val3);
printf("uninit g_val2 : %p\n", &g_val4);

char* p1 = (char*)malloc(sizeof(char*) * 10);
char* p2 = (char*)malloc(sizeof(char*) * 10);

printf("heap addr : %p\n", p1);
printf("heap addr : %p\n", p2);

printf("stack addr : %p\n", &str);
printf("stack addr : %p\n", &p1);
printf("stack addr : %p\n", &p2);

return 0;
}

函数栈帧详解

寄存器

函数的调用与CPU中的寄存器有很大关系,下面有一些基本知识

  • eax:通用寄存器,保留临时数据,常用于返回值
  • ebx:通用寄存器,保留临时数据
  • ebp:栈底寄存器
  • esp:栈顶寄存器
  • eip:指令寄存器,保存当前指令的下一条指令的地址,衡量走到了那一步

简单汇编语言

这里是一些常见的汇编语言的指令,这里先和大家列出,后面我们产看汇编语言的时候,直接回来看这些语言是什么意思.

  • mov:数据转移指令
  • push:数据入栈,同时esp栈顶寄存器也要发生改变
  • pop:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变
  • sub:减法命令
  • add:加法命令
  • call:函数调用,1. 压入返回地址 2. 转入目标函数
  • jump:通过修改eip,转入目标函数,进行调用
  • ret:恢复返回地址,压入eip,类似pop eip命令

函数栈帧

看了这么多知识,我们一定会感到很是枯燥,觉得这和函数栈帧一点关系都没有,不要着急,下面就开始我们正式的内容。这里为了便于理解,我们这么看栈的空间,我们就多画些图片

我们知道 main函数也是一个函数,它也是能够被调用,所以main函数也会形成栈帧。

函数栈帧详解

样例代码

下面我们用一个很简单的代码来和大家简单的谈函数的原理,代码虽小,但是却能反应问题.

int MyAdd(int a, int b)
{
int c = a + b;
return c;
}

int main()
{
int x = 0xA;
int y = 0xB;
int z = 0;

z = MyAdd(x, y);
printf("z = %d\n", z);
return 0;
}
我们直接开始调试,然后转到反汇编,打开寄存器.

函数栈帧详解

汇编代码

我将汇编代码复制下来,我们一步一步分析这些东西.

int main()
{
00821E40 push ebp
00821E41 mov ebp,esp
00821E43 sub esp,0E4h
00821E49 push ebx
00821E4A push esi
00821E4B push edi
00821E4C lea edi,[ebp-24h]
00821E4F mov ecx,9
00821E54 mov eax,0CCCCCCCCh
00821E59 rep stos dword ptr es:[edi]
00821E5B mov ecx,82C003h
00821E60 call 0082130C
int x = 0xA;
00821E65 mov dword ptr [ebp-8],0Ah
int y = 0xB;
00821E6C mov dword ptr [ebp-14h],0Bh
int z = 0;
00821E73 mov dword ptr [ebp-20h],0

z = MyAdd(x, y);
00821E7A mov eax,dword ptr [ebp-14h]
00821E7D push eax
00821E7E mov ecx,dword ptr [ebp-8]
00821E81 push ecx
00821E82 call 008211E5
00821E87 add esp,8
00821E8A mov dword ptr [ebp-20h],eax
printf("z = %d\n", z);
00821E8D mov eax,dword ptr [ebp-20h]
00821E90 push eax
00821E91 push 827BCCh
00821E96 call 008213A2
00821E9B add esp,8
return 0;
00821E9E xor eax,eax
}
00821EA0 pop edi
00821EA1 pop esi
00821EA2 pop ebx
00821EA3 add esp,0E4h
00821EA9 cmp ebp,esp
00821EAB call 00821235
00821EB0 mov esp,ebp
00821EB2 pop ebp
00821EB3 ret

这里把寄存器再次简单的说一下.

  • ebp指向栈底
  • esp指向栈顶
  • eip指向下一个​​即将​​执行的地址 还未执行

main函数栈帧形成

注意,main函数也是有栈帧的,这里我们默认mian函数栈帧已经创建完成了,只看MyAdd函数栈帧的形成,他们都是一样的,我们希望以最小的成本来和大家经行分析.

代码逻辑

下面开始main函数内部大代码逻辑,这是局部变量空间的开辟.

int x = 0xA;
01011E65 mov dword ptr [ebp-8],0Ah
//在ebp-8处在开辟一个空间,将x的值放进去

函数栈帧详解

int y = 0xB;
01011E6C mov dword ptr [ebp-14h],0Bh
//在ebp-14处在开辟一个空间,将y的值放进去

函数栈帧详解

int z = 0;
00821E73 mov dword ptr [ebp-20h],0
//在ebp-20处在开辟一个空间,将z的值放进去

函数栈帧详解

我们可以通过汇编语言看出可以看出,x、y、z 的空间是不连续的 ,这是VS保护机制, 防止一些程序员猜测对应的地址,不过一些老的编译器是不会这么做的,这里不要求我们记忆.

形参拷贝

下面汇编语言继续往下面走,我们此时遇到了函数的调用,那么编译器首先要做的就是形参的拷贝.

这就话的意思是​​把ebp-14(也就是y) 赋值给eax eax是一个临时的寄存器,保留临时数据,常用于返回值​​,这里的寄存器都是一个临时的容器,方便

z = MyAdd(x, y);
00821E7A mov eax,dword ptr [ebp-14h]

函数栈帧详解

此时编译器会做下面动作.把eax里面的数据推送到栈中.​​push命令将eax的值放入栈中,同时栈顶esp的位置发生变化,变化的大小是4个字节,因为y是int型​

00821E7D  push        eax

函数栈帧详解

此时我们更新一下我们地址空间的总图,把栈顶寄存器的值保存一下.

函数栈帧详解

下面同理,我们也罢x的值进行拷贝一番,把ebp-8(也就是x) 赋值给ecx,然后推送到栈帧中.

00821E7E  mov         ecx,dword ptr [ebp-8]  
00821E81 push ecx

函数栈帧详解

我们还是要更新一下栈顶的位置发生变化.

函数栈帧详解

下面我们开始总结一下上面的过程.

  • 编译器首先为main函数开辟栈帧,然后按照顺序进行变量空间的开辟和初始化
  • 遇到调用函数时,临时变量的形成(实参的临时拷贝)在函数调前就完成了
  • 形参实例化的顺序是从右向左依次形成的,这个有很大的用处,只不过在C++中会体现
  • 形参的空间是紧邻的,毕竟形参的变量空间的创建伴随着栈顶的变化.

函数调用

下面我们正式开始函数的调用工作,此时涉及很多的东西.这里先说一下call命令的作用

  • 压入返回地址 (最重要的)
  • 转入目标函数

我们说压入返回地址,此时谁是返回地址呢call命令的下一条命令的地址. 为什么要压入?根本原因是函数调用完毕,我们要放回mian函数执行其他的代码,需要返回就需要一个回来的坐标.

00821E82  call        008211E5

函数栈帧详解

此时我们一跳这个指令,那么此时编译器会做两个事情,把call指令的地址压入栈顶,顺便修改esp,让后进入call 的地址处.

函数栈帧详解

下面开始jump指令,这是一个跳转指令,把寄存器eip的数据从当前指令的地址改变成jump的地址,我们把这个目的地址认为是我们调用函数的地址,此时转入目标函数.

函数栈帧详解

这里存在一个问题, esp 也就是栈顶的指针,为何发生变化了?这是因为call指令的第一个作用,至于改变了多少,我们先不关心,只需要知道上升的空间里面是保存一个指针的,那么就是4个或者8个字节.

函数栈帧详解

MyAdd函数栈帧的形成

我们继续开始下面最关键的一个步骤,由于我的不小心,把代码从调试状态退出来了,此时这重新进入调试,那么汇编语言中的相关地址会发生变化,不过代码的逻辑是不会发生变化的,我们还是把汇编语言进行复制出来,这里只看MyAdd函数的汇编语言.

int MyAdd(int a, int b)
{
001E2EC0 push ebp
001E2EC1 mov ebp,esp
001E2EC3 sub esp,0CCh
001E2EC9 push ebx
001E2ECA push esi
001E2ECB push edi
001E2ECC lea edi,[ebp-0Ch]
001E2ECF mov ecx,3
001E2ED4 mov eax,0CCCCCCCCh
001E2ED9 rep stos dword ptr es:[edi]
001E2EDB mov ecx,1EC003h
001E2EE0 call 001E130C
int c = 0;
001E2EE5 mov dword ptr [ebp-8],0
c =a + b;
001E2EEC mov eax,dword ptr [ebp+8]
001E2EEF add eax,dword ptr [ebp+0Ch]
001E2EF2 mov dword ptr [ebp-8],eax
return c;
001E2EF5 mov eax,dword ptr [ebp-8]
}
001E2EF8 pop edi
001E2EF9 pop esi
001E2EFA pop ebx
001E2EFB add esp,0CCh
001E2F01 cmp ebp,esp
001E2F03 call 001E1235
001E2F08 mov esp,ebp
001E2F0A pop ebp
001E2F0B ret

压入栈底

此时编译器首先要做的就是把原本栈底寄存器ebp中的数据压入到栈中,同时栈顶也发生变化

00821740  push        ebp

这条命令是将ebp(也就是栈底)的内容压入栈中,同时栈顶也发生变化

函数栈帧详解

修改栈底

下面开始数据转移指令,该命令的意思是将esp的内容覆盖到ebp中,也就是此时栈底变得和栈顶一样了,该过程没有通过内存,直接通过CPU. 那么我们可能会发出疑惑,那main函数栈底怎么办,是不是找不回来了?实际上不是的,上一步我们不是把栈底的内容给保存了吗!

00821741  mov         ebp,esp

函数栈帧详解

修改栈顶

下面我们开始计算新的栈顶就是在哪里了,根据sub指令,可以计算出新的栈顶在哪里.该命令的意思是esp减去一定的值,结果放在esp中到这里我们已经简单的形成了MyAdd函数的栈帧了

00821743  sub         esp,0CCh
//0CCh 的大小和你定义的函数的规模有关

函数栈帧详解

代码逻辑

下面我们开始函数内部的代码逻辑,都是很简单的.这和main的变量开辟一样

int c = 0;
001E2EE5 mov dword ptr [ebp-8],0
//在ebp-8处在开辟一个空间,将c的值放进去

函数栈帧详解

下面我们开始一步步分析后面的逻辑,注意看,我们上面形参的拷贝都是把数据放在了寄存器中,此时要是想要使用我们形式参数的数据,那么需要编译器手动的取出.

c =a + b;
001E2EEC mov eax,dword ptr [ebp+8]
001E2EEF add eax,dword ptr [ebp+0Ch]
001E2EF2 mov dword ptr [ebp-8],eax

先例解释这一条,把ebp+8放在eax中.那么ebp+8是多少呢?答案就是我们的x值的拷贝

001E2EEC  mov         eax,dword ptr [ebp+8]

函数栈帧详解

同理,这个命令是将ebp+0Ch的内容和eax加起来放到eax中.ebp+0Ch就是y值的拷贝

001E2EEF  add         eax,dword ptr [ebp+0Ch]

函数栈帧详解

这里是将eax写入到ebp-8,也就是c中,就是加法计算的结果.

001E2EF2  mov         dword ptr [ebp-8],eax

函数栈帧详解

返回值

其中我们函数栈帧是如何得到的已经了解了,我们来看函数栈帧的最后的一部分.

保存返回值

我们把计算出的结果保存到寄存器中,为了后面的使用

001E2EF5  mov         eax,dword ptr [ebp-8]

函数栈帧详解

释放栈帧

此时我们函数已经结束了,现在是函数的后期处理工作,包含资源的清理.把ebp 覆盖到esp,也就是收缩栈顶到栈底,这一步可以称为“释放栈帧”,注意函数栈帧的释放可不是空间的释放,只不过是它变成无效,也就是释放栈帧是不等于释放空间的.

001E2F08  mov         esp,ebp

函数栈帧详解

弹栈

此时发生了弹栈操作,将main函数的栈底放在ebp中,esp内容发生改变.记住了我们在开辟函数栈帧前已经把main函数栈底的地址压入栈中的,此时拿到就可以了.

001E2F0A  pop         ebp  // pop:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变

函数栈帧详解

注意,此时我们还是处于MyAdd汇编语言的执行部分,此时我们应该返回原本mian函数调用函数的下一个地址,此时我们需要拿到我们曾经的保存在栈中的地址,压入到eip,要知道eip是编译器下一条要到的地址,esp内容改变

001E2F0B  ret  //类似pop eip命令

函数栈帧详解

释放空间

注意此时我们已经出了MyAdd函数的汇编语言了,已经开始了函数调用的后续工作,例如此时要释放临时拷贝的变量的空间,意思是esp+8放在esp中,也就是修改栈顶.

z = MyAdd(x, y);
00AC1440 add esp,8
00AC1443 mov dword ptr [z],eax

函数栈帧详解

我们开始继续执行函数调用后的代码,将eax的值放到ebp-20,也就是z,后面mian函数的栈帧的销毁我也就不分析了,逻辑是一样的.

001E1E8A  mov         dword ptr [ebp-20h],eax

总结

我来一个简单测总结,这个博客只要求大家理解就可以了.

  • 函数的的栈帧是由编译器决定的,C语言中很多数据类型,那么编译器有能力知道所有类型变量的大小。
  • push进去的变量的空间是连续的,所谓的push进入额变量就是 形参拷贝,压入返回值,压入main栈底的操作是连续的
  • 0CCh 的大小和你定义的函数的规模有关,编译器会自动计算.

我们看到了push变量空间是连续的,也就是我们可以通过形参的地址来修改另外的地址.

void MyAdd(int a, int b)
{
printf("before: b = %d\n", b);
*(&a + 1) = 100;
printf("after : b = %d\n", b);
}

int main()
{
int x = 0;
int y = 0;
MyAdd(x,y);
return 0;
}

函数栈帧详解

那么此时也会做一些更加大胆的事情,例如修改mian函数的栈底和修改放回值,这就会让我们代码不能继续mian函数后续代码了,不过现在在VS是非常做测试了,它做的安全性更加强了.

如果面试官问你你可以讲解一下函数栈帧的底层是什么,可以简单的说一下流程吗?这里我给出下面自己的一个答案,注意回答问题的时候一定要说是自己根据观察VS系列编译器理解的,C语言中的函数栈帧创建流程通常包括以下几个步骤:

  1. 函数参数被压入栈中。在调用函数之前,函数的参数被压入栈中,按照从右到左的顺序进行压栈,
  2. 函数被调用时,系统会将函数的返回地址压入栈中,同时将栈顶指针(Stack Pointer,SP)减少相应的大小,顺便把mian函数的栈底压入栈中
  3. 函数局部变量被压入栈中。函数局部变量也是在栈中分配的,同样按照从右到左的顺序进行压栈。当函数返回时,这些局部变量所占用的栈空间也将被释放。
  4. 在执行函数体之前,为函数创建栈空间。调整栈指针的大小通常是在编译时计算出来的。
  5. 进行函数体的逻辑运算,包含变量的空间创建,使用形参的过程,返回值会被保存在一个寄存器中
  6. 释放栈帧,弹栈,返回地址被弹出栈中,释放形参的空间,调整栈顶,继续执行函数调用后面的代码.