Go函数调用惯例

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

调用惯例

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

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

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

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

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

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

1.png

在参数及其之后的数据即当前函数的活动记录。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函数之前,其参数存放如下图所示

2.png

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

3.png

因此,对于默认的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函数时,其参数存放可视化出来如下所示

4.png

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

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

5.png

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

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

总结

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

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

C语言方式

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

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

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

只能支持一个返回值。

Go语言方式

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

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

编译器易于维护;

可以支持多返回值。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,313评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,369评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,916评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,333评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,425评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,481评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,491评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,268评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,719评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,004评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,179评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,832评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,510评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,153评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,402评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,045评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,071评论 2 352

推荐阅读更多精彩内容

  • 转载,详见原文:https://www.cnblogs.com/zhangjinfu/articles/11277...
    andy_shx阅读 828评论 0 0
  • 1、 go语言函数返回过程 首先需要明白go语言函数的返回过程借助defer关键字,我们了解到关键字return不...
    D_aemon阅读 822评论 0 1
  • 阅读经典——《深入理解计算机系统》04 函数调用时的栈结构变化是一个很有趣的话题,本文就来详细剖析这个过程。 栈帧...
    金戈大王阅读 23,201评论 14 36
  • 在开始函数调用约定之前我们需要先了解一下几个相关的指令 1.1 push pushq立即数# q/l是后缀,表示操...
    联旺阅读 884评论 0 0
  • 如何理解函数调用过程?本文把一个简单的C语言程序汇编成目标代码,然后用objdump目标文件反编译成的汇编代码,从...
    Ericgogo阅读 3,211评论 0 1