GO匯編常量和全局變量


GO匯編常量和全局變量

程序中的一切變量的初始值都直接或間接地依賴常量或常量表達式生成。在Go語言中很多變量是默認零值初始化的,但是Go匯編中定義的變量最好還是手工通過常量初始化。有了常量之后,就可以衍生定義全局變量,並使用常量組成的表達式初始化其它各種變量。本節將簡單討論Go匯編語言中常量和全局變量的用法。

常量

Go匯編語言中常量以$美元符號為前綴。常量的類型有整數常量、浮點數常量、字符常量和字符串常量等幾種類型。以下是幾種類型常量的例子:

$1           // 十進制
$0xf4f8fcff  // 十六進制
$1.5         // 浮點數
$'a'         // 字符
$"abcd"      // 字符串

其中整數類型常量默認是十進制格式,也可以用十六進制格式表示整數常量。所有的常量最終都必須和要初始化的變量內存大小匹配。

對於數值型常量,可以通過常量表達式構成新的常量:

$2+2      // 常量表達式
$3&1<<2   // == $4
$(3&1)<<2 // == $4

其中常量表達式中運算符的優先級和Go語言保持一致。

Go匯編語言中的常量其實不僅僅只有編譯時常量,還包含運行時常量。比如包中全局的變量和全局函數在運行時地址也是固定不變的,這里地址不會改變的包變量和函數的地址也是一種匯編常量。

下面是本章第一節用匯編定義的字符串代碼:

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

其中$·NameData(SB)也是以$美元符號為前綴,因此也可以將它看作是一個常量,它對應的是NameData包變量的地址。在匯編指令中,我們也可以通過LEA指令來獲取NameData變量的地址。

全局變量

在Go語言中,變量根據作用域和生命周期有全局變量和局部變量之分。全局變量是包一級的變量,全局變量一般有着較為固定的內存地址,聲明周期跨越整個程序運行時間。而局部變量一般是函數內定義的的變量,只有在函數被執行的時間才被在棧上創建,當函數調用完成后將回收(暫時不考慮閉包對局部變量捕獲的問題)。

從Go匯編語言角度來看,全局變量和局部變量有着非常大的差異。在Go匯編中全局變量和全局函數更為相似,都是通過一個人為定義的符號來引用對應的內存,區別只是內存中存放是數據還是要執行的指令。因為在馮諾伊曼系統結構的計算機中指令也是數據,而且指令和數據存放在統一編址的內存中。因為指令和數據並沒有本質的差別,因此我們甚至可以像操作數據那樣動態生成指令(這是所有JIT技術的原理)。而局部變量則需在了解了匯編函數之后,才能通過SP棧空間來隱式定義

在Go匯編語言中,內存是通過SB偽寄存器定位SB是Static base pointer的縮寫,意為靜態內存的開始地址。我們可以將SB想象為一個和內容容量有相同大小的字節數組,所有的靜態全局符號通常可以通過SB加一個偏移量定位,而我們定義的符號其實就是相對於SB內存開始地址偏移量。對於SB偽寄存器,全局變量和全局函數的符號並沒有任何區別。

要定義全局變量,首先要聲明一個變量對應的符號,以及變量對應的內存大小。導出變量符號的語法如下:

GLOBL symbol(SB), width

GLOBL匯編指令用於定義名為symbol的變量,變量對應的內存寬度為width,內存寬度部分必須用常量初始化。下面的代碼通過匯編定義一個int32類型的count變量:

GLOBL ·count(SB),$4

其中符號·count以中點開頭表示是當前包的變量,最終符號名為被展開為path/to/pkg.count。count變量的大小是4個字節,常量必須以``$美元符號開頭。內存的寬度必須是2的指數倍```,編譯器最終會保證變量的真實地址對齊到機器字倍數。需要注意的是,在Go匯編中我們無法為count變量指定具體的類型。在匯編中定義全局變量時,我們只關心變量的名字和內存大小,變量最終的類型只能在Go語言中聲明。

變量定義之后,我們可以通過DATA匯編指令指定對應內存中的數據,語法如下:

DATA symbol+offset(SB)/width, value

具體的含義是從symbol+offset偏移量開始,width寬度的內存用value常量對應的值初始化DATA初始化內存時width必須是1、2、4、8幾個寬度之一,因為再大的內存無法一次性用一個uint64大小的值表示。

對於int32類型的count變量來說,我們既可以逐個字節初始化,也可以一次性初始化:

DATA ·count+0(SB)/1,$1
DATA ·count+1(SB)/1,$2
DATA ·count+2(SB)/1,$3
DATA ·count+3(SB)/1,$4

// or

DATA ·count+0(SB)/4,$0x04030201

因為X86處理器是小端序,因此用十六進制0x04030201初始化全部的4個字節,和用1、2、3、4逐個初始化4個字節是一樣的效果。

最后還需要在Go語言中聲明對應的變量(和C語言頭文件聲明變量的作用類似),這樣垃圾回收器會根據變量的類型來管理其中的指針相關的內存數據。

數組類型

匯編中數組也是一種非常簡單的類型。Go語言中數組是一種有着扁平內存結構的基礎類型。因此[2]byte類型和[1]uint16類型有着相同的內存結構。只有當數組和結構體結合之后情況才會變的稍微復雜。

下面我們嘗試用匯編定義一個[2]int類型的數組變量num:

var num [2]int

然后在匯編中定義一個對應16字節大小的變量,並用零值進行初始化:

GLOBL ·num(SB),$16
DATA ·num+0(SB)/8,$0
DATA ·num+8(SB)/8,$0

下圖是Go語句和匯編語句定義變量時的對應關系:

匯編代碼中並不需要NOPTR標志,因為Go編譯器會從Go語言語句聲明的[2]int類型中推導出該變量內部沒有指針數據。

bool型變量

Go匯編語言定義變量無法指定類型信息,因此需要先通過Go語言聲明變量的類型。以下是在Go語言中聲明的幾個bool類型變量:

var (
    boolValue  bool
    trueValue  bool
    falseValue bool
)

在Go語言中聲明的變量不能含有初始化語句。然后下面是amd64環境的匯編定義:

GLOBL ·boolValue(SB),$1   // 未初始化

GLOBL ·trueValue(SB),$1   // var trueValue = true
DATA ·trueValue(SB)/1,$1  // 非 0 均為 true

GLOBL ·falseValue(SB),$1  // var falseValue = true
DATA ·falseValue(SB)/1,$0

bool類型的內存大小為1個字節。並且匯編中定義的變量需要手工指定初始化值,否則將可能導致產生未初始化的變量。當需要將1個字節的bool類型變量加載到8字節的寄存器時,需要使用MOVBQZX指令將不足的高位用0填充。

int型變量

所有的整數類型均有類似的定義的方式,比較大的差異是整數類型的內存大小和整數是否是有符號。下面是聲明的int32和uint32類型變量:

var int32Value int32

var uint32Value uint32

在Go語言中聲明的變量不能含有初始化語句。然后下面是amd64環境的匯編定義:

GLOBL ·int32Value(SB),$4
DATA ·int32Value+0(SB)/1,$0x01  // 第0字節
DATA ·int32Value+1(SB)/1,$0x02  // 第1字節
DATA ·int32Value+2(SB)/2,$0x03  // 第3-4字節

GLOBL ·uint32Value(SB),$4
DATA ·uint32Value(SB)/4,$0x01020304 // 第1-4字節

匯編定義變量時初始化數據並不區分整數是否有符號。只有在CPU指令處理該寄存器數據時,才會根據指令的類型來取分數據的類型或者是否帶有符號位。

float型變量

Go匯編語言通常無法區分變量是否是浮點數類型,與之相關的浮點數機器指令會將變量當作浮點數處理。Go語言的浮點數遵循IEEE754標准,有float32單精度浮點數和float64雙精度浮點數之分。

IEEE754標准中,最高位1bit為符號位,然后是指數位(指數為采用移碼格式表示),然后是有效數部分(其中小數點左邊的一個bit位被省略)。下圖是IEEE754中float32類型浮點數的bit布局:

IEEE754浮點數還有一些奇妙的特性:比如有正負兩個0;除了無窮大和無窮小Inf還有非數NaN;同時如果兩個浮點數有序那么對應的有符號整數也是有序的(反之則不一定成立,因為浮點數中存在的非數是不可排序的)。浮點數是程序中最難琢磨的角落,因為程序中很多手寫的浮點數字面值常量根本無法精確表達,浮點數計算涉及到的誤差舍入方式可能也的隨機的。

下面是在Go語言中聲明兩個浮點數(如果沒有在匯編中定義變量,那么聲明的同時也會定義變量)。

var float32Value float32

var float64Value float64

然后在匯編中定義並初始化上面聲明的兩個浮點數:

GLOBL ·float32Value(SB),$4
DATA ·float32Value+0(SB)/4,$1.5      // var float32Value = 1.5

GLOBL ·float64Value(SB),$8
DATA ·float64Value(SB)/8,$0x01020304 // bit 方式初始化

我們在上一節精簡的算術指令中都是針對整數,如果要通過整數指令處理浮點數的加減法必須根據浮點數的運算規則進行:先對齊小數點,然后進行整數加減法,最后再對結果進行歸一化並處理精度舍入問題。不過在目前的主流CPU中,都提針對浮點數提供了專有的計算指令。

string類型變量

從Go匯編語言角度看,字符串只是一種結構體。string的頭結構定義如下:

type reflect.StringHeader struct {
    Data uintptr
    Len  int
}

在amd64環境中StringHeader有16個字節大小,因此我們先在Go代碼聲明字符串變量,然后在匯編中定義一個16字節大小的變量:

var helloworld string
GLOBL ·helloworld(SB),$16

同時我們可以為字符串准備真正的數據。在下面的匯編代碼中,我們定義了一個text當前文件內的私有變量(以<>為后綴名為私有變量),內容為“Hello World!”:

GLOBL text<>(SB),NOPTR,$16
DATA text<>+0(SB)/8,$"Hello Wo"
DATA text<>+8(SB)/8,$"rld!"

雖然text<>私有變量表示的字符串只有12個字符長度,但是我們依然需要將變量的長度擴展為2的指數倍數,這里也就是16個字節的長度。其中NOPTR表示text<>不包含指針數據。

然后使用text私有變量對應的內存地址對應的常量來初始化字符串頭結構體中的Data部分,並且手工指定Len部分為字符串的長度:

DATA ·helloworld+0(SB)/8,$text<>(SB) // StringHeader.Data
DATA ·helloworld+8(SB)/8,$12         // StringHeader.Len

需要注意的是,字符串是只讀類型,要避免在匯編中直接修改字符串底層數據的內容。

slice類型變量

slice變量和string變量相似,只不過是對應的是切片頭結構體而已。切片頭的結構如下:

type reflect.SliceHeader struct {
    Data uintptr
    Len  int
    Cap  int
}

對比可以發現,切片的頭的前2個成員字符串是一樣的。因此我們可以在前面字符串變量的基礎上,再擴展一個Cap成員就成了切片類型了:

var helloworld []byte
GLOBL ·helloworld(SB),$24            // var helloworld []byte("Hello World!")
DATA ·helloworld+0(SB)/8,$text<>(SB) // StringHeader.Data
DATA ·helloworld+8(SB)/8,$12         // StringHeader.Len
DATA ·helloworld+16(SB)/8,$16        // StringHeader.Cap

GLOBL text<>(SB),$16
DATA text<>+0(SB)/8,$"Hello Wo"      // ...string data...
DATA text<>+8(SB)/8,$"rld!"          // ...string data...

因為切片和字符串的相容性,我們可以將切片頭的前16個字節臨時作為字符串使用,這樣可以省去不必要的轉換。

map/channel類型變量

map/channel等類型並沒有公開的內部結構,它們只是一種未知類型的指針,無法直接初始化。在匯編代碼中我們只能為類似變量定義並進行0值初始化:

var m map[string]int

var ch chan int
GLOBL ·m(SB),$8  // var m map[string]int
DATA  ·m+0(SB)/8,$0

GLOBL ·ch(SB),$8 // var ch chan int
DATA  ·ch+0(SB)/8,$0

其實在runtime包中為匯編提供了一些輔助函數。比如在匯編中可以通過runtime.makemap和runtime.makechan內部函數來創建map和chan變量。輔助函數的簽名如下:

func makemap(mapType *byte, hint int, mapbuf *any) (hmap map[any]any)
func makechan(chanType *byte, size int) (hchan chan any)

需要注意的是,makemap是一種范型函數,可以創建不同類型的map,map的具體類型是通過mapType參數指定。

變量的內存布局

我們已經多次強調,在Go匯編語言中變量是沒有類型的。因此在Go語言中有着不同類型的變量,底層可能對應的是相同的內存結構。深刻理解每個變量的內存布局是匯編編程時的必備條件。

首先查看前面已經見過的[2]int類型數組的內存布局:

變量在data段分配空間,數組的元素地址依次從低向高排列。

然后再查看下標准庫圖像包中image.Point結構體類型變量的內存布局:

變量也時在data段分配空間,變量結構體成員的地址也是依次從低向高排列。

因此[2]intimage.Point類型底層有着近似相同的內存布局。

標識符規則和特殊標志

Go語言的標識符可以由絕對的包路徑加標識符本身定位,因此不同包中的標識符即使同名也不會有問題。Go匯編是通過特殊的符號來表示斜杠和點符號,因為這樣可以簡化匯編器詞法掃描部分代碼的編寫,只要通過字符串替換就可以了。

下面是匯編中常見的幾種標識符的使用方式(通常也適用於函數標識符):

GLOBL ·pkg_name1(SB),$1
GLOBL main·pkg_name2(SB),$1
GLOBL my/pkg·pkg_name(SB),$1

此外,Go匯編中可以定義僅當前文件可以訪問的私有標識符(類似C語言中文件內static修飾的變量),以<>為后綴名:

GLOBL file_private<>(SB),$1

這樣可以減少私有標識符對其它文件內標識符命名的干擾。

此外,Go匯編語言還在"textflag.h"文件定義了一些標志。其中用於變量的標志有DUPOKRODATANOPTR幾個。DUPOK表示該變量對應的標識符可能有多個,在鏈接時只選擇其中一個即可(一般用於合並相同的常量字符串,減少重復數據占用的空間)。RODATA標志表示將變量定義在只讀內存段,因此后續任何對此變量的修改操作將導致異常(recover也無法捕獲)。NOPTR則表示此變量的內部不含指針數據,讓垃圾回收器忽略對該變量的掃描。如果變量已經在Go代碼中聲明過的話,Go編譯器會自動分析出該變量是否包含指針,這種時候可以不用手寫NOPTR標志。

比如下面的例子是通過匯編來定義一個只讀的int類型的變量:

var const_id int // readonly
#include "textflag.h"

GLOBL ·const_id(SB),NOPTR|RODATA,$8
DATA  ·const_id+0(SB)/8,$9527

我們使用#include語句包含定義標志的"textflag.h"頭文件(和C語言中預處理相同)。然后GLOBL匯編命令在定義變量時,給變量增加了NOPTR和RODATA兩個標志(多個標志之間采用豎杠分割),表示變量中沒有指針數據同時定義在只讀數據段。

變量一般也叫可取地址的值,但是const_id雖然可以取地址,但是確實不能修改。不能修改的限制並不是由編譯器提供,而是因為對該變量的修改會導致對只讀內存段進行寫,從而導致異常。

小結

以上我們初步展示了通過匯編定義全局變量的用法。但是真實的環境中我們並不推薦通過匯編定義變量——因為用Go語言定義變量更加簡單和安全。在Go語言中定義變量,編譯器可以幫助我們計算好變量的大小,生成變量的初始值,同時也包含了足夠的類型信息。匯編語言的優勢是挖掘機器的特性和性能,用匯編定義變量則無法發揮這些優勢。因此在理解了匯編定義變量的用法后,建議大家謹慎使用。


免責聲明!

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



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