过程:函数调用#
函数在 C 中是一个很重要的抽象机制,良好的软件设计通常使用函数来封装代码,实现某种功能。在程序中,通过调用函数来完成某些任务,除了可以隐藏具体的实现,还能够得到清晰易懂的代码。
在其他编程语言中,函数还可能以方法、子例程、句柄等名词出现,这些都可以称作过程。x86-64 汇编语言对过程提供了机器级支持,主要包括以下三个部分:
传递控制 通过调整
%rip
可以执行被调函数(Callee)指令,然后恢复%rip
可以继续执行主调函数(Caller)指令。传递数据 主调函数需要给被调函数提供一些参数;被调函数需要给主调函数返回一个值。
管理内存 被调函数执行时,需要为其局部变量分配内存,返回后又需要释放这些内存。
为了实现上述过程,尽可能减少调用开销,计算机工程师经过大量的实践,最终总结出可以满足最低要求策略的一组规则。
运行时栈#
在学习函数调用和递归过程时,我们已经了解了函数栈帧的原理。函数栈由高地址向低地址方向增长,其创建和收回均有系统自动管理。
在 x86-64 系统中,栈的内存管理由寄存器 %rsp
实现,%rsp
记录了栈顶的地址。创建栈时,将 %rsp
减小适当的量,实现内存的分配;收回栈时,将 %rsp
调整到上一个栈的顶部,实现内存的收回。
由于每个函数调用栈的大小都是固定的,所以收回后 %rsp
的值将依次调整为之前记录过的相同值。
入栈和出栈操作可以使用 pushq
和 popq
操作实现;出栈的值永远是最近入栈且仍然在栈中的值。
pushq
指令可以把一个四字数据压入栈上,操作数为压入的数据源 \(S\)。
pushq %rbp
实际的行为分为两个步骤,可以等价于两条指令的组合:
将栈指针,即
%rsp
记录的值,减去 8将数据写到新的栈顶地址
subq $8, %rsp
movq S, (%rsp)
popq
指令可以对一个四字数据进行出栈操作,操作数为数据的目的地 \(D\)。
popq %rax
实际的行为也分为两个步骤,可以等价于两条指令的组合:
将栈指针,即
%rsp
记录的值,增加 8将数据写到目的地 \(D\)
movq (%rsp), D
addq $8, %rsp
需要注意的是,原栈顶中的值并不会作清除处理,仍保留在原始位置,直到另一个新栈出现才会被覆盖。这也从底层说明了为什么栈的创建和释放速度很快!
传递控制#
控制的实现是由程序计数器 PC 实现的,即 %rip
寄存器。将控制从主调函数 Caller 转移到被调函数 Callee 只需要将 %rip
设置为 Callee 的起始位置。
但是,当 Callee 结束后,处理器如何返回 Caller 之前的位置继续执行呢?解决办法是,在创建 Callee 调用栈之前,系统会将 Caller 的返回地址(return address)压入栈中。当 Callee 调用结束后,在将返回地址弹出,设置给 %rip
寄存器,从而恢复执行。
在 x86-64 中,这一过程是由 call
和 ret
两个指令实现的。
指令 call
将随后要执行的指令(返回地址)先压入栈上,再将 %rip
设置为被调函数 Callee 的起始地址。指令 ret
从栈中弹出返回地址,并存储到 %rip
中,恢复主调函数 Caller 继续执行。
传递数据#
过程调用还会涉及到参数和返回值,这些数据传递大部分也是通过寄存器实现的。由于寄存器的数量是有限的,所以还需要一些规则来处理这些问题。
在 x86-64 平台上,前 6 个参数是由寄存器传递的;超过 6 个的参数全部放在栈上,地址由低到高,且按 8 的倍数对齐。参数就位后,才会执行 call
指令转移控制。
前 6 个参数依次存储在下表列出的寄存器中,名称由数据类型的大小决定。例如,如果第一个参数是 32 位的,那么可以通过 %edi
来访问。
如果过程调用有返回值(注意,区分上文的返回地址),该值将存储在 %rax
中。
局部存储#
大部分情况下,优先使用寄存器处理函数的局部变量等数据,可以获得更好的性能。但是有些情况下,不得不使用栈内存来存储,比如
寄存器数量不够存放所有本地数据
局部变量使用地址运算符
&
,必须为其生成一个内存地址局部变量为数组或结构体,通过索引或引用访问,也必须存储在内存上
寄存器限制#
寄存器文件是唯一被所有过程共享的资源。假设函数 \(A\) 将数据存储在寄存器 %r10
中,接着转移控制给函数 \(B\),如果此时函数 \(B\) 也要使用 %r10
,那么函数 \(A\) 的数据将被覆盖。如何解决这样的冲突?
为此,x86-64 采用了一组统一的寄存器使用管理,所有的过程都必须遵循。
寄存器
%rbx
,%rbp
和%r12~%r15
属于主调函数(caller-owned) 被调函数使用这些寄存器时,必须保存这些寄存器的值(callee-saved)。寄存器的值会被压入栈帧中,并在函数返回后恢复。压入的寄存器值在栈帧中的区域称为保存的寄存器(Saved Registers)。剩下的寄存器属于被调函数(callee-owned) 除
%rsp
外,在调用函数之前,主调函数必须保存这些寄存器的值(caller-saved)。这是因为被调函数可以任意使用这些寄存器,如果不作保存,这些寄存器的值很可能会被覆盖。