Go 語(yǔ)言 匯編語(yǔ)言的威力

2023-03-22 15:02 更新

原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch3-asm/ch3-07-hack-asm.html


3.7 匯編語(yǔ)言的威力

匯編語(yǔ)言的真正威力來(lái)自兩個(gè)維度:一是突破框架限制,實(shí)現(xiàn)看似不可能的任務(wù);二是突破指令限制,通過(guò)高級(jí)指令挖掘極致的性能。對(duì)于第一個(gè)問(wèn)題,我們將演示如何通過(guò) Go 匯編語(yǔ)言直接訪問(wèn)系統(tǒng)調(diào)用,和直接調(diào)用 C 語(yǔ)言函數(shù)。對(duì)于第二個(gè)問(wèn)題,我們將演示 X64 指令中 AVX 等高級(jí)指令的簡(jiǎn)單用法。

3.7.1 系統(tǒng)調(diào)用

系統(tǒng)調(diào)用是操作系統(tǒng)對(duì)外提供的公共接口。因?yàn)椴僮飨到y(tǒng)徹底接管了各種底層硬件設(shè)備,因此操作系統(tǒng)提供的系統(tǒng)調(diào)用成了實(shí)現(xiàn)某些操作的唯一方法。從另一個(gè)角度看,系統(tǒng)調(diào)用更像是一個(gè) RPC 遠(yuǎn)程過(guò)程調(diào)用,不過(guò)信道是寄存器和內(nèi)存。在系統(tǒng)調(diào)用時(shí),我們向操作系統(tǒng)發(fā)送調(diào)用的編號(hào)和對(duì)應(yīng)的參數(shù),然后阻塞等待系統(tǒng)調(diào)用地返回。因?yàn)樯婕暗阶枞却?,因此系統(tǒng)調(diào)用期間的 CPU 利用率一般是可以忽略的。另一個(gè)和 RPC 地遠(yuǎn)程調(diào)用類(lèi)似的地方是,操作系統(tǒng)內(nèi)核處理系統(tǒng)調(diào)用時(shí)不會(huì)依賴用戶的棧空間,一般不會(huì)導(dǎo)致爆棧發(fā)生。因此系統(tǒng)調(diào)用是最簡(jiǎn)單安全的一種調(diào)用了。

系統(tǒng)調(diào)用雖然簡(jiǎn)單,但是它是操作系統(tǒng)對(duì)外的接口,因此不同的操作系統(tǒng)調(diào)用規(guī)范可能有很大的差異。我們先看看 Linux 在 AMD64 架構(gòu)上的系統(tǒng)調(diào)用規(guī)范,在 syscall/asm_linux_amd64.s 文件中有注釋說(shuō)明:

//
// System calls for AMD64, Linux
//

// func Syscall(trap int64, a1, a2, a3 uintptr) (r1, r2, err uintptr);
// Trap # in AX, args in DI SI DX R10 R8 R9, return in AX DX
// Note that this differs from "standard" ABI convention, which
// would pass 4th arg in CX, not R10.

這是 syscall.Syscall 函數(shù)的內(nèi)部注釋?zhuān)?jiǎn)要說(shuō)明了 Linux 系統(tǒng)調(diào)用的規(guī)范。系統(tǒng)調(diào)用的前 6 個(gè)參數(shù)直接由 DI、SI、DX、R10、R8 和 R9 寄存器傳輸,結(jié)果由 AX 和 DX 寄存器返回。macOS 等類(lèi) UINX 系統(tǒng)調(diào)用的參數(shù)傳輸大多數(shù)都采用類(lèi)似的規(guī)則。

macOS 的系統(tǒng)調(diào)用編號(hào)在 /usr/include/sys/syscall.h 頭文件,Linux 的系統(tǒng)調(diào)用號(hào)在 /usr/include/asm/unistd.h 頭文件。雖然在 UNIX 家族中是系統(tǒng)調(diào)用的參數(shù)和返回值的傳輸規(guī)則類(lèi)似,但是不同操作系統(tǒng)提供的系統(tǒng)調(diào)用卻不是完全相同的,因此系統(tǒng)調(diào)用編號(hào)也有很大的差異。以 UNIX 系統(tǒng)中著名的 write 系統(tǒng)調(diào)用為例,在 macOS 的系統(tǒng)調(diào)用編號(hào)為 4,而在 Linux 的系統(tǒng)調(diào)用編號(hào)卻是 1。

我們將基于 write 系統(tǒng)調(diào)用包裝一個(gè)字符串輸出函數(shù)。下面的代碼是 macOS 版本:

// func SyscallWrite_Darwin(fd int, msg string) int
TEXT ·SyscallWrite_Darwin(SB), NOSPLIT, $0
    MOVQ $(0x2000000+4), AX // #define SYS_write 4
    MOVQ fd+0(FP),       DI
    MOVQ msg_data+8(FP), SI
    MOVQ msg_len+16(FP), DX
    SYSCALL
    MOVQ AX, ret+0(FP)
    RET

其中第一個(gè)參數(shù)是輸出文件的文件描述符編號(hào),第二個(gè)參數(shù)是字符串的頭部。字符串頭部是由 reflect.StringHeader 結(jié)構(gòu)定義,第一成員是 8 字節(jié)的數(shù)據(jù)指針,第二個(gè)成員是 8 字節(jié)的數(shù)據(jù)長(zhǎng)度。在 macOS 系統(tǒng)中,執(zhí)行系統(tǒng)調(diào)用時(shí)還需要將系統(tǒng)調(diào)用的編號(hào)加上 0x2000000 后再行傳入 AX。然后再將 fd、數(shù)據(jù)地址和長(zhǎng)度作為 write 系統(tǒng)調(diào)用的三個(gè)參數(shù)輸入,分別對(duì)應(yīng) DI、SI 和 DX 三個(gè)寄存器。最后通過(guò) SYSCALL 指令執(zhí)行系統(tǒng)調(diào)用,系統(tǒng)調(diào)用返回后從 AX 獲取返回值。

這樣我們就基于系統(tǒng)調(diào)用包裝了一個(gè)定制的輸出函數(shù)。在 UNIX 系統(tǒng)中,標(biāo)準(zhǔn)輸入 stdout 的文件描述符編號(hào)是 1,因此我們可以用 1 作為參數(shù)實(shí)現(xiàn)字符串的輸出:

func SyscallWrite_Darwin(fd int, msg string) int

func main() {
    if runtime.GOOS == "darwin" {
        SyscallWrite_Darwin(1, "hello syscall!\n")
    }
}

如果是 Linux 系統(tǒng),只需要將編號(hào)改為 write 系統(tǒng)調(diào)用對(duì)應(yīng)的 1 即可。而 Windows 的系統(tǒng)調(diào)用則有另外的參數(shù)傳輸規(guī)則。在 X64 環(huán)境 Windows 的系統(tǒng)調(diào)用參數(shù)傳輸規(guī)則和默認(rèn)的 C 語(yǔ)言規(guī)則非常相似,在后續(xù)的直接調(diào)用 C 函數(shù)部分再行討論。

3.7.2 直接調(diào)用 C 函數(shù)

在計(jì)算機(jī)的發(fā)展的過(guò)程中,C 語(yǔ)言和 UNIX 操作系統(tǒng)有著不可替代的作用。因此操作系統(tǒng)的系統(tǒng)調(diào)用、匯編語(yǔ)言和 C 語(yǔ)言函數(shù)調(diào)用規(guī)則幾個(gè)技術(shù)是密切相關(guān)的。

在 X86 的 32 位系統(tǒng)時(shí)代,C 語(yǔ)言一般默認(rèn)的是用棧傳遞參數(shù)并用 AX 寄存器返回結(jié)果,稱為 cdecl 調(diào)用約定。Go 語(yǔ)言函數(shù)和 cdecl 調(diào)用約定非常相似,它們都是以棧來(lái)傳遞參數(shù)并且返回地址和 BP 寄存器的布局都是類(lèi)似的。但是 Go 語(yǔ)言函數(shù)將返回值也通過(guò)棧返回,因此 Go 語(yǔ)言函數(shù)可以支持多個(gè)返回值。我們可以將 Go 語(yǔ)言函數(shù)看作是沒(méi)有返回值的 C 語(yǔ)言函數(shù),同時(shí)將 Go 語(yǔ)言函數(shù)中的返回值挪到 C 語(yǔ)言函數(shù)參數(shù)的尾部,這樣棧不僅僅用于傳入?yún)?shù)也用于返回多個(gè)結(jié)果。

在 X64 時(shí)代,AMD 架構(gòu)增加了 8 個(gè)通用寄存器,為了提高效率 C 語(yǔ)言也默認(rèn)改用寄存器來(lái)傳遞參數(shù)。在 X64 系統(tǒng),默認(rèn)有 System V AMD64 ABI 和 Microsoft x64 兩種 C 語(yǔ)言函數(shù)調(diào)用規(guī)范。其中 System V 的規(guī)范適用于 Linux、FreeBSD、macOS 等諸多類(lèi) UNIX 系統(tǒng),而 Windows 則是用自己特有的調(diào)用規(guī)范。

在理解了 C 語(yǔ)言函數(shù)的調(diào)用規(guī)范之后,匯編代碼就可以繞過(guò) CGO 技術(shù)直接調(diào)用 C 語(yǔ)言函數(shù)。為了便于演示,我們先用 C 語(yǔ)言構(gòu)造一個(gè)簡(jiǎn)單的加法函數(shù) myadd:

#include <stdint.h>

int64_t myadd(int64_t a, int64_t b) {
    return a+b;
}

然后我們需要實(shí)現(xiàn)一個(gè) asmCallCAdd 函數(shù):

func asmCallCAdd(cfun uintptr, a, b int64) int64

因?yàn)?Go 匯編語(yǔ)言和 CGO 特性不能同時(shí)在一個(gè)包中使用(因?yàn)?CGO 會(huì)調(diào)用 gcc,而 gcc 會(huì)將 Go 匯編語(yǔ)言當(dāng)做普通的匯編程序處理,從而導(dǎo)致錯(cuò)誤),我們通過(guò)一個(gè)參數(shù)傳入 C 語(yǔ)言 myadd 函數(shù)的地址。asmCallCAdd 函數(shù)的其余參數(shù)和 C 語(yǔ)言 myadd 函數(shù)的參數(shù)保持一致。

我們只實(shí)現(xiàn) System V AMD64 ABI 規(guī)范的版本。在 System V 版本中,寄存器可以最多傳遞六個(gè)參數(shù),分別對(duì)應(yīng) DI、SI、DX、CX、R8 和 R9 六個(gè)寄存器(如果是浮點(diǎn)數(shù)則需要通過(guò) XMM 寄存器傳送),返回值依然通過(guò) AX 返回。通過(guò)對(duì)比系統(tǒng)調(diào)用的規(guī)范可以發(fā)現(xiàn),系統(tǒng)調(diào)用的第四個(gè)參數(shù)是用 R10 寄存器傳遞,而 C 語(yǔ)言函數(shù)的第四個(gè)參數(shù)是用 CX 傳遞。

下面是 System V AMD64 ABI 規(guī)范的 asmCallCAdd 函數(shù)的實(shí)現(xiàn):

// System V AMD64 ABI
// func asmCallCAdd(cfun uintptr, a, b int64) int64
TEXT ·asmCallCAdd(SB), NOSPLIT, $0
    MOVQ cfun+0(FP), AX // cfun
    MOVQ a+8(FP),    DI // a
    MOVQ b+16(FP),   SI // b
    CALL AX
    MOVQ AX, ret+24(FP)
    RET

首先是將第一個(gè)參數(shù)表示的 C 函數(shù)地址保存到 AX 寄存器便于后續(xù)調(diào)用。然后分別將第二和第三個(gè)參數(shù)加載到 DI 和 SI 寄存器。然后 CALL 指令通過(guò) AX 中保持的 C 語(yǔ)言函數(shù)地址調(diào)用 C 函數(shù)。最后從 AX 寄存器獲取 C 函數(shù)的返回值,并通過(guò) asmCallCAdd 函數(shù)返回。

Win64 環(huán)境的 C 語(yǔ)言調(diào)用規(guī)范類(lèi)似。不過(guò) Win64 規(guī)范中只有 CX、DX、R8 和 R9 四個(gè)寄存器傳遞參數(shù)(如果是浮點(diǎn)數(shù)則需要通過(guò) XMM 寄存器傳送),返回值依然通過(guò) AX 返回。雖然是可以通過(guò)寄存器傳輸參數(shù),但是調(diào)用這依然要為前四個(gè)參數(shù)準(zhǔn)備??臻g。需要注意的是,Windows x64 的系統(tǒng)調(diào)用和 C 語(yǔ)言函數(shù)可能是采用相同的調(diào)用規(guī)則。因?yàn)闆](méi)有 Windows 測(cè)試環(huán)境,我們這里就不提供了 Windows 版本的代碼實(shí)現(xiàn)了,Windows 用戶可以自己嘗試實(shí)現(xiàn)類(lèi)似功能。

然后我們就可以使用 asmCallCAdd 函數(shù)直接調(diào)用 C 函數(shù)了:

/*
#include <stdint.h>

int64_t myadd(int64_t a, int64_t b) {
    return a+b;
}
*/
import "C"

import (
    asmpkg "path/to/asm"
)

func main() {
    if runtime.GOOS != "windows" {
        println(asmpkg.asmCallCAdd(
            uintptr(unsafe.Pointer(C.myadd)),
            123, 456,
        ))
    }
}

在上面的代碼中,通過(guò) C.myadd 獲取 C 函數(shù)的地址,然后轉(zhuǎn)換為合適的類(lèi)型再傳人 asmCallCAdd 函數(shù)。在這個(gè)例子中,匯編函數(shù)假設(shè)調(diào)用的 C 語(yǔ)言函數(shù)需要的棧很小,可以直接復(fù)用 Go 函數(shù)中多余的空間。如果 C 語(yǔ)言函數(shù)可能需要較大的棧,可以嘗試像 CGO 那樣切換到系統(tǒng)線程的棧上運(yùn)行。

3.7.3 AVX 指令

從 Go1.11 開(kāi)始,Go 匯編語(yǔ)言引入了 AVX512 指令的支持。AVX 指令集是屬于 Intel 家的 SIMD 指令集中的一部分。AVX512 的最大特點(diǎn)是數(shù)據(jù)有 512 位寬度,可以一次計(jì)算 8 個(gè) 64 位數(shù)或者是等大小的數(shù)據(jù)。因此 AVX 指令可以用于優(yōu)化矩陣或圖像等并行度很高的算法。不過(guò)并不是每個(gè) X86 體系的 CPU 都支持了 AVX 指令,因此首要的任務(wù)是如何判斷 CPU 支持了哪些高級(jí)指令。

在 Go 語(yǔ)言標(biāo)準(zhǔn)庫(kù)的 internal/cpu 包提供了 CPU 是否支持某些高級(jí)指令的基本信息,但是只有標(biāo)準(zhǔn)庫(kù)才能引用這個(gè)包(因?yàn)?internal 路徑的限制)。該包底層是通過(guò) X86 提供的 CPUID 指令來(lái)識(shí)別處理器的詳細(xì)信息。最簡(jiǎn)便的方法是直接將 internal/cpu 包克隆一份。不過(guò)這個(gè)包為了避免復(fù)雜的依賴沒(méi)有使用 init 函數(shù)自動(dòng)初始化,因此需要根據(jù)情況手工調(diào)整代碼執(zhí)行 doinit 函數(shù)初始化。

internal/cpu 包針對(duì) X86 處理器提供了以下特性檢測(cè):

package cpu

var X86 x86

// The booleans in x86 contain the correspondingly named cpuid feature bit.
// HasAVX and HasAVX2 are only set if the OS does support XMM and YMM registers
// in addition to the cpuid feature bit being set.
// The struct is padded to avoid false sharing.
type x86 struct {
    HasAES       bool
    HasADX       bool
    HasAVX       bool
    HasAVX2      bool
    HasBMI1      bool
    HasBMI2      bool
    HasERMS      bool
    HasFMA       bool
    HasOSXSAVE   bool
    HasPCLMULQDQ bool
    HasPOPCNT    bool
    HasSSE2      bool
    HasSSE3      bool
    HasSSSE3     bool
    HasSSE41     bool
    HasSSE42     bool
}

因此我們可以用以下的代碼測(cè)試運(yùn)行時(shí)的 CPU 是否支持 AVX2 指令集:

import (
    cpu "path/to/cpu"
)

func main() {
    if cpu.X86.HasAVX2 {
        // support AVX2
    }
}

AVX512 是比較新的指令集,只有高端的 CPU 才會(huì)提供支持。為了主流的 CPU 也能運(yùn)行代碼測(cè)試,我們選擇 AVX2 指令來(lái)構(gòu)造例子。AVX2 指令每次可以處理 32 字節(jié)的數(shù)據(jù),可以用來(lái)提升數(shù)據(jù)復(fù)制的工作的效率。

下面的例子是用 AVX2 指令復(fù)制數(shù)據(jù),每次復(fù)制數(shù)據(jù) 32 字節(jié)倍數(shù)大小的數(shù)據(jù):

// func CopySlice_AVX2(dst, src []byte, len int)
TEXT ·CopySlice_AVX2(SB), NOSPLIT, $0
    MOVQ dst_data+0(FP),  DI
    MOVQ src_data+24(FP), SI
    MOVQ len+32(FP),      BX
    MOVQ $0,              AX

LOOP:
    VMOVDQU 0(SI)(AX*1), Y0
    VMOVDQU Y0, 0(DI)(AX*1)
    ADDQ $32, AX
    CMPQ AX, BX
    JL   LOOP
    RET

其中 VMOVDQU 指令先將 0(SI)(AX*1) 地址開(kāi)始的 32 字節(jié)數(shù)據(jù)復(fù)制到 Y0 寄存器中,然后再?gòu)?fù)制到 0(DI)(AX*1) 對(duì)應(yīng)的目標(biāo)內(nèi)存中。VMOVDQU 指令操作的數(shù)據(jù)地址可以不用對(duì)齊。

AVX2 共有 16 個(gè) Y 寄存器,每個(gè)寄存器有 256bit 位。如果要復(fù)制的數(shù)據(jù)很多,可以多個(gè)寄存器同時(shí)復(fù)制,這樣可以利用更高效的流水特性優(yōu)化性能。



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

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)