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 調用慣例差異。
- 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 調用慣例),我們可以得出以下結論
-
當函數參數不超過六個時,其參數會按照順序分別使用 edi、esi、edx、ecx、r8d 和 r9d 六個寄存器進行傳遞;
-
當參數超過六個,那麼超過的參數會使用棧傳遞,函數的參數會以從右到左的順序依次入棧
C 語言函數的返回值是通過寄存器傳遞完成的,不過根據返回值的大小,有以下三種情況。
-
小於 4 字節,返回值存入 eax 寄存器,由函數調用方讀取 eax 的值
-
返回值 5 到 8 字節,採用 eax 和 edx 寄存器聯合返回
-
大於 8 個字節,首先在棧上額外開闢一部分空間 temp,將 temp 對象的地址做爲隱藏參數入棧。函數返回時將數據拷貝給 temp 對象,並將 temp 對象的地址用寄存器 eax 傳出。調用方從 eax 指向的 temp 對象拷貝內容。
可以看到,由於採用了寄存器傳遞返回值的設計,C 語言的返回值只能有一個,這裏回答了 C 爲什麼不能實現函數多值返回。
- 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