Golang 組合asm語言基礎學習

2022-08-05 18:00:16

Golang 組合asm語言基礎學習

一、CPU 基礎知識

cpu 內部結構

cpu 內部主要是由暫存器、控制器、運算器和時鐘四個部分組成。

暫存器:用來暫時存放指令、資料等物件。它是一個更快的記憶體。cpu 內部一般有 20 - 100 個暫存器。不同型別的cpu,它內部的暫存器數量、種類以及暫存器儲存的數值範圍都不相同。

控制器:它負責把記憶體上的指令、資料等讀入暫存器,根據指令執行的結果來控制整個計算機。

運算器:它負責運算從記憶體讀入暫存器的資料。

時鐘:它負責發出 cpu 開始計時的時鐘訊號。時鐘訊號頻率越高,cpu 的執行速度越快。

cpu 指令集

指令集架構,又叫指令集體系或指令集,是計算機系統中與程式設計有關,包含基本資料型別,指令集,暫存器,定址模式,儲存體系,中斷,例外處理以及外部I/O。指令集架構包含一系列的 opcode 即操作碼(機器語言)-- 維基百科。

就是用機器語言規定了一系列的操作指令,這些指令用來對cpu進行操作,或者說cpu能夠執行這些指令。

不同的 cpu 型別有不同的指令集,比如intel AI-32,X86-64,ARM 等處理器都有不同的指令集架構。

機器語言:它是由宣告和指令組成。

而指令一般包含以下幾個部分:

  • 用於算術運算,定址或控制功能的特定暫存器
  • 儲存空間地址或偏移量
  • 解釋運算元的特定定址模式

二、什麼是組合語言

維基百科解釋:

組合語言是任何一種用於電子計算機、微處理器、微控制器,或其他可程式化器件的低階語言。在不同裝置中,組合語言對應著不同的機器語言指令集。

一種組合語言專用於某種計算機系統結構,而不像許多高階語言,可以在不同系統平臺之間移植

上面的解釋一看,還是會讓一般人起一頭包,無法理解。

那就從另外一個方面來理解下,組合語言的來歷。

我們都知道計算機的組成,一般有 CPU,記憶體,主機板,I/O 裝置等。那 cpu 能夠執行什麼程式?我們可能會說,c,java 這種語言都可以執行啊。對又不對,對是因為我們平常編寫的程式就是 c,c++,java,js,go 等這些語言,不對是因為這些語言是高階程式語言,從高階語言到 cpu 能夠執行的語言,一般要經過一段旅程:

預處理 -> 編譯 -> 組合 -> 連結 -> 可執行程式

用 c 編寫的程式編譯流程圖:

最後的可執行程式才是 cpu 可以執行的程式,cpu 只能執行由 0 和 1 組成的機器語言。比如規定0011,表示增加,0111,表示減少 。

最開始打孔程式設計時,程式設計師就是用這種用 0 和 1 組合方式來編寫程式。想象一下,這種程式設計方式不僅編寫速度慢,效率低下,更不容易閱讀,且查詢程式錯誤也非常困難。

為了解決這個問題,隨著程式設計的發展,人們發明了組合語言,組合語言使用助記符來代替和表示低階機器語言(0 和 1)的操作,從而提高了程式設計的效率。比如組合規定 add ,表示增加;sub ,表示減少。

但是我們仍然要為不同的 cpu 編寫不同的組合程式,因為每個 cpu 的機器指令集各不相同,組合語言仍是一門低階語言。為了進一步提高效率,又發明了第一個正式推廣的高階語言 FORTRAN。隨後,各種高階語言不斷出現。用各種高階語言編寫程式符合人們思考事物、解決問題的習慣。

組合語言是低階語言,用來描述、控制 cpu 的執行。如果想要了解 cpu 到底幹了些啥,以及程式碼執行的步驟,可以學習組合語言,來幫助你

瞭解 cpu 執行情況。組合語言最終會編譯成機器語言。

每一個原本是電子訊號的機器語言指令都會有一個對應的助記符,而助記符通常為英文單詞的縮寫。

比如:mov 和 add 分別是 move(資料的儲存) 和 addition(相加) 的縮寫。組合語言和機器語言基本是一一對應的。

把組合語言轉化為機器語言的程式稱為組合器。

三、Go 組合和暫存器簡介

plan9 組合簡介

通過上面內容知道,CPU 內部的儲存單元是暫存器,用於存放從記憶體讀取而來的資料、指令和儲存 CPU 運算的中間結果。怎麼操作這些暫存器?用機器語言,但是機器語言程式編寫耗時耗力,排錯也很困難,為了解決這些問題,人們發明了組合語言。

Go 用了 plan9 組合,使用的是 GAS 組合語法(Gnu ASsembler),與 AT&T 組合格式有很多相同之處,但也有不同之處。plan9 作者們是寫 unix 作業系統的同一批人,大名鼎鼎的貝爾實驗室開發的(Plan 9 From Bell Labs),其實 plan9 是一個作業系統

plan9 組合與 Intel 的組合語法有明顯的區別。

2 者指令和運算元位置是相反的:

GAS: MOVL AX BX      // 將 AX 暫存器的值複製到 BX 暫存器中(複製也就是移動,行動資料)
Intel: MOV EBX EAX    // 將 EAX 暫存器的值複製到 EBX 暫存器中

還有一些命令也不同,比如在 GAS 中,運算元的字長,movq 表示移動 8byte=64位元 長度,movl 表示移動 4byte=32位元 長度。在 intel 的組合規則中則有不同,

GAS: MOVB $1 AL // 將 $1 的值複製到 AL 暫存器裡

Intel-x64: 
mov al, 0x44   // 1 byte
mov ah, 0x33   // 1 byte
mov rax, 0x1   // 8 bytes

組合一般有2大分類:Intel 組合 和 AT&T 組合

  • Intel 組合:windows 系列的,因為有 win-intel 聯盟,一般是 win 派系的 VC 編譯器
  • AT&T 組合:Unix、Linux 和 Max OS ,一般是 GCC 編譯器

plan9 通用暫存器

AX BX CX DX DI SI BP SP R8 R9 R10 R11 R12 R13 R14 R15 PC

BP : 基準指標暫存器。

應用程式碼用到的通用暫存器有14個:

AX, BX, CX, DX, DI, SI, R8~R15

偽暫存器

Go 組合中有 4 個偽暫存器,這個 4 個偽暫存器是編譯器用來維護上下文、特殊標識等作用的。

偽暫存器不是真正的暫存器,而是由工具鏈維護的虛擬(偽)暫存器,例如影格指標。

  • FP, Frame Pointer, arguments and locals:影格指標,引數和本地 。指向當前 frame 起始位置
  • SP, Stack Pointer, top of stack: 指向棧頂
  • PC, Program Counter, jumps and branches: 程式計數器,跳轉和分支
  • SB, Static Base, global symbols: 靜態基指標, 全域性符號

所有使用者定義的符號(區域性資料、引數名等)都作為偏移量寫入偽暫存器 FP(區域性資料、輸入引數、返回值) 和 SB(全域性資料)。

也就是說 FP 和 SB 維護了使用者空間的所有資料。

FP: 這個偽暫存器用來標識函數引數、返回值、區域性資料。

用法:symbol+offset(FP)

symbol(符號) 表示引數變數名,offset 表示偏移量,相對於 FP 的偏移量, 比如:first_arg+0(FP) 表示函數第一個引數位置,偏移 0byte; second_arg+8(FP) 表示函數引數偏移 8byte 的另外引數。first_arg 只是一個標記,在組合中first_arg 是不存在的。

操作命令:movq arg+8(FP), BX

前面知道 movq 命令是移動 8byte 長度的資料。這個命令意思:移動 8byte 長度的資料到 BX 暫存器,這個資料(arg+8(FP))偏移 FP 8byte。

SB:用來宣告全域性變數或宣告函數

package main
func add() {}

上面的函數用命令編譯後:

go tool compile -S -N -l .\demo1.go

"".add STEXT nosplit size=1 args=0x0 locals=0x0
        0x0000 00000 (.\demo1.go:3)     TEXT    "".add(SB), NOSPLIT|ABIInternal, $0-0
... 其餘的省略 ...

TEXT "".add(SB), NOSPLIT|ABIInternal, $0-0 這裡宣告了一個 add 函數。後面在詳解。

一般情況下,都不會對 FP,SB 暫存器進行運算操作,而是以他們作為基準地址,進行偏移(上面的 offset)解除參照操作。

PC:程式計數器,程式執行的下一個指令地址。

SP:plan9 中的 SP 指向當前棧幀的區域性變數的開始位置。

SP: symbol+offset(SP) ,參照函數的區域性變數,symbol 一般表示變數名,offset 表示區域性變數距離 SP 的偏移量。

偽暫存器和硬體暫存器區別:

SP 有對應的硬體暫存器和偽暫存器,區分 SP 到底是指硬體暫存器還是指偽暫存器,需要以特定的格式來區分。
symbol+offset(SP) 則表示偽暫存器 SP。
offset(SP) 則表示硬體暫存器 SP。

這裡 SP(Stack Pointer) 和 FP(Frame Pointer),很容易區別,SP 是整個函數棧起始位置指標。

把函數棧分成了很多小塊 Frame , FP 就是指向這些小塊 Frame 的指標。

想要了解組合,我們還要繼續學習一些計算機基礎知識。下面從程序記憶體角度理解下偽暫存器 FP 和 SP。

四、程序的虛擬記憶體佈局

在這篇 深入理解Go語言(07):記憶體分配原理 文章中,有一張圖表示了程序在 linux 32位元中的虛擬記憶體佈局。根據這張圖在畫一個稍微簡略點的記憶體模型圖,

以便能更好的理解程式的棧和堆。

這裡重點看 user stack 和 heap。

stack,編譯器自動維護的記憶體空間,向下增長。heap,使用者手動分配的記憶體空間,向上增長,比如 c 語言裡 malloc 函數分配的記憶體空間就位於 heap 裡。

常常說的函數呼叫棧,用的就是 user stack 這塊記憶體空間。

前文提到的偽暫存器 FP、SP,沒有關於 golang 呼叫棧的基礎知識,一頭包,很難理解,這裡再進一步加強理解。

把上面的 user stack 棧記憶體空間單獨拿出來,如下圖:

把 stack 棧空間又分割成了小塊,叫 frame(幀),也叫 stack frame(棧幀)。

  • FP, Frame Pointer, 指向當前 frame 起始位置
  • SP, Stack Pointer, 指向棧頂,top of stack

五、Go 函數呼叫棧

在官方 stack.go 程式中的 Stack frame layout 圖:

// Stack frame layout
//
// (x86)
// +------------------+
// | args from caller |
// +------------------+ <- frame->argp
// |  return address  |
// +------------------+
// |  caller's BP (*) | (*) if framepointer_enabled && varp < sp
// +------------------+ <- frame->varp
// |     locals       |
// +------------------+
// |  args to callee  |
// +------------------+ <- frame->sp
//
// (arm)
// +------------------+
// | args from caller |
// +------------------+ <- frame->argp
// | caller's retaddr |
// +------------------+ <- frame->varp
// |     locals       |
// +------------------+
// |  args to callee  |
// +------------------+
// |  return address  |
// +------------------+ <- frame->sp

六、Go 組合例子學習

Go 編譯器會輸出一種抽象可移植的組合程式碼,這種組合程式碼並不對應某種真實的硬體架構。Go 的組合器會使用這些偽組合,再為目標硬體生成具體的機器指令。

上面說了那麼多,我們還沒有真正感受到 go 的組合語言,下面就來看看 go 語言編譯成組合是個啥樣?

第一個組合例子

編寫一個 go 檔案,demo1.go,例子來自《Go語言高階程式設計》:

package main

var Id = 9876

然後編譯成 go 組合語言, go1.15.13 windows/amd64

$ go tool compile -S .\demo1.go
go.cuinfo.packagename. SDWARFINFO dupok size=0
        0x0000 6d 61 69 6e                                      main
""..inittask SNOPTRDATA size=24
        0x0000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
        0x0010 00 00 00 00 00 00 00 00                          ........
"".Id SNOPTRDATA size=8
        0x0000 94 26 00 00 00 00 00 00                          .&......

go tool compile:呼叫 go 語言提供的底層命令工具,-S 引數,表示輸出組合格式。

瞭解跟更多 compile 命令: go tool compile -h

在來看看組合,前面的內容可以先不管,主要看最後一段:

"".Id SNOPTRDATA size=8
        0x0000 94 26 00 00 00 00 00 00                          .&......

"".Id:對應 Id 變數符號

size=8:變數的記憶體大小為 8 個位元組,初始化內容 94 26 00 00 00 00 00 00,這個對應的是十六進位制

SNOPTRDATA:相關標識,其中 NOPTR 表示不包含指標資料

// 還可以加上 -N -l 禁止內聯優化
go tool compile -S -N -l demo1.go

第二個例子

寫個複雜點的程式,demo2.go。

package pkg

func add(a, b int) int {
	return a + b
}

go1.15.13 windows/amd64 下編譯。

編譯程式: go tool compile -S -N -l demo2.go

1 "".add STEXT nosplit size=25 args=0x18 locals=0x0
2        0x0000 00000 (demo2.go:3)       TEXT    "".add(SB), NOSPLIT|ABIInternal, $0-24
3        0x0000 00000 (demo2.go:3)       FUNCDATA   $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
4        0x0000 00000 (demo2.go:3)       FUNCDATA   $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
5        0x0000 00000 (demo2.go:3)       MOVQ    $0, "".~r2+24(SP)
6        0x0009 00009 (demo2.go:4)       MOVQ    "".a+8(SP), AX
7        0x000e 00014 (demo2.go:4)       ADDQ    "".b+16(SP), AX
8        0x0013 00019 (demo2.go:4)       MOVQ    AX, "".~r2+24(SP)
9        0x0018 00024 (demo2.go:4)       RET
        0x0000 48 c7 44 24 18 00 00 00 00 48 8b 44 24 08 48 03  H.D$.....H.D$.H.
        0x0010 44 24 10 48 89 44 24 18 c3                       D$.H.D$..
go.cuinfo.packagename. SDWARFINFO dupok size=0
        0x0000 70 6b 67                                         pkg
gclocals·33cdeccccebe80329f1fdbee7f5874cb SRODATA dupok size=8
        0x0000 01 00 00 00 00 00 00 00

第2行:TEXT "".add(SB), NOSPLIT|ABIInternal, $0-24

  • TEXT "".add(SB):

TEXT, 指令宣告 "".add 是一個 .text 文欄位。在文章《深入理解Go語言(07):記憶體分配原理》中有程序的記憶體佈局說明。

"".add,這是宣告了一個函數的函數體。"" 表示函數所在的包名,預設省略了,連結期間會替換為當前包名, pkg.add

SB,它是一個虛擬暫存器,儲存了靜態基地址指標,即是程式地址空間開始的地址。

  • NOSPLIT|ABIInternal:

用於標識函數的一些特殊行為。NOSPLIT 表示子函數不進行棧分裂。ABIInternal 實驗版本的calling convention,詳情看這裡

  • $0-24:

常用 $framesize[-argsize] 表示,$framesize 表示將要分配的函數棧幀大小,包含呼叫其它函數時準備呼叫引數的隱式棧空間。argsize 表示引數和返回值的大小,-argsize 前面 - 不是減號,而是一個分隔符。

$0 代表將要分配的棧幀大小;24 代表呼叫方傳入的引數大小。

第 3 、4 兩行:FUNCDATA 都是與垃圾回收有關的資訊,暫時不瞭解。

第 5 行:MOVQ $0, "".~r2+24(SP)

來一個複雜點的程式,相加相減例子 func_cal.go:

go1.15.13 windows/amd64

package main

func calculate(val1, val2 int) (sumret int, subret int) {
	res1 := val1 + val2
	res2 := val1 - val2
	return res1, res2
}

func main() {
	calculate(32, 53)
}

編譯:go tool compile -S -N -l func_cal.go

輸出組合語言,先看看 add 函數的組合:

1 "".calculate STEXT nosplit size=90 args=0x20 locals=0x18
2       0x0000 00000 (func_cal.go:3)    TEXT    "".calculate(SB), NOSPLIT|ABIInternal, $24-32
3       0x0000 00000 (func_cal.go:3)    SUBQ    $24, SP
4       0x0004 00004 (func_cal.go:3)    MOVQ    BP, 16(SP)
        0x0009 00009 (func_cal.go:3)    LEAQ    16(SP), BP
        0x000e 00014 (func_cal.go:3)    FUNCDATA  $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x000e 00014 (func_cal.go:3)    FUNCDATA  $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x000e 00014 (func_cal.go:3)    MOVQ    $0, "".sumret+48(SP)
        0x0017 00023 (func_cal.go:3)    MOVQ    $0, "".subret+56(SP)
        0x0020 00032 (func_cal.go:4)    MOVQ    "".val1+32(SP), AX
        0x0025 00037 (func_cal.go:4)    ADDQ    "".val2+40(SP), AX
        0x002a 00042 (func_cal.go:4)    MOVQ    AX, "".res1+8(SP)
        0x002f 00047 (func_cal.go:5)    MOVQ    "".val1+32(SP), AX
        0x0034 00052 (func_cal.go:5)    SUBQ    "".val2+40(SP), AX
        0x0039 00057 (func_cal.go:5)    MOVQ    AX, "".res2(SP)
        0x003d 00061 (func_cal.go:6)    MOVQ    "".res1+8(SP), AX
        0x0042 00066 (func_cal.go:6)    MOVQ    AX, "".sumret+48(SP)
        0x0047 00071 (func_cal.go:6)    MOVQ    "".res2(SP), AX
        0x004b 00075 (func_cal.go:6)    MOVQ    AX, "".subret+56(SP)
        0x0050 00080 (func_cal.go:6)    MOVQ    16(SP), BP
        0x0055 00085 (func_cal.go:6)    ADDQ    $24, SP
        0x0059 00089 (func_cal.go:6)    RET

第2行:TEXT "".calculate(SB), NOSPLIT|ABIInternal, $24-32

TEXT:函數識別符號的組合指令

"".calculate(SB):"",這個地方應該是包名,這裡省略了,用 "" 表示。calculate 函數名。calculate(SB) 表示函數名相對於 SB 偽暫存器的偏移量,二者組合就可以表示函數的具體位置。

NOSPLIT|ABIInternal:函數標識位,用於表示函數的一些特殊行為。NOSPLIT 表示子函數不進行棧分裂。ABIInternal 實驗版本的calling convention,詳情看這裡

$24-32:常用 $framesize[-argsize] 表示,$framesize 表示函數棧幀大小,包含呼叫其它函數時準備呼叫引數的隱式棧空間。argsize 表示引數和返回值的大小,前面 - 不是減號,而是一個連線符。

第3行:SUBQ $24, SP

這裡 SUBQ 做減法,為函數分配函數棧幀,SP 這裡表示硬體暫存器。如果是 $0x 開頭,那麼表示十六進位制數。這裡預設為十進位制,24個位元組。

plan9 中操有 push 和 pop,但一般生成的程式碼中是沒有的,它是通過 SUB 和 ADD 調整棧大小,是通過對硬體 SP 暫存器進行運算來實現的。

ADDQ $8, SP , 對 SP 做加法,清除函數棧幀。

把 Go 程式編譯成組合語言的命令:

go build -gcflags "-N -l" -ldflags=-compressdwarf=false -o main.out main.go
go tool objdump -s "main.main" main.out > main.S

// or
go tool compile -S main.go

// or
go build -gcflags -S main.go

組合怎麼定義變數

組合程式碼中用來表示使用者定義的符號(變數)時,可以用暫存器和偏移量還有變數名的組合來表示。
比如:x-8(SP),因為 SP 指向的是棧頂,所以偏移值都是負的,x則表示變數名

七、定義整型變數

package pkg

var Id = 9527

用下面的命令檢視Go的語言程式對應的偽組合程式碼:

$ go tool compile -S pkg.go   # or: go build -gcflags -S pkg.go
"".Id SNOPTRDATA size=8
  0x0000 37 25 00 00 00 00 00 00                          '.......

其中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

DATA 命令用於初始化包變數

Go組合語言提供了 DATA 命令用於初始化包變數,DATA命令的語法如下:

DATA symbol+offset(SB)/width, value

symbol 為變數在組合語言中對應的識別符號,
offset 是符號相對於SB的偏移量,
width 是要初始化記憶體的寬度大小,
value 是要初始化的值,
其中當前包中Go語言定義的符號symbol,在組合程式碼中對應 ·symbol,其中「·」中點符號為一個特殊的unicode符號。

我們採用以下命令可以給Id變數初始化為十六進位制的0x2537,對應十進位制的9527(常數需要以美元符號$開頭表示):

DATA ·Id+0(SB)/1, $0x37
DATA ·Id+1(SB)/1, $0x25

(未完待續)

八、參考