5

Go函数调用惯例

 3 years ago
source link: https://segmentfault.com/a/1190000040457926
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Go函数调用惯例

发布于 8 月 4 日

本文旨在探讨Go函数中的一个问题:为什么Go函数能支持多参数返回,而C/C++、java不行?这其实牵涉到了一个叫做函数调用惯例的问题。

在程序代码中,函数提供了最小功能单元,程序执行实际上就是函数间相互调用的过程。在调用时,函数调用方和被调用方必须遵守某种约定,它们的理解要一致,该约定就被称为函数调用惯例。

函数调用惯例往往由编译器来规定,本文主要关心两个点:

  • 函数的参数(入参与出参)是通过栈还是寄存器传递?
  • 如果通过栈传递,是从左至右,还是从右至左入栈?

栈是现代计算机程序里最为重要的概念之一,没有栈就没有函数,也没有局部变量。栈保存了一个函数调用所需要的维护信息,这常常被称为堆栈帧(Stack Frame)或活动记录 (Activate Record)。堆栈帧一般包括如下几方面内容:

  • 函数的返回地址和参数。
  • 临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量。
  • 保存的上下文信息:包括在函数调用前后需要保持不变的寄存器。

一个堆栈帧可以用指向栈顶的栈指针寄存器SP维护当前栈帧的基准地址的基准指针寄存器BP来表示。因此,一个典型的函数活动记录可以表示为如下

在参数及其之后的数据即当前函数的活动记录。BP固定在图中所示的位置(通过它便于索引参数与变量等),它不会随着函数的执行而变化。而SP始终指向栈顶,随着函数的执行,SP会不断变化。在BP之前是该函数的返回地址,在32位机器表示为BP+4,64位机器表示为BP+8,再往前就是压入栈中的参数。BP所直接指向的数据是调用该函数前BP的值,这样在函数返回的时候,BP可以通过读取这个值恢复到调用前的值。

汇编代码解析

下面,我们来对比分析C和Go调用惯例差异。

  1. C调用惯例

假设有main.c的C程序源文件,其中main函数调用add函数,详细代码如下。

// main.c
int add(int arg1, int arg2, int arg3, int arg4,int arg5, int arg6,int arg7, int arg8) {
    return arg1 + arg2 + arg3 + arg4 + arg5 + arg6 + arg7 + arg8;
}

int main() {
    int i = add(10, 20, 30, 40, 50, 60, 70, 80);
}

我们通过clang编译器在x86_64平台上进行编译。

$ clang -v
Apple clang version 12.0.0 (clang-1200.0.32.29)
Target: x86_64-apple-darwin19.5.0

main.c 编译后得到的汇编代码如下

 $ clang -S main.c
  ...
_main:                                
  ...
    subq    $32, %rsp      
    movl    $10, %edi    // 将参数1数据置于edi寄存器
    movl    $20, %esi    // 将参数2数据置于esi寄存器
    movl    $30, %edx    // 将参数3数据置于edx寄存器
    movl    $40, %ecx    // 将参数4数据置于ecx寄存器
    movl    $50, %r8d    // 将参数5数据置于r8d寄存器
    movl    $60, %r9d    // 将参数6数据置于r9d寄存器
    movl    $70, (%rsp)  // 将参数7数据置于栈上
    movl    $80, 8(%rsp) // 将参数8数据置于栈上
    callq    _add         // 调用add函数
    xorl    %ecx, %ecx
    movl    %eax, -4(%rbp)
    movl    %ecx, %eax  // 最终通过eax寄存器承载着返回值返回
    addq    $32, %rsp
    popq    %rbp
    retq
  ...  
_add:                                 
  ...
    movl    24(%rbp), %eax  
    movl    16(%rbp), %r10d 
    movl    %edi, -4(%rbp)  // 将edi寄存器上的数据放置于栈上
    movl    %esi, -8(%rbp)  // 将esi寄存器上的数据放置于栈上
    movl    %edx, -12(%rbp) // 将edx寄存器上的数据放置于栈上
    movl    %ecx, -16(%rbp) // 将ecx寄存器上的数据放置于栈上
    movl    %r8d, -20(%rbp) // 将r8d寄存器上的数据放置于栈上
    movl    %r9d, -24(%rbp) // 将edi寄存器上的数据放置于栈上
    movl    -4(%rbp), %ecx  // 将栈上的数据 10 放置于ecx寄存器
    addl    -8(%rbp), %ecx  // 实际为:ecx = ecx + 20
    addl    -12(%rbp), %ecx // ecx = ecx + 30
    addl    -16(%rbp), %ecx // ecx = ecx + 40
    addl    -20(%rbp), %ecx // ecx = ecx + 50 
    addl    -24(%rbp), %ecx // ecx = ecx + 60
    addl    16(%rbp), %ecx  // ecx = ecx + 70
    addl    24(%rbp), %ecx  // ecx = ecx + 80
    movl    %eax, -28(%rbp)        
    movl    %ecx, %eax      // 最终通过eax寄存器承载着返回值返回
    popq    %rbp
    retq
  ...  

因此,在main函数调用add函数之前,其参数存放如下图所示

调用add函数后的数据存放如下图所示

因此,对于默认的C语言调用惯例(cdecl调用惯例),我们可以得出以下结论

  • 当函数参数不超过六个时,其参数会按照顺序分别使用 edi、esi、edx、ecx、r8d 和 r9d 六个寄存器进行传递;
  • 当参数超过六个,那么超过的参数会使用栈传递,函数的参数会以从右到左的顺序依次入栈

C语言函数的返回值是通过寄存器传递完成的,不过根据返回值的大小,有以下三种情况。

  • 小于4字节,返回值存入eax寄存器,由函数调用方读取eax的值
  • 返回值5到8字节,采用eax和edx寄存器联合返回
  • 大于8个字节,首先在栈上额外开辟一部分空间temp,将temp对象的地址做为隐藏参数入栈。函数返回时将数据拷贝给temp对象,并将temp对象的地址用寄存器eax传出。调用方从eax指向的temp对象拷贝内容。

可以看到,由于采用了寄存器传递返回值的设计,C语言的返回值只能有一个,这里回答了C为什么不能实现函数多值返回。

  1. Go函数调用惯例

假设有main.go的Go程序源文件,和C中例子一样,其中main函数调用add函数,详细代码如下。

package main

func add(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8 int) int {
    return arg1 + arg2 + arg3 + arg4 + arg5 + arg6 + arg7 + arg8
}

func main() {
    _ = add(10, 20, 30, 40, 50, 60, 70, 80)
}

使用go tool compile -S -N -l main.go 命令编译得到如下汇编代码

"".main STEXT size=122 args=0x0 locals=0x50
        // 80代表栈帧大小为80个字节,0是入参和出参大小之和
        0x0000 00000 (main.go:7)        TEXT    "".main(SB), ABIInternal, $80-0
        ...
        0x000f 00015 (main.go:7)        SUBQ    $80, SP
        0x0013 00019 (main.go:7)        MOVQ    BP, 72(SP)
        0x0018 00024 (main.go:7)        LEAQ    72(SP), BP
        ...
        0x001d 00029 (main.go:8)        MOVQ    $10, (SP)  // 将数据填置栈上
        0x0025 00037 (main.go:8)        MOVQ    $20, 8(SP)
        0x002e 00046 (main.go:8)        MOVQ    $30, 16(SP)
        0x0037 00055 (main.go:8)        MOVQ    $40, 24(SP)
        0x0040 00064 (main.go:8)        MOVQ    $50, 32(SP)
        0x0049 00073 (main.go:8)        MOVQ    $60, 40(SP)
        0x0052 00082 (main.go:8)        MOVQ    $70, 48(SP)
        0x005b 00091 (main.go:8)        MOVQ    $80, 56(SP)
        0x0064 00100 (main.go:8)        PCDATA  $1, $0
        0x0064 00100 (main.go:8)        CALL    "".add(SB) // 调用add函数
        0x0069 00105 (main.go:9)        MOVQ    72(SP), BP
        0x006e 00110 (main.go:9)        ADDQ    $80, SP
        0x0072 00114 (main.go:9)        RET
        ...

"".add STEXT nosplit size=55 args=0x48 locals=0x0
        // add栈帧大小为0字节,72是 8个入参 + 1个出参 的字节大小之和
        0x0000 00000 (main.go:3)        TEXT    "".add(SB), NOSPLIT|ABIInternal, $0-72
        ...
        0x0000 00000 (main.go:3)        MOVQ    $0, "".~r8+72(SP)  // 初始化返回值,将其置为0
        0x0009 00009 (main.go:4)        MOVQ    "".arg1+8(SP), AX  // 开始将栈上的值放置在AX寄存器上
        0x000e 00014 (main.go:4)        ADDQ    "".arg2+16(SP), AX // AX = AX + 20
        0x0013 00019 (main.go:4)        ADDQ    "".arg3+24(SP), AX
        0x0018 00024 (main.go:4)        ADDQ    "".arg4+32(SP), AX
        0x001d 00029 (main.go:4)        ADDQ    "".arg5+40(SP), AX
        0x0022 00034 (main.go:4)        ADDQ    "".arg6+48(SP), AX
        0x0027 00039 (main.go:4)        ADDQ    "".arg7+56(SP), AX
        0x002c 00044 (main.go:4)        ADDQ    "".arg8+64(SP), AX
        0x0031 00049 (main.go:4)        MOVQ    AX, "".~r8+72(SP)  // 将结果AX填置到对应栈上位置
        0x0036 00054 (main.go:4)        RET
        ...

同样的,我们将main函数调用add函数时,其参数存放可视化出来如下所示

这里我们可以看到,add函数的入参压栈顺序和C一样,都是从右至左,即最后一个参数在靠近栈底方向的SP+56~SP+64,而第一个参数是在栈顶SP~SP+8。

调用add函数后的数据存放如下图所示

注意,这里与C中调用不同的是,由于通过栈传递参数,所以并不需要将寄存器中保存的参数再拷贝至栈上。在本例中,add帧直接调用main帧栈上的数据进行计算即可。通过将结果累加到AX寄存器上,最后再将最终的返回值置回栈中即可,返回值的位置是在最后一个入参之上。

因此我们知道,Go函数的出入参均是通过栈来传递的。所以,如果想返回多值,那么仅需要在栈上多分配一些内存即可。到这里也就回答了文章开头的问题。

在函数调用惯例中,C语言和Go语言选择了不同的实现方式。C语言同时使用了寄存器与栈传递参数,而Go语言除了在函数计算过程中会临时使用例如AX这种累加寄存器之外,全部是通过栈完成参数的传递。

任何选择都会有它的优劣所在,总体来讲,C语言实现方式更多地是考虑性能,Go语言实现方式更多地是考虑复杂度。下面,我们详细比较一下两种调用惯例。

C语言方式

CPU访问寄存器的效率会明显高于栈;

不同平台的寄存器存在差异,需要为每种架构设定对应的寄存器传递规则;

参数过多时,需要同时使用寄存器与栈传递,增加了实现复杂度,且此时函数调用性能和Go语言方式差别不再大;

只能支持一个返回值。

Go语言方式

遵循Go语言的跨平台编译理念:都是通过栈传递,因此不用担心架构不同带来的寄存器差异;

参数较少的情况下,函数调用性能会比C语言方式低;

编译器易于维护;

可以支持多返回值。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK