從棧上理解 Go語言函數調用


轉載請聲明出處哦~,本篇文章發布於luozhiyun的博客:https://www.luozhiyun.com/archives/518

本文使用的go的源碼 1.15.7

前言

函數調用類型

這篇文章中函數調用(Function Calls)中的函數指的是 Go 中的任意可執行代碼塊。在 《Go 1.1 Function Calls》中提到了,在 Go 中有這四類函數:

  • top-level func
  • method with value receiver
  • method with pointer receiver
  • func literal

top-level func 就是我們平常寫的普通函數:

func TopLevel(x int) {}

而 method with value receiver & method with pointer receiver 指的是結構體方法的值接收者方法指針接收者方法。

結構體方法能給用戶自定義的類型添加新的行為。它和函數的區別在於方法有一個接收者,給一個函數添加一個接收者,那么它就變成了方法。接收者可以是值接收者 value receiver,也可以是指針接收者 pointer receiver

我們拿ManWoman兩個簡單的結構體舉例:

type Man struct {
}
type Woman struct {
}
 
func (*Man) Say() {
}
 
func (Woman) Say() {
}

上面的例子中:(*Man).Say()使用的是指針接收者 pointer receiver(Woman) Say()值接收者 value receiver

function literal 的定義如下:

A function literal represents an anonymous function.

也就是說包含匿名函數和閉包。

下面在分析的時候也是按照這幾種類型進行展開。

基礎知識

在 《一文教你搞懂 Go 中棧操作 https://www.luozhiyun.com/archives/513 》中講解了棧操作,但是對於棧上的函數調用來說還有很多知識點直接被忽略了,所以在這里繼續看看函數調用相關知識。

如果沒有看過上面提到這篇文章,我這邊也寫一下基礎知識,看過的同學可以跳過。

Linux_stack-1617529674577

在現代主流機器架構上(例如x86)中,棧都是向下生長的。棧的增長方向是從高位地址到地位地址向下進行增長。

我們先來看看 plan9 的匯編函數的定義:

匯編函數

我們先來看看 plan9 的匯編函數的定義:

function

stack frame size:包含局部變量以及額外調用函數的參數空間;

arguments size:包含參數以及返回值大小,例如入參是 3 個 int64 類型,返回值是 1 個 int64 類型,那么返回值就是 sizeof(int64) * 4;

棧調整

棧的調整是通過對硬件 SP 寄存器進行運算來實現的,例如:

SUBQ    $24, SP  // 對 sp 做減法,為函數分配函數棧幀 
...
ADDQ    $24, SP  // 對 sp 做加法 ,清除函數棧幀

由於棧是往下增長的,所以 SUBQ 對 SP 做減法的時候實際上是為函數分配棧幀,ADDQ 則是清除棧幀。

常見指令

加減法操作

ADDQ  AX, BX   // BX += AX
SUBQ  AX, BX   // BX -= AX

數據搬運

常數在 plan9 匯編用 $num 表示,可以為負數,默認情況下為十進制。搬運的長度是由 MOV 的后綴決定。

MOVB $1, DI      // 1 byte
MOVW $0x10, BX   // 2 bytes
MOVD $1, DX      // 4 bytes
MOVQ $-10, AX     // 8 bytes

還有一點區別是在使用 MOVQ 的時候會看到帶括號和不帶括號的區別。

// 加括號代表是指針的引用
MOVQ (AX), BX   // => BX = *AX 將AX指向的內存區域8byte賦值給BX
MOVQ 16(AX), BX // => BX = *(AX + 16)

// 不加括號是值的引用
MOVQ AX, BX     // => BX = AX 將AX中存儲的內容賦值給BX,注意區別

地址運算:

LEAQ (AX)(AX*2), CX // => CX = AX + (AX * 2) = AX * 3

上面代碼中的 2 代表 scale,scale 只能是 0、2、4、8。

函數調用分析

直接函數調用

我們這里定義一個簡單的函數:

package main

func main() {
	add(1, 2)
}

func add(a, b int) int {
	return a + b
}

然后使用命令打印出匯編:

GOOS=linux GOARCH=amd64 go tool compile -S -N -l main.go

下面我們分段來看一下匯編指令以及棧的情況。先從 main 方法的調用開始:

"".main STEXT size=71 args=0x0 locals=0x20
0x0000 00000 (main.go:3)        TEXT    "".main(SB), ABIInternal, $32-0
0x0000 00000 (main.go:3)        MOVQ    (TLS), CX
0x0009 00009 (main.go:3)        CMPQ    SP, 16(CX)   ; 棧溢出檢測
0x000d 00013 (main.go:3)        PCDATA  $0, $-2      ; GC 相關
0x000d 00013 (main.go:3)        JLS     64
0x000f 00015 (main.go:3)        PCDATA  $0, $-1      ; GC 相關
0x000f 00015 (main.go:3)        SUBQ    $32, SP      ; 分配了 32bytes 的棧地址
0x0013 00019 (main.go:3)        MOVQ    BP, 24(SP)   ; 將 BP 的值存儲到棧上
0x0018 00024 (main.go:3)        LEAQ    24(SP), BP   ; 將剛分配的棧空間 8bytes 的地址賦值給BP
0x001d 00029 (main.go:3)        FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) ; GC 相關
0x001d 00029 (main.go:3)        FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) ; GC 相關
0x001d 00029 (main.go:4)        MOVQ    $1, (SP)     ; 將給add函數的第一個參數1,寫到SP
0x0025 00037 (main.go:4)        MOVQ    $2, 8(SP)    ; 將給add函數的第二個參數2,寫到SP 
0x002e 00046 (main.go:4)        PCDATA  $1, $0
0x002e 00046 (main.go:4)        CALL    "".add(SB)   ; 調用 add 函數 
0x0033 00051 (main.go:5)        MOVQ    24(SP), BP   ; 將棧上儲存的值恢復BP
0x0038 00056 (main.go:5)        ADDQ    $32, SP      ; 增加SP的值,棧收縮,收回 32 bytes的棧空間 
0x003c 00060 (main.go:5)        RET

下面來具體看看上面的匯編做了些什么:

0x0000 00000 (main.go:3)        TEXT    "".main(SB), ABIInternal, $32-0

0x0000: 當前指令相對於當前函數的偏移量;

TEXT:由於程序代碼在運行期會放在內存的 .text 段中,所以TEXT 是一個指令,用來定義一個函數;

"".main(SB): 表示的是包名.函數名,這里省略了包名。SB是一個虛擬寄存器,保存了靜態基地址(static-base) 指針,即我們程序地址空間的開始地址;

$32-0:$32表即將分配的棧幀大小;0指定了調用方傳入的參數大小。

0x000d 00013 (main.go:3)        PCDATA  $0, $-2      ; GC 相關
0x000f 00015 (main.go:3)        PCDATA  $0, $-1      ; GC 相關

0x001d 00029 (main.go:3)        FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) ; GC 相關
0x001d 00029 (main.go:3)        FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) ; GC 相關

The FUNCDATA and PCDATA directives contain information for use by the garbage collector; they are introduced by the compiler.

FUNCDATA以及PCDATA指令包含有被垃圾回收所使用的信息;這些指令是被編譯器加入的。

0x000f 00015 (main.go:3)        SUBQ    $32, SP

在執行棧上調用的時候由於棧是從內存地址高位向低位增長的,所以會根據當前的棧幀大小調用SUBQ $32, SP表示分配 32bytes 的棧內存;

0x0013 00019 (main.go:3)        MOVQ    BP, 24(SP)   ; 將 BP 的值存儲到棧上
0x0018 00024 (main.go:3)        LEAQ    24(SP), BP   ; 將剛分配的棧空間 8bytes 的地址賦值給BP

這里會用8 個字節(24(SP)-32(SP)) 來存儲當前幀指針 BP。

0x001d 00029 (main.go:4)        MOVQ    $1, (SP)     ; 將給add函數的第一個參數1,寫到SP
0x0025 00037 (main.go:4)        MOVQ    $2, 8(SP)    ; 將給add函數的第二個參數2,寫到SP 

參數值1會被壓入到棧的(0(SP)-8(SP)) 位置;

參數值2會被壓入到棧的(8(SP)-16(SP)) 位置;

需要注意的是我們這里的參數類型是 int,在 64 位中 int 是 8byte 大小。雖然棧的增長是從高地址位到低地址位,但是棧內的數據塊的存放還是從低地址位到高地址位,指針指向的位置也是數據塊的低地址位的起始位置。

綜上在函數調用中,關於參數的傳遞我們可以知道兩個信息:

  1. 參數完全通過棧傳遞
  2. 從參數列表的右至左壓棧

下面是調用 add 函數之前的調用棧的調用詳情:

call stack

當我們准備好函數的入參之后,會調用匯編指令CALL "".add(SB),這個指令首先會將 main 的返回地址 (8 bytes) 存入棧中,然后改變當前的棧指針 SP 並執行 add 的匯編指令。

下面我們進入到 add 函數:

"".add STEXT nosplit size=25 args=0x18 locals=0x0
0x0000 00000 (main.go:7)        TEXT    "".add(SB), NOSPLIT|ABIInternal, $0-24
0x0000 00000 (main.go:7)        FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) ; GC 相關
0x0000 00000 (main.go:7)        FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) ; GC 相關
0x0000 00000 (main.go:7)        MOVQ    $0, "".~r2+24(SP)   ; 初始化返回值
0x0009 00009 (main.go:8)        MOVQ    "".a+8(SP), AX      ; AX = 1
0x000e 00014 (main.go:8)        ADDQ    "".b+16(SP), AX     ; AX = AX + 2
0x0013 00019 (main.go:8)        MOVQ    AX, "".~r2+24(SP)   ; (24)SP = AX = 3
0x0018 00024 (main.go:8)        RET 

由於會改變當前的棧指針 SP,所以在看這個函數的匯編代碼之前我們先看一下棧中的數據情況,這里我們可以實際 dlv 操作一下:

進入到 add 函數之前的時候我們可以用 regs 打印一下當前的 Rsp 和 Rbp 寄存器:

(dlv) regs 
   Rsp = 0x000000c000044760
   Rbp = 0x000000c000044778
 	 ...

(dlv)  print uintptr(0x000000c000044778)
824634001272
(dlv)  print uintptr(0x000000c000044760)
824634001248

Rsp 和 Rbp 的地址值是相差 24 bytes ,是符合我們上面圖例的。

然后進入到 add 函數之后,我們可以用 regs 打印一下當前的 Rsp 和 Rbp 寄存器:

(dlv) regs
   Rsp = 0x000000c000044758
   Rbp = 0x000000c000044778
   ...

(dlv)  print uintptr(0x000000c000044778)
824634001272
(dlv)  print uintptr(0x000000c000044758)
824634001240

Rsp 和 Rbp 的地址值是相差 32 bytes。因為在調用 CALL 指令的時候將函數的返回地址(8 字節值)推到棧頂。

那么這個時候,本來參數值1和參數值2的位置也會改變:

本來參數值1在棧的(0(SP)-8(SP)) 位置,會移動到棧的(8(SP)-16(SP)) 位置;

本來參數值2在棧的(8(SP)-16(SP)) 位置,會移動到棧的(16(SP)-24(SP)) 位置;

我們也可以通過 dlv 將參數值打印出來:

(dlv) print *(*int)(uintptr(0x000000c000044758)+8)
1
(dlv) print *(*int)(uintptr(0x000000c000044758)+16)
2

下面是調用 add 函數之后的調用棧的調用詳情:

call stack2

從上面的 add 函數調用分析我們也可以得出以下結論:

  • 返回值通過棧傳遞,返回值的棧空間在參數之前

調用完畢之后我們看一下 add 函數的返回:

0x002e 00046 (main.go:4)        CALL    "".add(SB)   ; 調用 add 函數 
0x0033 00051 (main.go:5)        MOVQ    24(SP), BP   ; 將棧上儲存的值恢復BP
0x0038 00056 (main.go:5)        ADDQ    $32, SP      ; 增加SP的值,棧收縮,收回 32 bytes的棧空間 
0x003c 00060 (main.go:5)        RET

在調用完 add 函數之后會恢復 BP 指針,然后調用 ADDQ 指令將增加SP的值,執行棧收縮。從這里可以看出最后調用方(caller)會負責棧的清理工作。

小結以下棧的調用規則:

  1. 參數完全通過棧傳遞
  2. 從參數列表的右至左壓棧
  3. 返回值通過棧傳遞,返回值的棧空間在參數之前
  4. 函數調用完畢后,調用方(caller)會負責棧的清理工作

結構體方法:值接收者與指針接收者

上面我們也講到了,Go 的方法接收者有兩種,一種是值接收者(value receiver),一種是指針接收者(pointer receiver)。下面我們通過一個例子來進行說明:

package main

func main() { 
	p := Point{2, 5} 
	p.VIncr(10)
	p.PIncr(10)
}

type Point struct {
	X int
	Y int
}

func (p Point) VIncr(factor int) {
	p.X += factor
	p.Y += factor
}

func (p *Point) PIncr(factor int) {
	p.X += factor
	p.Y += factor
} 

自己可以手動的匯編輸出結合文章一起看。

調用值接收者(value receiver)方法

在匯編中,我們的結構體在匯編層面實際上就是一段連續內存,所以p := Point{2, 5} 初始化如下:

0x001d 00029 (main.go:5)        XORPS   X0, X0                  ;; 初始化寄存器 X0
0x0020 00032 (main.go:5)        MOVUPS  X0, "".p+24(SP)         ;; 初始化大小為16bytes連續內存塊
0x0025 00037 (main.go:5)        MOVQ    $2, "".p+24(SP)         ;; 初始化結構體 p 參數 x
0x002e 00046 (main.go:5)        MOVQ    $5, "".p+32(SP)         ;; 初始化結構體 p 參數 y

我們這里的結構體 Point 參數是兩個 int 組成,int 在 64 位機器上是 8bytes,所以這里使用 XORPS 先初始化 128-bit 大小的 X0 寄存器,然后使用 MOVUPS 將 128-bit 大小的 X0 賦值給 24(SP) 申請一塊 16bytes 內存塊。然后初始化 Point 的兩個參數 2 和 5。

接下來就是初始化變量,然后調用 p.VIncr 方法:

0x0037 00055 (main.go:7)        MOVQ    $2, (SP)                ;; 初始化變量2
0x003f 00063 (main.go:7)        MOVQ    $5, 8(SP)               ;; 初始化變量5
0x0048 00072 (main.go:7)        MOVQ    $10, 16(SP)             ;; 初始化變量10
0x0051 00081 (main.go:7)        PCDATA  $1, $0
0x0051 00081 (main.go:7)        CALL    "".Point.VIncr(SB)      ;; 調用 value receiver 方法

到這里,調用前的棧幀結構大概是這樣:

call stack3

再看 p.VIncr的匯編代碼::

"".Point.VIncr STEXT nosplit size=31 args=0x18 locals=0x0
        0x0000 00000 (main.go:16)       TEXT    "".Point.VIncr(SB), NOSPLIT|ABIInternal, $0-24
        0x0000 00000 (main.go:16)       FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (main.go:16)       FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (main.go:17)       MOVQ    "".p+8(SP), AX          ;; AX = 8(SP) = 2
        0x0005 00005 (main.go:17)       ADDQ    "".factor+24(SP), AX    ;; AX = AX + 24(SP) = 2+10
        0x000a 00010 (main.go:17)       MOVQ    AX, "".p+8(SP)          ;; 8(SP) = AX = 12
        0x000f 00015 (main.go:18)       MOVQ    "".p+16(SP), AX         ;; AX = 16(SP) = 5
        0x0014 00020 (main.go:18)       ADDQ    "".factor+24(SP), AX    ;; AX = AX + 24(SP) = 5+10
        0x0019 00025 (main.go:18)       MOVQ    AX, "".p+16(SP)         ;; 16(SP) = AX  = 15
        0x001e 00030 (main.go:19)       RET 

到這里調用后的棧幀結構大概是這樣:

call stack4

從這上面的分析我們可以看到,caller 在調用 VIncr 方法的時候實際上是將值賦值到棧上給 VIncr 當作參數在調用,對於在 VIncr 中的修改實際上都是修改棧上最后兩個參數值。

調用指針接收者(pointer receiver)方法

在main里面,調用的指令是:

0x0056 00086 (main.go:8)        LEAQ    "".p+24(SP), AX         ;; 將 24(SP) 地址值賦值到 AX
0x005b 00091 (main.go:8)        MOVQ    AX, (SP)                ;; 將AX的值作為第一個參數,參數值是 2
0x005f 00095 (main.go:8)        MOVQ    $10, 8(SP)              ;; 將 10 作為第二個參數
0x0068 00104 (main.go:8)        CALL    "".(*Point).PIncr(SB)   ;; 調用 pointer receiver 方法

從上面的匯編我們知道,AX 里面實際上是存放的 24(SP) 的地址值,並且將 AX 存放的指針也賦值給了 SP 的第一個參數。也就是 AX 和 SP 的第一個參數的值都是 24(SP) 的地址值。

整個棧幀結構應該如下圖所示:

call stack5

再看 p.PIncr的匯編代碼:

"".(*Point).PIncr STEXT nosplit size=53 args=0x10 locals=0x0
        0x0000 00000 (main.go:21)       TEXT    "".(*Point).PIncr(SB), NOSPLIT|ABIInternal, $0-16
        0x0000 00000 (main.go:21)       FUNCDATA        $0, gclocals·1a65e721a2ccc325b382662e7ffee780(SB)
        0x0000 00000 (main.go:21)       FUNCDATA        $1, gclocals·69c1753bd5f81501d95132d08af04464(SB)
        0x0000 00000 (main.go:22)       MOVQ    "".p+8(SP), AX          ;; 將8(SP) 處存放地址值賦值到 AX  
        0x0005 00005 (main.go:22)       TESTB   AL, (AX)
        0x0007 00007 (main.go:22)       MOVQ    "".p+8(SP), CX          ;; 將8(SP) 處存放地址值賦值到 CX 
        0x000c 00012 (main.go:22)       TESTB   AL, (CX)
        0x000e 00014 (main.go:22)       MOVQ    (AX), AX                ;; 從 AX 里讀到內存地址,從內存地址里拿到值,再讀到AX
        0x0011 00017 (main.go:22)       ADDQ    "".factor+16(SP), AX    ;; 將參數值 10 加到 AX 里, AX = AX + 10 =12
        0x0016 00022 (main.go:22)       MOVQ    AX, (CX)                ;; 將計算結果寫入到 CX 的內存地址
        0x0019 00025 (main.go:23)       MOVQ    "".p+8(SP), AX          ;; 將 8(SP) 處的地址值賦值給 AX
        0x001e 00030 (main.go:23)       TESTB   AL, (AX)
        0x0020 00032 (main.go:23)       MOVQ    "".p+8(SP), CX          ;; 將 8(SP) 處的地址值賦值給 CX
        0x0025 00037 (main.go:23)       TESTB   AL, (CX)
        0x0027 00039 (main.go:23)       MOVQ    8(AX), AX               ;; 從 AX 里讀到內存地址值+8 ,然后從內存地址里拿到值,再讀到AX
        0x002b 00043 (main.go:23)       ADDQ    "".factor+16(SP), AX    ;; AX = 5+10
        0x0030 00048 (main.go:23)       MOVQ    AX, 8(CX)               ;; 將計算結果 15 寫入到 CX+8 的內存地址
        0x0034 00052 (main.go:24)       RET

在這個方法里面實際上還是有點意思的,並且有點繞,因為很多地方實際上都是對指針的操作,從而做到任意一方做出的修改都會影響另一方。

下面我們一步步分析:

0x0000 00000 (main.go:22)       MOVQ    "".p+8(SP), AX
0x0007 00007 (main.go:22)       MOVQ    "".p+8(SP), CX 
0x000e 00014 (main.go:22)       MOVQ    (AX), AX

這兩句指令分別是將 8(SP) 里面存放的指針賦值給了 AX 和 CX,然后從 AX內存地址里拿到值,再寫到 AX。

0x0011 00017 (main.go:22)       ADDQ    "".factor+16(SP), AX 
0x0016 00022 (main.go:22)       MOVQ    AX, (CX)

這里會將傳入的 16(SP) 參數與 AX 相加,那么這個時候 AX 存放的值應該是 12。然后將 AX 賦值給 CX 的內存地址指向的值,通過上面的匯編我們可以知道 CX 指向的是 8(SP) 存放的指針,所以這里會同時將 8(SP) 指針指向的值也修改了。

我們可以使用使用 dlv 輸出 regs 進行驗證一下:

(dlv) regs
	Rsp = 0x000000c000056748
	Rax = 0x000000000000000c
	Rcx = 0x000000c000056768

然后我們可以查看 8(SP) 和 CX 所存放的值:

(dlv) print *(*int)(uintptr(0x000000c000056748) +8  ) 
824634074984
(dlv) print uintptr(0x000000c000056768)
824634074984

可以看到它們都指向了同一個 32(SP) 的指針:

(dlv) print uintptr(0x000000c000056748) +32
824634074984

然后我們可以打印出這個指針具體指向的值:

(dlv) print *(*int)(824634074984) 
12

這個時候棧幀的情況如下所示:

call stack6

我們繼續往下:

0x0019 00025 (main.go:23)       MOVQ    "".p+8(SP), AX
0x0020 00032 (main.go:23)       MOVQ    "".p+8(SP), CX

這里會將將 8(SP) 處存放的地址值賦值給 AX 和 CX;

這里我們通過單步的 step-instruction 命令讓代碼運行到 MOVQ "".p+8(SP), CX執行行之后,然后再查看 AX 指針位置:

(dlv) disassemble
		...
        main.go:21      0x467980        488b4c2408      mov rcx, qword ptr [rsp+0x8]
=>      main.go:21      0x467985        8401            test byte ptr [rcx], al
        main.go:21      0x467987        488b4008        mov rax, qword ptr [rax+0x8]
        ...
        
(dlv) regs
    Rsp = 0x000000c000056748
    Rax = 0x000000c000056768 
    Rcx = 0x000000c000056768

(dlv) print uintptr(0x000000c000056768)
824634074984

可以看到 AX 與 CX 指向了同一個內存地址位置。然后我們進入到下面:

0x0027 00039 (main.go:23)       MOVQ    8(AX), AX

在前面也說過,對於結構體來說分配的是連續的代碼塊,在棧上 32(SP)~48(SP)都是指向變量 p 所實例化的結構體,所以在上面的打印結果中 824634074984 代表的是 變量 p.X 的值,那么 p.Y 的地址值就是 824634074984+8,我們也可以通過 dlv 打印出地址代表的值:

(dlv) print *(*int)(824634074984+8) 
5

所以MOVQ 8(AX), AX實際上就是做了將地址值加 8,然后取出結果 5 賦值到 AX 上。

0x002b 00043 (main.go:23)       ADDQ    "".factor+16(SP), AX ;; AX = AX +10
0x0030 00048 (main.go:23)       MOVQ    AX, 8(CX)

到這里其實就是計算出 AX 等於 15,然后將計算結果 15 寫入到 CX+8 的內存地址值指向的空間,也就做到了同時修改了 40(SP) 處指針指向的值。

到這個方法結束的時候,棧幀如下:

call stack7

從上面的分析我們可以看到一件有趣的事情,在進行調用指針接收者(pointer receiver)方法調用的時候,實際上是先復制了結構體的指針到棧中,然后在方法調用中全都是基於指針的操作。

小結

通過分析我們知道在調用值接收者(value receiver)方法的時候,調用者 caller 會將參數值寫入到棧上,調用函數 callee 實際上操作的是調用者 caller 棧幀上的參數值。

進行調用指針接收者(pointer receiver)方法調用的時候,和 value receiver 方法的區別是調用者 caller 寫入棧的是參數的地址值,所以調用完之后可以直接體現在 receiver 的結構體中。

字面量方法 func literal

func literal 我也不知道怎么准確翻譯,就叫字面量方法吧,在 Go 中這類方法主要包括匿名函數以及閉包。

匿名函數

我這里還是通過一個簡單的例子來進行分析:

package main

func main() {
	f := func(x int) int {
		x += x
		return x
	}
	f(100)
}

下面我們看一下它的匯編:

        0x0000 00000 (main.go:3)        TEXT    "".main(SB), ABIInternal, $32-0
        ...
        0x001d 00029 (main.go:4)        LEAQ    "".main.func1·f(SB), DX
        0x0024 00036 (main.go:4)        MOVQ    DX, "".f+16(SP)
        0x0029 00041 (main.go:8)        MOVQ    $100, (SP)
        0x0031 00049 (main.go:8)        MOVQ    "".main.func1·f(SB), AX
        0x0038 00056 (main.go:8)        PCDATA  $1, $0
        0x0038 00056 (main.go:8)        CALL    AX
        0x003a 00058 (main.go:9)        MOVQ    24(SP), BP
        0x003f 00063 (main.go:9)        ADDQ    $32, SP
        0x0043 00067 (main.go:9)        RET

通過上面的分析相信大家應該都能看懂這段匯編是在做什么了,匿名函數實際上傳遞的是匿名函數的入口地址。

閉包

什么是閉包呢?在 Wikipedia 上有這么一段話形容閉包:

a closure is a record storing a function together with an environment.

閉包是由函數和與其相關的引用環境組合而成的實體,需要打起精神的是下面的閉包分析會復雜很多。

我這里還是通過一個簡單的例子來進行分析:

package main

func test() func() {
	x := 100
	return func() {
		x += 100
	}
}

func main() {
	f := test()
	f() //x= 200
	f() //x= 300
	f() //x= 400
} 

由於閉包是有上下文的,我們以測試例子為例,每調用一次 f() 函數,變量 x 都會發生變化。但是我們通過其他的方法調用都知道,如果變量保存在棧上那么變量會隨棧幀的退出而失效,所以閉包的變量會逃逸到堆上。

我們可以進行逃逸分析進行證明:

[root@localhost gotest]$ go run -gcflags "-m -l" main.go 
# command-line-arguments
./main.go:4:2: moved to heap: x
./main.go:5:9: func literal escapes to heap

可以看到變量 x 逃逸到了堆中。

下面我們直接來看看匯編:

先來看看 main 函數:

"".main STEXT size=88 args=0x0 locals=0x18
        0x0000 00000 (main.go:10)       TEXT    "".main(SB), ABIInternal, $24-0
        0x0000 00000 (main.go:10)       MOVQ    (TLS), CX
        0x0009 00009 (main.go:10)       CMPQ    SP, 16(CX)
        0x000d 00013 (main.go:10)       PCDATA  $0, $-2
        0x000d 00013 (main.go:10)       JLS     81
        0x000f 00015 (main.go:10)       PCDATA  $0, $-1
        0x000f 00015 (main.go:10)       SUBQ    $24, SP
        0x0013 00019 (main.go:10)       MOVQ    BP, 16(SP)
        0x0018 00024 (main.go:10)       LEAQ    16(SP), BP
        0x001d 00029 (main.go:10)       FUNCDATA        $0, gclocals·69c1753bd5f81501d95132d08af04464(SB)
        0x001d 00029 (main.go:10)       FUNCDATA        $1, gclocals·9fb7f0986f647f17cb53dda1484e0f7a(SB)
        0x001d 00029 (main.go:11)       PCDATA  $1, $0
        0x001d 00029 (main.go:11)       NOP
        0x0020 00032 (main.go:11)       CALL    "".test(SB)
        ...

其實這段匯編和其他的函數調用的匯編是一樣的,沒啥好講的,在調用 test 函數之前就是做了一些棧的初始化工作。

下面直接看看 test 函數:

0x0000 00000 (main.go:3)        TEXT    "".test(SB), ABIInternal, $40-8
0x0000 00000 (main.go:3)        MOVQ    (TLS), CX
0x0009 00009 (main.go:3)        CMPQ    SP, 16(CX)
0x000d 00013 (main.go:3)        PCDATA  $0, $-2
0x000d 00013 (main.go:3)        JLS     171
0x0013 00019 (main.go:3)        PCDATA  $0, $-1
0x0013 00019 (main.go:3)        SUBQ    $40, SP
0x0017 00023 (main.go:3)        MOVQ    BP, 32(SP)
0x001c 00028 (main.go:3)        LEAQ    32(SP), BP
0x0021 00033 (main.go:3)        FUNCDATA        $0, gclocals·263043c8f03e3241528dfae4e2812ef4(SB)
0x0021 00033 (main.go:3)        FUNCDATA        $1, gclocals·568470801006e5c0dc3947ea998fe279(SB)
0x0021 00033 (main.go:3)        MOVQ    $0, "".~r0+48(SP)
0x002a 00042 (main.go:4)        LEAQ    type.int(SB), AX
0x0031 00049 (main.go:4)        MOVQ    AX, (SP)
0x0035 00053 (main.go:4)        PCDATA  $1, $0
0x0035 00053 (main.go:4)        CALL    runtime.newobject(SB)           ;; 申請內存
0x003a 00058 (main.go:4)        MOVQ    8(SP), AX                       ;; 將申請的內存地址寫到 AX 中
0x003f 00063 (main.go:4)        MOVQ    AX, "".&x+24(SP)                ;; 將內存地址寫到 24(SP) 中
0x0044 00068 (main.go:4)        MOVQ    $100, (AX)                      ;; 將100 寫到 AX 保存的內存地址指向的內存中
0x004b 00075 (main.go:5)        LEAQ    type.noalg.struct { F uintptr; "".x *int }(SB), AX ;; 創建閉包結構體,並將函數地址寫到 AX
0x0052 00082 (main.go:5)        MOVQ    AX, (SP)                        ;; 將 AX 中保存的函數地址寫到 (SP)   
0x0056 00086 (main.go:5)        PCDATA  $1, $1
0x0056 00086 (main.go:5)        CALL    runtime.newobject(SB)           ;; 申請內存
0x005b 00091 (main.go:5)        MOVQ    8(SP), AX                       ;; 將申請的內存地址寫到 AX 中
0x0060 00096 (main.go:5)        MOVQ    AX, ""..autotmp_4+16(SP)        ;; 將內存地址寫到 16(SP) 中
0x0065 00101 (main.go:5)        LEAQ    "".test.func1(SB), CX           ;; 將 test.func1 函數地址寫到 CX
0x006c 00108 (main.go:5)        MOVQ    CX, (AX)                        ;; 將 CX 中保存的函數地址寫到 AX 保存的內存地址指向的內存中
0x006f 00111 (main.go:5)        MOVQ    ""..autotmp_4+16(SP), AX        ;; 將 16(SP) 保存的內存地址寫到 AX 
0x0074 00116 (main.go:5)        TESTB   AL, (AX)
0x0076 00118 (main.go:5)        MOVQ    "".&x+24(SP), CX                ;; 將 24(SP) 保存的地址值寫到 CX
0x007b 00123 (main.go:5)        LEAQ    8(AX), DI                       ;; 將 AX + 8 寫到 DI
0x007f 00127 (main.go:5)        PCDATA  $0, $-2
0x007f 00127 (main.go:5)        CMPL    runtime.writeBarrier(SB), $0
0x0086 00134 (main.go:5)        JEQ     138
0x0088 00136 (main.go:5)        JMP     164
0x008a 00138 (main.go:5)        MOVQ    CX, 8(AX)                       ;; 將 CX 中保存的函數地址寫到 AX+8
0x008e 00142 (main.go:5)        JMP     144
0x0090 00144 (main.go:5)        PCDATA  $0, $-1
0x0090 00144 (main.go:5)        MOVQ    ""..autotmp_4+16(SP), AX
0x0095 00149 (main.go:5)        MOVQ    AX, "".~r0+48(SP)
0x009a 00154 (main.go:5)        MOVQ    32(SP), BP
0x009f 00159 (main.go:5)        ADDQ    $40, SP
0x00a3 00163 (main.go:5)        RET

下面我們一步步看這段匯編:

0x002a 00042 (main.go:4)        LEAQ    type.int(SB), AX                ;; 將 type.int 函數地址值寫到 AX
0x0031 00049 (main.go:4)        MOVQ    AX, (SP)                        ;; 將 AX 保存的函數地址值寫到 (SP)  
0x0035 00053 (main.go:4)        PCDATA  $1, $0
0x0035 00053 (main.go:4)        CALL    runtime.newobject(SB)           ;; 申請內存
0x003a 00058 (main.go:4)        MOVQ    8(SP), AX                       ;; 將申請的內存地址寫到 AX 中
0x003f 00063 (main.go:4)        MOVQ    AX, "".&x+24(SP)                ;; 將內存地址寫到 24(SP) 中
0x0044 00068 (main.go:4)        MOVQ    $100, (AX)

這一步其實就是將 type.int 函數地址值通過 AX 寫到 (SP) 的位置,然后再調用 runtime.newobject 申請一段內存塊,通過 AX 將內存地址值寫到 24(SP) 相當於給變量 x 分配內存空間,最后將 x 的值設置為 100。

這個時候棧幀結構應該是這樣:

call stack8

0x004b 00075 (main.go:5)        LEAQ    type.noalg.struct { F uintptr; "".x *int }(SB), AX

這個結構體代表了一個閉包,然后將創建好的結構體的內存地址放到了 AX 寄存器中。

0x0052 00082 (main.go:5)        MOVQ    AX, (SP)

然后這一個匯編指令會將 AX 中保存的內存地址寫入到 (SP)中。

0x0056 00086 (main.go:5)        CALL    runtime.newobject(SB)           ;; 申請內存
0x005b 00091 (main.go:5)        MOVQ    8(SP), AX                       ;; 將申請的內存地址寫到 AX 中
0x0060 00096 (main.go:5)        MOVQ    AX, ""..autotmp_4+16(SP)        ;; 將內存地址寫到 16(SP) 中

這里會重新申請一塊內存,然后將內存地址由 AX 寫入到 16(SP) 中。

0x0065 00101 (main.go:5)        LEAQ    "".test.func1(SB), CX           ;; 將 test.func1 函數地址寫到 CX
0x006c 00108 (main.go:5)        MOVQ    CX, (AX)                        ;; 將 CX 中保存的函數地址寫到 AX 保存的內存地址指向的內存中
0x006f 00111 (main.go:5)        MOVQ    ""..autotmp_4+16(SP), AX        ;; 將 16(SP) 保存的內存地址寫到 AX

這里是將 test.func1 函數地址值寫入到 CX,然后將 CX 存放的地址值寫入到 AX 保存的內存地址所指向的內存。然后還將 16(SP) 保存的地址值寫入 AX,其實這里 AX 保存的值並沒有變,不知道為啥要生成一個這樣的匯編指令。

由於 AX 內存地址是 8(SP) 寫入的, 16(SP) 的內存地址是 AX 寫入的,所以這一次性實際上修改了三個地方的值,具體的棧幀結構如下:

call stack9

0x0076 00118 (main.go:5)        MOVQ    "".&x+24(SP), CX                ;; 將 24(SP) 保存的地址值寫到 CX
0x007b 00123 (main.go:5)        LEAQ    8(AX), DI                       ;; 將 AX + 8 寫到 DI
0x007f 00127 (main.go:5)        CMPL    runtime.writeBarrier(SB), $0	;; 寫屏障
0x0086 00134 (main.go:5)        JEQ     138
0x0088 00136 (main.go:5)        JMP     164
0x008a 00138 (main.go:5)        MOVQ    CX, 8(AX)                       ;; 將 CX 中保存的地址寫到 AX+8

24(SP) 實際上保存的是 x 變量的指針地址,這里會將這個指針地址寫入到 CX 中。然后將 8(AX) 保存的值轉移到 DI 中,最后將 CX 保存的值寫入到 8(AX)。

到這里稍微再說一下 AX 此時的引用情況:

AX -> test.func1的地址值,也就是AX 此時指向的是 test.func1的地址值;

8(AX) -> 24(SP) 地址值 -> 100,也就是 8(AX) 保存的地址值指向的是 24(SP) 地址值, 24(SP) 地址值指向的內存保存的是100;

0x0090 00144 (main.go:5)        MOVQ    ""..autotmp_4+16(SP), AX        ;; 16(SP) 中保存的地址寫入 AX
0x0095 00149 (main.go:5)        MOVQ    AX, "".~r0+48(SP)               ;; 將 AX 中保存的地址寫到 48(SP)   
0x009a 00154 (main.go:5)        MOVQ    32(SP), BP
0x009f 00159 (main.go:5)        ADDQ    $40, SP

這里最后會將 16(SP) 的值借 AX 寫入到上 caller 的棧幀 48(SP) 上,最后做棧的收縮,callee 棧調用完畢。

調用完畢之后會回到 main 函數中,這個時候的棧幀如下:

call stack10

下面再回到 main 函數的 test 函數調用后的位置:

0x0020 00032 (main.go:11)       CALL    "".test(SB)
0x0025 00037 (main.go:11)       MOVQ    (SP), DX                ;; 將(SP)保存的函數地址值寫到 DX 
0x0029 00041 (main.go:11)       MOVQ    DX, "".f+8(SP)          ;; 將 DX 保存的函數地址值寫到 8(SP)   
0x002e 00046 (main.go:12)       MOVQ    (DX), AX                ;; 將 DX 保存的函數地址值寫到 AX

test 函數調用完畢之后會返回一個 test.func1 函數地址值存在棧 main 調用棧的棧頂,然后調用完 test 函數之后會將存放在 (SP) 的 test.func1 函數地址值寫入到 AX 中,然后執行調用下面的指令進行調用:

0x0031 00049 (main.go:12)       CALL    AX

在進入到 test.func1 函數之前,我們現在應該知道 (SP) 里面保存的是指向 AX 的地址值。

test.func1 函數是 test 函數里封裝返回的函數:

"".test.func1 STEXT nosplit size=36 args=0x0 locals=0x10
        0x0000 00000 (main.go:5)        TEXT    "".test.func1(SB), NOSPLIT|NEEDCTXT|ABIInternal, $16-0
        0x0000 00000 (main.go:5)        SUBQ    $16, SP
        0x0004 00004 (main.go:5)        MOVQ    BP, 8(SP)
        0x0009 00009 (main.go:5)        LEAQ    8(SP), BP
        0x000e 00014 (main.go:5)        MOVQ    8(DX), AX       ;; 這里實際上是獲取變量 x 的地址值
        0x0012 00018 (main.go:5)        MOVQ    AX, "".&x(SP)
        0x0016 00022 (main.go:6)        ADDQ    $100, (AX)		;; 將x地址指向的值加100
        0x001a 00026 (main.go:7)        MOVQ    8(SP), BP
        0x001f 00031 (main.go:7)        ADDQ    $16, SP
        0x0023 00035 (main.go:7)        RET

由於 DX 保存的就是 AX 地址值,所以通過 8(DX) 可以獲取到變量 x 的地址值寫入到 AX 中。然后調用 ADDQ 指令將x地址指向的值加100。

小結

通過上面的分析,可以發現其實匿名函數就是閉包的一種,只是沒有傳遞變量信息而已。而在閉包的調用中,會將上下文信息逃逸到堆上,避免因為棧幀調用結束而被回收。

在上面的例子閉包函數 test 的調用中,非常復雜的做了很多變量的傳遞,其實就是做了這幾件事:

  1. 為上下文信息初始化內存塊;
  2. 將上下文信息的地址值保存到 AX 寄存器中;
  3. 將閉包函數封裝好的 test.func1 調用函數地址寫入到 caller 的棧頂;

這里的上下文信息指的是 x 變量以及 test.func1 函數。將這兩個信息地址寫入到 AX 寄存器之后回到 main 函數,獲取到棧頂的函數地址寫入到 AX 執行 CALL AX 進行調用。

因為 x 變量地址是寫入到 AX + 8 的位置上,所以在調用 test.func1 函數的時候是通過獲取 AX + 8 的位置上的值從而獲取到 x 變量地址從而做到改變閉包上下文信息的目的。

總結

這篇文章中,首先和大家分享了函數調用的過程是怎樣的,包括參數的傳遞、參數壓棧的順序、函數返回值的傳遞。然后分析了結構體方法傳遞之間的區別以及閉包函數調用是怎樣的。

在分析閉包的時候的時候 dlv 工具的 regs 命令和 step-instruction 命令幫助了很多,要不然指針在寄存器之間傳遞調用很容易繞暈,建議在看的時候可以動動手在紙上畫畫。

Reference

Go 函數調用 ━ 棧和寄存器視角 https://segmentfault.com/a/1190000019753885

函數 https://chai2010.cn/advanced-go-programming-book/ch3-asm/ch3-04-func.html

https://berryjam.github.io/2018/12/golang替換運行時函數體及其原理/

Go 匯編入門 https://github.com/go-internals-cn/go-internals/blob/master/chapter1_assembly_primer/README.md

plan9 assembly 完全解析 https://github.com/cch123/golang-notes/blob/master/assembly.md

Go Assembly by Example https://davidwong.fr/goasm/

https://golang.org/doc/asm

x86-64 下函數調用及棧幀原理 https://zhuanlan.zhihu.com/p/27339191

https://www.cnblogs.com/binHome/p/13034103.html

https://chai2010.cn/advanced-go-programming-book/ch3-asm/ch3-01-basic.html

Interfaces https://github.com/teh-cmc/go-internals/blob/master/chapter2_interfaces/README.md

Go 1.1 Function Calls https://docs.google.com/document/d/1bMwCey-gmqZVTpRax-ESeVuZGmjwbocYs1iHplK-cjo/pub

What is the difference between MOV and LEA? https://stackoverflow.com/questions/1699748/what-is-the-difference-between-mov-and-lea/1699778#1699778

Function literals https://golang.org/ref/spec#Function_literals

閉包 https://hjlarry.github.io/docs/go/closure/

luozhiyun很酷


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM