目录
- 1.什么是函数栈帧
- 2.理解函数栈帧能解决什么问题
- 3.函数栈帧的创建和销毁的过程解析
- 3.1 什么是栈
- 3.2 认识相关寄存器和汇编指令
- 3.3 解析函数栈帧的创建和销毁过程
- 3.3.1 准备环境
- 3.3.2 函数的调用堆栈
- 3.3.3 转到反汇编
- 3.3.4 函数栈帧的创建和销毁
1.什么是函数栈帧
在写C语言代码的时候,我们经常会把一个独立的功能抽象成函数,C程序是以函数为基本单位的,那么函数又是如何调用的呢?函数的参数是怎样传递的呢?这些答案都可以在函数栈帧中寻找
函数栈帧(stack frame):函数调用过程中在程序的调用栈(call stack)所开辟的空间,这些空间是用来存放:
- 函数参数和函数返回值
- 临时变量(包括函数的非静态的局部变量以及编译器自动产生的其他临时变量)
- 保存上下文信息(包括用来维护函数调用前后的寄存器)
2.理解函数栈帧能解决什么问题
只要理解好函数栈帧就可以对一下问题有额外的理解:
- 局部变量是如何创建的?
- 为什么局部变量不初始化时为随机值?
- 函数调用时形参的传递的顺序是怎样的?
- 函数的形参和实参的联系是怎样的?
- 函数的返回值是如何带回来的?
3.函数栈帧的创建和销毁的过程解析
3.1 什么是栈
栈(stack)是现代计算机程序中最为重要的概念之一,几乎每一个程序都要用到栈,没有栈就没有函数,没有局部变量,更没有更正语言的桥接
在经典的计算机科学中,栈被定义为一种特殊的容器,用户可以将数据压入栈中(该操作被称为压栈:push),也可以将已经压入栈中的数据弹出(出栈:pop),但是栈这个容器遵守一条规则:先进后出
在计算机系统中,栈则是一个具有以上属性的动态内存区域,程序可以将数据压入栈中,也可以将栈弹出,在经典的操作系统中,栈总是向下增长(由高地址到低地址)的,在我们常见的i386或者x86-64下,栈顶由esp的寄存器定位
3.2 认识相关寄存器和汇编指令
<1.相关寄存器
- eax:通用寄存器,保留临时数据,常用于返回值
- ebx:同样寄存器,保留临时数据
- ebp:栈底寄存器
- esp:栈顶寄存器,与ebp共同维护当前的函数栈帧
- eip:指令寄存器,保存当前指令的下一条指令的地址
<2.相关汇编命令
- mov:数据转移指令,将后面的数据赋值给前面的数据
- push:数据入栈,同时esp寄存器也要发生改变
- pop:数据弹出指定位置,同时esp也要发生改变
- sub:减法命令
- add:加法命令
- call:函数调用,1.压入返回地址 2.转入目标函数
- jump:通过修改eip,转入目标函数,进行调用
- ret:恢复返回地址,压入eip,类似pop eip命令
3.3 解析函数栈帧的创建和销毁过程
3.3.1 准备环境
为了更好地观察函数栈帧的整个过程,需要先关闭一些选项以免受到干扰:
3.3.2 函数的调用堆栈
这里我们写一段简单的代码,并将代码一条一条拆解处理足够好观察内部的细节
注意:函数栈帧的创建和销毁过程,在不同的编译器的实现方法大同小异,但大体的逻辑层次是不会差很多的,本次演示用的是VS2019环境
演示代码:
#include<stdio.h>
int Add(int x, int y)
{
int z = 0;
z = x + y;
return z;
}
int main()
{
int a = 10;
int b = 20;
int c = 0;
c = Add(a, b);
printf("%d\n", c);
return 0;
}
在VS2019环境下,按F10进入调试,打开窗口的调用堆栈:
调用堆栈是用来反馈函数调用逻辑的
然后继续按F10(按完整个主函数),进入界面:
我们会发现main函数也是被调用的,这里可以清晰地观察到:invoke_main函数调用了main函数,至于是什么函数调用了invoke_main就不再考虑了
3.3.3 转到反汇编
按F10调试到main函数的第一行,右击鼠标转到反汇编
注意:这里调试出来的地址是由系统自动分配的,所以每一次进去调试出来的地址都是不同的
int main()
{
//main函数的函数栈帧的创建
004C1820 push ebp
004C1821 mov ebp,esp
004C1823 sub esp,0E4h
004C1829 push ebx
004C182A push esi
004C182B push edi
004C182C lea edi,[ebp-24h]
004C182F mov ecx,9
004C1834 mov eax,0CCCCCCCCh
004C1839 rep stos dword ptr es:[edi]
//main函数中的核心代码
int a = 10;
004C183B mov dword ptr [ebp-8],0Ah
int b = 20;
004C1842 mov dword ptr [ebp-14h],14h
int c = 0;
004C1849 mov dword ptr [ebp-20h],0
c = Add(a, b);
004C1850 mov eax,dword ptr [ebp-14h]
004C1853 push eax
004C1854 mov ecx,dword ptr [ebp-8]
004C1857 push ecx
//执行call指令会跳转到Add函数内部
004C1858 call 004C10B4
004C185D add esp,8
004C1860 mov dword ptr [ebp-20h],eax
printf("%d\n", c);
004C1863 mov eax,dword ptr [ebp-20h]
004C1866 push eax
004C1867 push 4C7B30h
004C186C call 004C10D2
004C1871 add esp,8
return 0;
004C1874 xor eax,eax
}
调试至call指令,按住F11:
//Add函数的函数栈帧
int Add(int x, int y)
{
004C1760 push ebp
004C1761 mov ebp,esp
004C1763 sub esp,0CCh
004C1769 push ebx
004C176A push esi
004C176B push edi
int z = 0;
004C176C mov dword ptr [ebp-8],0
z = x + y;
004C1773 mov eax,dword ptr [ebp+8]
004C1776 add eax,dword ptr [ebp+0Ch]
004C1779 mov dword ptr [ebp-8],eax
return z;
004C177C mov eax,dword ptr [ebp-8]
}
004C177F pop edi
004C1780 pop esi
004C1781 pop ebx
004C1782 mov esp,ebp
004C1784 pop ebp
004C1785 ret
3.3.4 函数栈帧的创建和销毁
这里我们将拆解每一行汇编代码:
//main函数栈帧的创建,在创建之前esp和ebp维护的是invoke_main的函数栈帧
004C1820 push ebp //将ebp寄存器的值进行压栈,此时存放的是invoke_main的函数栈帧的ebp,esp-4
004C1821 mov ebp,esp //将esp中的值赋给ebp
004C1823 sub esp,0E4h //将esp减去0eE4(十六进制的表示),此时的esp已经指向了一个新的区域用来维护main的函数栈帧
004C1829 push ebx //把ebx的值进行压栈,esp-4
004C182A push esi //把esi的值进行压栈,esp-4
004C182B push edi //把edi的值进行压栈,esp-4
//上面3条指令保存了3个寄存器的值在栈区,这3个寄存器的在函数随后执行中可能会被修改,所以先保存寄
//存器原来的值,以便在退出函数时恢复。
004C182C lea edi,[ebp-24h] //lea(load effective address)加载有效地址,将ebp-24h的地址放到edi中
004C182F mov ecx,9 //将9赋给ecx
004C1834 mov eax,0CCCCCCCCh //将0CCCCCCCCh赋给eax
004C1839 rep stos dword ptr es:[edi]
//从edi开始将以ecx的存储数值为个数的4个字节的数据全部改为eax存储的值
//dword:double word(双字),一个字为2个字节,双字就是4个字节
该段汇编的内存:
解释烫烫烫的产生:
之所以得到了这些奇怪的汉字,是因为这里使用了未初始化的字符数组,就导致buf中存储的就是上面的0CCCCCCCCh的值,而0xCCCC的汉字编码就是“烫”
//main函数中的核心代码
int a = 10;
004C183B mov dword ptr [ebp-8],0Ah //将0Ah(10)赋给ebp-8的地址处,对变量a初始化
int b = 20;
004C1842 mov dword ptr [ebp-14h],14h //将14h(20)赋给ebp-14h的地址处,对变量b初始化
int c = 0;
004C1849 mov dword ptr [ebp-20h],0 //将0赋给ebp-20h的地址处,对变量c初始化
//调用Add函数
c = Add(a, b);
004C1850 mov eax,dword ptr [ebp-14h] //将ebp-14h地址处的值(20)存放在eax中,这里其实就是传递参数b
004C1853 push eax //把eax的值进行压栈
004C1854 mov ecx,dword ptr [ebp-8] //将ebp-8地址处的值(10)存放在ecx中,这里是传递参数a
004C1857 push ecx //把ecx的值进行压栈
该段汇编的内存:
//执行call指令会跳转到Add函数内部,在跳转之前会进行压栈操作
004C1858 call 004C10B4 //把call指令的下一条的地址进行压栈,esp-4,回调函数
//Add函数的函数栈帧的创建
004C1760 push ebp //将main函数的ebp的值压栈进行保存,esp-4
004C1761 mov ebp,esp //将esp的值赋给ebp
004C1763 sub esp,0CCh //将esp减去0CCh,esp开始维护新函数Add的函数栈帧
004C1769 push ebx //把ebx的值进行压栈,esp-4
004C176A push esi //将esi的值进行压栈,esp-4
004C176B push edi //将edi的值进行压栈,esp-4
//Add函数中的核心代码
int z = 0;
004C176C mov dword ptr [ebp-8],0 //将0赋给ebp-8的地址处
z = x + y;
004C1773 mov eax,dword ptr [ebp+8] //将ebp+8地址处的值赋给eax
004C1776 add eax,dword ptr [ebp+0Ch] //把ebp+0Ch地址处的值加到eax中
004C1779 mov dword ptr [ebp-8],eax //将eax中的值赋到ebp-8的地址处
return z;
004C177C mov eax,dword ptr [ebp-8] //将ebp-8地址处的值赋给eax
}
该段汇编的内存:
可以看出形参和实参的关系:形参是实参的一份临时拷贝,对形参的修改并不会改变实参
004C177F pop edi //把edi的值进行出栈,esp+4
004C1780 pop esi //把esi的值进行出栈,esp+4
004C1781 pop ebx //把ebx的值进行出栈,esp+4
004C1782 mov esp,ebp //将ebp的值赋给esp
004C1784 pop ebp //把ebp的值进行出栈,ebp此时又回到main函数的ebp,开始维护main函数的函数栈帧,esp+4
004C1785 ret //首先弹出栈顶的值,此时esp+4,并回到call指令的下一条地址处继续执行代码
//Add函数的函数栈帧销毁
回到call指令的下一条指令:
004C185D add esp,8 //esp+8
004C1860 mov dword ptr [ebp-20h],eax //将eax的值(存储的就是Add函数的返回值)赋到ebp-20h的地址处
printf("%d\n", c);
004C1863 mov eax,dword ptr [ebp-20h] //将ebp-20h地址处的值赋给eax
004C1866 push eax //将eax的值进行压栈
004C1867 push 4C7B30h //将4C7B30h进行压栈
004C186C call 004C10D2 //继续回调函数
004C1871 add esp,8 //esp+8
return 0;
004C1874 xor eax,eax
}
该段汇编的内存():
小结:对于函数栈帧的创建和销毁过程可以在VS上独自进行反汇编代码解析 + 内存和监视的观察,理解该过程更有助于对代码底层的东西了解的更深,此外,这里对于main函数的函数栈帧的销毁不再解析,相信了解过Add函数的整个过程后便能明白,上述提出的问题也可以直接回答出