五一假期在家沒事逛論壇的時候,發(fā)現(xiàn)了一個寶藏網(wǎng)站,傳送門 這個網(wǎng)站可以在線生成多種語言的匯編代碼,有這個好東西,那必須拿go實驗一番。
很久之前我寫過一篇go通過go匯編看多返回值實現(xiàn)的文章傳送門。當(dāng)時寫的時候比較早,后來 go 1.17 對函數(shù)調(diào)用時,傳遞參數(shù)做了修改,簡單說就是go1.17之前,函數(shù)參數(shù)是通過棧空間來傳遞的,在go1.17時做出了改變,在一些平臺上(AMD64)可以像C,C++那樣使用寄存器傳遞參數(shù)和函數(shù)返回值了。為什么做出這個改變呢,原因就是寄存器更快。雖然內(nèi)存已經(jīng)很快了,但是還是沒法和寄存器相比。之前為啥不用寄存器,用??臻g,原因是實現(xiàn)簡單,不用考慮不同平臺,不用架構(gòu)的區(qū)別。
簡單總結(jié)一下兩種方式
- 棧空間:
- 優(yōu)點:實現(xiàn)簡單,不用區(qū)分不同的平臺,通用性強
- 缺點:效率低
- 寄存器:
- 優(yōu)點:速度快
- 缺點:通用性差,不同的平臺需要單獨處理
當(dāng)然,這里說的通用性差是對于編譯器來說的
go匯編基礎(chǔ)知識
再來總結(jié)一次go匯編的基礎(chǔ)知識吧,現(xiàn)在回頭看之前總結(jié)的還是不全面的
go使用的 plan9 匯編,這個和 AT&T 的匯編差別還是有點大的,我個人感覺plan9匯編比較重要的就是四個寄存器,只要理解了這四個寄存器,匯編就理解了一半了
匯編中個幾個術(shù)語:
- 棧:進(jìn)程、線程、goroutine 都有自己的調(diào)用棧,先進(jìn)后出(FILO)
- 棧幀:可以理解是函數(shù)調(diào)用時,在棧上為函數(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)者
| 寄存器 | 說明 |
|---|---|
| SB(Static base pointer) | global symbols 全局靜態(tài)指針 |
| FP(Frame pointer) | arguments and locals 指向棧幀的開始 |
| SP(Stack pointer) | top of stack 指向棧頂 |
| PC(Program counter) | jumps and branches 簡單說程序計數(shù)器 |
簡單展開說一下幾個寄存器吧
- SB:全局靜態(tài)指針,即程序地址空間的開始地址。一般用在聲明函數(shù)、全局變量中。
- FP:指向的是 caller 調(diào)用 callee 時傳遞的第一個參數(shù)的位置,可以看作是指向兩個函數(shù)棧的分割位置;但是FP指向的位置不在 callee 的 stack frame 之內(nèi)。而是在 caller 的 stack frame 上,指向調(diào)用 add 函數(shù)時傳遞的第一個參數(shù)的位置;可以在 callee 中用
symbol+offset(FP)來獲取入?yún)⒌膮?shù)值,比如a+8(FP)。雖然symbol沒有什么具體意義,但是不加編譯器會報錯。 - SP:這個是最常用的寄存器了,同樣也是最復(fù)雜的寄存器了。不同的引用方式,代表不同的位置。SP寄存器 分為偽 SP 寄存器和硬件 SP 寄存器。
symbol+offset(SP)形式,則表示偽寄存器 SP (這個也簡稱為 BP)。如果是offset(SP)則表示硬件寄存器 SP。偽 SP 寄存器指向當(dāng)前棧幀第一個局部變量的結(jié)束位置;硬件SP指向的是整個函數(shù)棧結(jié)束的位置。有個比較坑的地方:對于編譯輸出(go tool compile -S / go tool objdump)的代碼來講,所有的 SP 都是硬件 SP 寄存器,無論是否帶 symbol(這一點非常具有迷惑性,需要慢慢理解。往往在分析編譯輸出的匯編時,看到的就是硬件 SP 寄存器)。 - PC:這個就是計算機常見的 pc 寄存器,在 x86 平臺下對應(yīng) ip 寄存器,amd64 上則是 rip。這個很少有用到。
通過一個棧幀的圖來理解一下這幾個寄存器

大體的棧幀就是圖中的這樣,圖中標(biāo)注的寄存器都是以 callee 函數(shù)為基準(zhǔn)的
通過圖中可知,如果callee函數(shù)中沒有局部變量的話,SP硬寄存器和SP偽寄存器指向的是同一個地方
偽 FP 寄存器對應(yīng)的是 caller 函數(shù)的幀指針,一般用來訪問 callee 函數(shù)的入?yún)?shù)和返回值。偽 SP 棧指針對應(yīng)的是當(dāng)前 callee 函數(shù)棧幀的底部(不包括參數(shù)和返回值部分),一般用于定位局部變量。硬件 SP 是一個比較特殊的寄存器,因為還存在一個同名的 SP 真寄存器,硬件 SP 寄存器對應(yīng)的是棧的頂部。
在編寫 Go 匯編時,當(dāng)需要區(qū)分偽寄存器和真寄存器的時候只需要記住一點:偽寄存器一般需要一個標(biāo)識符和偏移量為前綴,如果沒有標(biāo)識符前綴則是真寄存器。比如(SP)、+8(SP)沒有標(biāo)識符前綴為真 SP 寄存器,而 a(SP)、b+8(SP)有標(biāo)識符為前綴表示偽寄存器。
還有一點
如果callee的棧空間大小是0的話, caller BP 是不會被壓入棧中的,此時的SP硬件寄存器和偽FP寄存器指向的是同一個位置。
在匯編中,函數(shù)的定義
TEXT "".add(SB), NOSPLIT|ABIInternal, $0-24
TEXT 是一個特殊的指令,定義一個函數(shù)
.add 是函數(shù)的名
NOSPLIT 向編譯器表明不應(yīng)該插入 stack-split 的用來檢查棧需要擴張的前導(dǎo)指令
$0-24 這兩個參數(shù),0是聲明這個函數(shù)需要的??臻g的大小,一般來說就是局部變量需要的空間,單位是位。
24是聲明函數(shù)傳入?yún)?shù)和返回值需要的??臻g的大小,單位也是位。
生成匯編
了解了這些基本概念后,上一段代碼,通過匯編看一下不同版本go是如何處理函數(shù)傳遞參數(shù)的
先通過一段簡單的代碼看一下區(qū)別
package main
func main(){
add(10,20)
}
//go:noinline
func add(a,b int) int{
return a+b
}
//go:noinline 這個是告訴編譯器不要對這個函數(shù)內(nèi)聯(lián),這個東西叫g(shù)o的編譯指令,go還有你很多別的指令,這里就不展開了。想想go也挺有意思的,C++是通過 inline 顯式的指定要內(nèi)聯(lián),go是告訴編譯器不要內(nèi)聯(lián)。
先看一下在1.16下的匯編代碼
main_pc0:
.file 1 "<source>"
.loc 1 5 0
TEXT "".main(SB), ABIInternal, $32-0
MOVQ (TLS), CX
CMPQ SP, 16(CX)
PCDATA $0, $-2
JLS main_pc64
PCDATA $0, $-1
SUBQ $32, SP
MOVQ BP, 24(SP)
LEAQ 24(SP), BP
FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
.loc 1 11 0
MOVQ $10, (SP)
MOVQ $20, 8(SP)
PCDATA $1, $0
CALL "".add(SB)
.loc 1 12 0
MOVQ 24(SP), BP
ADDQ $32, SP
RET
NOP
.loc 1 5 0
PCDATA $1, $-1
PCDATA $0, $-2
NOP
main_pc64:
CALL runtime.morestack_noctxt(SB)
PCDATA $0, $-1
JMP main_pc0
.loc 1 21 0
TEXT "".add(SB), NOSPLIT|ABIInternal, $0-24
FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
.loc 1 22 0
MOVQ "".b+16(SP), AX
MOVQ "".a+8(SP), CX
ADDQ CX, AX
MOVQ AX, "".~r2+24(SP)
RET
代碼有很多,只關(guān)注下面圖中標(biāo)注的

通 MOVQ "".b+16(SP), AX 和 MOVQ "".a+8(SP), CX 可以得知,函數(shù)是通過SP寄存器偏移完成傳遞參數(shù)的。
這里要注意,.b+16(SP) 這種寫法看著像是使用的是偽SP寄存器,實際上用的是硬件SP寄存器
同樣的,在函數(shù)調(diào)用之前,也會把數(shù)值放到棧的指定位置
MOVQ $10, (SP) , MOVQ $20, 8(SP)
把編譯器換成最新的 1.18看一下
main_pc0:
.file 1 "<source>"
.loc 1 5 0
TEXT "".main(SB), ABIInternal, $24-0
CMPQ SP, 16(R14)
PCDATA $0, $-2
JLS main_pc47
PCDATA $0, $-1
SUBQ $24, SP
MOVQ BP, 16(SP)
LEAQ 16(SP), BP
FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
.loc 1 11 0
MOVL $10, AX
MOVL $20, BX
PCDATA $1, $0
NOP
CALL "".add(SB)
.loc 1 12 0
MOVQ 16(SP), BP
ADDQ $24, SP
RET
main_pc47:
NOP
.loc 1 5 0
PCDATA $1, $-1
PCDATA $0, $-2
CALL runtime.morestack_noctxt(SB)
PCDATA $0, $-1
JMP main_pc0
.loc 1 21 0
TEXT "".add(SB), NOSPLIT|ABIInternal, $0-16
FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
FUNCDATA $5, "".add.arginfo1(SB)
FUNCDATA $6, "".add.argliveinfo(SB)
PCDATA $3, $1
.loc 1 22 0
ADDQ BX, AX
RET
在1.18的匯編代碼中就沒有通過??臻g來傳遞參數(shù)了,而是直接通過寄存器完成操作,
ADDQ BX, AX,并且返回值直接放到寄存器中。
寄存器數(shù)量是有上限的,如果傳遞的參數(shù)個數(shù)超過了寄存器的上限,又會怎樣處理呢
package main
func main(){
add(1,2,3,4,5,6,7,8,9,10,11,12)
}
//go:noinline
func add(a,b,c,d,e,f,g,h,i,j,k,l int) int{
return a+b+c+d+e+f+g+h+i+j+k+l
}
對應(yīng)的匯編
main_pc0:
.file 1 "<source>"
.loc 1 5 0
TEXT "".main(SB), ABIInternal, $104-0
CMPQ SP, 16(R14)
PCDATA $0, $-2
JLS main_pc111
PCDATA $0, $-1
SUBQ $104, SP
MOVQ BP, 96(SP)
LEAQ 96(SP), BP
FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
.loc 1 11 0
MOVQ $10, (SP)
MOVQ $11, 8(SP)
MOVQ $12, 16(SP)
MOVL $1, AX
MOVL $2, BX
MOVL $3, CX
MOVL $4, DI
MOVL $5, SI
MOVL $6, R8
MOVL $7, R9
MOVL $8, R10
MOVL $9, R11
PCDATA $1, $0
NOP
CALL "".add(SB)
.loc 1 12 0
MOVQ 96(SP), BP
ADDQ $104, SP
RET
main_pc111:
NOP
.loc 1 5 0
PCDATA $1, $-1
PCDATA $0, $-2
CALL runtime.morestack_noctxt(SB)
PCDATA $0, $-1
JMP main_pc0
.loc 1 21 0
TEXT "".add(SB), NOSPLIT|ABIInternal, $0-96
FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
FUNCDATA $5, "".add.arginfo1(SB)
FUNCDATA $6, "".add.argliveinfo(SB)
PCDATA $3, $1
.loc 1 22 0
LEAQ (BX)(AX*1), DX
ADDQ DX, CX
ADDQ DI, CX
ADDQ SI, CX
ADDQ R8, CX
ADDQ R9, CX
ADDQ R10, CX
ADDQ R11, CX
MOVQ "".j+8(SP), DX
ADDQ DX, CX
MOVQ "".k+16(SP), DX
ADDQ DX, CX
MOVQ "".l+24(SP), DX
LEAQ (DX)(CX*1), AX
RET
通過匯編可以看到,會先使用寄存器,當(dāng)寄存器不夠時,會使用??臻g。
手寫匯編
通過手寫一段匯編代碼,驗證一下各個寄存器的位置,我用的go版本是 1.14.13,所以傳參數(shù)用的是??臻g。
在 main.go 文件中
package main
func add(int, int) int
func main() {
print(add(10, 20))
}
定義一個 main 函數(shù)作為整個程序的入口,聲明一個 add(int, int) int 函數(shù),add 函數(shù)的具體實現(xiàn)是用匯編寫的,
在 main.go 同級目錄下創(chuàng)建一個 add_amd64.s 的文件。
使用硬BP寄存器
TEXT ·add(SB), $0-24
MOVQ 8(SP), AX
MOVQ 16(SP), BX
ADDQ BX, AX
MOVQ AX, 24(SP)
RET
使用 go run . 命令,看一下程序執(zhí)行的結(jié)果。

這時候的棧幀如圖所示

因為add函數(shù)棧空間是0,所以偽SP寄存器沒有被壓入棧中,偽SP寄存器和硬件SP寄存器指向的是同一個位置。
使用偽SP寄存器
TEXT ·add(SB), $16-24
MOVQ a+16(SP), AX
MOVQ b+24(SP), BX
ADDQ BX, AX
MOVQ AX, ret+32(SP)
RET
為了區(qū)分對比,這時候把add函數(shù)的??臻g設(shè)置為16,然后使用偽SP寄存器來獲取值。
執(zhí)行結(jié)果如下:

這時候的棧空間如圖

使用FP寄存器
代碼如下:
TEXT ·add(SB), $16-24
MOVQ a+0(FP), AX
MOVQ b+8(FP), BX
ADDQ BX, AX
MOVQ AX, ret+16(FP)
RET
執(zhí)行結(jié)果:

說明還是正確的,此時的??臻g沒有變化,和上面是一樣的。
到這,應(yīng)該能理解各個寄存器的相對位置了吧:
在callee??臻g不為0的時候,
FP = 硬件SP + framsize + 16
SP = 硬件SP + framsize
在callee??臻g為0的時候,
FP = 硬件SP + 8
SP = 硬件SP
匯編簡單分析
通過上面的代碼會發(fā)現(xiàn),手寫匯編的代碼和反匯編的代碼有些不同。反匯編得到的代碼,在函數(shù)前面會有一段
CALL runtime.morestack_noctxt(SB)
其實這個是編譯器自動插入的一段函數(shù),這段指令會調(diào)用一次 runtime.morestack_noctxt 這個函數(shù)具體的作用是挺復(fù)雜的,主要有 檢查是否需要擴張棧,go的??臻g是可以動態(tài)擴充的,所以在調(diào)用函數(shù)前會檢查當(dāng)前的??臻g是否需要擴充。還有一個功能就是檢查當(dāng)前協(xié)程需要搶占。go在1.14之前goroutine的搶占是協(xié)作式搶占模式,怎么判斷一個協(xié)程是否需要搶占呢?后臺協(xié)程會定時掃描當(dāng)前運行中的協(xié)程,如果發(fā)現(xiàn)一個協(xié)程運行比較久,會將其標(biāo)記為搶占狀態(tài)。這個掃描的時間點就是函數(shù)調(diào)用期間完成的。
不同類型參數(shù)傳遞
傳結(jié)構(gòu)體
package main
type One struct {
a int
b int
}
func main(){
o := One {
a:10,
b.20,
}
f1(o)
}
//go:noinline
func f1(o One) int {
return o.a + o.b
}
只貼 關(guān)鍵的匯編代碼吧
MOVQ $10, (SP)
MOVQ $0, 8(SP)
PCDATA $1, $0
CALL "".f1(SB)
..........................
TEXT "".f1(SB), NOSPLIT|ABIInternal, $0-24
MOVQ "".o+16(SP), AX
MOVQ "".o+8(SP), CX
ADDQ CX, AX
MOVQ AX, "".~r1+24(SP)
RET
在傳結(jié)構(gòu)體的時候,只把結(jié)構(gòu)體的內(nèi)容傳進(jìn)去了。
傳結(jié)構(gòu)體指針
package main
type One struct {
a int
b int
}
func main(){
o := &One{
a:10,
b:20,
}
f1(o)
}
//go:noinline
func f1(o *One) int {
return o.a + o.b
}
匯編
LEAQ ""..autotmp_2+16(SP), AX
MOVQ AX, (SP)
PCDATA $1, $0
CALL "".f1(SB)
.......................
TEXT "".f1(SB), NOSPLIT|ABIInternal, $0-16
MOVQ "".o+8(SP), AX
MOVQ (AX), CX
ADDQ 8(AX), CX
MOVQ CX, "".~r1+16(SP)
RET
可以看到,傳遞指針的時候,只是把結(jié)構(gòu)體第一個元素的地址傳遞進(jìn)去了。
如果是傳一個空的結(jié)構(gòu)體
package main
type One struct {
}
func main(){
o := One{}
f1(o,10,20)
}
//go:noinline
func f1(o One, a,b int) int {
return a + b
}
匯編
TEXT "".f1(SB), NOSPLIT|ABIInternal, $0-24
.loc 1 15 0
MOVQ "".b+16(SP), AX
MOVQ "".a+8(SP), CX
ADDQ CX, AX
MOVQ AX, "".~r3+24(SP)
RET
可以看到,當(dāng)傳遞一個空結(jié)構(gòu)體時,相當(dāng)于沒有傳遞,因為空結(jié)構(gòu)體的空間大小是0,所以編譯器就給忽略了。
最后
暫時就想到這么多,先寫這些吧,后面想起別的再補充吧