chikaku

且听风吟

永远是深夜有多好。
github
email

Go 匯編與 ABI

封面圖出自 Renee French 遵循 Creative Commons 4.0 Attributions license

寄存器#

Plan9amd64通常用途
AXraxAccumulator存放算數操作數和返回值
BXrbxBase Register存儲內存基址 (結構體或數組) 或指針
CXrcxCount Register計數操作如循環計數器
DXrdxData Register存儲數據如乘數 / 除數
DIrdiDestination Index目的操作數的偏移量
SIrsiSource Index源操作數的偏移量
BPrbpBase Pointer保存棧基址
SPrspStack Pointer保存棧頂指針
PCripProgram Counter程序計數器
R8-R14r8-r14通用寄存器

伪寄存器#

名稱用途
FP(Frame pointer)參數和局部變量的基址
PC(Program counter)程序計數器
SB(Static base pointer)全局變量基址
SP(Stack pointer)棧指針 (當前棧幀的最高地址)

所有用戶程序中定義的本地變量都會被編譯成 FP 和 SB 這兩個寄存器上的基址加上一定量的偏移量。

伪寄存器 SB 用於引用全局變量如:

  • foo (SB) 表示全局變量 foo 的內存地址
  • foo<>(SB) 表示全局變量 foo 只在當前文件可見
  • foo+4 (SB) 表示 foo 內存基址加上四個字節偏移量

伪寄存器 FP 用於保存虛擬的棧指針,以便引用函數參數,編譯器使用 FP 加上偏移量來訪問當前函數的參數,在訪問時還可以附加一個參數名,儘管沒有實際的用途,但是有助於理解和閱讀代碼,此外匯編器也強制使用 FP 時必須附加參數名如:

  • 0 (FP) 或者是 first_arg+0 (FP) 表示當前函數的第一個參數
  • 8 (FP) 或者是 second_arg+8 (FP) 表示當前函數的第二個參數 (第一個參數占 8 字節)

注意:無論是否存在硬件 FP 寄存器 FP 都是一個伪寄存器。

伪寄存器 SP 保存虛擬的棧指針,用於訪問當前棧幀內的局部變量和函數調用參數,其指向當前棧幀的最高地址,則偏移量只能在 [−framesize, 0) 範圍內。如 x-8 (SP) y-4 (SP) 在具備硬件 SP 寄存器的體系結構中,使用帶參數名前綴和不帶參數名前綴訪問 SP 寄存器有以下區別:

  • x-8 (SP) 帶參數前綴訪問時使用的是伪寄存器 SP
  • -8 (SP) 不帶參數前綴訪問時使用的是硬件寄存器 SP

符號定義#

在 Go 的對象文件和二進制文件中,完整的符號名由包路徑跟上一個點加上一個符號名組成如 math/rand.Int 在源文件轉匯編的過程中,編譯器會將其轉換成 math∕rand・Int 這裡斜杠和點被轉換成 U+2215 和 U+00B7 在手寫匯編定義符號無需包含完整的包名,在鏈接過程中,鏈接器會自動為每個以。開頭的符號添加完整的包名,因此只需定義類似・Int 的符號名即可。

匯編器使用編程序指令 directives 將代碼或數據綁定到符號,比如:

函數符號定義#

函數 (代碼段) 通過 TEXT 指令定義如:

TEXT runtime·profileloop(SB),NOSPLIT,$24-8
  • pkgname: 包名,可以省略
  • funcname (SB): 函數名,因為函數本身是全局符號,通過 SB 引用
  • NOSPLIT: 匯編指令參數,後續會介紹
  • $24-8: 分別標識此函數的棧幀和 (參數 + 返回值) 的大小,當使用 NOSPLIT 參數時必須提供棧幀大小

全局數據符號定義#

全局數據通過一組 DATA 指令加上一個 GLOBAL 指令來定義 DATA 指令的格式是

DATA symbol+offset(SB)/width, value

表示在符號 symbol 的指定偏移量 offset 處初始化一個大小為 width 初始值為 value 的內存段,多個 DATA 指令的 offset/width 必須是連續的。GLOBAL 指令用於聲明全局符號,需要指定符號名,參數和大小。如果 DATA 指令沒有初始化值則 GLOBAL 會將其初始化為 0 如:

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

上述代碼明和初始化了一個 64byte 的只讀全局變量 divtab 和一個 4byte 的全局變量 runtime・tlsoffset 並將它們都初始化為 0 這裡聲明了 NOPTR 即這些數據都不包含指針

符號定義的參數#

每條匯編指令可以包含一個或兩個參數,如果有兩個參數,則第一個參數必須是標誌位掩碼。所有的參數定義需要通過 #include "textflag.h" 引入,參數如下:

  • DUPOK: 允許同二進制中有多個相同的符號,鏈接器會選擇其中之一
  • NOSPLIT: 用於 TEXT 指令,標記不需要插入棧溢出檢查
  • RODATA: 用於 DATA 和 GLOBAL 指令,將數據放入只讀段
  • NOPTR: 用於 DATA 和 GLOBAL 指令,標記數據不包含指針,不需要 GC 掃描
  • WRAPPER: 用於 TEXT 指令,標記該函數只是一個 wrap 不要禁用 recover 參見源碼 src/debug/gosym/pclntab.go
  • NEEDCTXT: 用於 TEXT 指令,標記該函數為閉包需使用傳入的上下文寄存器
  • TLSBSS: 用於 DATA 和 GLOBAL 指令,標記分配 TLS 存儲單元並將其偏移量存儲在變量中
  • NOFRAME: 用於 TEXT 指令,標記不在函數中插入分配棧幀空間的指令,適用於零棧幀函數
  • REFLECTMETHOD: 標記函數可以調用 reflect.Type.Method/reflect.Type.MethodByName
  • TOPFRAME: 用於 TEXT 指令,標記此函數為調用棧的最上層,棧展開應在此停止
  • ABIWRAPPER: 用於 TEXT 指令,標記該函數為 ABI 的 wrapper

在匯編中使用 Go 類型和常量#

如果一個包中包含 .s 文件則在進行 build 時編譯器會輸出一個特殊的頭文件 go_asm.h 該頭文件包含了很多常量的定義比如:結構體字段偏移量,結構體類型大小以及當前包中定義的常量。匯編中可以通過包含此頭文件來使用 Go 類型,在 go_asm.h 文件中,各種類型以以下形式定義:

  • 常量: const_name
  • 結構體字段偏移量: type_field
  • 結構體大小: type__size
const bufSize = 1024

type reader struct {
    buf [bufSize]byte
    r   int
}

以上代碼為例,在匯編代碼中可以:

  • 通過 const_bufSize 使用常量 bufSize
  • 通過 reader__size 獲取結構體 reader 的大小
  • 通過 reader_buf 和 reader_r 獲取 buf 和 r 字段的偏移量。如果 R1 包含一個 reader 指針則可以通過 reader_buf (R1) 和 reader_r (R1) 來訪問兩個字段

運行時#

為確保 GC 運行的正確性,運行時必須了解棧幀和全局變量中包含的所有指針。編譯器在編譯 go 代碼會自動插入這些信息,但在匯編代碼中需要顯式定義。有 NOPTR 參數的數據符號不包含運行時分配的數據指針;有 RODATA 參數的符號數據在內存只讀段中分配,因此也隱含 NOPTR 標記;小於指針大小的類型也自然不可能包含指針。雖然無法在匯編代碼中定義包含指針的符號,但是可以在 go 代碼中定義並在匯編代碼中通過相應的符號來引用。一般最好的的實踐方式是:在 go 中定義所有的非只讀符號,而不是匯編代碼中定義。

每個函數需要標註其參數,返回值和棧幀中的存活指針的位置。如果匯編函數沒有指針返回值,沒有函數調用和棧幀空間需求,只需要在同一包中定義 go 函數原型 (簽名) 即可。對於更複雜的情況,需要包含 funcdata.h 頭文件來引用伪匯編指令進行顯式標註。沒有參數和返回值 (TEXT 指令中標註 $n-0) 的函數可以忽略指針信息。除此之外,所有指針信息必須通過 go 代碼中的函數原型 (簽名) 提供,即使是不會被 go 函數直接調用的匯編函數。

在函數的開頭,可以假設參數已經被初始化,但是返回值未初始化,如果返回值中存在函數調用期間存活的指針,函數應該一開始就將返回值置空並執行 GO_RESULTS_INITIALIZED 伪指令,這個指令記錄了返回值已經被初始化,在棧轉移 (擴容) 和 GC 期間應該被掃描。大部分情況下可以有意避免在匯編函數中返回指針,至少在標準庫中沒有匯編函數使用 GO_RESULTS_INITIALIZED

如果函數沒有本地棧幀 (即在 TEXT 指令中聲明 $n-0) 或函數中不包含 CALL 指令則可以忽略指針信息。除此以外本地棧幀不能包含指針,匯編器會執行伪指令 NO_LOCAL_POINTERS 進行驗證。由於棧的擴張和收縮是通過複製移動棧空間實現的,函數調用期間棧指針可能會改變,因此即使是指向棧上數據的指針也不應該保存在局部變量中。

匯編函數應該始終給出 Go 原型,這樣既可以為參數和返回值提供指針信息,又可以讓 go vet 檢查偏移量的使用是否正確。

內存佈局#

Go 語言內置的基礎類型大小和對齊方式及複合類型 (結構體) 內字段偏移量的計算可以查看 ABI 文檔 Memory layout 對於其他類型:

  • map/chan/func 類型的內存佈局等價於 *T
  • 陣列類型 [N]T 內存佈局由 N 個 T 類型組成的連續內存構成
  • string 類型在內存中由兩部分組成:標識字符串按字節長度的 int 和指向 [cap]T 的指針
  • 切片類型 []T 在內存中由三部分組成:標識切片有效長度的 int 標識切片容量大小的 int 和指向 [cap]T 的指針

結構體類型的內存是由其各個字段的內存連續組成的。如 struct { f1 t1; ...; fM tM } 類型的結構體內存中的順序為 t1, ..., tM, tP 這裡 tP 是一個額外的字節當且僅當在最後一個字段 tM 的大小為零而前面的任意一個字段 ti 的大小不為零時填充。通過實驗可以得知,在結構體中對大小為零的字段取址時,總是返回該字段後面第一個非零大小類型字段的地址。所以在最後一個零大小字段後面填充了一个字节以保證取址不會訪問到外部的內存上去。

type S struct { // 0xc00034c000
    A struct{}  // 0xc00034c000
    B int       // 0xc00034c000
    C struct{}  // 0xc00034c008
    D struct{}  // 0xc00034c008
    E int       // 0xc00034c008
    F struct{}  // 0xc00034c010
}

空接口 interface {} 類型 runtime.eface 由以下部分組成:

  • 指向運行時動態數據類型描述的指針
  • 指向運行時動態數據值的 unsafe.Pointer 指針

非空接口類型由以下部分組成:

  • 指向 runtime.itab 的指針包含:
    • runtime.interfacetype 包含此接口相關的 method 指針
    • 指向運行時動態數據類型描述的指針
  • 指向運行時動態數據值的 unsafe.Pointer 指針

接口類型可以是直接或者是間接類型:

  • 直接類型的接口直接存儲了數據
  • 間接類型的接口存儲了指向數據的指針
  • 如果接口內的值只有單個指針組成,那麼這個接口類型只能是直接類型

以上就是 Go 所有類型的內存佈局結構,但是在手寫匯編函數時不應該依賴這些規則,而是引用 go_asm.h 頭文件中定義的常量。

函數調用的參數和返回值傳遞#

函數調用過程中參數 / 返回值通過棧和硬件寄存器傳遞。每個參數 / 返回值可能會整體存儲在寄存器上 (可以用多個寄存器同時存儲一個參數 / 返回值), 或存儲在棧上。通常情況下,由於訪問寄存器比訪問內存要快,參數 / 返回值優先存儲在寄存器中,然而當剩余的寄存器無法存儲完整值或者包含有非定長數組時,參數 / 返回值只能通過棧傳遞。

每個體系結構都定義了一組整數寄存器和一組浮點數寄存器,從高層次來看,所有參數和返回值類型都可以拆分成基礎類型並按順序存儲在寄存器上。參數和返回值可以共用一個寄存器,但是不能共用同一段棧空間。調用者會在調用棧上為存儲在寄存器中的參數保留一段溢出空間,但是不會填充這一段空間。具體參數 / 返回值在寄存器或棧上分配的算法比較複雜,參考 Function call argument and result passing

在調用方法之前,需要先在調用者的棧幀中分配一段內存來存儲方法接收者、棧上參數、棧上返回值以及寄存器參數溢出空間。然後將對應的參數值存儲到寄存器或棧空間中,執行調用操作。在執行調用時,返回值棧空間、溢出空間和返回值寄存器都沒有被初始化,被調用者需要在返回之前將返回值存儲到按照算法分配的相應寄存器或棧幀空間中。由於不存在 callee-save 寄存器,因此所有沒有明確意義的寄存器都可能會被覆蓋,包括參數寄存器。

在帶有 R0-R9 整數寄存器的 64 位架構下,函數 f 簽名及其調用棧空間如下:

func f(a1 uint8, a2 [2]uintptr, a3 uint8) (
    r1 struct { x uintptr; y [2]uintptr },
    r2 string,
)

// 棧空間佈局
// a2      [2]uintptr
// r1.x    uintptr
// r1.y    [2]uintptr
// a1Spill uint8
// a3Spill uint8
// _       [6]uint8  // alignment padding

由於 a2 和 r1 包含數組,因此它們只能分配到棧上進行賦值,其他的參數和返回值可以分配到寄存器上。r2 被解構成兩個可以獨立的在寄存器上賦值的部分。在調用時 a1 會賦值給 R0 寄存器 a3 會賦值給 R1 寄存器 a2 會在棧上賦值。在返回時 r2.base 會賦值給 R0 寄存器 r2.len 會賦值給 R1 寄存器 r1.x 和 r1.y 會在棧空間中賦值。

閉包#

函數值如 var f func 相當於一個指向閉包對象的指針,閉包由閉包函數入口地址和一些與閉包環境相關的內存空間組成。閉包的調用規則和靜態函數與基本一致,唯一的例外在於:每個體系結構下都設定了一個特殊的閉包上下文寄存器,調用閉包前會將閉包對象指針保存在這個寄存器上。這樣即使是閉包函數退出後,還能通過這個特殊的寄存器引用到閉包內的對象。

常用指令#

// TODO

Reference#

官方文檔和代碼:

其他資料:

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。