对于栈帧这两个字我们好像非常陌生,但是我们对一个程序函数的调用过程非常熟悉,传参、赋值等操作我们都能明白,那传参和赋值等操作,编译器是怎么进行的呢?编译器操作的过程的被栈帧记录下来,我们可以研究函数栈帧的创建和销毁来看看函数是怎么被调用的。
本次调试我全程使用VS2013,如果大家安装的不是13版本,其它版本也大差不差,只是VS随着版本更新,数据的封装就越复杂,用2013更方便看清楚函数调用的过程。
1、准备工作
1.1 寄存器
在查看函数栈帧前,我们得先了解一些寄存器。像:eax、ebx、ecx、edx、ebp、esp。
其中ebp和esp是非常重要的两个寄存器,ebp始终指向所调用函数的栈帧底部,esp始终指向所调用函数的栈帧顶部,它们的内容都是地址,这两个地址是用来维护函数栈帧的,并且这两个寄存器特殊之处是它们的值是哪个地址就指向哪个地址(但其它的寄存器的内容仅仅是内容)。
1.2 栈的基础知识
栈区是由高地址到低地址,栈底在高地址处,栈顶在低地址处,栈有“先进后出”的特点(队列是“先进先出”的特点)
栈顶和栈底都有指针,栈顶指针就是esp,栈底指针是ebp,换句话说,esp指向栈顶,ebp指向栈底。
每个函数被调用时都会建立栈帧,等下我们在调试过程中就可以看到main函数是如何调用Add函数,实参的值是如何传给形参的。
1.3 源代码
汇编语言是比较难看懂,我们就以一次简单的程序作为范例。
#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;
}
1.4 如何查看汇编代码
在main函数的第一行设置断点,然后按F10到main函数的‘{’后面,右击,选择“转到反汇编”。
待VS出现了反汇编代码后,我们右击 取消 显示符号表
最终,我们就可以看到没有符号名的反汇编代码。准备工作就做完了,我接下来会每一步每一步的讲解。
2、真正的干货
我用序号代表程序执行步骤
- push ebp : push 对象,是把该对象压入栈顶,把ebp压入栈顶,此时的栈顶就是main函数的栈底。
- mov ebp,esp : mov a,b,把b的值赋值给a,把esp的值赋值给ebp,寄存器存放了谁的地址,就指向谁。现在ebp和esp指向同样的位置。
- sub esp 0E4h : 这一步其实是在建立main函数的栈帧,让esp的值减去0E4h,0E4h为main函数所占的空间(如果有朋友对这个0E4h就是它的空间存在疑问的话,我的建议就是记住,每次到这一步esp所减去的这个数值就是main函数所建立的栈帧空间),此时esp指向main函数所创立的栈帧的栈底,ebp指向main函数所创立的栈帧的栈顶。
- push ebx: 把ebx压入栈顶
- push esi : 把esi压入栈顶
- push edi : 把edi压入栈顶
- lea edi,[ebp+FFFFFF1Ch] : lea(Load Effective Address的简称)加载有效值,lea a b 把b的值放到a中。右击勾选“显示符号名”就会变成“edi,[ebp-24h]”,现在edi的值为 ebp的值24h,让edi从目前的栈顶往低地址方向前进(这是在干什么我们等下就知道了,别着急)
- mov ecx 9 :把9赋值给ecx
- mov eax 0CCCCCCCCh:把0CCCCCCCCh赋值给eax
- rep stos dword ptr es:[edi] :rep stos 的意思是重复stos指令,dword ptr(double word pointer的缩写),这个指的是双字指针(一个字是两个字节,1字==2byte,双字就是占了四个字节)。这个stos指令是指 把eax的值拷贝到 es:[edi]指向的地址(edi到ebp指向地址) ecx 是重复的次数。
- mov ecx,59C003h :把59C003h赋值给ecx
- mov dword ptr [ebp-8],0Ah :ebp-8 这就是在给变量a赋值,a=10,记住变量a的地址是ebp-8。0Ah(十六进制,转换成十进制是10),就是把10这个值赋值ebp-8处变量,且这个变量是个双字节变量,占四个字节的变量。
- mov dword ptr [ebp-14h],14h :ebp-20 这就是在给变量b赋值,b=20,记住变量b的地址是ebp-14h。
- mov dword ptr [ebp-20h],0:ebp-32 这就是在给变量c赋值,c=0,记住变量c的地址是ebp-20h。
- mov eax,dword ptr [ebp-14h] :把变量b的值也就是20给寄存器eax
- push eax :把eax push到栈顶
- mov ecx,dword ptr [ebp-8] :把变量a的值也就是10给寄存器ecx
- push ecx :把ecx push到栈顶(在此我说明一下,我们可以发现,函数传参确实是从右往左传的,这里我们先是把b的值给寄存器,再把a的值给寄存器)
- call 005910B4:现在开始调用Add函数了(此时调试时,我们要按照F11进入函数内部),并且push call指令的下一条地址(005918F7),目的:调用完Add函数后还能回到call指令的下一条指令处,并继续执行。
- push ebp : 把ebp压入栈顶(现在开始是在为Add函数建立栈帧,跟main函数相同,我也就不赘述了)
- .....................................
- mov dword ptr [ebp-8],0 :建立z变量,赋值为0
- mov eax,dword ptr [ebp+8] :把a的值传给eax(可见,传参时并不是把a直接传过去,而是通过寄存器存放a变量的值)
- add eax,dword ptr [ebp+0Ch] :把a+b的值存放eax
- mov dword ptr [ebp-8],eax :把eax中的值,也就是a+b的值赋值给变量z
- mov eax,dword ptr [ebp-8] :把变量z的值赋值给eax(注意:此时的eax在main函数栈帧中)
- pop edi、esi、ebx:把这三个寄存器弹出栈,esp就会往下移动三个。
- add esp,0CCh : 销毁Add函数的栈帧
- mov esp,ebp :把ebp的值赋给esp,让esp跟ebp同时指向Add函数的栈底
- pop ebp:把ebp的值弹到回到main函数栈底(现在ebp和esp又回到main函数的栈底和栈顶)
- ret :返回call的下一个指令
- add esp,8 :让esp+8,esp往下走
- mov dword ptr [ebp-20h],eax :把eax的值也就是a+b的值赋值给c
- 后面的内容就是销毁main函数的栈帧了,跟销毁Add函数的栈帧一样,我就不再赘述了。
刚开始我们对这些还不太熟悉,可以操作一步画一步的图。
这里提醒一下:main函数并不是只调用别的函数,而不被调用,main还是也是会被调用的
main
__tmainCRTStartup
__mainCRTSartup
文章就到此结束了,这篇干货满满,喜欢的朋友请给我点个赞再走吧(真的狠狠走心了)。