原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch3-asm/ch3-07-hack-asm.html
匯編語言的真正威力來自兩個維度:一是突破框架限制,實現(xiàn)看似不可能的任務(wù);二是突破指令限制,通過高級指令挖掘極致的性能。對于第一個問題,我們將演示如何通過 Go 匯編語言直接訪問系統(tǒng)調(diào)用,和直接調(diào)用 C 語言函數(shù)。對于第二個問題,我們將演示 X64 指令中 AVX 等高級指令的簡單用法。
系統(tǒng)調(diào)用是操作系統(tǒng)對外提供的公共接口。因為操作系統(tǒng)徹底接管了各種底層硬件設(shè)備,因此操作系統(tǒng)提供的系統(tǒng)調(diào)用成了實現(xiàn)某些操作的唯一方法。從另一個角度看,系統(tǒng)調(diào)用更像是一個 RPC 遠程過程調(diào)用,不過信道是寄存器和內(nèi)存。在系統(tǒng)調(diào)用時,我們向操作系統(tǒng)發(fā)送調(diào)用的編號和對應(yīng)的參數(shù),然后阻塞等待系統(tǒng)調(diào)用地返回。因為涉及到阻塞等待,因此系統(tǒng)調(diào)用期間的 CPU 利用率一般是可以忽略的。另一個和 RPC 地遠程調(diào)用類似的地方是,操作系統(tǒng)內(nèi)核處理系統(tǒng)調(diào)用時不會依賴用戶的棧空間,一般不會導(dǎo)致爆棧發(fā)生。因此系統(tǒng)調(diào)用是最簡單安全的一種調(diào)用了。
系統(tǒng)調(diào)用雖然簡單,但是它是操作系統(tǒng)對外的接口,因此不同的操作系統(tǒng)調(diào)用規(guī)范可能有很大的差異。我們先看看 Linux 在 AMD64 架構(gòu)上的系統(tǒng)調(diào)用規(guī)范,在 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
函數(shù)的內(nèi)部注釋,簡要說明了 Linux 系統(tǒng)調(diào)用的規(guī)范。系統(tǒng)調(diào)用的前 6 個參數(shù)直接由 DI、SI、DX、R10、R8 和 R9 寄存器傳輸,結(jié)果由 AX 和 DX 寄存器返回。macOS 等類 UINX 系統(tǒng)調(diào)用的參數(shù)傳輸大多數(shù)都采用類似的規(guī)則。
macOS 的系統(tǒng)調(diào)用編號在 /usr/include/sys/syscall.h
頭文件,Linux 的系統(tǒng)調(diào)用號在 /usr/include/asm/unistd.h
頭文件。雖然在 UNIX 家族中是系統(tǒng)調(diào)用的參數(shù)和返回值的傳輸規(guī)則類似,但是不同操作系統(tǒng)提供的系統(tǒng)調(diào)用卻不是完全相同的,因此系統(tǒng)調(diào)用編號也有很大的差異。以 UNIX 系統(tǒng)中著名的 write 系統(tǒng)調(diào)用為例,在 macOS 的系統(tǒng)調(diào)用編號為
4,而在 Linux 的系統(tǒng)調(diào)用編號卻是 1。
我們將基于 write 系統(tǒng)調(diào)用包裝一個字符串輸出函數(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
其中第一個參數(shù)是輸出文件的文件描述符編號,第二個參數(shù)是字符串的頭部。字符串頭部是由 reflect.StringHeader 結(jié)構(gòu)定義,第一成員是 8 字節(jié)的數(shù)據(jù)指針,第二個成員是 8 字節(jié)的數(shù)據(jù)長度。在 macOS 系統(tǒng)中,執(zhí)行系統(tǒng)調(diào)用時還需要將系統(tǒng)調(diào)用的編號加上 0x2000000 后再行傳入 AX。然后再將 fd、數(shù)據(jù)地址和長度作為 write 系統(tǒng)調(diào)用的三個參數(shù)輸入,分別對應(yīng) DI、SI 和 DX 三個寄存器。最后通過 SYSCALL 指令執(zhí)行系統(tǒng)調(diào)用,系統(tǒng)調(diào)用返回后從 AX 獲取返回值。
這樣我們就基于系統(tǒng)調(diào)用包裝了一個定制的輸出函數(shù)。在 UNIX 系統(tǒng)中,標(biāo)準(zhǔn)輸入 stdout 的文件描述符編號是 1,因此我們可以用 1 作為參數(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),只需要將編號改為 write 系統(tǒng)調(diào)用對應(yīng)的 1 即可。而 Windows 的系統(tǒng)調(diào)用則有另外的參數(shù)傳輸規(guī)則。在 X64 環(huán)境 Windows 的系統(tǒng)調(diào)用參數(shù)傳輸規(guī)則和默認的 C 語言規(guī)則非常相似,在后續(xù)的直接調(diào)用 C 函數(shù)部分再行討論。
在計算機的發(fā)展的過程中,C 語言和 UNIX 操作系統(tǒng)有著不可替代的作用。因此操作系統(tǒng)的系統(tǒng)調(diào)用、匯編語言和 C 語言函數(shù)調(diào)用規(guī)則幾個技術(shù)是密切相關(guān)的。
在 X86 的 32 位系統(tǒng)時代,C 語言一般默認的是用棧傳遞參數(shù)并用 AX 寄存器返回結(jié)果,稱為 cdecl 調(diào)用約定。Go 語言函數(shù)和 cdecl 調(diào)用約定非常相似,它們都是以棧來傳遞參數(shù)并且返回地址和 BP 寄存器的布局都是類似的。但是 Go 語言函數(shù)將返回值也通過棧返回,因此 Go 語言函數(shù)可以支持多個返回值。我們可以將 Go 語言函數(shù)看作是沒有返回值的 C 語言函數(shù),同時將 Go 語言函數(shù)中的返回值挪到 C 語言函數(shù)參數(shù)的尾部,這樣棧不僅僅用于傳入?yún)?shù)也用于返回多個結(jié)果。
在 X64 時代,AMD 架構(gòu)增加了 8 個通用寄存器,為了提高效率 C 語言也默認改用寄存器來傳遞參數(shù)。在 X64 系統(tǒng),默認有 System V AMD64 ABI 和 Microsoft x64 兩種 C 語言函數(shù)調(diào)用規(guī)范。其中 System V 的規(guī)范適用于 Linux、FreeBSD、macOS 等諸多類 UNIX 系統(tǒng),而 Windows 則是用自己特有的調(diào)用規(guī)范。
在理解了 C 語言函數(shù)的調(diào)用規(guī)范之后,匯編代碼就可以繞過 CGO 技術(shù)直接調(diào)用 C 語言函數(shù)。為了便于演示,我們先用 C 語言構(gòu)造一個簡單的加法函數(shù) myadd:
#include <stdint.h>
int64_t myadd(int64_t a, int64_t b) {
return a+b;
}
然后我們需要實現(xiàn)一個 asmCallCAdd 函數(shù):
func asmCallCAdd(cfun uintptr, a, b int64) int64
因為 Go 匯編語言和 CGO 特性不能同時在一個包中使用(因為 CGO 會調(diào)用 gcc,而 gcc 會將 Go 匯編語言當(dāng)做普通的匯編程序處理,從而導(dǎo)致錯誤),我們通過一個參數(shù)傳入 C 語言 myadd 函數(shù)的地址。asmCallCAdd 函數(shù)的其余參數(shù)和 C 語言 myadd 函數(shù)的參數(shù)保持一致。
我們只實現(xiàn) System V AMD64 ABI 規(guī)范的版本。在 System V 版本中,寄存器可以最多傳遞六個參數(shù),分別對應(yīng) DI、SI、DX、CX、R8 和 R9 六個寄存器(如果是浮點數(shù)則需要通過 XMM 寄存器傳送),返回值依然通過 AX 返回。通過對比系統(tǒng)調(diào)用的規(guī)范可以發(fā)現(xiàn),系統(tǒng)調(diào)用的第四個參數(shù)是用 R10 寄存器傳遞,而 C 語言函數(shù)的第四個參數(shù)是用 CX 傳遞。
下面是 System V AMD64 ABI 規(guī)范的 asmCallCAdd 函數(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
首先是將第一個參數(shù)表示的 C 函數(shù)地址保存到 AX 寄存器便于后續(xù)調(diào)用。然后分別將第二和第三個參數(shù)加載到 DI 和 SI 寄存器。然后 CALL 指令通過 AX 中保持的 C 語言函數(shù)地址調(diào)用 C 函數(shù)。最后從 AX 寄存器獲取 C 函數(shù)的返回值,并通過 asmCallCAdd 函數(shù)返回。
Win64 環(huán)境的 C 語言調(diào)用規(guī)范類似。不過 Win64 規(guī)范中只有 CX、DX、R8 和 R9 四個寄存器傳遞參數(shù)(如果是浮點數(shù)則需要通過 XMM 寄存器傳送),返回值依然通過 AX 返回。雖然是可以通過寄存器傳輸參數(shù),但是調(diào)用這依然要為前四個參數(shù)準(zhǔn)備??臻g。需要注意的是,Windows x64 的系統(tǒng)調(diào)用和 C 語言函數(shù)可能是采用相同的調(diào)用規(guī)則。因為沒有 Windows 測試環(huán)境,我們這里就不提供了 Windows 版本的代碼實現(xiàn)了,Windows 用戶可以自己嘗試實現(xiàn)類似功能。
然后我們就可以使用 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,
))
}
}
在上面的代碼中,通過 C.myadd
獲取 C 函數(shù)的地址,然后轉(zhuǎn)換為合適的類型再傳人 asmCallCAdd 函數(shù)。在這個例子中,匯編函數(shù)假設(shè)調(diào)用的 C 語言函數(shù)需要的棧很小,可以直接復(fù)用 Go 函數(shù)中多余的空間。如果 C 語言函數(shù)可能需要較大的棧,可以嘗試像 CGO 那樣切換到系統(tǒng)線程的棧上運行。
從 Go1.11 開始,Go 匯編語言引入了 AVX512 指令的支持。AVX 指令集是屬于 Intel 家的 SIMD 指令集中的一部分。AVX512 的最大特點是數(shù)據(jù)有 512 位寬度,可以一次計算 8 個 64 位數(shù)或者是等大小的數(shù)據(jù)。因此 AVX 指令可以用于優(yōu)化矩陣或圖像等并行度很高的算法。不過并不是每個 X86 體系的 CPU 都支持了 AVX 指令,因此首要的任務(wù)是如何判斷 CPU 支持了哪些高級指令。
在 Go 語言標(biāo)準(zhǔn)庫的 internal/cpu
包提供了 CPU 是否支持某些高級指令的基本信息,但是只有標(biāo)準(zhǔn)庫才能引用這個包(因為 internal 路徑的限制)。該包底層是通過 X86 提供的 CPUID 指令來識別處理器的詳細信息。最簡便的方法是直接將 internal/cpu
包克隆一份。不過這個包為了避免復(fù)雜的依賴沒有使用 init 函數(shù)自動初始化,因此需要根據(jù)情況手工調(diào)整代碼執(zhí)行 doinit
函數(shù)初始化。
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 指令來構(gòu)造例子。AVX2 指令每次可以處理 32 字節(jié)的數(shù)據(jù),可以用來提升數(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)
地址開始的 32 字節(jié)數(shù)據(jù)復(fù)制到 Y0 寄存器中,然后再復(fù)制到 0(DI)(AX*1)
對應(yīng)的目標(biāo)內(nèi)存中。VMOVDQU 指令操作的數(shù)據(jù)地址可以不用對齊。
AVX2 共有 16 個 Y 寄存器,每個寄存器有 256bit 位。如果要復(fù)制的數(shù)據(jù)很多,可以多個寄存器同時復(fù)制,這樣可以利用更高效的流水特性優(yōu)化性能。
![]() | ![]() |
更多建議: