Golang 汇编入门知识总结
作者:ivansli,騰訊 IEG 運營開發工程師
在深入學習 Golang 的 runtime 和標準庫實現的時候發現,如果對 Golang 匯編沒有一定了解的話,很難深入了解其底層實現機制。在這里整理總結了一份基礎的 Golang 匯編入門知識,通過學習之后能夠對其底層實現有一定的認識。
0. 為什么寫本文
平時業務中一直使用 PHP 編寫代碼,但是一直對 Golang 比較感興趣,閑暇、周末之余會看一些 Go 底層源碼。
近日在分析 go 的某些特性底層功能實現時發現:有些又跟 runtime 運行時有關,而要掌握這一部分的話,有一道坎是繞不過去的,那就是 Go 匯編。索性就查閱了很多大佬們寫的資料,在閱讀之余整理總結了一下,并在這里分享給大家。
本文使用 Go 版本為 go1.14.1
1. 為什么需要匯編
眾所周知,在計算機的世界里,只有 2 種類型。那就是:0 和 1。
計算機工作是由一系列的機器指令進行驅動的,這些指令又是一組二進制數字,其對應計算機的高低電平。而這些機器指令的集合就是機器語言,這些機器語言在最底層是與硬件一一對應的。
顯而易見,這樣的機器指令有一個致命的缺點:可閱讀性太差(恐怕也只有天才和瘋子才有能力把控得了)。
為了解決可讀性的問題以及代碼編輯的需求,于是就誕生了最接近機器的語言:匯編語言(在我看來,匯編語言更像一種助記符,這些人們容易記住的每一條助記符都映射著一條不容易記住的由 0、1 組成的機器指令。你覺得像不像域名與 IP 地址的關系呢?)。
1.1 程序的編譯過程
以 C 語言為例來說,從 hello.c 的源碼文件到 hello 可執行文件,經過編譯器處理,大致分為幾個階段:
編譯器在不同的階段會做不同的事情,但是有一步是可以確定的,那就是:源碼會被編譯成匯編,最后才是二進制。
2. 程序與進程
源碼經過編譯之后,得到一個二進制的可執行文件。文件這兩個字也就表明,目前得到的這個文件跟其他文件對比,除了是具有一定的格式(Linux 中是 ELF 格式,即:可運行可鏈接。executable linkable formate)的二進制組成,并沒什么區別。
在 Linux 中文件類型大致分為 7 種:
b:?塊設備文件 c:字符設備文件 d:目錄 -:普通文件 l:鏈接 s:socket p:管道通過上面可以看到,可執行文件 main 與源碼文件 main.go,都是同一種類型,屬于普通文件。(當然了,在 Unix 中有一句很經典的話:一切皆文件)。
那么,問題來了:
什么是程序?
什么是進程?
2.1 程序
維基百科告訴我們:程序是指一組指示計算機或其他具有消息處理能力設備每一步動作的指令,通常用某種程序設計語言編寫,運行于某種目標體系結構上。
從某個層面來看,可以把程序分為靜態程序、動態程序:靜態程序:單純的指具有一定格式的可執行二進制文件。動態程序:則是靜態可執行程序文件被加載到內存之后的一種運行時模型(又稱為進程)。
2.2 進程
首先,要知道的是,進程是分配系統資源的最小單位,線程(帶有時間片的函數)是系統調度的最小單位。進程包含線程,線程所屬于進程。
創建進程一般使用 fork 方法(通常會有個拉起程序,先 fork 自身生成一個子進程。然后,在該子進程中通過 exec 函數把對應程序加載進來,進而啟動目標進程。當然,實際上要復雜得多),而創建線程則是使用 pthread 線程庫。
以 32 位 Linux 操作系統為例,進程經典的虛擬內存結構模型如下圖所示:
其中,有兩處結構是靜態程序所不具有的,那就是運行時堆(heap)與運行時棧(stack)。
運行時堆從低地址向高地址增長,申請的內存空間需要程序員自己或者由 GC 釋放。運行時棧從高地址向低地址增長,內存空間在當前棧楨調用結束之后自動釋放(并不是清除其所占用內存中數據,而是通過棧頂指針 SP 的移動,來標識哪些內存是正在使用的)。
3. Go 匯編
對于 Go 編譯器而言,其輸出的結果是一種抽象可移植的匯編代碼,這種匯編(Go 的匯編是基于 Plan9 的匯編)并不對應某種真實的硬件架構。Go 的匯編器會使用這種偽匯編,再為目標硬件生成具體的機器指令。
偽匯編這一個額外層可以帶來很多好處,最主要的一點是方便將 Go 移植到新的架構上。
相關的信息可以參考 Rob Pike 的 The Design of the Go Assembler。
要了解 Go 的匯編器最重要的是要知道 Go 的匯編器不是對底層機器的直接表示,即 Go 的匯編器沒有直接使用目標機器的匯編指令。Go 匯編器所用的指令,一部分與目標機器的指令一一對應,而另外一部分則不是。這是因為編譯器套件不需要匯編器直接參與常規的編譯過程。
相反,編譯器使用了一種半抽象的指令集,并且部分指令是在代碼生成后才被選擇的。匯編器基于這種半抽象的形式工作,所以雖然你看到的是一條 MOV 指令,但是工具鏈針對對這條指令實際生成可能完全不是一個移動指令,也許會是清除或者加載。也有可能精確的對應目標平臺上同名的指令。概括來說,特定于機器的指令會以他們的本尊出現, 然而對于一些通用的操作,如內存的移動以及子程序的調用以及返回通常都做了抽象。細節因架構不同而不一樣,我們對這樣的不精確性表示歉意,情況并不明確。
匯編器程序的工作是對這樣半抽象指令集進行解析并將其轉變為可以輸入到鏈接器的指令。
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模式,被調用函數的入參參數、返回值都由調用者維護、準備。因此,當需要調用一個函數時,需要先將這些工作準備好,才調用下一個函數,另外這些都需要進行內存對齊,對齊的大小是 sizeof(uintptr)。
3.1 幾個概念
在深入了解 Go 匯編之前,需要知道的幾個概念:
棧:進程、線程、goroutine 都有自己的調用棧,先進后出(FILO)
棧幀:可以理解是函數調用時,在棧上為函數所分配的內存區域
調用者:caller,比如:A 函數調用了 B 函數,那么 A 就是調用者
被調者:callee,比如:A 函數調用了 B 函數,那么 B 就是被調者
3.2 Go 的核心寄存器
go 匯編中有 4 個核心的偽寄存器,這 4 個寄存器是編譯器用來維護上下文、特殊標識等作用的:
| SB(Static base pointer) | global symbols |
| FP(Frame pointer) | arguments and locals |
| PC(Program counter) | jumps and branches |
| SP(Stack pointer) | top of stack |
FP: 使用如 symbol+offset(FP)的方式,引用 callee 函數的入參參數。例如 arg0+0(FP),arg1+8(FP),使用 FP 必須加 symbol ,否則無法通過編譯(從匯編層面來看,symbol 沒有什么用,加 symbol 主要是為了提升代碼可讀性)。另外,需要注意的是:往往在編寫 go 匯編代碼時,要站在 callee 的角度來看(FP),在 callee 看來,(FP)指向的是 caller 調用 callee 時傳遞的第一個參數的位置。假如當前的 callee 函數是 add,在 add 的代碼中引用 FP,該 FP 指向的位置不在 callee 的 stack frame 之內。而是在 caller 的 stack frame 上,指向調用 add 函數時傳遞的第一個參數的位置,經常在 callee 中用symbol+offset(FP)來獲取入參的參數值。
SB: 全局靜態基指針,一般用在聲明函數、全局變量中。
SP: 該寄存器也是最具有迷惑性的寄存器,因為會有偽 SP 寄存器和硬件 SP 寄存器之分。plan9 的這個偽 SP 寄存器指向當前棧幀第一個局部變量的結束位置(為什么說是結束位置,可以看下面寄存器內存布局圖),使用形如 symbol+offset(SP) 的方式,引用函數的局部變量。offset 的合法取值是 [-framesize, 0),注意是個左閉右開的區間。假如局部變量都是 8 字節,那么第一個局部變量就可以用 localvar0-8(SP) 來表示。與硬件寄存器 SP 是兩個不同的東西,在棧幀 size 為 0 的情況下,偽寄存器 SP 和硬件寄存器 SP 指向同一位置。手寫匯編代碼時,如果是 symbol+offset(SP)形式,則表示偽寄存器 SP。如果是 offset(SP)則表示硬件寄存器 SP。務必注意:對于編譯輸出(go tool compile -S / go tool objdump)的代碼來講,所有的 SP 都是硬件 SP 寄存器,無論是否帶 symbol(這一點非常具有迷惑性,需要慢慢理解。往往在分析編譯輸出的匯編時,看到的就是硬件 SP 寄存器)。
PC: 實際上就是在體系結構的知識中常見的 pc 寄存器,在 x86 平臺下對應 ip 寄存器,amd64 上則是 rip。除了個別跳轉之外,手寫 plan9 匯編代碼時,很少用到 PC 寄存器。
通過上面的講解,想必已經對 4 個核心寄存器的區別有了一定的認識(或者是更加的迷惑、一頭霧水)。那么,需要留意的是:如果是在分析編譯輸出的匯編代碼時,要重點看 SP、SB 寄存器(FP 寄存器在這里是看不到的)。如果是,在手寫匯編代碼,那么要重點看 FP、SP 寄存器。
3.2.1 偽寄存器的內存模型
下圖描述了棧楨與各個寄存器的內存關系模型,值得注意的是要站在 callee 的角度來看。
有一點需要注意的是,return addr 也是在 caller 的棧上的,不過往棧上插 return addr 的過程是由 CALL 指令完成的(在分析匯編時,是看不到關于 addr 相關空間信息的。在分配棧空間時,addr 所占用空間大小不包含在棧幀大小內)。
在 AMD64 環境,偽 PC 寄存器其實是 IP 指令計數器寄存器的別名。偽 FP 寄存器對應的是 caller 函數的幀指針,一般用來訪問 callee 函數的入參參數和返回值。偽 SP 棧指針對應的是當前 callee 函數棧幀的底部(不包括參數和返回值部分),一般用于定位局部變量。偽 SP 是一個比較特殊的寄存器,因為還存在一個同名的 SP 真寄存器,真 SP 寄存器對應的是棧的頂部。
在編寫 Go 匯編時,當需要區分偽寄存器和真寄存器的時候只需要記住一點:偽寄存器一般需要一個標識符和偏移量為前綴,如果沒有標識符前綴則是真寄存器。比如(SP)、+8(SP)沒有標識符前綴為真 SP 寄存器,而 a(SP)、b+8(SP)有標識符為前綴表示偽寄存器。
3.2.2 幾點說明
我們這里對容易混淆的幾點簡單進行說明:
偽 SP 和硬件 SP 不是一回事,在手寫匯編代碼時,偽 SP 和硬件 SP 的區分方法是看該 SP 前是否有 symbol。如果有 symbol,那么即為偽寄存器,如果沒有,那么說明是硬件 SP 寄存器。
偽 SP 和 FP 的相對位置是會變的,所以不應該嘗試用偽 SP 寄存器去找那些用 FP+offset 來引用的值,例如函數的入參和返回值。
官方文檔中說的偽 SP 指向 stack 的 top,可能是有問題的。其指向的局部變量位置實際上是整個棧的棧底(除 caller BP 之外),所以說 bottom 更合適一些。
在 go tool objdump/go tool compile -S 輸出的代碼中,是沒有偽 SP 和 FP 寄存器的,我們上面說的區分偽 SP 和硬件 SP 寄存器的方法,對于上述兩個命令的輸出結果是沒法使用的。在編譯和反匯編的結果中,只有真實的 SP 寄存器。
3.2.3 IA64 和 plan9 的對應關系
在 plan9 匯編里還可以直接使用的 amd64 的通用寄存器,應用代碼層面會用到的通用寄存器主要是: rax, rbx, rcx, rdx, rdi, rsi, r8~r15 這些寄存器,雖然 rbp 和 rsp 也可以用,不過 bp 和 sp 會被用來管理棧頂和棧底,最好不要拿來進行運算。
plan9 中使用寄存器不需要帶 r 或 e 的前綴,例如 rax,只要寫 AX 即可: MOVQ $101, AX = mov rax, 101
下面是通用通用寄存器的名字在 IA64 和 plan9 中的對應關系:
3.3 常用操作指令
下面列出了常用的幾個匯編指令(指令后綴Q 說明是 64 位上的匯編指令)
| MOVQ | 傳送 | 數據傳送 | MOVQ 48, AX // 把 48 傳送到 AX |
| LEAQ | 傳送 | 地址傳送 | LEAQ AX, BX // 把 AX 有效地址傳送到 BX |
| PUSHQ | 傳送 | 棧壓入 | PUSHQ AX // 將 AX 內容送入棧頂位置 |
| POPQ | 傳送 | 棧彈出 | POPQ AX // 彈出棧頂數據后修改棧頂指針 |
| ADDQ | 運算 | 相加并賦值 | ADDQ BX, AX // 等價于 AX+=BX |
| SUBQ | 運算 | 相減并賦值 | SUBQ BX, AX // 等價于 AX-=BX |
| CMPQ | 運算 | 比較大小 | CMPQ SI CX // 比較 SI 和 CX 的大小 |
| CALL | 轉移 | 調用函數 | CALL runtime.printnl(SB) // 發起調用 |
| JMP | 轉移 | 無條件轉移指令 | JMP 0x0185 //無條件轉至 0x0185 地址處 |
| JLS | 轉移 | 條件轉移指令 | JLS 0x0185 //左邊小于右邊,則跳到 0x0185 |
4. 匯編分析
說了那么多,it is code show time。
4.1 如何輸出 Go 匯編
對于寫好的 go 源碼,生成對應的 Go 匯編,大概有下面幾種
方法 1 先使用 go build -gcflags "-N -l" main.go 生成對應的可執行二進制文件 再使用 go tool objdump -s "main\." main 反編譯獲取對應的匯編
反編譯時"main\." 表示只輸出 main 包中相關的匯編"main\.main" 則表示只輸出 main 包中 main 方法相關的匯編
方法 2 使用 go tool compile -S -N -l main.go 這種方式直接輸出匯編
方法 3 使用go build -gcflags="-N -l -S" main.go 直接輸出匯編
注意:在使用這些命令時,加上對應的 flag,否則某些邏輯會被編譯器優化掉,而看不到對應完整的匯編代碼
-l 禁止內聯 -N 編譯時,禁止優化 -S 輸出匯編代碼
4.2 Go 匯編示例
go 示例代碼
package?mainfunc?add(a,?b?int)?int{sum?:=?0?//?不設置該局部變量sum,add棧空間大小會是0sum?=?a+breturn?sum }func?main(){println(add(1,2)) }編譯 go 源代碼,輸出匯編
go?tool?compile?-N?-l?-S?main.go截取主要匯編如下:
"".add STEXT nosplit size=60 args=0x18 locals=0x100x0000 00000 (main.go:3) TEXT "".add(SB), NOSPLIT, $16-240x0000 00000 (main.go:3) SUBQ $16, SP ;;生成add棧空間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賦為00x001f 00031 (main.go:5) MOVQ "".a+24(SP), AX ;;取參數a0x0024 00036 (main.go:5) ADDQ "".b+32(SP), AX ;;等價于AX=a+b0x0029 00041 (main.go:5) MOVQ AX, "".sum(SP) ;;賦值局部變量sum0x002d 00045 (main.go:6) MOVQ AX, "".~r2+40(SP) ;;設置返回值0x0032 00050 (main.go:6) MOVQ 8(SP), BP0x0037 00055 (main.go:6) ADDQ $16, SP ;;清除add棧空間0x003b 00059 (main.go:6) RET......"".main STEXT size=107 args=0x0 locals=0x280x0000 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入參:10x0025 00037 (main.go:10) MOVQ $2, 8(SP) ;;add入參:20x002e 00046 (main.go:10) CALL "".add(SB) ;;調用add函數0x0033 00051 (main.go:10) MOVQ 16(SP), AX0x0038 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), AX0x0047 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), BP0x005f 00095 (main.go:11) ADDQ $40, SP ;;清除main棧空間0x0063 00099 (main.go:11) RET......這里列舉了一個簡單的 int 類型加法示例,實際開發中會遇到各種參數類型,要復雜的多,這里只是拋磚引玉 :)
4.3 Go 匯編解析
針對 4.2 輸出匯編,對重要核心代碼進行分析。
4.3.1 add 函數匯編解析
TEXT "".add(SB), NOSPLIT|ABIInternal, $16-24
TEXT "".add TEXT 指令聲明了 "".add 是 .text 代碼段的一部分,并表明跟在這個聲明后的是函數的函數體。在鏈接期,""這個空字符會被替換為當前的包名: 也就是說,"".add 在鏈接到二進制文件后會變成 main.add
(SB) SB 是一個虛擬的偽寄存器,保存靜態基地址(static-base) 指針,即我們程序地址空間的開始地址。"".add(SB) 表明我們的符號位于某個固定的相對地址空間起始處的偏移位置 (最終是由鏈接器計算得到的)。換句話來講,它有一個直接的絕對地址: 是一個全局的函數符號。
NOSPLIT: 向編譯器表明不應該插入 stack-split 的用來檢查棧需要擴張的前導指令。在我們 add 函數的這種情況下,編譯器自己幫我們插入了這個標記: 它足夠聰明地意識到,由于 add 沒有任何局部變量且沒有它自己的棧幀,所以一定不會超出當前的棧。不然,每次調用函數時,在這里執行棧檢查就是完全浪費 CPU 時間了。
$0-16
24 指定了調用方傳入的參數+返回值大小(24 字節=入參 a、b 大小8字節*2+返回值8字節)通常來講,幀大小后一般都跟隨著一個參數大小,用減號分隔。(這不是一個減法操作,只是一種特殊的語法) 幀大小 $24-8 意味著這個函數有 24 個字節的幀以及 8 個字節的參數,位于調用者的幀上。如果 NOSPLIT 沒有在 TEXT 中指定,則必須提供參數大小。對于 Go 原型的匯編函數,go vet 會檢查參數大小是否正確。
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 為棧頂指針,該語句等價于 SP-=16(由于棧空間是向下增長的,所以開辟棧空間時為減操作),表示生成 16 字節大小的棧空間。
MOVQ $0, "".~r2+40(SP)此時的 SP 為 add 函數棧的棧頂指針,40(SP)的位置則是 add 返回值的位置,該位置位于 main 函數棧空間內。該語句設置返回值類型的 0 值,即初始化返回值,防止得到臟數據(返回值類型為 int,int 的 0 值為 0)。
MOVQ "".a+24(SP), AX從 main 函數棧空間獲取入參 a 的值,存到寄存器 AX
ADDQ "".b+32(SP), AX從 main 函數棧空間獲取入參 b 的值,與寄存器 AX 中存儲的 a 值相加,結果存到 AX。相當于 AX=a+b
MOVQ AX, "".~r2+40(SP)把 a+b 的結果放到 main 函數棧中, add(a+b)返回值所在的位置
ADDQ $16, SP歸還 add 函數占用的棧空間
4.3.2 函數棧楨結構模型
根據 4.2 對應匯編繪制的函數棧楨結構模型
還記得前面提到的,Go 匯編使用的是caller-save模式,被調用函數的參數、返回值、棧位置都需要由調用者維護、準備嗎?
在函數棧楨結構中可以看到,add()函數的入參以及返回值都由調用者 main()函數維護。也正是因為如此,GO 有了其他語言不具有的,支持多個返回值的特性。
4.4 Go 匯編語法
這里重點講一下函數聲明、變量聲明。
4.4.1 函數聲明
來看一個典型的 Go 匯編函數定義
//?func?add(a,?b?int)?int //?該add函數聲明定義在同一個?package?name?下的任意?.go文件中 //?只有函數頭,沒有實現//?add函數的Go匯編實現 //?pkgname?默認是??"" TEXT?pkgname·add(SB),?NOSPLIT,?$16-24MOVQ?a+0(FP),?AXADDQ?b+8(FP),?AXMOVQ?AX,?ret+16(FP)RETGo 匯編實現為什么是 TEXT 開頭?仔細觀察上面的進程內存布局圖就會發現,我們的代碼在是存儲在.text 段中的,這里也就是一種約定俗成的起名方式。實際上在 plan9 中 TEXT 是一個指令,用來定義一個函數。
定義中的 pkgname 是可以省略的,(非想寫也可以寫上,不過寫上 pkgname 的話,在重命名 package 之后還需要改代碼,默認為"") 編譯器會在鏈接期自動加上所屬的包名稱。
中點 · 比較特殊,是一個 unicode 的中點,該點在 mac 下的輸入方法是 option+shift+9。在程序被鏈接之后,所有的中點·都會被替換為句號.,比如你的方法是runtime·main,在編譯之后的程序里的符號則是runtime.main。
簡單總結一下, Go 匯編實現函數聲明,格式為:
?靜態基地址(static-base)?指針||?????????add函數入參+返回值總大小|???????????????| TEXT?pkgname·add(SB),NOSPLIT,$16-24|??????|????????????????| 函數所屬包名??函數名??????????add函數棧幀大小函數棧幀大小:局部變量+可能需要的額外調用函數的參數空間的總大小,不包括調用其它函數時的 ret address 的大小。
(SB): SB 是一個虛擬寄存器,保存了靜態基地址(static-base) 指針,即我們程序地址空間的開始地址。"".add(SB) 表明我們的符號位于某個固定的相對地址空間起始處的偏移位置 (最終是由鏈接器計算得到的)。換句話來講,它有一個直接的絕對地址: 是一個全局的函數符號。
NOSPLIT: 向編譯器表明,不應該插入 stack-split 的用來檢查棧需要擴張的前導指令。在我們 add 函數的這種情況下,編譯器自己幫我們插入了這個標記: 它足夠聰明地意識到,add 不會超出當前的棧,因此沒必要調用函數時在這里執行棧檢查。
4.4.2 變量聲明
匯編里的全局變量,一般是存儲在.rodata或者.data段中。對應到 Go 代碼,就是已初始化過的全局的 const、var 變量/常量。
使用 DATA 結合 GLOBL 來定義一個變量。
DATA 的用法為:
DATA?symbol+offset(SB)/width,?value大多數參數都是字面意思,不過這個 offset 需要注意:其含義是該值相對于符號 symbol 的偏移,而不是相對于全局某個地址的偏移。
GLOBL 匯編指令用于定義名為 symbol 的全局變量,變量對應的內存寬度為 width,內存寬度部分必須用常量初始化。
GLOBL?·symbol(SB),?width下面是定義了多個變量的例子:
DATA?·age+0(SB)/4,?$8??;;?數值8為?4字節 GLOBL?·age(SB),?RODATA,?$4DATA?·pi+0(SB)/8,?$3.1415926?;;?數值3.1415926為float64,?8字節 GLOBL?·pi(SB),?RODATA,?$8DATA?·year+0(SB)/4,?$2020?;;?數值2020為?4字節 GLOBL?·year(SB),?RODATA,?$4;;?變量hello?使用2個DATA來定義 DATA?·hello+0(SB)/8,?$"hello?my"?;;?`hello?my`?共8個字節 DATA?·hello+8(SB)/8,?$"???world"?;;?`???world`?共8個字節(3個空格) GLOBL?·hello(SB),?RODATA,?$16?;;?`hello?my???world`??共16個字節DATA?·hello<>+0(SB)/8,?$"hello?my"?;;?`hello?my`?共8個字節 DATA?·hello<>+8(SB)/8,?$"???world"?;;?`???world`?共8個字節(3個空格) GLOBL?·hello<>(SB),?RODATA,?$16?;;?`hello?my???world`??共16個字節大部分都比較好理解,不過這里引入了新的標記<>,這個跟在符號名之后,表示該全局變量只在當前文件中生效,類似于 C 語言中的 static。如果在另外文件中引用該變量的話,會報 relocation target not found 的錯誤。
5. 手寫匯編實現功能
在 Go 源碼中會看到一些匯編寫的代碼,這些代碼跟其他 go 代碼一起組成了整個 go 的底層功能實現。下面,我們通過一個簡單的 Go 匯編代碼示例來實現兩數相加功能。
5.1 使用 Go 匯編實現 add 函數
Go 代碼
package?mainfunc?add(a,?b?int64)?int64func?main(){println(add(2,3)) }Go 源碼中 add()函數只有函數簽名,沒有具體的實現(使用 GO 匯編實現)
使用 Go 匯編實現的 add()函數
TEXT?·add(SB),?$0-24?;;?add棧空間為0,入參+返回值大小=24字節MOVQ?x+0(FP), AX ;;?從main中取參數:2ADDQ?y+8(FP), AX ;;?從main中取參數:3MOVQ?AX,?ret+16(FP)?;;?保存結果到返回值RET把 Go 源碼與 Go 匯編編譯到一起(我這里,這兩個文件在同一個目錄)
go?build?-gcflags?"-N?-l"?.我這里目錄為 demo1,所以得到可執行程序 demo1,運行得到結果:5
5.2 反編譯可執行程序
對 5.1 中得到的可執行程序 demo1 使用 objdump 進行反編譯,獲取匯編代碼
go?tool?objdump?-s?"main\."?demo1得到匯編
...... TEXT?main.main(SB)?/root/go/src/demo1/main.gomain.go:5???0x4581d0?????64488b0c25f8ffffff??????MOVQ?FS:0xfffffff8,?CXmain.go:5???0x4581d9?????483b6110????????????????CMPQ?0x10(CX),?SPmain.go:5???0x4581dd?????7655????????????????????JBE?0x458234main.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),?BPmain.go:6???0x4581ed?????48c7042402000000????????MOVQ?$0x2,?0(SP)?;;參數值?2main.go:6???0x4581f5?????48c744240803000000??????MOVQ?$0x3,?0x8(SP)?;;參數值?3main.go:6???0x4581fe?????e83d000000??????????????CALL?main.add(SB);;call?addmain.go:6???0x458203?????488b442410??????????????MOVQ?0x10(SP),?AXmain.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),?AXmain.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),?BPmain.go:7???0x45822f?????4883c428????????????????ADDQ?$0x28,?SPmain.go:7???0x458233?????c3??????????????????????RETmain.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.sadd_amd64.s:2???0x458240????488b442408????MOVQ?0x8(SP),?AX?;;?獲取第一個參數add_amd64.s:3???0x458245????4803442410????ADDQ?0x10(SP),?AX?;;參數a+參數badd_amd64.s:5???0x45824a????4889442418????MOVQ?AX,?0x18(SP)?;;保存計算結果add_amd64.s:7???0x45824f????c3????????????RET通過上面操作,可知:
(FP)偽寄存器,只有在編寫 Go 匯編代碼時使用。FP 偽寄存器指向 caller 傳遞給 callee 的第一個參數
使用 go tool compile / go tool objdump 得到的匯編中看不到(FP)寄存器的蹤影
6. Go 調試工具
這里推薦 2 個 Go 代碼調試工具。
6.1 gdb 調試 Go 代碼
測試代碼
package?maintype?Ier?interface{add(a,?b?int)?intsub(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 調試
> gdb mainGNU gdb (GDB) Red Hat Enterprise Linux 7.6.1-80.el7 Copyright (C) 2013 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later http://gnu.org/licenses/gpl.html This 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+b 16 } 17 18 func (*data) sub(a, b int) int{ 19 return a-b 20 } 21 22 23 func main(){ (gdb) list 24 var t Ier = &data{3,4} 25 26 println(t.add(1,2)) 27 println(t.sub(3,2)) 28 } 29 (gdb) b 26 // 在源碼26行處設置斷點 Breakpoint 1 at 0x45827c: file /root/go/src/interface/main.go, line 26. (gdb) r Starting program: /root/go/src/interface/mainBreakpoint 1, main.main () at /root/go/src/interface/main.go:26 26 println(t.add(1,2)) (gdb) info locals // 顯示變量 t = {tab = 0x487020 <data,main.Ier>, data = 0xc000096000} (gdb) ptype t // 打印t的結構 type = struct runtime.iface {runtime.itab *tab;void *data; } (gdb) p *t.tab.inter // 打印t.tab.inter指針指向的數據 $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 "\002\003\004\005\006\a\b\t\n\f\r\016\017\020\022\025\026\030\033\034\036\037\"&(,-5<BUXx\216\231\330\335\377", 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,%rcx0x0000000000458219 <+9>: cmp 0x10(%rcx),%rsp0x000000000045821d <+13>: jbe 0x458324 <main.main+276>0x0000000000458223 <+19>: sub $0x50,%rsp0x0000000000458227 <+23>: mov %rbp,0x48(%rsp)0x000000000045822c <+28>: lea 0x48(%rsp),%rbp0x0000000000458231 <+33>: lea 0x10dc8(%rip),%rax # 0x4690000x0000000000458238 <+40>: mov %rax,(%rsp)0x000000000045823c <+44>: callq 0x40a5c0 <runtime.newobject>常用的 gdb 調試命令
run
continue
break
backtrace 與 frame
info break、locals
list 命令
print 和 ptype 命令
disass
除了 gdb,另外推薦一款 gdb 的增強版調試工具 cgdb
https://cgdb.github.io/
效果如下圖所示,分兩個窗口:上面顯示源代碼,下面是具體的命令行調試界面(跟 gdb 一樣):
6.2 delve 調試代碼
delve 項目地址
https://github.com/go-delve/delve
帶圖形化界面的 dlv 項目地址
https://github.com/aarzilli/gdlv
dlv 的安裝使用,這里不再做過多講解,感興趣的可以嘗試一下。
gdb 作為調試工具自是不用多說,比較老牌、強大,可以支持多種語言。
delve 則是使用 go 語言開發的,用來調試 go 的工具,功能也是十分強大,打印結果可以顯示 gdb 支持不了的東西,這里不再做過多講解,有興趣的可以查閱相關資料。
7. 總結
對于 Go 匯編基礎大致需要熟悉下面幾個方面:
通過上面的例子相信已經讓你對 Go 的匯編有了一定的理解。當然,對于大部分業務開發人員來說,只要看的懂即可。如果想進一步的了解,可以閱讀相關的資料或者書籍。
最后想說的是:鑒于個人能力有限,在閱讀過程中你可能會發現存在的一些問題或者缺陷,歡迎各位大佬指正。如果感興趣的話,也可以一起私下交流。
8. 參考資料
在整理的過程中,部分參考、引用下面鏈接地址內容。有一些寫的還是不錯的,感興趣的同學可以閱讀。
[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
總結
以上是生活随笔為你收集整理的Golang 汇编入门知识总结的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 前端性能分析工具利器
- 下一篇: 腾讯看点投放系统介绍:推荐系统的进化伙伴