[譯] 解析 Go 中的函式呼叫

xiaoyusilen發表於2017-04-18

讓我們來看一些簡單的 Go 的函式,然後看看我們能否明白函式呼叫是怎麼回事。我們將通過分析 Go 編譯器根據函式生成的彙編來完成這件事。對於一個小小的部落格來講,這樣的目標可能有點不切實際,但是別擔心,組合語言很簡單。哪怕是 CPU 都能讀懂。

[譯] 解析 Go 中的函式呼叫

圖片來自 Rob Baines github.com/telecoda/in…

這是我們的第一個函式。對,我們只是讓兩個數相加。

func add(a, b int) int {
        return a + b
}複製程式碼

我們編譯的時候需要關閉優化,這樣方便我們去理解生成的彙編程式碼。我們用 go build -gcflags 'N -l' 這個命令來完成上述操作。然後我們可以用 go tool objdump -s main.add func 輸出我們函式的具體細節(這裡的 func 是我們的包名,也就是我們剛剛用 go build 編譯出的可執行檔案)。

如果你之前沒有學過彙編,那麼恭喜你,你將接觸到一個全新的事物。另外我會在 Mac 上完成這篇部落格的程式碼,因此所生成的是 Intel 64-bit 彙編。

 main.go:20 0x22c0 48c744241800000000 MOVQ $0x0, 0x18(SP)
 main.go:21 0x22c9 488b442408  MOVQ 0x8(SP), AX
 main.go:21 0x22ce 488b4c2410  MOVQ 0x10(SP), CX
 main.go:21 0x22d3 4801c8   ADDQ CX, AX
 main.go:21 0x22d6 4889442418  MOVQ AX, 0x18(SP)
 main.go:21 0x22db c3   RET複製程式碼

現在我們看到了什麼?如下所示,每一行被分為了4部分:

  • 原始檔的名稱和行號(main.go:15)。這行的原始碼會被轉換為標有程式碼行號的說明。Go 的一行可能被轉換成多行程式集。
  • 目標檔案中的偏移量(例如 0x22C0)。
  • 機器碼(例如 48c744241800000000)。這是 CPU 實際執行的二進位制機器碼。我們不需要看這個,幾乎沒有人看這玩意。
  • 機器碼的彙編表示形式,這也是我們想要理解的部分。

讓我們將注意力集中在最後一部分,組合語言。

  • MOVQ,ADDQ 和 RET 是指令。它們告訴 CPU 需要執行的操作。後面的引數告訴 CPU 對什麼執行該操作。
  • SP,AX 和 CX 是 CPU 暫存器。暫存器是 CPU 用於儲存值的地方,CPU 有多個暫存器可以使用。
  • SP 是一個專用暫存器,用於儲存當前堆疊指標。堆疊是記錄區域性變數,引數和函式呼叫的暫存器。每個 goroutine 都有一個堆疊。當一個函式呼叫另一個函式,然後另一個函式再呼叫其他函式,每個函式在堆疊上獲得自己的儲存區域。在函式呼叫期間建立儲存區域,將 SP 的大小中減去所需的儲存大小。
  • 0x8(SP)是指超過 SP 指向的儲存單元的 8 個位元組的儲存單元。

因此,我們的工作的內容包含儲存單元,CPU 暫存器,用於在儲存器和暫存器之間移動值的指令以及暫存器上的操作。 這幾乎就是一個 CPU 所完成的事情了。

現在讓我們從第一條指令開始看每一條內容。別忘了我們需要從記憶體中載入兩個引數 ab,把它們相加,然後返回至呼叫函式。

  1. MOVQ $0x0, 0x18(SP) 將 0 置於儲存單元 SP+0x18 中。 這句程式碼看起來有點抽象。
  2. MOVQ 0x8(SP), AX 將儲存單元 SP+0x8 中的內容放到 CPU 暫存器 AX 中。也許這就是從記憶體中載入的我們所使用的引數之一?
  3. MOVQ 0x10(SP), CX 將儲存單元 SP+0x10 的內容置於 CPU 暫存器 CX 中。 這可能就是我們所需的另一個引數。
  4. ADDQ CX, AX 將 CX 與 AX 相加,將結果存到 AX 中。好,現在已經把兩個引數相加了。
  5. MOVQ AX, 0x18(sp) 將暫存器 AX 的內容儲存在儲存單元 SP+0x18 中。這就是在儲存相加的結果。
  6. RET 將結果返回至呼叫函式。

記住我們的函式有兩個引數 ab,它計算了 a+b 並且返回了結果。MOVQ 0x8(SP), AX 將引數 a 移到 AX 中,在 SP+0x8 的堆疊中 a 將被傳給函式。MOVQ 0x10(SP), CX 將引數 b 移到 CX 中,在 SP+0x10 的堆疊中 b 將被傳給函式。ADDQ CX, AX 使 ab 相加。MOVQ AX, 0x18(SP) 將結果儲存到 SP+0x18 中。 現在相加的結果被儲存在 SP+0x18 的堆疊中,當函式返回撥用函式時,可以從棧中讀取結果。

我假設 a 是第一個引數,b 是第二個引數。我不確定是不是這樣。我們需要花一點時間來完成這件事,但是這篇文章已經很長了。

那麼有點神祕的第一行程式碼究竟是做什麼用的?MOVQ $0X0, 0X18(SP) 將 0 儲存至 SP+0x18 中,而 SP+0x18 是我們儲存相加結果的地方。我們可以猜測,這是因為 Go 把沒有初始化的值設定為 0 ,我們已經關閉了優化,即使沒有必要,編譯器也會執行這個操作。

所以我們從中明白了什麼:

  • 好,看起來引數都存在堆疊中,第一個引數儲存在 SP+0x8 中,另一個在更高編號的地址中。
  • 並且看上去返回的結果儲存在引數後邊,一個更高編號的地址中。

現在讓我們看另一個函式。這個函式有一個區域性變數,不過我們依然會讓它看起來很簡單。

func add3(a int) int {
    b := 3
    return a + b
}複製程式碼

我們用和剛才一樣的過程來獲取程式集列表。

TEXT main.add3(SB) 
/Users/phil/go/src/github.com/philpearl/func/main.go
 main.go:15 0x2280 4883ec10  SUBQ $0x10, SP
 main.go:15 0x2284 48896c2408  MOVQ BP, 0x8(SP)
 main.go:15 0x2289 488d6c2408  LEAQ 0x8(SP), BP
 main.go:15 0x228e 48c744242000000000 MOVQ $0x0, 0x20(SP)

 main.go:16 0x2297 48c7042403000000 MOVQ $0x3, 0(SP)

 main.go:17 0x229f 488b442418  MOVQ 0x18(SP), AX
 main.go:17 0x22a4 4883c003  ADDQ $0x3, AX
 main.go:17 0x22a8 4889442420  MOVQ AX, 0x20(SP)
 main.go:17 0x22ad 488b6c2408  MOVQ 0x8(SP), BP
 main.go:17 0x22b2 4883c410  ADDQ $0x10, SP
 main.go:17 0x22b6 c3   RET複製程式碼

喔!看起來有點複雜。讓我們來試試。

前4條指令是根據原始碼中的第15行列出的。這行程式碼是這樣的:

func add3(a int) int {複製程式碼

這一行程式碼似乎沒有做什麼。所以這可能是一種宣告函式的方法。讓我們分析一下。

  • SUBQ $0x10, SP 從 SP 減去 0x10=16。這個操作為我們釋放了 16 位元組的堆疊空間
  • MOVQ BP, 0x8(SP) 將暫存器 BP 中的值儲存至 SP+8 中,然後 LEAQ 0x8(SP), BP 將地址 SP+8 中的內容載入到 BP 中。現在我們已經有空間可以儲存 BP 中之前所存的內容,然後將 BP 中的內容儲存至剛剛分配的儲存空間中,這有助於建立堆疊區域鏈(或者堆疊框架)。這有點神祕,不過在這篇文章中我們恐怕不會解決這個問題。
  • 在這一部分的最後是 MOVQ $ 0x0, 0x20 (SP),它和我們剛剛分析的最後一句類似,就是將返回值初始化為0。

下一行對應的是原始碼中的 b := 3MOVQ $03x, 0(SP) 把 3 放到 SP+0 中。這解決了我們的一個疑惑。當我們從 SP 中減去 0x10 = 16 時,我們得到了可以儲存兩個 8 位元組值的空間:我們的區域性變數 b 儲存在 SP+0 中,而 BP 之前的值儲存在 SP+0x08 中。

接下來的 6 行程式集對應於 return a + b。這需要從記憶體中載入 ab,然後將它們相加,並且返回結果。讓我們依次看看每一行。

  • MOVQ 0x18(SP), AX 將儲存在 SP+0x18 的引數 a 移動到暫存器 AX 中
  • ADDQ $0x3, AX 將 3 加到 AX(由於某些原因,它不使用我們儲存在 SP+0 的區域性變數 b,儘管編譯時優化被關閉了)
  • MOVQ AX, 0x20(SP)a+b 的結果儲存到 SP+0x20 中,也就是我們返回結果所存的地方。
  • 接下來我們得到的是 MOVQ 0x8(SP), BP 以及 ADDQ $0x10, SP,這些將恢復BP的舊值,然後將 0x10 新增到 SP,將其設定為該函式開始時的值。
  • 最後我們得到了 RET,將要返回給呼叫函式的。

所以我們從中學到了什麼呢?

  • 呼叫函式在堆疊中為返回值和引數分配空間。返回值的儲存地址比引數的儲存地址高。
  • 如果被呼叫函式有區域性變數,則通過減少堆疊指標 SP 的值為它們分配空間。它也和暫存器 BP 做了一些神祕的事情。
  • 當函式返回任何對 SP 和 BP 的操作都會相反。

讓我們看看堆疊在 add3() 方法中如何使用:

SP+0x20: the return value


SP+0x18: the parameter a


SP+0x10: ??


SP+0x08: the old value of BP

SP+0x0: the local variable b複製程式碼

如果你覺得文章中沒有提到 SP+0x10,所以不知道這是幹什麼用的。我可以告訴你,這是儲存返回地址的地方。這是為了讓 RET 指令知道返回到哪裡去。

這篇文章已經足夠了。 希望如果以前你不知道這些東西如何工作,但是現在你覺得你已經有了一些瞭解,或者如果你被彙編嚇倒了,那麼也許它不那麼晦澀難懂了。 如果你想了解有關彙編的更多資訊,請在評論中告訴我,我會考慮在之後的文章中寫出來。

既然你已經看到這兒了,如果喜歡我的這篇文章或者可以從中學到一點什麼的話,那麼請給我點個贊這樣這篇文章就可以被更多人看到了。

掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃

相關文章