说明

这篇文章基本上是完全了,但是还是不完全
所以以后可能会更新

哦对,头图是一张非常好看的雷电将军壁纸,可惜只能显示一半,不过配合内容也很有修仙的意境,版权出处可在图片右下角©悬浮鼠标查看。手机及其他触屏用户可点击查看(建议还是用电脑,电脑更好康)。

源文件


Tools

VS2019
VScode
Typora

《雷电将军》 by QuAn_ 2021年9月5日晚上11点09分 pid:92541207

引子

函数栈帧的创建和销毁

前期学习的时候,我们可能有很多困惑?

比如:

  • 局部变量是怎么创建的?
  • 为什么局部变量的值是随机值?
  • 函数是怎么传参的?传参的顺序是怎样的?
  • 形参和实参是什么关系?
  • 函数调用是怎么做的?
  • 函数调用结束后是怎么返回的?

知道函数栈帧的创建和销毁就都会了,其实就是修炼了自己的内功,也能搞懂后期更多的知识。


进入正题

今天讲解的时候,使用的环境是vs2013,不建议使用太高级的编译器,越高级的编译器,越不容易学习和观察。

同时在不同的编译器下,函数调用过程中栈帧的创建是略有差异的,具体细节取决于编译器的实现。

说明

首先,我们编写了一段代码:

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.寄存器

寄存器,是集成到CPU上的,她完全独立。

eax

ebx

ecx

edx

ebp 栈底指针 寄存当前函数栈帧栈底地址

esp 栈顶指针 寄存当前函数栈帧栈顶地址

epb和esp2个寄存器中存放的是地址

这个2个地址是用来维护函数栈帧的

每一个函数调用,都要在栈区创建一块空间

栈区的使用习惯:

先使用低地址,再使用高地址。(这个很重要,因为每一次操作都基于这个条件,不同的编译器可能会有不同)

2.main函数调用

可以看到,在VS2013中,main函数也是被其他函数调用的

__tmainCRTStartup

在626行调用了main函数

这个函数又是被别的函数调用的

mainCRTStartup

在466行调用了__tmainCRTStartup

3.反汇编(正篇开始,按动作划分步骤,详细到爆)

F10进行调试,右键反汇编

就可以看到我们C语言所对应的汇编代码了

为了方便我们看具体的地址,内存的布局,我们可以取消勾选显示符号名

可以看到a,b,c都变成了具体的地址

当然右键也可以选择取消勾选

注意

接下来的步骤皆由上述代码实验。

步骤过长且按部就班。

如需跳转请直点目录索引。

1.push 开始创建main的函数栈帧

注意

这里已经进入了main函数,那么也就意味着调用main函数的__tmainCRTStartup已经创建了栈帧,并且已有一个ebp和一个esp指针来维护

压栈:

给栈顶放一个元素 push

同样

出栈:

从栈顶删除一个元素 pop

在这里当我们进入第一步push!

就会在这个栈帧顶放一个ebp的值,没错,就是__tmainCRTStartup的ebp的值。

同时,esp是维护栈顶的,所以esp也要往上走。

也就是说:

push的动作

每一次push,esp都要往上走,esp的值都会变

可以查看一下esp地址

esp地址变小了

在内存中可以看到ebp被压进去了

2.mov

既然已经把ebp的值给了esp,那么是不是可以说ebp也已经提升上来了?

很显然是的

3.sub

sub就是减法

这里显示sub,esp,0E4h。意思就是说esp减去一个0E4h

  • 0E4h是十六进制显示,十进制就是228

那也就是说,esp变小,会被放到更低的地址,用图来说明就是放到了上面的某一个地址

那么这就有了一个esp和ebp新维护的空间。这块空间,其实就是预先为main函数申请的空间

4.push ebx,esi,edi

接下来3个步骤,直接压3个寄存器

可以看到现在所有寄存器的值

同样esp也会移动

5.lea

Load Effective Address - 加载有效地址

勾选显示符号名就可以看到后面这个值

也就是说,把ebp-0E4h放到edi里面

ebp-0E4h意思和前面esp一样,减去了0E4h

此时的地址,放到了edi里去

注意

此步骤与接下来的2个mov,这3个步骤具体有什么用,需要看后面rep stos的操作

6.mov ecx,39h

把39h放到ecx里

7.mov eax,0CCCCCCCCh

把0CCCCCCCCh这个值放到eax里

8.rep stos

真正起作用的是这句话:

//rep stos        dword ptr es:[edi]

这句话的意思是:

要把从edi开始,向下的ecx(39h)次,这么多个dword(一个word2个字节,double word就是双字,四个字节)数据全部改成eax(0CCCCCCCCh)。

注意edi,原本和ebp-0E4h一样的值,变成了和ebp一样的值

那么用图就可以说明,从ebp-0E4h到ebp,也就是main函数的栈帧。全部变成了c

9.mov 存放变量并初始化

这里的ebp-8,ebp-14h,ebp-20h,根据上述步骤即可得知

ebp-8h

把0Ah放到ebp-8里,0Ah转换成10进制就是10

在内存中也可以看到

这时,变量就创建好了。

变量初始化 烫烫烫

之前我们在研究的时候会打印的"烫",就是int a = 10没放进去的时候存放的随机值,这里就是c,不同的编译器可能放不同的随机值。所以,这就是变量初始化的重要性。

ebp-14h

然后,ebp-14h存放的就是b了。

十六进制的14转换成十进制就是20。在内存中可以看到

存放距离

通过内存可以很直观的看到和int a = 10隔了2个字节的距离,当然因为是dword操作,也要算上其他6个bit位的0,所以b存放在了14h的位置。

在别的编译器上可能是紧挨着,也可能会间隔别的距离

ebp-20h

int c = 0又和b = 20间隔了2个字节

此时,所有的变量都已创建。

10.mov 开始函数调用

笔者的话

看到这里,想必你已经有了一定的耐心毅力,且已经从懵逼状态进化到了略懂一点。当然,能够从前面研究到现在已经很了不起了,我的老师说过:“函数栈帧就是修炼内功的过程”,到现在为止也只是创建了main函数的函数栈帧,明白了一些操作内容等等。接下来开始进入函数相比也会轻松起来。加油吧!

《雷电将军》(笑)by QuAn_ 2021年9月5日晚上11点09分 pid:92541207

函数传参

函数调用就要传参呀,那到底是怎么传的呢?

把epb-14h的值放到eax里面去

可以看到之前的eax值被填满了随机值c

运行这一步后,eax变成了20,也就是b的值

到这里,可以看到,传参先传了b的值,说明传参的顺序是从右到左的。

11.push eax

顾名思义把eax压栈到顶,当然esp也要上升

在内存中可以看到esp也变成了20

12.mov ecx

ebp-8前面存放了a,也就是说要把a放到ecx里去了,可以看到原来的ecx什么都没存。

运行一步就可以看到把a存放进去了

13.push ecx

这一次顶上又多了一个元素,同样esp也上升

可以考虑一下,步骤10到13把a和b都移动到了上面,是不是就是传参呢。答案是肯定的。

这时,形参创建了。

14.call

这里的call指令是做了什么呢,我们先记一下。哦对,也同时记一下内存中刚被传参的a的地址的上一个地址。

在这运行下一步需要按F11了,因为要进入函数了。

此时,反编译跳入了我们所写的Add函数中。

这时我们观察内存,call指令的下一条指令的地址,被压到了原本是0的这里

也就是说在执行call指令的时候,在顶上又压了个地址,是call指令的下一条指令的地址。

这是为什么呢?

这是因为:call执行的时候跳入了函数中,call执行完返回的时候,需要返回到下一个地址,不能没地方返回。

15.真正进入函数

再按一下F11

注意:这时才真正的进入了我们所写的Add函数里面

我们可以发现,函数里面一样有前面main的函数栈帧创建的步骤,push ebp到rep stos。这就是为我们的Add函数创建函数栈帧的过程。

16.push epb

17.mov ebp,esp

18.sub esp,0CCh

把esp移走,也就是说给Add函数分配了空间创建了栈帧

19.push ebx

20.push esi

21.push edi

22.lea edi,[ebp+FFFFFF34]

ebp+FFFFFF34显示符号名ebp-0CCh

23.mov ecx,33h

把33h放到ecx里

24.mov eax,0CCCCCCCCh

把0CCCCCCCCh放到eax里

25.rep stos

从edi开始,向下的ecx(33h)次,这么多个dword(一个word2个字节,double word就是双字,四个字节)数据全部改成eax(0CCCCCCCCh)。(和前面main函数栈帧一样)

此时,Add函数的栈帧创建完成

《jio香一刀》 by 红白0v0:别人画胸我画JIO 2021年9月5日下午12点51分 pid:92523275

26.mov

初始化z变量,把0放到ebp-8的位置

27.mov

这时,函数创建了,但是x和y呢?

通过我们前面传参的步骤,这里就可得知,ebp+8的位置,就是传参来的a,ebp+12的位置,就是传参来的b。

所以,把ebp+8放到eax里,eax现在是10

28.add

0Ch转换成10进制就是12

ebp+12的位置是20,20加上前面eax的10,就是30,放到eax里。

现在eax是30.

29.mov

把eax也就是30,放到ebp-8里面去

形参的位置

可以看到,在进行运算的时候,2个参数根本就没有在栈帧中存放,而是存放在之前mov到的位置。

函数的传参方式

还没有调用函数的时候,参数已经先传过去了。当开始进行运算的时候,再找回之前压栈进去的参数。

形参是实参的一份临时拷贝。

30.mov

把ebp-8的值放到eax里去

栈帧销毁寄存器是不会消失的

所以,这里把值保存到了寄存器中,eax是不会销毁的,等回到主函数中再拿出来。

31.pop edi,esi,ebx

这里就好理解了,把3个寄存器都弹出

esp自然也就向下移动了,从地址上看就是esp+4+4+4

32.mov

函数已经用完了,栈帧销毁,只需一行指令

意思就是把ebp的值赋给esp,从图来看就是把esp指向下面

33.pop

这个时候,ebp的位置指向之前记录的ebp-main。

也就是说,当返回的时候,正好之前压栈记录下了main函数栈帧的位置,这时可以直接把之前存的ebp-main直接弹出,ebp就会顺利的返回main函数栈帧的位置,继续和esp维护。

34.ret

和之前的call相对应,返回之后当然是从call指令的下一条指令的地址接着运行。所以之前在栈顶压栈存了一个地址。

ret把这条地址pop,于是返回到了主函数。

此时,开头提出的所有问题,全部已经有了答案。

当然,运算好的值还存放在寄存器中。

35.add

2个参数已经没有用了

给esp加上8指向下面

这时,形参销毁了。

36.mov

这时,把存放在寄存器eax里的值放到c里面去。

4.后续

接下来打印,销毁main函数栈帧,就大同小异了。

总结

可以看到,在反汇编中,从步骤1到8是main函数的栈帧创建,然后把里面填满了随机值c,步骤9存放了a,b,c3个值,也就是初始化变量。步骤10开始函数调用,到13这4个步骤就是函数传参。步骤14记录下返回时的地址,步骤15真正进入函数。步骤16到25创建函数栈帧,步骤27到29计算完成。步骤30把运算好的值保存到寄存器中。步骤31到34函数栈帧销毁。步骤35和36把值返回。

通过一个动作一个动作的讲解,我把函数栈帧非常详尽的表述了出来。(可以说细的比吴签还细,再细,也不是这次要讲述的内容了。)

注意

1.函数栈帧创建使用过一次销毁后,下次再用,找的是代码,创建新的栈帧,而不是销毁的栈帧。

2.函数栈帧的动作,不仅要走出去,还要回得来。

3.只要一次函数调用完,无论是函数栈帧或者是参数,都会统统销毁返回给操作系统。就算主函数中还要指回去,也没用。

4.编译器会为函数预开辟空间,不会出现空间不够的情况。给每个函数预开辟的空间肯定也不一样。

5.还有其他一些问题,注意有编译器原本固定的做法,可以不用深入研究,每个编译器每次调试都可能不一样,非要搞明白的话建议自己做一个编译器。

4.此乃博主自随师从学耗良久时日编写,如果对你有帮助或者感触良多,请直接通过给出的链接喂饱我!(理直气壮)

如果还想有更加深♂刻♀的交流或者问题,可以通过下方评论或者留言板告诉我,即刻会有邮件交互(当然本地笔记没这功能)

《雷電将軍》by あかさあい⚓︎ 2021年9月5日下午5点02分 pid:92528728

广告位招租
最后修改:2021 年 09 月 10 日 02 : 11 PM
如果觉得我的文章对你有用,请喂饱我!(理直气壮)