从汇编的角度理解程序(三)—— 函数调用

我们已经知道,程序是按顺序执行的指令流水线(PipeLine)。分支和循环逻辑,可以通过在流水线中往后跳或往前跳实现。
其实,函数调用也不过是在跳转。调用某个函数,就跳转到那个函数的指令流的开始位置,函数执行完成后,再跳转回来。
函数能获取外部写入的数据(输入),能持有自己独特的数据(本地状态),还能向外部写数据(输出)。而理论上程序所拥有的函数数量是无限的,但是寄存器的数量却很少,无限的函数怎么通过有限的寄存器数量来保存它们各自的数据呢?

一、 如何通过跳转实现函数调用和返回

只需要知道目的地,就能实现跳转了。但是如果还要跳回来,那么就需要在跳转前把回来时的地址保存在一个跳转后也能读取到的地方。
汇编指令 call 用来在内存中保存跳回地址然后跳转,而 ret 则能利用 call 指令保存的跳回地址来从函数返回。
call 指令后面往往接着一个地址,就像 jmp 指令一样。不过在真正 jump 之前,它把紧挨着 call 指令的下一条指令的地址保存在内存里面去了。
保存在内存的哪里呢?保存在 M[%rsp]
这么多的函数,1 个 %rsp 够用吗?基于以下两个事实,我们说:够用。

  • 事实1:不管有多少函数,特定时间上被执行的函数只有一个。于是特定时间上只需要一个 %rsp,问题在于怎么由这一个 %rsp 推导到其它 %rsp
  • 事实2:不管有多少函数,每个被执行的函数都是被上一个函数调用的。于是我们不需要由当前 %rsp 推导出所有其它函数的 %rsp,只需要知道上一个函数(调用者)的 %rsp。这不就是加减法吗?

比如主函数的%rspx,那函数 A 调用 B 函数时,%rsp = %rsp + N, B 返回时,%rsp = %rsp - N
由于 %rsp 用于内存寻址,因此常量 N 为 8。
这就是经典的栈内存的伸缩模型。
说了这么多,还是来看代码吧:

1
2
3
4
5
6
7
8
9
10
400540: lea     2(%rdi), %rax
400544: ret

400545: sub 5, %rdi
400549: call 400540
40054e: add %rax, %rax
400551: ret

40055b: call 400545
400560: mov %rax, %rdx

假如在执行 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 是第一个,等等)。
被调用函数 retcall 的下一个指令位置后,%rax 里面就保存着被调用函数的返回值。
不过,这还是有问题。返回值通常只有一个,但是参数可不一定只有 6 个,当函数的参数个数超过了 6 个时怎么办?
这时候,就又依赖到栈内存了。

三、 如何通过内存保存更多的函数状态

有一下几种情况导致我们必须在内存中保存函数的状态:

  • 当被调用函数的参数个数超过了 6 个
  • 函数的本地数据超过了寄存器数量
  • 函数本地数据使用了数组,因此必须使用数组的方式访问数据
  • 函数里面对本地数据使用了 & 操作获取内存地址,因此必须把数据保存到内存

不管是哪种情况导致我们必须使用内存保存数据,这些数据都保存在以函数栈指针为基点的特定偏移位置上,统称为栈内存(也是因为其伸缩方式类似栈数据结构)。
一般用 pushpop 指令。 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 的消耗并支持动态栈大小