初学编程,大多有疑问:
函数是怎么调用的呀?局部变量出了作用域是怎么销毁的呀?函数传参是怎么传的呀?函数的形参为什么不能改变到实参呀…
今天,我们就从反汇编的角度观察“函数栈帧的创建和销毁” , 细细品味 编程沉淀几十年后精密巧妙
C的编程中,常常把独立的功能抽象为函数,也能说C的程序是以函数为基本单位的
函数栈帧,就是函数调用过程中,在程序的调用栈(call stack)中开辟的空间
来了解一下它的定义和作用
定义:
栈帧也叫过程活动记录,是编译器用来实现过程/函数调用的一种数据结构。
作用:
为被调用的函数开辟空间
可以存放
- 函数参数、函数返回值
- 临时变量 (函数内的局部变量等)
- 上下文信息(函数调用前后需要保持不变的寄存器)
栈(stack),一种“特殊的容器”,可以放入数据:压栈(push),把数据压入栈中;出栈(pop),把已经压入栈中的元素弹出
可知:
栈是一个具有以上特性的 动态内存区域 : 压栈使栈增大,出栈使栈减小
eax:通用寄存器,保留临时数据,常用于返回值
ebx:通用寄存器,保留临时数据
ebp:栈底寄存器
esp:栈顶寄存器
eip:指令寄存器,保存当前指令的下一条指令的地址
mov:数据转移指令
push:数据入栈,同时esp栈顶寄存器也要发生改变
pop:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变
sub:减法命令
add:加法命令
call:函数调用,1. 压入返回地址 2. 转入目标函数
jump:通过修改eip,转入目标函数,进行调用
ret:恢复返回地址,压入eip,类似pop eip命令
函数栈帧的创建和销毁中, esp 和 ebp 非常重要!
剖析这个例子:
#include
int Add(int x, int y)
{
int z = 0;
z = x + y;
return z;
}
int main()
{
int a = 0;
int b = 0;
int ret = 0;
ret = Add(a, b);
printf("%d\n", ret);
return 0;
}
F10 调试起来
“调试” – “窗口” – “调用堆栈” – 右击勾选“显示外部代码”
调用堆栈,让我们清晰地看到函数调用的逻辑
可以看到,在调用 main函数 之前,有几个函数已经被调用了; main函数 是被 invoke_main() 调用
既然如此,invoke_main() 应该也有自己的栈帧,main() 、 Add()都有——每个函数栈帧都有自己的 ebp 和 esp 维护
对main函数前的函数今天不做太多讨论
通过 esp 和 ebp 两个指针的移动,维护起一块空间(两指针之间的空间)
现在调试到main函数开始执行的第一行,右击转到反汇编
004F18B0 push ebp //push ebp中的值(invoke_main的ebp)
004F18B1 mov ebp,esp //把esp(invoke_main的esp)的值放到ebp中
004F18B3 sub esp,0E4h //esp-0E4h
004F18B9 push ebx //将寄存器ebx的值压栈,esp-4
004F18BA push esi //将寄存器esi的值压栈,esp-4
004F18BB push edi //将寄存器edi的值压栈,esp-4
004F18BC lea edi,[ebp-24h] //在edi中加载有效地址[ebp-24h]
004F18BF mov ecx,9
004F18C4 mov eax,0CCCCCCCCh
004F18C9 rep stos dword ptr es:[edi]
int a = 3;
009218D5 mov dword ptr [ebp-8],3 //把3存到[ebp-8]处,[ebp-8] 的位置就是a变量
int b = 4;
009218DC mov dword ptr [ebp-14h],4 //把4存到[ebp-14h]处,[ebp-14]的位置就是b变量
int ret = 0;
009218E3 mov dword ptr [ebp-20h],0 //把0存到[ebp-20h]处,[ebp-20h]的位置就是ret变量
//调用Add函数
ret = Add(a, b);
//函数传参:存到寄存器,再push寄存器的值
009218EA mov eax,dword ptr [ebp-14h] //把b放到eax里
009218ED push eax //push eax
009218EE mov ecx,dword ptr [ebp-8] //把a放到ecx里
009218F1 push ecx //push ecx
//跳转到调用的函数
009218F2 call 009210B4 //push下一条指令的地址再执行函数调用逻辑
//(是为了函数调用完直接从call的下一条语句开始执行)
----------------离开了main---------------
//这里F11进入Add
int Add(int x, int y)
{
//
00921770 push ebp //保存main的ebp,esp-4
00921771 mov ebp,esp //把main的esp赋给ebp,产生Add的ebp
00921773 sub esp,0CCh //esp-0CCh,计算Add的esp
00921779 push ebx //push 寄存器
0092177A push esi //push 寄存器
0092177B push edi //push 寄存器
//初始化栈帧
0092177C lea edi,[ebp-0Ch]
0092177F mov ecx,3
00921784 mov eax,0CCCCCCCCh
00921789 rep stos dword ptr es:[edi]
0092178B mov ecx,92C003h
//
00921790 call 0092131B
int z = 0;
00921795 mov dword ptr [ebp-8],0 //把0存到[ebp-8]的地址处,创建z变量
z = x + y;
0092179C mov eax,dword ptr [ebp+8] //把[ebp+8]处的值(就是先前push的形参)存到eax中
0092179F add eax,dword ptr [ebp+0Ch] //给eax再加上[ebp+0Ch]处的值(也是先前push的形参)
009217A2 mov dword ptr [ebp-8],eax //把eax(利用形参计算后的结果)放到[ebp-8](z变量的位置)处,
return z;
009217A5 mov eax,dword ptr [ebp-8] //把[ebp-8](z)中的值放到寄存器里,通过寄存器带回返回值
}
//销毁Add的栈帧
009217A8 pop edi //pop edi,esp+4
009217A9 pop esi //pop esi,esp+4
009217AA pop ebx //pop ebx,esp+4
009217AB mov esp,ebp //把ebp赋给esp,相当于回收了Add的栈帧
009217AC pop ebp //pop ebp,弹出ebp的值并放到ebp,此时栈顶的值就是main的ebp,esp+4,跳过Add的ebp,又一次维护起main函数了
009217AD ret //先pop一下(此时栈顶的值就是call指令的下一条指令的地址),再esp+4,跳到“call的下一条指令”的地址,继续执行
-----------回到了main----------------
//现在又回到了call指令的下一条指令开始执行...
009218F7 add esp,8 //esp+8,销毁先前push的形参a和b
009218FA mov dword ptr [ebp-20h],eax //把eax的值放到ret变量,此时eax中存的是Add的返回值
- push 上一函数的 ebp
- 计算出本函数的 ebp 和 esp
- push ebx esi edi 三个寄存器中的值
- 初始化函数的栈帧空间
- 销毁被调函数栈帧——mov esp,ebp
- 指针转置——把被调函数ebp esp置成主调函数的 ebp esp
- 传参——把实参放到寄存器再压栈
- call——先push call的下一条指令的地址,然后进入Add
- 创建栈帧
- 核心代码——例子的Add利用ebp的地址偏移访问到形参,这就是形参访问
- 存返回值——把计算结果放到寄存器,回到主调函数再使用
- 销毁栈帧——销毁掉被调函数的栈帧,再把被调函数ebp esp置成主调函数的 ebp esp
- 跳转回call的下一条指令的地址
拓展:
其实返回对象时内置类型时,一般都是通过寄存器来带回返回值的,返回对象如果时较大的对象时,一
般会在主调函数的栈帧中开辟一块空间,然后把这块空间的地址,隐式传递给被调函数,在被调函数中
通过地址找到主调函数中预留的空间,将返回值直接保存到主调函数的。