GO匯編-匯編語言的為例


匯編語言的為例

匯編語言的真正威力來自兩個維度:一是突破框架限制,實現看似不可能的任務;二是突破指令限制,通過高級指令挖掘極致的性能。對於第一個問題,我們將演示如何通過Go匯編語言直接訪問系統調用,和直接調用C語言函數。對於第二個問題,我們將演示X64指令中AVX等高級指令的簡單用法。

系統調用

系統調用是操作系統為外提供的公共接口。因為操作系統徹底接管了各種底層硬件設備,因此操作系統提供的系統調用成了實現某些操作的唯一方法。從另一個角度看,系統調用更像是一個RPC遠程過程調用,不過信道是寄存器和內存。在系統調用時,我們向操作系統發送調用的編號和對應的參數,然后阻塞等待系統調用地返回。因為涉及到阻塞等待,因此系統調用期間的CPU利用率一般是可以忽略的。另一個和RPC地遠程調用類似的地方是,操作系統內核處理系統調用時不會依賴用戶的棧空間,一般不會導致爆棧發生。因此系統調用是最簡單安全的一種調用了。

系統調用雖然簡單,但是它是操作系統對外的接口,因此不同的操作系統調用規范可能有很大地差異。我們先看看Linux在AMD64架構上的系統調用規范,在syscall/asm_linux_amd64.s文件中有注釋說明:

//
// 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函數的內部注釋,簡要說明了Linux系統調用的規范。系統調用的前6個參數直接由DI、SI、DX、R10、R8和R9寄存器傳輸,結果由AX和DX寄存器返回。macOS等類UINX系統調用的參數傳輸大多數都采用類似的規則。

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

我們將基於write系統調用包裝一個字符串輸出函數。下面的代碼是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

其中第一個參數是輸出文件的文件描述符編號,第二個參數是字符串的頭部。字符串頭部是由reflect.StringHeader結構定義,第一成員是8字節的數據指針,第二個成員是8字節的數據長度。在macOS系統中,執行系統調用時還需要將系統調用的編號加上0x2000000后再行傳入AX。然后再將fd、數據地址和長度作為write系統調用的三個參數輸入,分別對應DI、SI和DX三個寄存器。最后通過SYSCALL指令執行系統調用,系統調用返回后從AX獲取返回值。

這樣我們就基於系統調用包裝了一個定制的輸出函數。在UNIX系統中,標准輸入stdout的文件描述符編號是1,因此我們可以用1作為參數實現字符串的輸出:

func SyscallWrite_Darwin(fd int, msg string) int

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

如果是Linux系統,只需要將編號改為write系統調用對應的1即可。而Windows的系統調用則有另外的參數傳輸規則。在X64環境Windows的系統調用參數傳輸規則和默認的C語言規則非常相似,在后續的直接調用C函數部分再行討論。

直接調用C函數

在計算機的發展的過程中,C語言和UNIX操作系統有着不可替代的作用。因此操作系統的系統調用、匯編語言和C語言函數調用規則幾個技術是密切相關的。

在X86的32位系統時代,C語言一般默認的是用棧傳遞參數並用AX寄存器返回結果,稱為cdecl調用約定。Go語言函數和cdecl調用約定非常相似,它們都是以棧來傳遞參數並且返回地址和BP寄存器的布局都是類似的。但是Go語言函數將返回值也通過棧返回,因此Go語言函數可以支持多個返回值。我們可以將Go語言函數看作是沒有返回值的C語言函數,同時將Go語言函數中的返回值挪到C語言函數參數的尾部,這樣棧不僅僅用於傳入參數也用於返回多個結果。

在X64時代,AMD架構增加了8個通用寄存器,為了提高效率C語言也默認改用寄存器來傳遞參數。在X64系統,默認有System V AMD64 ABI和Microsoft x64兩種C語言函數調用規范。其中System V的規范適用於Linux、FreeBSD、macOS等諸多類UNIX系統,而Windows則是用自己特有的調用規范。

在理解了C語言函數的調用規范之后,匯編代碼就可以繞過CGO技術直接調用C語言函數。為了便於演示,我們先用C語言構造一個簡單的加法函數myadd:

#include <stdint.h>

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

然后我們需要實現一個asmCallCAdd函數:

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

因為Go匯編語言和CGO特性不能同時在一個包中使用(因為CGO會調用gcc,而gcc會將Go匯編語言當做普通的匯編程序處理,從而導致錯誤),我們通過一個參數傳入C語言myadd函數的地址。asmCallCAdd函數的其余參數和C語言myadd函數的參數保持一致。

我們只實現System V AMD64 ABI規范的版本。在System V版本中,寄存器可以最多傳遞六個參數,分別對應DI、SI、DX、CX、R8和R9六個寄存器(如果是浮點數則需要通過XMM寄存器傳送),返回值依然通過AX返回。通過對比系統調用的規范可以發現,系統調用的第四個參數是用R10寄存器傳遞,而C語言函數的第四個參數是用CX傳遞。

下面是System V AMD64 ABI規范的asmCallCAdd函數的實現:

// 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

首先是將第一個參數表示的C函數地址保存到AX寄存器便於后續調用。然后分別將第二和第三個參數加載到DI和SI寄存器。然后CALL指令通過AX中保持的C語言函數地址調用C函數。最后從AX寄存器獲取C函數的返回值,並通過asmCallCAdd函數返回。

Win64環境的C語言調用規范類似。不過Win64規范中只有CX、DX、R8和R9四個寄存器傳遞參數(如果是浮點數則需要通過XMM寄存器傳送),返回值依然通過AX返回。雖然是可以通過寄存器傳輸參數,但是調用這依然要為前四個參數准備棧空間。需要注意的是,Windows x64的系統調用和C語言函數可能是采用相同的調用規則。因為沒有Windows測試環境,我們這里就不提供了Windows版本的代碼實現了,Windows用戶可以自己嘗試實現類似功能。

然后我們就可以使用asmCallCAdd函數直接調用C函數了:

/*
#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,
        ))
    }
}

在上面的代碼中,通過C.myadd獲取C函數的地址,然后轉換為合適的類型再傳人asmCallCAdd函數。在這個例子中,匯編函數假設調用的C語言函數需要的棧很小,可以直接復用Go函數中多余的空間。如果C語言函數可能需要較大的棧,可以嘗試像CGO那樣切換到系統線程的棧上運行。

AVX指令

從Go1.11開始,Go匯編語言引入了AVX512指令的支持。AVX指令集是屬於Intel家的SIMD指令集中的一部分。AVX512的最大特點是數據有512位寬度,可以一次計算8個64位數或者是等大小的數據。因此AVX指令可以用於優化矩陣或圖像等並行度很高的算法。不過並不是每個X86體系的CPU都支持了AVX指令,因此首要的任務是如何判斷CPU支持了哪些高級指令。

在Go語言標准庫的internal/cpu包提供了CPU是否支持某些高級指令的基本信息,但是只有標准庫才能引用這個包(因為internal路徑的限制)。該包底層是通過X86提供的CPUID指令來識別處理器的詳細信息。最簡便的方法是直接將internal/cpu包克隆一份。不過這個包為了避免復雜的依賴沒有使用init函數自動初始化,因此需要根據情況手工調整代碼執行doinit函數初始化。

internal/cpu包針對X86處理器提供了以下特性檢測:

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
}

因此我們可以用以下的代碼測試運行時的CPU是否支持AVX2指令集:

import (
    cpu "path/to/cpu"
)

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

AVX512是比較新的指令集,只有高端的CPU才會提供支持。為了主流的CPU也能運行代碼測試,我們選擇AVX2指令來構造例子。AVX2指令每次可以處理32字節的數據,可以用來提升數據復制的工作的效率。

下面的例子是用AVX2指令復制數據,每次復制數據32字節倍數大小的數據:

// 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)地址開始的32字節數據復制到Y0寄存器中,然后再復制到0(DI)(AX*1)對應的目標內存中。VMOVDQU指令操作的數據地址可以不用對齊。

AVX2共有16個Y寄存器,每個寄存器有256bit位。如果要復制的數據很多,可以多個寄存器同時復制,這樣可以利用更高效的流水特性優化性能。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM