Golang原始碼學習:使用gdb除錯探究Golang函式呼叫棧結構

蝦敏四把刀發表於2020-05-23

本文所使用的golang為1.14,gdb為8.1。

一直以來對於函式呼叫都僅限於函式呼叫棧這個概念上,但對於其中的詳細結構卻瞭解不多。所以用gdb除錯一個簡單的例子,一探究竟。

函式呼叫棧的結構(以下簡稱棧)

棧包含以下作用:

  • 儲存函式返回地址。
  • 儲存呼叫者的rbp。
  • 儲存區域性變數。
  • 為被呼叫函式預留返回值記憶體空間。
  • 向被呼叫函式傳遞引數。

每個函式在執行時都需要一段記憶體來儲存上述的內容,這段記憶體被稱為函式的“棧幀

一般CPU中包含兩個與棧相關的暫存器:

  • rsp:始終指向整個函式呼叫棧的棧頂
  • rbp:指向棧幀的開始位置

但儲存函式返回地址的記憶體單元的地址並不在rbp~rsp之間。而是在0x8(%rbp)的位置

棧的工作原理

棧是一種後進先出(LIFO)的結構,在Linux AMD64環境中,golang棧由高地址向低地址生長。

當發生函式呼叫時,由於呼叫者未執行完成,棧幀還要繼續使用,不可以被呼叫者覆蓋,所以要在當前棧頂外繼續為被呼叫者劃分棧幀。這個操作叫做壓棧(push),並向外移動rbp、rsp,棧空間隨之增長。

與之對應的,當被呼叫者執行完成時,其棧幀就會被收回。這個操作叫出棧(pop),並向內移動rbp、rsp,棧空間隨之縮小。呼叫者繼續執行

棧空間的生長和收縮是由編譯器生成的程式碼自動管理的的,與堆不同(手動或者gc)。

流程圖

先給出流程圖,好心裡有個數:

Golang原始碼學習:使用gdb除錯探究Golang函式呼叫棧結構

程式碼及編譯

指定 -gcflags="-N -l" 是為了關閉編譯器優化。

go build -gcflags="-N -l" -o test test.go

為了方便檢視記憶體內容,將變數都宣告為了int64。

package main

func main() {
	caller()
}

func caller() {
	var a int64 = 1
	var b int64 = 2
	callee(a, b)
}

func callee(a, b int64) (int64, int64) {
	c := a + 5
	d := b * 4
	return c, d
}

反彙編程式碼

反彙編的內容為:

  • 指令地址
  • 指令相對於當前函式起始位置以位元組為單位的偏移
  • 指令內容
gdb test

斷點打在caller方法上,因為主要的研究物件是caller與callee。

(gdb) b main.caller
Breakpoint 1 at 0x458360: file /root/study/test.go, line 7.

輸入run 執行程式。

caller函式反彙編,/s 表示將原始碼與彙編程式碼一起顯示,如不指定則只顯示彙編程式碼。

可使用step(s)按原始碼級別除錯,或者stepi(si)按彙編指令級別除錯。

下面是caller、callee的反彙編程式碼和原始碼註釋,還有與之相關的記憶體結構對照表。

(gdb) disassemble /s
Dump of assembler code for function main.caller:
7   func caller() {
=> 0x0000000000458360 <+0>:     mov    %fs:0xfffffffffffffff8,%rcx  # 將當前g的指標存入rcx
   0x0000000000458369 <+9>:     cmp    0x10(%rcx),%rsp              # 比較g.stackguard0和rsp
   0x000000000045836d <+13>:    jbe    0x4583b0 <main.caller+80>    # 如果rsp較小,表示棧有溢位風險,呼叫runtime.morestack_noctxt
   0x000000000045836f <+15>:    sub    $0x38,%rsp       # 劃分0x38位元組的棧空間
   0x0000000000458373 <+19>:    mov    %rbp,0x30(%rsp)  # 儲存呼叫者main的rbp
   0x0000000000458378 <+24>:    lea    0x30(%rsp),%rbp  # 設定此函式棧的rbp

8       var a int64 = 1
   0x000000000045837d <+29>:    movq   $0x1,0x28(%rsp)  # 區域性變數a入棧

9       var b int64 = 2
   0x0000000000458386 <+38>:    movq   $0x2,0x20(%rsp)  # 區域性變數b入棧

10      callee(a, b)
   0x000000000045838f <+47>:    mov    0x28(%rsp),%rax  # 讀取第一個引數到rax
   0x0000000000458394 <+52>:    mov    %rax,(%rsp)      # callee第一個引數入棧
   0x0000000000458398 <+56>:    movq   $0x2,0x8(%rsp)   # callee第二個引數入棧
   0x00000000004583a1 <+65>:    callq  0x4583c0 <main.callee> # 呼叫callee

11  }
   0x00000000004583a6 <+70>:    mov    0x30(%rsp),%rbp  # rbp還原為main的rbp
   0x00000000004583ab <+75>:    add    $0x38,%rsp       # rsp還原為main的rsp
   0x00000000004583af <+79>:    retq                    # 返回
<autogenerated>:
   0x00000000004583b0 <+80>:	callq  0x451b30 <runtime.morestack_noctxt>
   0x00000000004583b5 <+85>:	jmp    0x458360 <main.caller>
End of assembler dump.

callee函式反彙編

(gdb) s  # 單步除錯進入的callee函式
main.callee (a=1, b=2, ~r2=824634073176, ~r3=0) at /root/study/test.go:13
13	func callee(a, b int64) (int64, int64) {

(gdb) disassemble /s
Dump of assembler code for function main.callee:
13  func callee(a, b int64) (int64, int64) {
=> 0x00000000004583c0 <+0>:     sub    $0x18,%rsp        # 劃分0x18大小的棧
   0x00000000004583c4 <+4>:     mov    %rbp,0x10(%rsp)   # 儲存呼叫者caller的rbp
   0x00000000004583c9 <+9>:     lea    0x10(%rsp),%rbp   # 設定此函式棧的rbp
   0x00000000004583ce <+14>:    movq   $0x0,0x30(%rsp)   # 初始化第一個返回值為0
   0x00000000004583d7 <+23>:    movq   $0x0,0x38(%rsp)   # 初始化第二個返回值為0

14      c := a + 5
   0x00000000004583e0 <+32>:    mov    0x20(%rsp),%rax   # 從記憶體中獲取第一個引數值到rax
   0x00000000004583e5 <+37>:    add    $0x5,%rax         # rax+=5
   0x00000000004583e9 <+41>:    mov    %rax,0x8(%rsp)    # 區域性變數c入棧

15      d := b * 4
   0x00000000004583ee <+46>:    mov    0x28(%rsp),%rax   # 從記憶體中獲取第二個引數值到rax
   0x00000000004583f3 <+51>:    shl    $0x2,%rax         # rax*=2
   0x00000000004583f7 <+55>:    mov    %rax,(%rsp)       # 區域性變數d入棧

16      return c, d
   0x00000000004583fb <+59>:    mov    0x8(%rsp),%rax    # 區域性變數c的值儲存到rax
   0x0000000000458400 <+64>:    mov    %rax,0x30(%rsp)   # 將c賦值給第一個返回值
   0x0000000000458405 <+69>:    mov    (%rsp),%rax       # 區域性變數d的值儲存到rax
   0x0000000000458409 <+73>:    mov    %rax,0x38(%rsp)   # 將d賦值給第二個返回值

17  }
   0x000000000045840e <+78>:    mov    0x10(%rsp),%rbp   # rbp還原為caller的rbp
   0x0000000000458413 <+83>:    add    $0x18,%rsp        # rsp還原為caller的rsp
   0x0000000000458417 <+87>:    retq                     # 返回

End of assembler dump.

記憶體結構對照表

Golang原始碼學習:使用gdb除錯探究Golang函式呼叫棧結構

一些結論

  • golang通過rsp加偏移量訪問棧幀。
  • 被呼叫者的入參是位於呼叫者的棧中。
  • caller會為有返回值的callee,在棧中預留返回值記憶體空間。而callee在執行return時,會將返回值寫入caller在棧中預留的空間。
  • 意外收穫是瞭解了多值返回的實現。

相關文章