這篇文章國內研究 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.
官方的描述稍微有一些問題,咱們對這些說明進行一點擴充:
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 上。具體可參見以後的 棧結構 一章。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。咱們這裏對容易混淆的幾點簡單進行說明:
以上說明看不懂也不要緊,在熟悉了函數的棧結構以後再反覆回來查看應該就能夠明白了。我的意見,這些是 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
中定義的全局變量的,看看下面這個簡單的例子:
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 的主要判斷依據是:
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
在函數聲明中:
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 不須要考慮由編譯器插入的 caller BP,要考慮:
地址運算也是用 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 的完整功能。
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 的位置關係。
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})) }
標準庫中的數值類型不少:
這些類型在彙編中就是一段存儲着數據的連續內存,只是內存長度不同,操做的時候看好數據長度就行。
前面的例子已經說過了,slice 在傳遞給函數的時候,實際上會展開成三個參數:
在彙編中處理時,只要知道這個原則那就很好辦了,按順序仍是按索引操做隨你開心。
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 在彙編層面實際上就是一段連續內存,在做爲參數傳給函數時,會將其展開在 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,這代表即便是內嵌結構體,在內存分佈上依然是連續的。
經過對下述文件進行彙編(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 在 runtime 也是比較複雜的數據結構,若是在彙編層面操做,實際上也是調用 runtime 中 chan.go 中的函數,和 map 比較相似,這裏就不展開說了。
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 是 Single Instruction, Multiple Data 的縮寫,在 Intel 平臺上的 SIMD 指令集前後爲 SSE,AVX,AVX2,AVX512,這些指令集引入了標準之外的指令,和寬度更大的寄存器,例如:
這些寄存器的關係,相似 RAX,EAX,AX 之間的關係。指令方面能夠同時對多組數據進行移動或者計算,例如:
上述指令,當咱們將數組做爲函數的入參時有很大機率會看到,例如:
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/ 大大。特別感謝他,給了很多線索和提示。
參考資料[4]須要特別注意,在該 slide 中給出的 callee stack frame 中把 caller 的 return address 也包含進去了,我的認爲不是很合適。