原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch3-asm/ch3-06-func-again.html
在前面的章節(jié)中我們已經(jīng)簡(jiǎn)單討論過(guò) Go 的匯編函數(shù),但是那些主要是葉子函數(shù)。葉子函數(shù)的最大特點(diǎn)是不會(huì)調(diào)用其他函數(shù),也就是棧的大小是可以預(yù)期的,葉子函數(shù)也就是可以基本忽略爆棧的問(wèn)題(如果已經(jīng)爆了,那也是上級(jí)函數(shù)的問(wèn)題)。如果沒(méi)有爆棧問(wèn)題,那么也就是不會(huì)有棧的分裂問(wèn)題;如果沒(méi)有棧的分裂也就不需要移動(dòng)棧上的指針,也就不會(huì)有棧上指針管理的問(wèn)題。但是是現(xiàn)實(shí)中 Go 語(yǔ)言的函數(shù)是可以任意深度調(diào)用的,永遠(yuǎn)不用擔(dān)心爆棧的風(fēng)險(xiǎn)。那么這些近似黑科技的特性是如何通過(guò)低級(jí)的匯編語(yǔ)言實(shí)現(xiàn)的呢?這些都是本節(jié)嘗試討論的問(wèn)題。
在 Go 匯編語(yǔ)言中 CALL 指令用于調(diào)用函數(shù),RET 指令用于從調(diào)用函數(shù)返回。但是 CALL 和 RET 指令并沒(méi)有處理函數(shù)調(diào)用時(shí)輸入?yún)?shù)和返回值的問(wèn)題。CALL 指令類似 PUSH IP
和 JMP somefunc
兩個(gè)指令的組合,首先將當(dāng)前的 IP 指令寄存器的值壓入棧中,然后通過(guò) JMP 指令將要調(diào)用函數(shù)的地址寫入到 IP 寄存器實(shí)現(xiàn)跳轉(zhuǎn)。而 RET 指令則是和 CALL 相反的操作,基本和 POP IP
指令等價(jià),也就是將執(zhí)行
CALL 指令時(shí)保存在 SP 中的返回地址重新載入到 IP 寄存器,實(shí)現(xiàn)函數(shù)的返回。
和 C 語(yǔ)言函數(shù)不同,Go 語(yǔ)言函數(shù)的參數(shù)和返回值完全通過(guò)棧傳遞。下面是 Go 函數(shù)調(diào)用時(shí)棧的布局圖:
圖 3-13 函數(shù)調(diào)用參數(shù)布局
首先是調(diào)用函數(shù)前準(zhǔn)備的輸入?yún)?shù)和返回值空間。然后 CALL 指令將首先觸發(fā)返回地址入棧操作。在進(jìn)入到被調(diào)用函數(shù)內(nèi)之后,匯編器自動(dòng)插入了 BP 寄存器相關(guān)的指令,因此 BP 寄存器和返回地址是緊挨著的。再下面就是當(dāng)前函數(shù)的局部變量的空間,包含再次調(diào)用其它函數(shù)需要準(zhǔn)備的調(diào)用參數(shù)空間。被調(diào)用的函數(shù)執(zhí)行 RET 返回指令時(shí),先從?;謴?fù) BP 和 SP 寄存器,接著取出的返回地址跳轉(zhuǎn)到對(duì)應(yīng)的指令執(zhí)行。
Go 匯編語(yǔ)言其實(shí)是一種高級(jí)的匯編語(yǔ)言。在這里高級(jí)一詞并沒(méi)有任何褒義或貶義的色彩,而是要強(qiáng)調(diào) Go 匯編代碼和最終真實(shí)執(zhí)行的代碼并不完全等價(jià)。Go 匯編語(yǔ)言中一個(gè)指令在最終的目標(biāo)代碼中可能會(huì)被編譯為其它等價(jià)的機(jī)器指令。Go 匯編實(shí)現(xiàn)的函數(shù)或調(diào)用函數(shù)的指令在最終代碼中也會(huì)被插入額外的指令。要徹底理解 Go 匯編語(yǔ)言就需要徹底了解匯編器到底插入了哪些指令。
為了便于分析,我們先構(gòu)造一個(gè)禁止棧分裂的 printnl 函數(shù)。printnl 函數(shù)內(nèi)部都通過(guò)調(diào)用 runtime.printnl 函數(shù)輸出換行:
TEXT ·printnl_nosplit(SB), NOSPLIT, $8
CALL runtime·printnl(SB)
RET
然后通過(guò) go tool asm -S main_amd64.s
指令查看編譯后的目標(biāo)代碼:
"".printnl_nosplit STEXT nosplit size=29 args=0xffffffff80000000 locals=0x10
0x0000 00000 (main_amd64.s:5) TEXT "".printnl_nosplit(SB), NOSPLIT $16
0x0000 00000 (main_amd64.s:5) SUBQ $16, SP
0x0004 00004 (main_amd64.s:5) MOVQ BP, 8(SP)
0x0009 00009 (main_amd64.s:5) LEAQ 8(SP), BP
0x000e 00014 (main_amd64.s:6) CALL runtime.printnl(SB)
0x0013 00019 (main_amd64.s:7) MOVQ 8(SP), BP
0x0018 00024 (main_amd64.s:7) ADDQ $16, SP
0x001c 00028 (main_amd64.s:7) RET
輸出代碼中我們刪除了非指令的部分。為了便于講述,我們將上述代碼重新排版,并根據(jù)縮進(jìn)表示相關(guān)的功能:
TEXT "".printnl(SB), NOSPLIT, $16
SUBQ $16, SP
MOVQ BP, 8(SP)
LEAQ 8(SP), BP
CALL runtime.printnl(SB)
MOVQ 8(SP), BP
ADDQ $16, SP
RET
第一層是 TEXT 指令表示函數(shù)開始,到 RET 指令表示函數(shù)返回。第二層是 SUBQ $16, SP
指令為當(dāng)前函數(shù)幀分配 16 字節(jié)的空間,在函數(shù)返回前通過(guò) ADDQ $16, SP
指令回收 16 字節(jié)的棧空間。我們謹(jǐn)慎猜測(cè)在第二層是為函數(shù)多分配了 8 個(gè)字節(jié)的空間。那么為何要多分配 8 個(gè)字節(jié)的空間呢?再繼續(xù)查看第三層的指令:開始部分有兩個(gè)指令 MOVQ BP, 8(SP)
和 LEAQ 8(SP), BP
,首先是將
BP 寄存器保持到多分配的 8 字節(jié)??臻g,然后將 8(SP)
地址重新保持到了 BP 寄存器中;結(jié)束部分是 MOVQ 8(SP), BP
指令則是從棧中恢復(fù)之前備份的前 BP 寄存器的值。最里面第四次層才是我們寫的代碼,調(diào)用 runtime.printnl 函數(shù)輸出換行。
如果去掉 NOSPLIT 標(biāo)志,再重新查看生成的目標(biāo)代碼,會(huì)發(fā)現(xiàn)在函數(shù)的開頭和結(jié)尾的地方又增加了新的指令。下面是經(jīng)過(guò)縮進(jìn)格式化的結(jié)果:
TEXT "".printnl_nosplit(SB), $16
L_BEGIN:
MOVQ (TLS), CX
CMPQ SP, 16(CX)
JLS L_MORE_STK
SUBQ $16, SP
MOVQ BP, 8(SP)
LEAQ 8(SP), BP
CALL runtime.printnl(SB)
MOVQ 8(SP), BP
ADDQ $16, SP
L_MORE_STK:
CALL runtime.morestack_noctxt(SB)
JMP L_BEGIN
RET
其中開頭有三個(gè)新指令,MOVQ (TLS), CX
用于加載 g 結(jié)構(gòu)體指針,然后第二個(gè)指令 CMPQ SP, 16(CX)
SP 棧指針和 g 結(jié)構(gòu)體中 stackguard0 成員比較,如果比較的結(jié)果小于 0 則跳轉(zhuǎn)到結(jié)尾的 L_MORE_STK 部分。當(dāng)獲取到更多??臻g之后,通過(guò) JMP L_BEGIN
指令跳轉(zhuǎn)到函數(shù)的開始位置重新進(jìn)行??臻g的檢測(cè)。
g 結(jié)構(gòu)體在 $GOROOT/src/runtime/runtime2.go
文件定義,開頭的結(jié)構(gòu)成員如下:
type g struct {
// Stack parameters.
stack stack // offset known to runtime/cgo
stackguard0 uintptr // offset known to liblink
stackguard1 uintptr // offset known to liblink
...
}
第一個(gè)成員是 stack 類型,表示當(dāng)前棧的開始和結(jié)束地址。stack 的定義如下:
// Stack describes a Go execution stack.
// The bounds of the stack are exactly [lo, hi),
// with no implicit data structures on either side.
type stack struct {
lo uintptr
hi uintptr
}
在 g 結(jié)構(gòu)體中的 stackguard0 成員是出現(xiàn)爆棧前的警戒線。stackguard0 的偏移量是 16 個(gè)字節(jié),因此上述代碼中的 CMPQ SP, 16(AX)
表示將當(dāng)前的真實(shí) SP 和爆棧警戒線比較,如果超出警戒線則表示需要進(jìn)行棧擴(kuò)容,也就是跳轉(zhuǎn)到 L_MORE_STK。在 L_MORE_STK 標(biāo)號(hào)處,先調(diào)用 runtime·morestack_noctxt 進(jìn)行棧擴(kuò)容,然后又跳回到函數(shù)的開始位置,此時(shí)此刻函數(shù)的棧已經(jīng)調(diào)整了。然后再進(jìn)行一次棧大小的檢測(cè),如果依然不足則繼續(xù)擴(kuò)容,直到棧足夠大為止。
以上是棧的擴(kuò)容,但是棧的收縮是在何時(shí)處理的呢?我們知道 Go 運(yùn)行時(shí)會(huì)定期進(jìn)行垃圾回收操作,這其中包含棧的回收工作。如果棧使用到比例小于一定到閾值,則分配一個(gè)較小到??臻g,然后將棧上面到數(shù)據(jù)移動(dòng)到新的棧中,棧移動(dòng)的過(guò)程和棧擴(kuò)容的過(guò)程類似。
Go 語(yǔ)言中有個(gè) runtime.Caller 函數(shù)可以獲取當(dāng)前函數(shù)的調(diào)用者列表。我們可以非常容易在運(yùn)行時(shí)定位每個(gè)函數(shù)的調(diào)用位置,以及函數(shù)的調(diào)用鏈。因此在 panic 異?;蛴?log 輸出信息時(shí),可以精確定位代碼的位置。
比如以下代碼可以打印程序的啟動(dòng)流程:
func main() {
for skip := 0; ; skip++ {
pc, file, line, ok := runtime.Caller(skip)
if !ok {
break
}
p := runtime.FuncForPC(pc)
fnfile, fnline := p.FileLine(0)
fmt.Printf("skip = %d, pc = 0x%08X\n", skip, pc)
fmt.Printf("func: file = %s, line = L%03d, name = %s, entry = 0x%08X\n", fnfile, fnline, p.Name(), p.Entry())
fmt.Printf("call: file = %s, line = L%03d\n", file, line)
}
}
其中 runtime.Caller 先獲取當(dāng)時(shí)的 PC 寄存器值,以及文件和行號(hào)。然后根據(jù) PC 寄存器表示的指令位置,通過(guò) runtime.FuncForPC 函數(shù)獲取函數(shù)的基本信息。Go 語(yǔ)言是如何實(shí)現(xiàn)這種特性的呢?
Go 語(yǔ)言作為一門靜態(tài)編譯型語(yǔ)言,在執(zhí)行時(shí)每個(gè)函數(shù)的地址都是固定的,函數(shù)的每條指令也是固定的。如果針對(duì)每個(gè)函數(shù)和函數(shù)的每個(gè)指令生成一個(gè)地址表格(也叫 PC 表格),那么在運(yùn)行時(shí)我們就可以根據(jù) PC 寄存器的值輕松查詢到指令當(dāng)時(shí)對(duì)應(yīng)的函數(shù)和位置信息。而 Go 語(yǔ)言也是采用類似的策略,只不過(guò)地址表格經(jīng)過(guò)裁剪,舍棄了不必要的信息。因?yàn)橐谶\(yùn)行時(shí)獲取任意一個(gè)地址的位置,必然是要有一個(gè)函數(shù)調(diào)用,因此我們只需要為函數(shù)的開始和結(jié)束位置,以及每個(gè)函數(shù)調(diào)用位置生成地址表格就可以了。同時(shí)地址是有大小順序的,在排序后可以通過(guò)只記錄增量來(lái)減少數(shù)據(jù)的大小;在查詢時(shí)可以通過(guò)二分法加快查找的速度。
在匯編中有個(gè) PCDATA 用于生成 PC 表格,PCDATA 的指令用法為:PCDATA tableid, tableoffset
。PCDATA 有個(gè)兩個(gè)參數(shù),第一個(gè)參數(shù)為表格的類型,第二個(gè)是表格的地址。在目前的實(shí)現(xiàn)中,有 PCDATA_StackMapIndex 和 PCDATA_InlTreeIndex 兩種表格類型。兩種表格的數(shù)據(jù)是類似的,應(yīng)該包含了代碼所在的文件路徑、行號(hào)和函數(shù)的信息,只不過(guò) PCDATA_InlTreeIndex 用于內(nèi)聯(lián)函數(shù)的表格。
此外對(duì)于匯編函數(shù)中返回值包含指針的類型,在返回值指針被初始化之后需要執(zhí)行一個(gè) GO_RESULTS_INITIALIZED 指令:
#define GO_RESULTS_INITIALIZED PCDATA $PCDATA_StackMapIndex, $1
GO_RESULTS_INITIALIZED 記錄的也是 PC 表格的信息,表示 PC 指針越過(guò)某個(gè)地址之后返回值才完成被初始化的狀態(tài)。
Go 語(yǔ)言二進(jìn)制文件中除了有 PC 表格,還有 FUNC 表格用于記錄函數(shù)的參數(shù)、局部變量的指針信息。FUNCDATA 指令和 PCDATA 的格式類似:FUNCDATA tableid, tableoffset
,第一個(gè)參數(shù)為表格的類型,第二個(gè)是表格的地址。目前的實(shí)現(xiàn)中定義了三種 FUNC 表格類型:FUNCDATA_ArgsPointerMaps 表示函數(shù)參數(shù)的指針信息表,F(xiàn)UNCDATA_LocalsPointerMaps 表示局部指針信息表,F(xiàn)UNCDATA_InlTree
表示被內(nèi)聯(lián)展開的指針信息表。通過(guò) FUNC 表格,Go 語(yǔ)言的垃圾回收器可以跟蹤全部指針的生命周期,同時(shí)根據(jù)指針指向的地址是否在被移動(dòng)的棧范圍來(lái)確定是否要進(jìn)行指針移動(dòng)。
在前面遞歸函數(shù)的例子中,我們遇到一個(gè) NO_LOCAL_POINTERS 宏。它的定義如下:
#define FUNCDATA_ArgsPointerMaps 0 /* garbage collector blocks */
#define FUNCDATA_LocalsPointerMaps 1
#define FUNCDATA_InlTree 2
#define NO_LOCAL_POINTERS FUNCDATA $FUNCDATA_LocalsPointerMaps, runtime·no_pointers_stackmap(SB)
因此 NO_LOCAL_POINTERS 宏表示的是 FUNCDATA_LocalsPointerMaps 對(duì)應(yīng)的局部指針表格,而 runtime·no_pointers_stackmap 是一個(gè)空的指針表格,也就是表示函數(shù)沒(méi)有指針類型的局部變量。
PCDATA 和 FUNCDATA 的數(shù)據(jù)一般是由編譯器自動(dòng)生成的,手工編寫并不現(xiàn)實(shí)。如果函數(shù)已經(jīng)有 Go 語(yǔ)言聲明,那么編譯器可以自動(dòng)輸出參數(shù)和返回值的指針表格。同時(shí)所有的函數(shù)調(diào)用一般是對(duì)應(yīng) CALL 指令,編譯器也是可以輔助生成 PCDATA 表格的。編譯器唯一無(wú)法自動(dòng)生成是函數(shù)局部變量的表格,因此我們一般要在匯編函數(shù)的局部變量中謹(jǐn)慎使用指針類型。
對(duì)于 PCDATA 和 FUNCDATA 細(xì)節(jié)感興趣的同學(xué)可以嘗試從 debug/gosym 包入手,參考包的實(shí)現(xiàn)和測(cè)試代碼。
Go 語(yǔ)言中方法函數(shù)和全局函數(shù)非常相似,比如有以下的方法:
package main
type MyInt int
func (v MyInt) Twice() int {
return int(v)*2
}
func MyInt_Twice(v MyInt) int {
return int(v)*2
}
其中 MyInt 類型的 Twice 方法和 MyInt_Twice 函數(shù)的類型是完全一樣的,只不過(guò) Twice 在目標(biāo)文件中被修飾為 main.MyInt.Twice
名稱。我們可以用匯編實(shí)現(xiàn)該方法函數(shù):
// func (v MyInt) Twice() int
TEXT ·MyInt·Twice(SB), NOSPLIT, $0-16
MOVQ a+0(FP), AX // v
ADDQ AX, AX // AX *= 2
MOVQ AX, ret+8(FP) // return v
RET
不過(guò)這只是接收非指針類型的方法函數(shù)?,F(xiàn)在增加一個(gè)接收參數(shù)是指針類型的 Ptr 方法,函數(shù)返回傳入的指針:
func (p *MyInt) Ptr() *MyInt {
return p
}
在目標(biāo)文件中,Ptr 方法名被修飾為 main.(*MyInt).Ptr
,也就是對(duì)應(yīng)匯編中的 ·(*MyInt)·Ptr
。不過(guò)在 Go 匯編語(yǔ)言中,星號(hào)和小括弧都無(wú)法用作函數(shù)名字,也就是無(wú)法用匯編直接實(shí)現(xiàn)接收參數(shù)是指針類型的方法。
在最終的目標(biāo)文件中的標(biāo)識(shí)符名字中還有很多 Go 匯編語(yǔ)言不支持的特殊符號(hào)(比如 type.string."hello"
中的雙引號(hào)),這導(dǎo)致了無(wú)法通過(guò)手寫的匯編代碼實(shí)現(xiàn)全部的特性。或許是 Go 語(yǔ)言官方故意限制了匯編語(yǔ)言的特性。
遞歸函數(shù)是比較特殊的函數(shù),遞歸函數(shù)通過(guò)調(diào)用自身并且在棧上保存狀態(tài),這可以簡(jiǎn)化很多問(wèn)題的處理。Go 語(yǔ)言中遞歸函數(shù)的強(qiáng)大之處是不用擔(dān)心爆棧問(wèn)題,因?yàn)闂?梢愿鶕?jù)需要進(jìn)行擴(kuò)容和收縮。
首先通過(guò) Go 遞歸函數(shù)實(shí)現(xiàn)一個(gè) 1 到 n 的求和函數(shù):
// sum = 1+2+...+n
// sum(100) = 5050
func sum(n int) int {
if n > 0 {return n+sum(n-1) } else { return 0 }
}
然后通過(guò) if/goto 重構(gòu)上面的遞歸函數(shù),以便于轉(zhuǎn)義為匯編版本:
func sum(n int) (result int) {
var AX = n
var BX int
if n > 0 {goto L_STEP_TO_END}
goto L_END
L_STEP_TO_END:
AX -= 1
BX = sum(AX)
AX = n // 調(diào)用函數(shù)后, AX 重新恢復(fù)為 n
BX += AX
return BX
L_END:
return 0
}
在改寫之后,遞歸調(diào)用的參數(shù)需要引入局部變量,保存中間結(jié)果也需要引入局部變量。而通過(guò)棧來(lái)保存中間的調(diào)用狀態(tài)正是遞歸函數(shù)的核心。因?yàn)檩斎雲(yún)?shù)也在棧上,所以我們可以通過(guò)輸入?yún)?shù)來(lái)保存少量的狀態(tài)。同時(shí)我們模擬定義了 AX 和 BX 寄存器,寄存器在使用前需要初始化,并且在函數(shù)調(diào)用后也需要重新初始化。
下面繼續(xù)改造為匯編語(yǔ)言版本:
// func sum(n int) (result int)
TEXT ·sum(SB), NOSPLIT, $16-16
MOVQ n+0(FP), AX // n
MOVQ result+8(FP), BX // result
CMPQ AX, $0 // test n - 0
JG L_STEP_TO_END // if > 0: goto L_STEP_TO_END
JMP L_END // goto L_STEP_TO_END
L_STEP_TO_END:
SUBQ $1, AX // AX -= 1
MOVQ AX, 0(SP) // arg: n-1
CALL ·sum(SB) // call sum(n-1)
MOVQ 8(SP), BX // BX = sum(n-1)
MOVQ n+0(FP), AX // AX = n
ADDQ AX, BX // BX += AX
MOVQ BX, result+8(FP) // return BX
RET
L_END:
MOVQ $0, result+8(FP) // return 0
RET
在匯編版本函數(shù)中并沒(méi)有定義局部變量,只有用于調(diào)用自身的臨時(shí)棧空間。因?yàn)楹瘮?shù)本身的參數(shù)和返回值有 16 個(gè)字節(jié),因此棧幀的大小也為 16 字節(jié)。L_STEP_TO_END 標(biāo)號(hào)部分用于處理遞歸調(diào)用,是函數(shù)比較復(fù)雜的部分。L_END 用于處理遞歸終結(jié)的部分。
調(diào)用 sum 函數(shù)的參數(shù)在 0(SP)
位置,調(diào)用結(jié)束后的返回值在 8(SP)
位置。在函數(shù)調(diào)用之后要需要重新為需要的寄存器注入值,因?yàn)楸徽{(diào)用的函數(shù)內(nèi)部很可能會(huì)破壞了寄存器的狀態(tài)。同時(shí)調(diào)用函數(shù)的參數(shù)值也是不可信任的,輸入?yún)?shù)值也可能在被調(diào)用函數(shù)內(nèi)部被修改了。
總得來(lái)說(shuō)用匯編實(shí)現(xiàn)遞歸函數(shù)和普通函數(shù)并沒(méi)有什么區(qū)別,當(dāng)然是在沒(méi)有考慮爆棧的前提下。我們的函數(shù)應(yīng)該可以對(duì)較小的 n 進(jìn)行求和,但是當(dāng) n 大到一定程度,也就是棧達(dá)到一定的深度,必然會(huì)出現(xiàn)爆棧的問(wèn)題。爆棧是 C 語(yǔ)言的特性,不應(yīng)該在哪怕是 Go 匯編語(yǔ)言中出現(xiàn)。
Go 語(yǔ)言的編譯器在生成函數(shù)的機(jī)器代碼時(shí),會(huì)在開頭插入一小段代碼。因?yàn)?sum 函數(shù)也需要深度遞歸調(diào)用,因此我們刪除了 NOSPLIT 標(biāo)志,讓匯編器為我們自動(dòng)生成一個(gè)棧擴(kuò)容的代碼:
#include "funcdata.h"
// func sum(n int) int
TEXT ·sum(SB), $16-16
NO_LOCAL_POINTERS
// 原來(lái)的代碼
除了去掉了 NOSPLIT 標(biāo)志,我們還在函數(shù)開頭增加了一個(gè) NO_LOCAL_POINTERS 語(yǔ)句,該語(yǔ)句表示函數(shù)沒(méi)有局部指針變量。棧的擴(kuò)容必然要涉及函數(shù)參數(shù)和局部編指針的調(diào)整,如果缺少局部指針信息將導(dǎo)致擴(kuò)容工作無(wú)法進(jìn)行。不僅僅是棧的擴(kuò)容需要函數(shù)的參數(shù)和局部指針標(biāo)記表格,在 GC 進(jìn)行垃圾回收時(shí)也將需要。函數(shù)的參數(shù)和返回值的指針狀態(tài)可以通過(guò)在 Go 語(yǔ)言中的函數(shù)聲明中獲取,函數(shù)的局部變量則需要手工指定。因?yàn)槭止ぶ付ㄖ羔槺砀袷且粋€(gè)非常繁瑣的工作,因此一般要避免在手寫匯編中出現(xiàn)局部指針。
喜歡深究的讀者可能會(huì)有一個(gè)問(wèn)題:如果進(jìn)行垃圾回收或棧調(diào)整時(shí),寄存器中的指針是如何維護(hù)的?前文說(shuō)過(guò),Go 語(yǔ)言的函數(shù)調(diào)用是通過(guò)棧進(jìn)行傳遞參數(shù)的,并沒(méi)有使用寄存器傳遞參數(shù)。同時(shí)函數(shù)調(diào)用之后所有的寄存器視為失效。因此在調(diào)整和維護(hù)指針時(shí),只需要掃描內(nèi)存中的指針數(shù)據(jù),寄存器中的數(shù)據(jù)在垃圾回收器函數(shù)返回后都需要重新加載,因此寄存器是不需要掃描的。
閉包函數(shù)是最強(qiáng)大的函數(shù),因?yàn)殚]包函數(shù)可以捕獲外層局部作用域的局部變量,因此閉包函數(shù)本身就具有了狀態(tài)。從理論上來(lái)說(shuō),全局的函數(shù)也是閉包函數(shù)的子集,只不過(guò)全局函數(shù)并沒(méi)有捕獲外層變量而已。
為了理解閉包函數(shù)如何工作,我們先構(gòu)造如下的例子:
package main
func NewTwiceFunClosure(x int) func() int {
return func() int {
x *= 2
return x
}
}
func main() {
fnTwice := NewTwiceFunClosure(1)
println(fnTwice()) // 1*2 => 2
println(fnTwice()) // 2*2 => 4
println(fnTwice()) // 4*2 => 8
}
其中 NewTwiceFunClosure
函數(shù)返回一個(gè)閉包函數(shù)對(duì)象,返回的閉包函數(shù)對(duì)象捕獲了外層的 x
參數(shù)。返回的閉包函數(shù)對(duì)象在執(zhí)行時(shí),每次將捕獲的外層變量乘以 2 之后再返回。在 main
函數(shù)中,首先以 1 作為參數(shù)調(diào)用 NewTwiceFunClosure
函數(shù)構(gòu)造一個(gè)閉包函數(shù),返回的閉包函數(shù)保存在 fnTwice
閉包函數(shù)類型的變量中。然后每次調(diào)用 fnTwice
閉包函數(shù)將返回翻倍后的結(jié)果,也就是:2,4,8。
上述的代碼,從 Go 語(yǔ)言層面是非常容易理解的。但是閉包函數(shù)在匯編語(yǔ)言層面是如何工作的呢?下面我們嘗試手工構(gòu)造閉包函數(shù)來(lái)展示閉包的工作原理。首先是構(gòu)造 FunTwiceClosure
結(jié)構(gòu)體類型,用來(lái)表示閉包對(duì)象:
type FunTwiceClosure struct {
F uintptr
X int
}
func NewTwiceFunClosure(x int) func() int {
var p = &FunTwiceClosure{
F: asmFunTwiceClosureAddr(),
X: x,
}
return ptrToFunc(unsafe.Pointer(p))
}
FunTwiceClosure
結(jié)構(gòu)體包含兩個(gè)成員,第一個(gè)成員 F
表示閉包函數(shù)的函數(shù)指令的地址,第二個(gè)成員 X
表示閉包捕獲的外部變量。如果閉包函數(shù)捕獲了多個(gè)外部變量,那么 FunTwiceClosure
結(jié)構(gòu)體也要做相應(yīng)的調(diào)整。然后構(gòu)造 FunTwiceClosure
結(jié)構(gòu)體對(duì)象,其實(shí)也就是閉包函數(shù)對(duì)象。其中 asmFunTwiceClosureAddr
函數(shù)用于輔助獲取閉包函數(shù)的函數(shù)指令的地址,采用匯編語(yǔ)言實(shí)現(xiàn)。最后通過(guò) ptrToFunc
輔助函數(shù)將結(jié)構(gòu)體指針轉(zhuǎn)為閉包函數(shù)對(duì)象返回,該函數(shù)也是通過(guò)匯編語(yǔ)言實(shí)現(xiàn)。
匯編語(yǔ)言實(shí)現(xiàn)了以下三個(gè)輔助函數(shù):
func ptrToFunc(p unsafe.Pointer) func() int
func asmFunTwiceClosureAddr() uintptr
func asmFunTwiceClosureBody() int
其中 ptrToFunc
用于將指針轉(zhuǎn)化為 func() int
類型的閉包函數(shù),asmFunTwiceClosureAddr
用于返回閉包函數(shù)機(jī)器指令的開始地址(類似全局函數(shù)的地址),asmFunTwiceClosureBody
是閉包函數(shù)對(duì)應(yīng)的全局函數(shù)的實(shí)現(xiàn)。
然后用 Go 匯編語(yǔ)言實(shí)現(xiàn)以上三個(gè)輔助函數(shù):
#include "textflag.h"
TEXT ·ptrToFunc(SB), NOSPLIT, $0-16
MOVQ ptr+0(FP), AX // AX = ptr
MOVQ AX, ret+8(FP) // return AX
RET
TEXT ·asmFunTwiceClosureAddr(SB), NOSPLIT, $0-8
LEAQ ·asmFunTwiceClosureBody(SB), AX // AX = ·asmFunTwiceClosureBody(SB)
MOVQ AX, ret+0(FP) // return AX
RET
TEXT ·asmFunTwiceClosureBody(SB), NOSPLIT|NEEDCTXT, $0-8
MOVQ 8(DX), AX
ADDQ AX , AX // AX *= 2
MOVQ AX , 8(DX) // ctx.X = AX
MOVQ AX , ret+0(FP) // return AX
RET
其中 ·ptrToFunc
和 ·asmFunTwiceClosureAddr
函數(shù)的實(shí)現(xiàn)比較簡(jiǎn)單,我們不再詳細(xì)描述。最重要的是 ·asmFunTwiceClosureBody
函數(shù)的實(shí)現(xiàn):它有一個(gè) NEEDCTXT
標(biāo)志。采用 NEEDCTXT
標(biāo)志定義的匯編函數(shù)表示需要一個(gè)上下文環(huán)境,在
AMD64 環(huán)境下是通過(guò) DX
寄存器來(lái)傳遞這個(gè)上下文環(huán)境指針,也就是對(duì)應(yīng) FunTwiceClosure
結(jié)構(gòu)體的指針。函數(shù)首先從 FunTwiceClosure
結(jié)構(gòu)體對(duì)象取出之前捕獲的 X
,將 X
乘以 2 之后寫回內(nèi)存,最后返回修改之后的 X
的值。
如果是在匯編語(yǔ)言中調(diào)用閉包函數(shù),也需要遵循同樣的流程:首先為構(gòu)造閉包對(duì)象,其中保存捕獲的外層變量;在調(diào)用閉包函數(shù)時(shí)首先要拿到閉包對(duì)象,用閉包對(duì)象初始化 DX
,然后從閉包對(duì)象中取出函數(shù)地址并用通過(guò) CALL
指令調(diào)用。
![]() | ![]() |
更多建議: