在深入閱讀runtime和標准庫的源碼時候,發現底層有大片代碼都會與匯編打交道,所以這篇文章主要是介紹golang使用到的匯編。
go匯編語言是一個不可忽視的技術。因為哪怕只懂一點點匯編,也便於更好地理解計算機原理,也更容易理解Go語言中動態棧/接口等高級特性的實現原理。
本文涉及到計算機架構體系相關的情況時,請假設我們是運行在 linux/amd64 平台上。
偽匯編
Go 編譯器會輸出一種抽象可移植的匯編代碼,這種匯編並不對應某種真實的硬件架構。之后 Go 的匯編器使用這種偽匯編,為目標硬件生成具體的機器指令。
偽匯編這一個額外層可以帶來很多好處,最主要的一點是方便將 Go 移植到新的架構上。相關的信息可以參考文后列出的 Rob Pike 的 The Design of the Go Assembler。
go 匯編語言的一個簡單實例
思考下面這行代碼:我們創建一個test1.go文件:
package main
//go:noinline
func add(a, b int32) (int32, bool) {
return a + b, true
}
func main() { add(10, 32) }
注意這里的 //go:noinline 編譯器指令。不要省略掉這部分,我們使用如下命令生成相應的匯編輸出:go tool compile -S test1.go,會輸出包含如下的輸出:
"".add STEXT nosplit size=20 args=0x10 locals=0x0 0x0000 00000 (test1.go:5) TEXT "".add(SB), NOSPLIT|ABIInternal, $0-16 0x0000 00000 (test1.go:5) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x0000 00000 (test1.go:5) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x0000 00000 (test1.go:5) FUNCDATA $2, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x0000 00000 (test1.go:6) PCDATA $0, $0 0x0000 00000 (test1.go:6) PCDATA $1, $0 0x0000 00000 (test1.go:6) MOVL "".b+12(SP), AX 0x0004 00004 (test1.go:6) MOVL "".a+8(SP), CX 0x0008 00008 (test1.go:6) ADDL CX, AX 0x000a 00010 (test1.go:6) MOVL AX, "".~r2+16(SP) 0x000e 00014 (test1.go:6) MOVB $1, "".~r3+20(SP) 0x0013 00019 (test1.go:6) RET 0x0000 8b 44 24 0c 8b 4c 24 08 01 c8 89 44 24 10 c6 44 .D$..L$....D$..D 0x0010 24 14 01 c3 $... "".main STEXT size=65 args=0x0 locals=0x18 0x0000 00000 (test1.go:9) TEXT "".main(SB), ABIInternal, $24-0 0x0000 00000 (test1.go:9) MOVQ (TLS), CX 0x0009 00009 (test1.go:9) CMPQ SP, 16(CX) 0x000d 00013 (test1.go:9) JLS 58 0x000f 00015 (test1.go:9) SUBQ $24, SP 0x0013 00019 (test1.go:9) MOVQ BP, 16(SP) 0x0018 00024 (test1.go:9) LEAQ 16(SP), BP 0x001d 00029 (test1.go:9) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x001d 00029 (test1.go:9) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x001d 00029 (test1.go:9) FUNCDATA $2, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x001d 00029 (test1.go:10) PCDATA $0, $0 0x001d 00029 (test1.go:10) PCDATA $1, $0 0x001d 00029 (test1.go:10) MOVQ $137438953482, AX 0x0027 00039 (test1.go:10) MOVQ AX, (SP) 0x002b 00043 (test1.go:10) CALL "".add(SB) 0x0030 00048 (test1.go:11) MOVQ 16(SP), BP 0x0035 00053 (test1.go:11) ADDQ $24, SP 0x0039 00057 (test1.go:11) RET 0x003a 00058 (test1.go:11) NOP 0x003a 00058 (test1.go:9) PCDATA $1, $-1 0x003a 00058 (test1.go:9) PCDATA $0, $-1 0x003a 00058 (test1.go:9) CALL runtime.morestack_noctxt(SB) 0x003f 00063 (test1.go:9) JMP 0 0x0000 64 48 8b 0c 25 00 00 00 00 48 3b 61 10 76 2b 48 dH..%....H;a.v+H 0x0010 83 ec 18 48 89 6c 24 10 48 8d 6c 24 10 48 b8 0a ...H.l$.H.l$.H.. 0x0020 00 00 00 20 00 00 00 48 89 04 24 e8 00 00 00 00 ... ...H..$..... 0x0030 48 8b 6c 24 10 48 83 c4 18 c3 e8 00 00 00 00 eb H.l$.H.......... 0x0040 bf . rel 5+4 t=16 TLS+0 rel 44+4 t=8 "".add+0 rel 59+4 t=8 runtime.morestack_noctxt+0
接下來我們會就上面的輸出進行解析。
函數add部分
0x0000 00000 (test1.go:5) TEXT "".add(SB), NOSPLIT|ABIInternal, $0-16
- 0x0000: 當前指令相對於當前函數的偏移量。
-
TEXT “”.add: TEXT 指令聲明了 “”.add 是 .text 段(程序代碼在運行期會放在內存的 .text 段中)的一部分,並表明跟在這個聲明后的是函數的函數體。在鏈接期,"" 這個空字符會被替換為當前的包名: 也就是說,"".add 在鏈接到二進制文件后會變成 main.add。
-
(SB): SB 是一個虛擬寄存器,保存了靜態基地址(static-base) 指針,即我們程序地址空間的開始地址。"".add(SB) 表明我們的符號位於某個固定的相對地址空間起始處的偏移位置 (最終是由鏈接器計算得到的)。換句話來講,它有一個直接的絕對地址: 是一個全局的函數符號。
- objdump 這個工具能幫我們確認上面這些結論::
$ objdump -j .text -t test1 | grep 'main.add' 00000000010512e0 l F __TEXT,__text main.add
-
NOSPLIT: 向編譯器表明不應該插入 stack-split 的用來檢查棧需要擴張的前導指令。
在我們 add 函數的這種情況下,編譯器自己幫我們插入了這個標記: 它足夠聰明地意識到,由於 add 沒有任何局部變量且沒有它自己的棧幀,所以一定不會超出當前的棧;因此每次調用函數時在這里執行棧檢查就是完全浪費 CPU 循環了。 - $0-16: $0 代表即將分配的棧幀大小;而 $16 指定了調用方傳入的參數大小。
- Go 的調用規約要求每一個參數都通過棧來傳遞,這部分空間由 caller 在其棧幀(stack frame)上提供。調用其它函數之前,caller 就需要按照參數和返回變量的大小來對應地增長(返回后收縮)棧。
Go 編譯器沒有 PUSH/POP 族的指令: 棧的增長和收縮是通過在棧指針寄存器 SP 上分別執行減法和加法指令來實現的與大多數最近的編譯器做法一樣,Go 工具鏈總是在其生成的代碼中,使用相對棧指針(stack-pointer)的偏移量來引用參數和局部變量。這樣使得我們可以用幀指針(frame-pointer)來作為一個額外的通用寄存器,這一點即使是在那些寄存器數量較少的平台上也是一樣的(例如 x86)。
“”.b+12(SP) 和 “”.a+8(SP) 分別指向棧的低 12 字節和低 8 字節位置(記住: 棧是向低位地址方向增長的!)。.a 和 .b 是分配給引用地址的任意別名;盡管 它們沒有任何語義上的含義 ,但在使用虛擬寄存器和相對地址時,這種別名是需要強制使用的。
最后,有兩個重點需要指出:
- 第一個變量 a 的地址並不是 0(SP),而是在 8(SP);這是因為調用方通過使用 CALL 偽指令,把其返回地址保存在了 0(SP) 位置。
- 參數是反序傳入的;也就是說,第一個參數和棧頂距離最近。(壓棧)
0x0008 00008 (test1.go:6) ADDL CX, AX 0x000a 00010 (test1.go:6) MOVL AX, "".~r2+16(SP) 0x000e 00014 (test1.go:6) MOVB $1, "".~r3+20(SP)
ADDL 進行實際的加法操作,L 這里代表 Long,4 字節的值,其將保存在 AX 和 CX 寄存器中的值進行相加,然后再保存進 AX 寄存器中。
這個結果之后被移動到 "".~r2+16(SP)地址處,這是之前調用方專門為返回值預留的棧空間。這一次 “”.~r2 同樣沒什么語義上的含義。
stacks 和 splits
stacks
由於 Go 程序中的 goroutine 數目是不可確定的,並且實際場景可能會有百萬級別的 goroutine,runtime 必須使用保守的思路來給 goroutine 分配空間以避免吃掉所有的可用內存。也由於此,每個新的 goroutine 會被 runtime 分配初始為 2KB 大小的棧空間(Go 的棧在底層實際上是分配在堆空間上的)。隨着一個 goroutine 進行自己的工作,可能會超出最初分配的棧空間限制(就是棧溢出的意思)。為了防止這種情況發生,runtime 確保 goroutine 在超出棧范圍時,會創建一個相當於原來兩倍大小的新棧,並將原來棧的上下文拷貝到新棧上。這個過程被稱為 棧分裂(stack-split),這樣使得 goroutine 棧能夠動態調整大小。
splits
為了使棧分裂正常工作,編譯器會在每一個函數的開頭和結束位置插入指令來防止 goroutine 爆棧。像我們本章早些看到的一樣,為了避免不必要的開銷,一定不會爆棧的函數會被標記上 NOSPLIT 來提示編譯器不要在這些函數的開頭和結束部分插入這些檢查指令。
寄存器
通用寄存器
應用代碼層面會用到的通用寄存器主要是: rax, rbx, rcx, rdx, rdi, rsi, r8~r15 這 14 個寄存器,雖然 rbp 和 rsp 也可以用,不過 bp 和 sp 會被用來管理棧頂和棧底,最好不要拿來進行運算。
plan9 中使用寄存器不需要帶 r 或 e 的前綴,例如 rax,只要寫 AX 即可
偽寄存器
Go 的匯編還引入了 4 個偽寄存器,援引官方文檔的描述:
FP: Frame pointer: arguments and locals.
PC: Program counter: jumps and branches.
SB: Static base pointer: global symbols.
SP: Stack pointer: top of stack.
官方的描述稍微有一些問題,我們對這些說明進行一點擴充:
-
FP: 使用形如 symbol+offset(FP) 的方式,引用函數的輸入參數。例如 arg0+0(FP),arg1+8(FP),使用 FP 不加 symbol 時,無法通過編譯,在匯編層面來講,symbol 並沒有什么用,加 symbol 主要是為了提升代碼可讀性。另外,官方文檔雖然將偽寄存器 FP 稱之為 frame pointer,實際上它根本不是 frame pointer,按照傳統的 x86 的習慣來講,frame pointer 是指向整個 stack frame 底部的 BP 寄存器。假如當前的 callee 函數是 add,在 add 的代碼中引用 FP,該 FP 指向的位置不在 callee 的 stack frame 之內,而是在 caller 的 stack frame 上。具體可參見之后的 棧結構 一章。
- PC: 實際上就是在體系結構的知識中常見的 pc 寄存器,在 x86 平台下對應 ip 寄存器,amd64 上則是 rip。除了個別跳轉之外,手寫 plan9 代碼與 PC 寄存器打交道的情況較少。
- SB: 全局靜態基指針,一般用來聲明函數或全局變量,在之后的函數知識和示例部分會看到具體用法。
-
plan9 的這個 SP 寄存器指向當前棧幀的局部變量的開始位置,使用形如 symbol+offset(SP) 的方式,引用函數的局部變量。offset 的合法取值是 [-framesize, 0),注意是個左閉右開的區間。假如局部變量都是 8 字節,那么第一個局部變量就可以用 localvar0-8(SP) 來表示。這也是一個詞不表意的寄存器。與硬件寄存器 SP 是兩個不同的東西,在棧幀 size 為 0 的情況下,偽寄存器 SP 和硬件寄存器 SP 指向同一位置。手寫匯編代碼時,如果是 symbol+offset(SP) 形式,則表示偽寄存器 SP。如果是 offset(SP) 則表示硬件寄存器 SP。務必注意。對於編譯輸出(go tool compile -S / go tool objdump)的代碼來講,目前所有的 SP
我們這里對容易混淆的幾點簡單進行說明:
- 偽 SP 和硬件 SP 不是一回事,在手寫代碼時,偽 SP 和硬件 SP 的區分方法是看該 SP 前是否有 symbol。如果有 symbol,那么即為偽寄存器,如果沒有,那么說明是硬件 SP 寄存器。
- SP 和 FP 的相對位置是會變的,所以不應該嘗試用偽 SP 寄存器去找那些用 FP + offset 來引用的值,例如函數的入參和返回值。
- 官方文檔中說的偽 SP 指向 stack 的 top,是有問題的。其指向的局部變量位置實際上是整個棧的棧底(除 caller BP 之外),所以說 bottom 更合適一些。
- 在 go tool objdump/go tool compile -S 輸出的代碼中,是沒有偽 SP 和 FP 寄存器的,我們上面說的區分偽 SP 和硬件 SP 寄存器的方法,對於上述兩個命令的輸出結果是沒法使用的。在編譯和反匯編的結果中,只有真實的 SP 寄存器。
- FP 和 Go 的官方源代碼里的 frame pointer 不是一回事,源代碼里的 frame pointer 指的是 caller BP 寄存器的值,在這里和 caller 的偽 SP 是值是相等的。
棧結構
結構圖:
----------------- current func arg0 ----------------- <----------- FP(pseudo FP) caller ret addr +---------------+ | caller BP(*) | ----------------- <----------- SP(pseudo SP,實際上是當前棧幀的 BP 位置) | Local Var0 | ----------------- | Local Var1 | ----------------- | Local Var2 | ----------------- - | ........ | ----------------- | Local VarN | ----------------- | | | | | temporarily | | unused space | | | | | ----------------- | call retn | ----------------- | call ret(n-1)| ----------------- | .......... | ----------------- | call ret1 | ----------------- | call argn | ----------------- | ..... | ----------------- | call arg3 | ----------------- | call arg2 | |---------------| | call arg1 | ----------------- <------------ hardware SP 位置 | return addr | +---------------+
圖上的 caller BP,指的是 caller 的 BP 寄存器值,有些人把 caller BP 叫作 caller 的 frame pointer,實際上這個習慣是從 x86 架構沿襲來的。Go 的 asm 文檔中把偽寄存器 FP 也稱為 frame pointer,但是這兩個 frame pointer 根本不是一回事。
此外需要注意的是,caller BP 是在編譯期由編譯器插入的,用戶手寫代碼時,計算 frame size 時是不包括這個 caller BP 部分的。是否插入 caller BP 的主要判斷依據是:
- 函數的棧幀大小大於 0
- 下述函數返回 true
func Framepointer_enabled(goos, goarch string) bool { return framepointer_enabled != 0 && goarch == "amd64" && goos != "nacl" }
如果編譯器在最終的匯編結果中沒有插入 caller BP(源代碼中所稱的 frame pointer)的情況下,偽 SP 和偽 FP 之間只有 8 個字節的 caller 的 return address,而插入了 BP 的話,就會多出額外的 8 字節。也就說偽 SP 和偽 FP 的相對位置是不固定的,有可能是間隔 8 個字節,也有可能間隔 16 個字節。並且判斷依據會根據平台和 Go 的版本有所不同。
圖上可以看到,FP 偽寄存器指向函數的傳入參數的開始位置,因為棧是朝低地址方向增長,為了通過寄存器引用參數時方便,所以參數的擺放方向和棧的增長方向是相反的,即:
FP high ----------------------> low argN, ... arg3, arg2, arg1, arg0
假設所有參數均為 8 字節,這樣我們就可以用 symname+0(FP) 訪問第一個 參數,symname+8(FP) 訪問第二個參數,以此類推。用偽 SP 來引用局部變量,原理上來講差不多,不過因為偽 SP 指向的是局部變量的底部,所以 symname-8(SP) 表示的是第一個局部變量,symname-16(SP)表示第二個,以此類推。當然,這里假設局部變量都占用 8 個字節。
圖的最上部的 caller return address 和 current func arg0 都是由 caller 來分配空間的。不算在當前的棧幀內。
因為官方文檔本身較模糊,我們來一個函數調用的全景圖,來看一下這些真假 SP/FP/BP 到底是個什么關系:
caller +------------------+ | | +----------------------> -------------------- | | | | | caller parent BP | | BP(pseudo SP) -------------------- | | | | | Local Var0 | | -------------------- | | | | | ....... | | -------------------- | | | | | Local VarN | -------------------- caller stack frame | | | callee arg2 | | |------------------| | | | | | callee arg1 | | |------------------| | | | | | callee arg0 | | ----------------------------------------------+ FP(virtual register) | | | | | | return addr | parent return address | +----------------------> +------------------+--------------------------- <-------------------------------+ | caller BP | | | (caller frame pointer) | | BP(pseudo SP) ---------------------------- | | | | | Local Var0 | | ---------------------------- | | | | Local Var1 | ---------------------------- callee stack frame | | | ..... | ---------------------------- | | | | | Local VarN | | SP(Real Register) ---------------------------- | | | | | | | | | | | | | | | | +--------------------------+ <-------------------------------+ callee
下面我們演示個例子
首先新建一個項目目錄,添加一個demo.go文件
package main import ( "fmt" "time" ) func add(a, b int) int // 匯編函數聲明 func sub(a, b int) int // 匯編函數聲明 func mul(a, b int) int // 匯編函數聲明 func main() { fmt.Println(add(10, 11)) fmt.Println(sub(99, 15)) fmt.Println(mul(11, 12)) time.Sleep(2*time.Second) }
然后我們要去實現添加的方法,使用匯編的語句:創建一個demo.s文件(注意文件名要和go文件的名相對應),系統會自動找到對應的.s文件:
#include "textflag.h" // 因為我們聲明函數用到了 NOSPLIT 這樣的 flag,所以需要將 textflag.h 包含進來 // func add(a, b int) int TEXT ·add(SB), NOSPLIT, $0-24 MOVQ a+0(FP), AX // 參數 a MOVQ b+8(FP), BX // 參數 b ADDQ BX, AX // AX += BX MOVQ AX, ret+16(FP) // 返回 RET // func sub(a, b int) int TEXT ·sub(SB), NOSPLIT, $0-24 MOVQ a+0(FP), AX MOVQ b+8(FP), BX SUBQ BX, AX // AX -= BX MOVQ AX, ret+16(FP) RET // func mul(a, b int) int TEXT ·mul(SB), NOSPLIT, $0-24 MOVQ a+0(FP), AX MOVQ b+8(FP), BX IMULQ BX, AX // AX *= BX MOVQ AX, ret+16(FP) RET // 最后一行的空行是必須的,否則可能報 unexpected EOF
最后在項目路徑下輸入:go build,然后會生成.exe文件,直接運行既可。
上面是摘抄於:https://blog.csdn.net/u010853261/article/details/101060546的一篇文章,覺得是go匯編入門的一個比較易懂的文件,所以收錄下來