文章目录
函数栈帧的创建和销毁
1.什么是函数栈帧?
C语言程序是以函数为基本单位的,会把一个独立的功能抽象出来,编写为单独的函数。
既然是函数,就需要考虑到如何调用?返回值又如何对待?参数如何传递?等等一系列的问题都和函数栈帧有关。
函数栈帧(stack frame)就是函数在调用过程中在程序的调用栈
(call stack)所开辟的空间,这些空间是用来存放:
1. 函数参数和函数返回值
2. 临时变量(保存函数的非静态的局部变量以及编译器自动生产的其他
临时变量
3.保存上下文信息(包括在函数调用前后需要保存不变的寄存器)
2.函数栈帧能解决什么问题?
理解函数栈帧的创建和销毁之后,以下问题就能够很好地理解
1.局部变量是如何创建的?
2.为什么局部变量不初始化内容是随机的?
3.函数调用时参数是如何传递的?传参的顺序是怎么样的?
4.函数的形参和实参分别是如何实例化(创建变量)的?
5.函数的返回值是如何返回到 main
函数中的?
3.解析函数栈帧的创建和销毁
3.1栈的概念
在经典的计算机科学中,栈(stack)被定义为一种特殊的容器,用户可以将数据压入栈中(入栈, push
),也可以将已经压入栈中的数据弹出(出栈, pop
),但是必须遵守一条规则:先入栈的数据后出栈。
在计算机系统中,栈是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出,压栈操作使得栈增大,弹出操作使得栈减小。
在经典操作系统中,栈总是向下增长(由低地址向高地址)的。
在常见的x86或者x64环境下,栈顶由 esp
寄存器进行定位。
3.2掌握相关寄存器和汇编指令
相关寄存器
eax:通用寄存器,保留临时数据,常用于返回值
ebx:通用寄存器,保留临时数
ebp:栈底寄存器
esp:栈顶寄存器
eip:指令寄存器,保存当前指令的下一条指令的地址
汇编指令
mov:数据转移指令
push:数据入栈,同时esp栈顶寄存器也要发生变化
pop:数据弹出至指定位置,同时esp栈顶寄存器也要发生变化
sub:减法命令
add:加法命令
call:函数调用 1.压入返回地址;2.转入目标函数
jump:通过修改eip,转入目标函数,进行调用
ret:恢复返回地址,压入eip,类似pop eip命令
3.3解析函数栈帧的创建和销毁
3.3.1前言
首先需要一些预备知识才能帮助我们理解函数栈帧的创建和销毁
1.每一次的函数调用,都要为本次函数调用开辟空间,就是开辟函数栈帧的空间
2.这块空间的维护是使用了2个寄存器: esp
和 ebp
, ebp
记录的是栈底的地址, esp
记录的是栈顶的地址。
3.函数栈帧的创建和销毁过程,在不同的编译器上实现的方法大同小异。
3.3.2函数的调用堆栈
#include<stdio.h>
int Add(int x, int y)
{
int z = 0;
z = x + y;
return z;
}
int main()
{
int a = 3;
int b = 2;
int ret = 0;
ret = Add(a, b);
printf("%d\n", ret);
return 0;
}
在vs2022编译器下,这段代码,调试进入Add函数后,就可以观察函数的调用堆栈,如下图
函数调用堆栈是反应函数调用逻辑的,可以清楚地观察到,先是调用mian
函数,然后通过mian
函数来调用Add
函数。
接下来就从main
函数的栈帧创建开始分析
3.3.3转到反汇编
调试到main
函数开始执行的第一行,右击鼠标转到反汇编。
int main()
{
//函数栈帧的创建
009118B0 push ebp
009118B1 mov ebp,esp
009118B3 sub esp,0E4h
009118B9 push ebx
009118BA push esi
009118BB push edi
009118BC lea edi,[ebp-24h]
009118BF mov ecx,9
009118C4 mov eax,0CCCCCCCCh
009118C9 rep stos dword ptr es:[edi]
009118CB mov ecx,offset _352C9E5E_test@c (091C008h)
009118D0 call @__CheckForDebuggerJustMyCode@4 (091131Bh)
//main函数中的核心代码
int a = 3;
00EE18D5 mov dword ptr [ebp-8],3
int b = 2;
00EE18DC mov dword ptr [ebp-14h],2
int ret = 0;
00EE18E3 mov dword ptr [ebp-20h],0
ret = Add(a, b);
00EE18EA mov eax,dword ptr [ebp-14h]
00EE18ED push eax
00EE18EE mov ecx,dword ptr [ebp-8]
00EE18F1 push ecx
00EE18F2 call 00EE13B6
00EE18F7 add esp,8
00EE18FA mov dword ptr [ebp-20h],eax
printf("%d\n", ret);
009118FD mov eax,dword ptr [ebp-20h]
00911900 push eax
00911901 push offset string "%d\n" (0917B30h)
00911906 call _printf (09110D2h)
0091190B add esp,8
return 0;
0091190E xor eax,eax
}
00911910 pop edi
00911911 pop esi
00911912 pop ebx
00911913 add esp,0E4h
00911919 cmp ebp,esp
0091191B call __RTC_CheckEsp (0911244h)
00911920 mov esp,ebp
00911922 pop ebp
00911923 ret
3.3.4函数栈帧的创建
通过上面的汇编代码,接下来一行一行地拆解汇编代码
009118B0 push ebp
//把ebp寄存器中的值进行压栈
009118B1 mov ebp,esp
//move指令会把esp的值存放到ebp中,此时就产生了mian函数的ebp
009118B3 sub esp,0E4h
//sub的作用是使esp中的地址减去一个十六进制的数字0xE4,产生新的esp
//此时esp是main函数栈帧的esp,与上面的ebp之间维护了一块栈空间,
//这块空间就是为main函数开辟的栈空间,这块空间将存储main函数中的
//局部变量,以及调试信息等。
009118B9 push ebx
//将寄存器ebx的值压栈,esp-4
009118BA push esi
//将寄存器esi的值压栈,esp-4
009118BB push edi
//将寄存器edi的值压栈,esp-4
//上面三条指令将三个寄存器的值保存在栈区,因为这三个寄存器在函数
//随后的执行过程中可能被修改,如此便避免其中的值被修改,也方便退
//出函数时恢复其中的值
//以下操作便是在初始化main函数的栈帧空间
//1.先把 ebp-0x24h 的地址,放到edi中
//2.把 9 放入ecx
//3.把0CCCCCCCC放在 eax 中
//4.将从 ebp-0x24h 到 ebp 这一段内存的每个字节都初始化为0CCCCCCCC
009118BC lea edi,[ebp-24h]
009118BF mov ecx,9
009118C4 mov eax,0CCCCCCCCh
009118C9 rep stos dword ptr es:[edi]
画图展示如下
平常在写代码没有初始化变量直接打印会出现一连串的烫烫烫,其实打印的就是0xCCCCCCCC
例如
接着再分析main
函数的核心代码
int a = 3;
00EE18D5 mov dword ptr [ebp-8],3
//将3存储到 ebp-8 的地址处,也就是变量a的地址
int b = 2;
00EE18DC mov dword ptr [ebp-14h],2
//将2存储到 ebp-14h 的地址处,也就是变量b的地址
int ret = 0;
00EE18E3 mov dword ptr [ebp-20h],0
//将0存储到 ebp-20h 的地址处,就是变量 ret 的地址
//以上汇编代码的本质就是变量 a,b,ret 的创建和初始化,就是局部变量
//的创建和初始化
//局部变量是在局部变量所在的函数的栈帧空间中所创建的
画图展示如下
Add函数的传参
//调用Add函数
ret = Add(a, b);
//调用Add函数时的传参,是把参数压栈到栈帧空间
00EE18EA mov eax,dword ptr [ebp-14h] 寄存器中
//传递b,将 ebp-14h 地址处的2存放在 eax
00EE18ED push eax
//将 eax 的值进行压栈,esp-4
00EE18EE mov ecx,dword ptr [ebp-8]
//传递a,将 ebp-8 地址处的3存放在 ecx 寄存器中
00EE18F1 push ecx
//将 ecx 的值进行压栈,esp-4
//跳转调用函数
00EE18F2 call 00EE13B6
00EE18F7 add esp,8
00EE18FA mov dword ptr [ebp-20h],eax
00EE18F2 call 00EE13B6
00EE18F7 add esp,8
00EE18FA mov dword ptr [ebp-20h],eax
call
指令是要执行函数调用逻辑的,在执行 call
指令之前会先把 call
指令下一条指令的地址进行压栈操作,此操作是为了解决当函数调用结束后要回到 call
指令的下一条指令的地址处,继续执行程序。
当跳转到Add函数时,就要开始观察Add函数的反汇编代码
int Add(int x, int y)
{
00EE1830 push ebp
//保存 main函数 栈帧的 ebp ,esp-4
00EE1831 mov ebp,esp
//将main函数的 esp 赋值给新的 ebp,ebp就变成 Add 函数的 ebp
00EE1833 sub esp,0CCh
//sub的作用是使esp中的地址减去一个十六进制的数字0CCh,产生新的esp
//此时esp是Add函数栈帧的esp,与上面的ebp之间维护了一块栈空间,
//这块空间就是为Add函数开辟的栈空间,这块空间将存储Add函数中的
//局部变量,以及调试信息等
00EE1839 push ebx
//将 ebx 的值进行压栈,esp-4
00EE183A push esi
//将 esi 的值进行压栈,esp-4
00EE183B push edi
//将 edi 的值进行压栈,esp-4
00EE183C lea edi,[ebp-0Ch]
00EE183F mov ecx,3
00EE1844 mov eax,0CCCCCCCCh
00EE1849 rep stos dword ptr es:[edi]
00EE184B mov ecx,0EEC008h
00EE1850 call 00EE131B
int z = 0;
00EE1855 mov dword ptr [ebp-8],0
//将0存放在 ebp-8 的地址处,其实就是 z 的地址处
z = x + y;
00EE185C mov eax,dword ptr [ebp+8]
//将 ebp+8 的地址处的数字存储到 eax 中
00EE185F add eax,dword ptr [ebp+0Ch]
//将 ebp+0xC地址处的数字加到 eax 中
00EE1862 mov dword ptr [ebp-8],eax
//将 eax 的结果保存到 ebp-8 的地址处,也就是 z 的地址处
return z;
00EE1865 mov eax,dword ptr [ebp-8]
//将 ebp-8 地址处的值存放在 eax 中,本质上就是将 z 的值存储到
//eax 中,通过 eax 带回计算的结果,作为函数的返回值
}
00EE1868 pop edi
00EE1869 pop esi
00EE186A pop ebx
00EE186B add esp,0CCh
00EE1871 cmp ebp,esp
00EE1873 call 00EE1244
00EE1878 mov esp,ebp
00EE187A pop ebp
00EE187B ret
代码执行到Add
函数时,就需要开始创建Add
函数的栈帧空间
在Add
函数中创建栈帧的方法与在main
函数相似。
创建Add
函数栈帧的整体思路
1. 将mian函数的 ebp 压栈
2. 计算新的 ebp 和 esp
3. 将 ebx,esi,edi 寄存器的值保存
4. 计算求和,在计算的过程中,通过 ebp 的地址访问函数调用前压栈
进去的参数,也就是形参访问
5.将求出的和放在 eax 寄存器中带回
上图中的a'
和b'
其实是Add
函数的形参x
,y
。图中就很好地说明函数在传参过程中,以及函数在进行传值调用时,形参就是实参的一份临时拷贝,对形参的修改不会影响实参。
3.3.5函数栈帧的销毁
当函数调用即将结束时,前面创建的函数栈帧也即将开始销毁
接下来,通过反汇编代码来具体了解是怎么销毁的
00EE1868 pop edi
//在栈顶弹出一个值,存放到 edi 中,esp+4
00EE1869 pop esi
//在栈顶弹出一个值,存放到 esi 中,esp+4
00EE186A pop ebx
//在栈顶弹出一个值,存放到 ebx 中,esp+4
00EE186B add esp,0CCh
00EE1871 cmp ebp,esp
//再次将Add函数的 ebp 的值赋值给 esp ,相当于回收了Add函数的
栈帧空间
00EE1873 call 00EE1244
00EE1878 mov esp,ebp
00EE187A pop ebp
//弹出栈顶的值存放到 ebp,栈顶此时的值恰好是main函数的 ebp,esp+4
//此时恢复了main函数的栈帧维护,esp指向mian函数栈帧的栈顶,
ebp指向了main函数栈帧的栈底
00EE187B ret
//ret指令的执行,首先是从栈顶弹出一个值,此时栈顶的值就是call指令
下一条指令的地址,此时 esp+4 ,接着就直接跳转到call指令下一条指令
的地址处,举行执行程序。
回到call指令的下一条指令的地址处
00EE18F7 add esp,8
00EE18FA mov dword ptr [ebp-20h],eax
printf("%d\n", ret);
00EE18FD mov eax,dword ptr [ebp-20h]
00EE1900 push eax
00EE1901 push 0EE7B30h
00EE1906 call 00EE10D2
00EE190B add esp,8
调用完Add函数,回到函数时,继续执行后面的代码。
00EE18F7 add esp,8
//esp加上8,等价于跳过main函数中压栈的 a'和b'
00EE18FA mov dword ptr [ebp-20h],eax
//将 eax 中的值,存放到 ebp-0x20h的地址处,也就是存储到main函数
//中的变量 ret 中,eax 中的值就是Add函数中计算的 x 和 y 的和,显而
//易见,本次函数的返回值是由 eax 寄存器带回来的,程序是在函数调用
//返回之后,在 eax 中读取返回值的。