Go 語言 快速入門

2023-03-22 15:01 更新

原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch3-asm/ch3-01-basic.html


3.1 快速入門

Go 匯編程序始終是幽靈一樣的存在。我們將通過分析簡單的 Go 程序輸出的匯編代碼,然后照貓畫虎用匯編實現(xiàn)一個簡單的輸出程序。

3.1.1 實現(xiàn)和聲明

Go 匯編語言并不是一個獨立的語言,因為 Go 匯編程序無法獨立使用。Go 匯編代碼必須以 Go 包的方式組織,同時包中至少要有一個 Go 語言文件用于指明當前包名等基本包信息。如果 Go 匯編代碼中定義的變量和函數(shù)要被其它 Go 語言代碼引用,還需要通過 Go 語言代碼將匯編中定義的符號聲明出來。用于變量的定義和函數(shù)的定義 Go 匯編文件類似于 C 語言中的 .c 文件,而用于導出匯編中定義符號的 Go 源文件類似于 C 語言的 .h 文件。

3.1.2 定義整數(shù)變量

為了簡單,我們先用 Go 語言定義并賦值一個整數(shù)變量,然后查看生成的匯編代碼。

首先創(chuàng)建一個 pkg.go 文件,內(nèi)容如下:

package pkg

var Id = 9527

代碼中只定義了一個 int 類型的包級變量,并進行了初始化。然后用以下命令查看的 Go 語言程序?qū)膫螀R編代碼:

$ go tool compile -S pkg.go
"".Id SNOPTRDATA size=8
  0x0000 37 25 00 00 00 00 00 00                          '.......

其中 go tool compile 命令用于調(diào)用 Go 語言提供的底層命令工具,其中 -S 參數(shù)表示輸出匯編格式。輸出的匯編比較簡單,其中 "".Id 對應 Id 變量符號,變量的內(nèi)存大小為 8 個字節(jié)。變量的初始化內(nèi)容為 37 25 00 00 00 00 00 00,對應十六進制格式的 0x2537,對應十進制為 9527。SNOPTRDATA 是相關(guān)的標志,其中 NOPTR 表示數(shù)據(jù)中不包含指針數(shù)據(jù)。

以上的內(nèi)容只是目標文件對應的匯編,和 Go 匯編語言雖然相似當并不完全等價。Go 語言官網(wǎng)自帶了一個 Go 匯編語言的入門教程,地址在:https://golang.org/doc/asm 。

Go 匯編語言提供了 DATA 命令用于初始化包變量,DATA 命令的語法如下:

DATA symbol+offset(SB)/width, value

其中 symbol 為變量在匯編語言中對應的標識符,offset 是符號開始地址的偏移量,width 是要初始化內(nèi)存的寬度大小,value 是要初始化的值。其中當前包中 Go 語言定義的符號 symbol,在匯編代碼中對應 ·symbol,其中 “·” 中點符號為一個特殊的 unicode 符號。

我們采用以下命令可以給 Id 變量初始化為十六進制的 0x2537,對應十進制的 9527(常量需要以美元符號 $ 開頭表示):

DATA ·Id+0(SB)/1,$0x37
DATA ·Id+1(SB)/1,$0x25

變量定義好之后需要導出以供其它代碼引用。Go 匯編語言提供了 GLOBL 命令用于將符號導出:

GLOBL symbol(SB), width

其中 symbol 對應匯編中符號的名字,width 為符號對應內(nèi)存的大小。用以下命令將匯編中的 ·Id 變量導出:

GLOBL ·Id, $8

現(xiàn)在已經(jīng)初步完成了用匯編定義一個整數(shù)變量的工作。

為了便于其它包使用該 Id 變量,我們還需要在 Go 代碼中聲明該變量,同時也給變量指定一個合適的類型。修改 pkg.go 的內(nèi)容如下:

package pkg

var Id int

現(xiàn)狀 Go 語言的代碼不再是定義一個變量,語義變成了聲明一個變量(聲明一個變量時不能再進行初始化操作)。而 Id 變量的定義工作已經(jīng)在匯編語言中完成了。

我們將完整的匯編代碼放到 pkg_amd64.s 文件中:

#include "textflag.h"

GLOBL ·Id(SB),NOPTR,$8

DATA ·Id+0(SB)/1,$0x37
DATA ·Id+1(SB)/1,$0x25
DATA ·Id+2(SB)/1,$0x00
DATA ·Id+3(SB)/1,$0x00
DATA ·Id+4(SB)/1,$0x00
DATA ·Id+5(SB)/1,$0x00
DATA ·Id+6(SB)/1,$0x00
DATA ·Id+7(SB)/1,$0x00

文件名 pkg_amd64.s 的后綴名表示 AMD64 環(huán)境下的匯編代碼文件。

雖然 pkg 包是用匯編實現(xiàn),但是用法和之前的 Go 語言版本完全一樣:

package main

import pkg "pkg 包的路徑"

func main() {
    println(pkg.Id)
}

對于 Go 包的用戶來說,用 Go 匯編語言或 Go 語言實現(xiàn)并無任何區(qū)別。

3.1.3 定義字符串變量

在前一個例子中,我們通過匯編定義了一個整數(shù)變量。現(xiàn)在我們提高一點難度,嘗試通過匯編定義一個字符串變量。雖然從 Go 語言角度看,定義字符串和整數(shù)變量的寫法基本相同,但是字符串底層卻有著比單個整數(shù)更復雜的數(shù)據(jù)結(jié)構(gòu)。

實驗的流程和前面的例子一樣,還是先用 Go 語言實現(xiàn)類似的功能,然后觀察分析生成的匯編代碼,最后用 Go 匯編語言仿寫。首先創(chuàng)建 pkg.go 文件,用 Go 語言定義字符串:

package pkg

var Name = "gopher"

然后用以下命令查看的 Go 語言程序?qū)膫螀R編代碼:

$ go tool compile -S pkg.go
go.string."gopher" SRODATA dupok size=6
  0x0000 67 6f 70 68 65 72                                gopher
"".Name SDATA size=16
  0x0000 00 00 00 00 00 00 00 00 06 00 00 00 00 00 00 00  ................
  rel 0+8 t=1 go.string."gopher"+0

輸出中出現(xiàn)了一個新的符號 go.string."gopher",根據(jù)其長度和內(nèi)容分析可以猜測是對應底層的 "gopher" 字符串數(shù)據(jù)。因為 Go 語言的字符串并不是值類型,Go 字符串其實是一種只讀的引用類型。如果多個代碼中出現(xiàn)了相同的 "gopher" 只讀字符串時,程序鏈接后可以引用的同一個符號 go.string."gopher"。因此,該符號有一個 SRODATA 標志表示這個數(shù)據(jù)在只讀內(nèi)存段,dupok 表示出現(xiàn)多個相同標識符的數(shù)據(jù)時只保留一個就可以了。

而真正的 Go 字符串變量 Name 對應的大小卻只有 16 個字節(jié)了。其實 Name 變量并沒有直接對應 “gopher” 字符串,而是對應 16 字節(jié)大小的 reflect.StringHeader 結(jié)構(gòu)體:

type reflect.StringHeader struct {
    Data uintptr
    Len  int
}

從匯編角度看,Name 變量其實對應的是 reflect.StringHeader 結(jié)構(gòu)體類型。前 8 個字節(jié)對應底層真實字符串數(shù)據(jù)的指針,也就是符號 go.string."gopher" 對應的地址。后 8 個字節(jié)對應底層真實字符串數(shù)據(jù)的有效長度,這里是 6 個字節(jié)。

現(xiàn)在創(chuàng)建 pkg_amd64.s 文件,嘗試通過匯編代碼重新定義并初始化 Name 字符串:

GLOBL ·NameData(SB),$8
DATA  ·NameData(SB)/8,$"gopher"

GLOBL ·Name(SB),$16
DATA  ·Name+0(SB)/8,$·NameData(SB)
DATA  ·Name+8(SB)/8,$6

因為在 Go 匯編語言中,go.string."gopher" 不是一個合法的符號,因此我們無法通過手工創(chuàng)建(這是給編譯器保留的部分特權(quán),因為手工創(chuàng)建類似符號可能打破編譯器輸出代碼的某些規(guī)則)。因此我們新創(chuàng)建了一個 ·NameData 符號表示底層的字符串數(shù)據(jù)。然后定義 ·Name 符號內(nèi)存大小為 16 字節(jié),其中前 8 個字節(jié)用 ·NameData 符號對應的地址初始化,后 8 個字節(jié)為常量 6 表示字符串長度。

當用匯編定義好字符串變量并導出之后,還需要在 Go 語言中聲明該字符串變量。然后就可以用 Go 語言代碼測試 Name 變量了:

package main

import pkg "path/to/pkg"

func main() {
    println(pkg.Name)
}

不幸的是這次運行產(chǎn)生了以下錯誤:

pkgpath.NameData: missing Go type information for global symbol: size 8

錯誤提示匯編中定義的 NameData 符號沒有類型信息。其實 Go 匯編語言中定義的數(shù)據(jù)并沒有所謂的類型,每個符號只不過是對應一塊內(nèi)存而已,因此 NameData 符號也是沒有類型的。但是 Go 語言是帶垃圾回收器的語言,Go 匯編語言工作在這個自動垃圾回收體系框架內(nèi)。當 Go 語言的垃圾回收器在掃描到 NameData 變量的時候,無法知曉該變量內(nèi)部是否包含指針,因此就出現(xiàn)了這種錯誤。錯誤的根本原因并不是 NameData 沒有類型,而是 NameData 變量沒有標注是否會含有指針信息。

通過給 NameData 變量增加一個 NOPTR 標志,表示其中不會包含指針數(shù)據(jù)可以修復該錯誤:

#include "textflag.h"

GLOBL ·NameData(SB),NOPTR,$8

通過給 ·NameData 增加 NOPTR 標志的方式表示其中不含指針數(shù)據(jù)。我們也可以通過給 ·NameData 變量在 Go 語言中增加一個不含指針并且大小為 8 個字節(jié)的類型來修改該錯誤:

package pkg

var NameData [8]byte
var Name string

我們將 NameData 聲明為長度為 8 的字節(jié)數(shù)組。編譯器可以通過類型分析出該變量不會包含指針,因此匯編代碼中可以省略 NOPTR 標志?,F(xiàn)在垃圾回收器在遇到該變量的時候就會停止內(nèi)部數(shù)據(jù)的掃描。

在這個實現(xiàn)中,Name 字符串底層其實引用的是 NameData 內(nèi)存對應的 “gopher” 字符串數(shù)據(jù)。因此,如果 NameData 發(fā)生變化,Name 字符串的數(shù)據(jù)也會跟著變化。

func main() {
    println(pkg.Name)

    pkg.NameData[0] = '?'
    println(pkg.Name)
}

當然這和字符串的只讀定義是沖突的,正常的代碼需要避免出現(xiàn)這種情況。最好的方法是不要導出內(nèi)部的 NameData 變量,這樣可以避免內(nèi)部數(shù)據(jù)被無意破壞。

在用匯編定義字符串時我們可以換一種思維:將底層的字符串數(shù)據(jù)和字符串頭結(jié)構(gòu)體定義在一起,這樣可以避免引入 NameData 符號:

GLOBL ·Name(SB),$24

DATA ·Name+0(SB)/8,$·Name+16(SB)
DATA ·Name+8(SB)/8,$6
DATA ·Name+16(SB)/8,$"gopher"

在新的結(jié)構(gòu)中,Name 符號對應的內(nèi)存從 16 字節(jié)變?yōu)?24 字節(jié),多出的 8 個字節(jié)存放底層的 “gopher” 字符串?!ame 符號前 16 個字節(jié)依然對應 reflect.StringHeader 結(jié)構(gòu)體:Data 部分對應 $·Name+16(SB),表示數(shù)據(jù)的地址為 Name 符號往后偏移 16 個字節(jié)的位置;Len 部分依然對應 6 個字節(jié)的長度。這是 C 語言程序員經(jīng)常使用的技巧。

3.1.4 定義 main 函數(shù)

前面的例子已經(jīng)展示了如何通過匯編定義整型和字符串類型變量。我們現(xiàn)在將嘗試用匯編實現(xiàn)函數(shù),然后輸出一個字符串。

先創(chuàng)建 main.go 文件,創(chuàng)建并初始化字符串變量,同時聲明 main 函數(shù):

package main

var helloworld = "你好, 世界"

func main()

然后創(chuàng)建 main_amd64.s 文件,里面對應 main 函數(shù)的實現(xiàn):

TEXT ·main(SB), $16-0
    MOVQ ·helloworld+0(SB), AX; MOVQ AX, 0(SP)
    MOVQ ·helloworld+8(SB), BX; MOVQ BX, 8(SP)
    CALL runtime·printstring(SB)
    CALL runtime·printnl(SB)
    RET

TEXT ·main(SB), $16-0 用于定義 main 函數(shù),其中 $16-0 表示 main 函數(shù)的幀大小是 16 個字節(jié)(對應 string 頭部結(jié)構(gòu)體的大小,用于給 runtime·printstring 函數(shù)傳遞參數(shù)),0 表示 main 函數(shù)沒有參數(shù)和返回值。main 函數(shù)內(nèi)部通過調(diào)用運行時內(nèi)部的 runtime·printstring(SB) 函數(shù)來打印字符串。然后調(diào)用 runtime·printnl 打印換行符號。

Go 語言函數(shù)在函數(shù)調(diào)用時,完全通過棧傳遞調(diào)用參數(shù)和返回值。先通過 MOVQ 指令,將 helloworld 對應的字符串頭部結(jié)構(gòu)體的 16 個字節(jié)復制到棧指針 SP 對應的 16 字節(jié)的空間,然后通過 CALL 指令調(diào)用對應函數(shù)。最后使用 RET 指令表示當前函數(shù)返回。

3.1.5 特殊字符

Go 語言函數(shù)或方法符號在編譯為目標文件后,目標文件中的每個符號均包含對應包的絕對導入路徑。因此目標文件的符號可能非常復雜,比如 “path/to/pkg.(*SomeType).SomeMethod” 或“go.string."abc"”等名字。目標文件的符號名中不僅僅包含普通的字母,還可能包含點號、星號、小括弧和雙引號等諸多特殊字符。而 Go 語言的匯編器是從 plan9 移植過來的二把刀,并不能處理這些特殊的字符,導致了用 Go 匯編語言手工實現(xiàn) Go 諸多特性時遇到種種限制。

Go 匯編語言同樣遵循 Go 語言少即是多的哲學,它只保留了最基本的特性:定義變量和全局函數(shù)。其中在變量和全局函數(shù)等名字中引入特殊的分隔符號支持 Go 語言等包體系。為了簡化 Go 匯編器的詞法掃描程序的實現(xiàn),特別引入了 Unicode 中的中點 · 和大寫的除法 /,對應的 Unicode 碼點為 U+00B7 和 U+2215。匯編器編譯后,中點 · 會被替換為 ASCII 中的點 “.”,大寫的除法會被替換為 ASCII 碼中的除法 “/”,比如 math/rand·Int 會被替換為 math/rand.Int。這樣可以將中點和浮點數(shù)中的小數(shù)點、大寫的除法和表達式中的除法符號分開,可以簡化匯編程序詞法分析部分的實現(xiàn)。

即使暫時拋開 Go 匯編語言設計取舍的問題,在不同的操作系統(tǒng)不同等輸入法中如何輸入中點 · 和除法 / 兩個字符就是一個挑戰(zhàn)。這兩個字符在 https://golang.org/doc/asm 文檔中均有描述,因此直接從該頁面復制是最簡單可靠的方式。

如果是 macOS 系統(tǒng),則有以下幾種方法輸入中點 ·:在不開輸入法時,可直接用 option+shift+9 輸入;如果是自帶的簡體拼音輸入法,輸入左上角 ~ 鍵對應 ·,如果是自帶的 Unicode 輸入法,則可以輸入對應的 Unicode 碼點。其中 Unicode 輸入法可能是最安全可靠等輸入方式。

3.1.6 沒有分號

Go 匯編語言中分號可以用于分隔同一行內(nèi)的多個語句。下面是用分號混亂排版的匯編代碼:

TEXT ·main(SB), $16-0; MOVQ ·helloworld+0(SB), AX; MOVQ ·helloworld+8(SB), BX;
MOVQ AX, 0(SP);MOVQ BX, 8(SP);CALL runtime·printstring(SB);
CALL runtime·printnl(SB);
RET;

和 Go 語言一樣,也可以省略行尾的分號。當遇到末尾時,匯編器會自動插入分號。下面是省略分號后的代碼:

TEXT ·main(SB), $16-0
    MOVQ ·helloworld+0(SB), AX; MOVQ AX, 0(SP)
    MOVQ ·helloworld+8(SB), BX; MOVQ BX, 8(SP)
    CALL runtime·printstring(SB)
    CALL runtime·printnl(SB)
    RET

和 Go 語言一樣,語句之間多個連續(xù)的空白字符和一個空格是等價的。



以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號