面試官:來說一說Go語言的函式呼叫慣例

asong發表於2022-03-27

原文連結:面試官:來說一說Go語言的函式呼叫慣例

前言

哈嘍,大家好,我是asong。好久沒有更新了,最近因工作需要忙著寫pythonGo語言我都有些生疏了,但是我不能放棄Go語言,該學習還是要學習的,今天與大家聊一聊Go語言的函式呼叫慣例,呼叫慣例是呼叫方和被呼叫方對於引數和返回值傳遞的約定,Go語言的呼叫慣例在1.17版本進行了優化,本文我們就看一下兩個版本的呼叫慣例是什麼樣的吧~。

1.17版本前棧傳遞

Go1.17版本之前,Go語言函式呼叫是通過棧來傳遞的,我們使用Go1.12版本寫個例子來看一下:

package main

func Test(a, b int) (int, int) {
    return a + b, a - b
}

func main() {
    Test(10, 20)
}

執行go tool compile -S -N -l main.go可以看到其彙編指令,我們分兩部分來看,先看main函式部分:

"".main STEXT size=68 args=0x0 locals=0x28
        0x0000 00000 (main.go:7)        TEXT    "".main(SB), ABIInternal, $40-0
        0x0000 00000 (main.go:7)        MOVQ    (TLS), CX
        0x0009 00009 (main.go:7)        CMPQ    SP, 16(CX)
        0x000d 00013 (main.go:7)        JLS     61
        0x000f 00015 (main.go:7)        SUBQ    $40, SP // 分配40位元組棧空間
        0x0013 00019 (main.go:7)        MOVQ    BP, 32(SP) // 基址指標儲存到棧上
        0x0018 00024 (main.go:7)        LEAQ    32(SP), BP
        0x001d 00029 (main.go:7)        FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x001d 00029 (main.go:7)        FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x001d 00029 (main.go:7)        FUNCDATA        $3, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x001d 00029 (main.go:8)        PCDATA  $2, $0
        0x001d 00029 (main.go:8)        PCDATA  $0, $0
        0x001d 00029 (main.go:8)        MOVQ    $10, (SP) // 第一個引數壓棧
        0x0025 00037 (main.go:8)        MOVQ    $20, 8(SP) // 第二個引數壓棧
        0x002e 00046 (main.go:8)        CALL    "".Test(SB) // 呼叫函式Test 
        0x0033 00051 (main.go:9)        MOVQ    32(SP), BP // Test函式返回後恢復棧基址指標
        0x0038 00056 (main.go:9)        ADDQ    $40, SP // 銷燬40位元組棧記憶體
        0x003c 00060 (main.go:9)        RET // 返回
        0x003d 00061 (main.go:9)        NOP
        0x003d 00061 (main.go:7)        PCDATA  $0, $-1
        0x003d 00061 (main.go:7)        PCDATA  $2, $-1
        0x003d 00061 (main.go:7)        CALL    runtime.morestack_noctxt(SB)
        0x0042 00066 (main.go:7)        JMP     0
        0x0000 65 48 8b 0c 25 00 00 00 00 48 3b 61 10 76 2e 48  eH..%....H;a.v.H
        0x0010 83 ec 28 48 89 6c 24 20 48 8d 6c 24 20 48 c7 04  ..(H.l$ H.l$ H..
        0x0020 24 0a 00 00 00 48 c7 44 24 08 14 00 00 00 e8 00  $....H.D$.......
        0x0030 00 00 00 48 8b 6c 24 20 48 83 c4 28 c3 e8 00 00  ...H.l$ H..(....
        0x0040 00 00 eb bc                                      ....
        rel 5+4 t=16 TLS+0
        rel 47+4 t=8 "".Test+0
        rel 62+4 t=8 runtime.morestack_noctxt+0

通過上面的彙編指令我們可以分析出,引數1020按照從右向左進行壓棧,所以第一個引數在棧頂的位置SP~SP+8,第二個引數儲存在SP+8 ~ SP+16,引數準備完畢後就去呼叫TEST函式,對應的彙編指令:CALL "".Test(SB),對應的彙編指令如下:

"".Test STEXT nosplit size=49 args=0x20 locals=0x0
        0x0000 00000 (main.go:3)        TEXT    "".Test(SB), NOSPLIT|ABIInternal, $0-32
        0x0000 00000 (main.go:3)        FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (main.go:3)        FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (main.go:3)        FUNCDATA        $3, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (main.go:3)        PCDATA  $2, $0
        0x0000 00000 (main.go:3)        PCDATA  $0, $0
        0x0000 00000 (main.go:3)        MOVQ    $0, "".~r2+24(SP)// SP+16 ~ SP+24 儲存第一個返回值
        0x0009 00009 (main.go:3)        MOVQ    $0, "".~r3+32(SP)
// SP+24 ~ SP+32 儲存第二個返回值
        0x0012 00018 (main.go:4)        MOVQ    "".a+8(SP), AX // 第一個引數放入AX暫存器 AX = 10
        0x0017 00023 (main.go:4)        ADDQ    "".b+16(SP), AX // 第二個引數放入AX暫存器做加法 AX = AX + 20 = 30
        0x001c 00028 (main.go:4)        MOVQ    AX, "".~r2+24(SP)
// AX暫存器中的值在存回棧中:24(SP)
        0x0021 00033 (main.go:4)        MOVQ    "".a+8(SP), AX
// 第一個引數放入AX暫存器 AX = 10
        0x0026 00038 (main.go:4)        SUBQ    "".b+16(SP), AX
// 第二個引數放入AX暫存器做減法 AX = AX - 20 = -10
        0x002b 00043 (main.go:4)        MOVQ    AX, "".~r3+32(SP)
// AX暫存器中的值在存回棧中:32(SP)
        0x0030 00048 (main.go:4)        RET // 函式返回

通過以上的彙編指令我們可以得出結論:Go語言使用棧傳遞引數和接收返回值,多個返回值也是通過多分配一些記憶體來完成的。

這種基於棧傳遞引數和接收返回值的設計大大降低了實現的複雜度,但是犧牲了函式呼叫的效能,像C語言採用同時使用棧和暫存器傳遞引數,在效能上是優於Go語言的,下面我們就來看一看Go1.17引入的暫存器傳參。

為什麼暫存器傳參效能優於棧傳參

我們都知道CPU是一臺計算機的運算核心和控制核心,其主要功能是解釋計算機指令以及處理計算機軟體中的資料,CPU的大致內部結構如下:

圖片來自於網路

主要由運算器和控制器組成,運算器負責完成算術運算和邏輯運算,暫存器臨時儲存將要被運算器處理的資料和處理後的結果,回到主題上,暫存器是CPU內部元件,而儲存一般在外部,CPU操作暫存器與讀取記憶體的速度差距是數量級別的,當要進行資料計算時,如果資料處於記憶體中,CPU需要先將資料從記憶體拷貝到暫存器進行計算,所以對於棧傳遞引數與接收返回值這種呼叫規約,每次計算都需要從記憶體拷貝到暫存器,計算完畢在拷貝回記憶體,如果使用暫存器傳參的話,引數就已經按順序放在特定暫存器了,這樣就減少了記憶體和暫存器之間的資料拷貝,從而改善了效能,提供程式執行效率。

既然暫存器傳參效能高於棧傳遞引數,為什麼所有語言不都使用暫存器傳遞引數呢?因為不同架構上的暫存器差異不同,所以要支援暫存器傳參就要在編譯器上進行支援,這要就使編譯器變得更加複雜且不易維護,並且暫存器的數量也是有限的,還要考慮超過暫存器數量的引數應該如何傳遞。

1.17基於暫存器傳遞

Go語言在1.17版本設計了一套基於暫存器傳參的呼叫規約,目前也只支援x86平臺,我們也是通過一個簡單的例子看一下:

func Test(a, b, c, d int) (int,int,int,int) {
    return a, b, c, d
}

func main()  {
    Test(1, 2, 3 ,4)
}

執行go tool compile -S -N -l main.go可以看到其彙編指令,我們分兩部分來看,先看main函式部分:

"".main STEXT size=62 args=0x0 locals=0x28 funcid=0x0
        0x0000 00000 (main.go:7)        TEXT    "".main(SB), ABIInternal, $40-0
        0x0000 00000 (main.go:7)        CMPQ    SP, 16(R14)
        0x0004 00004 (main.go:7)        PCDATA  $0, $-2
        0x0004 00004 (main.go:7)        JLS     55
        0x0006 00006 (main.go:7)        PCDATA  $0, $-1
        0x0006 00006 (main.go:7)        SUBQ    $40, SP// 分配40位元組棧空間,基址指標儲存到棧上
        0x000a 00010 (main.go:7)        MOVQ    BP, 32(SP)// 基址指標儲存到棧上
        0x000f 00015 (main.go:7)        LEAQ    32(SP), BP
        0x0014 00020 (main.go:7)        FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0014 00020 (main.go:7)        FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0014 00020 (main.go:8)        MOVL    $1, AX // 引數1使用AX暫存器傳遞
        0x0019 00025 (main.go:8)        MOVL    $2, BX // 引數2使用BX暫存器傳遞
        0x001e 00030 (main.go:8)        MOVL    $3, CX // 引數3使用CX暫存器傳遞
        0x0023 00035 (main.go:8)        MOVL    $4, DI // 引數4使用DI暫存器傳遞
        0x0028 00040 (main.go:8)        PCDATA  $1, $0
        0x0028 00040 (main.go:8)        CALL    "".Test(SB) // 呼叫Test函式
        0x002d 00045 (main.go:9)        MOVQ    32(SP), BP // Test函式返回後恢復棧基址指標
        0x0032 00050 (main.go:9)        ADDQ    $40, SP // 銷燬40位元組棧記憶體
        0x0036 00054 (main.go:9)        RET // 返回

通過上面的彙編指令我們可以分析出,現在引數已經不是從右向左進行壓棧了,引數直接在暫存器上了,引數準備完畢後就去呼叫TEST函式,對應的彙編指令:CALL "".Test(SB),對應的彙編指令如下:

"".Test STEXT nosplit size=133 args=0x20 locals=0x28 funcid=0x0
        0x0000 00000 (main.go:3)        TEXT    "".Test(SB), NOSPLIT|ABIInternal, $40-32
        0x0000 00000 (main.go:3)        SUBQ    $40, SP
        0x0004 00004 (main.go:3)        MOVQ    BP, 32(SP)
        0x0009 00009 (main.go:3)        LEAQ    32(SP), BP
        0x000e 00014 (main.go:3)        FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x000e 00014 (main.go:3)        FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x000e 00014 (main.go:3)        FUNCDATA        $5, "".Test.arginfo1(SB)
0x000e 00014 (main.go:3)        MOVQ    AX, "".a+48(SP) // 從暫存器AX獲取引數 1 放入棧中 48(SP)
0x0013 00019 (main.go:3)        MOVQ    BX, "".b+56(SP) // 從暫存器BX獲取引數 2 放入棧中 56(SP)
0x0018 00024 (main.go:3)        MOVQ    CX, "".c+64(SP) // 從暫存器CX獲取引數 3 放入棧中 64(SP)
0x001d 00029 (main.go:3)        MOVQ    DI, "".d+72(SP) // 從暫存器DI獲取引數 4 放入棧中 72(SP)
        0x0022 00034 (main.go:3)        MOVQ    $0, "".~r4+24(SP)
        0x002b 00043 (main.go:3)        MOVQ    $0, "".~r5+16(SP)
        0x0034 00052 (main.go:3)        MOVQ    $0, "".~r6+8(SP)
        0x003d 00061 (main.go:3)        MOVQ    $0, "".~r7(SP)
        0x0045 00069 (main.go:4)        MOVQ    "".a+48(SP), DX // 以下操作是返回值放到暫存器中返回
        0x004a 00074 (main.go:4)        MOVQ    DX, "".~r4+24(SP)
        0x004f 00079 (main.go:4)        MOVQ    "".b+56(SP), DX
        0x0054 00084 (main.go:4)        MOVQ    DX, "".~r5+16(SP)
        0x0059 00089 (main.go:4)        MOVQ    "".c+64(SP), DX
        0x005e 00094 (main.go:4)        MOVQ    DX, "".~r6+8(SP)
        0x0063 00099 (main.go:4)        MOVQ    "".d+72(SP), DI
        0x0068 00104 (main.go:4)        MOVQ    DI, "".~r7(SP)
        0x006c 00108 (main.go:4)        MOVQ    "".~r4+24(SP), AX
        0x0071 00113 (main.go:4)        MOVQ    "".~r5+16(SP), BX
        0x0076 00118 (main.go:4)        MOVQ    "".~r6+8(SP), CX
        0x007b 00123 (main.go:4)        MOVQ    32(SP), BP
        0x0080 00128 (main.go:4)        ADDQ    $40, SP
        0x0084 00132 (main.go:4)        RET

傳參和返回都採用了暫存器進行傳遞,並且返回值和輸入都使用了完全相同的暫存器序列,並且使用的順序也是一致的。

因為這個優化,在一些函式呼叫巢狀層次較深的場景下,記憶體有一定概率會降低,有機會做壓測可以試一試~。

總結

熟練掌握並理解函式的呼叫過程是我們深入學習Go語言的重要一課,看完本文希望你已經熟練掌握了函式的呼叫慣例~。

好啦,本文到這裡就結束了,我是asong,我們下期見。

歡迎關注公眾號:Golang夢工廠

相關文章