我们已经知道,程序是按顺序执行的指令流水线(PipeLine)。分支和循环逻辑,可以通过在流水线中往后跳或往前跳实现。
其实,函数调用也不过是在跳转。调用某个函数,就跳转到那个函数的指令流的开始位置,函数执行完成后,再跳转回来。
函数能获取外部写入的数据(输入),能持有自己独特的数据(本地状态),还能向外部写数据(输出)。而理论上程序所拥有的函数数量是无限的,但是寄存器的数量却很少,无限的函数怎么通过有限的寄存器数量来保存它们各自的数据呢?
一、 如何通过跳转实现函数调用和返回
只需要知道目的地,就能实现跳转了。但是如果还要跳回来,那么就需要在跳转前把回来时的地址保存在一个跳转后也能读取到的地方。
汇编指令 call
用来在内存中保存跳回地址然后跳转,而 ret
则能利用 call
指令保存的跳回地址来从函数返回。
call
指令后面往往接着一个地址,就像 jmp
指令一样。不过在真正 jump 之前,它把紧挨着 call
指令的下一条指令的地址保存在内存里面去了。
保存在内存的哪里呢?保存在 M[%rsp]
。
这么多的函数,1 个 %rsp
够用吗?基于以下两个事实,我们说:够用。
- 事实1:不管有多少函数,特定时间上被执行的函数只有一个。于是特定时间上只需要一个
%rsp
,问题在于怎么由这一个%rsp
推导到其它%rsp
。 - 事实2:不管有多少函数,每个被执行的函数都是被上一个函数调用的。于是我们不需要由当前
%rsp
推导出所有其它函数的%rsp
,只需要知道上一个函数(调用者)的%rsp
。这不就是加减法吗?
比如主函数的 %rsp
是 x
,那函数 A 调用 B 函数时,%rsp = %rsp + N
, B 返回时,%rsp = %rsp - N
。
由于 %rsp
用于内存寻址,因此常量 N 为 8。
这就是经典的栈内存的伸缩模型。
说了这么多,还是来看代码吧:
1 | 400540: lea 2(%rdi), %rax |
假如在执行 40055b: call 400545
时 %rsp
的值为 0x7fffffffe820
,那执行完 call
后,%rsp
的值将减 8 变成 0x7fffffffe818
,同时在内存 M[0x7fffffe818]
处的将保存值 0x400560
,即 call
下面那一个命令 mov 的地址。之后,程序跳转到 0x400545
处开始运行。
在 0x400549
处又是一个 call
指令,于是同样的逻辑再来一次,%rsp
的值减 8 为 0x7fffffff10
, 内存 M[0x7fffffffe810]
处保存下面 add
指令的地址 0x40054e
。之后,程序跳转到 0x400540
。
在 0x400544
处遇到了 ret
指令,此时先从 %rsp
中读出栈指针地址为 0x7fffffffe810
,再从中读出 M[0x7fffffffe810]
的值 0x40054e
。然后 %rsp
加 8 恢复到上一次 call
指令之前的值,并跳转到 0x40054e
,紧接着上一个 call
指令之后开始运行。
0x400551
处的 ret
流程和上一步差不多,不再赘述。
总结就是,通过一个随函数调用而同步伸缩的栈指针来保存函数的返回位置,就既能跳过去又能跳回来了。
二、 如何通过寄存器保存函数的状态
大部分函数有参数,有返回值。如何通过有限的寄存器保存这些数据呢?
简单,还记得寄存器都被赋予了明确的职责吗,%rdi
, %rsi
, %rdx
, %rcx
, %r8
, %r9
分别用来保存函数的 6 个参数,而 %rax
用来保存函数的返回值。
在调用函数中,执行 call
指令前先把参数保存到参数寄存器里面,在被调用函数中,从哪个寄存器里面读取值就等同于在使用哪个参数(%rdi
是第一个,等等)。
被调用函数 ret
到 call
的下一个指令位置后,%rax
里面就保存着被调用函数的返回值。
不过,这还是有问题。返回值通常只有一个,但是参数可不一定只有 6 个,当函数的参数个数超过了 6 个时怎么办?
这时候,就又依赖到栈内存了。
三、 如何通过内存保存更多的函数状态
有一下几种情况导致我们必须在内存中保存函数的状态:
- 当被调用函数的参数个数超过了 6 个
- 函数的本地数据超过了寄存器数量
- 函数本地数据使用了数组,因此必须使用数组的方式访问数据
- 函数里面对本地数据使用了 & 操作获取内存地址,因此必须把数据保存到内存
不管是哪种情况导致我们必须使用内存保存数据,这些数据都保存在以函数栈指针为基点的特定偏移位置上,统称为栈内存(也是因为其伸缩方式类似栈数据结构)。
一般用 push
和 pop
指令。 push
指令把当前的栈指针值 R[%rsp]
减 8,然后把要保存的值保存到内存的 M[R[%rsp]]
。pop
指针把内存值 M[R[%rsp]]
取出到指定寄存器,然后把 R[%rsp]
加 8 以复原栈指针位置。
编译期保证每一个 push
必定对应一个 pop
,以保证函数在返回前 R[%rsp]
能复原到函数被调用一开始的值。而同时,开发者也不用担心栈空间的内存释放问题,因为编译期会自动维护。
3.1 使用栈内存保存更多的函数参数
P 调用 Q 时,会先把参数保存到对应的寄存器里面。如果寄存器数量不够,则会把剩余的参数使用 push
指令保存到栈内存上。准备完这些参数后,再调用 call
指令。
在 Q 执行时,前 6 个参数照常从寄存器里面读写,往后的就按照 M[R[%rsp] + 8n]
偏移来读写。注意,参数是保存在 P 的栈空间里面的,因为它们从 %rsp
往上寻址(+8n)。
3.2 使用栈内存保存函数本地数据
寄存器的数量是有限的,并且分成了调用者保存和被调用者保存两大类。P 在执行期间,如果本地数据过多,它就要把被调用者保存寄存器里面的数据保存到栈上,然后才能使用它们,并且在返回之前把原来的值从栈上取回来。
如果加上了被调用者保存寄存器之后,数量还是不够,那就更需要把数据 push
到栈上了。
之前说到,当需要以数组方式访问数据时,就必须把数据保存到内存上,再以首地址加偏移的方式进行访问,而不能再用彼此毫无关联的寄存器。此时我们可以用 push
逐个把数组的元素保存到内存上,但是使用 push
保存数据有两个弊端:每个 push
指令必须有一个 pop
指令,并且 push
的次数必须提前知道。
为了减少 push-pop
的消耗以及支持动态地指定数组长度,编译器使用了栈帧模式。
栈帧模式依赖了另一个寄存器 %rbp
。其使用过程为:
- (
push %rbp
) 保存%rbp
原来的值 - (
movq %rsp, %rbp
) 用%rbp
保存%rsp
的值,以此作为栈帧的起点(base-pointer) - (
subq 8n, %rsp
)%rsp
减去 8n 以开辟 n 个地址用来存储本地变量 - (
addq 8n, %rsp
)%rsp
加上 8n 以回收分配的 n 个地址 - (
movq %rbp, %rsp
) 从%rbp
恢复%rsp
- (
popq %rbp
) 从%rsp
恢复%rbp
原来的值
当然,虽然说支持动态地分配 n 个元素所需的空间,但 n 也不能无限大,具体多少是可以配置的。如果超过了这个大小导致报错,那就去 StackOverflow 搜一下怎么解决吧。
四、 总结
函数调用主要有两个问题要解决:
- 怎么回
- 函数状态怎么保存
对第一个问题的解决办法是:
- 使用随函数同步伸缩的栈指针来保存返回地址
对第二个问题的解决办法是:
- 把寄存器分类为调用者保存和被调用者保存以维护函数独立的状态
- 把超出寄存器数量的数据根据栈指针
push
到内存并用pop
回收地址以及维护栈指针状态 - 把数组状态的数据保存到帧上,减少
push-pop
的消耗并支持动态栈大小