Go 語(yǔ)言 快速入門

2023-03-22 15:01 更新

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


3.1 快速入門

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

3.1.1 實(shí)現(xiàn)和聲明

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

3.1.2 定義整數(shù)變量

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

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

package pkg

var Id = 9527

代碼中只定義了一個(gè) int 類型的包級(jí)變量,并進(jìn)行了初始化。然后用以下命令查看的 Go 語(yǔ)言程序?qū)?yīng)的偽匯編代碼:

$ 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 語(yǔ)言提供的底層命令工具,其中 -S 參數(shù)表示輸出匯編格式。輸出的匯編比較簡(jiǎn)單,其中 "".Id 對(duì)應(yīng) Id 變量符號(hào),變量的內(nèi)存大小為 8 個(gè)字節(jié)。變量的初始化內(nèi)容為 37 25 00 00 00 00 00 00,對(duì)應(yīng)十六進(jìn)制格式的 0x2537,對(duì)應(yīng)十進(jìn)制為 9527。SNOPTRDATA 是相關(guān)的標(biāo)志,其中 NOPTR 表示數(shù)據(jù)中不包含指針數(shù)據(jù)。

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

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

DATA symbol+offset(SB)/width, value

其中 symbol 為變量在匯編語(yǔ)言中對(duì)應(yīng)的標(biāo)識(shí)符,offset 是符號(hào)開(kāi)始地址的偏移量,width 是要初始化內(nèi)存的寬度大小,value 是要初始化的值。其中當(dāng)前包中 Go 語(yǔ)言定義的符號(hào) symbol,在匯編代碼中對(duì)應(yīng) ·symbol,其中 “·” 中點(diǎn)符號(hào)為一個(gè)特殊的 unicode 符號(hào)。

我們采用以下命令可以給 Id 變量初始化為十六進(jìn)制的 0x2537,對(duì)應(yīng)十進(jìn)制的 9527(常量需要以美元符號(hào) $ 開(kāi)頭表示):

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

變量定義好之后需要導(dǎo)出以供其它代碼引用。Go 匯編語(yǔ)言提供了 GLOBL 命令用于將符號(hào)導(dǎo)出:

GLOBL symbol(SB), width

其中 symbol 對(duì)應(yīng)匯編中符號(hào)的名字,width 為符號(hào)對(duì)應(yīng)內(nèi)存的大小。用以下命令將匯編中的 ·Id 變量導(dǎo)出:

GLOBL ·Id, $8

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

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

package pkg

var Id int

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

我們將完整的匯編代碼放到 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 包是用匯編實(shí)現(xiàn),但是用法和之前的 Go 語(yǔ)言版本完全一樣:

package main

import pkg "pkg 包的路徑"

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

對(duì)于 Go 包的用戶來(lái)說(shuō),用 Go 匯編語(yǔ)言或 Go 語(yǔ)言實(shí)現(xiàn)并無(wú)任何區(qū)別。

3.1.3 定義字符串變量

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

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

package pkg

var Name = "gopher"

然后用以下命令查看的 Go 語(yǔ)言程序?qū)?yīng)的偽匯編代碼:

$ 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)了一個(gè)新的符號(hào) go.string."gopher",根據(jù)其長(zhǎng)度和內(nèi)容分析可以猜測(cè)是對(duì)應(yīng)底層的 "gopher" 字符串?dāng)?shù)據(jù)。因?yàn)?Go 語(yǔ)言的字符串并不是值類型,Go 字符串其實(shí)是一種只讀的引用類型。如果多個(gè)代碼中出現(xiàn)了相同的 "gopher" 只讀字符串時(shí),程序鏈接后可以引用的同一個(gè)符號(hào) go.string."gopher"。因此,該符號(hào)有一個(gè) SRODATA 標(biāo)志表示這個(gè)數(shù)據(jù)在只讀內(nèi)存段,dupok 表示出現(xiàn)多個(gè)相同標(biāo)識(shí)符的數(shù)據(jù)時(shí)只保留一個(gè)就可以了。

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

type reflect.StringHeader struct {
    Data uintptr
    Len  int
}

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

現(xiàn)在創(chuàng)建 pkg_amd64.s 文件,嘗試通過(guò)匯編代碼重新定義并初始化 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

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

當(dāng)用匯編定義好字符串變量并導(dǎo)出之后,還需要在 Go 語(yǔ)言中聲明該字符串變量。然后就可以用 Go 語(yǔ)言代碼測(cè)試 Name 變量了:

package main

import pkg "path/to/pkg"

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

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

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

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

通過(guò)給 NameData 變量增加一個(gè) NOPTR 標(biāo)志,表示其中不會(huì)包含指針數(shù)據(jù)可以修復(fù)該錯(cuò)誤:

#include "textflag.h"

GLOBL ·NameData(SB),NOPTR,$8

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

package pkg

var NameData [8]byte
var Name string

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

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

func main() {
    println(pkg.Name)

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

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

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

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

3.1.4 定義 main 函數(shù)

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

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

package main

var helloworld = "你好, 世界"

func main()

然后創(chuàng)建 main_amd64.s 文件,里面對(duì)應(yīng) main 函數(shù)的實(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 個(gè)字節(jié)(對(duì)應(yīng) string 頭部結(jié)構(gòu)體的大小,用于給 runtime·printstring 函數(shù)傳遞參數(shù)),0 表示 main 函數(shù)沒(méi)有參數(shù)和返回值。main 函數(shù)內(nèi)部通過(guò)調(diào)用運(yùn)行時(shí)內(nèi)部的 runtime·printstring(SB) 函數(shù)來(lái)打印字符串。然后調(diào)用 runtime·printnl 打印換行符號(hào)。

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

3.1.5 特殊字符

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

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

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

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

3.1.6 沒(méi)有分號(hào)

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

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 語(yǔ)言一樣,也可以省略行尾的分號(hào)。當(dāng)遇到末尾時(shí),匯編器會(huì)自動(dòng)插入分號(hào)。下面是省略分號(hào)后的代碼:

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 語(yǔ)言一樣,語(yǔ)句之間多個(gè)連續(xù)的空白字符和一個(gè)空格是等價(jià)的。



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

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)