$ go version
go version go1.10 linux/amd644
第一章: Go 編譯入門
在開始深入研究運行時類庫和標(biāo)準(zhǔn)類庫之前古戴,學(xué)習(xí)一些go的抽象匯編語言是有必要的罩句。這份快速指引希望能助你提速财著。
目錄
- 偽匯編
- 拆解一個簡單程序
- 分析goroutines, stacks 和 splits
- 總結(jié)
- 參考文獻
- 這篇文章需要基礎(chǔ)的匯編知識
- 如果涉及到機器架構(gòu),假定都是 linux/amd64
- 我們將一直使用編譯器優(yōu)化項
- 除了特別說明扰才,引用文字或者注釋基本來自于官方文檔或者codebase
偽匯編
Go編譯器輸出一種抽象同波、簡便格式的匯編鳄梅,這種匯編并不適合任何真實的硬件。Go匯編使用這種偽匯編輸出 適用于不同的機器架構(gòu)未檩。
這種設(shè)計有很多好處戴尸,其中最主要的是go可以很容易地適應(yīng)一種新的架構(gòu)。要看更多信息冤狡,Rob Pike 的 《The Design of the Go Assembler》這本書里面有講到校赤。文末參考文獻里面也有羅列。
關(guān)于Go匯編筒溃,最需要知道的一點是它并不依賴于一個具體的機器。很多東西跟機器有映射沾乘,但是有些不是怜奖。這是因為編譯器組件在執(zhí)行過程中并不需要匯編校驗通過。而是編譯器操作是基于一堆半抽象指令集翅阵,指令選擇有一部分發(fā)生在代碼生成之后歪玲,所以當(dāng)你看到一個指令MOV 可能不是move 這個指令迁央,有可能是clear 或者load . 或者在某些機器架構(gòu)中就跟他的名字一樣的含義。一般來說 機器相關(guān)的操作傾向于跟他們展示的含義一樣滥崩, 而像跟內(nèi)存移動岖圈、子程序調(diào)用與返回等相關(guān)的指令則更抽象。這些細節(jié)隨機器架構(gòu)調(diào)整钙皮,我們?yōu)檫@種不精確道歉蜂科,到目前位置,并沒有一個很好的解決方案短条。
匯編語言是一種解析半抽象指令描述指令集 的途徑导匣,他可以將半抽象指令集轉(zhuǎn)換成輸出到鏈接器的指令。
拆解一個簡單程序
看下面這段代碼(direct_topfunc_call.go):
//go:noinline
func add(a, b int32) (int32, bool) {
return a + b, true
}
func main() {
add(10, 32)
}
(注意 //go:noinline 是編譯器指令茸时,不要省略)
把這個程序編譯成匯編:
$ GOOS=linux GOARCH=amd64 go tool compile -S direct_topfunc_call.go
0x0000 TEXT "".add(SB), NOSPLIT, $0-16
0x0000 FUNCDATA $0, gclocals·f207267fbf96a0178e8758c6e3e0ce28(SB)
0x0000 FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 MOVL "".b+12(SP), AX
0x0004 MOVL "".a+8(SP), CX
0x0008 ADDL CX, AX
0x000a MOVL AX, "".~r2+16(SP)
0x000e MOVB $1, "".~r3+20(SP)
0x0013 RET
0x0000 TEXT "".main(SB), $24-0
;; ...省略stack-split 相關(guān)的開始部分...
0x000f SUBQ $24, SP
0x0013 MOVQ BP, 16(SP)
0x0018 LEAQ 16(SP), BP
0x001d FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x001d FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x001d MOVQ $137438953482, AX
0x0027 MOVQ AX, (SP)
0x002b PCDATA $0, $0
0x002b CALL "".add(SB)
0x0030 MOVQ 16(SP), BP
0x0035 ADDQ $24, SP
0x0039 RET
;; ...省略stack-split 相關(guān)的結(jié)尾部分...
為了更好地了解匯編器做了什么贡定,我們將逐行分析這兩個方法。
分析 add
0x0000 TEXT "".add(SB), NOSPLIT, $0-16
- 0x0000: 當(dāng)前指令的偏移可都,程序的開始
- TEXT "".add: TEXT 表明 字符 "".add 符號作為.text端(程序段) 的一部分缓待,指示 之后的指令是function的一部分。
- (SB): SB 是一個存儲“靜態(tài)基地址”指針的虛擬寄存器渠牲,即存儲我們程序空間的開始地址
- "".add(SB) 標(biāo)識我們的字符位于距離程序空間開始處固定偏移量的位置旋炒。換言之,它有一個絕對地址:這是個全局函數(shù)變量嘱兼。objdump將會驗證這一切国葬。
$ objdump -j .text -t direct_topfunc_call | grep 'main.add'
000000000044d980 g F .text 000000000000000f main.add
所有用戶定義變量都是以為寄存器FP(參數(shù)和本地變量)和SB(全局變量)為基準(zhǔn),找到對應(yīng)的偏移量而被寫入芹壕。SB偽寄存器被認(rèn)為是內(nèi)存的起始地址汇四,所以foo(SB) 就是foo在內(nèi)存中的地址。
-
NOSPLIT: 告知編譯器不要將其插入stack-split的前導(dǎo)指令踢涌, 這用來檢查當(dāng)前的棧是否需要擴大通孽。
在我們這個例子中, 編譯器自己設(shè)置了一些flag:很容易就能看出來睁壁,因為add方法沒有本地變量也沒有自己的棧幀背苦,它不會超過當(dāng)前的棧;因此在每次函數(shù)調(diào)用時踐行檢查是純粹浪費CPU指令周期潘明。
"NOSPLIT": 在棧必須分割時去檢查不要插入到前導(dǎo)指令行剂。 協(xié)程的棧幀,包括協(xié)程所調(diào)用的那些钳降,必須位于堆棧段中一段空閑空間的頂部厚宰。用來保護類似用來棧分配代碼的協(xié)程,
本章文末,我們會有一個關(guān)于go 協(xié)程跟棧分配的簡單介紹铲觉。
- $0-16: $0 標(biāo)識將要在占空間分配的字節(jié)數(shù)澈蝙;而$16 表示傳參所占的空間
通常,幀大小后面緊跟著參數(shù)大小撵幽,被“-”分割(這并不是一個減號灯荧,而是一個語法約定)。幀大小 $24-8 標(biāo)識這個方法有一個24字節(jié)的幀空間盐杂,如果要調(diào)用它需要8字節(jié)的參數(shù)大小逗载,這參數(shù)大小分配在調(diào)用方的幀空間中。
0x0000 FUNCDATA $0, gclocals·f207267fbf96a0178e8758c6e3e0ce28(SB)
0x0000 FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
FUNCDATA 和PCDATA 表示了垃圾回收所需要的信息况褪;編譯器用這些信息撕贞。
關(guān)于這部分現(xiàn)在不要關(guān)注;當(dāng)討論垃圾回收的時候测垛,我們將回過頭來了解這部分捏膨。
0x0000 MOVL "".b+12(SP), AX
0x0004 MOVL "".a+8(SP), CX
Go調(diào)用過程 規(guī)定,所有的參數(shù)必須加載到棧中食侮,使用調(diào)用者預(yù)分配的椇叛模空間。
調(diào)用方需要適當(dāng)?shù)財U大或者縮小棧大小锯七,以保證參數(shù)可以傳遞到被調(diào)用方法链快,以及接受來自被調(diào)用者的返回。
Go編譯器并沒有產(chǎn)生PUSH/POP家族的指令眉尸,而是通過增大或者縮小硬件棧指針SP來進行棧的縮小或者擴大的域蜗。
【更新: 在issue #21: about SP register中我們建一個討論了這個問題≡牖】
SP偽寄存器是一個用于指示本地變量和傳參的偽寄存器霉祸。它指向本地棧的頂部,所以引用應(yīng)該使用范圍在 [?framesize, 0) 的負(fù)偏移量袱蜡,比如x-8(SP), y-4(SP) 等等丝蹭。
雖然官方文檔生成“所有的用戶定義的變量都是以偽寄存器FP為基礎(chǔ)進行偏移(參數(shù)和本地變量)”,對手寫代碼適合坪蚁。
像很多現(xiàn)代編譯器奔穿,Go 工具集經(jīng)常采用以代碼生成地址為基準(zhǔn)的偏移量,進行引用參數(shù)和本地變量敏晤。這使得平臺可以用很少的指令 將幀指針(frame-pointer) 用作額外的一般用途寄存器(比如像x86)贱田。
如果感興趣,你可以看下在文末文章中介紹的x86-64 棧幀布局嘴脾。
【更新:在issue #2: Frame pointer中我們討論了這個問題】
"".b+12(SP) 和 "".a+8(SP)分別指向棧的低12字節(jié)和低8字節(jié)的地址男摧。
.a 和 .b 就是一個隨意指定的別名;雖然他們沒有具體的文法含義,但是當(dāng)使用虛擬存儲器的相對地址時彩倚,他們是必須的。虛擬frame-pointer 的文中提到了這一點:
FP 偽寄存器是一個虛擬指針扶平,用來指示方法的參數(shù)帆离。編譯器維護一份虛擬指針,指向棧中的參數(shù)结澄,這些參數(shù)的地址是從偽指針之后的偏移量哥谷。因此0(FP)是方法的第一個參數(shù), 8(FP)在第二個等等(在64位機器上)麻献。如果以這種方法指示方法的參數(shù)们妥,需要在開始處放置一個名字,比如 first_arg+0(FP) 和 second_arg+8(FP).(偏移量的意思是從frame pointer開始的偏移量勉吻,到SB的距離监婶,這就是一個變量的偏移量)。編譯器遵循這種約定齿桃,拒絕類似0(FP)和 8(FP). 具體的名字是預(yù)發(fā)無關(guān)的惑惶,但是需要被用來只是變量的名字。
最后短纵,有兩點需要注意:
- 第一個參數(shù)a并不是位于0(SP)带污, 而是位于8(SP);這是因為調(diào)用方通過CALL這個偽指令使用了0(SP)這個地址香到。
- 參數(shù)是以一個相反的順序傳遞鱼冀,比如第一個參數(shù)里棧頂更近。
0x0008 ADDL CX, AX
0x000a MOVL AX, "".~r2+16(SP)
0x000e MOVB $1, "".~r3+20(SP)
ADDL 執(zhí)行了兩個長變量(占了4字節(jié)的值)的相加操作悠就,并且將結(jié)果存儲在AX中千绪。
這個結(jié)果被移到 "".~r2+16(SP), 這個地址是調(diào)用者在棧空間中預(yù)占的空間理卑,并且期待在這兒找到它的值翘紊。 再強調(diào)一下, "".~r2 并沒有具體的文法含義藐唠。
為了更好地延時go語言是如何處理多返回值的情況帆疟,我們也返回了一個固定的布爾類型的true. 機制跟返回第一個值是一樣的,只是距離SP的偏移量變了宇立。
0x0013 RET
最后的偽指令 RET 告訴Go編譯器插入一些指令踪宠,這些指令依托于特定的平臺,以便從子協(xié)程調(diào)用中返回妈嘹。
像這樣的操作會將存儲在0(SP)中的返回地址彈出柳琢,并且跳回到這個地址。
一個TEXT塊的最后一條指令必須是jump指令的一種,通常是一個RET(偽)指令柬脸。(如果不是他去,鏈接器會自己加入一個”跳回自己”的指令;TEXTs中沒有fallthrough)
這兒需要一次性學(xué)習(xí)好多句法和語言倒堕,這兒有一個快速的總結(jié):
聲明一個全局方法 "".add (實際鏈接是 main.add )
;; 不要插入stack-split 序列
;; 0 字節(jié) 椩植猓空間,16字節(jié)傳入?yún)?shù)
;; func add(a, b int32) (int32, bool)
0x0000 TEXT "".add(SB), NOSPLIT, $0-16
;; ...省略 FUNCDATA 相關(guān)知識...
0x0000 MOVL "".b+12(SP), AX ;; move second Long-word (4B) argument from caller's stack-frame into AX
0x0004 MOVL "".a+8(SP), CX ;; move first Long-word (4B) argument from caller's stack-frame into CX
0x0008 ADDL CX, AX ;; 計算 AX=CX+AX
0x000a MOVL AX, "".~r2+16(SP) ;; 把結(jié)果移動到調(diào)用者的椏寻停空間
0x000e MOVB $1, "".~r3+20(SP) ;; 將布爾類型的true移動到調(diào)用者的椣碧拢空間
0x0013 RET ;; 跳轉(zhuǎn)到 0(SP)中存儲地址對應(yīng)內(nèi)存中去
最后,這兒有一個當(dāng)main.add 執(zhí)行完之后棧的示意圖
| +-------------------------+ <-- 32(SP)
| | |
G | | |
R | | |
O | | main.main's saved |
W | | frame-pointer (BP) |
S | |-------------------------| <-- 24(SP)
| | [alignment] |
D | | "".~r3 (bool) = 1/true | <-- 21(SP)
O | |-------------------------| <-- 20(SP)
W | | |
N | | "".~r2 (int32) = 42 |
W | |-------------------------| <-- 16(SP)
A | | |
R | | "".b (int32) = 32 |
D | |-------------------------| <-- 12(SP)
S | | |
| | "".a (int32) = 10 |
| |-------------------------| <-- 8(SP)
| | |
| | |
| | |
\ | / | return address to |
\|/ | main.main + 0x30 |
- +-------------------------+ <-- 0(SP) (TOP OF STACK)
(diagram made with https://textik.com)
分析 main
省略了一些代碼骤宣,幫你節(jié)省滾鼠標(biāo)的時間秦爆,如下是main方法匯編之后的樣子:
0x0000 TEXT "".main(SB), $24-0
;; ...omitted stack-split prologue...
0x000f SUBQ $24, SP
0x0013 MOVQ BP, 16(SP)
0x0018 LEAQ 16(SP), BP
;; ...omitted FUNCDATA stuff...
0x001d MOVQ $137438953482, AX
0x0027 MOVQ AX, (SP)
;; ...omitted PCDATA stuff...
0x002b CALL "".add(SB)
0x0030 MOVQ 16(SP), BP
0x0035 ADDQ $24, SP
0x0039 RET
;; ...omitted stack-split epilogue...
0x0000 TEXT "".main(SB), $24-0
其實也沒啥:
- "".main (鏈接之后是main.main)在.text 代碼段中中是一個全局方法,它有一個到我們地址空間開始處有固定偏移量的地址憔披。
- 分配了24字節(jié)的椀认蓿空間 并且沒有接收任何參數(shù),也沒有任何返回活逆。
0x000f SUBQ $24, SP
0x0013 MOVQ BP, 16(SP)
0x0018 LEAQ 16(SP), BP
如上文所述精刷,Go 調(diào)用必須將所有的參數(shù)放在棧中。
main這個調(diào)用者蔗候,通過遞減虛擬棧指針怒允,增加了24字節(jié)的棧空間(記仔庖!:棧是向下增長的纫事,所以SUBQ 指令就是擴大棧空間)所灸。這24字節(jié)包含如下部分:
- 8字節(jié)(16(SP) - 24(SP))用來存儲當(dāng)前棧指針BP的值丽惶,為了 棧展開(stack-unwinding) 和 方便調(diào)試(facilitate debugging)。
- 1+3 字節(jié) (12(SP) - 16(SP)) 為了存儲第二個返回值(布爾類型)加 3字節(jié)的偏移對齊量(amd64 機器架構(gòu))
- 4字節(jié)(8(SP) - 12(SP))存儲第一個返回值(int32)
- 4字節(jié)(4(SP) - 8(SP))存儲參數(shù)b(int32)
- 4字節(jié)(0(SP) - 4(SP))存儲參數(shù)a(int32)
最后爬立, 伴隨著棧的增長钾唬,LEAQ 計算棧指針的新地址,并將其存儲在BP中侠驯。
0x001d MOVQ $137438953482, AX
0x0027 MOVQ AX, (SP)
調(diào)用者把給 被調(diào)用者的參數(shù)作為一個Quad word 推送到剛剛擴容棧的棧頂抡秆。
137438953482 雖然開始看起來像個垃圾值,實際上這個值對應(yīng)的就是 10 和 32 這兩個 4 字節(jié)值吟策,它們兩被連接成了一個 8 字節(jié)值儒士。
$ echo 'obase=2;137438953482' | bc
10000000000000000000000000000000001010
\____/\______________________________/
32 10
0x002b CALL "".add(SB)
我們使用相對于基地址指針的偏移量來調(diào)用add方法, 這相當(dāng)于直接跳到一個指定的地址檩坚。
注意:CALL 將返回地址(一個8字節(jié)的值)推到棧頂着撩;所以每次在add函數(shù)中引用SP寄存器的時候還需要額外偏移8字節(jié)诅福。
比如 "".a 不再位于0(SP), 而是位于 8(SP).
0x0030 MOVQ 16(SP), BP
0x0035 ADDQ $24, SP
0x0039 RET
最后:
1、將幀指針(frame-pointer)下降一個棧幀(stack-frame)的大小(就是“向下”一級)
2拖叙、釋放我們先前占用的24字節(jié)椕ト螅空間
3、請求Go匯編器插入一個子協(xié)程返回相關(guān)的操作
分析goroutines, stacks 和 splits
現(xiàn)在并不是一個深入研究go協(xié)程實現(xiàn)的好時機(后面可能會講)薯鳍,但是隨著我們越來越多地研究匯編輸出旺芽, 跟棧管理相關(guān)的指令將會迅速熟悉起來。我們應(yīng)該快速熟悉這些模式辐啄,當(dāng)我們熟悉了,我們會理解指令在做什么以及為什么這么做运嗜。
棧(Stacks)
由于Go程序中的協(xié)程數(shù)量是不確定的壶辜,在實踐中會有幾百萬個,所以在分配椀W猓空間時要保守一些砸民,避免耗光所有可用的內(nèi)存。
每個協(xié)程啟動時會分配2kb的棧內(nèi)存(雖然說是椃芫龋空間岭参,其實分配在堆上).
當(dāng)協(xié)程執(zhí)行其job時,可能因為超出其初始分配的空間大小尝艘。為了避免這種情況發(fā)生演侯,runtime 保證當(dāng)一個協(xié)程超出其棧空間時背亥,會分配一個2倍大小的空間給它秒际,并將初始空間的內(nèi)容分配到新空間中。
這個過程被稱為棧分裂(stack-split) 狡汉,這是協(xié)程棧動態(tài)大小的一個有效方法娄徊。
分裂(Splits)
為了保證stack-splitting 正常工作,編譯器會在可能發(fā)生棧超出的每個方法開始跟結(jié)束插入幾條指令盾戴。
正如在本文開始我們看到的寄锐,為了避免沒必要的超出,被標(biāo)注NOSPLIT的方法不會擴大他的椉夥龋空間橄仆。有了這個標(biāo)注,編譯器不會插入這些指令可婶。
看一下main方法沿癞,這次沒有省略 stack-split 前導(dǎo)指令:
0x0000 TEXT "".main(SB), $24-0
;; stack-split prologue
0x0000 MOVQ (TLS), CX
0x0009 CMPQ SP, 16(CX)
0x000d JLS 58
0x000f SUBQ $24, SP
0x0013 MOVQ BP, 16(SP)
0x0018 LEAQ 16(SP), BP
;; ...omitted FUNCDATA stuff...
0x001d MOVQ $137438953482, AX
0x0027 MOVQ AX, (SP)
;; ...omitted PCDATA stuff...
0x002b CALL "".add(SB)
0x0030 MOVQ 16(SP), BP
0x0035 ADDQ $24, SP
0x0039 RET
;; stack-split epilogue
0x003a NOP
;; ...omitted PCDATA stuff...
0x003a CALL runtime.morestack_noctxt(SB)
0x003f JMP 0
如我們所見,stack-split 序列被分成一個prologue (序幕) 和一個epilogue(結(jié)束):
- prologue 檢查協(xié)程是否超出空間矛渴,如果是椎扬,直接跳到epilogue
- epilogue 惫搏,會觸發(fā)棧增長機制,然后跳轉(zhuǎn)到prologue
這創(chuàng)造了一個反饋循環(huán)蚕涤,這個循環(huán)保證了只要有足夠的椏鹋猓空間未被分配,“饑餓"協(xié)程就能正常運轉(zhuǎn)揖铜。
Prologue
0x0000 MOVQ (TLS), CX ;; store current *g in CX
0x0009 CMPQ SP, 16(CX) ;; compare SP and g.stackguard0
0x000d JLS 58 ;; jumps to 0x3a if SP <= g.stackguard0
TLS 是一個runtime線程持有的虛擬寄存器茴丰,保存了指向當(dāng)前go協(xié)程的指針。會跟蹤當(dāng)前協(xié)程的運行時狀態(tài).
看一下runtime中g(shù)協(xié)程的定義
type g struct {
stack stack // 16 bytes
// stackguard0 is the stack pointer compared in the Go stack growth prologue.
// It is stack.lo+StackGuard normally, but can be StackPreempt to trigger a preemption.
stackguard0 uintptr
stackguard1 uintptr
// ...omitted dozens of fields...
}
16(CX) 關(guān)聯(lián)了 g.stackguard0天吓,一個閾值贿肩。其用途是當(dāng)跟棧指針比較,判斷一個協(xié)程是否馬上要用完當(dāng)前的椓淠空間汰规。
prologue 檢查當(dāng)前SP的值是否小于或者等于stackguard0 的值,如果是物邑,則跳轉(zhuǎn)到Epilogue溜哮。
Epilogue
0x003a NOP
0x003a CALL runtime.morestack_noctxt(SB)
0x003f JMP 0
Epilogue 的結(jié)構(gòu)比較直接: 它直接調(diào)用runtime的函數(shù),然后跳轉(zhuǎn)到方法的第一個指令prologue去色解。
在 CALL 之前出現(xiàn)的 NOP 這個指令使 prologue 部分不會直接跳到 CALL 指令位置茂嗓。在一些平臺上,直接跳到 CALL 可能會有一些麻煩的問題科阎;所以在調(diào)用位置插一個 noop 的指令并在跳轉(zhuǎn)時跳到這個 NOP 位置是一種最佳實踐述吸。
[更新:我們在issue #4: Clarify "nop before call" paragraph.討論了這個問題]
Minus some subtleties (缺失了一些細節(jié))
我們僅僅覆蓋了冰山一角。
像棧擴容有很多細節(jié)我們在這兒并不能一一描述锣笨。擴容細節(jié)非常復(fù)雜刚梭,將會有一個單獨的章節(jié)介紹。
到時再回頭看這兒票唆。
總結(jié)
快速介紹GO匯編 已經(jīng)給你足夠的內(nèi)容去玩味朴读。
隨著我們深入研究本書其他部分的go內(nèi)核知識,Go匯編將會是我們理解場景背后知識最依賴的工具之一走趋。
如果有任何問題或者建議衅金,不要遲疑,創(chuàng)建一個有關(guān)chapter1的issue簿煌, prefix!!!
參考文獻
- [Official] A Quick Guide to Go's Assembler
- [Official] Go Compiler Directives
- [Official] The design of the Go Assembler
- [Official] Contiguous stacks Design Document
- [Official] The
_StackMin
constant - [Discussion] Issue #2: Frame pointer
- [Discussion] Issue #4: Clarify "nop before call" paragraph
- A Foray Into Go Assembly Programming
- Dropping Down Go Functions in Assembly
- What is the purpose of the EBP frame pointer register?
- Stack frame layout on x86-64
- How Stacks are Handled in Go
- Why stack grows down