原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch3-asm/ch3-01-basic.html
Go 匯編程序始終是幽靈一樣的存在。我們將通過分析簡單的 Go 程序輸出的匯編代碼,然后照貓畫虎用匯編實現一個簡單的輸出程序。
Go 匯編語言并不是一個獨立的語言,因為 Go 匯編程序無法獨立使用。Go 匯編代碼必須以 Go 包的方式組織,同時包中至少要有一個 Go 語言文件用于指明當前包名等基本包信息。如果 Go 匯編代碼中定義的變量和函數要被其它 Go 語言代碼引用,還需要通過 Go 語言代碼將匯編中定義的符號聲明出來。用于變量的定義和函數的定義 Go 匯編文件類似于 C 語言中的 .c
文件,而用于導出匯編中定義符號的 Go 源文件類似于 C 語言的 .h
文件。
為了簡單,我們先用 Go 語言定義并賦值一個整數變量,然后查看生成的匯編代碼。
首先創(chuàng)建一個 pkg.go
文件,內容如下:
package pkg
var Id = 9527
代碼中只定義了一個 int 類型的包級變量,并進行了初始化。然后用以下命令查看的 Go 語言程序對應的偽匯編代碼:
$ go tool compile -S pkg.go
"".Id SNOPTRDATA size=8
0x0000 37 25 00 00 00 00 00 00 '.......
其中 go tool compile
命令用于調用 Go 語言提供的底層命令工具,其中 -S
參數表示輸出匯編格式。輸出的匯編比較簡單,其中 "".Id
對應 Id 變量符號,變量的內存大小為 8 個字節(jié)。變量的初始化內容為 37 25 00 00 00 00 00 00
,對應十六進制格式的 0x2537,對應十進制為 9527。SNOPTRDATA
是相關的標志,其中 NOPTR 表示數據中不包含指針數據。
以上的內容只是目標文件對應的匯編,和 Go 匯編語言雖然相似當并不完全等價。Go 語言官網自帶了一個 Go 匯編語言的入門教程,地址在:https://golang.org/doc/asm 。
Go 匯編語言提供了 DATA 命令用于初始化包變量,DATA 命令的語法如下:
DATA symbol+offset(SB)/width, value
其中 symbol 為變量在匯編語言中對應的標識符,offset 是符號開始地址的偏移量,width 是要初始化內存的寬度大小,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 為符號對應內存的大小。用以下命令將匯編中的 ·Id 變量導出:
GLOBL ·Id, $8
現在已經初步完成了用匯編定義一個整數變量的工作。
為了便于其它包使用該 Id 變量,我們還需要在 Go 代碼中聲明該變量,同時也給變量指定一個合適的類型。修改 pkg.go
的內容如下:
package pkg
var Id int
現狀 Go 語言的代碼不再是定義一個變量,語義變成了聲明一個變量(聲明一個變量時不能再進行初始化操作)。而 Id 變量的定義工作已經在匯編語言中完成了。
我們將完整的匯編代碼放到 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 包是用匯編實現,但是用法和之前的 Go 語言版本完全一樣:
package main
import pkg "pkg 包的路徑"
func main() {
println(pkg.Id)
}
對于 Go 包的用戶來說,用 Go 匯編語言或 Go 語言實現并無任何區(qū)別。
在前一個例子中,我們通過匯編定義了一個整數變量?,F在我們提高一點難度,嘗試通過匯編定義一個字符串變量。雖然從 Go 語言角度看,定義字符串和整數變量的寫法基本相同,但是字符串底層卻有著比單個整數更復雜的數據結構。
實驗的流程和前面的例子一樣,還是先用 Go 語言實現類似的功能,然后觀察分析生成的匯編代碼,最后用 Go 匯編語言仿寫。首先創(chuàng)建 pkg.go
文件,用 Go 語言定義字符串:
package pkg
var Name = "gopher"
然后用以下命令查看的 Go 語言程序對應的偽匯編代碼:
$ 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
輸出中出現了一個新的符號 go.string."gopher",根據其長度和內容分析可以猜測是對應底層的 "gopher" 字符串數據。因為 Go 語言的字符串并不是值類型,Go 字符串其實是一種只讀的引用類型。如果多個代碼中出現了相同的 "gopher" 只讀字符串時,程序鏈接后可以引用的同一個符號 go.string."gopher"。因此,該符號有一個 SRODATA 標志表示這個數據在只讀內存段,dupok 表示出現多個相同標識符的數據時只保留一個就可以了。
而真正的 Go 字符串變量 Name
對應的大小卻只有 16 個字節(jié)了。其實 Name
變量并沒有直接對應 “gopher” 字符串,而是對應 16 字節(jié)大小的 reflect.StringHeader 結構體:
type reflect.StringHeader struct {
Data uintptr
Len int
}
從匯編角度看,Name
變量其實對應的是 reflect.StringHeader
結構體類型。前 8 個字節(jié)對應底層真實字符串數據的指針,也就是符號 go.string."gopher" 對應的地址。后 8 個字節(jié)對應底層真實字符串數據的有效長度,這里是 6 個字節(jié)。
現在創(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)建(這是給編譯器保留的部分特權,因為手工創(chuàng)建類似符號可能打破編譯器輸出代碼的某些規(guī)則)。因此我們新創(chuàng)建了一個 ·NameData 符號表示底層的字符串數據。然后定義 ·Name 符號內存大小為 16 字節(jié),其中前 8 個字節(jié)用 ·NameData 符號對應的地址初始化,后 8 個字節(jié)為常量 6 表示字符串長度。
當用匯編定義好字符串變量并導出之后,還需要在 Go 語言中聲明該字符串變量。然后就可以用 Go 語言代碼測試 Name
變量了:
package main
import pkg "path/to/pkg"
func main() {
println(pkg.Name)
}
不幸的是這次運行產生了以下錯誤:
pkgpath.NameData: missing Go type information for global symbol: size 8
錯誤提示匯編中定義的 NameData 符號沒有類型信息。其實 Go 匯編語言中定義的數據并沒有所謂的類型,每個符號只不過是對應一塊內存而已,因此 NameData 符號也是沒有類型的。但是 Go 語言是帶垃圾回收器的語言,Go 匯編語言工作在這個自動垃圾回收體系框架內。當 Go 語言的垃圾回收器在掃描到 NameData 變量的時候,無法知曉該變量內部是否包含指針,因此就出現了這種錯誤。錯誤的根本原因并不是 NameData 沒有類型,而是 NameData 變量沒有標注是否會含有指針信息。
通過給 NameData 變量增加一個 NOPTR 標志,表示其中不會包含指針數據可以修復該錯誤:
#include "textflag.h"
GLOBL ·NameData(SB),NOPTR,$8
通過給 ·NameData 增加 NOPTR 標志的方式表示其中不含指針數據。我們也可以通過給 ·NameData 變量在 Go 語言中增加一個不含指針并且大小為 8 個字節(jié)的類型來修改該錯誤:
package pkg
var NameData [8]byte
var Name string
我們將 NameData 聲明為長度為 8 的字節(jié)數組。編譯器可以通過類型分析出該變量不會包含指針,因此匯編代碼中可以省略 NOPTR 標志?,F在垃圾回收器在遇到該變量的時候就會停止內部數據的掃描。
在這個實現中,Name 字符串底層其實引用的是 NameData 內存對應的 “gopher” 字符串數據。因此,如果 NameData 發(fā)生變化,Name 字符串的數據也會跟著變化。
func main() {
println(pkg.Name)
pkg.NameData[0] = '?'
println(pkg.Name)
}
當然這和字符串的只讀定義是沖突的,正常的代碼需要避免出現這種情況。最好的方法是不要導出內部的 NameData 變量,這樣可以避免內部數據被無意破壞。
在用匯編定義字符串時我們可以換一種思維:將底層的字符串數據和字符串頭結構體定義在一起,這樣可以避免引入 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"
在新的結構中,Name 符號對應的內存從 16 字節(jié)變?yōu)?24 字節(jié),多出的 8 個字節(jié)存放底層的 “gopher” 字符串?!ame 符號前 16 個字節(jié)依然對應 reflect.StringHeader 結構體:Data 部分對應 $·Name+16(SB)
,表示數據的地址為 Name 符號往后偏移 16 個字節(jié)的位置;Len 部分依然對應 6 個字節(jié)的長度。這是 C 語言程序員經常使用的技巧。
前面的例子已經展示了如何通過匯編定義整型和字符串類型變量。我們現在將嘗試用匯編實現函數,然后輸出一個字符串。
先創(chuàng)建 main.go 文件,創(chuàng)建并初始化字符串變量,同時聲明 main 函數:
package main
var helloworld = "你好, 世界"
func main()
然后創(chuàng)建 main_amd64.s 文件,里面對應 main 函數的實現:
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
函數,其中 $16-0
表示 main
函數的幀大小是 16 個字節(jié)(對應 string 頭部結構體的大小,用于給 runtime·printstring
函數傳遞參數),0
表示 main
函數沒有參數和返回值。main
函數內部通過調用運行時內部的 runtime·printstring(SB)
函數來打印字符串。然后調用 runtime·printnl
打印換行符號。
Go 語言函數在函數調用時,完全通過棧傳遞調用參數和返回值。先通過 MOVQ 指令,將 helloworld 對應的字符串頭部結構體的 16 個字節(jié)復制到棧指針 SP 對應的 16 字節(jié)的空間,然后通過 CALL 指令調用對應函數。最后使用 RET 指令表示當前函數返回。
Go 語言函數或方法符號在編譯為目標文件后,目標文件中的每個符號均包含對應包的絕對導入路徑。因此目標文件的符號可能非常復雜,比如 “path/to/pkg.(*SomeType).SomeMethod” 或“go.string."abc"”等名字。目標文件的符號名中不僅僅包含普通的字母,還可能包含點號、星號、小括弧和雙引號等諸多特殊字符。而 Go 語言的匯編器是從 plan9 移植過來的二把刀,并不能處理這些特殊的字符,導致了用 Go 匯編語言手工實現 Go 諸多特性時遇到種種限制。
Go 匯編語言同樣遵循 Go 語言少即是多的哲學,它只保留了最基本的特性:定義變量和全局函數。其中在變量和全局函數等名字中引入特殊的分隔符號支持 Go 語言等包體系。為了簡化 Go 匯編器的詞法掃描程序的實現,特別引入了 Unicode 中的中點 ·
和大寫的除法 /
,對應的 Unicode 碼點為 U+00B7
和 U+2215
。匯編器編譯后,中點 ·
會被替換為
ASCII 中的點 “.”,大寫的除法會被替換為 ASCII 碼中的除法 “/”,比如 math/rand·Int
會被替換為 math/rand.Int
。這樣可以將中點和浮點數中的小數點、大寫的除法和表達式中的除法符號分開,可以簡化匯編程序詞法分析部分的實現。
即使暫時拋開 Go 匯編語言設計取舍的問題,在不同的操作系統(tǒng)不同等輸入法中如何輸入中點 ·
和除法 /
兩個字符就是一個挑戰(zhàn)。這兩個字符在 https://golang.org/doc/asm 文檔中均有描述,因此直接從該頁面復制是最簡單可靠的方式。
如果是 macOS 系統(tǒng),則有以下幾種方法輸入中點 ·
:在不開輸入法時,可直接用 option+shift+9 輸入;如果是自帶的簡體拼音輸入法,輸入左上角 ~
鍵對應 ·
,如果是自帶的 Unicode 輸入法,則可以輸入對應的 Unicode 碼點。其中 Unicode 輸入法可能是最安全可靠等輸入方式。
Go 匯編語言中分號可以用于分隔同一行內的多個語句。下面是用分號混亂排版的匯編代碼:
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ù)的空白字符和一個空格是等價的。
![]() | ![]() |
更多建議: