plan9 assembly 徹底解析

這篇文章國內研究 Go 底層的人應該都看過,準備去學習 runtime 的你也應該讀一讀。html

衆所周知,Go 使用了 Unix 老古董(誤 們發明的 plan9 彙編。就算你對 x86 彙編有所瞭解,在 plan9 裏仍是有些許區別。說不定你在看代碼的時候,偶然發現代碼裏的 SP 看起來是 SP,但它實際上不是 SP 的時候就抓狂了哈哈哈。linux

本文將對 plan9 彙編進行全面的介紹,同時解答你在接觸 plan9 彙編時可能遇到的大部分問題。git

本文所使用的平臺是 linux amd64,由於不一樣的平臺指令集和寄存器都不同,因此沒有辦法共同探討。這也是由彙編自己的性質決定的。github

基本指令

棧調整

intel 或 AT&T 彙編提供了 push 和 pop 指令族,plan9 中沒有 push 和 pop,plan9 中雖然有 push 和 pop 指令,但通常生成的代碼中是沒有的,咱們看到的棧的調整大可能是經過對硬件 SP 寄存器進行運算來實現的,例如:golang

SUBQ $0x18, SP // 對 SP 作減法,爲函數分配函數棧幀
...               // 省略無用代碼
ADDQ $0x18, SP // 對 SP 作加法,清除函數棧幀

通用的指令和 X64 平臺差很少,下面分節詳述。shell

數據搬運

常數在 plan9 彙編用 $num 表示,能夠爲負數,默認狀況下爲十進制。能夠用 $0x123 的形式來表示十六進制數。數組

MOVB $1, DI      // 1 byte
MOVW $0x10, BX   // 2 bytes
MOVD $1, DX      // 4 bytes
MOVQ $-10, AX     // 8 bytes

能夠看到,搬運的長度是由 MOV 的後綴決定的,這一點與 intel 彙編稍有不一樣,看看相似的 X64 彙編:數據結構

mov rax, 0x1   // 8 bytes
mov eax, 0x100 // 4 bytes
mov ax, 0x22   // 2 bytes
mov ah, 0x33   // 1 byte
mov al, 0x44   // 1 byte

plan9 的彙編的操做數的方向是和 intel 彙編相反的,與 AT&T 相似。架構

MOVQ $0x10, AX ===== mov rax, 0x10
       |    |------------|      |
       |------------------------|

不過凡事總有例外,若是想了解這種意外,能夠參見參考資料中的 [1]。app

常見計算指令

ADDQ  AX, BX   // BX += AX
SUBQ  AX, BX   // BX -= AX
IMULQ AX, BX   // BX *= AX

相似數據搬運指令,一樣能夠經過修改指令的後綴來對應不一樣長度的操做數。例如 ADDQ/ADDW/ADDL/ADDB。

條件跳轉/無條件跳轉

// 無條件跳轉
JMP addr   // 跳轉到地址,地址可爲代碼中的地址,不過實際上手寫不會出現這種東西
JMP label  // 跳轉到標籤,能夠跳轉到同一函數內的標籤位置
JMP 2(PC)  // 以當前指令爲基礎,向前/後跳轉 x 行
JMP -2(PC) // 同上

// 有條件跳轉
JZ target // 若是 zero flag 被 set 過,則跳轉

指令集

能夠參考源代碼的 arch 部分。

額外提一句,Go 1.10 添加了大量的 SIMD 指令支持,因此在該版本以上的話,不像以前寫那樣痛苦了,也就是不用人肉填 byte 了。

寄存器

通用寄存器

amd64 的通用寄存器:

(lldb) reg read
General Purpose Registers:
       rax = 0x0000000000000005
       rbx = 0x000000c420088000
       rcx = 0x0000000000000000
       rdx = 0x0000000000000000
       rdi = 0x000000c420088008
       rsi = 0x0000000000000000
       rbp = 0x000000c420047f78
       rsp = 0x000000c420047ed8
        r8 = 0x0000000000000004
        r9 = 0x0000000000000000
       r10 = 0x000000c420020001
       r11 = 0x0000000000000202
       r12 = 0x0000000000000000
       r13 = 0x00000000000000f1
       r14 = 0x0000000000000011
       r15 = 0x0000000000000001
       rip = 0x000000000108ef85  int`main.main + 213 at int.go:19
    rflags = 0x0000000000000212
        cs = 0x000000000000002b
        fs = 0x0000000000000000
        gs = 0x0000000000000000

在 plan9 彙編裏都是可使用的,應用代碼層面會用到的通用寄存器主要是: rax, rbx, rcx, rdx, rdi, rsi, r8~r15 這 14 個寄存器,雖然 rbp 和 rsp 也能夠用,不過 bp 和 sp 會被用來管理棧頂和棧底,最好不要拿來進行運算。

plan9 中使用寄存器不須要帶 r 或 e 的前綴,例如 rax,只要寫 AX 便可:

MOVQ $101, AX = mov rax, 101

下面是通用通用寄存器的名字在 X64 和 plan9 中的對應關係:

X64 rax rbx rcx rdx rdi rsi rbp rsp r8 r9 r10 r11 r12 r13 r14 rip
Plan9 AX BX CX DX DI SI BP SP R8 R9 R10 R11 R12 R13 R14 PC

僞寄存器

Go 的彙編還引入了 4 個僞寄存器,援引官方文檔的描述:

  • FP: Frame pointer: arguments and locals.
  • PC: Program counter: jumps and branches.
  • SB: Static base pointer: global symbols.
  • SP: Stack pointer: top of stack.

官方的描述稍微有一些問題,咱們對這些說明進行一點擴充:

  • FP: 使用形如 symbol+offset(FP) 的方式,引用函數的輸入參數。例如 arg0+0(FP)arg1+8(FP),使用 FP 不加 symbol 時,沒法經過編譯,在彙編層面來說,symbol 並無什麼用,加 symbol 主要是爲了提高代碼可讀性。另外,官方文檔雖然將僞寄存器 FP 稱之爲 frame pointer,實際上它根本不是 frame pointer,按照傳統的 x86 的習慣來說,frame pointer 是指向整個 stack frame 底部的 BP 寄存器。假如當前的 callee 函數是 add,在 add 的代碼中引用 FP,該 FP 指向的位置不在 callee 的 stack frame 以內,而是在 caller 的 stack frame 上。具體可參見以後的 棧結構 一章。
  • PC: 實際上就是在體系結構的知識中常見的 pc 寄存器,在 x86 平臺下對應 ip 寄存器,amd64 上則是 rip。除了個別跳轉以外,手寫 plan9 代碼與 PC 寄存器打交道的狀況較少。
  • SB: 全局靜態基指針,通常用來聲明函數或全局變量,在以後的函數知識和示例部分會看到具體用法。
  • 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。

咱們這裏對容易混淆的幾點簡單進行說明:

  1. 僞 SP 和硬件 SP 不是一回事,在手寫代碼時,僞 SP 和硬件 SP 的區分方法是看該 SP 前是否有 symbol。若是有 symbol,那麼即爲僞寄存器,若是沒有,那麼說明是硬件 SP 寄存器。
  2. SP 和 FP 的相對位置是會變的,因此不該該嘗試用僞 SP 寄存器去找那些用 FP + offset 來引用的值,例如函數的入參和返回值。
  3. 官方文檔中說的僞 SP 指向 stack 的 top,是有問題的。其指向的局部變量位置其實是整個棧的棧底(除 caller BP 以外),因此說 bottom 更合適一些。
  4. 在 go tool objdump/go tool compile -S 輸出的代碼中,是沒有僞 SP 和 FP 寄存器的,咱們上面說的區分僞 SP 和硬件 SP 寄存器的方法,對於上述兩個命令的輸出結果是無法使用的。在編譯和反彙編的結果中,只有真實的 SP 寄存器。
  5. FP 和 Go 的官方源代碼裏的 framepointer 不是一回事,源代碼裏的 framepointer 指的是 caller BP 寄存器的值,在這裏和 caller 的僞 SP 是值是相等的。

以上說明看不懂也不要緊,在熟悉了函數的棧結構以後再反覆回來查看應該就能夠明白了。我的意見,這些是 Go 官方挖的坑。。

變量聲明

在彙編裏所謂的變量,通常是存儲在 .rodata 或者 .data 段中的只讀值。對應到應用層的話,就是已初始化過的全局的 const、var、static 變量/常量。

使用 DATA 結合 GLOBL 來定義一個變量。DATA 的用法爲:

DATA    symbol+offset(SB)/width, value

大多數參數都是字面意思,不過這個 offset 須要稍微注意。其含義是該值相對於符號 symbol 的偏移,而不是相對於全局某個地址的偏移。

使用 GLOBL 指令將變量聲明爲 global,額外接收兩個參數,一個是 flag,另外一個是變量的總大小。

GLOBL divtab(SB), RODATA, $64

GLOBL 必須跟在 DATA 指令以後,下面是一個定義了多個 readonly 的全局變量的完整例子:

DATA age+0x00(SB)/4, $18  // forever 18
GLOBL age(SB), RODATA, $4

DATA pi+0(SB)/8, $3.1415926
GLOBL pi(SB), RODATA, $8

DATA birthYear+0(SB)/4, $1988
GLOBL birthYear(SB), RODATA, $4

正如以前所說,全部符號在聲明時,其 offset 通常都是 0。

有時也可能會想在全局變量中定義數組,或字符串,這時候就須要用上非 0 的 offset 了,例如:

DATA bio<>+0(SB)/8, $"oh yes i"
DATA bio<>+8(SB)/8, $"am here "
GLOBL bio<>(SB), RODATA, $16

大部分都比較好理解,不過這裏咱們又引入了新的標記 <>,這個跟在符號名以後,表示該全局變量只在當前文件中生效,相似於 C 語言中的 static。若是在另外文件中引用該變量的話,會報 relocation target not found 的錯誤。

本小節中提到的 flag,還能夠有其它的取值:

  • NOPROF = 1
(For  `TEXT`  items.) Don't profile the marked function. This flag is deprecated.
  • DUPOK = 2
It is legal to have multiple instances of this symbol in a single binary. The linker will choose one of the duplicates to use.
  • NOSPLIT = 4
(For  `TEXT`  items.) Don't insert the preamble to check if the stack must be split. The frame for the routine, plus anything it calls, must fit in the spare space at the top of the stack segment. Used to protect routines such as the stack splitting code itself.
  • RODATA = 8
(For  `DATA`  and  `GLOBL`  items.) Put this data in a read-only section.
  • NOPTR = 16
(For  `DATA`  and  `GLOBL`  items.) This data contains no pointers and therefore does not need to be scanned by the garbage collector.
  • WRAPPER = 32
(For  `TEXT`  items.) This is a wrapper function and should not count as disabling  `recover`.
  • NEEDCTXT = 64
(For  `TEXT`  items.) This function is a closure so it uses its incoming context register.

當使用這些 flag 的字面量時,須要在彙編文件中 #include "textflag.h"

.s 和 .go 文件的全局變量互通

.s 文件中是能夠直接使用 .go 中定義的全局變量的,看看下面這個簡單的例子:

refer.go:

package main

var a = 999
func get() int

func main() {
    println(get())
}

refer.s:

#include "textflag.h"

TEXT ·get(SB), NOSPLIT, $0-8
    MOVQ ·a(SB), AX
    MOVQ AX, ret+0(FP)
    RET

·a(SB),表示該符號須要連接器來幫咱們進行重定向(relocation),若是找不到該符號,會輸出 relocation target not found 的錯誤。

例子比較簡單,你們能夠自行嘗試。

函數聲明

咱們來看看一個典型的 plan9 的彙編函數的定義:

// func add(a, b int) int
//   => 該聲明定義在同一個 package 下的任意 .go 文件中
//   => 只有函數頭,沒有實現
TEXT pkgname·add(SB), NOSPLIT, $0-8
    MOVQ a+0(FP), AX
    MOVQ a+8(FP), BX
    ADDQ AX, BX
    MOVQ BX, ret+16(FP)
    RET

爲何要叫 TEXT ?若是對程序數據在文件中和內存中的分段稍有了解的同窗應該知道,咱們的代碼在二進制文件中,是存儲在 .text 段中的,這裏也就是一種約定俗成的起名方式。實際上在 plan9 中 TEXT 是一個指令,用來定義一個函數。除了 TEXT 以外還有前面變量聲明說到的 DATA/GLOBL。

定義中的 pkgname 部分是能夠省略的,非想寫也能夠寫上。不過寫上 pkgname 的話,在重命名 package 以後還須要改代碼,因此推薦最好仍是不要寫。

中點 · 比較特殊,是一個 unicode 的中點,該點在 mac 下的輸入方法是 option+shift+9。在程序被連接以後,全部的中點· 都會被替換爲句號.,好比你的方法是 runtime·main,在編譯以後的程序裏的符號則是 runtime.main。嗯,看起來很變態。簡單總結一下:

參數及返回值大小
                                  | 
 TEXT pkgname·add(SB),NOSPLIT,$32-32
       |        |               |
      包名     函數名         棧幀大小(局部變量+可能須要的額外調用函數的參數空間的總大小,但不包括調用其它函數時的 ret address 的大小)

棧結構

下面是一個典型的函數的棧結構圖:

-----------------                                           
                       current func arg0                                           
                       ----------------- <----------- FP(pseudo FP)                
                        caller ret addr                                            
                       +---------------+                                           
                       | caller BP(*)  |                                           
                       ----------------- <----------- SP(pseudo SP,其實是當前棧幀的 BP 位置)
                       |   Local Var0  |                                           
                       -----------------                                           
                       |   Local Var1  |                                           
                       -----------------                                           
                       |   Local Var2  |                                           
                       -----------------                -                          
                       |   ........    |                                           
                       -----------------                                           
                       |   Local VarN  |                                           
                       -----------------                                           
                       |               |                                           
                       |               |                                           
                       |  temporarily  |                                           
                       |  unused space |                                           
                       |               |                                           
                       |               |                                           
                       -----------------                                           
                       |  call retn    |                                           
                       -----------------                                           
                       |  call ret(n-1)|                                           
                       -----------------                                           
                       |  ..........   |                                           
                       -----------------                                           
                       |  call ret1    |                                           
                       -----------------                                           
                       |  call argn    |                                           
                       -----------------                                           
                       |   .....       |                                           
                       -----------------                                           
                       |  call arg3    |                                           
                       -----------------                                           
                       |  call arg2    |                                           
                       |---------------|                                           
                       |  call arg1    |                                           
                       -----------------   <------------  hardware SP 位置           
                         return addr                                               
                       +---------------+

從原理上來說,若是當前函數調用了其它函數,那麼 return addr 也是在 caller 的棧上的,不過往棧上插 return addr 的過程是由 CALL 指令完成的,在 RET 時,SP 又會恢復到圖上位置。咱們在計算 SP 和參數相對位置時,能夠認爲硬件 SP 指向的就是圖上的位置。

圖上的 caller BP,指的是 caller 的 BP 寄存器值,有些人把 caller BP 叫做 caller 的 frame pointer,實際上這個習慣是從 x86 架構沿襲來的。Go 的 asm 文檔中把僞寄存器 FP 也稱爲 frame pointer,可是這兩個 frame pointer 根本不是一回事。

此外須要注意的是,caller BP 是在編譯期由編譯器插入的,用戶手寫代碼時,計算 frame size 時是不包括這個 caller BP 部分的。是否插入 caller BP 的主要判斷依據是:

  1. 函數的棧幀大小大於 0
  2. 下述函數返回 true
func Framepointer_enabled(goos, goarch string) bool {
    return framepointer_enabled != 0 && goarch == "amd64" && goos != "nacl"
}

若是編譯器在最終的彙編結果中沒有插入 caller BP(源代碼中所稱的 frame pointer)的狀況下,僞 SP 和僞 FP 之間只有 8 個字節的 caller 的 return address,而插入了 BP 的話,就會多出額外的 8 字節。也就說僞 SP 和僞 FP 的相對位置是不固定的,有多是間隔 8 個字節,也有可能間隔 16 個字節。而且判斷依據會根據平臺和 Go 的版本有所不一樣。

圖上能夠看到,FP 僞寄存器指向函數的傳入參數的開始位置,由於棧是朝低地址方向增加,爲了經過寄存器引用參數時方便,因此參數的擺放方向和棧的增加方向是相反的,即:

FP
high ----------------------> low
argN, ... arg3, arg2, arg1, arg0

假設全部參數均爲 8 字節,這樣咱們就能夠用 symname+0(FP) 訪問第一個 參數,symname+8(FP) 訪問第二個參數,以此類推。用僞 SP 來引用局部變量,原理上來說差很少,不過由於僞 SP 指向的是局部變量的底部,因此 symname-8(SP) 表示的是第一個局部變量,symname-16(SP)表示第二個,以此類推。固然,這裏假設局部變量都佔用 8 個字節。

圖的最上部的 caller return address 和 current func arg0 都是由 caller 來分配空間的。不算在當前的棧幀內。

由於官方文檔自己較模糊,咱們來一個函數調用的全景圖,來看一下這些真假 SP/FP/BP 究竟是個什麼關係:

caller                                                                                 
                                 +------------------+                                                                         
                                 |                  |                                                                         
       +---------------------->  --------------------                                                                         
       |                         |                  |                                                                         
       |                         | caller parent BP |                                                                         
       |           BP(pseudo SP) --------------------                                                                         
       |                         |                  |                                                                         
       |                         |   Local Var0     |                                                                         
       |                         --------------------                                                                         
       |                         |                  |                                                                         
       |                         |   .......        |                                                                         
       |                         --------------------                                                                         
       |                         |                  |                                                                         
       |                         |   Local VarN     |                                                                         
                                 --------------------                                                                         
 caller stack frame              |                  |                                                                         
                                 |   callee arg2    |                                                                         
       |                         |------------------|                                                                         
       |                         |                  |                                                                         
       |                         |   callee arg1    |                                                                         
       |                         |------------------|                                                                         
       |                         |                  |                                                                         
       |                         |   callee arg0    |                                                                         
       |                         ----------------------------------------------+   FP(virtual register)                       
       |                         |                  |                          |                                              
       |                         |   return addr    |  parent return address   |                                              
       +---------------------->  +------------------+---------------------------    <-------------------------------+         
                                                    |  caller BP               |                                    |         
                                                    |  (caller frame pointer)  |                                    |         
                                     BP(pseudo SP)  ----------------------------                                    |         
                                                    |                          |                                    |         
                                                    |     Local Var0           |                                    |         
                                                    ----------------------------                                    |         
                                                    |                          |                                              
                                                    |     Local Var1           |                                              
                                                    ----------------------------                            callee stack frame
                                                    |                          |                                              
                                                    |       .....              |                                              
                                                    ----------------------------                                    |         
                                                    |                          |                                    |         
                                                    |     Local VarN           |                                    |         
                                  SP(Real Register) ----------------------------                                    |         
                                                    |                          |                                    |         
                                                    |                          |                                    |         
                                                    |                          |                                    |         
                                                    |                          |                                    |         
                                                    |                          |                                    |         
                                                    +--------------------------+    <-------------------------------+         
                                                                                                                              
                                                              callee

argsize 和 framesize 計算規則

argsize

在函數聲明中:

TEXT pkgname·add(SB),NOSPLIT,$16-32

前面已經說過 $16-32 表示 $framesize-argsize。Go 在函數調用時,參數和返回值都須要由 caller 在其棧幀上備好空間。callee 在聲明時仍然須要知道這個 argsize。argsize 的計算方法是,參數大小求和+返回值大小求和,例如入參是 3 個 int64 類型,返回值是 1 個 int64 類型,那麼這裏的 argsize = sizeof(int64) * 4。

不過真實世界永遠沒有咱們假設的這麼美好,函數參數每每混合了多種類型,還須要考慮內存對齊問題。

若是不肯定本身的函數簽名須要多大的 argsize,能夠經過簡單實現一個相同簽名的空函數,而後 go tool objdump 來逆向查找應該分配多少空間。

framesize

函數的 framesize 就稍微複雜一些了,手寫代碼的 framesize 不須要考慮由編譯器插入的 caller BP,要考慮:

  1. 局部變量,及其每一個變量的 size。
  2. 在函數中是否有對其它函數調用時,若是有的話,調用時須要將 callee 的參數、返回值考慮在內。雖然 return address(rip)的值也是存儲在 caller 的 stack frame 上的,可是這個過程是由 CALL 指令和 RET 指令完成 PC 寄存器的保存和恢復的,在手寫彙編時,一樣也是不須要考慮這個 PC 寄存器在棧上所需佔用的 8 個字節的。
  3. 原則上來講,調用函數時只要不把局部變量覆蓋掉就能夠了。稍微多分配幾個字節的 framesize 也不會死。
  4. 在確保邏輯沒有問題的前提下,你願意覆蓋局部變量也沒有問題。只要保證進入和退出彙編函數時的 caller 和 callee 能正確拿到返回值就能夠。

地址運算

地址運算也是用 lea 指令,英文原意爲 Load Effective Address,amd64 平臺地址都是 8 個字節,因此直接就用 LEAQ 就好:

LEAQ (BX)(AX*8), CX
// 上面代碼中的 8 表明 scale
// scale 只能是 0、二、四、8
// 若是寫成其它值:
// LEAQ (BX)(AX*3), CX
// ./a.s:6: bad scale: 3

// 用 LEAQ 的話,即便是兩個寄存器值直接相加,也必須提供 scale
// 下面這樣是不行的
// LEAQ (BX)(AX), CX
// asm: asmidx: bad address 0/2064/2067
// 正確的寫法是
LEAQ (BX)(AX*1), CX


// 在寄存器運算的基礎上,能夠加上額外的 offset
LEAQ 16(BX)(AX*1), CX

// 三個寄存器作運算,仍是別想了
// LEAQ DX(BX)(AX*8), CX
// ./a.s:13: expected end of operand, found (

使用 LEAQ 的好處也比較明顯,能夠節省指令數。若是用基本算術指令來實現 LEAQ 的功能,須要兩~三條以上的計算指令才能實現 LEAQ 的完整功能。

示例

add/sub/mul

math.go:

package main

import "fmt"

func add(a, b int) int // 彙編函數聲明

func sub(a, b int) int // 彙編函數聲明

func mul(a, b int) int // 彙編函數聲明

func main() {
    fmt.Println(add(10, 11))
    fmt.Println(sub(99, 15))
    fmt.Println(mul(11, 12))
}

math.s:

#include "textflag.h" // 由於咱們聲明函數用到了 NOSPLIT 這樣的 flag,因此須要將 textflag.h 包含進來

// func add(a, b int) int
TEXT ·add(SB), NOSPLIT, $0-24
    MOVQ a+0(FP), AX // 參數 a
    MOVQ b+8(FP), BX // 參數 b
    ADDQ BX, AX    // AX += BX
    MOVQ AX, ret+16(FP) // 返回
    RET

// func sub(a, b int) int
TEXT ·sub(SB), NOSPLIT, $0-24
    MOVQ a+0(FP), AX
    MOVQ b+8(FP), BX
    SUBQ BX, AX    // AX -= BX
    MOVQ AX, ret+16(FP)
    RET

// func mul(a, b int) int
TEXT ·mul(SB), NOSPLIT, $0-24
    MOVQ  a+0(FP), AX
    MOVQ  b+8(FP), BX
    IMULQ BX, AX    // AX *= BX
    MOVQ  AX, ret+16(FP)
    RET
    // 最後一行的空行是必須的,不然可能報 unexpected EOF

把這兩個文件放在任意目錄下,執行 go build 並運行就能夠看到效果了。

僞寄存器 SP 、僞寄存器 FP 和硬件寄存器 SP

來寫一段簡單的代碼證實僞 SP、僞 FP 和硬件 SP 的位置關係。
spspfp.s:

#include "textflag.h"

// func output(int) (int, int, int)
TEXT ·output(SB), $8-48
    MOVQ 24(SP), DX // 不帶 symbol,這裏的 SP 是硬件寄存器 SP
    MOVQ DX, ret3+24(FP) // 第三個返回值
    MOVQ perhapsArg1+16(SP), BX // 當前函數棧大小 > 0,因此 FP 在 SP 的上方 16 字節處
    MOVQ BX, ret2+16(FP) // 第二個返回值
    MOVQ arg1+0(FP), AX
    MOVQ AX, ret1+8(FP)  // 第一個返回值
    RET

spspfp.go:

package main

import (
    "fmt"
)

func output(int) (int, int, int) // 彙編函數聲明

func main() {
    a, b, c := output(987654321)
    fmt.Println(a, b, c)
}

執行上面的代碼,能夠獲得輸出:

987654321 987654321 987654321

和代碼結合思考,能夠知道咱們當前的棧結構是這樣的:

------
ret2 (8 bytes)
------
ret1 (8 bytes)
------
ret0 (8 bytes)
------
arg0 (8 bytes)
------ FP
ret addr (8 bytes)
------
caller BP (8 bytes)
------ pseudo SP
frame content (8 bytes)
------ hardware SP

本小節例子的 framesize 是大於 0 的,讀者能夠嘗試修改 framesize 爲 0,而後調整代碼中引用僞 SP 和硬件 SP 時的 offset,來研究 framesize 爲 0 時,僞 FP,僞 SP 和硬件 SP 三者之間的相對位置。

本小節的例子是爲了告訴你們,僞 SP 和僞 FP 的相對位置是會變化的,手寫時不該該用僞 SP 和 >0 的 offset 來引用數據,不然結果可能會出乎你的預料。

彙編調用非彙編函數

output.s:

#include "textflag.h"

// func output(a,b int) int
TEXT ·output(SB), NOSPLIT, $24-24
    MOVQ a+0(FP), DX // arg a
    MOVQ DX, 0(SP) // arg x
    MOVQ b+8(FP), CX // arg b
    MOVQ CX, 8(SP) // arg y
    CALL ·add(SB) // 在調用 add 以前,已經把參數都經過物理寄存器 SP 搬到了函數的棧頂
    MOVQ 16(SP), AX // add 函數會把返回值放在這個位置
    MOVQ AX, ret+16(FP) // return result
    RET

output.go:

package main

import "fmt"

func add(x, y int) int {
    return x + y
}

func output(a, b int) int

func main() {
    s := output(10, 13)
    fmt.Println(s)
}

彙編中的循環

經過 DECQ 和 JZ 結合,能夠實現高級語言裏的循環邏輯:

sum.s:

#include "textflag.h"

// func sum(sl []int64) int64
TEXT ·sum(SB), NOSPLIT, $0-32
    MOVQ $0, SI
    MOVQ sl+0(FP), BX // &sl[0], addr of the first elem
    MOVQ sl+8(FP), CX // len(sl)
    INCQ CX           // CX++, 由於要循環 len 次

start:
    DECQ CX       // CX--
    JZ   done
    ADDQ (BX), SI // SI += *BX
    ADDQ $8, BX   // 指針移動
    JMP  start

done:
    // 返回地址是 24 是怎麼得來的呢?
    // 能夠經過 go tool compile -S math.go 得知
    // 在調用 sum 函數時,會傳入三個值,分別爲:
    // slice 的首地址、slice 的 len, slice 的 cap
    // 不過咱們這裏的求和只須要 len,但 cap 依然會佔用參數的空間
    // 就是 16(FP)
    MOVQ SI, ret+24(FP)
    RET

sum.go:

package main

func sum([]int64) int64

func main() {
    println(sum([]int64{1, 2, 3, 4, 5}))
}

擴展話題

標準庫中的一些數據結構

數值類型

標準庫中的數值類型不少:

  1. int/int8/int16/int32/int64
  2. uint/uint8/uint16/uint32/uint64
  3. float32/float64
  4. byte/rune
  5. uintptr

這些類型在彙編中就是一段存儲着數據的連續內存,只是內存長度不同,操做的時候看好數據長度就行。

slice

前面的例子已經說過了,slice 在傳遞給函數的時候,實際上會展開成三個參數:

  1. 首元素地址
  2. slice 的 len
  3. slice 的 cap

在彙編中處理時,只要知道這個原則那就很好辦了,按順序仍是按索引操做隨你開心。

string

package main

//go:noinline
func stringParam(s string) {}

func main() {
    var x = "abcc"
    stringParam(x)
}

go tool compile -S 輸出其彙編:

0x001d 00029 (stringParam.go:11)    LEAQ    go.string."abcc"(SB), AX  // 獲取 RODATA 段中的字符串地址
0x0024 00036 (stringParam.go:11)    MOVQ    AX, (SP) // 將獲取到的地址放在棧頂,做爲第一個參數
0x0028 00040 (stringParam.go:11)    MOVQ    $4, 8(SP) // 字符串長度做爲第二個參數
0x0031 00049 (stringParam.go:11)    PCDATA  $0, $0 // gc 相關
0x0031 00049 (stringParam.go:11)    CALL    "".stringParam(SB) // 調用 stringParam 函數

在彙編層面 string 就是地址 + 字符串長度。

struct

struct 在彙編層面實際上就是一段連續內存,在做爲參數傳給函數時,會將其展開在 caller 的棧上傳給對應的 callee:

struct.go

package main

type address struct {
    lng int
    lat int
}

type person struct {
    age    int
    height int
    addr   address
}

func readStruct(p person) (int, int, int, int)

func main() {
    var p = person{
        age:    99,
        height: 88,
        addr: address{
            lng: 77,
            lat: 66,
        },
    }
    a, b, c, d := readStruct(p)
    println(a, b, c, d)
}

struct.s

#include "textflag.h"

TEXT ·readStruct(SB), NOSPLIT, $0-64
    MOVQ arg0+0(FP), AX
    MOVQ AX, ret0+32(FP)
    MOVQ arg1+8(FP), AX
    MOVQ AX, ret1+40(FP)
    MOVQ arg2+16(FP), AX
    MOVQ AX, ret2+48(FP)
    MOVQ arg3+24(FP), AX
    MOVQ AX, ret3+56(FP)
    RET

上述的程序會輸出 99, 88, 77, 66,這代表即便是內嵌結構體,在內存分佈上依然是連續的。

map

經過對下述文件進行彙編(go tool compile -S),咱們能夠獲得一個 map 在對某個 key 賦值時所須要作的操做:

m.go:

package main

func main() {
    var m = map[int]int{}
    m[43] = 1
    var n = map[string]int{}
    n["abc"] = 1
    println(m, n)
}

看一看第七行的輸出:

0x0085 00133 (m.go:7)   LEAQ    type.map[int]int(SB), AX
0x008c 00140 (m.go:7)   MOVQ    AX, (SP)
0x0090 00144 (m.go:7)   LEAQ    ""..autotmp_2+232(SP), AX
0x0098 00152 (m.go:7)   MOVQ    AX, 8(SP)
0x009d 00157 (m.go:7)   MOVQ    $43, 16(SP)
0x00a6 00166 (m.go:7)   PCDATA  $0, $1
0x00a6 00166 (m.go:7)   CALL    runtime.mapassign_fast64(SB)
0x00ab 00171 (m.go:7)   MOVQ    24(SP), AX
0x00b0 00176 (m.go:7)   MOVQ    $1, (AX)

前面咱們已經分析過調用函數的過程,這裏前幾行都是在準備 runtime.mapassign_fast64(SB) 的參數。去 runtime 裏看看這個函數的簽名:

func mapassign_fast64(t *maptype, h *hmap, key uint64) unsafe.Pointer {

不用看函數的實現咱們也大概能推測出函數輸入參數和輸出參數的關係了,把入參和彙編指令對應的話:

t *maptype
=>
LEAQ    type.map[int]int(SB), AX
MOVQ    AX, (SP)

h *hmap
=>
LEAQ    ""..autotmp_2+232(SP), AX
MOVQ    AX, 8(SP)

key uint64
=>
MOVQ    $43, 16(SP)

返回參數就是 key 對應的能夠寫值的內存地址,拿到該地址後咱們把想要寫的值寫進去就能夠了:

MOVQ    24(SP), AX
MOVQ    $1, (AX)

整個過程還挺複雜的,咱們手抄一遍倒也能夠實現。不過還要考慮,不一樣類型的 map,實際上須要執行的 runtime 中的 assign 函數是不一樣的,感興趣的同窗能夠彙編本節的示例自行嘗試。

總體來說,用匯編來操做 map 並非一個明智的選擇。

channel

channel 在 runtime 也是比較複雜的數據結構,若是在彙編層面操做,實際上也是調用 runtime 中 chan.go 中的函數,和 map 比較相似,這裏就不展開說了。

獲取 goroutine id

Go 的 goroutine 是一個叫 g 的結構體,內部有本身的惟一 id,不過 runtime 沒有把這個 id 暴露出來,但不知道爲何有不少人就是想把這個 id 獲得。因而就有了各類或其 goroutine id 的庫。

在 struct 一小節咱們已經提到,結構體自己就是一段連續的內存,咱們知道起始地址和字段的偏移量的話,很容易就能夠把這段數據搬運出來:

go_tls.h:

#ifdef GOARCH_arm
#define LR R14
#endif

#ifdef GOARCH_amd64
#define    get_tls(r)    MOVQ TLS, r
#define    g(r)    0(r)(TLS*1)
#endif

#ifdef GOARCH_amd64p32
#define    get_tls(r)    MOVL TLS, r
#define    g(r)    0(r)(TLS*1)
#endif

#ifdef GOARCH_386
#define    get_tls(r)    MOVL TLS, r
#define    g(r)    0(r)(TLS*1)
#endif

goid.go:

package goroutineid
import "runtime"
var offsetDict = map[string]int64{
    // ... 省略一些行
    "go1.7":    192,
    "go1.7.1":  192,
    "go1.7.2":  192,
    "go1.7.3":  192,
    "go1.7.4":  192,
    "go1.7.5":  192,
    "go1.7.6":  192,
    // ... 省略一些行
}

var offset = offsetDict[runtime.Version()]

// GetGoID returns the goroutine id
func GetGoID() int64 {
    return getGoID(offset)
}

func getGoID(off int64) int64

goid.s:

#include "textflag.h"
#include "go_tls.h"

// func getGoID() int64
TEXT ·getGoID(SB), NOSPLIT, $0-16
    get_tls(CX)
    MOVQ g(CX), AX
    MOVQ offset(FP), BX
    LEAQ 0(AX)(BX*1), DX
    MOVQ (DX), AX
    MOVQ AX, ret+8(FP)
    RET

這樣就實現了一個簡單的獲取 struct g 中的 goid 字段的小 library,做爲玩具放在這裏:

https://github.com/cch123/gor...

SIMD

SIMD 是 Single Instruction, Multiple Data 的縮寫,在 Intel 平臺上的 SIMD 指令集前後爲 SSE,AVX,AVX2,AVX512,這些指令集引入了標準之外的指令,和寬度更大的寄存器,例如:

  • 128 位的 XMM0~XMM31 寄存器。
  • 256 位的 YMM0~YMM31 寄存器。
  • 512 位的 ZMM0~ZMM31 寄存器。

這些寄存器的關係,相似 RAX,EAX,AX 之間的關係。指令方面能夠同時對多組數據進行移動或者計算,例如:

  • movups : 把4個不對準的單精度值傳送到xmm寄存器或者內存
  • movaps : 把4個對準的單精度值傳送到xmm寄存器或者內存

上述指令,當咱們將數組做爲函數的入參時有很大機率會看到,例如:

arr_par.go:

package main

import "fmt"

func pr(input [3]int) {
    fmt.Println(input)
}

func main() {
    pr([3]int{1, 2, 3})
}

go compile -S:

0x001d 00029 (arr_par.go:10)    MOVQ    "".statictmp_0(SB), AX
0x0024 00036 (arr_par.go:10)    MOVQ    AX, (SP)
0x0028 00040 (arr_par.go:10)    MOVUPS  "".statictmp_0+8(SB), X0
0x002f 00047 (arr_par.go:10)    MOVUPS  X0, 8(SP)
0x0034 00052 (arr_par.go:10)    CALL    "".pr(SB)

可見,編譯器在某些狀況下已經考慮到了性能問題,幫助咱們使用 SIMD 指令集來對數據搬運進行了優化。

由於 SIMD 這個話題自己比較廣,這裏就不展開細說了。

特別感謝

研究過程基本碰到不太明白的都去騷擾卓巨巨了,就是這位 https://mzh.io/ 大大。特別感謝他,給了很多線索和提示。

參考資料

  1. https://quasilyte.github.io/b...
  2. http://davidwong.fr/goasm
  3. https://www.doxsey.net/blog/g...
  4. https://github.com/golang/go/...
  5. https://golang.org/doc/asm

參考資料[4]須要特別注意,在該 slide 中給出的 callee stack frame 中把 caller 的 return address 也包含進去了,我的認爲不是很合適。

wechat.png

相關文章
相關標籤/搜索