1.什么是函数栈帧?
函数栈帧就是函数调用过程中在程序的调用栈里开辟的空间,这些空间是用来存放:
(1).函数参数和函数返回值。
(2).临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量)。
(3).保存上下文信息(包括在函数调用前后需要保持不变的寄存器)。
2.理解函数栈帧能够解决什么问题?
理解函数栈帧有什么用呢?
(1).局部变量是如何创建的?
(2).为什么局部变量不初始化值是随机的?
(3).函数调用时参数是如何传递的?传参的顺序是怎么样的?
(4).函数的实参和形参是什么关系?
(5).函数的返回值是如何带回的?
3.函数栈帧的创建和销毁解析
3.1. 什么是栈
栈(stack)是现代计算机程序里最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函 数,没有局部变量,也就没有我们如今看到的所有的计算机语言。
在经典的计算机科学中,栈被定义为一种特殊的容器,用户可以将数据压入栈中(入栈push),也可以将已经压入栈中的数据弹出(出栈,pop),但是栈这个容器必须遵守一条规则: 先入栈的数据后出栈(First In Last Out, FIFO)。就像叠成一叠的术,先叠上去的书在最下面,因此要最后才能取出。 在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据 从栈顶弹出。压栈操作使得栈增大,而弹出操作使得栈减小。 在经典的操作系统中,栈总是向下增长(由高地址向低地址)的。 在我们常见的i386或者 x86-64 下,栈顶由成为 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.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 = 5; int ret = 0; ret = Add(a, b); printf("%d\n", ret); return 0; }
这段代码,如果我们在 VS2019 编译器上调试,调试进入 Add 函数后,我们就可以观察到函数的调用堆栈
(右击勾选【显示外部代码】),如下图:
函数调用堆栈是反馈函数调用逻辑的,那我们可以清晰的观察到, main 函数调用之前,是由
invoke_main 函数来调用 main 函数。
在 invoke_main 函数之前的函数调用我们就暂时不考虑了。
那我们可以确定, invoke_main 函数应该会有自己的栈帧, main 函数和 Add 函数也会维护自己的栈 帧,每个函数栈帧都有自己的 ebp 和 esp 来维护栈帧空间。
那接下来我们从 main 函数的栈帧创建开始讲解:
3.3.3 转到反汇编
调试到 main 函数开始执行的第一行,右击鼠标转到反汇编
int main() { //函数栈帧的创建 00BE1820 push ebp 00BE1821 mov ebp,esp 00BE1823 sub esp,0E4h 00BE1829 push ebx 00BE182A push esi 00BE182B push edi 00BE182C lea edi,[ebp-24h] 00BE182F mov ecx,9 00BE1834 mov eax,0CCCCCCCCh 00BE1839 rep stos dword ptr es:[edi] //main函数中的核心代码 int a = 3; 00BE183B mov dword ptr [ebp-8],3 int b = 5; 00BE1842 mov dword ptr [ebp-14h],5 int ret = 0; 00BE1849 mov dword ptr [ebp-20h],0 ret = Add(a, b); 00BE1850 mov eax,dword ptr [ebp-14h] 00BE1853 push eax 00BE1854 mov ecx,dword ptr [ebp-8] 00BE1857 push ecx 00BE1858 call 00BE10B4 00BE185D add esp,8 00BE1860 mov dword ptr [ebp-20h],eax printf("%d\n", ret); 00BE1863 mov eax,dword ptr [ebp-20h] 00BE1866 push eax 00BE1867 push 0BE7B30h 00BE186C call 00BE10D2 00BE1871 add esp,8 return 0; 00BE1874 xor eax,eax }
3.3.4 函数栈帧的创建
上图就是我们的main函数转化来的汇编代码
下面就让我们来拆解:
00 BE1820 push ebp // 把 ebp 寄存器中的值进行压栈,此时的 ebp 中存放的是
invoke_main 函数栈帧的 ebp , esp-4
00BE1821 mov ebp, esp //move 指令会把 esp 的值存放到 ebp 中,相当于产生了 main 函数的
ebp ,这个值就是 invoke_main 函数栈帧的 esp 00 BE1823 sub esp , 0E4 h //sub 会让 esp 中的地址减去一个 16 进制数字 0xe4, 产生新的
esp ,此时的 esp 是 main 函数栈帧的 esp ,此时结合上一条指令的 ebp 和当前的 esp , ebp 和 esp 之间维护了一
个块栈空间,这块栈空间就是为 main 函数开辟的,就是 main 函数的栈帧空间,这一段空间中将存储 main 函数
中的局部变量,临时数据已经调试信息等。
00BE1829 push ebx // 将寄存器 ebx 的值压栈, esp-4
00BE182A push esi // 将寄存器 esi 的值压栈, esp-4
00 BE182B push edi // 将寄存器 edi 的值压栈, esp-4
// 上面 3 条指令保存了 3 个寄存器的值在栈区,这 3 个寄存器的在函数随后执行中可能会被修改,所以先保存寄
存器原来的值,以便在退出函数时恢复。
// 下面的代码是在初始化 main 函数的栈帧空间。
//1. 先把 ebp-24h 的地址,放在 edi 中
//2. 把 9 放在 ecx 中
//3. 把 0xCCCCCCCC 放在 eax 中
//4. 将从 edp-0x2h 到 ebp 这一段的内存的每个字节都初始化为 0xCC
00BE182C lea edi,[ebp-24h]
00BE182F mov ecx,9
00BE1834 mov eax,0CCCCCCCCh
00BE1839 rep stos dword ptr es:[edi]
上面的这段代码最后 4 句,等价于下面的伪代码:
edi = ebp-0x24;
ecx = 9;
eax = 0xCCCCCCCC;
for(; ecx = 0; --ecx,edi+=4)
{
*(int*)edi = eax;
}
烫烫烫 ~
之所以上面的程序输出 “ 烫 ” 这么一个奇怪的字,是因为 main 函数调用时,在栈区开辟的空间的其中每一 个字节都被初始化为0xCC ,而 arr 数组是一个未初始化的数组,恰好在这块空间上创建的, 0xCCCC (两 个连续排列的0xCC )的汉字编码就是 “ 烫 ” ,所以 0xCCCC 被当作文本就是 “ 烫 ” 。
接下来我们再分析 main 函数中的核心代码:
int a = 3;
00BE183B mov dword ptr [ebp-8],3 // 将 3 存储到 ebp-8 的地址处, ebp-8 的位置其实就
是 a 变量
int b = 5;
00BE1842 mov dword ptr [ebp-14h], 5 // 将 5 存储到 ebp-14h 的地址处, ebp-14h 的位置
其实是 b 变量
int ret = 0;
00BE1849 mov dword ptr [ebp-20h], 0 // 将 0 存储到 ebp-20h 的地址处, ebp-20h 的位
置其实是 ret 变量
// 以上汇编代码表示的变量 a,b,ret 的创建和初始化,这就是局部的变量的创建和初始化
// 其实是局部变量的创建时在局部变量所在函数的栈帧空间中创建的
// 调用 Add 函数
ret = Add(a, b);
// 调用 Add 函数时的传参
// 其实传参就是把参数 push 到栈帧空间中
00BE1850 mov eax,dword ptr [ebp-14h ] // 传递 b ,将 ebp-14h 处放的 5 放在 eax 寄存器
中
00BE1853 push eax // 将 eax 的值压栈, esp-4
00BE1854 mov ecx,dword ptr [ebp-8 ] // 传递 a ,将 ebp-8 处放的 3 放在 ecx 寄存器中
00BE1857 push ecx // 将 ecx 的值压栈, esp-4
// 跳转调用函数
00BE1858 call 00BE10B4
00BE185D add esp,8
00BE1860 mov dword ptr [ebp-20h], eax
Add 函数的传参:
// 调用 Add 函数
ret = Add(a, b);
// 调用 Add 函数时的传参
// 其实传参就是把参数 push 到栈帧空间中,这里就是函数传参
00BE1850 mov eax,dword ptr [ebp-14h] // 传递 b ,将 ebp-14h 处放的 5 放在 eax 寄存器
中
00BE1853 push eax // 将 eax 的值压栈, esp-4
00BE1854 mov ecx,dword ptr [ebp-8 ] // 传递 a ,将 ebp-8 处放的 3 放在 ecx 寄存器中
00 BE1857 push ecx // 将 ecx 的值压栈, esp-4
// 跳转调用函数
00BE1858 call 00BE10B4
00BE185D add esp,8
00BE1860 mov dword ptr [ebp-20h],eax
函数调用过程:
// 跳转调用函数
00BE1858 call 00BE10B4
00BE185D add esp,8
00BE1860 mov dword ptr [ebp-20h],eax
call指令是要执行函数调用逻辑的,在执行 call 指令之前先会把 call 指令的下一条指令的地址进行压栈操作,这个操作是为了解决当函数调用结束后要回到call 指令的下一条指令的地方,继续往后执行。
当我们跳转到 Add 函数,就要开始观察 Add 函数的反汇编代码了。
int Add(int x, int y)
{
00 BE1760 push ebp // 将 main 函数栈帧的 ebp 保存 ,esp-4
00BE1761 mov ebp, esp // 将 main 函数的 esp 赋值给新的 ebp , ebp 现在是 Add 函数的 ebp
00BE1763 sub esp,0 CCh // 给 esp-0xCC ,求出 Add 函数的 esp
00BE1769 push ebx // 将 ebx 的值压栈 ,esp-4
00 BE176A push esi // 将 esi 的值压栈 ,esp-4
00 BE176B push edi // 将 edi 的值压栈 ,esp-4
int z = 0;
00BE176C mov dword ptr [ebp-8], 0 // 将 0 放在 ebp-8 的地址处,其实就是创建 z
z = x + y;
// 接下来计算的是 x+y ,结果保存到 z 中
00BE1773 mov eax,dword ptr [ebp+8 ] // 将 ebp+8 地址处的数字存储到 eax 中
00BE1776 add eax,dword ptr [ebp+0Ch ] // 将 ebp+12 地址处的数字加到 eax 寄存中
00BE1779 mov dword ptr [ebp-8], eax // 将 eax 的结果保存到 ebp-8 的地址处,其实
就是放到 z 中
return z;
00BE177C mov eax,dword ptr [ebp-8 ] // 将 ebp-8 地址处的值放在 eax 中,其实就是
把 z 的值存储到 eax 寄存器中,这里是想通过 eax 寄存器带回计算的结果,做函数的返回值。
}
00BE177F pop edi
00BE1780 pop esi
00BE1781 pop ebx
00BE1782 mov esp,ebp
00BE1784 pop ebp
00BE1785 ret
代码执行到 Add 函数的时候,就要开始创建 Add 函数的栈帧空间了。
在 Add 函数中创建栈帧的方法和在 main 函数中是相似的,在栈帧空间的大小上略有差异而已。
1. 将 main 函数的 ebp 压栈
2. 计算新的 ebp 和 esp
3. 将 ebx , esi , edi 寄存器的值保存
4. 计算求和,在计算求和的时候,我们是通过 ebp 中的地址进行偏移访问到了函数调用前压栈进去的参数,这就是形参访问。
5. 将求出的和放在 eax 寄存器尊准备带回
图片中的 a' 和 b' 其实就是 Add 函数的形参 x , y 。这里的分析很好的说明了函数的传参过程,以及函数在进行值传递调用的时候,形参其实是实参的一份拷贝。对形参的修改不会影响实参。
3.3.5 函数栈帧的销毁
当函数调用要结束返回的时候,前面创建的函数栈帧也开始销毁。
那具体是怎么销毁的呢?我们看一下反汇编代码。
00 BE177F pop edi // 在栈顶弹出一个值,存放到 edi 中, esp+4
00 BE1780 pop esi // 在栈顶弹出一个值,存放到 esi 中, esp+4
00 BE1781 pop ebx // 在栈顶弹出一个值,存放到 ebx 中, esp+4
00BE1782 mov esp, ebp // 再将 Add 函数的 ebp 的值赋值给 esp ,相当于回收了 Add 函数的栈
帧空间
00 BE1784 pop ebp // 弹出栈顶的值存放到 ebp ,栈顶此时的值恰好就是 main 函数的 ebp ,
esp+4 ,此时恢复了 main 函数的栈帧维护, esp 指向 main 函数栈帧的栈顶, ebp 指向了 main 函数栈帧的栈底。
00 BE1785 ret //ret 指令的执行,首先是从栈顶弹出一个值,此时栈顶的值就是 call 指
令下一条指令的地址,此时 esp+4 ,然后直接跳转到 call 指令下一条指令的地址处,继续往下执行。
回到了 call 指令的下一条指令的地方:
也就是说add函数调用完毕,继续执行mian函数,函数的返回值会存放在寄存器eax中被读取。
4.拓展了解
其实返回对象是内置类型时,一般都是通过寄存器来带回返回值的,返回对象如果是较大的对象时,一 般会在主调函数的栈帧中开辟一块空间,然后把这块空间的地址,隐式传递给被调函数,在被调函数中通过地址找到主调函数中预留的空间,将返回值直接保存到主调函数的。