作者:ivansli,騰訊 IEG 運(yùn)營開發(fā)工程師
在深入學(xué)習(xí) Golang 的 runtime 和標(biāo)準(zhǔn)庫實(shí)現(xiàn)的時(shí)候發(fā)現(xiàn),如果對(duì) Golang 匯編沒有一定了解的話,很難深入了解其底層實(shí)現(xiàn)機(jī)制。在這里整理總結(jié)了一份基礎(chǔ)的 Golang 匯編入門知識(shí),通過學(xué)習(xí)之后能夠?qū)ζ涞讓訉?shí)現(xiàn)有一定的認(rèn)識(shí)。
0. 為什么寫本文
平時(shí)業(yè)務(wù)中一直使用 PHP 編寫代碼,但是一直對(duì) Golang 比較感興趣,閑暇、周末之余會(huì)看一些 Go 底層源碼。
近日在分析 go 的某些特性底層功能實(shí)現(xiàn)時(shí)發(fā)現(xiàn):有些又跟 runtime 運(yùn)行時(shí)有關(guān),而要掌握這一部分的話,有一道坎是繞不過去的,那就是 Go 匯編。索性就查閱了很多大佬們寫的資料,在閱讀之余整理總結(jié)了一下,并在這里分享給大家。
本文使用 Go 版本為 go1.14.1
1. 為什么需要匯編
眾所周知,在計(jì)算機(jī)的世界里,只有 2 種類型。那就是:0 和 1。
計(jì)算機(jī)工作是由一系列的機(jī)器指令進(jìn)行驅(qū)動(dòng)的,這些指令又是一組二進(jìn)制數(shù)字,其對(duì)應(yīng)計(jì)算機(jī)的高低電平。而這些機(jī)器指令的集合就是機(jī)器語言,這些機(jī)器語言在最底層是與硬件一一對(duì)應(yīng)的。
顯而易見,這樣的機(jī)器指令有一個(gè)致命的缺點(diǎn):可閱讀性太差(恐怕也只有天才和瘋子才有能力把控得了)。
為了解決可讀性的問題以及代碼編輯的需求,于是就誕生了最接近機(jī)器的語言:匯編語言(在我看來,匯編語言更像一種助記符,這些人們?nèi)菀子涀〉拿恳粭l助記符都映射著一條不容易記住的由 0、1 組成的機(jī)器指令。你覺得像不像域名與 IP 地址的關(guān)系呢?)。
1.1 程序的編譯過程
以 C 語言為例來說,從 hello.c 的源碼文件到 hello 可執(zhí)行文件,經(jīng)過編譯器處理,大致分為幾個(gè)階段:
編譯器在不同的階段會(huì)做不同的事情,但是有一步是可以確定的,那就是:源碼會(huì)被編譯成匯編,最后才是二進(jìn)制。
2. 程序與進(jìn)程
源碼經(jīng)過編譯之后,得到一個(gè)二進(jìn)制的可執(zhí)行文件。文件這兩個(gè)字也就表明,目前得到的這個(gè)文件跟其他文件對(duì)比,除了是具有一定的格式(Linux 中是 ELF 格式,即:可運(yùn)行可鏈接。executable linkable formate)的二進(jìn)制組成,并沒什么區(qū)別。
在 Linux 中文件類型大致分為 7 種:
b: 塊設(shè)備文件c:字符設(shè)備文件d:目錄-:普通文件l:鏈接s:socketp:管道
通過上面可以看到,可執(zhí)行文件 main 與源碼文件 main.go,都是同一種類型,屬于普通文件。(當(dāng)然了,在 Unix 中有一句很經(jīng)典的話:一切皆文件)。
那么,問題來了:
- 什么是程序?
- 什么是進(jìn)程?
2.1 程序
維基百科告訴我們:程序是指一組指示計(jì)算機(jī)或其他具有消息處理能力設(shè)備每一步動(dòng)作的指令,通常用某種程序設(shè)計(jì)語言編寫,運(yùn)行于某種目標(biāo)體系結(jié)構(gòu)上。
從某個(gè)層面來看,可以把程序分為靜態(tài)程序、動(dòng)態(tài)程序:靜態(tài)程序:單純的指具有一定格式的可執(zhí)行二進(jìn)制文件。動(dòng)態(tài)程序:則是靜態(tài)可執(zhí)行程序文件被加載到內(nèi)存之后的一種運(yùn)行時(shí)模型(又稱為進(jìn)程)。
2.2 進(jìn)程
首先,要知道的是,進(jìn)程是分配系統(tǒng)資源的最小單位,線程(帶有時(shí)間片的函數(shù))是系統(tǒng)調(diào)度的最小單位。進(jìn)程包含線程,線程所屬于進(jìn)程。
創(chuàng)建進(jìn)程一般使用 fork 方法(通常會(huì)有個(gè)拉起程序,先 fork 自身生成一個(gè)子進(jìn)程。然后,在該子進(jìn)程中通過 exec 函數(shù)把對(duì)應(yīng)程序加載進(jìn)來,進(jìn)而啟動(dòng)目標(biāo)進(jìn)程。當(dāng)然,實(shí)際上要復(fù)雜得多),而創(chuàng)建線程則是使用 pthread 線程庫。
以 32 位 Linux 操作系統(tǒng)為例,進(jìn)程經(jīng)典的虛擬內(nèi)存結(jié)構(gòu)模型如下圖所示:
其中,有兩處結(jié)構(gòu)是靜態(tài)程序所不具有的,那就是運(yùn)行時(shí)堆(heap)與運(yùn)行時(shí)棧(stack)。
運(yùn)行時(shí)堆從低地址向高地址增長,申請(qǐng)的內(nèi)存空間需要程序員自己或者由 GC 釋放。運(yùn)行時(shí)棧從高地址向低地址增長,內(nèi)存空間在當(dāng)前棧楨調(diào)用結(jié)束之后自動(dòng)釋放(并不是清除其所占用內(nèi)存中數(shù)據(jù),而是通過棧頂指針 SP 的移動(dòng),來標(biāo)識(shí)哪些內(nèi)存是正在使用的)。
3. Go 匯編
對(duì)于 Go 編譯器而言,其輸出的結(jié)果是一種抽象可移植的匯編代碼,這種匯編(Go 的匯編是基于 Plan9 的匯編)并不對(duì)應(yīng)某種真實(shí)的硬件架構(gòu)。Go 的匯編器會(huì)使用這種偽匯編,再為目標(biāo)硬件生成具體的機(jī)器指令。
偽匯編這一個(gè)額外層可以帶來很多好處,最主要的一點(diǎn)是方便將 Go 移植到新的架構(gòu)上。
相關(guān)的信息可以參考 Rob Pike 的 The Design of the Go Assembler。
要了解 Go 的匯編器最重要的是要知道 Go 的匯編器不是對(duì)底層機(jī)器的直接表示,即 Go 的匯編器沒有直接使用目標(biāo)機(jī)器的匯編指令。Go 匯編器所用的指令,一部分與目標(biāo)機(jī)器的指令一一對(duì)應(yīng),而另外一部分則不是。這是因?yàn)榫幾g器套件不需要匯編器直接參與常規(guī)的編譯過程。
相反,編譯器使用了一種半抽象的指令集,并且部分指令是在代碼生成后才被選擇的。匯編器基于這種半抽象的形式工作,所以雖然你看到的是一條 MOV 指令,但是工具鏈針對(duì)對(duì)這條指令實(shí)際生成可能完全不是一個(gè)移動(dòng)指令,也許會(huì)是清除或者加載。也有可能精確的對(duì)應(yīng)目標(biāo)平臺(tái)上同名的指令。概括來說,特定于機(jī)器的指令會(huì)以他們的本尊出現(xiàn), 然而對(duì)于一些通用的操作,如內(nèi)存的移動(dòng)以及子程序的調(diào)用以及返回通常都做了抽象。細(xì)節(jié)因架構(gòu)不同而不一樣,我們對(duì)這樣的不精確性表示歉意,情況并不明確。
匯編器程序的工作是對(duì)這樣半抽象指令集進(jìn)行解析并將其轉(zhuǎn)變?yōu)榭梢暂斎氲芥溄悠鞯闹噶睢?/p>
The most important thing to know about Go’s assembler is that it is not a direct representation of the underlying machine. Some of the details map precisely to the machine, but some do not. This is because the compiler suite needs no assembler pass in the usual pipeline. Instead, the compiler operates on a kind of semi-abstract instruction set, and instruction selection occurs partly after code generation. The assembler works on the semi-abstract form, so when you see an instruction like MOV what the toolchain actually generates for that operation might not be a move instruction at all, perhaps a clear or load.
Or it might correspond exactly to the machine instruction with that name. In general, machine-specific operations tend to appear as themselves, while more general concepts like memory move and subroutine call and return are more abstract. The details vary with architecture, and we apologize for the imprecision; the situation is not well-defined.
The assembler program is a way to parse a description of that semi-abstract instruction set and turn it into instructions to be input to the linker.
Go 匯編使用的是caller-save模式,被調(diào)用函數(shù)的入?yún)?shù)、返回值都由調(diào)用者維護(hù)、準(zhǔn)備。因此,當(dāng)需要調(diào)用一個(gè)函數(shù)時(shí),需要先將這些工作準(zhǔn)備好,才調(diào)用下一個(gè)函數(shù),另外這些都需要進(jìn)行內(nèi)存對(duì)齊,對(duì)齊的大小是 sizeof(uintptr)。
3.1 幾個(gè)概念
在深入了解 Go 匯編之前,需要知道的幾個(gè)概念:
- 棧:進(jìn)程、線程、goroutine 都有自己的調(diào)用棧,先進(jìn)后出(FILO)
- 棧幀:可以理解是函數(shù)調(diào)用時(shí),在棧上為函數(shù)所分配的內(nèi)存區(qū)域
- 調(diào)用者:caller,比如:A 函數(shù)調(diào)用了 B 函數(shù),那么 A 就是調(diào)用者
- 被調(diào)者:callee,比如:A 函數(shù)調(diào)用了 B 函數(shù),那么 B 就是被調(diào)者
3.2 Go 的核心寄存器
go 匯編中有 4 個(gè)核心的偽寄存器,這 4 個(gè)寄存器是編譯器用來維護(hù)上下文、特殊標(biāo)識(shí)等作用的:
寄存器說明SB(Static base pointer)global symbolsFP(Frame pointer)arguments and localsPC(Program counter)jumps and branchesSP(Stack pointer)top of stack
- FP: 使用如 symbol offset(FP)的方式,引用 callee 函數(shù)的入?yún)?shù)。例如 arg0 0(FP),arg1 8(FP),使用 FP 必須加 symbol ,否則無法通過編譯(從匯編層面來看,symbol 沒有什么用,加 symbol 主要是為了提升代碼可讀性)。另外,需要注意的是:往往在編寫 go 匯編代碼時(shí),要站在 callee 的角度來看(FP),在 callee 看來,(FP)指向的是 caller 調(diào)用 callee 時(shí)傳遞的第一個(gè)參數(shù)的位置。假如當(dāng)前的 callee 函數(shù)是 add,在 add 的代碼中引用 FP,該 FP 指向的位置不在 callee 的 stack frame 之內(nèi)。而是在 caller 的 stack frame 上,指向調(diào)用 add 函數(shù)時(shí)傳遞的第一個(gè)參數(shù)的位置,經(jīng)常在 callee 中用symbol offset(FP)來獲取入?yún)⒌膮?shù)值。
- SB: 全局靜態(tài)基指針,一般用在聲明函數(shù)、全局變量中。
- SP: 該寄存器也是最具有迷惑性的寄存器,因?yàn)闀?huì)有偽 SP 寄存器和硬件 SP 寄存器之分。plan9 的這個(gè)偽 SP 寄存器指向當(dāng)前棧幀第一個(gè)局部變量的結(jié)束位置(為什么說是結(jié)束位置,可以看下面寄存器內(nèi)存布局圖),使用形如 symbol offset(SP) 的方式,引用函數(shù)的局部變量。offset 的合法取值是 [-framesize, 0),注意是個(gè)左閉右開的區(qū)間。假如局部變量都是 8 字節(jié),那么第一個(gè)局部變量就可以用 localvar0-8(SP) 來表示。與硬件寄存器 SP 是兩個(gè)不同的東西,在棧幀 size 為 0 的情況下,偽寄存器 SP 和硬件寄存器 SP 指向同一位置。手寫匯編代碼時(shí),如果是 symbol offset(SP)形式,則表示偽寄存器 SP。如果是 offset(SP)則表示硬件寄存器 SP。務(wù)必注意:對(duì)于編譯輸出(go tool compile -S / go tool objdump)的代碼來講,所有的 SP 都是硬件 SP 寄存器,無論是否帶 symbol(這一點(diǎn)非常具有迷惑性,需要慢慢理解。往往在分析編譯輸出的匯編時(shí),看到的就是硬件 SP 寄存器)。
- PC: 實(shí)際上就是在體系結(jié)構(gòu)的知識(shí)中常見的 pc 寄存器,在 x86 平臺(tái)下對(duì)應(yīng) ip 寄存器,amd64 上則是 rip。除了個(gè)別跳轉(zhuǎn)之外,手寫 plan9 匯編代碼時(shí),很少用到 PC 寄存器。
通過上面的講解,想必已經(jīng)對(duì) 4 個(gè)核心寄存器的區(qū)別有了一定的認(rèn)識(shí)(或者是更加的迷惑、一頭霧水)。那么,需要留意的是:如果是在分析編譯輸出的匯編代碼時(shí),要重點(diǎn)看 SP、SB 寄存器(FP 寄存器在這里是看不到的)。如果是,在手寫匯編代碼,那么要重點(diǎn)看 FP、SP 寄存器。
3.2.1 偽寄存器的內(nèi)存模型
下圖描述了棧楨與各個(gè)寄存器的內(nèi)存關(guān)系模型,值得注意的是要站在 callee 的角度來看。
有一點(diǎn)需要注意的是,return addr 也是在 caller 的棧上的,不過往棧上插 return addr 的過程是由 CALL 指令完成的(在分析匯編時(shí),是看不到關(guān)于 addr 相關(guān)空間信息的。在分配棧空間時(shí),addr 所占用空間大小不包含在棧幀大小內(nèi))。
在 AMD64 環(huán)境,偽 PC 寄存器其實(shí)是 IP 指令計(jì)數(shù)器寄存器的別名。偽 FP 寄存器對(duì)應(yīng)的是 caller 函數(shù)的幀指針,一般用來訪問 callee 函數(shù)的入?yún)?shù)和返回值。偽 SP 棧指針對(duì)應(yīng)的是當(dāng)前 callee 函數(shù)棧幀的底部(不包括參數(shù)和返回值部分),一般用于定位局部變量。偽 SP 是一個(gè)比較特殊的寄存器,因?yàn)檫€存在一個(gè)同名的 SP 真寄存器,真 SP 寄存器對(duì)應(yīng)的是棧的頂部。
在編寫 Go 匯編時(shí),當(dāng)需要區(qū)分偽寄存器和真寄存器的時(shí)候只需要記住一點(diǎn):偽寄存器一般需要一個(gè)標(biāo)識(shí)符和偏移量為前綴,如果沒有標(biāo)識(shí)符前綴則是真寄存器。比如(SP)、 8(SP)沒有標(biāo)識(shí)符前綴為真 SP 寄存器,而 a(SP)、b 8(SP)有標(biāo)識(shí)符為前綴表示偽寄存器。
3.2.2 幾點(diǎn)說明
我們這里對(duì)容易混淆的幾點(diǎn)簡單進(jìn)行說明:
- 偽 SP 和硬件 SP 不是一回事,在手寫匯編代碼時(shí),偽 SP 和硬件 SP 的區(qū)分方法是看該 SP 前是否有 symbol。如果有 symbol,那么即為偽寄存器,如果沒有,那么說明是硬件 SP 寄存器。
- 偽 SP 和 FP 的相對(duì)位置是會(huì)變的,所以不應(yīng)該嘗試用偽 SP 寄存器去找那些用 FP offset 來引用的值,例如函數(shù)的入?yún)⒑头祷刂怠?/li>
- 官方文檔中說的偽 SP 指向 stack 的 top,可能是有問題的。其指向的局部變量位置實(shí)際上是整個(gè)棧的棧底(除 caller BP 之外),所以說 bottom 更合適一些。
- 在 go tool objdump/go tool compile -S 輸出的代碼中,是沒有偽 SP 和 FP 寄存器的,我們上面說的區(qū)分偽 SP 和硬件 SP 寄存器的方法,對(duì)于上述兩個(gè)命令的輸出結(jié)果是沒法使用的。在編譯和反匯編的結(jié)果中,只有真實(shí)的 SP 寄存器。
3.2.3 IA64 和 plan9 的對(duì)應(yīng)關(guān)系
在 plan9 匯編里還可以直接使用的 amd64 的通用寄存器,應(yīng)用代碼層面會(huì)用到的通用寄存器主要是: rax, rbx, rcx, rdx, rdi, rsi, r8~r15 這些寄存器,雖然 rbp 和 rsp 也可以用,不過 bp 和 sp 會(huì)被用來管理?xiàng)m敽蜅5?,最好不要拿來進(jìn)行運(yùn)算。
plan9 中使用寄存器不需要帶 r 或 e 的前綴,例如 rax,只要寫 AX 即可: MOVQ $101, AX = mov rax, 101
下面是通用通用寄存器的名字在 IA64 和 plan9 中的對(duì)應(yīng)關(guān)系:
3.3 常用操作指令
下面列出了常用的幾個(gè)匯編指令(指令后綴Q 說明是 64 位上的匯編指令)
助記符指令種類用途示例MOVQ傳送數(shù)據(jù)傳送MOVQ 48, AX // 把 48 傳送到 AXLEAQ傳送地址傳送LEAQ AX, BX // 把 AX 有效地址傳送到 BXPUSHQ傳送棧壓入PUSHQ AX // 將 AX 內(nèi)容送入棧頂位置POPQ傳送棧彈出POPQ AX // 彈出棧頂數(shù)據(jù)后修改棧頂指針ADDQ運(yùn)算相加并賦值A(chǔ)DDQ BX, AX // 等價(jià)于 AX =BXSUBQ運(yùn)算相減并賦值SUBQ BX, AX // 等價(jià)于 AX-=BXCMPQ運(yùn)算比較大小CMPQ SI CX // 比較 SI 和 CX 的大小CALL轉(zhuǎn)移調(diào)用函數(shù)CALL runtime.printnl(SB) // 發(fā)起調(diào)用JMP轉(zhuǎn)移無條件轉(zhuǎn)移指令JMP 0x0185 //無條件轉(zhuǎn)至 0x0185 地址處JLS轉(zhuǎn)移條件轉(zhuǎn)移指令JLS 0x0185 //左邊小于右邊,則跳到 0x0185
4. 匯編分析
說了那么多,it is code show time。
4.1 如何輸出 Go 匯編
對(duì)于寫好的 go 源碼,生成對(duì)應(yīng)的 Go 匯編,大概有下面幾種
- 方法 1 先使用 go build -gcflags "-N -l" main.go 生成對(duì)應(yīng)的可執(zhí)行二進(jìn)制文件 再使用 go tool objdump -s "main." main 反編譯獲取對(duì)應(yīng)的匯編
反編譯時(shí)"main." 表示只輸出 main 包中相關(guān)的匯編"main.main" 則表示只輸出 main 包中 main 方法相關(guān)的匯編
- 方法 2 使用 go tool compile -S -N -l main.go 這種方式直接輸出匯編
- 方法 3 使用go build -gcflags="-N -l -S" main.go 直接輸出匯編
注意:在使用這些命令時(shí),加上對(duì)應(yīng)的 flag,否則某些邏輯會(huì)被編譯器優(yōu)化掉,而看不到對(duì)應(yīng)完整的匯編代碼
-l 禁止內(nèi)聯(lián) -N 編譯時(shí),禁止優(yōu)化 -S 輸出匯編代碼
4.2 Go 匯編示例
go 示例代碼
package mainfunc add(a, b int) int{ sum := 0 // 不設(shè)置該局部變量sum,add??臻g大小會(huì)是0 sum = a b return sum}func main(){ println(add(1,2))}
編譯 go 源代碼,輸出匯編
go tool compile -N -l -S main.go
截取主要匯編如下:
"".add STEXT nosplit size=60 args=0x18 locals=0x10 0x0000 00000 (main.go:3) TEXT "".add(SB), NOSPLIT, $16-24 0x0000 00000 (main.go:3) SUBQ $16, SP ;;生成add??臻g 0x0004 00004 (main.go:3) MOVQ BP, 8(SP) 0x0009 00009 (main.go:3) LEAQ 8(SP), BP ;; ...omitted FUNCDATA stuff... 0x000e 00014 (main.go:3) MOVQ $0, "".~r2 40(SP) ;;初始化返回值 0x0017 00023 (main.go:4) MOVQ $0, "".sum(SP) ;;局部變量sum賦為0 0x001f 00031 (main.go:5) MOVQ "".a 24(SP), AX ;;取參數(shù)a 0x0024 00036 (main.go:5) ADDQ "".b 32(SP), AX ;;等價(jià)于AX=a b 0x0029 00041 (main.go:5) MOVQ AX, "".sum(SP) ;;賦值局部變量sum 0x002d 00045 (main.go:6) MOVQ AX, "".~r2 40(SP) ;;設(shè)置返回值 0x0032 00050 (main.go:6) MOVQ 8(SP), BP 0x0037 00055 (main.go:6) ADDQ $16, SP ;;清除add??臻g 0x003b 00059 (main.go:6) RET ......"".main STEXT size=107 args=0x0 locals=0x28 0x0000 00000 (main.go:9) TEXT "".main(SB), $40-0 ...... 0x000f 00015 (main.go:9) SUBQ $40, SP ;; 生成main棧空間 0x0013 00019 (main.go:9) MOVQ BP, 32(SP) 0x0018 00024 (main.go:9) LEAQ 32(SP), BP ;; ...omitted FUNCDATA stuff... 0x001d 00029 (main.go:10) MOVQ $1, (SP) ;;add入?yún)ⅲ? 0x0025 00037 (main.go:10) MOVQ $2, 8(SP) ;;add入?yún)ⅲ? 0x002e 00046 (main.go:10) CALL "".add(SB) ;;調(diào)用add函數(shù) 0x0033 00051 (main.go:10) MOVQ 16(SP), AX 0x0038 00056 (main.go:10) MOVQ AX, ""..autotmp_0 24(SP) 0x003d 00061 (main.go:10) CALL runtime.printlock(SB) 0x0042 00066 (main.go:10) MOVQ ""..autotmp_0 24(SP), AX 0x0047 00071 (main.go:10) MOVQ AX, (SP) 0x004b 00075 (main.go:10) CALL runtime.printint(SB) 0x0050 00080 (main.go:10) CALL runtime.printnl(SB) 0x0055 00085 (main.go:10) CALL runtime.printunlock(SB) 0x005a 00090 (main.go:11) MOVQ 32(SP), BP 0x005f 00095 (main.go:11) ADDQ $40, SP ;;清除main??臻g 0x0063 00099 (main.go:11) RET ......
這里列舉了一個(gè)簡單的 int 類型加法示例,實(shí)際開發(fā)中會(huì)遇到各種參數(shù)類型,要復(fù)雜的多,這里只是拋磚引玉 ??
4.3 Go 匯編解析
針對(duì) 4.2 輸出匯編,對(duì)重要核心代碼進(jìn)行分析。
4.3.1 add 函數(shù)匯編解析
- TEXT "".add(SB), NOSPLIT|ABIInternal, $16-24
TEXT "".add TEXT 指令聲明了 "".add 是 .text 代碼段的一部分,并表明跟在這個(gè)聲明后的是函數(shù)的函數(shù)體。在鏈接期,""這個(gè)空字符會(huì)被替換為當(dāng)前的包名: 也就是說,"".add在鏈接到二進(jìn)制文件后會(huì)變成 main.add
(SB) SB 是一個(gè)虛擬的偽寄存器,保存靜態(tài)基地址(static-base) 指針,即我們程序地址空間的開始地址。"".add(SB) 表明我們的符號(hào)位于某個(gè)固定的相對(duì)地址空間起始處的偏移位置 (最終是由連接器計(jì)算得到的)。換句話來講,它有一個(gè)直接的絕對(duì)地址: 是一個(gè)全局的函數(shù)符號(hào)。
NOSPLIT: 向編譯器表明不應(yīng)該插入 stack-split 的用來檢查棧需要擴(kuò)張的前導(dǎo)指令。在我們 add 函數(shù)的這種情況下,編譯器自己幫我們插入了這個(gè)標(biāo)記: 它足夠聰明地意識(shí)到,由于 add 沒有任何局部變量且沒有它自己的棧幀,所以一定不會(huì)超出當(dāng)前的棧。不然,每次調(diào)用函數(shù)時(shí),在這里執(zhí)行棧檢查就是完全浪費(fèi) CPU 時(shí)間了。
$0-16
24 指定了調(diào)用方傳入的參數(shù) 返回值大?。?4 字節(jié)=入?yún)?a、b 大小8字節(jié)*2 返回值8字節(jié))
通常來講,幀大小后一般都跟隨著一個(gè)參數(shù)大小,用減號(hào)分隔。(這不是一個(gè)減法操作,只是一種特殊的語法) 幀大小 $24-8 意味著這個(gè)函數(shù)有 24 個(gè)字節(jié)的幀以及 8 個(gè)字節(jié)的參數(shù),位于調(diào)用者的幀上。如果 NOSPLIT 沒有在 TEXT 中指定,則必須提供參數(shù)大小。對(duì)于 Go 原型的匯編函數(shù),go vet 會(huì)檢查參數(shù)大小是否正確。
In the general case, the frame size is followed by an argument size, separated by a minus sign. (It’s not a subtraction, just idiosyncratic syntax.) The frame size $24-8 states that the function has a 24-byte frame and is called with 8 bytes of argument, which live on the caller’s frame. If NOSPLIT is not specified for the TEXT, the argument size must be provided. For assembly functions with Go prototypes, go vet will check that the argument size is correct.
- SUBQ $16, SPSP 為棧頂指針,該語句等價(jià)于 SP-=16(由于??臻g是向下增長的,所以開辟??臻g時(shí)為減操作),表示生成 16 字節(jié)大小的??臻g。
- MOVQ $0, "".~r2 40(SP)此時(shí)的 SP 為 add 函數(shù)棧的棧頂指針,40(SP)的位置則是 add 返回值的位置,該位置位于 main 函數(shù)棧空間內(nèi)。該語句設(shè)置返回值類型的 0 值,即初始化返回值,防止得到臟數(shù)據(jù)(返回值類型為 int,int 的 0 值為 0)。
- MOVQ "".a 24(SP), AX從 main 函數(shù)??臻g獲取入?yún)?a 的值,存到寄存器 AX
- ADDQ "".b 32(SP), AX從 main 函數(shù)棧空間獲取入?yún)?b 的值,與寄存器 AX 中存儲(chǔ)的 a 值相加,結(jié)果存到 AX。相當(dāng)于 AX=a b
- MOVQ AX, "".~r2 40(SP)把 a b 的結(jié)果放到 main 函數(shù)棧中, add(a b)返回值所在的位置
- ADDQ $16, SP歸還 add 函數(shù)占用的??臻g
4.3.2 函數(shù)棧楨結(jié)構(gòu)模型
根據(jù) 4.2 對(duì)應(yīng)匯編繪制的函數(shù)棧楨結(jié)構(gòu)模型
還記得前面提到的,Go 匯編使用的是caller-save模式,被調(diào)用函數(shù)的參數(shù)、返回值、棧位置都需要由調(diào)用者維護(hù)、準(zhǔn)備嗎?
在函數(shù)棧楨結(jié)構(gòu)中可以看到,add()函數(shù)的入?yún)⒁约胺祷刂刀加烧{(diào)用者 main()函數(shù)維護(hù)。也正是因?yàn)槿绱?,GO 有了其他語言不具有的,支持多個(gè)返回值的特性。
4.4 Go 匯編語法
這里重點(diǎn)講一下函數(shù)聲明、變量聲明。
4.4.1 函數(shù)聲明
來看一個(gè)典型的 Go 匯編函數(shù)定義
// func add(a, b int) int// 該add函數(shù)聲明定義在同一個(gè) package name 下的任意 .go文件中// 只有函數(shù)頭,沒有實(shí)現(xiàn)// add函數(shù)的Go匯編實(shí)現(xiàn)// pkgname 默認(rèn)是 ""TEXT pkgname·add(SB), NOSPLIT, $16-24 MOVQ a 0(FP), AX ADDQ b 8(FP), AX MOVQ AX, ret 16(FP) RET
Go 匯編實(shí)現(xiàn)為什么是 TEXT 開頭?仔細(xì)觀察上面的進(jìn)程內(nèi)存布局圖就會(huì)發(fā)現(xiàn),我們的代碼在是存儲(chǔ)在.text 段中的,這里也就是一種約定俗成的起名方式。實(shí)際上在 plan9 中 TEXT 是一個(gè)指令,用來定義一個(gè)函數(shù)。
定義中的 pkgname 是可以省略的,(非想寫也可以寫上,不過寫上 pkgname 的話,在重命名 package 之后還需要改代碼,默認(rèn)為"") 編譯器會(huì)在鏈接期自動(dòng)加上所屬的包名稱。
中點(diǎn) · 比較特殊,是一個(gè) unicode 的中點(diǎn),該點(diǎn)在 mac 下的輸入方法是 option shift 9。在程序被鏈接之后,所有的中點(diǎn)·都會(huì)被替換為句號(hào).,比如你的方法是runtime·main,在編譯之后的程序里的符號(hào)則是runtime.main。
簡單總結(jié)一下, Go 匯編實(shí)現(xiàn)函數(shù)聲明,格式為:
靜態(tài)基地址(static-base) 指針 | | add函數(shù)入?yún)?返回值總大小 | |TEXT pkgname·add(SB),NOSPLIT,$16-24 | | |函數(shù)所屬包名 函數(shù)名 add函數(shù)棧幀大小
- 函數(shù)棧幀大?。壕植孔兞?可能需要的額外調(diào)用函數(shù)的參數(shù)空間的總大小,不包括調(diào)用其它函數(shù)時(shí)的 ret address 的大小。
- (SB): SB 是一個(gè)虛擬寄存器,保存了靜態(tài)基地址(static-base) 指針,即我們程序地址空間的開始地址。"".add(SB) 表明我們的符號(hào)位于某個(gè)固定的相對(duì)地址空間起始處的偏移位置 (最終是由鏈接器計(jì)算得到的)。換句話來講,它有一個(gè)直接的絕對(duì)地址: 是一個(gè)全局的函數(shù)符號(hào)。
- NOSPLIT: 向編譯器表明,不應(yīng)該插入 stack-split 的用來檢查棧需要擴(kuò)張的前導(dǎo)指令。在我們 add 函數(shù)的這種情況下,編譯器自己幫我們插入了這個(gè)標(biāo)記: 它足夠聰明地意識(shí)到,add 不會(huì)超出當(dāng)前的棧,因此沒必要調(diào)用函數(shù)時(shí)在這里執(zhí)行棧檢查。
4.4.2 變量聲明
匯編里的全局變量,一般是存儲(chǔ)在.rodata或者.data段中。對(duì)應(yīng)到 Go 代碼,就是已初始化過的全局的 const、var 變量/常量。
使用 DATA 結(jié)合 GLOBL 來定義一個(gè)變量。
DATA 的用法為:
DATA symbol offset(SB)/width, value
大多數(shù)參數(shù)都是字面意思,不過這個(gè) offset 需要注意:其含義是該值相對(duì)于符號(hào) symbol 的偏移,而不是相對(duì)于全局某個(gè)地址的偏移。
GLOBL 匯編指令用于定義名為 symbol 的全局變量,變量對(duì)應(yīng)的內(nèi)存寬度為 width,內(nèi)存寬度部分必須用常量初始化。
GLOBL ·symbol(SB), width
下面是定義了多個(gè)變量的例子:
DATA ·age 0(SB)/4, $8 ;; 數(shù)值8為 4字節(jié)GLOBL ·age(SB), RODATA, $4DATA ·pi 0(SB)/8, $3.1415926 ;; 數(shù)值3.1415926為float64, 8字節(jié)GLOBL ·pi(SB), RODATA, $8DATA ·year 0(SB)/4, $2020 ;; 數(shù)值2020為 4字節(jié)GLOBL ·year(SB), RODATA, $4;; 變量hello 使用2個(gè)DATA來定義DATA ·hello 0(SB)/8, $"hello my" ;; `hello my` 共8個(gè)字節(jié)DATA ·hello 8(SB)/8, $" world" ;; ` world` 共8個(gè)字節(jié)(3個(gè)空格)GLOBL ·hello(SB), RODATA, $16 ;; `hello my world` 共16個(gè)字節(jié)DATA ·hello<> 0(SB)/8, $"hello my" ;; `hello my` 共8個(gè)字節(jié)DATA ·hello<> 8(SB)/8, $" world" ;; ` world` 共8個(gè)字節(jié)(3個(gè)空格)GLOBL ·hello<>(SB), RODATA, $16 ;; `hello my world` 共16個(gè)字節(jié)
大部分都比較好理解,不過這里引入了新的標(biāo)記<>,這個(gè)跟在符號(hào)名之后,表示該全局變量只在當(dāng)前文件中生效,類似于 C 語言中的 static。如果在另外文件中引用該變量的話,會(huì)報(bào) relocation target not found 的錯(cuò)誤。
5. 手寫匯編實(shí)現(xiàn)功能
在 Go 源碼中會(huì)看到一些匯編寫的代碼,這些代碼跟其他 go 代碼一起組成了整個(gè) go 的底層功能實(shí)現(xiàn)。下面,我們通過一個(gè)簡單的 Go 匯編代碼示例來實(shí)現(xiàn)兩數(shù)相加功能。
5.1 使用 Go 匯編實(shí)現(xiàn) add 函數(shù)
Go 代碼
package mainfunc add(a, b int64) int64func main(){ println(add(2,3))}
Go 源碼中 add()函數(shù)只有函數(shù)簽名,沒有具體的實(shí)現(xiàn)(使用 GO 匯編實(shí)現(xiàn))
使用 Go 匯編實(shí)現(xiàn)的 add()函數(shù)
TEXT ·add(SB), $0-24 ;; add棧空間為0,入?yún)?返回值大小=24字節(jié) MOVQ x 0(FP), AX ;; 從main中取參數(shù):2 ADDQ y 8(FP), AX ;; 從main中取參數(shù):3 MOVQ AX, ret 16(FP) ;; 保存結(jié)果到返回值 RET
把 Go 源碼與 Go 匯編編譯到一起(我這里,這兩個(gè)文件在同一個(gè)目錄)
go build -gcflags "-N -l" .
我這里目錄為 demo1,所以得到可執(zhí)行程序 demo1,運(yùn)行得到結(jié)果:5
5.2 反編譯可執(zhí)行程序
對(duì) 5.1 中得到的可執(zhí)行程序 demo1 使用 objdump 進(jìn)行反編譯,獲取匯編代碼
go tool objdump -s "main." demo1
得到匯編
......TEXT main.main(SB) /root/go/src/demo1/main.go main.go:5 0x4581d0 64488b0c25f8ffffff MOVQ FS:0xfffffff8, CX main.go:5 0x4581d9 483b6110 CMPQ 0x10(CX), SP main.go:5 0x4581dd 7655 JBE 0x458234 main.go:5 0x4581df 4883ec28 SUBQ $0x28, SP ;;生成main棧楨 main.go:5 0x4581e3 48896c2420 MOVQ BP, 0x20(SP) main.go:5 0x4581e8 488d6c2420 LEAQ 0x20(SP), BP main.go:6 0x4581ed 48c7042402000000 MOVQ $0x2, 0(SP) ;;參數(shù)值 2 main.go:6 0x4581f5 48c744240803000000 MOVQ $0x3, 0x8(SP) ;;參數(shù)值 3 main.go:6 0x4581fe e83d000000 CALL main.add(SB);;call add main.go:6 0x458203 488b442410 MOVQ 0x10(SP), AX main.go:6 0x458208 4889442418 MOVQ AX, 0x18(SP) main.go:6 0x45820d e8fe2dfdff CALL runtime.printlock(SB) main.go:6 0x458212 488b442418 MOVQ 0x18(SP), AX main.go:6 0x458217 48890424 MOVQ AX, 0(SP) main.go:6 0x45821b e87035fdff CALL runtime.printint(SB) main.go:6 0x458220 e87b30fdff CALL runtime.printnl(SB) main.go:6 0x458225 e8662efdff CALL runtime.printunlock(SB) main.go:7 0x45822a 488b6c2420 MOVQ 0x20(SP), BP main.go:7 0x45822f 4883c428 ADDQ $0x28, SP main.go:7 0x458233 c3 RET main.go:5 0x458234 e89797ffff CALL runtime.morestack_noctxt(SB) main.go:5 0x458239 eb95 JMP main.main(SB);; 反編譯得到的匯編與add_amd64.s文件中的匯編大致操作一致TEXT main.add(SB) /root/go/src/demo1/add_amd64.s add_amd64.s:2 0x458240 488b442408 MOVQ 0x8(SP), AX ;; 獲取第一個(gè)參數(shù) add_amd64.s:3 0x458245 4803442410 ADDQ 0x10(SP), AX ;;參數(shù)a 參數(shù)b add_amd64.s:5 0x45824a 4889442418 MOVQ AX, 0x18(SP) ;;保存計(jì)算結(jié)果 add_amd64.s:7 0x45824f c3 RET
通過上面操作,可知:
- (FP)偽寄存器,只有在編寫 Go 匯編代碼時(shí)使用。FP 偽寄存器指向 caller 傳遞給 callee 的第一個(gè)參數(shù)
- 使用 go tool compile / go tool objdump 得到的匯編中看不到(FP)寄存器的蹤影
6. Go 調(diào)試工具
這里推薦 2 個(gè) Go 代碼調(diào)試工具。
6.1 gdb 調(diào)試 Go 代碼
測試代碼
package maintype Ier interface{ add(a, b int) int sub(a, b int) int}type data struct{ a, b int}func (*data) add(a, b int) int{ return a b}func (*data) sub(a, b int) int{ return a-b}func main(){ var t Ier = &data{3,4} println(t.add(1,2)) println(t.sub(3,2))}
編譯 go build -gcflags "-N -l" -o main
使用 GDB 調(diào)試
> gdb mainGNU gdb (GDB) Red Hat Enterprise Linux 7.6.1-80.el7Copyright (C) 2013 Free Software Foundation, Inc.License GPLv3 : GNU GPL version 3 or later http://gnu.org/licenses/gpl.htmlThis is free software: you are free to change and redistribute it.There is NO WARRANTY, to the extent permitted by law. Type "show copying"and "show warranty" for details.This GDB was configured as "x86_64-redhat-linux-gnu".For bug reporting instructions, please see:<http://www.gnu.org/software/gdb/bugs/>...Reading symbols from /root/go/src/interface/main...done.Loading Go Runtime support.(gdb) list // 顯示源碼14 func (*data) add(a, b int) int{15 return a b16 }1718 func (*data) sub(a, b int) int{19 return a-b20 }212223 func main(){(gdb) list24 var t Ier = &data{3,4}2526 println(t.add(1,2))27 println(t.sub(3,2))28 }29(gdb) b 26 // 在源碼26行處設(shè)置斷點(diǎn)Breakpoint 1 at 0x45827c: file /root/go/src/interface/main.go, line 26.(gdb) rStarting program: /root/go/src/interface/mainBreakpoint 1, main.main () at /root/go/src/interface/main.go:2626 println(t.add(1,2))(gdb) info locals // 顯示變量t = {tab = 0x487020 <data,main.Ier>, data = 0xc000096000}(gdb) ptype t // 打印t的結(jié)構(gòu)type = struct runtime.iface { runtime.itab *tab; void *data;}(gdb) p *t.tab.inter // 打印t.tab.inter指針指向的數(shù)據(jù)$2 = {typ = {size = 16, ptrdata = 16, hash = 2491815843, tflag = 7 'a', align = 8 'b', fieldAlign = 8 'b', kind = 20 '024', equal = {void (void *, void *, bool *)} 0x466ec0, gcdata = 0x484351 "002003004005006abtnfr016017020022025026030033034036037"&(,-5<BUXx216231330335377", str = 6568, ptrToThis = 23808}, pkgpath = {bytes = 0x4592b4 ""}, mhdr = []runtime.imethod = {{name = 277, ityp = 48608}, {name = 649, ityp = 48608}}}(gdb) disass // 顯示匯編Dump of assembler code for function main.main: 0x0000000000458210 < 0>: mov %fs:0xfffffffffffffff8,%rcx 0x0000000000458219 < 9>: cmp 0x10(%rcx),%rsp 0x000000000045821d < 13>: jbe 0x458324 <main.main 276> 0x0000000000458223 < 19>: sub $0x50,%rsp 0x0000000000458227 < 23>: mov %rbp,0x48(%rsp) 0x000000000045822c < 28>: lea 0x48(%rsp),%rbp 0x0000000000458231 < 33>: lea 0x10dc8(%rip),%rax # 0x469000 0x0000000000458238 < 40>: mov %rax,(%rsp) 0x000000000045823c < 44>: callq 0x40a5c0 <runtime.newobject>
常用的 gdb 調(diào)試命令
- run
- continue
- break
- backtrace 與 frame
- info break、locals
- list 命令
- print 和 ptype 命令
- disass
除了 gdb,另外推薦一款 gdb 的增強(qiáng)版調(diào)試工具 cgdb
https://cgdb.github.io/
效果如下圖所示,分兩個(gè)窗口:上面顯示源代碼,下面是具體的命令行調(diào)試界面(跟 gdb 一樣):
6.2 delve 調(diào)試代碼
delve 項(xiàng)目地址
https://github.com/go-delve/delve
帶圖形化界面的 dlv 項(xiàng)目地址
https://github.com/aarzilli/gdlv
dlv 的安裝使用,這里不再做過多講解,感興趣的可以嘗試一下。
- gdb 作為調(diào)試工具自是不用多說,比較老牌、強(qiáng)大,可以支持多種語言。
- delve 則是使用 go 語言開發(fā)的,用來調(diào)試 go 的工具,功能也是十分強(qiáng)大,打印結(jié)果可以顯示 gdb 支持不了的東西,這里不再做過多講解,有興趣的可以查閱相關(guān)資料。
7. 總結(jié)
對(duì)于 Go 匯編基礎(chǔ)大致需要熟悉下面幾個(gè)方面:
通過上面的例子相信已經(jīng)讓你對(duì) Go 的匯編有了一定的理解。當(dāng)然,對(duì)于大部分業(yè)務(wù)開發(fā)人員來說,只要看的懂即可。如果想進(jìn)一步的了解,可以閱讀相關(guān)的資料或者書籍。
最后想說的是:鑒于個(gè)人能力有限,在閱讀過程中你可能會(huì)發(fā)現(xiàn)存在的一些問題或者缺陷,歡迎各位大佬指正。如果感興趣的話,也可以一起私下交流。
8. 參考資料
在整理的過程中,部分參考、引用下面鏈接地址內(nèi)容。有一些寫的還是不錯(cuò)的,感興趣的同學(xué)可以閱讀。
[1] https://github.com/cch123/golang-notes/blob/master/assembly.md plan9 assembly
[2] https://segmentfault.com/a/1190000019753885 匯編入門
[3] https://www.davidwong.fr/goasm/ Go Assembly by Example
[4] https://juejin.im/post/6844904005630443533#heading-3
[5] https://github.com/go-internals-cn/go-internals/blob/master/chapter1_assembly_primer/README.md
[6] https://lrita.github.io/2017/12/12/golang-asm/
[7] https://chai2010.cn/advanced-go-programming-book/ch3-asm/ch3-01-basic.html
版權(quán)聲明:本文內(nèi)容由互聯(lián)網(wǎng)用戶自發(fā)貢獻(xiàn),該文觀點(diǎn)僅代表作者本人。本站僅提供信息存儲(chǔ)空間服務(wù),不擁有所有權(quán),不承擔(dān)相關(guān)法律責(zé)任。如發(fā)現(xiàn)本站有涉嫌抄襲侵權(quán)/違法違規(guī)的內(nèi)容, 請(qǐng)發(fā)送郵件至 舉報(bào),一經(jīng)查實(shí),本站將立刻刪除。