电子说
有读者问题函数调用是如何实现的,今天就来聊聊这个比较简单的问题。
大家都应该打包过东西吧,搬家之类的,通常都是找几个箱子一股脑装进去,为了不让箱子占地方,你通常会把它们摞好,就像这样:
最先被打包好的箱子被摞在最下方,刚打包好的箱子总是放在最上方,这就形成了一种first in last out的结构,也就是我们所说的栈,stack,上面的这些箱子就形成了栈。
如果你懂得用箱子打包东西,你就能明白函数调用是怎么一回事。
原来,在程序运行时每个被调用的函数都有自己的一个箱子,假设这段代码是这样写的:
void D() {}
void C() {
D();
}
void B() {
C();
}
void A() {
B();
}
函数A调用函数B、B调用C、C调用D,那么当函数D在运行时内存中就会有四个箱子,每个函数一个:
每个函数占据的这个箱子——也就是这块内存,就被称为栈帧,stack frame,只不过由于引力的作用,我们摞箱子时是从下往上增长,而出于内存布局的需要,函数调用时的栈是从高地址向低地址增长。
这些箱子中都装有什么呢?你在函数中定义的局部变量就装在这里,关于栈帧内容更详细的讲解你可以参考这里《函数调用是在内存中是什么样子》,这些不是本文的重点,这里更关心的是这些栈帧是怎样增长以及减少的。
仔细观察上面这张图,每个箱子最重要的信息有两个, 你至少需要知道箱子的底部以及箱子的顶部在哪里 !
在计算机中,每个函数栈帧的“底部”和“顶部”的信息——也就是内存地址,分别存放在两个寄存器中:BasePointer(BP)寄存器以及StackPointer(SP)寄存器,即我们熟悉的rbp以及rsp,32位下为ebp以及esp,注意本文以x86_64为例。
只要确定了rbp和rsp你就能得到一块栈区,在这块栈区上就可以进行函数调用:
读到这里肯定有的同学可能会问,CPU中的寄存器不是有限的吗?从这里的讲解看每个栈帧都需要维护一个“栈顶”与“栈底”的信息,每个核心中的rbp以及rsp寄存器就一个,我们该怎样确保函数运行时相应栈帧使用的rbp以及rsp是正确的呢?
方法非常简单,调用函数时会创建新的栈帧,此时需要将原有rbp寄存器中的值保存在新的栈帧上,就像这样:
上图就是函数调用时第一件要完成的事情,把rbp的值push到栈上,rsp下移,然后呢?然后也很简单,只需要把rsp指向的地址也赋值给rbp即可,这样就开启了一个新的栈帧:
完成上述操作的有两条机器指令(gcc编译器):
push %rbp
mov %rsp,%rbp
如果你去看编译器为每个函数生成的机器指令,那么开头几乎都是这两条指令,现在你应该明白这两条指令的作用了吧。
这两条指令就把上一个栈帧的rbp的保存到了新的栈帧,由于此时rsp已经指向了新的栈帧栈顶,由于此时栈为空,因此栈顶和栈底的地址是一样的,可以直接把rsp赋给rbp,这样一个全新的栈帧就创建出来了。
如果我们在被调函数内部创建一些局部变量:
void funcB() {
int a = 1;
int b = 2;
int c = 3;
...
}
那么此时栈会进一步扩大,并把局部变量存放在该函数的栈帧中:
现在我们的栈可以随着函数调用而增长,可以看到,栈帧和你搬家时用的纸箱子还是不太一样的,函数栈帧不会一开始就大小固定好,而是随着指令的执行动态增加,也就是如果你往栈上push一些数据,栈帧就会相应的增大一点。
那么函数调用完成时该怎么办呢?这也非常简单,只需要一条机器指令:
leave
我们在上一篇栈区分配内存快还是堆区分配内存快中讲解了一部分,leave指令的作用是将栈基址赋值给rsp,这样栈指针指向上一个栈帧的栈顶,然后pop出rbp,这样rbp就指向上一个栈帧的栈底:
看到了吧,执行完leave指令后rbp以及rsp就指向了上一个栈帧,这就相当于栈帧的弹出,这样stack 1占用的内存就无效了,没有任何用处了,显然这就是我们常说的内存回收,因此简单的一条leave指令即可把栈区中的内存回收掉。
而在x86平台,leave指令后往往跟上一条ret指令:
leave
ret
我们已经了解了leave指令的作用,这条指令让rbp以及rsp指向上一个栈帧,然后呢?显然CPU应该从funcA调用函数funcB之后的一行代码处继续运行,那么这行代码的地址在哪里呢?显然就在funcA栈帧的栈顶:
当CPU执行call指令时会把该函数的返回地址push到栈中,而ret指令的作用正是将栈顶弹出(pop)到rip寄存器,rip寄存器告诉CPU接下来该从哪里执行机器指令,这个返回地址是funcA调用funcB时push到栈上的,这样当从函数funcB()返回后我们就知道该从哪里继续执行机器指令了,这就是ret指令的作用,当然这里也是函数调用实现的基本原理。
全部0条评论
快来发表一下你的评论吧 !