1. 程式人生 > >Golang彙編層面程式碼分析

Golang彙編層面程式碼分析

這篇文件是對於Go編譯器套件(6g, 8g, etc.)中不常用的組合語言的快速預覽,涵蓋面不是很廣泛。

Go的組合語言基於Plan 9的彙編,Plan 9網站的頁面上有詳細描述。如果你想編寫組合語言,你應該讀這篇文件,雖然它是Plan 9相關的。這邊文件總結了彙編的語法,並且描述了使用匯編語言和Go程式互動時的特殊之處。

有一點是很重要的是,Go的彙編中沒有直接體現出底層的機器。有些彙編細節能直接對應到機器,但有些不是。這是因為編譯器套件在常規過程中不需要組合語言。取而代之的是,編譯器產生二進位制的不完整的彙編指令集,連結器會完成它。實際上,連結器做了彙編指令的選擇,所以當你看到類似於MOV

這樣的指令,連結器的實際操作可能不是一個移動指令,也許是清除或者載入。或者可能會根據指令的名字對應到真實的機器指令。總體上,機器相關的指令操作趨向於體現出真實的機器指令,但是一些通用的概念類似於移動記憶體資料、呼叫子例程、返回等操作就更抽象了。具體的細節和架構相關,我們為這種不精確性道歉。

彙編程式是生成中間碼的一種方法,未完整定義的指令集作為連結器的輸入。 如果你想看到特定CPU架構下的彙編指令集,如amd64,在Go標準庫的原始檔中就有許多例子,在runtimemath/big包中。 或者你還可以參照下面的程式,來檢查編譯器的彙編輸出:

$ cat x.go
package main

func
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-0 0001 (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,$8 0005 (x.go:4) CALL ,runtime.printint+0
(SB) 0006 (x.go:4) PCDATA $0,$-1 0007 (x.go:4) PCDATA $0,$0 0008 (x.go:4) CALL ,runtime.printnl+0(SB) 0009 (x.go:4) PCDATA $0,$-1 0010 (x.go:5) RET , ...

FUNCDATAPCDATA指令用來包含一些垃圾收集器需要的資訊。它們由編譯器產生。

符號

有些符號,例如PCR0SP,是預定義的並且是對一個暫存器的引用。 另外還有兩種預定義的符號,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.Printfmath/rand.Int。但是彙編器會把句點和斜槓當做標點符號來對待,這些字元不能當做符號的識別符號。取而代之的是,允許在彙編程式中使用中點字元(Unicode字元00B7)和除法斜槓(原文中是division slash,Unicode字元2215,區別於forward slash)當做識別符號並且把它們重寫成純句點和斜槓。 在組合語言的原始檔中,上面的符號寫成fmt·Printfmath∕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, $0xf4f8fcff
DATA divtab<>+0x04(SB)/4, $0xe6eaedf0
...
DATA divtab<>+0x3c(SB)/4, $0x81828384
GLOBL divtab<>(SB), RODATA, $64

GLOBL 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暫存器來載入gm的指令序列如下:

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)

訪問gm指標的彙編和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

暫存器R10R11由編譯器和連結器保留。

R10指向g(goroutine)結構體。在彙編原始碼中,這個指標必須以g來引用,R10這個名稱是不被認可的。

為了讓人類和編譯器更容易的寫彙編程式碼,ARM的連結器允許通用的定址形式和像DIVMOD這樣的偽操作,這可能不是使用一個單條的指令可以表現出來的。連結器使用多條指令來實現這些操作,經常使用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