Go 語言 控制流

2023-03-22 15:02 更新

原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch3-asm/ch3-05-control-flow.html


3.5 控制流

程序主要有順序、分支和循環(huán)幾種執(zhí)行流程。本節(jié)主要討論如何將 Go 語言的控制流比較直觀地轉(zhuǎn)譯為匯編程序,或者說如何以匯編思維來編寫 Go 語言代碼。

3.5.1 順序執(zhí)行

順序執(zhí)行是我們比較熟悉的工作模式,類似俗稱流水賬編程。所有不含分支、循環(huán)和 goto 語句,并且沒有遞歸調(diào)用的 Go 函數(shù)一般都是順序執(zhí)行的。

比如有如下順序執(zhí)行的代碼:

func main() {
    var a = 10
    println(a)

    var b = (a+a)*a
    println(b)
}

我們嘗試用 Go 匯編的思維改寫上述函數(shù)。因為 X86 指令中一般只有 2 個操作數(shù),因此在用匯編改寫時要求出現(xiàn)的變量表達(dá)式中最多只能有一個運(yùn)算符。同時對于一些函數(shù)調(diào)用,也需要用匯編中可以調(diào)用的函數(shù)來改寫。

第一步改寫依然是使用 Go 語言,只不過是用匯編的思維改寫:

func main() {
    var a, b int

    a = 10
    runtime.printint(a)
    runtime.printnl()

    b = a
    b += b
    b *= a
    runtime.printint(b)
    runtime.printnl()
}

首選模仿 C 語言的處理方式在函數(shù)入口處聲明全部的局部變量。然后根據(jù) MOV、ADD、MUL 等指令的風(fēng)格,將之前的變量表達(dá)式展開為用 =、+= 和 *= 幾種運(yùn)算表達(dá)的多個指令。最后用 runtime 包內(nèi)部的 printint 和 printnl 函數(shù)代替之前的 println 函數(shù)輸出結(jié)果。

經(jīng)過用匯編的思維改寫過后,上述的 Go 函數(shù)雖然看著繁瑣了一點,但是還是比較容易理解的。下面我們進(jìn)一步嘗試將改寫后的函數(shù)繼續(xù)轉(zhuǎn)譯為匯編函數(shù):

TEXT ·main(SB), $24-0
    MOVQ $0, a-8*2(SP) // a = 0
    MOVQ $0, b-8*1(SP) // b = 0

    // 將新的值寫入 a 對應(yīng)內(nèi)存
    MOVQ $10, AX       // AX = 10
    MOVQ AX, a-8*2(SP) // a = AX

    // 以 a 為參數(shù)調(diào)用函數(shù)
    MOVQ AX, 0(SP)
    CALL runtime·printint(SB)
    CALL runtime·printnl(SB)

    // 函數(shù)調(diào)用后, AX/BX 寄存器可能被污染, 需要重新加載
    MOVQ a-8*2(SP), AX // AX = a
    MOVQ b-8*1(SP), BX // BX = b

    // 計算 b 值, 并寫入內(nèi)存
    MOVQ AX, BX        // BX = AX  // b = a
    ADDQ BX, BX        // BX += BX // b += a
    IMULQ AX, BX       // BX *= AX // b *= a
    MOVQ BX, b-8*1(SP) // b = BX

    // 以 b 為參數(shù)調(diào)用函數(shù)
    MOVQ BX, 0(SP)
    CALL runtime·printint(SB)
    CALL runtime·printnl(SB)

    RET

匯編實現(xiàn) main 函數(shù)的第一步是要計算函數(shù)棧幀的大小。因為函數(shù)內(nèi)有 a、b 兩個 int 類型變量,同時調(diào)用的 runtime·printint 函數(shù)參數(shù)是一個 int 類型并且沒有返回值,因此 main 函數(shù)的棧幀是 3 個 int 類型組成的 24 個字節(jié)的棧內(nèi)存空間。

在函數(shù)的開始處先將變量初始化為 0 值,其中 a-8*2(SP) 對應(yīng) a 變量、a-8*1(SP) 對應(yīng) b 變量(因為 a 變量先定義,因此 a 變量的地址更?。?。

然后給 a 變量分配一個 AX 寄存器,并且通過 AX 寄存器將 a 變量對應(yīng)的內(nèi)存設(shè)置為 10,AX 也是 10。為了輸出 a 變量,需要將 AX 寄存器的值放到 0(SP) 位置,這個位置的變量將在調(diào)用 runtime·printint 函數(shù)時作為它的參數(shù)被打印。因為我們之前已經(jīng)將 AX 的值保存到 a 變量內(nèi)存中了,因此在調(diào)用函數(shù)前并不需要再進(jìn)行寄存器的備份工作。

在調(diào)用函數(shù)返回之后,全部的寄存器將被視為可能被調(diào)用的函數(shù)修改,因此我們需要從 a、b 對應(yīng)的內(nèi)存中重新恢復(fù)寄存器 AX 和 BX。然后參考上面 Go 語言中 b 變量的計算方式更新 BX 對應(yīng)的值,計算完成后同樣將 BX 的值寫入到 b 對應(yīng)的內(nèi)存。

需要說明的是,上面的代碼中 IMULQ AX, BX 使用了 IMULQ 指令來計算乘法。沒有使用 MULQ 指令的原因是 MULQ 指令默認(rèn)使用 AX 保存結(jié)果。讀者可以自己嘗試用 MULQ 指令改寫上述代碼。

最后以 b 變量作為參數(shù)再次調(diào)用 runtime·printint 函數(shù)進(jìn)行輸出工作。所有的寄存器同樣可能被污染,不過 main 函數(shù)馬上就返回了,因此不再需要恢復(fù) AX、BX 等寄存器了。

重新分析匯編改寫后的整個函數(shù)會發(fā)現(xiàn)里面很多的冗余代碼。我們并不需要 a、b 兩個臨時變量分配兩個內(nèi)存空間,而且也不需要在每個寄存器變化之后都要寫入內(nèi)存。下面是經(jīng)過優(yōu)化的匯編函數(shù):

TEXT ·main(SB), $16-0
    // var temp int

    // 將新的值寫入 a 對應(yīng)內(nèi)存
    MOVQ $10, AX        // AX = 10
    MOVQ AX, temp-8(SP) // temp = AX

    // 以 a 為參數(shù)調(diào)用函數(shù)
    CALL runtime·printint(SB)
    CALL runtime·printnl(SB)

    // 函數(shù)調(diào)用后, AX 可能被污染, 需要重新加載
    MOVQ temp-8*1(SP), AX // AX = temp

    // 計算 b 值, 不需要寫入內(nèi)存
    MOVQ AX, BX        // BX = AX  // b = a
    ADDQ BX, BX        // BX += BX // b += a
    IMULQ AX, BX       // BX *= AX // b *= a

    // ...
v

首先是將 main 函數(shù)的棧幀大小從 24 字節(jié)減少到 16 字節(jié)。唯一需要保存的是 a 變量的值,因此在調(diào)用 runtime·printint 函數(shù)輸出時全部的寄存器都可能被污染,我們無法通過寄存器備份 a 變量的值,只有在棧內(nèi)存中的值才是安全的。然后在 BX 寄存器并不需要保存到內(nèi)存。其它部分的代碼基本保持不變。

3.5.2 if/goto 跳轉(zhuǎn)

Go 語言剛剛開源的時候并沒有 goto 語句,后來 Go 語言雖然增加了 goto 語句,但是并不推薦在編程中使用。有一個和 cgo 類似的原則:如果可以不使用 goto 語句,那么就不要使用 goto 語句。Go 語言中的 goto 語句是有嚴(yán)格限制的:它無法跨越代碼塊,并且在被跨越的代碼中不能含有變量定義的語句。雖然 Go 語言不推薦 goto 語句,但是 goto 確實每個匯編語言碼農(nóng)的最愛。因為 goto 近似等價于匯編語言中的無條件跳轉(zhuǎn)指令 JMP,配合 if 條件 goto 就組成了有條件跳轉(zhuǎn)指令,而有條件跳轉(zhuǎn)指令正是構(gòu)建整個匯編代碼控制流的基石。

為了便于理解,我們用 Go 語言構(gòu)造一個模擬三元表達(dá)式的 If 函數(shù):

func If(ok bool, a, b int) int {
    if ok {return a} else { return b }
}

比如求兩個數(shù)最大值的三元表達(dá)式 (a>b)?a:b 用 If 函數(shù)可以這樣表達(dá):If(a>b, a, b)。因為語言的限制,用來模擬三元表達(dá)式的 If 函數(shù)不支持泛型(可以將 a、b 和返回類型改為空接口,不過使用會繁瑣一些)。

這個函數(shù)雖然看似只有簡單的一行,但是包含了 if 分支語句。在改用匯編實現(xiàn)前,我們還是先用匯編的思維來重新審視 If 函數(shù)。在改寫時同樣要遵循每個表達(dá)式只能有一個運(yùn)算符的限制,同時 if 語句的條件部分必須只有一個比較符號組成,if 語句的 body 部分只能是一個 goto 語句。

用匯編思維改寫后的 If 函數(shù)實現(xiàn)如下:

func If(ok int, a, b int) int {
    if ok == 0 {goto L}
    return a
L:
    return b
}

因為匯編語言中沒有 bool 類型,我們改用 int 類型代替 bool 類型(真實的匯編是用 byte 表示 bool 類型,可以通過 MOVBQZX 指令加載 byte 類型的值,這里做了簡化處理)。當(dāng) ok 參數(shù)非 0 時返回變量 a,否則返回變量 b。我們將 ok 的邏輯反轉(zhuǎn)下:當(dāng) ok 參數(shù)為 0 時,表示返回 b,否則返回變量 a。在 if 語句中,當(dāng) ok 參數(shù)為 0 時 goto 到 L 標(biāo)號指定的語句,也就是返回變量 b。如果 if 條件不滿足,也就是 ok 參數(shù)非 0,執(zhí)行后面的語句返回變量 a。

上述函數(shù)的實現(xiàn)已經(jīng)非常接近匯編語言,下面是改為匯編實現(xiàn)的代碼:

TEXT ·If(SB), NOSPLIT, $0-32
    MOVQ ok+8*0(FP), CX // ok
    MOVQ a+8*1(FP), AX  // a
    MOVQ b+8*2(FP), BX  // b

    CMPQ CX, $0         // test ok
    JZ   L              // if ok == 0, goto L
    MOVQ AX, ret+24(FP) // return a
    RET

L:
    MOVQ BX, ret+24(FP) // return b
    RET

首先是將三個參數(shù)加載到寄存器中,ok 參數(shù)對應(yīng) CX 寄存器,a、b 分別對應(yīng) AX、BX 寄存器。然后使用 CMPQ 比較指令將 CX 寄存器和常數(shù) 0 進(jìn)行比較。如果比較的結(jié)果為 0,那么下一條 JZ 為 0 時跳轉(zhuǎn)指令將跳轉(zhuǎn)到 L 標(biāo)號對應(yīng)的語句,也就是返回變量 b 的值。如果比較的結(jié)果不為 0,那么 JZ 指令將沒有效果,繼續(xù)執(zhí)行后面的指令,也就是返回變量 a 的值。

在跳轉(zhuǎn)指令中,跳轉(zhuǎn)的目標(biāo)一般是通過一個標(biāo)號表示。不過在有些通過宏實現(xiàn)的函數(shù)中,更希望通過相對位置跳轉(zhuǎn),這時候可以通過 PC 寄存器的偏移量來計算臨近跳轉(zhuǎn)的位置。

3.5.3 for 循環(huán)

Go 語言的 for 循環(huán)有多種用法,我們這里只選擇最經(jīng)典的 for 結(jié)構(gòu)來討論。經(jīng)典的 for 循環(huán)由初始化、結(jié)束條件、迭代步長三個部分組成,再配合循環(huán)體內(nèi)部的 if 條件語言,這種 for 結(jié)構(gòu)可以模擬其它各種循環(huán)類型。

基于經(jīng)典的 for 循環(huán)結(jié)構(gòu),我們定義一個 LoopAdd 函數(shù),可以用于計算任意等差數(shù)列的和:

func LoopAdd(cnt, v0, step int) int {
    result := v0
    for i := 0; i < cnt; i++ {
        result += step
    }
    return result
}

比如 1+2+...+100 等差數(shù)列可以這樣計算 LoopAdd(100, 1, 1),而 10+8+...+0 等差數(shù)列則可以這樣計算 LoopAdd(5, 10, -2)。在用匯編徹底重寫之前先采用前面 if/goto 類似的技術(shù)來改造 for 循環(huán)。

新的 LoopAdd 函數(shù)只有 if/goto 語句構(gòu)成:

func LoopAdd(cnt, v0, step int) int {
    var i = 0
    var result = 0

LOOP_BEGIN:
    result = v0

LOOP_IF:
    if i <cnt { goto LOOP_BODY}
    goto LOOP_END

LOOP_BODY
    i = i+1
    result = result + step
    goto LOOP_IF

LOOP_END:

    return result
}

函數(shù)的開頭先定義兩個局部變量便于后續(xù)代碼使用。然后將 for 語句的初始化、結(jié)束條件、迭代步長三個部分拆分為三個代碼段,分別用 LOOP_BEGIN、LOOP_IF、LOOP_BODY 三個標(biāo)號表示。其中 LOOP_BEGIN 循環(huán)初始化部分只會執(zhí)行一次,因此該標(biāo)號并不會被引用,可以省略。最后 LOOP_END 語句表示 for 循環(huán)的結(jié)束。四個標(biāo)號分隔出的三個代碼段分別對應(yīng) for 循環(huán)的初始化語句、循環(huán)條件和循環(huán)體,其中迭代語句被合并到循環(huán)體中了。

下面用匯編語言重新實現(xiàn) LoopAdd 函數(shù)

#include "textflag.h"

// func LoopAdd(cnt, v0, step int) int
TEXT ·LoopAdd(SB), NOSPLIT,  $0-32
    MOVQ cnt+0(FP), AX   // cnt
    MOVQ v0+8(FP), BX    // v0/result
    MOVQ step+16(FP), CX // step

LOOP_BEGIN:
    MOVQ $0, DX          // i

LOOP_IF:
    CMPQ DX, AX          // compare i, cnt
    JL   LOOP_BODY       // if i < cnt: goto LOOP_BODY
    JMP LOOP_END

LOOP_BODY:
    ADDQ $1, DX          // i++
    ADDQ CX, BX          // result += step
    JMP LOOP_IF

LOOP_END:

    MOVQ BX, ret+24(FP)  // return result
    RET

其中 v0 和 result 變量復(fù)用了一個 BX 寄存器。在 LOOP_BEGIN 標(biāo)號對應(yīng)的指令部分,用 MOVQ 將 DX 寄存器初始化為 0,DX 對應(yīng)變量 i,循環(huán)的迭代變量。在 LOOP_IF 標(biāo)號對應(yīng)的指令部分,使用 CMPQ 指令比較 DX 和 AX,如果循環(huán)沒有結(jié)束則跳轉(zhuǎn)到 LOOP_BODY 部分,否則跳轉(zhuǎn)到 LOOP_END 部分結(jié)束循環(huán)。在 LOOP_BODY 部分,更新迭代變量并且執(zhí)行循環(huán)體中的累加語句,然后直接跳轉(zhuǎn)到 LOOP_IF 部分進(jìn)入下一輪循環(huán)條件判斷。LOOP_END 標(biāo)號之后就是返回累加結(jié)果的語句。

循環(huán)是最復(fù)雜的控制流,循環(huán)中隱含了分支和跳轉(zhuǎn)語句。掌握了循環(huán)的寫法基本也就掌握了匯編語言的基礎(chǔ)寫法。更極客的玩法是通過匯編語言打破傳統(tǒng)的控制流,比如跨越多層函數(shù)直接返回,比如參考基因編輯的手段直接執(zhí)行一個從 C 語言構(gòu)建的代碼片段等??傊莆找?guī)律之后,你會發(fā)現(xiàn)其實匯編語言編程會變得異常簡單和有趣。



以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號