【C语言-函数栈帧】从反汇编的角度,剖析函数调用全流程

前言

初学编程,大多有疑问:

函数是怎么调用的呀?局部变量出了作用域是怎么销毁的呀?函数传参是怎么传的呀?函数的形参为什么不能改变到实参呀…

今天,我们就从反汇编的角度观察“函数栈帧的创建和销毁” , 细细品味 编程沉淀几十年后精密巧妙

1. 函数栈帧(stack frame)

C的编程中,常常把独立的功能抽象为函数,也能说C的程序是以函数为基本单位的

函数栈帧,就是函数调用过程中,在程序的调用栈(call stack)中开辟的空间

来了解一下它的定义和作用

定义:
栈帧也叫过程活动记录,是编译器用来实现过程/函数调用的一种数据结构。

作用:

  1. 为被调用的函数开辟空间

  2. 可以存放

  • 函数参数、函数返回值
  • 临时变量 (函数内的局部变量等)
  • 上下文信息(函数调用前后需要保持不变的寄存器)

2. 函数栈帧预备知识

2.1 栈

栈(stack),一种“特殊的容器”,可以放入数据:压栈(push),把数据压入栈中;出栈(pop),把已经压入栈中的元素弹出

  • 要遵循一条规则:先入栈的数据后出栈(First In Last Out)
  • 栈中的内存使用,总是从高地址开始

【C语言-函数栈帧】从反汇编的角度,剖析函数调用全流程_第1张图片

可知:

栈是一个具有以上特性的 动态内存区域压栈使栈增大,出栈使栈减小

2.2相关寄存器和汇编指令

相关寄存器

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

汇编指令

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

3. 解析函数栈帧的创建和销毁

函数栈帧的创建和销毁中, 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 调试起来

3.1 调用堆栈

“调试” – “窗口” – “调用堆栈” – 右击勾选“显示外部代码”

调用堆栈,让我们清晰地看到函数调用的逻辑

【C语言-函数栈帧】从反汇编的角度,剖析函数调用全流程_第2张图片

可以看到,在调用 main函数 之前,有几个函数已经被调用了; main函数 是被 invoke_main() 调用

既然如此,invoke_main() 应该也有自己的栈帧,main() 、 Add()都有——每个函数栈帧都有自己的 ebp 和 esp 维护

对main函数前的函数今天不做太多讨论

3.2 函数栈帧!

函数的内存空间如何开辟的?

通过 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]  
  1. push 上一函数的 ebp
  2. 计算出本函数的 ebp 和 esp
  3. push ebx esi edi 三个寄存器中的值,因为后续执行中它们可能被修改,所以先保存,以便退出函数的时候可以恢复
  4. 初始化函数的栈帧空间
    最后三条指令的意思:将 从edi开始 ,向下9个空间的内容 初始化成 0xcc cc cc cc

核心代码的执行(含函数栈帧的销毁)

	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的返回值

总结

创建栈帧

  1. push 上一函数的 ebp
  2. 计算出本函数的 ebp 和 esp
  3. push ebx esi edi 三个寄存器中的值
  4. 初始化函数的栈帧空间

销毁栈帧

  1. 销毁被调函数栈帧——mov esp,ebp
  2. 指针转置——把被调函数ebp esp置成主调函数的 ebp esp

调用函数

  1. 传参——把实参放到寄存器再压栈
  2. call——先push call的下一条指令的地址,然后进入Add
  3. 创建栈帧
  4. 核心代码——例子的Add利用ebp的地址偏移访问到形参,这就是形参访问
  5. 存返回值——把计算结果放到寄存器,回到主调函数再使用
  6. 销毁栈帧——销毁掉被调函数的栈帧,再把被调函数ebp esp置成主调函数的 ebp esp
  7. 跳转回call的下一条指令的地址

拓展:
其实返回对象时内置类型时,一般都是通过寄存器来带回返回值的,返回对象如果时较大的对象时,一
般会在主调函数的栈帧中开辟一块空间,然后把这块空间的地址,隐式传递给被调函数,在被调函数中
通过地址找到主调函数中预留的空间,将返回值直接保存到主调函数的。

你可能感兴趣的:(C语言,c语言,数据结构,开发语言)