在开始本篇的内容前,咱们先来思考几个问题。
- 咱们先来看一段简略的代码:
void func(int a) {if (a > 100000000) return;
int arr[100] = {0};
func(a + 1);
}
你能看出这段代码会有什么问题吗?
- 咱们在之前的文章《高性能高并发服务器是如何实现的》一中提到了一项关键技术——协程,你晓得协程的实质是什么吗?有的同学可能会说是用户线程,那么什么是用户态线程,这是怎么实现的?
- 函数运行起来后是什么样子?
这个问题看似没什么关联,但这背地有一样货色你须要了解,这就是所谓的函数运行时栈,run time stack。
接下来咱们就好好看看到底什么是函数运行时栈,为什么彻底了解函数运行时栈对程序员来说十分重要。
从过程、线程到函数调用
汽车在高速上行驶时有很多信息,像速度、地位等等,通过这些信息咱们能够直观的感触汽车的运行时状态。
同样的,程序在运行时也有很多信息,像有哪些程序正在运行、这些程序执行到了哪里等等,通过这些信息咱们能够直观的感触零碎中程序运行的状态。
其中,咱们发明了过程、线程这样的概念来记录有哪些程序正在运行,对于过程和线程的概念请参见《看完这篇还不懂过程和线程你来打我》。
过程和线程的运行体现在函数执行上,函数的执行除了函数外部执行的程序执行还有子函数调用的管制转移以及子函数执行结束的返回。其中函数外部的程序执行乏善可陈,重点是函数的调用。
因而接下来咱们的视角将从宏观的过程和线程拉近到宏观下的函数调用,重点来讨论一下函数调用是怎么实现的。
函数调用的流动轨迹:栈
玩过游戏的同学应该晓得,有时你为了实现一项主线工作不得不去打一些干线的工作,干线工作中可能还有干线工作,当一个干线工作实现后退回到前一个干线工作,这是什么意思呢,举个例子你就明确了。
假如主线工作西天取经 A 依赖干线工作收服孙悟空 B 和收服猪八戒 C,也就是说收服孙悟空 B 和收服猪八戒 C 实现后能力持续主线工作西天取经 A;
干线工作收服孙悟空 B 依赖工作拿到紧箍咒 D,只有当工作 D 实现后能力回到工作 B;
整个工作的依赖关系如图所示:
当初咱们来模仿一下工作实现过程。
首先咱们来到工作 A,执行主线工作:
执行工作 A 的过程中咱们发现工作 A 依赖工作 B,这时咱们暂停工作 A 去执行工作 B:
执行工作 B 的时候,咱们又发现依赖工作 D:
执行工作 D 的时候咱们发现该工作不再依赖任何其它工作,因而 C 实现后咱们能够会退到前一个工作,也就是 B:
工作 B 除了依赖工作 C 外不再依赖其它工作,这样工作 B 实现后就能够回到工作 A:
当初咱们回到了主线工作 A,依赖的工作 B 执行实现,接下来是工作 C:
和工作 D 一样,C 不依赖任何其它其它工作,工作 C 实现后就能够再次回到工作 A,再之后工作 A 执行结束,整个工作执行实现。
让咱们来看一下整个工作的流动轨迹:
仔细观察,实际上你会发现这是一个 First In Last Out 的程序,人造实用于栈这种数据结构来解决。
再认真看一下栈顶的轨迹,也就是 A、B、D、B、A、C、A,实际上你会发现这里的轨迹就是工作依赖树的遍历过程,是不是很神奇,这也是为什么树这种数据结构的遍历除了能够用递归也能够用栈来实现的起因。
A box
函数调用也是同样的情理,你把下面的 ABCD 换成函数 ABCD,实质不变。
因而,当初咱们晓得了,应用栈这种构造就能够用来保留函数调用信息。
和游戏中的每个工作一样,当函数在运行时每个函数也要有本人的一个“小盒子”,这个小盒子中保留了函数运行时的各种信息 ,这些小盒子通过栈这种构造组织起来,这个小盒子就被称为栈帧,stack frames,也有的称之为 call stack,不论什么命名形式,总之,就是这里所说的小盒子,这个小盒子就是函数运行起来后占用的内存, 这些小盒子形成了咱们通常所说的栈区。对于栈区具体的解说你能够参考《深刻了解操作系统:程序员应如何了解内存》一文。
那么函数调用时都有哪些信息呢?
函数调用与返回信息
咱们晓得当函数 A 调用函数 B 的时候,管制从 A 转移到了 B,所谓管制其实就是指 CPU 执行属于哪个函数的机器指令,CPU 从开始执行属于函数 A 的指令切换到执行属于函数 B 的指令,咱们就说管制从函数 A 转移到了函数 B。
管制从函数 A 转移到函数 B,那么咱们须要有这样两个信息:
- 我从哪里来 (返回)
- 要到去哪里 (跳转)
是不是很简略,就好比你进来游览,你须要晓得去哪里,还须要记住回家的路。
函数调用也是同样的情理。
当函数 A 调用函数 B 时,咱们只有晓得:
- 函数 A 对于的机器指令执行到了哪里 (我从哪里来,返回)
- 函数 B 第一条机器指令所在的地址 (要到哪里去,跳转)
有这两条信息就足以让 CPU 开始执行函数 B 对应的机器指令,当函数 B 执行结束后跳转回函数 A。
那么这些信息是怎么获取并放弃的呢?
当初咱们就能够关上这个小盒子,看看是怎么应用的了。
假如函数 A 调用函数 B,如图所示:
以后,CPU 执行函数 A 的机器指令,该指令的地址为 0x400564,接下来 CPU 将执行下一条机器指令也就是:
call 0x400540
这条机器指令是什么意思呢?
这条机器指令对应的就是咱们在代码中所写的函数调用,留神 call 后有一条机器指令地址,留神察看上图你会看到,该地址就是函数 B 的第一条机器指令,从这条机器指令后 CPU 将跳转到函数 B。
当初咱们曾经解决了管制跳转的“要到哪里去”问题,当函数 B 执行结束后怎么跳转回来呢?
原来,call 指令除了给出跳转地址之外还有这样一个作用,也就是 把 call 指令的下一条指令的地址,也就是 0x40056a push 到函数 A 的栈帧中,如图所示:
当初,函数 A 的小盒子变大了一些,因为装入了返回地址:
当初 CPU 开始执行函数 B 对应的机器指令,留神察看,函数 B 也有一个属于本人的小盒子(栈帧),能够往里面扔一些必要的信息。
如果函数 B 中又调用了其它函数呢?
情理和函数 A 调用函数 B 是一样的。
让咱们来看一下函数 B 最初一条机器指令 ret,这条机器指令的作用是通知 CPU 跳转到函数 A 保留在栈帧上的返回地址,这样当函数 B 执行结束后就能够跳转到函数 A 继续执行了。
至此,咱们解决了管制转移中“我从哪里来”的问题。
参数传递与返回值
函数调用与返回使得咱们能够编写函数,进行函数调用。但调用函数除了提供函数名称之外还须要传递参数以及获取返回值,那么这又是怎么实现的呢?
在 x86-64 中,少数状况下参数的传递与获取返回值是通过 寄存器 来实现的。
假如函数 A 调用了函数 B,函数 A 将一些参数写入相应的寄存器,当 CPU 执行函数 B 时就能够从这些寄存器中获取参数了。
同样的,函数 B 也能够将返回值写入寄存器,当函数 B 执行完结后函数 A 从该寄存器中就能够读取到返回值了。
咱们晓得寄存器的数量是无限的,当传递的参数个数多于寄存器的数量该怎么办呢?
这时那个属于函数的小盒子也就是栈帧又能发挥作用了。
原来,当参数个数多于寄存器数量时剩下的参数间接放到栈帧中,这样被调函数就能够从前一个函数的栈帧中获取到参数了。
当初栈帧的样子又能够进一步丰盛了,如图所示:
从图中咱们能够看到,调用函数 B 时有局部参数放到了函数 A 的栈帧中,同时函数 A 栈帧的顶部仍然保留的是返回地址。
局部变量
咱们晓得在函数外部定义的变量被称为局部变量,这些变量在函数运行时被放在了哪里呢?
原来,这些变量同样能够放在寄存器中,然而当局部变量的数量超过寄存器的时候这些变量就必须放到栈帧中了。
因而,咱们的栈帧内容又一步丰盛了。
仔细的同学可能会有这样的疑难,咱们晓得寄存器是共享资源能够被所有函数应用,既然能够将函数 A 的局部变量写入寄存器,那么当函数 A 调用函数 B 时,函数 B 的局部变量也能够写到寄存器,这样的话当函数 B 执行结束回到函数 A 时寄存器的值曾经被函数 B 批改过了,这样会有问题吧。
这样确实会有问题,因而咱们在向寄存器中写入局部变量之前,肯定要先将寄存器中开始的值保存起来,当寄存器应用结束后再复原原值就能够了。
那么咱们要将寄存器中的原始值保留在哪里呢?
有的同学可能曾经猜到了,没错,仍然是函数的栈帧中。
最终,咱们的小盒子就变成了如图所示的样子,当寄存器应用结束后依据栈帧中保留的初始值复原其内容就能够了。
当初你应该晓得函数在运行时到底是什么样子了吧,以上就是问题 3 的答案。
Big Picture
须要再次强调的一点就是,上述探讨的栈帧就位于咱们常说的栈区。
栈区,属于过程地址空间的一部分,如图所示,咱们将栈区放大就是图右边的样子。
对于栈区具体的解说你能够参考《深刻了解操作系统:程序员应如何了解内存》这篇。
最初,让咱们回到文章开始的这段简略代码:
void func(int a) {if (a > 100000000) return;
int arr[100] = {0};
func(a + 1);
}
void main(){func(0);
}
想一想这段代码会有什么问题?
总结
本章咱们从几个看似没什么关联的问题登程,具体解说了函数运行时栈是怎么一回事,为什么咱们不能创立过多的局部变量。仔细的同学会发现第 2 个问题咱们没有解答,这个问题解说放到下一篇,也就是协程中解说。
心愿这篇文章能对大家了解函数运行时栈有所帮忙。