查看原文
其他

函数调用时底层发生了什么?

IT服务圈儿 2023-02-06

The following article is from 码农的荒岛求生 Author 码农的荒岛求生

来源丨经授权转自 码农的荒岛求生(ID:escape-it)

作者丨码农的荒岛求生


有读者问题函数调用是如何实现的,今天就来聊聊这个比较简单的问题。
大家都应该打包过东西吧,搬家之类的,通常都是找几个箱子一股脑装进去,为了不让箱子占地方,你通常会把它们摞好,就像这样:

注意看上面的箱子,最先被打包好的箱子被摞在最下方,刚打包好的箱子总是放在最上方,这就形成了一种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指令的作用,当然这里也是函数调用实现的基本原理。关于栈帧更详细的讲解可以参考我写的这篇《函数调用在内存中是什么样子》。


1、京东二面:高并发设计,都有哪些技术方案?

2、程序员最硬大佬,你绝对想不到!!!

3、从公司角度来看,为什么要招实习生?

4、利用 PicGo 快速迁移 Gitee 图床外链图片到服务器

5、小小的 likely 背后却大有玄机!

点分享

点点赞

点在看

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存