再探 go 匯編

五一假期在家沒事逛論壇的時候,發(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é)一下兩種方式

  1. 棧空間:
  • 優(yōu)點:實現(xiàn)簡單,不用區(qū)分不同的平臺,通用性強
  • 缺點:效率低
  1. 寄存器:
  • 優(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。這個很少有用到。

通過一個棧幀的圖來理解一下這幾個寄存器

棧幀.jpg

大體的棧幀就是圖中的這樣,圖中標(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), AXMOVQ "".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,所以編譯器就給忽略了。

最后

暫時就想到這么多,先寫這些吧,后面想起別的再補充吧

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容