Go匯編初識


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 tool compile命令用於調用Go語言提供的底層命令工具,其中-S參數表示輸出匯編格式。輸出的匯編比較簡單,其中"".Id對應Id變量符號,變量的內存大小為8個字節。變量的初始化內容為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文件中:

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表示出現多個相同標識符的數據時只保留一個就可以了。

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

type reflect.StringHeader struct {
    Data uintptr
    Len  int
}

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

現在創建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"

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

定義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+00B7U+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語言一樣,語句之間多個連續的空白字符和一個空格是等價的。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM