原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch3-asm/ch3-01-basic.html
Go 匯編程序始終是幽靈一樣的存在。我們將通過分析簡單的 Go 程序輸出的匯編代碼,然后照貓畫虎用匯編實現(xiàn)一個簡單的輸出程序。
Go 匯編語言并不是一個獨立的語言,因為 Go 匯編程序無法獨立使用。Go 匯編代碼必須以 Go 包的方式組織,同時包中至少要有一個 Go 語言文件用于指明當前包名等基本包信息。如果 Go 匯編代碼中定義的變量和函數(shù)要被其它 Go 語言代碼引用,還需要通過 Go 語言代碼將匯編中定義的符號聲明出來。用于變量的定義和函數(shù)的定義 Go 匯編文件類似于 C 語言中的 .c
文件,而用于導出匯編中定義符號的 Go 源文件類似于 C 語言的 .h
文件。
為了簡單,我們先用 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ū)別。
在前一個例子中,我們通過匯編定義了一個整數(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)常使用的技巧。
前面的例子已經(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ù)返回。
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 輸入法可能是最安全可靠等輸入方式。
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ù)的空白字符和一個空格是等價的。
![]() | ![]() |
更多建議: