原文連結:面試官:來說一說Go語言的函式呼叫慣例
前言
哈嘍,大家好,我是asong
。好久沒有更新了,最近因工作需要忙著寫python
,Go
語言我都有些生疏了,但是我不能放棄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
通過上面的彙編指令我們可以分析出,引數10
、20
按照從右向左進行壓棧,所以第一個引數在棧頂的位置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夢工廠