函数是如何被调用的


在编程语言中,函数是一种用于封装可重用代码的语法结构。函数可以接收从外部调用环境传入的数据,并在函数体内以复合语句的形式,使用这些数据构建独立的功能逻辑单元。借助函数,我们可以将一个程序的实现过程拆分为多个子步骤,并以结构化的方式来构建程序。这种方式可以减少程序中的重复代码,并通过抽象和替换来提高代码的整体可读性,以及可追溯性。

C 函数的调用约定

调用约定规定了函数调用时需要关注的一系列问题,比如:如何将实参传递给被调用函数、如何将返回值从被调用函数中返回、如何管理寄存器,以及如何管理栈内存,等等。调用约定并非 C 语言标准的一部分,因此实际上每个编译器都可以使用自己独有的调用约定,来实现 C 函数的调用过程。但相应地,这也会导致另外一个问题:当具有外部链接的函数在多个不同编译单元内被使用,且这些不同编译单元对应的源文件通过不同的编译器进行编译时,那么它们各自生成的对象文件可能无法再被整合在一起,并生成最终的可执行文件。

幸运的是,对于 C 语言来说,运行在 x86-64 平台上的编译器基本都会根据所在操作系统的不同,选择使用几种常见的调用约定事实标准。比如,对于 Windows 来说,编译器会采用专有的 Microsoft x64 或 Vector 调用约定。而在 Unix 和类 Unix 系统上,则会使用名为 System V AMD64 ABI(后简称 “SysV”)的调用约定。

接下来,让我们看看 SysV 调用约定中都规定了哪些重要的实现细节。为了更直观地观察这些内容,让我们先来编写一段简单的 C 代码,并在 x86-64 平台上使用默认优化等级,通过 GCC 编译生成它所对应的汇编代码。具体如下图所示:

C函数调用示例代码

实际上,在 x86-64 的机器指令中,函数调用是通过 call 指令来完成。而每一个函数体在执行完毕后,都需要再通过 ret 指令来退出函数的执行,并转移代码执行流程到之前函数调用指令的下一条指令上。你可以通过下面这张图来直观地感受这个流程。其中,箭头标注出了代码的整体执行顺序。

C语言函数调用

接下来,我们来具体看看 SysV 调用约定中都规定了函数调用时的哪些内容。

参数传递

SysV 调用约定的第一个规则是:在调用函数时,对于整型和指针类型的实参,需要分别使用寄存器 rdi、rsi、rdx、rcx、r8、r9,按函数定义时参数从左到右的顺序进行传值。而若一个函数接收的参数超过了 6 个,则余下参数将通过栈内存进行传送。此时,多出来的参数将按照从右往左(RTL)的顺序被逐个压入栈中。关于这一点,你可以通过图 (C函数调用示例代码)右侧第 30 到 40 行红框内的汇编代码得到验证。

这里,函数 foo 在调用前,分别用寄存器 edi、esi 存放局部变量 x 与 y 的值,并用寄存器 edx、ecx、r8d、r9d 存放字面量值 3、4、5、6。而多出来的另外两个字面量值参数 7 和 8 ,则直接通过 push 指令被放在了栈内存中。你需要注意这里指令操作它们的先后顺序,因为要保证这些参数以从右向左的顺序被放入栈中。另外,由于 x、y 为局部变量,因此最开始它们会被存储在栈内存中。

除此之外,对于浮点参数,编译器将会使用另外的 xmm0xmm7,共 8 个寄存器进行存储。对于更宽的值,也可能会使用 ymm 与 zmm 寄存器来替代 xmm 寄存器。而上面提到的 xmm、ymm、zmm 寄存器,都是由 x86 指令集架构中名为 AVX(Advanced Vector Extensions)的扩展指令集使用的。这些指令集一般专门用于浮点数计算以及 SIMD 相关的处理过程。

返回值传递

对于函数调用产生的返回值,SysV 调用约定也有相应的规则:当函数调用产生整数类型的返回值,且小于等于 64 位时,通过寄存器 rax 进行传递;当大于 64 位,小于等于 128 位时,则使用寄存器 rax 与 rdx 分别存储返回值的低 64 位与高 64 位。你可以参考图 (C函数调用示例代码) 右侧第 4、21、47 行蓝框内的代码,来验证这个规则。这三行代码分别处理了函数 bar、foo,以及 main 的返回值。需要注意的是,对于复合类型(比如结构体)的返回值,编译器可能会直接使用栈内存进行“中转”。

对于浮点数类型的返回值,同参数传递类似,编译器会默认使用 xmm0 与 xmm1 寄存器进行存储。而当返回值过大时,则会选择性使用 ymm 与 zmm 来替代 xmm 寄存器。

寄存器使用

SysV 调用约定对寄存器的使用也作出了规定:对于寄存器 rbx、rbp、rsp,以及 r12 到 r15,若被调用函数需要使用它们,则需要该函数在使用之前将这些寄存器中的值进行暂存,并在函数退出之前恢复它们的值(callee-saved)。而对于其他寄存器,则根据调用方的需要,自行保存和恢复它们的值(caller-saved)。

堆栈清理

每一个函数在调用结束前,都需要由它自身完成堆栈的清理工作。比如在图(C函数调用示例代码)所示的代码中,foo 函数在被调用时,它在栈内存中分配了对应的空间,用于存放局部变量 n 的值。而在该函数执行完毕,准备退出前,便需要由它自己将之前在栈上分配的数据清理干净。而这个任务是可以由 leave 指令来完成的。

除此之外,对于 foo 函数被调用前所传入实参的清理工作,则是由调用函数,也就是这里的 main 函数来完成的。可以看到,当 foo 函数调用结束,程序执行流程返回到之前 call 指令的下一条指令时,程序通过 add 指令修改了 rsp 寄存器的值。通过这种方式,main 函数对之前放入栈中传递给函数 foo 的实参进行了清理。

其他约定

除此之外,SysV 调用约定还有下面这几点规定:

  • 函数在被 call 指令调用前,需要保证栈顶于 16 字节对齐,也就是栈顶的所在地址值(以字节为单位)是 16 的倍数;
  • 从栈顶向上保留 128 字节作为 “Red Zone”;ed Zone 是位于栈顶向上(低地址方向)的一段固定长度的内存段,这块区域通常可以被函数调用栈中的“叶子”函数(即不再调用其他函数的函数)使用。这样,在需要额外的栈内存时,就能在一定条件下省去先调整栈内存大小的过程。
  • 不同于用户函数的调用过程,系统调用(System Call)函数需使用寄存器 rdi、rsi、rdx、r10、r8、r9 传递参数。

Author: Andy Zhang
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint polocy. If reproduced, please indicate source Andy Zhang !
评论
  TOC