写在前面
这个模块临近C语言的边界,学起来需要一定的时间,不过当我们知道这些知识后,在C语言函数这块我们看到的不仅仅是表象了,可以真正了解函数是怎么调用的。不过我的能力有限,下面的的知识若是不当,还请各位斧正。
知识点储备
- 初步了解函数( 这里的所说的函数我们默认为自定义函数)
- 了解C程序地址空间
- 基本的寄存器
- 知道一些汇编语言
函数的概念
函数大家应该都很熟悉了,这里就不细说了。我们看看就行
虚拟地址空间
我们一直说 :“全局变量的生命周期是所在的整个程序”、“static修饰的变量的生命周期变长了”、以及“最重要的临时变量出函数就要被销毁”。不过我们要知道这是因为什么.在C语言中我们所创建的每一个变量都会有自己空间的的存储类别,就比如汽车一般不会停在高楼那样,每一个事物都会有自己的集合,计算机的数据存储也是如此,我们这看一下.
在计算机中,我们把内存分为若干的区间(这里暂时这样理解),每一个区间保存特定的数据,我们先来看C语言程序地址空间,也是虚拟地址空间.
上面不做解析,我们在后面会学到,大家把这个图给记住就可以了.看一下代码,来验证一下.可以看出局部变量存储在栈上且栈空间是沿着向低地址方向开辟的,堆区与之相反.
寄存器
函数的调用与CPU中的寄存器有很大关系,下面有一些基本知识
- eax:通用寄存器,保留临时数据,常用于返回值
- ebx:通用寄存器,保留临时数据
- ebp:栈底寄存器
- esp:栈顶寄存器
- eip:指令寄存器,保存当前指令的下一条指令的地址,衡量走到了那一步
简单汇编语言
这里是一些常见的汇编语言的指令,这里先和大家列出,后面我们产看汇编语言的时候,直接回来看这些语言是什么意思.
- mov:数据转移指令
- push:数据入栈,同时esp栈顶寄存器也要发生改变
- pop:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变
- sub:减法命令
- add:加法命令
- call:函数调用,1. 压入返回地址 2. 转入目标函数
- jump:通过修改eip,转入目标函数,进行调用
- ret:恢复返回地址,压入eip,类似pop eip命令
函数栈帧
看了这么多知识,我们一定会感到很是枯燥,觉得这和函数栈帧一点关系都没有,不要着急,下面就开始我们正式的内容。这里为了便于理解,我们这么看栈的空间,我们就多画些图片
我们知道 main函数也是一个函数,它也是能够被调用,所以main函数也会形成栈帧。
样例代码
下面我们用一个很简单的代码来和大家简单的谈函数的原理,代码虽小,但是却能反应问题.
我们直接开始调试,然后转到反汇编,打开寄存器.
汇编代码
我将汇编代码复制下来,我们一步一步分析这些东西.
这里把寄存器再次简单的说一下.
- ebp指向栈底
- esp指向栈顶
- eip指向下一个
即将
执行的地址 还未执行
main函数栈帧形成
注意,main函数也是有栈帧的,这里我们默认mian函数栈帧已经创建完成了,只看MyAdd函数栈帧的形成,他们都是一样的,我们希望以最小的成本来和大家经行分析.
代码逻辑
下面开始main函数内部大代码逻辑,这是局部变量空间的开辟.
我们可以通过汇编语言看出可以看出,x、y、z 的空间是不连续的 ,这是VS保护机制, 防止一些程序员猜测对应的地址,不过一些老的编译器是不会这么做的,这里不要求我们记忆.
形参拷贝
下面汇编语言继续往下面走,我们此时遇到了函数的调用,那么编译器首先要做的就是形参的拷贝.
这就话的意思是
把ebp-14(也就是y) 赋值给eax eax是一个临时的寄存器,保留临时数据,常用于返回值
,这里的寄存器都是一个临时的容器,方便
此时编译器会做下面动作.把eax里面的数据推送到栈中.
push命令将eax的值放入栈中,同时栈顶esp的位置发生变化,变化的大小是4个字节,因为y是int型
此时我们更新一下我们地址空间的总图,把栈顶寄存器的值保存一下.
下面同理,我们也罢x的值进行拷贝一番,把ebp-8(也就是x) 赋值给ecx,然后推送到栈帧中.
我们还是要更新一下栈顶的位置发生变化.
下面我们开始总结一下上面的过程.
- 编译器首先为main函数开辟栈帧,然后按照顺序进行变量空间的开辟和初始化
- 遇到调用函数时,临时变量的形成(实参的临时拷贝)在函数调前就完成了
- 形参实例化的顺序是从右向左依次形成的,这个有很大的用处,只不过在C++中会体现
- 形参的空间是紧邻的,毕竟形参的变量空间的创建伴随着栈顶的变化.
函数调用
下面我们正式开始函数的调用工作,此时涉及很多的东西.这里先说一下call命令的作用
- 压入返回地址 (最重要的)
- 转入目标函数
我们说压入返回地址,此时谁是返回地址呢call命令的下一条命令的地址. 为什么要压入?根本原因是函数调用完毕,我们要放回mian函数执行其他的代码,需要返回就需要一个回来的坐标.
此时我们一跳这个指令,那么此时编译器会做两个事情,把call指令的地址压入栈顶,顺便修改esp,让后进入call 的地址处.
下面开始jump指令,这是一个跳转指令,把寄存器eip的数据从当前指令的地址改变成jump的地址,我们把这个目的地址认为是我们调用函数的地址,此时转入目标函数.
这里存在一个问题, esp 也就是栈顶的指针,为何发生变化了?这是因为call指令的第一个作用,至于改变了多少,我们先不关心,只需要知道上升的空间里面是保存一个指针的,那么就是4个或者8个字节.
MyAdd函数栈帧的形成
我们继续开始下面最关键的一个步骤,由于我的不小心,把代码从调试状态退出来了,此时这重新进入调试,那么汇编语言中的相关地址会发生变化,不过代码的逻辑是不会发生变化的,我们还是把汇编语言进行复制出来,这里只看MyAdd函数的汇编语言.
压入栈底
此时编译器首先要做的就是把原本栈底寄存器ebp中的数据压入到栈中,同时栈顶也发生变化
这条命令是将ebp(也就是栈底)的内容压入栈中,同时栈顶也发生变化
修改栈底
下面开始数据转移指令,该命令的意思是将esp的内容覆盖到ebp中,也就是此时栈底变得和栈顶一样了,该过程没有通过内存,直接通过CPU. 那么我们可能会发出疑惑,那main函数栈底怎么办,是不是找不回来了?实际上不是的,上一步我们不是把栈底的内容给保存了吗!
修改栈顶
下面我们开始计算新的栈顶就是在哪里了,根据sub指令,可以计算出新的栈顶在哪里.该命令的意思是esp减去一定的值,结果放在esp中到这里我们已经简单的形成了MyAdd函数的栈帧了
代码逻辑
下面我们开始函数内部的代码逻辑,都是很简单的.这和main的变量开辟一样
下面我们开始一步步分析后面的逻辑,注意看,我们上面形参的拷贝都是把数据放在了寄存器中,此时要是想要使用我们形式参数的数据,那么需要编译器手动的取出.
先例解释这一条,把ebp+8放在eax中.那么ebp+8是多少呢?答案就是我们的x值的拷贝
同理,这个命令是将ebp+0Ch的内容和eax加起来放到eax中.ebp+0Ch就是y值的拷贝
这里是将eax写入到ebp-8,也就是c中,就是加法计算的结果.
返回值
其中我们函数栈帧是如何得到的已经了解了,我们来看函数栈帧的最后的一部分.
保存返回值
我们把计算出的结果保存到寄存器中,为了后面的使用
释放栈帧
此时我们函数已经结束了,现在是函数的后期处理工作,包含资源的清理.把ebp 覆盖到esp,也就是收缩栈顶到栈底,这一步可以称为“释放栈帧”,注意函数栈帧的释放可不是空间的释放,只不过是它变成无效,也就是释放栈帧是不等于释放空间的.
弹栈
此时发生了弹栈操作,将main函数的栈底放在ebp中,esp内容发生改变.记住了我们在开辟函数栈帧前已经把main函数栈底的地址压入栈中的,此时拿到就可以了.
注意,此时我们还是处于MyAdd汇编语言的执行部分,此时我们应该返回原本mian函数调用函数的下一个地址,此时我们需要拿到我们曾经的保存在栈中的地址,压入到eip,要知道eip是编译器下一条要到的地址,esp内容改变
释放空间
注意此时我们已经出了MyAdd函数的汇编语言了,已经开始了函数调用的后续工作,例如此时要释放临时拷贝的变量的空间,意思是esp+8放在esp中,也就是修改栈顶.
我们开始继续执行函数调用后的代码,将eax的值放到ebp-20,也就是z,后面mian函数的栈帧的销毁我也就不分析了,逻辑是一样的.
总结
我来一个简单测总结,这个博客只要求大家理解就可以了.
- 函数的的栈帧是由编译器决定的,C语言中很多数据类型,那么编译器有能力知道所有类型变量的大小。
- push进去的变量的空间是连续的,所谓的push进入额变量就是 形参拷贝,压入返回值,压入main栈底的操作是连续的
- 0CCh 的大小和你定义的函数的规模有关,编译器会自动计算.
我们看到了push变量空间是连续的,也就是我们可以通过形参的地址来修改另外的地址.
那么此时也会做一些更加大胆的事情,例如修改mian函数的栈底和修改放回值,这就会让我们代码不能继续mian函数后续代码了,不过现在在VS是非常做测试了,它做的安全性更加强了.
如果面试官问你你可以讲解一下函数栈帧的底层是什么,可以简单的说一下流程吗?这里我给出下面自己的一个答案,注意回答问题的时候一定要说是自己根据观察VS系列编译器理解的,C语言中的函数栈帧创建流程通常包括以下几个步骤:
- 函数参数被压入栈中。在调用函数之前,函数的参数被压入栈中,按照从右到左的顺序进行压栈,
- 函数被调用时,系统会将函数的返回地址压入栈中,同时将栈顶指针(Stack Pointer,SP)减少相应的大小,顺便把mian函数的栈底压入栈中
- 函数局部变量被压入栈中。函数局部变量也是在栈中分配的,同样按照从右到左的顺序进行压栈。当函数返回时,这些局部变量所占用的栈空间也将被释放。
- 在执行函数体之前,为函数创建栈空间。调整栈指针的大小通常是在编译时计算出来的。
- 进行函数体的逻辑运算,包含变量的空间创建,使用形参的过程,返回值会被保存在一个寄存器中
- 释放栈帧,弹栈,返回地址被弹出栈中,释放形参的空间,调整栈顶,继续执行函数调用后面的代码.