在某些场景下,我们需要进行一些特殊优化,因此我们可能需要用到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
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_64 | rax | rbx | rcx | rdx | rdi | rsi | rbp | rsp | r8 | r9 | r10 | r11 | r12 | r13 | r14 | rip |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
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
那我们再看一个函数:
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
参考