原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch3-asm/ch3-03-const-and-var.html
程序中的一切變量的初始值都直接或間接地依賴常量或常量表達(dá)式生成。在 Go 語言中很多變量是默認(rèn)零值初始化的,但是 Go 匯編中定義的變量最好還是手工通過常量初始化。有了常量之后,就可以衍生定義全局變量,并使用常量組成的表達(dá)式初始化其它各種變量。本節(jié)將簡單討論 Go 匯編語言中常量和全局變量的用法。
Go 匯編語言中常量以 $ 美元符號為前綴。常量的類型有整數(shù)常量、浮點(diǎn)數(shù)常量、字符常量和字符串常量等幾種類型。以下是幾種類型常量的例子:
$1 // 十進(jìn)制
$0xf4f8fcff // 十六進(jìn)制
$1.5 // 浮點(diǎn)數(shù)
$'a' // 字符
$"abcd" // 字符串
其中整數(shù)類型常量默認(rèn)是十進(jìn)制格式,也可以用十六進(jìn)制格式表示整數(shù)常量。所有的常量最終都必須和要初始化的變量內(nèi)存大小匹配。
對于數(shù)值型常量,可以通過常量表達(dá)式構(gòu)成新的常量:
$2+2 // 常量表達(dá)式
$3&1<<2 // == $4
$(3&1)<<2 // == $4
其中常量表達(dá)式中運(yùn)算符的優(yōu)先級和 Go 語言保持一致。
Go 匯編語言中的常量其實(shí)不僅僅只有編譯時(shí)常量,還包含運(yùn)行時(shí)常量。比如包中全局的變量和全局函數(shù)在運(yùn)行時(shí)地址也是固定不變的,這里地址不會(huì)改變的包變量和函數(shù)的地址也是一種匯編常量。
下面是本章第一節(jié)用匯編定義的字符串代碼:
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)
也是以 $ 美元符號為前綴,因此也可以將它看作是一個(gè)常量,它對應(yīng)的是 NameData 包變量的地址。在匯編指令中,我們也可以通過 LEA 指令來獲取 NameData 變量的地址。
在 Go 語言中,變量根據(jù)作用域和生命周期有全局變量和局部變量之分。全局變量是包一級的變量,全局變量一般有著較為固定的內(nèi)存地址,生命周期跨越整個(gè)程序運(yùn)行時(shí)間。而局部變量一般是函數(shù)內(nèi)定義的的變量,只有在函數(shù)被執(zhí)行的時(shí)間才被在棧上創(chuàng)建,當(dāng)函數(shù)調(diào)用完成后將回收(暫時(shí)不考慮閉包對局部變量捕獲的問題)。
從 Go 匯編語言角度來看,全局變量和局部變量有著非常大的差異。在 Go 匯編中全局變量和全局函數(shù)更為相似,都是通過一個(gè)人為定義的符號來引用對應(yīng)的內(nèi)存,區(qū)別只是內(nèi)存中存放是數(shù)據(jù)還是要執(zhí)行的指令。因?yàn)樵隈T諾伊曼系統(tǒng)結(jié)構(gòu)的計(jì)算機(jī)中指令也是數(shù)據(jù),而且指令和數(shù)據(jù)存放在統(tǒng)一編址的內(nèi)存中。因?yàn)橹噶詈蛿?shù)據(jù)并沒有本質(zhì)的差別,因此我們甚至可以像操作數(shù)據(jù)那樣動(dòng)態(tài)生成指令(這是所有 JIT 技術(shù)的原理)。而局部變量則需在了解了匯編函數(shù)之后,才能通過 SP ??臻g來隱式定義。
在 Go 匯編語言中,內(nèi)存是通過 SB 偽寄存器定位。SB 是 Static base pointer 的縮寫,意為靜態(tài)內(nèi)存的開始地址。我們可以將 SB 想象為一個(gè)和內(nèi)容容量有相同大小的字節(jié)數(shù)組,所有的靜態(tài)全局符號通??梢酝ㄟ^ SB 加一個(gè)偏移量定位,而我們定義的符號其實(shí)就是相對于 SB 內(nèi)存開始地址偏移量。對于 SB 偽寄存器,全局變量和全局函數(shù)的符號并沒有任何區(qū)別。
要定義全局變量,首先要聲明一個(gè)變量對應(yīng)的符號,以及變量對應(yīng)的內(nèi)存大小。導(dǎo)出變量符號的語法如下:
GLOBL symbol(SB), width
GLOBL 匯編指令用于定義名為 symbol 的變量,變量對應(yīng)的內(nèi)存寬度為 width,內(nèi)存寬度部分必須用常量初始化。下面的代碼通過匯編定義一個(gè) int32 類型的 count 變量:
GLOBL ·count(SB),$4
其中符號 ·count
以中點(diǎn)開頭表示是當(dāng)前包的變量,最終符號名為被展開為 path/to/pkg.count
。count 變量的大小是 4 個(gè)字節(jié),常量必須以 $ 美元符號開頭。內(nèi)存的寬度必須是 2 的指數(shù)倍,編譯器最終會(huì)保證變量的真實(shí)地址對齊到機(jī)器字倍數(shù)。需要注意的是,在 Go 匯編中我們無法為 count 變量指定具體的類型。在匯編中定義全局變量時(shí),我們只關(guān)心變量的名字和內(nèi)存大小,變量最終的類型只能在 Go
語言中聲明。
變量定義之后,我們可以通過 DATA 匯編指令指定對應(yīng)內(nèi)存中的數(shù)據(jù),語法如下:
DATA symbol+offset(SB)/width, value
具體的含義是從 symbol+offset 偏移量開始,width 寬度的內(nèi)存,用 value 常量對應(yīng)的值初始化。DATA 初始化內(nèi)存時(shí),width 必須是 1、2、4、8 幾個(gè)寬度之一,因?yàn)樵俅蟮膬?nèi)存無法一次性用一個(gè) uint64 大小的值表示。
對于 int32 類型的 count 變量來說,我們既可以逐個(gè)字節(jié)初始化,也可以一次性初始化:
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
因?yàn)?X86 處理器是小端序,因此用十六進(jìn)制 0x04030201 初始化全部的 4 個(gè)字節(jié),和用 1、2、3、4 逐個(gè)初始化 4 個(gè)字節(jié)是一樣的效果。
最后還需要在 Go 語言中聲明對應(yīng)的變量(和 C 語言頭文件聲明變量的作用類似),這樣垃圾回收器會(huì)根據(jù)變量的類型來管理其中的指針相關(guān)的內(nèi)存數(shù)據(jù)。
匯編中數(shù)組也是一種非常簡單的類型。Go 語言中數(shù)組是一種有著扁平內(nèi)存結(jié)構(gòu)的基礎(chǔ)類型。因此 [2]byte
類型和 [1]uint16
類型有著相同的內(nèi)存結(jié)構(gòu)。只有當(dāng)數(shù)組和結(jié)構(gòu)體結(jié)合之后情況才會(huì)變的稍微復(fù)雜。
下面我們嘗試用匯編定義一個(gè) [2]int
類型的數(shù)組變量 num:
var num [2]int
然后在匯編中定義一個(gè)對應(yīng) 16 字節(jié)大小的變量,并用零值進(jìn)行初始化:
GLOBL ·num(SB),$16
DATA ·num+0(SB)/8,$0
DATA ·num+8(SB)/8,$0
下圖是 Go 語句和匯編語句定義變量時(shí)的對應(yīng)關(guān)系:
圖 3-4 變量定義
匯編代碼中并不需要 NOPTR 標(biāo)志,因?yàn)?Go 編譯器會(huì)從 Go 語言語句聲明的 [2]int
類型中推導(dǎo)出該變量內(nèi)部沒有指針數(shù)據(jù)。
Go 匯編語言定義變量無法指定類型信息,因此需要先通過 Go 語言聲明變量的類型。以下是在 Go 語言中聲明的幾個(gè) bool 類型變量:
var (
boolValue bool
trueValue bool
falseValue bool
)
在 Go 語言中聲明的變量不能含有初始化語句。然后下面是 amd64 環(huán)境的匯編定義:
GLOBL ·boolValue(SB),$1 // 未初始化
GLOBL ·trueValue(SB),$1 // var trueValue = true
DATA ·trueValue(SB)/1,$1 // 非 0 均為 true
GLOBL ·falseValue(SB),$1 // var falseValue = false
DATA ·falseValue(SB)/1,$0
bool 類型的內(nèi)存大小為 1 個(gè)字節(jié)。并且匯編中定義的變量需要手工指定初始化值,否則將可能導(dǎo)致產(chǎn)生未初始化的變量。當(dāng)需要將 1 個(gè)字節(jié)的 bool 類型變量加載到 8 字節(jié)的寄存器時(shí),需要使用 MOVBQZX 指令將不足的高位用 0 填充。
所有的整數(shù)類型均有類似的定義的方式,比較大的差異是整數(shù)類型的內(nèi)存大小和整數(shù)是否是有符號。下面是聲明的 int32 和 uint32 類型變量:
var int32Value int32
var uint32Value uint32
在 Go 語言中聲明的變量不能含有初始化語句。然后下面是 amd64 環(huán)境的匯編定義:
GLOBL ·int32Value(SB),$4
DATA ·int32Value+0(SB)/1,$0x01 // 第 0 字節(jié)
DATA ·int32Value+1(SB)/1,$0x02 // 第 1 字節(jié)
DATA ·int32Value+2(SB)/2,$0x03 // 第 3-4 字節(jié)
GLOBL ·uint32Value(SB),$4
DATA ·uint32Value(SB)/4,$0x01020304 // 第 1-4 字節(jié)
匯編定義變量時(shí)初始化數(shù)據(jù)并不區(qū)分整數(shù)是否有符號。只有在 CPU 指令處理該寄存器數(shù)據(jù)時(shí),才會(huì)根據(jù)指令的類型來取分?jǐn)?shù)據(jù)的類型或者是否帶有符號位。
Go 匯編語言通常無法區(qū)分變量是否是浮點(diǎn)數(shù)類型,與之相關(guān)的浮點(diǎn)數(shù)機(jī)器指令會(huì)將變量當(dāng)作浮點(diǎn)數(shù)處理。Go 語言的浮點(diǎn)數(shù)遵循 IEEE754 標(biāo)準(zhǔn),有 float32 單精度浮點(diǎn)數(shù)和 float64 雙精度浮點(diǎn)數(shù)之分。
IEEE754 標(biāo)準(zhǔn)中,最高位 1bit 為符號位,然后是指數(shù)位(指數(shù)為采用移碼格式表示),然后是有效數(shù)部分(其中小數(shù)點(diǎn)左邊的一個(gè) bit 位被省略)。下圖是 IEEE754 中 float32 類型浮點(diǎn)數(shù)的 bit 布局:
圖 3-5 IEEE754 浮點(diǎn)數(shù)結(jié)構(gòu)
IEEE754 浮點(diǎn)數(shù)還有一些奇妙的特性:比如有正負(fù)兩個(gè) 0;除了無窮大和無窮小 Inf 還有非數(shù) NaN;同時(shí)如果兩個(gè)浮點(diǎn)數(shù)有序那么對應(yīng)的有符號整數(shù)也是有序的(反之則不一定成立,因?yàn)楦↑c(diǎn)數(shù)中存在的非數(shù)是不可排序的)。浮點(diǎn)數(shù)是程序中最難琢磨的角落,因?yàn)槌绦蛑泻芏嗍謱懙母↑c(diǎn)數(shù)字面值常量根本無法精確表達(dá),浮點(diǎn)數(shù)計(jì)算涉及到的誤差舍入方式可能也的隨機(jī)的。
下面是在 Go 語言中聲明兩個(gè)浮點(diǎn)數(shù)(如果沒有在匯編中定義變量,那么聲明的同時(shí)也會(huì)定義變量)。
var float32Value float32
var float64Value float64
然后在匯編中定義并初始化上面聲明的兩個(gè)浮點(diǎn)數(shù):
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 方式初始化
我們在上一節(jié)精簡的算術(shù)指令中都是針對整數(shù),如果要通過整數(shù)指令處理浮點(diǎn)數(shù)的加減法必須根據(jù)浮點(diǎn)數(shù)的運(yùn)算規(guī)則進(jìn)行:先對齊小數(shù)點(diǎn),然后進(jìn)行整數(shù)加減法,最后再對結(jié)果進(jìn)行歸一化并處理精度舍入問題。不過在目前的主流 CPU 中,都針對浮點(diǎn)數(shù)提供了專有的計(jì)算指令。
從 Go 匯編語言角度看,字符串只是一種結(jié)構(gòu)體。string 的頭結(jié)構(gòu)定義如下:
type reflect.StringHeader struct {
Data uintptr
Len int
}
在 amd64 環(huán)境中 StringHeader 有 16 個(gè)字節(jié)大小,因此我們先在 Go 代碼聲明字符串變量,然后在匯編中定義一個(gè) 16 字節(jié)大小的變量:
var helloworld string
GLOBL ·helloworld(SB),$16
同時(shí)我們可以為字符串準(zhǔn)備真正的數(shù)據(jù)。在下面的匯編代碼中,我們定義了一個(gè) text 當(dāng)前文件內(nèi)的私有變量(以 <>
為后綴名),內(nèi)容為 “Hello World!”:
GLOBL text<>(SB),NOPTR,$16
DATA text<>+0(SB)/8,$"Hello Wo"
DATA text<>+8(SB)/8,$"rld!"
雖然 text<>
私有變量表示的字符串只有 12 個(gè)字符長度,但是我們依然需要將變量的長度擴(kuò)展為 2 的指數(shù)倍數(shù),這里也就是 16 個(gè)字節(jié)的長度。其中 NOPTR
表示 text<>
不包含指針數(shù)據(jù)。
然后使用 text 私有變量對應(yīng)的內(nèi)存地址對應(yīng)的常量來初始化字符串頭結(jié)構(gòu)體中的 Data 部分,并且手工指定 Len 部分為字符串的長度:
DATA ·helloworld+0(SB)/8,$text<>(SB) // StringHeader.Data
DATA ·helloworld+8(SB)/8,$12 // StringHeader.Len
需要注意的是,字符串是只讀類型,要避免在匯編中直接修改字符串底層數(shù)據(jù)的內(nèi)容。
slice 變量和 string 變量相似,只不過是對應(yīng)的是切片頭結(jié)構(gòu)體而已。切片頭的結(jié)構(gòu)如下:
type reflect.SliceHeader struct {
Data uintptr
Len int
Cap int
}
對比可以發(fā)現(xiàn),切片的頭的前 2 個(gè)成員字符串是一樣的。因此我們可以在前面字符串變量的基礎(chǔ)上,再擴(kuò)展一個(gè) Cap 成員就成了切片類型了:
var helloworld []byte
GLOBL ·helloworld(SB),$24 // var helloworld []byte("Hello World!")
DATA ·helloworld+0(SB)/8,$text<>(SB) // SliceHeader.Data
DATA ·helloworld+8(SB)/8,$12 // SliceHeader.Len
DATA ·helloworld+16(SB)/8,$16 // SliceHeader.Cap
GLOBL text<>(SB),$16
DATA text<>+0(SB)/8,$"Hello Wo" // ...string data...
DATA text<>+8(SB)/8,$"rld!" // ...string data...
因?yàn)榍衅妥址南嗳菪?,我們可以將切片頭的前 16 個(gè)字節(jié)臨時(shí)作為字符串使用,這樣可以省去不必要的轉(zhuǎn)換。
map/channel 等類型并沒有公開的內(nèi)部結(jié)構(gòu),它們只是一種未知類型的指針,無法直接初始化。在匯編代碼中我們只能為類似變量定義并進(jìn)行 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
其實(shí)在 runtime 包中為匯編提供了一些輔助函數(shù)。比如在匯編中可以通過 runtime.makemap 和 runtime.makechan 內(nèi)部函數(shù)來創(chuàng)建 map 和 chan 變量。輔助函數(shù)的簽名如下:
func makemap(mapType *byte, hint int, mapbuf *any) (hmap map[any]any)
func makechan(chanType *byte, size int) (hchan chan any)
需要注意的是,makemap 是一種泛型函數(shù),可以創(chuàng)建不同類型的 map,map 的具體類型是通過 mapType 參數(shù)指定。
我們已經(jīng)多次強(qiáng)調(diào),在 Go 匯編語言中變量是沒有類型的。因此在 Go 語言中有著不同類型的變量,底層可能對應(yīng)的是相同的內(nèi)存結(jié)構(gòu)。深刻理解每個(gè)變量的內(nèi)存布局是匯編編程時(shí)的必備條件。
首先查看前面已經(jīng)見過的 [2]int
類型數(shù)組的內(nèi)存布局:
圖 3-6 變量定義
變量在 data 段分配空間,數(shù)組的元素地址依次從低向高排列。
然后再查看下標(biāo)準(zhǔn)庫圖像包中 image.Point
結(jié)構(gòu)體類型變量的內(nèi)存布局:
圖 3-7 結(jié)構(gòu)體變量定義
變量也是在 data 段分配空間,變量結(jié)構(gòu)體成員的地址也是依次從低向高排列。
因此 [2]int
和 image.Point
類型底層有著近似相同的內(nèi)存布局。
Go 語言的標(biāo)識(shí)符可以由絕對的包路徑加標(biāo)識(shí)符本身定位,因此不同包中的標(biāo)識(shí)符即使同名也不會(huì)有問題。Go 匯編是通過特殊的符號來表示斜杠和點(diǎn)符號,因?yàn)檫@樣可以簡化匯編器詞法掃描部分代碼的編寫,只要通過字符串替換就可以了。
下面是匯編中常見的幾種標(biāo)識(shí)符的使用方式(通常也適用于函數(shù)標(biāo)識(shí)符):
GLOBL ·pkg_name1(SB),$1
GLOBL main·pkg_name2(SB),$1
GLOBL my/pkg·pkg_name(SB),$1
此外,Go 匯編中可以定義僅當(dāng)前文件可以訪問的私有標(biāo)識(shí)符(類似 C 語言中文件內(nèi) static 修飾的變量),以 <>
為后綴名:
GLOBL file_private<>(SB),$1
這樣可以減少私有標(biāo)識(shí)符對其它文件內(nèi)標(biāo)識(shí)符命名的干擾。
此外,Go 匯編語言還在 "textflag.h" 文件定義了一些標(biāo)志。其中用于變量的標(biāo)志有 DUPOK、RODATA 和 NOPTR 幾個(gè)。DUPOK 表示該變量對應(yīng)的標(biāo)識(shí)符可能有多個(gè),在鏈接時(shí)只選擇其中一個(gè)即可(一般用于合并相同的常量字符串,減少重復(fù)數(shù)據(jù)占用的空間)。RODATA 標(biāo)志表示將變量定義在只讀內(nèi)存段,因此后續(xù)任何對此變量的修改操作將導(dǎo)致異常(recover 也無法捕獲)。NOPTR 則表示此變量的內(nèi)部不含指針數(shù)據(jù),讓垃圾回收器忽略對該變量的掃描。如果變量已經(jīng)在 Go 代碼中聲明過的話,Go 編譯器會(huì)自動(dòng)分析出該變量是否包含指針,這種時(shí)候可以不用手寫 NOPTR 標(biāo)志。
比如下面的例子是通過匯編來定義一個(gè)只讀的 int 類型的變量:
var const_id int // readonly
#include "textflag.h"
GLOBL ·const_id(SB),NOPTR|RODATA,$8
DATA ·const_id+0(SB)/8,$9527
我們使用 #include 語句包含定義標(biāo)志的 "textflag.h" 頭文件(和 C 語言中預(yù)處理相同)。然后 GLOBL 匯編命令在定義變量時(shí),給變量增加了 NOPTR 和 RODATA 兩個(gè)標(biāo)志(多個(gè)標(biāo)志之間采用豎杠分割),表示變量中沒有指針數(shù)據(jù)同時(shí)定義在只讀數(shù)據(jù)段。
變量一般也叫可取地址的值,但是 const_id 雖然可以取地址,但是確實(shí)不能修改。不能修改的限制并不是由編譯器提供,而是因?yàn)閷υ撟兞康男薷臅?huì)導(dǎo)致對只讀內(nèi)存段進(jìn)行寫,從而導(dǎo)致異常。
以上我們初步展示了通過匯編定義全局變量的用法。但是真實(shí)的環(huán)境中我們并不推薦通過匯編定義變量——因?yàn)橛?Go 語言定義變量更加簡單和安全。在 Go 語言中定義變量,編譯器可以幫助我們計(jì)算好變量的大小,生成變量的初始值,同時(shí)也包含了足夠的類型信息。匯編語言的優(yōu)勢是挖掘機(jī)器的特性和性能,用匯編定義變量則無法發(fā)揮這些優(yōu)勢。因此在理解了匯編定義變量的用法后,建議大家謹(jǐn)慎使用。
![]() | ![]() |
更多建議: