过程调用
- 过程对应C语言中的函数,是对一系列工作流程的抽象
过程调用的机器级实现
- 当函数嵌套调用时(在一个函数内部调用另外一个函数),在计算机底层会进行过程调用
- 过程调用主要有三个机制:
- 控制传递。程序在代码片段中执行到某一个位置时,打断当前的顺序执行,切换到另一个代码片段进行执行。另一个代码片段执行结束后,又回到刚才的语句,继续执行。本质上是代码执行地址的改变和切换
- 数据传递。调用函数时要传给函数一些参数,在返回函数时也可能会将一些函数计算结果以返回值形式返回给原函数。本质上是数据传入新过程,又传回原过程
- 内存管理。在过程进行时,如何分配内存空间;在过程返回后,如何销毁内存中存储的局部变量
栈的访问
- 栈是计算机中的重要数据结构,底层过程的调用依赖这种数据结构。正由于这种结构的重要性,导致处理器本身就原生支持这种数据结构,有对栈的特殊操作指令
- 栈是一片位于内存中的连续线性空间,栈底位于高地址,栈顶位于低地址,栈由高地址方向向低地址增长。由rsp寄存器存放栈顶元素地址
两种基本操作(均为单操作数指令)
- pushq入栈指令,操作数可以为立即数或寄存器。操作过程分为三步:将操作数中数据取出;将rsp的值减8,栈向低地址方向生长;将取出的src中的数据存放到更新后的rsp指向的内存位置中
- popq出栈指令,操作数只能为寄存器。操作过程也是三步:根据当前rsp寄存器指向的地址,从地址中取出数据;将取出的数据放到操作数中;将rsp寄存器的值加8,完成栈的收缩过程
- 值得注意的是,在popq出栈操作中,没有进行删除数据工作,只是将数据取出放到操作数中,在对应位置仍然保存着原先的数据。只是由于栈的边界收缩,故而数据尽管仍然存在,但已经位于栈的外部而已。未来入栈时再用新数据将此数据覆盖掉,完成入栈操作
控制传递
- 过程调用中的控制传递需借助栈的数据结构,主要有两种指令call、ret
- call过程调用指令
- 操作数为所调用过程的入口地址
- 进行两步工作:将返回地址压入栈中;跳转到操作数指向的位置,继续执行下一条指令。返回地址,即call指令后边紧接着的指令的地址。
- 跳转过程和跳转指令非常类似,就是将指令计数器(即rip寄存器)设置为对应目标位置的地址,即可完成跳转
- ret过程返回指令
- 也进行了两步工作:从栈顶将call指令压入的返回地址弹出;跳转到返回地址的位置
结合实例
- call指令
call指令执行前,rsp寄存器储存的是0x120,rip存储的是call指令的地址;call指令执行时,先先进行入栈操作,rsp寄存器减8,然后把call指令后紧接着的地址(即返回地址)放进去,再将rip寄存器设置为所调用过程的入口地址(400550)
- ret过程返回指令
ret指令执行前,rip寄存器的值为ret指令的地址(400557),rsp存放的栈顶地址与刚进入过程时一样,是call指令的返回地址;ret执行时,执行出栈操作,将栈顶指针弹出,并加载到rip寄存器中,作为下一步跳转的地址
数据传输
- 过程调用中数据的传输分为两部分:一个是过程调用时传入参数,一个是过程返回时传出返回值
- 如何传入参数:在X86的64位系统中,参数的传递优先使用寄存器完成存储。对于过程调用的前六个参数,均放到寄存器中存储,并按照rdi,rxi,rdx,rcx,r9,r10的顺序依次存储;剩余参数使用栈进行传递,最后一个参数最靠近栈底,地址最高,最先入栈,第7个参数在栈顶的位置(仅仅指X86的64位系统,其余系统有所不同)
- x86的32位系统,则完全是用栈传递参数,不使用寄存器:因为x86的32位系统仅有八个寄存器,没有足够的通用寄存器传递参数,故而完全用栈传递。用栈传递时,使用参数要首先进行内存访问,而寄存器则不需要访问内存,故而能够提高性能
- 返回值只有rax一个寄存器保存,这也说明了为何C语言、C++的返回值只能有一个
栈上的局部储存:过程中的存储管理
- 如今,大多数编程语言都支持递归,如c、c++、Java、Pascal、c#。而支持递归语言的基本要求就是代码可以重入,即一个过程或函数在调用没有返回之前可以被再次调用。可重入决定了代码内部的局部变量,是不能在各个相同的过程中共享的,即过程可能调用多次,但每个过程都有其单独的实例,每次过程的内部状态都不相同
- 因此,在每次过程调用时,我们都需要一个特殊空间,为每个过程存放状态。状态包含过程调用时的参数,过程内部定义的局部变量,返回地址等。
- 结合栈的数据结构,过程状态有一个生命周期的概念。过程调用时,过程状态被创建出来,用于存储状态;过程返回时销毁过程状态。而由于过程调用时,调用者在内部调用被调用者,被调用者的过程会先于调用者过程返回。 因此多个过程嵌套调用时,最后返回的过程,应该是最开始调用的过程。这与栈先入后出的特性相似
- 因此,我们将栈与过程调用结合在一起。调用过程时,把每个实例的状态存储在栈上;返回过程时,把此实例的状态释放。存储在栈中过程的状态称为帧,也称栈帧
- 结合实例,将函数调用过程与栈帧联合分析,理解栈帧的形成与销毁
程序进入yoo函数时,在栈中会为yoo函数分配一个栈帧,rsp寄存器指向栈的顶部,rbp寄存器
指向当前栈帧的底部。尽管在最新版X86 64位编译器中,rbp寄存器已经没有了这种作用,但我们仍可以通过某些编译选项设置,使其发挥作用。
程序继续调用函数,栈中就会继续为调用的函数分配栈帧,rsp、rbp寄存器存储的地址也会随之发生变化;当函数返回时,栈帧就被释放出来,rsp、rbp寄存器随之向上移动
栈帧的分配和回收
- 在栈帧中储存的数据主要有四种类型
- 参数。过程中参数超过六个时会在栈上传递,这部分在栈上进行传递的参数属于栈帧的一部分
- 局部变量
- 返回地址
- 临时空间。可能不会被使用,但分配栈帧时,有时会分配出一部分
- 对于栈帧的管理是在机器语言级别进行管理,而非由处理器实现。将高级语言程序编译成指令时,编译器会自动在指令中插入栈帧管理指令。
- 栈帧管理指令主要有两部分:
- 刚刚进入过程时,如需建立栈帧,会插入栈帧空间申请指令(包含call指令,因为call存入了返回地址)
- 过程返回前,如果建立了栈帧,会插入栈帧空间释放指令(包含ret指令,因为ret释放了返回地址)
- 结合实例理解栈帧的分配和回收
- call-incr的前两条指令是插入的栈帧空间申请指令。rsp-16将rsp寄存器向低地址方向移动16位,解读为为call-incr分配了一个大小为16字节的栈帧。然后将12513分配到了栈帧中
- 参数应该优先分配到寄存器中,为何将12513分配到内存中:接下来调用incr函数要进行v1变量的取址操作,若储存在寄存器中是无法进行地址操作的。由于有取址操作,故而v1变量必须分配到内存中
- incr函数完成后,会插入栈帧空间回收指令。rsp+16完成栈帧回收
- 由图可知,call-incr过程中还有八个字节一直没有被使用,也就是临时空间
- 值得注意的是,incr过程并没有栈帧的初始化、回收指令。这是由于incr函数并未嵌套调用其他函数,也不需要使用局部变量,因此不需要使用内存,所以没有分配栈帧。由此可见,编译器对于函数的栈帧不是一定要分配,而是按需分配,对于叶节点函数,如没有使用内存的需求,通常是不会分配栈帧的
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。