這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
本文翻譯自Golang官方文檔,原文地址: https://golang.org/doc/asm
這篇文檔是對於Go編譯器套件(6g, 8g, etc.)中不常用的組合語言的快速預覽,涵蓋面不是很廣泛。
Go的組合語言基於Plan 9的彙編,Plan 9網站的頁面上有詳細描述。如果你想編寫組合語言,你應該讀這篇文檔,雖然它是Plan 9相關的。這邊文檔總結了彙編的文法,並且描述了使用組合語言和Go程式互動時的特殊之處。
有一點是很重要的是,Go的彙編中沒有直接體現出底層的機器。有些彙編細節能直接對應到機器,但有些不是。這是因為編譯器套件在常規過程中不需要組合語言。取而代之的是,編譯器產生二進位的不完整的彙編指令集,連結器會完成它。實際上,連結器做了彙編指令的選擇,所以當你看到類似於MOV這樣的指令,連結器的實際操作可能不是一個移動指令,也許是清除或者載入。或者可能會根據指令的名字對應到真實的機器指令。總體上,機器相關的指令操作趨向於體現出真實的機器指令,但是一些通用的概念類似於移動記憶體資料、調用子常式、返回等操作就更抽象了。具體的細節和架構相關,我們為這種不精確性道歉。
組譯工具是產生中間碼的一種方法,未完整定義的指令集作為連結器的輸入。如果你想看到特定CPU架構下的彙編指令集,如amd64,在Go標準庫的源檔案中就有許多例子,在runtime和math/big包中。或者你還可以參照下面的程式,來檢查編譯器的彙編輸出:
$ cat x.gopackage mainfunc main() { println(3)}$ go tool 6g -S x.go # or: go build -gcflags -S x.go--- prog list "main" ---0000 (x.go:3) TEXT main+0(SB),$8-00001 (x.go:3) FUNCDATA $0,gcargs·0+0(SB)0002 (x.go:3) FUNCDATA $1,gclocals·0+0(SB)0003 (x.go:4) MOVQ $3,(SP)0004 (x.go:4) PCDATA $0,$80005 (x.go:4) CALL ,runtime.printint+0(SB)0006 (x.go:4) PCDATA $0,$-10007 (x.go:4) PCDATA $0,$00008 (x.go:4) CALL ,runtime.printnl+0(SB)0009 (x.go:4) PCDATA $0,$-10010 (x.go:5) RET ,...
FUNCDATA和PCDATA指令用來包含一些垃圾收集器需要的資訊。它們由編譯器產生。
符號
有些符號,例如PC,R0和SP,是預定義的並且是對一個寄存器的引用。另外還有兩種預定義的符號,SB(static base)和FP(frame pointer)。所有使用者定義的符號,除了標籤跳轉之外,都是對偽寄存器的offsets操作。
SB偽寄存器可以想象成記憶體的地址,所以符號foo(SB)是一個由foo這個名字代表的記憶體位址。這種形式一般用來命名全域函數和資料。給名字增加一個<>符號,就像foo<>(SB),會讓這個名字只有在當前檔案可見,就像在C檔案中預定義的static。
FP偽寄存器是一個虛擬幀指標,用來指向函數的參數。編譯器維護了一個虛擬棧指標,使用對偽寄存器的offsets操作的形式,指向棧上的函數參數。於是,0(FP)就是第一個參數,8(FP)就是第二個(64位機器),以此類推。當用這種方式引用函數參數時,可以很方便的在符號前面加上一個名稱,就像first_arg+0(FP)和second_arg+8(FP)。有些組譯工具強制使用這種約定,禁止單一的0(FP)和8(FP)。在使用Go標準定義的彙編函數中,go vet會檢查參數的名字和它們的匹配範圍。在32位系統上,一個64位值的高32和低32位表示為增加_lo和_hi這個兩個尾碼到一個名稱,就像arg_lo+0(FP)或者arg_hi+4(FP)。如果一個Go原型函數沒有命名它的結果,期待的名字將會被返回。
SP偽寄存器是一個虛擬棧指標,用來指向棧幀本地的變數和為函數調用準備參數。它指向本地棧幀的頂部,所以一個對棧幀的引用必須是一個負值且範圍在[-framesize:0]之間,例如: x-8(SP),y-4(SP),以此類推。在CPU架構中,存在一個真實的寄存器SP,虛擬棧寄存器和真實的SP寄存器的區別在於名字的首碼上。就是說,x-8(SP)和-8(SP)是不同的記憶體位址:前者是引用偽棧指標寄存器,但後者是硬體中真實存在的SP寄存器。
指令、寄存器和彙編指令始終使用大寫字母表示,提醒你組合語言編程是非常令人擔憂的。(例外:在ARM平台下,代表當前goroutine的g寄存器被重新命名。)
在Go對象檔案和二進位檔案中,符號的完整名字是包的路徑加上一個句點:fmt.Printf或math/rand.Int。但是彙編器會把句點和斜杠當做標點符號來對待,這些字元不能當做符號的標識符。取而代之的是,允許在組譯工具中使用中點字元(Unicode字元00B7)和除法斜杠(原文中是division slash,Unicode字元2215,區別於forward slash)當做標識符並且把它們重寫成純句點和斜杠。在組合語言的源檔案中,上面的符號寫成fmt·Printf和math∕rand.Int。通過在編譯時間使用-S標誌看到的彙編代碼清單中直接顯示了句點和斜杠,而不是在組譯工具中需要的Unicode替代字元(指上面的兩個特殊Unicode字元)。
大部分手寫的彙編檔案中,不要在符號名中包含完整的包路徑,因為連結器會在任何以句點開頭的名字前面插入當前對象檔案的路徑:在math/rand包的彙編源檔案中,rand包的Int函數被當做了·Int來引用。這種便捷性避免了需要在自身的原始碼中寫入程式碼匯入路徑,可以讓代碼從一個地方移動到另一個地方時變得更容易。
指令
組譯工具中使用多種指令綁定文本和資料到符號名。舉個例子,下面有一個簡單但是完整的函數定義。TEXT指令聲明了符號runtime·profileloop,指令緊接在類似於函數的主體中。TEXT塊的最後必須是某種形式的跳轉,通常是一個RET(偽)指令。(如果沒有,連結器會追加一個跳轉到塊自身的指令,TEXT塊中沒有fallthrough) 符號的後面,參數是標誌和棧幀的大小,是一個常量(但是看下面的代碼):
TEXT runtime·profileloop(SB),NOSPLIT,$8 MOVQ $runtime·profileloop1(SB), CX MOVQ CX, 0(SP) CALL runtime·externalthreadhandler(SB) RET
這個函數的棧幀大小為8位元組(MOVQ CX, 0(SP)操作棧指標),沒有參數
一般情況下,棧幀的大小跟在參數的大小之後,由一個減法符號分隔。(它不是減號,只是特殊的文法) 棧幀大小是$24-8描述了函數有24位元組的棧幀並且需要一個8位元組的參數,存在於調用者的棧幀中。如果沒有為TEXT指定NOSPLIT標誌,必須提供參數大小。在使用Go標準定義的彙編函數中,go vet會檢查參數大小是否正確。
注意符號名是使用中點來分割組件的,並且被定義為從偽寄存器SB開始的一個offsets。在Go源碼的runtime包中,使用簡稱profileloop來調用。
全域資料符號使用初始化的一系列DATA指令來定義,並且跟在一個GLOBAL指令之後。每個DATA指令初始化一塊指定的記憶體地區。沒有明確初始化的記憶體地區會被置為零。標準的DATA指令形式為:
DATA symbol+offset(SB)/width, value
這樣就初始化了symbol,記憶體在指定的offset處,帶有指定的width和給定的value。一個symbol中的DATA指令必須是逐漸增長的offsets。
GLOBAL指令將一個symbol聲明為全域的。參數是可選的標誌和需要聲明為全域的資料的大小,並會初始化為零值,除非DATA指令中已經初始化它。GLOBAL指令必須跟在對應的DATA指令之後。
舉例:
DATA divtab<>+0x00(SB)/4, $0xf4f8fcffDATA divtab<>+0x04(SB)/4, $0xe6eaedf0...DATA divtab<>+0x3c(SB)/4, $0x81828384GLOBL divtab<>(SB), RODATA, $64GLOBL runtime·tlsoffset(SB), NOPTR, $4
聲明並且初始化了divtab<>,一個唯讀64位table含有4位元組的整數值。並且聲明了runtime·tlsoffset,一個4位元組並且明確被零值初始化的值,其中不含有指標。
指令可以含有一個或者兩個參數。如果有兩個參數,第一個是位元掩碼的標誌,可以寫成數位運算式,多個掩碼之間可以相加或者做邏輯或運算,或者可以寫成友好可讀的形式。這些值定義在標頭檔textflag.h中:
NOPROF = 1(TEXT項使用.)不最佳化NOPROF標記的函數。這個標誌已廢棄。
DUPOK = 2在二進位檔案中允許一個符號的多個執行個體。連結器會選擇其中之一。
NOSPLIT = 4(TEXT項使用.)不插入預先檢測是否將棧空間分裂的代碼。程式的棧幀中,如果調用任何其他代碼都會增加棧幀的大小,必須在棧頂留出可用空間。用來保護處理棧空間分裂的代碼本身。
RODATA = 8(DATA和GLOBAL項使用.) 將這個資料放在唯讀塊中。
NOPTR = 16這個資料不包含指標所以就不需要垃圾收集器來掃描。
WRAPPER = 32(For TEXT items.) This is a wrapper function and should not count as disabling recover.
協調Runtime
為了使垃圾收集正確運行,runtime必須知道在全域資料和大多數棧幀中指標的位置。Go的編譯器在編譯Go源檔案的時候產生這些資訊,但是在組譯工具中必須明確定義這些資訊。
帶有NOPTR標誌的資料符號,不包含指向runtime分配的資料的指標。帶有RODATA標誌的資料符號,是在唯讀記憶體中分配的,並且被看做是明確定義的NOPTR類型的資料。總的大小小於一個指標大小的資料符號,也被看做是明確定義的NOPTR類型。不能在組合語言中定義包含指標的符號;取而代之的是,符號必須定義在Go源檔案中。彙編源檔案中依然可以使用名字引用一個符號,即使這個符號沒有使用DATA和GLOBAL指令定義。一個很好的通用規則是,在Go代碼中定義非唯讀的資料,而不是在組譯工具中。
每個函數同樣需要給出註解,標明在其參數、返回結果和本地棧幀上生存的指標的位置。如果彙編函數沒有指標類型的結果並且沒有本地棧幀,或者沒有調用函數,唯一需要做的是為函數在同名的包中定義一個Go函數原型。在更複雜的情況下,需要明確的注釋出。這些注釋使用在標頭檔funcdata.h中定義的偽指令。
如果一個函數沒有參數並且沒有返回結果,就可以忽略指標資訊。這可以通過在TEXT指令中使用參數大小$n-0指出。否則,Go原檔案中的Go原型函數必須提供指標的資訊,即使彙編函數不是直接被Go代碼調用的。(這個原型會讓go vet檢查參數引用。) 在函數的開頭,參數都假設是已經被初始化的,但是函數的返回結果會假設是未初始化的。如果在執行CALL指令時,結果中HOLD住一個指標,函數應該在開頭就將返回結果初始化為零值,並且接著執行偽指令GO_RESULTS_INITIALIZED。這個指令記錄了當前返回結果已經被初始化,並且在當棧幀轉移和垃圾收集的時候掃描返回結果。非常具有代表性的是會安排彙編函數不返回指標或者不包含任何CALL指令;在Go標準庫中的彙編函數都沒有使用GO_RESULTS_INITIALIZED。
如果一個函數沒有本地棧幀,就可以忽略指標資訊。這可以通過在TEXT指令中使用棧幀大小$0-n指出。如果函數沒有包含CALL指令,同樣可以忽略指標資訊。否則,本地棧幀必須不包含指標(函數沒有本地棧幀且含有CALL指令的情況下),彙編中必須通過NO_LOCAL_POINTERS來確認這種情況。因為棧的縮放使用過移動棧來實現的,棧指標可能在函數調用的時候發生改變:甚至棧資料的指標必須不得保持在本地變數。
架構相關的細節
列出某種機器的全部指令和細節是不切實際的。如果想看到某種特定機器的指令,如32位Intel X86,查看對應編輯器的頂層的標頭檔,這裡是8l。就是說,在檔案$GOROOT/src/cmd/8l/8.out.h中包含了C枚舉量,叫做as,是指定架構的彙編器和連結器的機器指令的指令的寫法。
enum as{ AXXX, AAAA, AAAD, AAAM, AAAS, AADCB, ...
在上面的代碼中每個指令以大寫字母A開頭,所以AADCB表示ADCB指令(和進位位元組)。枚舉量是按照字母順序排序的,加上後面的附加內容(AXXX佔據了第0個位置,被當做一個獨立的指令)。對於在實際機器中的編碼,這些指令序列什麼都不需要改變。再說一遍,這是因為連結器會負責具體的細節。
在前一小節的例子中需要注意的是,資料在指令中的順序是從左至右: MOVQ $0, CX清除CX。即使在某些架構上順序是相反的,這種規則也是適用的。
這裡有一些對於Go所指的架構的相關的細節的描述。
32位Intel 386
runtime中指向g結構體(goroutine)的指標通過MMU中其他未使用的寄存器來維護(這也是Golang中擔心的)。如果源碼中包含了架構相關的標頭檔,那麼彙編器會定義一個OS相關的宏,就像下面這樣:
#include "zasm_GOOS_GOARCH.h"
在runtime內部,get_tls宏將g指標載入到它的參數寄存器中,並且g結構體中包含了m指標。使用CX寄存器來載入g和m的指令序列如下:
get_tls(CX)MOVL g(CX), AX // Move g into AX.MOVL g_m(AX), BX // Move g->m into BX.
64位Intel 386(amd64)
訪問g和m指標的彙編和386相似,只不過指令中使用MOVQ,而不是MOVL:
get_tls(CX)MOVQ g(CX), AX // Move g into AX.MOVQ g_m(AX), BX // Move g->m into BX.
ARM
寄存器R10和R11由編譯器和連結器保留。
R10指向g(goroutine)結構體。在彙編源碼中,這個指標必須以g來引用,R10這個名稱是不被認可的。
為了讓人類和編譯器更容易的寫彙編代碼,ARM的連結器允許通用的定址形式和像DIV、MOD這樣的偽操作,這可能不是使用一個單條的指令可以表現出來的。連結器使用多條指令來實現這些操作,經常使用R11來儲存臨時的值。在手寫的組譯工具中可以使用R11寄存器,但是這樣做就需要確認連結器還沒有使用R11來實現函數中的其他指令。
當定義一個TEXT段,聲明棧幀大小$-4會告訴連結器這個函數是一個leaf function,不需要在入口儲存LR寄存器。
leaf function的解釋:
Leaf function,A function that does not require a stack frame. A leaf function does not require a function table entry. It cannot call any functions, allocate space, or save any nonvolatile registers. It can leave the stack unaligned while it executes.
名稱SP總是會引用在之前提到過的虛擬棧幀。而硬體中的SP寄存器使用R13。