Go匯編初識
對於每一個嚴肅的Gopher,Go匯編語言都是一個不可忽視的技術。因為哪怕只懂一點點匯編,也便於更好地理解計算機原理,也更容易理解Go語言中動態棧/接口等高級特性的實現原理。而且掌握了Go匯編語言之后,你將重新站在編程語言鄙視鏈的頂端,不用擔心再被任何其它所謂的高級編程語言用戶鄙視。
本章我們將以AMD64為主要開發環境,簡單地探討Go匯編語言的基礎用法。
快速入門
Go匯編程序始終是幽靈一樣的存在。我們將通過分析簡單的Go程序輸出的匯編代碼,然后照貓畫虎用匯編實現一個簡單的輸出程序。
實現和聲明
Go匯編語言並不是一個獨立的語言,因為Go匯編程序無法獨立使用。Go匯編代碼必須以Go包的方式組織,同時包中至少要有一個Go語言文件用於指明當前包名等基本包信息。如果Go匯編代碼中定義的變量和函數要被其它Go語言代碼引用,還需要通過Go語言代碼將匯編中定義的符號聲明出來。用於變量的定義和函數的定義Go匯編文件類似於C語言中的.c文件,而用於導出匯編中定義符號的Go源文件類似於C語言的.h文件。
定義整數變量
為了簡單,我們先用Go語言定義並賦值一個整數變量,然后查看生成的匯編代碼。
首先創建一個pkg.go文件,內容如下:
package pkg
var Id = 9527
代碼中只定義了一個int類型的包級變量,並進行了初始化。然后用以下命令查看的Go語言程序對應的偽匯編代碼:
$ go tool compile -S pkg.go
>>go.cuinfo.packagename. SDWARFINFO dupok size=0
>> 0x0000 70 6b 67 pkg
>>"".Id SNOPTRDATA size=8
>> 0x0000 37 25 00 00 00 00 00 00 7%......
以上的內容只是目標文件對應的匯編,和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文件中:
GLOBL ·Id(SB),$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環境下的匯編代碼文件。
雖然pkg包是用匯編實現,但是用法和之前的Go語言版本完全一樣:
package main
import pkg "pkg包的路徑"
func main() {
println(pkg.Id)
}
對於Go包的用戶來說,用Go匯編語言或Go語言實現並無任何區別。
定義字符串變量
在前一個例子中,我們通過匯編定義了一個整數變量。現在我們提高一點難度,嘗試通過匯編定義一個字符串變量。雖然從Go語言角度看,定義字符串和整數變量的寫法基本相同,但是字符串底層卻有着比單個整數更復雜的數據結構。
實驗的流程和前面的例子一樣,還是先用Go語言實現類似的功能,然后觀察分析生成的匯編代碼,最后用Go匯編語言仿寫。首先創建pkg.go文件,用Go語言定義字符串:
package pkg
var Name = "gopher"
然后用以下命令查看的Go語言程序對應的偽匯編代碼:
$ go tool compile -S pkg.go
>>go.cuinfo.packagename. SDWARFINFO dupok size=0
>> 0x0000 70 6b 67 pkg
>>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表示出現多個相同標識符的數據時只保留一個就可以了。
type reflect.StringHeader struct {
Data uintptr
Len int
}
現在創建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"不是一個合法的符號,因此我們無法通過手工創建(這是給編譯器保留的部分特權,因為手工創建類似符號可能打破編譯器輸出代碼的某些規則)。因此我們新創建了一個·NameData符號表示底層的字符串數據。然后定義·Name符號內存大小為16字節,其中前8個字節用·NameData符號對應的地址初始化,后8個字節為常量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個字節的類型來修改該錯誤:
package pkg
var NameData [8]byte
var Name string
我們將NameData聲明為長度為8的字節數組。編譯器可以通過類型分析出該變量不會包含指針,因此匯編代碼中可以省略NOPTR標志。現在垃圾回收器在遇到該變量的時候就會停止內部數據的掃描。
在這個實現中,Name字符串底層其實引用的是NameData內存對應的“gopher”字符串數據。因此,如果NameData發生變化,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"
定義main函數
前面的例子已經展示了如何通過匯編定義整型和字符串類型變量。我們現在將嘗試用匯編實現函數,然后輸出一個字符串。
先創建main.go文件,創建並初始化字符串變量,同時聲明main函數:
package main
var helloworld = "你好, 世界"
func main()
然后創建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個字節(對應string頭部結構體的大小,用於給runtime·printstring函數傳遞參數),0表示main函數沒有參數和返回值。main函數內部通過調用運行時內部的runtime·printstring(SB)函數來打印字符串。然后調用runtime·printnl打印換行符號。
Go語言函數在函數調用時,完全通過棧傳遞調用參數和返回值。先通過MOVQ指令,將helloworld對應的字符串頭部結構體的16個字節復制到棧指針SP對應的16字節的空間,然后通過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匯編語言設計取舍的問題,在不同的操作系統不同等輸入法中如何輸入中點·和除法/兩個字符就是一個挑戰。這兩個字符在 https://golang.org/doc/asm 文檔中均有描述,因此直接從該頁面復制是最簡單可靠的方式。
如果是macOS系統,則有以下幾種方法輸入中點·:在不開輸入法時,可直接用 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語言一樣,語句之間多個連續的空白字符和一個空格是等價的。