Go 函數調用慣例

本文旨在探討 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 函數,詳細代碼如下。

1// main.c
2int add(int arg1, int arg2, int arg3, int arg4,int arg5, int arg6,int arg7, int arg8) {
3    return arg1 + arg2 + arg3 + arg4 + arg5 + arg6 + arg7 + arg8;
4}
5
6int main() {
7    int i = add(10, 20, 30, 40, 50, 60, 70, 80);
8}

我們通過 clang 編譯器在 x86_64 平臺上進行編譯。

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

main.c 編譯後得到的彙編代碼如下

 1 $ clang -S main.c
 2  ...
 3_main:                                
 4  ...
 5    subq    $32, %rsp      
 6    movl    $10, %edi    // 將參數1數據置於edi寄存器
 7    movl    $20, %esi    // 將參數2數據置於esi寄存器
 8    movl    $30, %edx    // 將參數3數據置於edx寄存器
 9    movl    $40, %ecx    // 將參數4數據置於ecx寄存器
10    movl    $50, %r8d    // 將參數5數據置於r8d寄存器
11    movl    $60, %r9d    // 將參數6數據置於r9d寄存器
12    movl    $70, (%rsp)  // 將參數7數據置於棧上
13    movl    $80, 8(%rsp) // 將參數8數據置於棧上
14    callq   _add         // 調用add函數
15    xorl    %ecx, %ecx
16    movl    %eax, -4(%rbp)
17    movl    %ecx, %eax  // 最終通過eax寄存器承載着返回值返回
18    addq    $32, %rsp
19    popq    %rbp
20    retq
21  ...  
22_add:                                 
23  ...
24    movl    24(%rbp), %eax  
25    movl    16(%rbp), %r10d 
26    movl    %edi, -4(%rbp)  // 將edi寄存器上的數據放置於棧上
27    movl    %esi, -8(%rbp)  // 將esi寄存器上的數據放置於棧上
28    movl    %edx, -12(%rbp) // 將edx寄存器上的數據放置於棧上
29    movl    %ecx, -16(%rbp) // 將ecx寄存器上的數據放置於棧上
30    movl    %r8d, -20(%rbp) // 將r8d寄存器上的數據放置於棧上
31    movl    %r9d, -24(%rbp) // 將edi寄存器上的數據放置於棧上
32    movl    -4(%rbp), %ecx  // 將棧上的數據 10 放置於ecx寄存器
33    addl    -8(%rbp), %ecx  // 實際爲:ecx = ecx + 20
34    addl    -12(%rbp), %ecx // ecx = ecx + 30
35    addl    -16(%rbp), %ecx // ecx = ecx + 40
36    addl    -20(%rbp), %ecx // ecx = ecx + 50 
37    addl    -24(%rbp), %ecx // ecx = ecx + 60
38    addl    16(%rbp), %ecx  // ecx = ecx + 70
39    addl    24(%rbp), %ecx  // ecx = ecx + 80
40    movl    %eax, -28(%rbp)        
41    movl    %ecx, %eax      // 最終通過eax寄存器承載着返回值返回
42    popq    %rbp
43    retq
44  ...  

因此,在 main 函數調用 add 函數之前,其參數存放如下圖所示

調用 add 函數後的數據存放如下圖所示

因此,對於默認的 C 語言調用慣例(cdecl 調用慣例),我們可以得出以下結論

C 語言函數的返回值是通過寄存器傳遞完成的,不過根據返回值的大小,有以下三種情況。

可以看到,由於採用了寄存器傳遞返回值的設計,C 語言的返回值只能有一個,這裏回答了 C 爲什麼不能實現函數多值返回。

  1. Go 函數調用慣例

假設有 main.go 的 Go 程序源文件,和 C 中例子一樣,其中 main 函數調用 add 函數,詳細代碼如下。

1package main
2
3func add(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8 int) int {
4    return arg1 + arg2 + arg3 + arg4 + arg5 + arg6 + arg7 + arg8
5}
6
7func main() {
8    _ = add(10, 20, 30, 40, 50, 60, 70, 80)
9}

使用go tool compile -S -N -l main.go 命令編譯得到如下彙編代碼

 1"".main STEXT size=122 args=0x0 locals=0x50
 2        // 80代表棧幀大小爲80個字節,0是入參和出參大小之和
 3        0x0000 00000 (main.go:7)        TEXT    "".main(SB), ABIInternal, $80-0
 4        ...
 5        0x000f 00015 (main.go:7)        SUBQ    $80, SP
 6        0x0013 00019 (main.go:7)        MOVQ    BP, 72(SP)
 7        0x0018 00024 (main.go:7)        LEAQ    72(SP), BP
 8        ...
 9        0x001d 00029 (main.go:8)        MOVQ    $10, (SP)  // 將數據填置棧上
10        0x0025 00037 (main.go:8)        MOVQ    $20, 8(SP)
11        0x002e 00046 (main.go:8)        MOVQ    $30, 16(SP)
12        0x0037 00055 (main.go:8)        MOVQ    $40, 24(SP)
13        0x0040 00064 (main.go:8)        MOVQ    $50, 32(SP)
14        0x0049 00073 (main.go:8)        MOVQ    $60, 40(SP)
15        0x0052 00082 (main.go:8)        MOVQ    $70, 48(SP)
16        0x005b 00091 (main.go:8)        MOVQ    $80, 56(SP)
17        0x0064 00100 (main.go:8)        PCDATA  $1, $0
18        0x0064 00100 (main.go:8)        CALL    "".add(SB) // 調用add函數
19        0x0069 00105 (main.go:9)        MOVQ    72(SP), BP
20        0x006e 00110 (main.go:9)        ADDQ    $80, SP
21        0x0072 00114 (main.go:9)        RET
22        ...
23
24"".add STEXT nosplit size=55 args=0x48 locals=0x0
25        // add棧幀大小爲0字節,72是 8個入參 + 1個出參 的字節大小之和
26        0x0000 00000 (main.go:3)        TEXT    "".add(SB), NOSPLIT|ABIInternal, $0-72
27        ...
28        0x0000 00000 (main.go:3)        MOVQ    $0, "".~r8+72(SP)  // 初始化返回值,將其置爲0
29        0x0009 00009 (main.go:4)        MOVQ    "".arg1+8(SP), AX  // 開始將棧上的值放置在AX寄存器上
30        0x000e 00014 (main.go:4)        ADDQ    "".arg2+16(SP), AX // AX = AX + 20
31        0x0013 00019 (main.go:4)        ADDQ    "".arg3+24(SP), AX
32        0x0018 00024 (main.go:4)        ADDQ    "".arg4+32(SP), AX
33        0x001d 00029 (main.go:4)        ADDQ    "".arg5+40(SP), AX
34        0x0022 00034 (main.go:4)        ADDQ    "".arg6+48(SP), AX
35        0x0027 00039 (main.go:4)        ADDQ    "".arg7+56(SP), AX
36        0x002c 00044 (main.go:4)        ADDQ    "".arg8+64(SP), AX
37        0x0031 00049 (main.go:4)        MOVQ    AX, "".~r8+72(SP)  // 將結果AX填置到對應棧上位置
38        0x0036 00054 (main.go:4)        RET
39        ...

同樣的,我們將 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 語言方式低;

編譯器易於維護;

可以支持多返回值。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/f2Zu-sj-i4nO0wmmOBkoUg