轉載請宣告出處哦~,本篇文章釋出於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
。
我們拿Man
和Woman
兩個簡單的結構體舉例:
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 》中講解了棧操作,但是對於棧上的函式呼叫來說還有很多知識點直接被忽略了,所以在這裡繼續看看函式呼叫相關知識。
如果沒有看過上面提到這篇文章,我這邊也寫一下基礎知識,看過的同學可以跳過。
在現代主流機器架構上(例如x86
)中,棧都是向下生長的。棧的增長方向是從高位地址到地位地址向下進行增長。
我們先來看看 plan9 的彙編函式的定義:
彙編函式
我們先來看看 plan9 的彙編函式的定義:
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 大小。雖然棧的增長是從高地址位到低地址位,但是棧內的資料塊的存放還是從低地址位到高地址位,指標指向的位置也是資料塊的低地址位的起始位置。
綜上在函式呼叫中,關於引數的傳遞我們可以知道兩個資訊:
- 引數完全通過棧傳遞
- 從引數列表的右至左壓棧
下面是呼叫 add 函式之前的呼叫棧的呼叫詳情:
當我們準備好函式的入參之後,會調用匯編指令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 函式之後的呼叫棧的呼叫詳情:
從上面的 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)會負責棧的清理工作。
小結以下棧的呼叫規則:
- 引數完全通過棧傳遞
- 從引數列表的右至左壓棧
- 返回值通過棧傳遞,返回值的棧空間在引數之前
- 函式呼叫完畢後,呼叫方(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 方法
到這裡,呼叫前的棧幀結構大概是這樣:
再看 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
到這裡呼叫後的棧幀結構大概是這樣:
從這上面的分析我們可以看到,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) 的地址值。
整個棧幀結構應該如下圖所示:
再看 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
這個時候棧幀的情況如下所示:
我們繼續往下:
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) 處指標指向的值。
到這個方法結束的時候,棧幀如下:
從上面的分析我們可以看到一件有趣的事情,在進行呼叫指標接收者(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。
這個時候棧幀結構應該是這樣:
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 寫入的,所以這一次性實際上修改了三個地方的值,具體的棧幀結構如下:
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 函式中,這個時候的棧幀如下:
下面再回到 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 的呼叫中,非常複雜的做了很多變數的傳遞,其實就是做了這幾件事:
- 為上下文資訊初始化記憶體塊;
- 將上下文資訊的地址值儲存到 AX 暫存器中;
- 將閉包函式封裝好的 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/
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/