【Go】彙編快速指南

如果想要深入瞭解 Go 語言,Go 彙編是一個繞不過的環節。

本文以 Go 官方文檔 A Quick Guide to Go's Assembler 爲基礎對 Go 彙編進行介紹。

Go 彙編是在 Plan 9 彙編的基礎上進化出的新版本。如果需要進一步深入學習,還是建議閱讀 A Manual for the Plan 9 assembler 。

關於 Go 的彙編,最重要的一點是它不是底層機器碼的直接表示。而是進行了一層抽象,但是抽象的也不是很理想,所以稱爲 semi-abstract instruction set(半抽象)。所以一些細節指令精確地映射到機器碼,但有些沒有,這可以在本文的示例代碼中看到。

因爲每種處理器架構的指令集、寄存器各不相同,所以彙編代碼實現功能需要適配各種處理器架構;當然,也要適配各種操作系統。

環境

OS : Ubuntu 20.04.2 LTS; x86_64
Go : go version go1.16.2 linux/amd64

聲明

操作系統、處理器架構、Go版本不同,均有可能造成相同的源碼編譯後運行時的寄存器值、內存地址、數據結構等存在差異。

本文僅包含 linux/amd64 系統架構下的 64 位可執行程序的示例。

本文僅保證學習過程中的分析數據在當前環境下的準確有效性。

代碼清單

go.mod

module go-asm-guide
go 1.16

main.go

package main
import (
  "fmt"
  "unsafe"
)
type Text struct {
  Language string
  _        uint8
  Length   int
}
func add(a, b int) int
func addX(a, b int) int
// 獲取 Text 的 Length 字段的值
func length(text *Text) int
// 獲取 Text 結構體的大小
func sizeOfTextStruct() int
func main() {
  println(add(1, 2))
  println(addX(1, 2))
  text := &Text{
    Language: "chinese",
    Length:   1024,
  }
  fmt.Println(text)
  println(length(text))
  println(sizeOfTextStruct())
  println(unsafe.Sizeof(*text))
}

main.s

#include "textflag.h"
#include "go_asm.h" // 該文件自動生成
TEXT ·add(SB),NOSPLIT,$0-24
    MOVQ a+0(FP), AX // 讀取第一個參數
    MOVQ b+8(FP), BX // 讀取第二個參數
    ADDQ BX, AX
    MOVQ AX, ret+16(FP) // 保存結果
    RET
TEXT ·addX(SB),NOSPLIT,$0-24
    MOVQ a+0(FP), AX
    MOVQ b+8(FP), BX
    ADDQ BX, AX
    MOVQ $x(SB), BX // 讀取全局變量 x 的地址
    MOVQ 0(BX), BX  // 讀取全局變量 x 的值
    ADDQ BX, AX
    MOVQ AX, ret+16(FP)
    RET
TEXT ·length(SB),NOSPLIT,$0-16
    MOVQ text+0(FP), AX
    MOVQ Text_Length(AX), AX // 通過字段在結構體中的偏移量讀取字段值
    MOVQ AX, ret+8(FP)
    RET
TEXT ·sizeOfTextStruct(SB),NOSPLIT,$0-8
    MOVQ $Text__size, AX // 保存結構體的大小到 AX 寄存器
    MOVQ AX, ret+0(FP)
    RET
DATA  x+0(SB)/8, $10    // 初始化全局變量 x, 賦值爲 10
GLOBL x(SB), RODATA, $8 // 聲明全局變量 x

常用命令

Go 彙編的學習需要時間,不是天才、沒有牢固的彙編基礎的話,看一篇教程,花一天時間是不能立即熟練掌握的。

但是熟練使用工具能夠使我們有效地學習 Go 彙編。

編譯生成彙編

這是 Go 語言自帶的功能,通過以下命令即可查看。當然還可以指定各種參數進行詳細的研究,此處不再贅述。

$ cat x.go
package main
func main() {
  println(3)
}
$ go tool compile -S x.go
"".main STEXT size=77 args=0x0 locals=0x10 funcid=0x0
  0x0000 00000 (x.go:3)  TEXT  "".main(SB), ABIInternal, $16-0
  0x0000 00000 (x.go:3)  MOVQ  (TLS), CX
  0x0009 00009 (x.go:3)  CMPQ  SP, 16(CX)
  0x000d 00013 (x.go:3)  PCDATA  $0, $-2
  0x000d 00013 (x.go:3)  JLS  70
  0x000f 00015 (x.go:3)  PCDATA  $0, $-1
  0x000f 00015 (x.go:3)  SUBQ  $16, SP
  0x0013 00019 (x.go:3)  MOVQ  BP, 8(SP)
  0x0018 00024 (x.go:3)  LEAQ  8(SP), BP
  0x001d 00029 (x.go:3)  FUNCDATA  $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
  0x001d 00029 (x.go:3)  FUNCDATA  $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
  0x001d 00029 (x.go:4)  PCDATA  $1, $0
  0x001d 00029 (x.go:4)  NOP
  0x0020 00032 (x.go:4)  CALL  runtime.printlock(SB)
  0x0025 00037 (x.go:4)  MOVQ  $3, (SP)
  0x002d 00045 (x.go:4)  CALL  runtime.printint(SB)
  0x0032 00050 (x.go:4)  CALL  runtime.printnl(SB)
  0x0037 00055 (x.go:4)  CALL  runtime.printunlock(SB)
  0x003c 00060 (x.go:5)  MOVQ  8(SP), BP
  0x0041 00065 (x.go:5)  ADDQ  $16, SP
  0x0045 00069 (x.go:5)  RET
  0x0046 00070 (x.go:5)  NOP
  0x0046 00070 (x.go:3)  PCDATA  $1, $-1
  0x0046 00070 (x.go:3)  PCDATA  $0, $-2
  0x0046 00070 (x.go:3)  CALL  runtime.morestack_noctxt(SB)
  0x004b 00075 (x.go:3)  PCDATA  $0, $-1
  0x004b 00075 (x.go:3)  JMP  0
  0x0000 64 48 8b 0c 25 00 00 00 00 48 3b 61 10 76 37 48  dH..%....H;a.v7H
  0x0010 83 ec 10 48 89 6c 24 08 48 8d 6c 24 08 0f 1f 00  ...H.l$.H.l$....
  0x0020 e8 00 00 00 00 48 c7 04 24 03 00 00 00 e8 00 00  .....H..$.......
  0x0030 00 00 e8 00 00 00 00 e8 00 00 00 00 48 8b 6c 24  ............H.l$
  0x0040 08 48 83 c4 10 c3 e8 00 00 00 00 eb b3           .H...........
  rel 5+4 t=17 TLS+0
  rel 33+4 t=8 runtime.printlock+0
  rel 46+4 t=8 runtime.printint+0
  rel 51+4 t=8 runtime.printnl+0
  rel 56+4 t=8 runtime.printunlock+0
  rel 71+4 t=8 runtime.morestack_noctxt+0
go.cuinfo.packagename. SDWARFCUINFO dupok size=0
  0x0000 6d 61 69 6e                                      main
""..inittask SNOPTRDATA size=24
  0x0000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
  0x0010 00 00 00 00 00 00 00 00                          ........
gclocals·33cdeccccebe80329f1fdbee7f5874cb SRODATA dupok size=8
  0x0000 01 00 00 00 00 00 00 00

反編譯程序

go tool objdump

這是 Go 語言自帶的反編譯命令。

$ cat x.go
package main
func main() {
  println(3)
}
$ go build x.go
$ go tool objdump -s main.main x
TEXT main.main(SB) /home/foo/codes/goinmemory/go_asm/x.go
  x.go:3    0x45ec60    64488b0c25f8ffffff  MOVQ FS:0xfffffff8, CX      
  x.go:3    0x45ec69    483b6110    CMPQ 0x10(CX), SP      
  x.go:3    0x45ec6d    7637      JBE 0x45eca6        
  x.go:3    0x45ec6f    4883ec10    SUBQ $0x10, SP        
  x.go:3    0x45ec73    48896c2408    MOVQ BP, 0x8(SP)      
  x.go:3    0x45ec78    488d6c2408    LEAQ 0x8(SP), BP      
  x.go:4    0x45ec7d    0f1f00      NOPL 0(AX)        
  x.go:4    0x45ec80    e8fb05fdff    CALL runtime.printlock(SB)    
  x.go:4    0x45ec85    48c7042403000000  MOVQ $0x3, 0(SP)      
  x.go:4    0x45ec8d    e8ee0dfdff    CALL runtime.printint(SB)    
  x.go:4    0x45ec92    e8a908fdff    CALL runtime.printnl(SB)    
  x.go:4    0x45ec97    e86406fdff    CALL runtime.printunlock(SB)    
  x.go:5    0x45ec9c    488b6c2408    MOVQ 0x8(SP), BP      
  x.go:5    0x45eca1    4883c410    ADDQ $0x10, SP        
  x.go:5    0x45eca5    c3      RET          
  x.go:3    0x45eca6    e8b5afffff    CALL runtime.morestack_noctxt(SB)  
  x.go:3    0x45ecab    ebb3      JMP main.main(SB)

objdump

這是 Linux 環境中一個通用的反編譯工具,不僅僅適用於 Go 程序。

$ objdump --disassemble=main.main x
x:     file format elf64-x86-64
Disassembly of section .text:
000000000045ec60 <main.main>:
  45ec60:  64 48 8b 0c 25 f8 ff   mov    %fs:0xfffffffffffffff8,%rcx
  45ec67:  ff ff 
  45ec69:  48 3b 61 10            cmp    0x10(%rcx),%rsp
  45ec6d:  76 37                  jbe    45eca6 <main.main+0x46>
  45ec6f:  48 83 ec 10            sub    $0x10,%rsp
  45ec73:  48 89 6c 24 08         mov    %rbp,0x8(%rsp)
  45ec78:  48 8d 6c 24 08         lea    0x8(%rsp),%rbp
  45ec7d:  0f 1f 00               nopl   (%rax)
  45ec80:  e8 fb 05 fd ff         callq  42f280 <runtime.printlock>
  45ec85:  48 c7 04 24 03 00 00   movq   $0x3,(%rsp)
  45ec8c:  00 
  45ec8d:  e8 ee 0d fd ff         callq  42fa80 <runtime.printint>
  45ec92:  e8 a9 08 fd ff         callq  42f540 <runtime.printnl>
  45ec97:  e8 64 06 fd ff         callq  42f300 <runtime.printunlock>
  45ec9c:  48 8b 6c 24 08         mov    0x8(%rsp),%rbp
  45eca1:  48 83 c4 10            add    $0x10,%rsp
  45eca5:  c3                     retq   
  45eca6:  e8 b5 af ff ff         callq  459c60 <runtime.morestack_noctxt>
  45ecab:  eb b3                  jmp    45ec60 <main.main>

僞寄存器

某些符號(例如 R1 或 LR)是預定義的並指代寄存器。確切的集合取決於架構。

有四個預聲明的符號表示僞寄存器。這些不是真正的寄存器,而是工具鏈維護的虛擬寄存器。所有架構的僞寄存器集都是相同的:

所有開發者定義的符號都作爲僞寄存器 FP 和 SB 的偏移量進行引用。

SB

例如,foo(SB)表示一個全局符號foo,也就是我們在 Go 開發中聲明的函數名稱或者全局變量名稱。

GLOBL foo(SB), NOPTR, $8
DATA
foo
DATA foo+0(SB)/8, $10
TEXT
TEXT ·foo(SB), NOSPLIT, $0-0
  RET

FP

FP 僞寄存器是用於引用函數參數的虛擬幀指針 。編譯器維護一個虛擬幀指針,並將堆棧上的參數作爲與該僞寄存器的偏移量進行訪問。因此,在 64 位機器上,0(FP)表示函數的第一個參數,8(FP)表示函數的第二個參數,依此類推。

但是,當以這種方式引用函數參數時,必須在開頭放置一個名稱,如first_arg+0(FP)、 second_arg+8(FP)

彙編器強制執行這個約定,拒絕普通的0(FP)8(FP)。實際名稱在語義上無關緊要,但應用於記錄參數的名稱。值得強調的是FP始終是僞寄存器,而不是硬件寄存器,即使在具有硬件幀指針的架構上也是如此。

在上述代碼清單中,add函數的彙編實現,清楚地演示了這一點。

通常情況下,在 Go 語言中,函數返回值其實也是函數的一種參數。

SP

SP 僞寄存器是一個虛擬堆棧指針,用於引用幀局部變量和爲函數調用準備參數 。它指向本地棧幀內的最高地址,因此引用應使用 [−framesize, 0): x-8(SP),y-4(SP) 等範圍內的負偏移量。

在具有名爲 SP 的硬件寄存器的體系結構上,名稱前綴區分對虛擬堆棧指針的引用和對體系結構 SP 寄存器的引用。例如,x-8(SP)-8(SP)是不同的內存位置:第一個是指虛擬堆棧指針僞寄存器,而第二個是指硬件的 SP 寄存器。

Directives

在英文中,instruction表示指令,directive也表示指令,但是他們的含義實際差別比較大。

在本文中,筆者約定將directive稱爲關鍵字。

GLOBL symbol(SB), flags, width
GLOBL
var
const
DATA symbol+offset(SB)/width, value
TEXT [package]·symbol(SB), flags, $framesize-argumentsize
    // instructions
	RET

TEXT聲明函數時相當於func

單行:

#define NOSPLIT  4

多行:

#define DISPATCH(NAME,MAXSIZE)    \
  CMPQ  CX, $MAXSIZE;    \
  JA  3(PC);      \
  MOVQ  $NAME(SB), AX;    \
  JMP  AX
#include "textflag.h"

flags

Go 語言在runtime/textflag.h源文件中定義了一些特殊的宏,用於標記全局符號(函數和全局變量)。

#define NOPROF  1
#define DUPOK  2
#define NOSPLIT  4
#define RODATA  8
#define NOPTR  16
#define WRAPPER 32
#define NEEDCTXT 64
#define TLSBSS  256
#define NOFRAME 512
#define REFLECTMETHOD 1024
#define TOPFRAME 2048

它們的含義,只有開發者在對可執行文件的結構、數據在可執行文件中的存儲方式、函數調用約定等都有比較深入的瞭解之後,纔會變得顯而易見。否則,這玩意不是三言兩語能解釋清楚的。

與 Go 類型以及常量交互

如果一個包有任何 .s 文件,那麼go build將指示編譯器生成(emit)一個名爲go_asm.h的特殊頭文件,然後 .s 文件可以通過#include引用該文件。

該文件包含通過#define指令定義的 Go 結構字段的偏移量、Go 結構類型的大小以及當前包中定義的大多數使用const聲明的符號常量。Go 彙編應該避免對 Go 類型的佈局做出假設,而是使用這些常量。這提高了彙編代碼的可讀性,並使其對 Go 類型定義或 Go 編譯器使用的佈局規則中的數據佈局變化保持穩健。

在上述代碼清單中,length函數的彙編代碼中,讀取Text結構體的Length字段的值,是通過該字段的偏移量Text_Length讀取的。 Text_Length這個定義在go_asm.h文件中。雖然我們從來沒有定義這個頭文件,但是可以通過#include "go_asm.h"直接引用該文件。

同樣,sizeOfTextStruct函數的彙編代碼中,常量Text__size表示Text結構體對象的大小。

運行時協調

爲了正確運行垃圾收集,運行時必須知道指針在所有全局數據和大多數棧幀中的位置。Go 編譯器在編譯 Go 源文件時會生成此信息,但彙編程序必須明確定義它。

無法在彙編源文件中定義包含指針的符號,這樣的符號必須在 Go 源文件中定義;在彙編代碼中可以直接通過名稱應用這些符號。一個好的一般經驗法則是在 Go 中定義所有非RODATA數據,而不是在彙編中定義 。

彙編函數應該總是被賦予 Go 原型,既可以爲參數和結果提供指針信息,也可以使用go vet檢查用於訪問它們的偏移量是否正確。例如代碼清單中的函數聲明:

func add(a, b int) int
func addX(a, b int) int
func length(text *Text) int
func sizeOfTextStruct() int

運行效果

編譯並運行程序,然後反編譯可執行程序,對比源代碼,我們可以看到一些變化。

本文先介紹到這裏,歡迎大家溝通指正。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/7Bsmt9F-AZBjsIeVENo3ng