在某些场景下,我们需要进行一些特殊优化,因此我们可能需要用到golang汇编,golang汇编源于plan9,此方面的 介绍很多,就不进行展开了。我们WHY和HOW开始讲起。

golang汇编相关的内容还是很少的,而且多数都语焉不详,而且缺乏细节。对于之前没有汇编经验的人来说,是很难 理解的。而且很多资料都过时了,包括官方文档的一些细节也未及时更新。因此需要掌握该知识的人需要仔细揣摩, 反复实验。

WHY

我们为什么需要用到golang的汇编,基本出于以下场景。

HOW

使用到golang会汇编时,golang的对象类型、buildin对象、语法糖还有一些特殊机制就都不见了,全部底层实现 暴露在我们面前,就像你拆开一台电脑,暴露在你面前的是一堆PCB、电阻、电容等元器件。因此我们必须掌握一些 go ABI的机制才能进行golang汇编编程。

go汇编简介

这部分内容可以参考:

寄存器

go 汇编中有4个核心的伪寄存器,这4个寄存器是编译器用来维护上下文、特殊标识等作用的:

  • FP(Frame pointer): arguments and locals
  • PC(Program counter): jumps and branches
  • SB(Static base pointer): global symbols
  • SP(Stack pointer): top of stack
SBFPSBFP
SB
SB
TEXT runtime·_divu(SB), NOSPLIT, $16-0TEXT·SBruntime._divuSBGLOBL fast_udiv_tab<>(SB), RODATA, $64GLOBLfast_udiv_tabSBfast_udiv_tabSBCALL runtime·callbackasm1(SB)CALLruntime·callbackasm1SBruntime·callbackasm1MOVW $fast_udiv_tab<>-64(SB), RMfast_udiv_tab
FB
FPsymbol+offset(FP)arg0+0(FP)arg1+8(FP)arg0arg1MOVQ arg+8(FP), AXMOVQAXAXFParg+FPFPfirst_arg+0(FP)FPfirst_argfirst_argfirst_arg
PC
pcipripPC

images/posts/go/go-asm-pc-jump.png

SP
SPsymbol+offset(SP)[-framesize, 0)localvar0-8(SP)
SPSPsymbolSPSPSPSP

但是:

go tool compile -S / go tool objdumpSPSP

我们这里对容易混淆的几点简单进行说明:

SPSPSPSPSPsymbolsymbolSPSPFPSPFPSPgo tool objdump/go tool compile -SSPFPSPSPSPFPframepointerframepointerSP
FPSPFPSPSPSPFPSP
// func checking()(before uintptr, after uintptr)
TEXT ·checking(SB),$4112-16
        LEAQ x-0(SP), DI         //
        MOVQ DI, before+0(FP)    // 将原伪寄存器SP偏移量存入返回值before

        MOVQ    SP, BP           // 存储物理SP偏移量到BP寄存器
        ADDQ    $4096, SP        // 将物理SP偏移增加4K

        LEAQ x-0(SP), SI
        MOVQ    BP, SP           // 恢复物理SP,因为修改物理SP后,伪寄存器FP/SP随之改变,
                                 // 为了正确访问FP,先恢复物理SP
        MOVQ SI, cpu+8(FP)       // 将偏移后的伪寄存器SP偏移量存入返回值after
        RET
                                 // 从输出的after-before来看,正好相差4K
通用寄存器
rax, rbx, rcx, rdx, rdi, rsi, r8~r15rbprspbpspreraxAX

下面是通用通用寄存器的名字在 IA64 和 plan9 中的对应关系:

X86_64raxrbxrcxrdxrdirsirbprspr8r9r10r11r12r13r14rip
Plan9 AX BX CX DX DI SI BP SP R8 R9 R10 R11 R12 R13 R14 PC

控制流

goto
next:
  MOVW $0, R1
  JMP  next

指令

使用汇编就意味着丧失了跨平台的特性。因此使用对应平台的汇编指令。这个需要自行去了解,也可以参考GoFunctionsInAssembly 其中有各个平台汇编指令速览和对照。

文件命名

使用到汇编时,即表明了所写的代码不能够跨平台使用,因此需要针对不同的平台使用不同的汇编 代码。go编译器采用文件名中加入平台名后缀进行区分。

sqrt_386.s sqrt_amd64p32.s sqrt_amd64.s sqrt_arm.s
+build tag

函数声明

首先我们先需要对go汇编代码有一个抽象的认识,因此我们可以先看一段go汇编代码:

TEXT runtime·profileloop(SB),NOSPLIT,$8-16
  MOVQ    $runtime·profileloop1(SB), CX
  MOVQ    CX, 0(SP)
  CALL    runtime·externalthreadhandler(SB)
  RET
profileloopTEXT${package}·${function}${package}·${function}·shift+option+9$8CALLER BP$16NOSPLIT

/images/posts/go/go-asm-declare.png

那我们再看一个函数:

TEXT ·add(SB),$0-24
  MOVQ x+0(FP), BX
  MOVQ y+8(FP), BP
  ADDQ BP, BX
  MOVQ BX, ret+16(FP)
  RET

该函数等同于:

$

全局变量声明

<>DATA symbol+offset(SB)/width, value
DATA divtab<>+0x00(SB)/4, $0xf4f8fcff  // divtab的前4个byte为0xf4f8fcff
DATA divtab<>+0x04(SB)/4, $0xe6eaedf0  // divtab的4-7个byte为0xe6eaedf0
...
DATA divtab<>+0x3c(SB)/4, $0x81828384  // divtab的最后4个byte为0x81828384
GLOBL divtab<>(SB), RODATA, $64        // 全局变量名声明,以及数据所在的段"RODATA",数据的长度64byte
RODATANOPTR
  • NOPROF = 1 (For TEXT items.) Don’t profile the marked function. This flag is deprecated.
  • DUPOK = 2 It is legal to have multiple instances of this symbol in a single binary. The linker will choose one of the duplicates to use.
  • NOSPLIT = 4 (For TEXT items.) Don’t insert the preamble to check if the stack must be split. The frame for the routine, plus anything it calls, must fit in the spare space at the top of the stack segment. Used to protect routines such as the stack splitting code itself.
  • RODATA = 8 (For DATA and GLOBL items.) Put this data in a read-only section.
  • NOPTR = 16 (For DATA and GLOBL items.) This data contains no pointers and therefore does not need to be scanned by the garbage collector.
  • WRAPPER = 32 (For TEXT items.) This is a wrapper function and should not count as disabling recover.
  • NEEDCTXT = 64 (For TEXT items.) This function is a closure so it uses its incoming context register.

局部变量声明

局部变量存储在函数栈上,因此不需要额外进行声明,在函数栈上预留出空间,使用命令操作这些内存即可。因此这些 局部变量没有标识,操作时,牢记局部变量的分布、内存偏移即可。

#define get_tls(r) MOVQ TLS, r#include "textflag.h"
structgo_asm.h#include "go_asm.h"struct

会生成宏定义:

在汇编代码中,我们就可以直接使用这些宏:

MOVQ vdsoVersionKey_version(DX) AX
MOVQ (vdsoVersionKey_version+vdsoVersionKey_verHash)(DX) AX
runtime
    MOVQ    DX, m_vdsoPC(BX)
    LEAQ    ret+0(SP), DX
    MOVQ    DX, m_vdsoSP(BX)
go tool compile -S -asmhdr dump.h *.go

地址运算

Load Effective Address8LEAQ
LEAQ (BX)(AX*8), CX
// 上面代码中的 8 代表 scale
// scale 只能是 0、2、4、8
// 如果写成其它值:
// LEAQ (BX)(AX*3), CX
// ./a.s:6: bad scale: 3
// 整个表达式含义是 CX = BX + (AX * 8)
// 如果要表示3倍的乘法可以表示为:
LEAQ (AX)(AX*2), CX // => CX = AX + (AX * 2) = AX * 3

// 用 LEAQ 的话,即使是两个寄存器值直接相加,也必须提供 scale
// 下面这样是不行的
// LEAQ (BX)(AX), CX
// asm: asmidx: bad address 0/2064/2067
// 正确的写法是
LEAQ (BX)(AX*1), CX


// 在寄存器运算的基础上,可以加上额外的 offset
LEAQ 16(BX)(AX*1), CX
// 整个表达式含义是 CX = 16 + BX + (AX * 8)

// 三个寄存器做运算,还是别想了
// LEAQ DX(BX)(AX*8), CX
// ./a.s:13: expected end of operand, found (
MOVQMOVQ

buildin类型

struct/slice/string/map/chan/interface{}
(u)int??/float??
uint32float64
int/unsafe.Pointer/unint
intint32uintptruint32unsafe.Pointer
intint64uintptruint64unsafe.Pointer
byteuint8runeint32
stringStringHeadersliceSliceHeader
map
maphmapunsafe.Pointer
chan
chanhchanunsafe.Pointer
interface{}
interface{}eface

go函数调用

通常函数会有输入输出,我们要进行编程就需要掌握其ABI,了解其如何传递输入参数、返回值、调用函数。

caller-savesizeof(uintptr)

我们将结合一些函数来进行说明:

无局部变量的函数

注意:其实go函数的栈布局在是否有局部变量时,是没有区别的。在没有局部变量时,只是少了局部变量那部分空间。在当时研究的时候,未能抽象其共同部分,导致拆成2部分写了。

FP

该函数有3个输入参数、3个返回值,假设我们使用x86_64平台,因此一个int占用8byte。则其函数栈空间为:

各个输入参数和返回值将以倒序的方式从高地址位分布于栈空间上,由于没有局部变量,则xxx的函数栈空间为 0,根据前面的描述,该函数应该为:

然后在一个go源文件(.go)中声明该函数即可

有局部变量的函数

当函数中有局部变量时,函数的栈空间就应该留出足够的空间:

当函数中有局部变量时,我们就需要移动函数栈帧来进行栈内存分配,因此我们就需要了解相关平台计算机体系 的一些设计问题,在此我们只讲解x86平台的相关要求,我们先需要参考:

BPBPBPCALLER BP
CALLER BPframesizeCALLER BPCALLER BP
func Framepointer_enabled(goos, goarch string) bool {
  return framepointer_enabled != 0 && goarch == "amd64" && goos != "nacl"
}

此处需要注意,go编译器会将函数栈空间自动加8,用于存储BP寄存器,跳过这8字节后才是函数栈上局部变量的内存。 逻辑上的FP/SP位置就是我们在写汇编代码时,计算偏移量时,FP/SP的基准位置,因此局部变量的内存在逻辑SP的低地 址侧,因此我们访问时,需要向负方向偏移。

SUBQLEAQ
函数返回地址CALLER BP

然后我们go源码中声明该函数:

汇编中调用其他函数

在汇编中调用其他函数通常可以使用2中方式:

JMPSPFPCALLCALLSPFPCALLCALLSPWORDreturn addr(函数返回地址)CALL
CALL

该函数使用汇编实现就是:

TEXT ·yyy0(SB), $48-48
   MOVQ a+0(FP), AX
   MOVQ AX, ia-48(SP)
   MOVQ b+8(FP), AX
   MOVQ AX, ib-40(SP)
   MOVQ c+16(FP), AX
   MOVQ AX, ic-32(SP)
   CALL ·zzz(SB)
   MOVQ z2-24(SP), AX
   MOVQ AX, r2+24(FP)
   MOVQ z1-16(SP), AX
   MOVQ AX, r1+32(FP)
   MOVQ z1-8(SP), AX
   MOVQ AX, r2+40(FP)
   RET
yyy0main
yyy0

其调用者和被调用者的栈关系为(该图来自plan9 assembly 完全解析):

zzzyyy
TEXT ·yyy(SB),NOSPLIT,$0-48
   MOVQ pc+0(SP),          AX            // 将PC寄存器中的值暂时保存在最后一个返回值的位置,因为在
                                         // 调用zzz时,该位置不会参与计算
   MOVQ AX,                ret_2+40(FP)  //
   MOVQ a+0(FP),           AX            // 将输入参数a,放置在栈顶
   MOVQ AX,                z_a+0(SP)     //
   MOVQ b+8(FP),           AX            // 将输入参数b,放置在栈顶+8
   MOVQ AX,                z_b+8(SP)     //
   MOVQ c+16(FP),          AX            // 将输入参数c,放置在栈顶+16
   MOVQ AX,                z_c+16(SP)    //
   CALL ·zzz(SB)                         // 调用函数zzz
   MOVQ ret_2+40(FP),      AX            // 将PC寄存器恢复
   MOVQ AX,                pc+0(SP)      //
   MOVQ z_ret_2+40(SP),    AX            // 将zzz的返回值[2]放置在yyy返回值[2]的位置
   MOVQ AX,                ret_2+40(FP)  //
   MOVQ z_ret_1+32(SP),    AX            // 将zzz的返回值[1]放置在yyy返回值[1]的位置
   MOVQ AX,                ret_1+32(FP)  //
   MOVQ z_ret_0+24(SP),    AX            // 将zzz的返回值[0]放置在yyy返回值[0]的位置
   MOVQ AX,                ret_0+24(FP)  //
   RET                                   // return

整个函数调用过程为:

然后我们可以使用反汇编来对比我们自己实现的汇编代码版本和go源码版本生成的汇编代码的区别:

我们自己汇编的版本:

TEXT main.yyy(SB) go/asm/xx.s
  xx.s:31               0x104f6b0               488b0424                MOVQ 0(SP), AX
  xx.s:32               0x104f6b4               4889442430              MOVQ AX, 0x30(SP)
  xx.s:33               0x104f6b9               488b442408              MOVQ 0x8(SP), AX
  xx.s:34               0x104f6be               48890424                MOVQ AX, 0(SP)
  xx.s:35               0x104f6c2               488b442410              MOVQ 0x10(SP), AX
  xx.s:36               0x104f6c7               4889442408              MOVQ AX, 0x8(SP)
  xx.s:37               0x104f6cc               488b442418              MOVQ 0x18(SP), AX
  xx.s:38               0x104f6d1               4889442410              MOVQ AX, 0x10(SP)
  xx.s:39               0x104f6d6               e865ffffff              CALL main.zzz(SB)
  xx.s:40               0x104f6db               488b442430              MOVQ 0x30(SP), AX
  xx.s:41               0x104f6e0               48890424                MOVQ AX, 0(SP)
  xx.s:42               0x104f6e4               488b442428              MOVQ 0x28(SP), AX
  xx.s:43               0x104f6e9               4889442430              MOVQ AX, 0x30(SP)
  xx.s:44               0x104f6ee               488b442420              MOVQ 0x20(SP), AX
  xx.s:45               0x104f6f3               4889442428              MOVQ AX, 0x28(SP)
  xx.s:46               0x104f6f8               488b442418              MOVQ 0x18(SP), AX
  xx.s:47               0x104f6fd               4889442420              MOVQ AX, 0x20(SP)
  xx.s:48               0x104f702               c3                      RET

go源码版本生成的汇编:

TEXT main.yyy(SB) go/asm/main.go
  main.go:20            0x104f360               4883ec50                        SUBQ $0x50, SP
  main.go:20            0x104f364               48896c2448                      MOVQ BP, 0x48(SP)
  main.go:20            0x104f369               488d6c2448                      LEAQ 0x48(SP), BP
  main.go:20            0x104f36e               48c744247000000000              MOVQ $0x0, 0x70(SP)
  main.go:20            0x104f377               48c744247800000000              MOVQ $0x0, 0x78(SP)
  main.go:20            0x104f380               48c784248000000000000000        MOVQ $0x0, 0x80(SP)
  main.go:20            0x104f38c               488b442458                      MOVQ 0x58(SP), AX
  main.go:21            0x104f391               48890424                        MOVQ AX, 0(SP)
  main.go:20            0x104f395               488b442460                      MOVQ 0x60(SP), AX
  main.go:21            0x104f39a               4889442408                      MOVQ AX, 0x8(SP)
  main.go:20            0x104f39f               488b442468                      MOVQ 0x68(SP), AX
  main.go:21            0x104f3a4               4889442410                      MOVQ AX, 0x10(SP)
  main.go:21            0x104f3a9               e892020000                      CALL main.zzz(SB)
  main.go:21            0x104f3ae               488b442418                      MOVQ 0x18(SP), AX
  main.go:21            0x104f3b3               4889442430                      MOVQ AX, 0x30(SP)
  main.go:21            0x104f3b8               0f10442420                      MOVUPS 0x20(SP), X0
  main.go:21            0x104f3bd               0f11442438                      MOVUPS X0, 0x38(SP)
  main.go:22            0x104f3c2               488b442430                      MOVQ 0x30(SP), AX
  main.go:22            0x104f3c7               4889442470                      MOVQ AX, 0x70(SP)
  main.go:22            0x104f3cc               0f10442438                      MOVUPS 0x38(SP), X0
  main.go:22            0x104f3d1               0f11442478                      MOVUPS X0, 0x78(SP)
  main.go:22            0x104f3d6               488b6c2448                      MOVQ 0x48(SP), BP
  main.go:22            0x104f3db               4883c450                        ADDQ $0x50, SP
  main.go:22            0x104f3df               c3                              RET

经过对比可以看出我们的优点:

  • 没有额外分配栈空间
  • 没有中间变量,减少了拷贝次数
  • 没有中间变量的初始化,节省操作

go源码版本的优点:

go benchmark

回调函数/闭包

callfn
caller-basecallcallee-call
func() { num+=5 }struct { F uintptr; a *int }CALLcallee-callcallee-callDXDXfunc() { num+=5 }AXCALL AXDX

下面结合几个例子来理解:

例一

call0
TEXT ·call0(SB), $16-0           # 分配栈空间16字节,8字节为call函数的入参,8字节为间接传参的'临时内存区域'
	LEAQ	·callback(SB), AX    # 取·callback函数地址存储于'临时内存区域'
	MOVQ	AX, fn-8(SP)         #
	LEAQ	fn-8(SP), AX         # 取'临时内存区域'地址存储于call入参位置
	MOVQ	AX, fn-16(SP)        #
	CALL	·call(SB)            # 调用call函数
	RET
go tool compile -l -N -Scall1
TEXT    "".call1(SB), ABIInternal, $16-0
    MOVQ    (TLS), CX
    CMPQ    SP, 16(CX)
    JLS     55
    SUBQ    $16, SP
    MOVQ    BP, 8(SP)
    LEAQ    8(SP), BP
    FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
    FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
    FUNCDATA        $3, gclocals·9fb7f0986f647f17cb53dda1484e0f7a(SB)
    PCDATA  $2, $1
    PCDATA  $0, $0                 # 以上是函数编译器生成的栈管理,不用理会
    LEAQ    "".callback·f(SB), AX  # 这部分,貌似没有分配'临时内存区域'进行中转,
    PCDATA  $2, $0                 # 而是直接将函数地址赋值给call的参数。然后按
    MOVQ    AX, (SP)               # 照这样写,会出现SIGBUS错误。对比之下,其猫
    CALL    "".call(SB)            # 腻可能出现在`callback·f`上,此处可能包含
    MOVQ    8(SP), BP              # 一些隐藏信息,因为手写汇编采用这种格式是会
    ADDQ    $16, SP                # 编译错误的。
    RET

例二

其生成的汇编为:

TEXT	testing.func1(SB), NOSPLIT|NEEDCTXT, $16-0  # NEEDCTXT标识闭包
	MOVQ	8(DX), AX                               # 从DX+8偏移出取出捕获参数n的指针
	INCQ	(AX)                                    # 对参数n指针指向的内存执行++操作,n++
	RET

TEXT	testing(SB), NOSPLIT, $56-0
	MOVQ	$0, n+16(SP)             # 初始化栈上临时变量n
	XORPS	X0, X0                   # 清空寄存器X0
	MOVUPS	X0, autotmp_2+32(SP)     # 用X0寄存器初始化栈上临时空间,该空间是分配给闭包的临时内存区域
	LEAQ	autotmp_2+32(SP), AX     # 取临时内存区域指针到AX
	MOVQ	AX, autotmp_3+24(SP)     # 不知道此步有何用意,liveness?
	TESTB	AL, (AX)
	LEAQ	testing.func1(SB), CX    # 将闭包函数指针存储于临时内存区域首部
	MOVQ	CX, autotmp_2+32(SP)
	TESTB	AL, (AX)
	LEAQ	n+16(SP), CX             # 将临时变量n的地址存储于临时内存区域尾部
	MOVQ	CX, autotmp_2+40(SP)
	MOVQ	AX, (SP)                 # 将临时内存区域地址赋值给call函数入参1
	MOVQ	$1, 8(SP)                # 将立即数1赋值给call函数入参2
	CALL	call(SB)                 # 调用call函数
	RET

# func call(fn func(), n int)
TEXT	call(SB), NOSPLIT, $8-16
	MOVQ	"".fn+16(SP), DX     # 取出临时区域的地址到DX
	MOVQ	(DX), AX             # 对首部解引用获取函数指针,存储到AX
	CALL	AX                   # 调用闭包函数
	RET

直接调用C函数(FFI)

CGO is not GoruntimevDSO

amd64 C ABI

在调用C函数时,主流有2种ABI:

Windows x64 C and C++ ABISystem V ABI
System V ABI
rdirsirdxrcxr8r9右向左

由于该issue的存在,通常goroutine的栈空间很小,很可能产生栈溢出的错误。解决的方法有:

编译/反编译

因为go汇编的资料很少,所以我们需要通过编译、反汇编来学习。

总结

了解go汇编并不是一定要去手写它,因为汇编总是不可移植和难懂的。但是它能够帮助我们了解go的一些底层机制, 了解计算机结构体系,同时我们需要做一些hack的事时可以用得到。

go:noescape

很多时候,我们可以使函数内计算过程使用栈上的空间做缓存,这样可以减少对内存的使用,并且是计算速度更快:

bufsync.Poolnoescapebuf
// asm_amd64.s
#include "textflag.h"

TEXT ·noescape(SB),NOSPLIT,$0-48
        MOVQ    d_base+0(FP),   AX
        MOVQ    AX,     b_base+24(FP)
        MOVQ    d_len+8(FP),    AX
        MOVQ    AX,     b_len+32(FP)
        MOVQ    d_cap+16(FP),AX
        MOVQ    AX,     b_cap+40(FP)
        RET

c2goasm

sse4_2/avx/avx2/avx-512
sse4_2/avx/avx2/avx-512clang/gcc
c2goasmsse4_2/avx/avx2/avx-512
c2goasm
clangclang_c.sc2goasmclang_c.sxxx.sc2goasmxxx.sxxx.goxxx.sclang_c.sfunc_xxxc2goasm__func_xxxxxx.go_func_xxx_func_xxxslicemapnamed return()Badly formatted return argument ....
cat /proc/cpuinfo |grep flags

然后,我们可以先使用C来实现这3个函数:

clang
clang0x80000000000000000x7FFFFFFFFFFFFFFFRODATAc2goasm
.LCPI1_0:
	.quad	-9223372036854775808    # 0x8000000000000000
	.section	.rodata,"a",@progbits
	.align	32
.LCPI1_1:
	.long	0                       # 0x0
	.long	2                       # 0x2
	.long	4                       # 0x4
	.long	6                       # 0x6
	.zero	4
	.zero	4
	.zero	4
	.zero	4
	.text
	.globl	sample_max_avx2

改为:

.LCPI1_0:
        .quad   -9223372036854775808    # 0x8000000000000000
        .quad   -9223372036854775808    # 0x8000000000000000
        .quad   -9223372036854775808    # 0x8000000000000000
        .quad   -9223372036854775808    # 0x8000000000000000
        .section        .rodata,"a",@progbits
.LCPI1_1:
        .long   0                       # 0x0
        .long   2                       # 0x2
        .long   4                       # 0x4
        .long   6                       # 0x6
        .zero   4
        .zero   4
        .zero   4
        .zero   4
        .text
        .globl  sample_max_avx2
        .align  16, 0x90
        .type   sample_max_avx2,@function

另一处同理,具体修改后的结果为:sample_avx2.s

sample_sse4_amd64.ssample_avx_amd64.ssample_avx2_amd64.ssample_sse4_amd64.gosample_avx_amd64.gosample_avx2_amd64.go
c2goasm

然后我们添加一段初始化逻辑,根据CPU支持的指令集来选择使用对应的实现:

go test

我们可以看出,sum方法得到10倍的提升,max/min得到了3倍多的提升,可能是因为max/min方法中每次循环中都有一次分支判断的原因,导致其提升效果不如sum方法那么多。

RDTSC精确计时

time.Now()runtimeruntime·cputicks
RDTSCRDTSC

参考