终于到函数了!因为Go汇编语言中,可以也建议通过Go语言来定义全局变量,那么剩下的也就是函数了。只有掌握了汇编函数的基本用法,才能真正算是Go汇编语言入门。本章将简单讨论Go汇编中函数的定义和用法。
基本语法
函数标识符通过TEXT
汇编指令定义,表示该行开始的指令定义在TEXT
内存段。TEXT
语句后的指令一般对应函数的实现,但是对于TEXT
指令本身来说并不关心后面是否有指令。因此TEXT
和LABEL
定义的符号是类似的,区别只是LABEL
是用于跳转标号,但是本质上他们都是通过标识符映射一个内存地址。
函数的定义的语法如下:
TEXT symbol(SB), [flags,] $framesize[-argsize]
函数的定义部分由5
个部分组成:TEXT指令
、函数名
、可选的flags标志
、函数帧大小
和可选的函数参数大小
。
其中TEXT
用于定义函数符号,函数名中当前包的路径可以省略。函数的名字后面是(SB),表示是函数名符号相对于SB伪寄存器
的偏移量,二者组合在一起最终是绝对地址。作为全局的标识符的全局变量和全局函数的名字一般都是基于SB伪寄存器的相对地址。标志部分用于指示函数的一些特殊行为,标志在textlags.h
文件中定义,常见的NOSPLIT
主要用于指示叶子函数不进行栈分裂。framesize
部分表示函数的局部变量需要多少栈空间,其中包含调用其它函数时准备调用参数的隐式栈空间。最后是可以省略的参数大小,之所以可以省略是因为编译器可以从Go语言的函数声明中推导出函数参数的大小。
我们首先从一个简单的Swap函数开始。Swap函数用于交互输入的两个参数的顺序,然后通过返回值返回交换了顺序的结果。如果用Go语言中声明Swap函数,大概这样的:
package main
//go:nosplit
func Swap(a, b int) (int, int)
下面是main包中Swap函数在汇编中两种定义方式:
// func Swap(a, b int) (int, int)
TEXT ·Swap(SB), NOSPLIT, $0-32
// func Swap(a, b int) (int, int)
TEXT ·Swap(SB), NOSPLIT, $0
下图是Swap函数几种不同写法的对比关系图:
第一种是最完整的写法:函数名部分包含了当前包的路径,同时指明了函数的参数大小为32个字节(对应参数和返回值的4个int类型)。第二种写法则比较简洁,省略了当前包的路径和参数的大小。如果有NOSPLIT标注,会禁止汇编器为汇编函数插入栈分裂的代码。NOSPLIT对应Go语言中的//go:nosplit注释。
目前可能遇到的函数标志有NOSPLIT
、WRAPPER
和NEEDCTXT
几个。其中NOSPLIT
不会生成或包含栈分裂代码,这一般用于没有任何其它函数调用的叶子函数,这样可以适当提高性能。WRAPPER
标志则表示这个是一个包装函数,在panic或runtime.caller等某些处理函数帧的地方不会增加函数帧计数。最后的NEEDCTXT
表示需要一个上下文参数,一般用于闭包函数。
需要注意的是函数也没有类型,上面定义的Swap函数签名可以下面任意一种格式:
func Swap(a, b, c int) int
func Swap(a, b, c, d int)
func Swap() (a, b, c, d int)
func Swap() (a []int, d int)
// ...
对于汇编函数来说,只要是函数的名字和参数大小一致就可以是相同的函数了。而且在Go汇编语言中,输入参数和返回值参数是没有任何的区别的。
函数参数和返回值
对于函数来说,最重要的是函数对外提供的API约定,包含函数的名称、参数和返回值。当这些都确定之后,如何精确计算参数和返回值的大小是第一个需要解决的问题。
比如有一个Swap函数的签名如下:
func Swap(a, b int) (ret0, ret1 int)
对于这个函数,我们可以轻易看出它需要4个int类型的空间,参数和返回值的大小也就是32个字节:
TEXT ·Swap(SB), $0-32
那么如何在汇编中引用这4个参数呢?为此Go汇编中引入了一个FP伪寄存器,表示函数当前帧的地址,也就是第一个参数的地址。因此我们以通过+0(FP)
、+8(FP)
、+16(FP)
和+24(FP)
来分别引用a、b、ret0和ret1
四个参数。
但是在汇编代码中,我们并不能直接以+0(FP)
的方式来使用参数。为了编写易于维护的汇编代码,Go汇编语言要求,任何通过FP伪寄存器访问的变量必和一个临时标识符前缀组合后才能有效,一般使用参数对应的变量名作为前缀
。
下图是Swap函数中参数和返回值在内存中的布局图:
下面的代码演示了如何在汇编函数中使用参数和返回值:
TEXT ·Swap(SB), $0
MOVQ a+0(FP), AX // AX = a
MOVQ b+8(FP), BX // BX = b
MOVQ BX, ret0+16(FP) // ret0 = BX
MOVQ AX, ret1+24(FP) // ret1 = AX
RET
从代码可以看出a、b、ret0和ret1的内存地址是依次递增
的,FP伪寄存器是第一个变量的开始地址
。
参数和返回值的内存布局
如果是参数和返回值类型比较复杂的情况该如何处理呢?下面我们再尝试一个更复杂的函数参数和返回值的计算。比如有以下一个函数:
func Foo(a bool, b int16) (c []byte)
函数的参数有不同的类型,而且返回值中含有更复杂的切片类型。我们该如何计算每个参数的位置和总的大小呢?
其实函数参数和返回值的大小以及对齐问题
和结构体的大小和成员对齐问题
是一致
的,函数的第一个参数
和第一个返回值
会分别进行一次地址对齐
。我们可以用诡代思路将全部的参数和返回值以同样的顺序分别放到两个结构体中,将FP伪寄存器作为唯一的一个指针参数,而每个成员的地址也就是对应原来参数的地址。
用这样的策略可以很容易计算前面的Foo函数的参数和返回值的地址和总大小。为了便于描述我们定义一个Foo_args_and_returns
临时结构体类型用于诡代原始的参数和返回值:
type Foo_args struct {
a bool
b int16
c []byte
}
type Foo_returns struct {
c []byte
}
然后将Foo原来的参数替换为结构体形式,并且只保留唯一的FP作为参数:
func Foo(FP *SomeFunc_args, FP_ret *SomeFunc_returns) {
// a = FP + offsetof(&args.a)
_ = unsafe.Offsetof(FP.a) + uintptr(FP) // a
// b = FP + offsetof(&args.b)
// argsize = sizeof(args)
argsize = unsafe.Offsetof(FP)
// c = FP + argsize + offsetof(&return.c)
_ = uintptr(FP) + argsize + unsafe.Offsetof(FP_ret.c)
// framesize = sizeof(args) + sizeof(returns)
_ = unsafe.Offsetof(FP) + unsafe.Offsetof(FP_ret)
return
}
代码完全和Foo函数参数的方式类似。唯一的差异是每个函数的偏移量,通过unsafe.Offsetof
函数自动计算生成。因为Go结构体中的每个成员已经满足了对齐要求,因此采用通用方式得到每个参数的偏移量也是满足对齐要求的。序言注意的是第一个返回值地址需要重新对齐机器字大小的倍数。
Foo函数的参数和返回值的大小和内存布局:
下面的代码演示了Foo汇编函数参数和返回值的定位:
TEXT ·Foo(SB), $0
MOVEQ a+0(FP), AX // a
MOVEQ b+2(FP), BX // b
MOVEQ c_dat+8*1(FP), CX // c.Data
MOVEQ c_len+8*2(FP), DX // c.Len
MOVEQ c_cap+8*3(FP), DI // c.Cap
RET
其中a和b参数之间出现了一个字节的空洞,b和c之间出现了4个字节的空洞。出现空洞的原因是要保证每个参数变量地址都要对齐到相应的倍数。
函数中的局部变量
从Go语言函数角度讲,局部变量是函数内明确定义的变量,同时也包含函数的参数和返回值变量。但是从Go汇编角度看,局部变量是指函数运行时,在当前函数栈帧所对应的内存内的变量,不包含函数的参数和返回值(因为访问方式有差异)。函数栈帧的空间主要由函数参数和返回值、局部变量和被调用其它函数的参数和返回值空间组成。为了便于理解,我们可以将汇编函数的局部变量类比为Go语言函数中显式定义的变量,不包含参数和返回值部分。
为了便于访问局部变量,Go汇编语言引入了伪SP寄存器,对应当前栈帧的底部
。因为在当前栈帧时栈的底部是固定不变的
,因此局部变量的相对于伪SP的偏移量也就是固定的,这可以简化局部变量的维护工作。SP真伪寄存器的区分只有一个原则:如果使用SP时有一个临时标识符前缀就是伪SP,否则就是真SP寄存器
。比如a(SP)和b+8(SP)有a和b临时前缀,这里都是伪SP,而前缀部分一般用于表示局部变量的名字。而(SP)和+8(SP)没有临时标识符作为前缀,它们都是真SP寄存器。
在X86平台,函数的调用栈是从高地址向低地址增长的,因此伪SP寄存器对应栈帧的底部其实是对应更大的地址
。当前栈的顶部对应真实存在的SP寄存器,对应当前函数栈帧的栈顶,对应更小的地址。如果整个内存用Memory数组表示,那么Memory[0(SP):end-0(SP)]就是对应当前栈帧的切片,其中开始位置是真SP寄存器,结尾部分是伪SP寄存器。真SP寄存器一般用于表示调用其它函数时的参数和返回值,真SP寄存器对应内存较低的地址,所以被访问变量的偏移量是正数;而伪SP寄存器对应高地址,对应的局部变量的偏移量都是负数。
为了便于对比,我们将前面Foo函数的参数和返回值变量改成局部变量:
func Foo() {
var c []byte
var b int16
var a bool
}
然后通过汇编语言重新实现Foo函数,并通过伪SP来定位局部变量:
TEXT ·Foo(SB), $32-0
MOVQ a-32(SP), AX // a
MOVQ b-30(SP), BX // b
MOVQ c_data-24(SP), CX // c.Data
MOVQ c_len-16(SP), DX // c.Len
MOVQ c_cap-8(SP), DI // c.Cap
RET
Foo函数有3个局部变量,但是没有调用其它的函数,因为对齐和填充的问题导致函数的栈帧大小为32个字节。因为Foo函数没有参数和返回值,因此参数和返回值大小为0个字节,当然这个部分可以省略不写。而局部变量中先定义的变量c离伪SP寄存器对应的地址最近,最后定义的变量a离伪SP寄存器最远。有两个因素导致出现这种逆序的结果:一个从Go语言函数角度理解,先定义的c变量地址要比后定义的变量的地址更大;另一个是伪SP寄存器对应栈帧的底部,而X86中栈是从高向低生长的,所以最先定义有着更大地址的c变量离栈的底部伪SP更近。
我们同样可以通过结构体来模拟局部变量的布局:
func Foo() {
var local [1]struct{
a bool
b int16
c []byte
}
var SP = &local[1];
_ = -(unsafe.Sizeof(local)-unsafe.Offsetof(local.a)) + uintptr(&SP) // a
_ = -(unsafe.Sizeof(local)-unsafe.Offsetof(local.b)) + uintptr(&SP) // b
_ = -(unsafe.Sizeof(local)-unsafe.Offsetof(local.c)) + uintptr(&SP) // c
}
我们将之前的三个局部变量挪到一个结构体中。然后构造一个SP变量对应伪SP寄存器,对应局部变量结构体的顶部。然后根据局部变量总大小和每个变量对应成员的偏移量计算相对于伪SP的距离,最终偏移量是一个负数。
通过这种方式可以处理复杂的局部变量的偏移,同时也能保证每个变量地址的对齐要求。当然,除了地址对齐外,局部变量的布局并没有顺序要求。对于汇编比较熟悉同学可以根据自己的习惯组织变量的布局。
下面是Foo函数的局部变量的大小和内存布局:
从图中可以看出Foo函数局部变量和前一个例子中参数和返回值的内存布局是完全一样的,这也是我们故意设计的结果。但是参数和返回值是通过伪FP寄存器定位的
,FP寄存器对应第一个参数的开始地址(第一个参数地址较低)
,因此每个变量的偏移量是正数。而局部变量是通过伪SP寄存器定位的
,而伪SP寄存器对应的是第一个局部变量的结束地址(第一个局部变量地址较大)
,因此每个局部变量的偏移量都是负数。
调用其它函数
常见的用Go汇编实现的函数都是叶子函数,也就是被其它函数调用的函数,但是很少调用其它函数。这主要是因为叶子函数比较简单,可以简化汇编函数的编写;同时一般性能或特性的瓶颈也处于叶子函数。但是能够调用其它函数和能够被其它函数调用同样重要,否则Go汇编就不是一个完整的汇编语言。
在前文中我们已经学习了一些汇编实现的函数参数和返回值处理的规则。那么一个显然的问题是,汇编函数的参数是从哪里来的?答案同样明显,被调用函数的参数是由调用方准备的:调用方在栈上设置好空间和数据后调用函数,被调用方在返回前将返回值放在对应的位置,函数通过RET指令返回调用方函数之后,调用方再从返回值对应的栈内存位置取出结果。Go语言函数的调用参数和返回值均是通过栈传输的,这样做的优点是函数调用栈比较清晰,缺点是函数调用有一定的性能损耗(Go编译器是通过函数内联来缓解这个问题的影响)。
为了便于展示,我们先使用Go语言来构造三个逐级调用的函数:
func main() {
printsum(1, 2)
}
func printsum(a, b int) {
var ret = sum(a, b)
println(ret)
}
func sum(a, b int) int {
return a+b
}
其中main函数通过字面值常量直接调用printsum
函数,printsum
函数输出两个整数的和。而printsum
函数内部又通过调用sum
函数计算两个数的和,并最终调用打印函数进行输出。因为printsum
既是被调用函数又是调用函数,所以它是我们要重点分析的函数。
下图展示了三个函数逐级调用时内存中函数参数和返回值的布局:
为了便于理解,我们对真实的内存布局进行了简化。要记住的是调用函数时,被调用函数的参数和返回值内存空间都必须由调用者提供。因此函数的局部变量和为调用其它函数准备的栈空间总和就确定了函数帧的大小。调用其它函数前调用方要选择保存相关寄存器到栈中,并在调用函数返回后选择要恢复的寄存器进行保存。最终通过CALL
指令调用函数的过程和调用我们熟悉的调用println
函数输出的过程类似。
Go语言中函数调用是一个复杂的问题,因为Go函数不仅仅要了解函数调用参数的布局,还会涉及到栈的跳转,栈上局部变量的生命周期管理。本节只是简单了解函数调用参数的布局规则,在后续的章节中会更详细的讨论函数的细节。
宏函数
宏函数并不是Go汇编语言所定义,而是Go汇编引入的预处理特性自带的特性。
在C语言中我们可以通过带参数的宏定义一个交换2个数的宏函数:
我们可以用类似的方式定义一个交换两个寄存器的宏:
因为汇编语言中无法定义临时变量,我们增加一个参数用于临时寄存器。下面是通过SWAP宏函数交换AX和BX寄存器的值,然后返回结果:
// func Swap(a, b int) (int, int)
TEXT ·Swap(SB), $0-32
MOVQ a+0(FP), AX // AX = a
MOVQ b+8(FP), BX // BX = b
SWAP(AX, BX, CX) // AX, BX = b, a
MOVQ AX, ret0+16(FP) // return
MOVQ BX, ret1+24(FP) //
RET
因为预处理器可以通过条件编译针对不同的平台定义宏的实现,这样可以简化平台带来的差异。
函数进阶函数调用规范
在Go汇编语言中CALL
指令用于调用函数,RET
指令用于从调用函数返回。但是CALL和RET指令并没有处理函数调用时输入参数和返回值的问题。CALL指令类似PUSH IP和JMP somefunc两个指令的组合
,首先将当前的IP指令寄存器的值压入栈中,然后通过JMP指令将要调用函数的地址写入到IP寄存器实现跳转。而RET指令则是和CALL相反的操作,基本和POP IP指令等价
,也就是将执行CALL指令时保存在SP中的返回地址重新载入到IP寄存器,实现函数的返回。
和C语言函数不同,Go语言函数的参数和返回值完全通过栈传递。下面是Go函数调用时栈的布局图:
首先是调用函数前准备的输入参数和返回值空间。然后CALL指令将首先触发返回地址入栈操作。在进入到被调用函数内之后,汇编器自动插入了BP寄存器相关的指令,因此BP寄存器和返回地址是紧挨着的。再下面就是当前函数的局部变量的空间,包含再次调用其它函数需要准备的调用参数空间。被调用的函数执行RET返回指令时,先从栈恢复BP和SP寄存器,接着取出的返回地址跳转到对应的指令执行。
高级汇编语言
Go汇编语言其实是一种高级的汇编语言。在这里高级一词并没有任何褒义或贬义的色彩,而是要强调Go汇编代码和最终真实执行的代码并不完全等价。Go汇编语言中一个指令在最终的目标代码中可能会被编译为其它等价的机器指令。Go汇编实现的函数或调用函数的指令在最终代码中也会被插入额外的指令。要彻底理解Go汇编语言就需要彻底了解汇编器到底插入了哪些指令。
为了便于分析,我们先构造一个禁止栈分裂的printnl函数。printnl函数内部都通过调用runtime.printnl函数输出换行:
TEXT ·printnl_nosplit(SB), NOSPLIT, $8
CALL runtime·printnl(SB)
RET
然后通过go tool asm -S main_amd64.s
指令查看编译后的目标代码:
"".printnl_nosplit STEXT nosplit size=29 args=0xffffffff80000000 locals=0x10
0x0000 00000 (main_amd64.s:5) TEXT "".printnl_nosplit(SB), NOSPLIT $16
0x0000 00000 (main_amd64.s:5) SUBQ $16, SP
0x0004 00004 (main_amd64.s:5) MOVQ BP, 8(SP)
0x0009 00009 (main_amd64.s:5) LEAQ 8(SP), BP
0x000e 00014 (main_amd64.s:6) CALL runtime.printnl(SB)
0x0013 00019 (main_amd64.s:7) MOVQ 8(SP), BP
0x0018 00024 (main_amd64.s:7) ADDQ $16, SP
0x001c 00028 (main_amd64.s:7) RET
输出代码中我们删除了非指令的部分。为了便于讲述,我们将上述代码重新排版,并根据缩进表示相关的功能:
TEXT "".printnl(SB), NOSPLIT, $16
SUBQ $16, SP
MOVQ BP, 8(SP)
LEAQ 8(SP), BP
CALL runtime.printnl(SB)
MOVQ 8(SP), BP
ADDQ $16, SP
RET
第一层是TEXT
指令表示函数开始,到RET指令表示函数返回。第二层是SUBQ $16, SP
指令为当前函数帧分配16字节的空间,在函数返回前通过ADDQ $16, SP
指令回收16字节的栈空间。我们谨慎猜测在第二层是为函数多分配了8个字节的空间。那么为何要多分配8个字节的空间呢?再继续查看第三层的指令:开始部分有两个指令MOVQ BP, 8(SP)和LEAQ 8(SP), BP,
首先是将BP寄存器保持到多分配的8字节栈空间,然后将8(SP)地址重新保持到了BP寄存器中;结束部分是MOVQ 8(SP)
, BP指令则是从栈中恢复之前备份的前BP寄存器的值。最里面第四次层才是我们写的代码,调用runtime.printnl
函数输出换行。
如果去掉NOSPILT标志,再重新查看生成的目标代码,会发现在函数的开头和结尾的地方又增加了新的指令。下面是经过缩进格式化的结果:
TEXT "".printnl_nosplit(SB), $16
L_BEGIN:
MOVQ (TLS), CX
CMPQ SP, 16(CX)
JLS L_MORE_STK
SUBQ $16, SP
MOVQ BP, 8(SP)
LEAQ 8(SP), BP
CALL runtime.printnl(SB)
MOVQ 8(SP), BP
ADDQ $16, SP
L_MORE_STK:
CALL runtime.morestack_noctxt(SB)
JMP L_BEGIN
RET
其中开头有三个新指令,MOVQ (TLS), CX
用于加载g结构体指针,然后第二个指令CMPQ SP, 16(CX)SP
栈指针和g结构体中stackguard0成员比较,如果比较的结果小于0则跳转到结尾的L_MORE_STK
部分。当获取到更多栈空间之后,通过JMP L_BEGIN
指令跳转到函数的开始位置重新进行栈空间的检测。
g结构体在$GOROOT/src/runtime/runtime2.go
文件定义,开头的结构成员如下:
type g struct {
// Stack parameters.
stack stack // offset known to runtime/cgo
stackguard0 uintptr // offset known to liblink
stackguard1 uintptr // offset known to liblink
...
}
第一个成员是stack类型,表示当前栈的开始和结束地址。stack的定义如下:
// Stack describes a Go execution stack.
// The bounds of the stack are exactly [lo, hi),
// with no implicit data structures on either side.
type stack struct {
lo uintptr
hi uintptr
}
在g结构体中的stackguard0成员是出现爆栈前的警戒线。stackguard0的偏移量是16个字节,因此上述代码中的CMPQ SP, 16(AX)
表示将当前的真实SP和爆栈警戒线比较,如果超出警戒线则表示需要进行栈扩容,也就是跳转到L_MORE_STK
。在L_MORE_STK
标号处,先调用runtime·morestack_noctxt
进行栈扩容,然后又跳回到函数的开始位置,此时此刻函数的栈已经调整了。然后再进行一次栈大小的检测,如果依然不足则继续扩容,直到栈足够大为止。
以上是栈的扩容,但是栈的收缩是在何时处理的呢?我们知道Go运行时会定期进行垃圾回收操作,这其中包含栈的回收工作。如果栈使用到比例小于一定到阈值,则分配一个较小到栈空间,然后将栈上面到数据移动到新的栈中,栈移动的过程和栈扩容的过程类似。
PCDATA和FUNCDATA
Go语言中有个runtime.Caller
函数可以获取当前函数的调用者列表。我们可以非常容易在运行时定位每个函数的调用位置,以及函数的调用链。因此在panic异常或用log输出信息时,可以精确定位代码的位置。
比如以下代码可以打印程序的启动流程:
func main() {
for skip := 0; ; skip++ {
pc, file, line, ok := runtime.Caller(skip)
if !ok {
break
}
p := runtime.FuncForPC(pc)
fnfile, fnline := p.FileLine(0)
fmt.Printf("skip = %d, pc = 0x%08X\n", skip, pc)
fmt.Printf(" func: file = %s, line = L%03d, name = %s, entry = 0x%08X\n", fnfile, fnline, p.Name(), p.Entry())
fmt.Printf(" call: file = %s, line = L%03d\n", file, line)
}
}
其中runtime.Caller
先获取当时的PC寄存器值,以及文件和行号。然后根据PC寄存器表示的指令位置,通过````runtime.FuncForPC```函数获取函数的基本信息。Go语言是如何实现这种特性的呢?
Go语言作为一门静态编译型语言,在执行时每个函数的地址都是固定的,函数的每条指令也是固定的。如果针对每个函数和函数的每个指令生成一个地址表格(也叫PC表格),那么在运行时我们就可以根据PC寄存器的值轻松查询到指令当时对应的函数和位置信息。而Go语言也是采用类似的策略,只不过地址表格经过裁剪,舍弃了不必要的信息。因为要在运行时获取任意一个地址的位置,必然是要有一个函数调用,因此我们只需要为函数的开始和结束位置,以及每个函数调用位置生成地址表格就可以了。同时地址是有大小顺序的,在排序后可以通过只记录增量来减少数据的大小;在查询时可以通过二分法加快查找的速度。
在汇编中有个PCDATA用于生成PC表格,PCDATA的指令用法为:PCDATA tableid, tableoffset
。PCDATA有个两个参数,第一个参数为表格的类型,第二个是表格的地址。在目前的实现中,有PCDATA_StackMapIndex
和PCDATA_InlTreeIndex
两种表格类型。两种表格的数据是类似的,应该包含了代码所在的文件路径、行号和函数的信息,只不过PCDATA_InlTreeIndex
用于内联函数的表格。
此外对于汇编函数中返回值包含指针的类型,在返回值指针被初始化之后需要执行一个GO_RESULTS_INITIALIZED
指令:
#define GO_RESULTS_INITIALIZED PCDATA $PCDATA_StackMapIndex, $1
GO_RESULTS_INITIALIZED
记录的也是PC表格的信息,表示PC指针越过某个地址之后返回值才完成被初始化的状态。
Go语言二进制文件中除了有PC表格,还有FUNC表格用于记录函数的参数、局部变量的指针信息。FUNCDATA指令和PCDATA的格式类似:FUNCDATA tableid, tableoffset
,第一个参数为表格的类型,第二个是表格的地址。目前的实现中定义了三种FUNC表格类型:FUNCDATA_ArgsPointerMaps
表示函数参数的指针信息表,FUNCDATA_LocalsPointerMaps
表示局部指针信息表,FUNCDATA_InlTree
表示被内联展开的指针信息表。通过FUNC表格,Go语言的垃圾回收器可以跟踪全部指针的生命周期,同时根据指针指向的地址是否在被移动的栈范围来确定是否要进行指针移动。
在前面递归函数的例子中,我们遇到一个NO_LOCAL_POINTERS
宏。它的定义如下:
#define FUNCDATA_ArgsPointerMaps 0 /* garbage collector blocks */
#define FUNCDATA_LocalsPointerMaps 1
#define FUNCDATA_InlTree 2
#define NO_LOCAL_POINTERS FUNCDATA $FUNCDATA_LocalsPointerMaps, runtime·no_pointers_stackmap(SB)
因此NO_LOCAL_POINTERS
宏表示的是FUNCDATA_LocalsPointerMaps
对应的局部指针表格,而runtime·no_pointers_stackmap
是一个空的指针表格,也就是表示函数没有指针类型的局部变量。
PCDATA
和FUNCDATA
的数据一般是由编译器自动生成的,手工编写并不现实。如果函数已经有Go语言声明,那么编译器可以自动输出参数和返回值的指针表格。同时所有的函数调用一般是对应CALL
指令,编译器也是可以辅助生成PCDATA
表格的。编译器唯一无法自动生成是函数局部变量的表格,因此我们一般要在汇编函数的局部变量中谨慎使用指针类型。
对于PCDATA和FUNCDATA细节感兴趣的同学可以尝试从debug/gosym包入手,参考包的实现和测试代码。
方法函数
Go语言中方法函数和全局函数非常相似,比如有以下的方法:
package main
type MyInt int
func (v MyInt) Twice() int {
return int(v)*2
}
func MyInt_Twice(v MyInt) int {
return int(v)*2
}
其中MyInt类型的Twice方法和MyInt_Twice函数的类型是完全一样的,只不过Twice在目标文件中被修饰为main.MyInt.Twice
名称。我们可以用汇编实现该方法函数:
// func (v MyInt) Twice() int
TEXT ·MyInt·Twice(SB), NOSPLIT, $0-16
MOVQ a+0(FP), AX // v
ADDQ AX, AX // AX *= 2
MOVQ AX, ret+8(FP) // return v
RET
不过这只是接收非指针类型的方法函数。现在增加一个接收参数是指针类型的Ptr方法,函数返回传入的指针:
func (p *MyInt) Ptr() *MyInt {
return p
}
在目标文件中,Ptr方法名被修饰为main.(*MyInt).Ptr
,也就是对应汇编中的·(*MyInt)·Ptr
。不过在Go汇编语言中,星号和小括弧都无法用作函数名字,也就是无法用汇编直接实现接收参数是指针类型的方法。
在最终的目标文件中的标识符名字中还有很多Go汇编语言不支持的特殊符号(比如type.string."hello"
中的双引号),这导致了无法通过手写的汇编代码实现全部的特性。或许是Go语言官方故意限制了汇编语言的特性。
递归函数: 1到n求和
递归函数是比较特殊的函数,递归函数通过调用自身并且在栈上保存状态,这可以简化很多问题的处理。Go语言中递归函数的强大之处是不用担心爆栈问题,因为栈可以根据需要进行扩容和收缩。
首先通过Go递归函数实现一个1到n的求和函数:
// sum = 1+2+...+n
// sum(100) = 5050
func sum(n int) int {
if n > 0 { return n+sum(n-1) } else { return 0 }
}
然后通过if/goto重构上面的递归函数,以便于转义为汇编版本:
func sum(n int) (result int) {
var AX = n
var BX int
if n > 0 { goto L_STEP_TO_END }
goto L_END
L_STEP_TO_END:
AX -= 1
BX = sum(AX)
AX = n // 调用函数后, AX重新恢复为n
BX += AX
return BX
L_END:
return 0
}
在改写之后,递归调用的参数需要引入局部变量,保存中间结果也需要引入局部变量。而通过栈来保存中间的调用状态正是递归函数的核心。因为输入参数也在栈上,所以我们可以通过输入参数来保存少量的状态。同时我们模拟定义了AX和BX寄存器,寄存器在使用前需要初始化,并且在函数调用后也需要重新初始化。
下面继续改造为汇编语言版本:
// func sum(n int) (result int)
TEXT ·sum(SB), NOSPLIT, $16-16
MOVQ n+0(FP), AX // n
MOVQ result+8(FP), BX // result
CMPQ AX, $0 // test n - 0
JG L_STEP_TO_END // if > 0: goto L_STEP_TO_END
JMP L_END // goto L_STEP_TO_END
L_STEP_TO_END:
SUBQ $1, AX // AX -= 1
MOVQ AX, 0(SP) // arg: n-1
CALL ·sum(SB) // call sum(n-1)
MOVQ 8(SP), BX // BX = sum(n-1)
MOVQ n+0(FP), AX // AX = n
ADDQ AX, BX // BX += AX
MOVQ BX, result+8(FP) // return BX
RET
L_END:
MOVQ $0, result+8(FP) // return 0
RET
在汇编版本函数中并没有定义局部变量,只有用于调用自身的临时栈空间。因为函数本身的参数和返回值有16个字节,因此栈帧的大小也为16字节。L_STEP_TO_END标号部分用于处理递归调用,是函数比较复杂的部分。L_END用于处理递归终结的部分。
调用sum函数的参数在0(SP)
位置,调用结束后的返回值在8(SP)
位置。在函数调用之后要需要重新为需要的寄存器注入值,因为被调用的函数内部很可能会破坏了寄存器的状态。同时调用函数的参数值也是不可信任的,输入参数值也可能在被调用函数内部被修改了。
总得来说用汇编实现递归函数和普通函数并没有什么区别,当然是在没有考虑爆栈的前提下。我们的函数应该可以对较小的n进行求和,但是当n大到一定程度,也就是栈达到一定的深度,必然会出现爆栈的问题。爆栈是C语言的特性,不应该在哪怕是Go汇编语言中出现。
Go语言的编译器在生成函数的机器代码时,会在开头插入一小段代码。因为sum函数也需要深度递归调用,因此我们删除了NOSPLIT
标志,让汇编器为我们自动生成一个栈扩容的代码:
// func sum(n int) int
TEXT ·sum(SB), $16-16
NO_LOCAL_POINTERS
// 原来的代码
除了去掉了NOSPLIT标志,我们还在函数开头增加了一个NO_LOCAL_POINTERS
语句,该语句表示函数没有局部指针变量。栈的扩容必然要涉及函数参数和局部编指针的调整,如果缺少局部指针信息将导致扩容工作无法进行。不仅仅是栈的扩容需要函数的参数和局部指针标记表格,在GC进行垃圾回收时也将需要。函数的参数和返回值的指针状态可以通过在Go语言中的函数声明中获取,函数的局部变量则需要手工指定。因为手工指定指针表格是一个非常繁琐的工作,因此一般要避免在手写汇编中出现局部指针。
喜欢深究的读者可能会有一个问题:如果进行垃圾回收或栈调整时,寄存器中的指针是如何维护的?前文说过,Go语言的函数调用是通过栈进行传递参数的,并没有使用寄存器传递参数。同时函数调用之后所有的寄存器视为失效。因此在调整和维护指针时,只需要扫描内存中的指针数据,寄存器中的数据在垃圾回收器函数返回后都需要重新加载,因此寄存器是不需要扫描的。
闭包函数
闭包函数是最强大的函数,因为闭包函数可以捕获外层局部作用域的局部变量,因此闭包函数本身就具有了状态。从理论上来说,全局的函数也是闭包函数的子集,只不过全局函数并没有捕获外层变量而已。
为了理解闭包函数如何工作,我们先构造如下的例子:
package main
func NewTwiceFunClosure(x int) func() int {
return func() int {
x *= 2
return x
}
}
func main() {
fnTwice := NewTwiceFunClosure(1)
println(fnTwice()) // 1*2 => 2
println(fnTwice()) // 2*2 => 4
println(fnTwice()) // 4*2 => 8
}
其中NewTwiceFunClosure
函数返回一个闭包函数对象,返回的闭包函数对象捕获了外层的x参数。返回的闭包函数对象在执行时,每次将捕获的外层变量乘以2之后再返回。在main
函数中,首先以1作为参数调用NewTwiceFunClosure
函数构造一个闭包函数,返回的闭包函数保存在fnTwice
闭包函数类型的变量中。然后每次调用fnTwice
闭包函数将返回翻倍后的结果,也就是:2,4,8。
上述的代码,从Go语言层面是非常容易理解的。但是闭包函数在汇编语言层面是如何工作的呢?下面我们尝试手工构造闭包函数来展示闭包的工作原理。首先是构造```FunTwiceClosure````结构体类型,用来表示闭包对象:
type FunTwiceClosure struct {
F uintptr
X int
}
func NewTwiceFunClosure(x int) func() int {
var p = &FunTwiceClosure{
F: asmFunTwiceClosureAddr(),
X: x,
}
return ptrToFunc(unsafe.Pointer(p))
}
FunTwiceClosure
结构体包含两个成员,第一个成员F表示闭包函数的函数指令的地址
,第二个成员X表示闭包捕获的外部变量
。如果闭包函数捕获了多个外部变量,那么FunTwiceClosure
结构体也要做相应的调整。然后构造FunTwiceClosure
结构体对象,其实也就是闭包函数对象。其中asmFunTwiceClosureAddr
函数用于辅助获取闭包函数的函数指令的地址,采用汇编语言实现。最后通过ptrToFunc
辅助函数将结构体指针转为闭包函数对象返回,该函数也是通过汇编语言实现。
汇编语言实现了以下三个辅助函数:
func ptrToFunc(p unsafe.Pointer) func() int
func asmFunTwiceClosureAddr() uintptr
func asmFunTwiceClosureBody() int
其中ptrToFunc用于将指针转化为func() int类型的闭包函数
,asmFunTwiceClosureAddr用于返回闭包函数机器指令的开始地址(类似全局函数的地址)
,asmFunTwiceClosureBody是闭包函数对应的全局函数的实现
。
然后用Go汇编语言实现以上三个辅助函数:
#include "textflag.h"
TEXT ·ptrToFunc(SB), NOSPLIT, $0-16
MOVQ ptr+0(FP), AX // AX = ptr
MOVQ AX, ret+8(FP) // return AX
RET
TEXT ·asmFunTwiceClosureAddr(SB), NOSPLIT, $0-8
LEAQ ·asmFunTwiceClosureBody(SB), AX // AX = ·asmFunTwiceClosureBody(SB)
MOVQ AX, ret+0(FP) // return AX
RET
TEXT ·asmFunTwiceClosureBody(SB), NOSPLIT|NEEDCTXT, $0-8
MOVQ 8(DX), AX
ADDQ AX , AX // AX *= 2
MOVQ AX , 8(DX) // ctx.X = AX
MOVQ AX , ret+0(FP) // return AX
RET
其中·ptrToFunc
和·asmFunTwiceClosureAddr
函数的实现比较简单,我们不再详细描述。最重要的是·asmFunTwiceClosureBody
函数的实现:它有一个NEEDCTXT
标志。采用NEEDCTXT
标志定义的汇编函数表示需要一个上下文环境,在AMD64环境下是通过DX寄存器
来传递这个上下文环境指针,也就是对应FunTwiceClosure
结构体的指针。函数首先从FunTwiceClosure
结构体对象取出之前捕获的X,将X乘以2之后写回内存,最后返回修改之后的X的值。
如果是在汇编语言中调用闭包函数,也需要遵循同样的流程:首先为构造闭包对象,其中保存捕获的外层变量;在调用闭包函数时首先要拿到闭包对象,用闭包对象初始化DX
,然后从闭包对象中取出函数地址并用通过CALL
指令调用。
Songzhibin
关注成功
0
0
有多种方式可以获得Go程序的汇编代码, 尽管输出的格式有些不同,但是都是方便阅读的汇编代码,可以帮助我们更好的了解程序的底层运行方式。
我们看下面一段代码, 它是sync.Once的实现,去掉了不必要的注释,复制出来用来研究的一段小代码:
-
once.go -
1type Once struct { -
2 m sync.Mutex -
3 done uint32 -
4} -
5func (o *Once) Do(f func()) { -
6 if atomic.LoadUint32(&o.done) == 1 { -
7 return -
8 } -
9 o.m.Lock() -
10 defer o.m.Unlock() -
11 if o.done == 0 { -
12 defer atomic.StoreUint32(&o.done, 1) -
13 f() -
14 } -
15}
方法一: go tool compile
使用go tool compile -N -l -S once.go生成汇编代码:
-
1"".(*Once).Do STEXT size=239 args=0x10 locals=0x28 -
2 0x0000 00000 (once.go:13) TEXT "".(*Once).Do(SB), $40-16 -
3 0x0000 00000 (once.go:13) MOVQ (TLS), CX -
4 0x0009 00009 (once.go:13) CMPQ SP, 16(CX) -
5 0x000d 00013 (once.go:13) JLS 229 -
6 0x0013 00019 (once.go:13) SUBQ $40, SP -
7 0x0017 00023 (once.go:13) MOVQ BP, 32(SP) -
8 0x001c 00028 (once.go:13) LEAQ 32(SP), BP -
9 0x0021 00033 (once.go:13) FUNCDATA $0, gclocals·fdbf1f5761f6d17e8ae3f0aaecb6a3c5(SB) -
10 0x0021 00033 (once.go:13) FUNCDATA $1, gclocals·7d2d5fca80364273fb07d5820a76fef4(SB) -
11 0x0021 00033 (once.go:13) FUNCDATA $3, gclocals·96839595c383af6ae8227769d90a999e(SB) -
12 0x0021 00033 (once.go:14) PCDATA $2, $1 -
13 0x0021 00033 (once.go:14) PCDATA $0, $0 -
14 0x0021 00033 (once.go:14) MOVQ "".o+48(SP), AX -
15 0x0026 00038 (once.go:14) MOVL 8(AX), CX -
16 0x0029 00041 (once.go:14) CMPL CX, $1 -
17 0x002c 00044 (once.go:14) JEQ 213 -
18 0x0032 00050 (once.go:18) PCDATA $2, $0 -
19 0x0032 00050 (once.go:18) MOVQ AX, (SP) -
20 0x0036 00054 (once.go:18) CALL sync.(*Mutex).Lock(SB) -
21 0x003b 00059 (once.go:19) PCDATA $2, $1 -
22 0x003b 00059 (once.go:19) MOVQ "".o+48(SP), AX -
23 ……
方法二: go tool objdump
首先先编译程序: go tool compile -N -l once.go,
使用go tool objdump once.o反汇编出代码 (或者使用go tool objdump -s Do once.o反汇编特定的函数:):
-
1TEXT %22%22.(*Once).Do(SB) gofile../Users/……/once.go -
2 once.go:13 0x7cd 65488b0c2500000000 MOVQ GS:0, CX [5:9]R_TLS_LE -
3 once.go:13 0x7d6 483b6110 CMPQ 0x10(CX), SP -
4 once.go:13 0x7da 0f86d2000000 JBE 0x8b2 -
5 once.go:13 0x7e0 4883ec28 SUBQ $0x28, SP -
6 once.go:13 0x7e4 48896c2420 MOVQ BP, 0x20(SP) -
7 once.go:13 0x7e9 488d6c2420 LEAQ 0x20(SP), BP -
8 once.go:14 0x7ee 488b442430 MOVQ 0x30(SP), AX -
9 once.go:14 0x7f3 8b4808 MOVL 0x8(AX), CX -
10 once.go:14 0x7f6 83f901 CMPL $0x1, CX -
11 once.go:14 0x7f9 0f84a3000000 JE 0x8a2 -
12 once.go:18 0x7ff 48890424 MOVQ AX, 0(SP) -
13 once.go:18 0x803 e800000000 CALL 0x808 [1:5]R_CALL:sync.(*Mutex).Lock -
14 once.go:19 0x808 488b442430 MOVQ 0x30(SP), AX -
15 once.go:19 0x80d 4889442410 MOVQ AX, 0x10(SP) -
16 once.go:19 0x812 c7042408000000 MOVL $0x8, 0(SP) -
17 ……
方法三: go build -gcflags -S
使用go build -gcflags -S once.go也可以得到汇编代码:
-
1"".(*Once).Do STEXT size=239 args=0x10 locals=0x28 -
2 0x0000 00000 (/Users/……/once.go:13) TEXT "".(*Once).Do(SB), $40-16 -
3 0x0000 00000 (/Users/……/once.go:13) MOVQ (TLS), CX -
4 0x0009 00009 (/Users/……/once.go:13) CMPQ SP, 16(CX) -
5 0x000d 00013 (/Users/……/once.go:13) JLS 229 -
6 0x0013 00019 (/Users/……/once.go:13) SUBQ $40, SP -
7 0x0017 00023 (/Users/……/once.go:13) MOVQ BP, 32(SP) -
8 0x001c 00028 (/Users/……/once.go:13) LEAQ 32(SP), BP -
9 0x0021 00033 (/Users/……/once.go:13) FUNCDATA $0, gclocals·fdbf1f5761f6d17e8ae3f0aaecb6a3c5(SB) -
10 0x0021 00033 (/Users/……/once.go:13) FUNCDATA $1, gclocals·7d2d5fca80364273fb07d5820a76fef4(SB) -
11 0x0021 00033 (/Users/……/once.go:13) FUNCDATA $3, gclocals·96839595c383af6ae8227769d90a999e(SB) -
12 0x0021 00033 (/Users/……/once.go:14) PCDATA $2, $1 -
13 0x0021 00033 (/Users/……/once.go:14) PCDATA $0, $0 -
14 0x0021 00033 (/Users/……/once.go:14) MOVQ "".o+48(SP), AX -
15 0x0026 00038 (/Users/……/once.go:14) MOVL 8(AX), CX -
16 0x0029 00041 (/Users/……/once.go:14) CMPL CX, $1 -
17 0x002c 00044 (/Users/……/once.go:14) JEQ 213
go tool compile 和 go build -gcflags -S 生成的是过程中的汇编,和最终的机器码的汇编可以通过go tool objdump生成。
在某些场景下,我们需要进行一些特殊优化,因此我们可能需要用到golang汇编,golang汇编源于plan9,此方面的 介绍很多,就不进行展开了。我们WHY和HOW开始讲起。
golang汇编相关的内容还是很少的,而且多数都语焉不详,而且缺乏细节。对于之前没有汇编经验的人来说,是很难 理解的。而且很多资料都过时了,包括官方文档的一些细节也未及时更新。因此需要掌握该知识的人需要仔细揣摩, 反复实验。
WHY
我们为什么需要用到golang的汇编,基本出于以下场景。
-
算法加速,golang编译器生成的机器码基本上都是通用代码,而且 优化程度一般,远比不上C/C++的
gcc/clang
生成的优化程度高,毕竟时间沉淀在那里。因此通常我们需要用到特 殊优化逻辑、特殊的CPU指令让我们的算法运行速度更快,如sse4_2/avx/avx2/avx-512
等。 - 摆脱golang编译器的一些约束,如通过汇编调用其他package的私有函数。
- 进行一些hack的事,如通过汇编适配其他语言的ABI来直接调用其他语言的函数。
- 利用
//go:noescape
进行内存分配优化,golang编译器拥有逃逸分析,用于决定每一个变量是分配在堆内存上 还是函数栈上。但是有时逃逸分析的结果并不是总让人满意,一些变量完全可以分配在函数栈上,但是逃逸分析将其 移动到堆上,因此我们需要使用golang编译器的go:noescape 将其转换,强制分配在函数栈上。当然也可以强制让对象分配在堆上,可以参见这段实现。
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
所有用户空间的数据都可以通过FP(局部数据、输入参数、返回值)或SB(全局数据)访问。 通常情况下,不会对SB
/FP
寄存器进行运算操作,通常情况以会以SB
/FP
作为基准地址,进行偏移解引用 等操作。
SB
而且在某些情况下SB
更像一些声明标识,其标识语句的作用。例如:
-
TEXT runtime·_divu(SB), NOSPLIT, $16-0
在这种情况下,TEXT
、·
、SB
共同作用声明了一个函数 runtime._divu
,这种情况下,不能对SB
进行解引用。 -
GLOBL fast_udiv_tab<>(SB), RODATA, $64
在这种情况下,GLOBL
、fast_udiv_tab
、SB
共同作用, 在RODATA段声明了一个私有全局变量fast_udiv_tab
,大小为64byte,此时可以对SB
进行偏移、解引用。 -
CALL runtime·callbackasm1(SB)
在这种情况下,CALL
、runtime·callbackasm1
、SB
共同标识, 标识调用了一个函数runtime·callbackasm1
。 -
MOVW $fast_udiv_tab<>-64(SB), RM
在这种情况下,与2类似,但不是声明,是解引用全局变量 fast_udiv_tab
。
FB
FP
伪寄存器用来标识函数参数、返回值。其通过symbol+offset(FP)
的方式进行使用。例如arg0+0(FP)
表示第函数第一个参数其实的位置(amd64平台),arg1+8(FP)
表示函数参数偏移8byte的另一个参数。arg0
/arg1
用于助记,但是必须存在,否则 无法通过编译。至于这两个参数是输入参数还是返回值,得对应其函数声明的函数个数、位置才能知道。 如果操作命令是MOVQ arg+8(FP), AX
的话,MOVQ
表示对8byte长的内存进行移动,其实位置是函数参数偏移8byte 的位置,目的是寄存器AX
,因此此命令为将一个参数赋值给寄存器AX
,参数长度是8byte,可能是一个uint64,FP
前面的arg+
是标记。至于FP
的偏移怎么计算,会在后面的go函数调用中进行表述。同时我们 还可以在命令中对FP
的解引用进行标记,例如first_arg+0(FP)
将FP
的起始标记为参数first_arg
,但是 first_arg
只是一个标记,在汇编中first_arg
是不存在的。
PC
实际上就是在体系结构的知识中常见的pc
寄存器,在x86平台下对应ip
寄存器,amd64上则是rip
。除了个别跳转 之外,手写代码与PC
寄存器打交道的情况较少。
SP
SP
是栈指针寄存器,指向当前函数栈的栈顶,通过symbol+offset(SP)
的方式使用。offset 的合法取值是 [-framesize, 0)
,注意是个左闭右开的区间。假如局部变量都是8字节,那么第一个局部变量就可以用localvar0-8(SP)
来表示。
但是硬件寄存器中也有一个SP
。在用户手写的汇编代码中,如果操作SP
寄存器时没有带symbol
前缀,则操作的是 硬件寄存器SP
。在实际情况中硬件寄存器SP
与伪寄存器SP
并不指向同一地址,具体硬件寄存器SP
指向哪里与函 数
但是:
对于编译输出(go tool compile -S / go tool objdump
)的代码来讲,目前所有的SP
都是硬件寄存器SP
,无论 是否带 symbol。
我们这里对容易混淆的几点简单进行说明:
- 伪
SP
和硬件SP
不是一回事,在手写代码时,伪SP
和硬件SP
的区分方法是看该SP
前是否有symbol
。如果有 symbol
,那么即为伪寄存器,如果没有,那么说明是硬件SP
寄存器。 - 伪
SP
和FP
的相对位置是会变的,所以不应该尝试用伪SP
寄存器去找那些用FP
+offset来引用的值,例如函数的 入参和返回值。 - 官方文档中说的伪
SP
指向stack的top,是有问题的。其指向的局部变量位置实际上是整个栈的栈底(除caller BP 之外),所以说bottom更合适一些。 - 在
go tool objdump/go tool compile -S
输出的代码中,是没有伪SP
和FP
寄存器的,我们上面说的区分伪SP
和硬件SP
寄存器的方法,对于上述两个命令的输出结果是没法使用的。在编译和反汇编的结果中,只有真实的SP
寄 存器。 -
FP
和Go的官方源代码里的framepointer
不是一回事,源代码里的framepointer
指的是caller BP寄存器的值, 在这里和caller的伪SP
是值是相等的。
注: 如何理解伪寄存器FP
和SP
呢?其实伪寄存器FP
和SP
相当于plan9伪汇编中的一个助记符,他们是根据当前函数栈空间计算出来的一个相对于物理寄存器SP
的一个偏移量坐标。当在一个函数中,如果用户手动修改了物理寄存器SP
的偏移,则伪寄存器FP
和SP
也随之发生对应的偏移。例如
// 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
通用寄存器
在plan9汇编里还可以直接使用的amd64的通用寄存器,应用代码层面会用到的通用寄存器主要是: rax, rbx, rcx, rdx, rdi, rsi, r8~r15
这14个寄存器,虽然rbp
和rsp
也可以用,不过bp
和sp
会被用来管 理栈顶和栈底,最好不要拿来进行运算。plan9中使用寄存器不需要带r
或e
的前缀,例如rax
,只要写AX
即可:
MOVQ $101, AX = mov rax, 101
下面是通用通用寄存器的名字在 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 |
控制流
对于函数控制流的跳转,是用label来实现的,label只在函数内可见,类似goto
语句:
next:
MOVW $0, R1
JMP next
指令
使用汇编就意味着丧失了跨平台的特性。因此使用对应平台的汇编指令。这个需要自行去了解,也可以参考GoFunctionsInAssembly 其中有各个平台汇编指令速览和对照。
文件命名
使用到汇编时,即表明了所写的代码不能够跨平台使用,因此需要针对不同的平台使用不同的汇编 代码。go编译器采用文件名中加入平台名后缀进行区分。
比如sqrt_386.s sqrt_amd64p32.s sqrt_amd64.s sqrt_arm.s
或者使用+build tag
也可以,详情可以参考go/build。
函数声明
首先我们先需要对go汇编代码有一个抽象的认识,因此我们可以先看一段go汇编代码:
TEXT runtime·profileloop(SB),NOSPLIT,$8-16
MOVQ $runtime·profileloop1(SB), CX
MOVQ CX, 0(SP)
CALL runtime·externalthreadhandler(SB)
RET
此处声明了一个函数profileloop
,函数的声明以TEXT
标识开头,以${package}·${function}
为函数名。 如何函数属于本package时,通常可以不写${package}
,只留·${function}
即可。·
在mac上可以用shift+option+9
打出。$8
表示该函数栈大小为8byte,计算栈大小时,需要考虑局部变量和本函数内调用其他函数时,需要传参的空间,不含函数返回地址和CALLER BP
(这2个后面会讲到)。 $16
表示该函数入参和返回值一共有16byte。当有NOSPLIT
标识时,可以不写输入参数、返回值占用的大小。
那我们再看一个函数:
TEXT ·add(SB),$0-24
MOVQ x+0(FP), BX
MOVQ y+8(FP), BP
ADDQ BP, BX
MOVQ BX, ret+16(FP)
RET
该函数等同于:
func add(x, y int64) int64 {
return x + y
}
该函数没有局部变量,故$
后第一个数为0,但其有2个输入参数,1个返回值,每个值占8byte,则第二个数为24(3*8byte)。
全局变量声明
以下就是一个私有全局变量的声明,<>
表示该变量只在该文件内全局可见。 全局变量的数据部分采用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
GLOBL runtime·tlsoffset(SB), NOPTR, $4 // 声明一个全局变量tlsoffset,4byte,没有DATA部分,因其值为0。
// NOPTR 表示这个变量数据中不存在指针,GC不需要扫描。
类似RODATA
/NOPTR
的特殊声明还有:
- 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
类似语句来定义一个宏,语法结构与C语言类似;通过#include "textflag.h"
类似语句来引用一个外部宏定义文件。
go编译器为了方便汇编中访问struct
的指定字段,会在编译过程中自动生成一个go_asm.h
文件,可以通过#include "go_asm.h"
语言来引用,该文件中会生成该包内全部struct
的每个字段的偏移量宏定义与结构体大小的宏定义,比如:
type vdsoVersionKey struct {
version string
verHash uint32
}
会生成宏定义:
#define vdsoVersionKey__size 24
#define vdsoVersionKey_version 0
#define vdsoVersionKey_verHash 16
在汇编代码中,我们就可以直接使用这些宏:
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
来导出相关文件编译过程中会生成的宏定义。
地址运算
字段部分引用自《plan9-assembly-完全解析》:
地址运算也是用 lea 指令,英文原意为
Load Effective Address
,amd64 平台地址都是8
个字节,所以直接就用LEAQ
就好:
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 (
其余MOVQ
等表达式的区别是,在寄存器加偏移的情况下MOVQ
会对地址进行解引用:
MOVQ (AX), BX // => BX = *AX 将AX指向的内存区域8byte赋值给BX
MOVQ 16(AX), BX // => BX = *(AX + 16)
MOVQ AX, BX // => BX = AX 将AX中存储的内容赋值给BX,注意区别
buildin类型
在golang汇编中,没有struct/slice/string/map/chan/interface{}
等类型,有的只是寄存器、内存。因此我们需要了解这些 类型对象在汇编中是如何表达的。
(u)int??/float??
uint32
就是32bit长的一段内存,float64
就是64bit长的一段内存,其他相似类型可以以此类推。
int/unsafe.Pointer/unint
在32bit系统中int
等同于int32
,uintptr
等同于uint32
,unsafe.Pointer
长度32bit。
在64bit系统中int
等同于int64
,uintptr
等同于uint64
,unsafe.Pointer
长度64bit。
byte
等同于uint8
。rune
等同于int32
。
string
底层是StringHeader 这样一个结构体,slice
底层是SliceHeader 这样一个结构体。
map
map
是指向hmap 的一个unsafe.Pointer
chan
chan
是指向hchan 的一个unsafe.Pointer
interface{}
interface{}
是eface 这样一个结构体。详细可以参考深入解析GO
go函数调用
通常函数会有输入输出,我们要进行编程就需要掌握其ABI,了解其如何传递输入参数、返回值、调用函数。
go汇编使用的是caller-save
模式,因此被调用函数的参数、返回值、栈位置都需要由调用者维护、准备。因此 当你需要调用一个函数时,需要先将这些工作准备好,方能调用下一个函数,另外这些都需要进行内存对其,对其 的大小是sizeof(uintptr)
。
我们将结合一些函数来进行说明:
无局部变量的函数
注意:其实go函数的栈布局在是否有局部变量时,是没有区别的。在没有局部变量时,只是少了局部变量那部分空间。在当时研究的时候,未能抽象其共同部分,导致拆成2部分写了。
对于手写汇编来说,所有参数通过栈来传递,通过伪寄存器FP
偏移进行访问。函数的返回值跟随在输入参数 后面,并且对其到指针大小。amd64平台上指针大小为8byte。如果输入参数为20byte。则返回值会在从24byte其, 中间跳过4byte用以对其。
func xxx(a, b, c int) (e, f, g int) {
e, f, g = a, b, c
return
}
该函数有3个输入参数、3个返回值,假设我们使用x86_64平台,因此一个int占用8byte。则其函数栈空间为:
高地址位
┼───────────┼
│ 返回值g │
┼───────────┼
│ 返回值f │
┼───────────┼
│ 返回值e │
┼───────────┼
│ 参数之c │
┼───────────┼
│ 参数之b │
┼───────────┼
│ 参数之a │
┼───────────┼ <-- 伪FP
│ 函数返回地址│
┼───────────┼ <-- 伪SP 和 硬件SP
低地址位
各个输入参数和返回值将以倒序的方式从高地址位分布于栈空间上,由于没有局部变量,则xxx的函数栈空间为 0,根据前面的描述,该函数应该为:
#include "textflag.h"
TEXT ·xxx(SB),NOSPLIT,$0-48
MOVQ a+0(FP), AX // FP+0 为参数a,将其值拷贝到寄存器AX中
MOVQ AX, e+24(FP) // FP+24 为返回值e,将寄存器AX赋值给返回值e
MOVQ b+8(FP), AX // FP+8 为参数b
MOVQ AX, f+32(FP) // FP+24 为返回值f
MOVQ c+16(FP), AX // FP+16 为参数c
MOVQ AX, g+40(FP) // FP+24 为返回值g
RET // return
然后在一个go源文件(.go)中声明该函数即可
func xxx(a, b, c int) (e, f, g int)
有局部变量的函数
当函数中有局部变量时,函数的栈空间就应该留出足够的空间:
func zzz(a, b, c int) [3]int{
var d [3]int
d[0], d[1], d[2] = a, b, c
return d
}
当函数中有局部变量时,我们就需要移动函数栈帧来进行栈内存分配,因此我们就需要了解相关平台计算机体系 的一些设计问题,在此我们只讲解x86平台的相关要求,我们先需要参考:
其中讲到x86平台上BP
寄存器,通常用来指示函数栈的起始位置,仅仅其一个指示作用,现代编译器生成的代码 通常不会用到BP
寄存器,但是可能某些debug工具会用到该寄存器来寻找函数参数、局部变量等。因此我们写汇编 代码时,也最好将栈起始位置存储在BP
寄存器中。因此在amd64平台上,会在函数返回值之后插入8byte来放置CALLER BP
寄存器。
此外需要注意的是,CALLER BP
是在编译期由编译器插入的,用户手写代码时,计算framesize
时是不包括这个 CALLER BP
部分的,但是要计算函数返回值的8byte。是否插入CALLER BP
的主要判断依据是:
- 函数的栈帧大小大于
0
- 下述函数返回
true
func Framepointer_enabled(goos, goarch string) bool { return framepointer_enabled != 0 && goarch == "amd64" && goos != "nacl" }
此处需要注意,go编译器会将函数栈空间自动加8,用于存储BP寄存器,跳过这8字节后才是函数栈上局部变量的内存。 逻辑上的FP/SP位置就是我们在写汇编代码时,计算偏移量时,FP/SP的基准位置,因此局部变量的内存在逻辑SP的低地 址侧,因此我们访问时,需要向负方向偏移。
实际上,在该函数被调用后,编译器会添加SUBQ
/LEAQ
代码修改物理SP指向的位置。我们在反汇编的代码中能看到这部分操作,因此我们需要注意物理SP与伪SP指向位置的差别。
高地址位
┼───────────┼
│ 返回值g │
┼───────────┼
│ 返回值f │
┼───────────┼
│ 返回值e │
┼───────────┼
│ 参数之c │
┼───────────┼
│ 参数之b │
┼───────────┼
│ 参数之a │
┼───────────┼ <-- 伪FP
│ 函数返回地址│
┼───────────┼
│ CALLER BP │
┼───────────┼ <-- 伪SP
│ 变量之[2] │ <-- d0-8(SP)
┼───────────┼
│ 变量之[1] │ <-- d1-16(SP)
┼───────────┼
│ 变量之[0] │ <-- d2-24(SP)
┼───────────┼ <-- 硬件SP
低地址位
图中的函数返回地址
使用的是调用者的栈空间,CALLER BP
由编辑器“透明”插入,因此,不算在当前函数的栈空间内。我们实现该函数的汇编代码:
#include "textflag.h"
TEXT ·zzz(SB),NOSPLIT,$24-48 // $24值栈空间24byte,- 后面的48跟上面的含义一样,
// 在编译后,栈空间会被+8用于存储BP寄存器,这步骤由编译器自动添加
MOVQ $0, d-24(SP) // 初始化d[0]
MOVQ $0, d-16(SP) // 初始化d[1]
MOVQ $0, d-8(SP) // 初始化d[2]
MOVQ a+0(FP), AX // d[0] = a
MOVQ AX, d-24(SP) //
MOVQ b+8(FP), AX // d[1] = b
MOVQ AX, d-16(SP) //
MOVQ c+16(FP), AX // d[2] = c
MOVQ AX, d-8(SP) //
MOVQ d-24(SP), AX // d[0] = return [0]
MOVQ AX, r+24(FP) //
MOVQ d-16(SP), AX // d[1] = return [1]
MOVQ AX, r+32(FP) //
MOVQ d-8(SP), AX // d[2] = return [2]
MOVQ AX, r+40(FP) //
RET // return
然后我们go源码中声明该函数:
func zzz(a, b, c int) [3]int
汇编中调用其他函数
在汇编中调用其他函数通常可以使用2中方式:
-
JMP
含义为跳转,直接跳转时,与函数栈空间相关的几个寄存器SP
/FP
不会发生变化,可以理解为被调用函数 复用调用者的栈空间,此时,参数传递采用寄存器传递,调用者和被调用者协商好使用那些寄存传递参数,调用者将 参数写入这些寄存器,然后跳转到被调用者,被调用者从相关寄存器读出参数。具体实践可以参考1。 -
CALL
通过CALL
命令来调用其他函数时,栈空间会发生响应的变化(寄存器SP
/FP
随之发生变化),传递参数时,我们需要输入参数、返回值按之前将的栈布局安排在调用者的栈顶(低地址段),然后再调用CALL
命令来调用其函数,调用CALL
命令后,SP
寄存器会下移一个WORD
(x86_64上是8byte),然后进入新函数的栈空间运行。下图中return addr(函数返回地址)
不需要用户手动维护,CALL
指令会自动维护。
下面演示一个CALL
方法调用的例子:
func yyy(a, b, c int) [3]int {
return zzz(a, b, c)
}
该函数使用汇编实现就是:
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
然后在go文件中声明yyy0
,并且在main
函数中调用:
func yyy0(a, b, c int) [3]int
//go:noinline
func yyy1(a, b, c int) [3]int {
return zzz(a, b, c)
}
func main() {
y0 := yyy0(1, 2, 3)
y1 := yyy1(1, 2, 3)
println("yyy0", y0[0], y0[1], y0[2])
println("yyy1", y1[0], y1[1], y1[2])
}
在函数yyy0
的栈空间分布为:
高地址位
┼───────────┼
│ 返回值[2] │ <-- r2+40(FP)
┼───────────┼
│ 返回值[1] │ <-- r1+32(FP)
┼───────────┼
│ 返回值[0] │ <-- r2+24(FP)
┼───────────┼
│ 参数之c │ <-- c+16(FP)
┼───────────┼
│ 参数之b │ <-- b+8(FP)
┼───────────┼
│ 参数之a │ <-- a+0(FP)
┼───────────┼ <-- 伪FP
│ 函数返回地址│ <-- yyy0函数返回值
┼───────────┼
│ CALLER BP │
┼───────────┼ <-- 伪SP
│ 返回值[2] │ <-- z1-8(SP)
┼───────────┼
│ 返回值[1] │ <-- z1-16(SP)
┼───────────┼
│ 返回值[0] │ <-- z2-24(SP)
┼───────────┼
│ 参数之c │ <-- ic-32(SP)
┼───────────┼
│ 参数之b │ <-- ib-40(SP)
┼───────────┼
│ 参数之a │ <-- ia-48(SP)
┼───────────┼ <-- 硬件SP
低地址位
其调用者和被调用者的栈关系为:
caller
+------------------+
| |
+----------------------> --------------------
| | |
| | caller parent BP |
| BP(pseudo SP) --------------------
| | |
| | Local Var0 |
| --------------------
| | |
| | ....... |
| --------------------
| | |
| | Local VarN |
--------------------
caller stack frame | |
| callee arg2 |
| |------------------|
| | |
| | callee arg1 |
| |------------------|
| | |
| | callee arg0 |
| SP(Real Register) ----------------------------------------------+ FP(virtual register)
| | | |
| | return addr | parent return address |
+----------------------> +------------------+--------------------------- <-------------------------------+
| caller BP | |
| (caller frame pointer) | |
BP(pseudo SP) ---------------------------- |
| | |
| Local Var0 | |
---------------------------- |
| |
| Local Var1 |
---------------------------- callee stack frame
| |
| ..... |
---------------------------- |
| | |
| Local VarN | |
SP(Real Register) ---------------------------- |
| | |
| | |
| | |
| | |
| | |
+--------------------------+ <-------------------------------+
callee
此外我们还可以做一些优化,其中中间的临时变量,让zzz
的输入参数、返回值复用yyy
的输入参数、返回值 这部分空间,其代码为:
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
整个函数调用过程为:
高地址位
┼───────────┼ ┼────────────┼ ┼────────────┼
│ 返回值[2] │ │ 函数返回值 │ │ PC │
┼───────────┼ ┼────────────┼ ┼────────────┼
│ 返回值[1] │ │zzz返回值[2] │ │zzz返回值[2] │
┼───────────┼ ┼────────────┼ ┼────────────┼
│ 返回值[0] │ │zzz返回值[1] │ │zzz返回值[1] │
┼───────────┼ =调整后=> ┼────────────┼ =调用后=> ┼────────────┼
│ 参数之c │ │zzz返回值[0] │ │zzz返回值[0] │
┼───────────┼ ┼────────────┼ ┼────────────┼
│ 参数之b │ │ 参数之c │ │ 参数之c │
┼───────────┼ ┼────────────┼ ┼────────────┼
│ 参数之a │ <-- FP │ 参数之b │ <-- FP │ 参数之b │
┼───────────┼ ┼────────────┼ ┼────────────┼
│ 函数返回值 │ <-- SP │ 参数之a │ <-- SP │ 参数之a │ <--FP
┼───────────┼ ┼────────────┼ ┼────────────┼
│ 函数返回值 │
┼────────────┼
│ CALLER BP │ <--SP(伪) zzz函数栈空间
┼────────────┼
│ zzz变量之2 │
┼────────────┼
│ zzz变量之1 │
┼────────────┼
│ zzz变量之0 │
┼────────────┼
低地址位
然后我们可以使用反汇编来对比我们自己实现的汇编代码版本和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源码版本的优点:
- 对于连续内存使用了
MOVUPS
命令优化,(此处不一定是优化,有时还会劣化,因为X86_64不同 指令集混用时,会产生额外开销)
我们可以运行一下go benchmark
来比较一下两个版本,可以看出自己的汇编版本速度上明显快于go源码版本。
go test -bench=. -v -count=3
goos: darwin
goarch: amd64
BenchmarkYyyGoVersion-4 100000000 16.9 ns/op
BenchmarkYyyGoVersion-4 100000000 17.0 ns/op
BenchmarkYyyGoVersion-4 100000000 17.1 ns/op
BenchmarkYyyAsmVersion-4 200000000 10.1 ns/op
BenchmarkYyyAsmVersion-4 200000000 7.90 ns/op
BenchmarkYyyAsmVersion-4 200000000 8.01 ns/op
PASS
ok go/asm 13.005s
回调函数/闭包
var num int
func call(fn func(), n int) {
fn()
num += n
}
func basecall() {
call(func() {
num += 5
}, 1)
}
如上面所示,当函数(call
)参数中包含回调函数(fn
)时,回调函数的指针通过一种简介方式传入,之所以采用这种设计也是为了照顾闭包调用的实现。接下来简单介绍一下这种传参。当一个函数的参数为一个函数时,其调用者与被调用者之间的关系如下图所示:
caller
+------------------+
| |
+----------------------> --------------------
| | |
| | caller parent BP |
| BP(pseudo SP) --------------------
| | |
| | Local Var0 |
| --------------------
| | |
| | ....... |
| --------------------
caller stack frame | |
| | Local VarN | ┼────────────┼
| |------------------| │ .... │ 如果是闭包时,可
| | | ┼────────────┼ 以扩展该区域存储
| | callee arg1(n) | │ .... │ 闭包中的变量。
| |------------------| ┼────────────┼
| | | ---->│ fn pointer │ 间接临时区域
| | callee arg0 | ┼────────────┼
| SP(Real Register) ----------------------------------------------+ FP(virtual register)
| | | |
| | return addr | parent return address |
+----------------------> +------------------+--------------------------- <-------------------------------+
| caller BP | |
| (caller frame pointer) | |
BP(pseudo SP) ---------------------------- |
| | |
| Local Var0 | |
---------------------------- |
| |
| Local Var1 |
---------------------------- callee stack frame
| |
| ..... |
---------------------------- |
| | |
| Local VarN | |
SP(Real Register) ---------------------------- |
| | |
| | |
+--------------------------+ <-------------------------------+
callee
在golang的ABI中,关于回调函数、闭包的上下文由调用者(caller-basecall
)来维护,被调用者(callee-call
)直接按照规定的格式来使用即可。
- 调用者需要申请一段临时内存区域来存储函数(
func() { num+=5 }
)的指针,当传递参数是闭包时,该临时内存区域开可以进行扩充,用于存储闭包中捕获的变量,通常编译器将该内存区域定义成型为struct { F uintptr; a *int }
的结构。该临时内存区域可以分配在栈上,也可以分配在堆上,还可以分配在寄存器上。到底分配在哪里,需要编译器根据逃逸分析的结果来决定; - 将临时内存区域的地址存储于对应被调用函数入参的对应位置上;其他参数按照上面的常规方法放置;
- 使用
CALL
执行调用被调用函数(callee-call
); - 在被调用函数(
callee-call
)中从对应参数位置中取出临时内存区域的指针存储于指定寄存器DX
(仅针对amd64平台) - 然后从
DX
指向的临时内存区域的首部取出函数(func() { num+=5 }
)指针,存储于AX
(此处寄存器可以任意指定) - 然后在执行
CALL AX
指令来调用传入的回调函数。 - 当回调函数是闭包时,需要使用捕获的变量时,直接通过集群器
DX
加对应偏移量来获取。
下面结合几个例子来理解:
例一
func callback() {
println("xxx")
}
func call(fn func()) {
fn()
}
func call1() {
call(callback)
}
func call0()
其中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 -S
来获取call1
的实现,可以的得到:
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
例二
func call(fn func(), n int) {
fn()
}
func testing() {
var n int
call(func() {
n++
}, 1)
_ = n
}
其生成的汇编为:
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 Go,在go中调用C函数存在着巨大额外开销,而一些短小精悍的C函数,我们可以考虑绕过CGO机制,直接调用,比如runtime
包中vDSO
的调用、fastcgo、rustgo等。要直接调用C函数,就要遵循C的ABI。
amd64 C ABI
在调用C函数时,主流有2种ABI:
-
Windows x64 C and C++ ABI
主要适用于各Windows平台 - System V ABI主要适用于Solaris, Linux, FreeBSD, macOS等。
在ABI规定中,涉及内容繁多,下面简单介绍一下System V ABI
中参数传递的协议:
- 当参数都是整数时,参数少于7个时, 参数从左到右放入寄存器:
rdi
, rsi
, rdx
, rcx
, r8
, r9
- 当参数都是整数时,参数为7个以上时, 前6个与前面一样, 但后面的依次从
右向左
放入栈中,即和32位汇编一样
H(a, b, c, d, e, f, g, h);
=>
a->%rdi, b->%rsi, c->%rdx, d->%rcx, e->%r8, f->%r9
h->8(%esp)
g->(%esp)
CALL H
- 如果参数中包含浮点数时,会利用xmm寄存器传递浮点数,其他参数的位置按顺序排列
- 常用寄存器有16个,分为x86通用寄存器以及r8-r15寄存器
- 通用寄存器中,函数执行前后必须保持原始的寄存器有3个:是rbx、rbp、rsp
- rx寄存器中,最后4个必须保持原值:r12、r13、r14、r15(保持原值的意义是为了让当前函数有可信任的寄存器,减小在函数调用过程中的保存/恢复操作。除了rbp、rsp用于特定用途外,其余5个寄存器可随意使用。)
由于该issue的存在,通常goroutine的栈空间很小,很可能产生栈溢出的错误。解决的方法有:
- 直接切换到
g0
栈,g0
栈是系统原生线程的栈,通常比较大而且与C兼容性更好,切换g0
栈的方式可以参考fastcgo中的实现,但是这有着强烈的版本依赖,不是很推荐; - 调用函数自身声明一个很大的栈空间,迫使goroutine栈扩张。具体参考方法可以参考rustgo,该方法不能确定每一个C函数具体的栈空间需求,只能根据猜测分配一个足够大的,同时也会造成比较大的浪费,也不推荐;
- 使用
runtime·systemstack
切换到g0
栈,同时摆脱了版本依赖。具体方法可以参考numa。
编译/反编译
因为go汇编的资料很少,所以我们需要通过编译、反汇编来学习。
// 编译
go build -gcflags="-S"
go tool compile -S hello.go
go tool compile -l -N -S hello.go // 禁止内联 禁止优化
// 反编译
go tool objdump <binary>
总结
了解go汇编并不是一定要去手写它,因为汇编总是不可移植和难懂的。但是它能够帮助我们了解go的一些底层机制, 了解计算机结构体系,同时我们需要做一些hack的事时可以用得到。
比如,我们可以使用go:noescape
来减少内存的分配:
很多时候,我们可以使函数内计算过程使用栈上的空间做缓存,这样可以减少对内存的使用,并且是计算速度更快:
func xxx() int{
var buf [1024]byte
data := buf[:]
// do something in data
}
但是,很多时候,go编译器的逃逸分析并不让人满意,经常会使buf
移动到堆内存上,造成不必要的内存分配。 这是我们可以使用sync.Pool
,但是总让人不爽。因此我们使用汇编完成一个noescape
函数,绕过go编译器的 逃逸检测,使buf
不会移动到堆内存上。
// 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
//此处使用go编译器的指示
//go:noescape
func noescape(d []byte) (b []byte)
func xxx() int {
var buf [1024]byte
data := noescape(buf[:])
// do something in data
// 这样可以确保buf一定分配在xxx的函数栈上
}
c2goasm
当我们需要做一些密集的数列运算或实现其他算法时,我们可以使用先进CPU的向量扩展指令集进行加速,如sse4_2/avx/avx2/avx-512
等。有些人觉得通常可以遇不见这样的场景,其实能够用到这些的场景还是很多的。比如,我们常用的监控采集go-metrics库,其中就有很多可以优化的地方,如SampleSum、SampleMax、SampleMin这些函数都可以进行加速。
但是,虽然这些方法很简单,但是对于汇编基础很弱的人来说,手写这些sse4_2/avx/avx2/avx-512
指令代码,仍然是很困难的。但是,我们可以利用clang/gcc
这些深度优化过的C语言编译器来帮我们生成对于的汇编代码。
所幸,这项工作已经有人帮我们很好的完成了,那就是c2goasm。c2goasm
可以将C/C++编译器生成的汇编代码转换为golang汇编代码。在这里,我们可以学习该工具如何使用。它可以帮助我们在代码利用上sse4_2/avx/avx2/avx-512
等这些先进指令。但是这些执行需要得到CPU的支持。因此我们先要判断使用的CPU代码是否支持。
注意c2goasm
中其中有很多默认规则需要我们去遵守:
- 我们先需要使用
clang
将c源文件编译成汇编代码clang_c.s
(该文件名随意); - 然后我们可以使用
c2goasm
将汇编代码clang_c.s
转换成go汇编源码xxx.s
; - 我们每使用
c2goasm
生成一个go汇编文件xxx.s
之前,我们先添加一个对应的xxx.go
的源码文件,其中需要包含xxx.s
中函数的声明。 - 当c源码或者
clang_c.s
源码中函数名称为func_xxx
时,经过c2goasm
转成的汇编函数会增加_
前缀,变成_func_xxx
,因此在xxx.go
中的函数声明为_func_xxx
。要求声明的_func_xxx
函数的入参个数与原来C源码中的入参个数相等,且为每个64bit大小。此时go声明函数中需要需要使用slice
/map
时,需要进行额外的转化。如果函数有返回值,则声明对应的go函数时,返回值必须为named return
,即返回值需要由()
包裹,否则会报错:Badly formatted return argument ....
- 如果我们需要生成多种指令的go汇编实现时,我们需要实现对应的多个c函数,因此我们可以使用c的宏辅助我们声明对应的c函数,避免重复的书写。
在linux上,我们可以使用命令cat /proc/cpuinfo |grep flags
来查看CPU支持的指令集。但是在工作环境中,我们的代码需要在多个环境中运行,比如开发环境、和生产环境,这些环境之间可能会有很大差别,因此我们希望我们的代码可以动态支持不同的CPU环境。这里,我们可以用到intel-go/cpuid,我们可以实现多个指令版本的代码,然后根据运行环境中CPU的支持情况,选择实际实行哪一段逻辑:
package main
import (
"fmt"
"github.com/intel-go/cpuid"
)
func main() {
fmt.Println("EnabledAVX", cpuid.EnabledAVX)
fmt.Println("EnabledAVX512", cpuid.EnabledAVX512)
fmt.Println("SSE4_1", cpuid.HasFeature(cpuid.SSE4_1))
fmt.Println("SSE4_2", cpuid.HasFeature(cpuid.SSE4_2))
fmt.Println("AVX", cpuid.HasFeature(cpuid.AVX))
fmt.Println("AVX2", cpuid.HasExtendedFeature(cpuid.AVX2))
}
然后,我们可以先使用C来实现这3个函数:
#include <stdint.h>
/* 我们要实现3中指令的汇编实现,因此我们需要生成3个版本的C代码,此处使用宏来辅助添加后缀,避免生成的函数名冲突 */
#if defined ENABLE_AVX2
#define NAME(x) x##_avx2
#elif defined ENABLE_AVX
#define NAME(x) x##_avx
#elif defined ENABLE_SSE4_2
#define NAME(x) x##_sse4_2
#endif
int64_t NAME(sample_sum)(int64_t *beg, int64_t len) {
int64_t sum = 0;
int64_t *end = beg + len;
while (beg < end) {
sum += *beg++;
}
return sum;
}
int64_t NAME(sample_max)(int64_t *beg, int64_t len) {
int64_t max = 0x8000000000000000;
int64_t *end = beg + len;
if (len == 0) {
return 0;
}
while (beg < end) {
if (*beg > max) {
max = *beg;
}
beg++;
}
return max;
}
int64_t NAME(sample_min)(int64_t *beg, int64_t len) {
if (len == 0) {
return 0;
}
int64_t min = 0x7FFFFFFFFFFFFFFF;
int64_t *end = beg + len;
while (beg < end) {
if (*beg < min) {
min = *beg;
}
beg++;
}
return min;
}
然后使用clang
生成三中指令的汇编代码:
clang -S -DENABLE_SSE4_2 -target x86_64-unknown-none -masm=intel -mno-red-zone -mstackrealign -mllvm -inline-threshold=1000 -fno-asynchronous-unwind-tables -fno-exceptions -fno-rtti -O3 -fno-builtin -ffast-math -msse4 lib/sample.c -o lib/sample_sse4.s
clang -S -DENABLE_AVX -target x86_64-unknown-none -masm=intel -mno-red-zone -mstackrealign -mllvm -inline-threshold=1000 -fno-asynchronous-unwind-tables -fno-exceptions -fno-rtti -O3 -fno-builtin -ffast-math -mavx lib/sample.c -o lib/sample_avx.s
clang -S -DENABLE_AVX2 -target x86_64-unknown-none -masm=intel -mno-red-zone -mstackrealign -mllvm -inline-threshold=1000 -fno-asynchronous-unwind-tables -fno-exceptions -fno-rtti -O3 -fno-builtin -ffast-math -mavx2 lib/sample.c -o lib/sample_avx2.s
注意:此处目前有一个待解决的问题issues8,如果谁指定如何解决,请帮助我一下。使用clang
生成的AVX2汇编代码,其中局部变量0x8000000000000000
/0x7FFFFFFFFFFFFFFF
会被分片到RODATA
段,并且使用32byte对其。使用c2goasm
转换时,会生成一个很大的全局变量(几个G…,此处会运行很久)。目前的解决方式是,将生成
.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
回归正题,添加对应的go函数声明,我们要生成的三个go汇编文件为:sample_sse4_amd64.s
,sample_avx_amd64.s
和sample_avx2_amd64.s
,因此对应的三个go文件为:sample_sse4_amd64.go
,sample_avx_amd64.go
和sample_avx2_amd64.go
。 其中声明的go函数为下面,我们挑其中一个文件说,其他两个类似:
package sample
import "unsafe"
// 声明的go汇编函数,不支持go buildin 数据类型,参数个数要与c实现的参数个数相等,最多支持14个。
//go:noescape
func _sample_sum_sse4_2(addr unsafe.Pointer, len int64) (x int64)
//go:noescape
func _sample_max_sse4_2(addr unsafe.Pointer, len int64) (x int64)
//go:noescape
func _sample_min_sse4_2(addr unsafe.Pointer, len int64) (x int64)
// 因为我们希望输入参数为一个slice,则我们在下面进行3个封装。
func sample_sum_sse4_2(v []int64) int64 {
x := (*slice)(unsafe.Pointer(&v))
return _sample_sum_sse4_2(x.addr, x.len)
}
func sample_max_sse4_2(v []int64) int64 {
x := (*slice)(unsafe.Pointer(&v))
return _sample_max_sse4_2(x.addr, x.len)
}
func sample_min_sse4_2(v []int64) int64 {
x := (*slice)(unsafe.Pointer(&v))
return _sample_min_sse4_2(x.addr, x.len)
}
有了这些函数声明,我们就可以使用c2goasm
进行转换了:
c2goasm -a -f lib/sample_sse4.s sample_sse4_amd64.s
c2goasm -a -f lib/sample_avx.s sample_avx_amd64.s
c2goasm -a -f lib/sample_avx2.s sample_avx2_amd64.s
然后我们添加一段初始化逻辑,根据CPU支持的指令集来选择使用对应的实现:
import (
"math"
"unsafe"
"github.com/intel-go/cpuid"
)
var (
// SampleSum returns the sum of the slice of int64.
SampleSum func(values []int64) int64
// SampleMax returns the maximum value of the slice of int64.
SampleMax func(values []int64) int64
// SampleMin returns the minimum value of the slice of int64.
SampleMin func(values []int64) int64
)
func init() {
switch {
case cpuid.EnabledAVX && cpuid.HasExtendedFeature(cpuid.AVX2):
SampleSum = sample_sum_avx2
SampleMax = sample_max_avx2
SampleMin = sample_min_avx2
case cpuid.EnabledAVX && cpuid.HasFeature(cpuid.AVX):
SampleSum = sample_sum_avx
SampleMax = sample_max_avx
SampleMin = sample_min_avx
case cpuid.HasFeature(cpuid.SSE4_2):
SampleSum = sample_sum_sse4_2
SampleMax = sample_max_sse4_2
SampleMin = sample_min_sse4_2
default:
// 纯go实现
SampleSum = sampleSum
SampleMax = sampleMax
SampleMin = sampleMin
}
}
此时我们的工作就完成了,我们可以使用go test
的benchmark来进行比较,看看跟之前的纯go实现,性能提升了多少:
name old time/op new time/op delta
SampleSum-4 519ns ± 1% 53ns ± 2% -89.72% (p=0.000 n=10+9)
SampleMax-4 676ns ± 2% 183ns ± 2% -73.00% (p=0.000 n=10+10)
SampleMin-4 627ns ± 1% 180ns ± 1% -71.27% (p=0.000 n=10+9)
我们可以看出,sum方法得到10倍的提升,max/min得到了3倍多的提升,可能是因为max/min方法中每次循环中都有一次分支判断的原因,导致其提升效果不如sum方法那么多。
完整的实现在lrita/c2goasm-example
RDTSC精确计时
在x86架构的CPU上,每个CPU上有一个单调递增的时间戳寄存器,可以帮助我们精确计算每一段逻辑的精确耗时,其调用代价和计时精度远远优于time.Now()
,在runtime
中有着广泛应用,可以参考runtime·cputicks
的实现。在但是对于指令比较复杂的函数逻辑并不适用于此方法,因为该寄存器时与CPU核心绑定,每个CPU核心上的寄存器可能并不一致,如果被测量的函数比较长,在运行过程中很可能发生CPU核心/线程的调度,使该函数在执行的过程中被调度到不同的CPU核心上,这样测量前后取到的时间戳不是来自于同一个寄存器,从而造成比较大的误差。
还要注意的是RDTSC
并不与其他指令串行,为了保证计时的准确性,需要在调用RDTSC前增加对应的内存屏障,保证其准确性。
有时候我们想要知道写出来的代码是怎么编译执行的,这时候go tool compile
就是一个很好用的工具。
如何输出汇编代码
有三种方法可以输出go代码的汇编代码:
-
go tool compile
生成obj文件 -
go build -gcflags
生成最终二进制文件 - 先
go build
然后在go tool objdump
对二进制文件进行反汇编
当然,具体行为还需要在这些命令后面加上具体的flag。flag的内容可以通过查阅官方文档获得。
本文涉及Flags说明
-N 禁止优化
-S 输出汇编代码
-l 禁止内联
什么是内联
如果学过*c/c++*就知道,通过inline
关键字修饰的函数叫做内联函数。内联函数的优势是在编译过程中直接展开函数中的代码,将其替换到源码的函数调用位置,这样可以节省函数调用的消耗,提高运行速度。适用于函数体短小且频繁调用的函数,如果函数体太大了,会增大目标代码。是一种空间换时间的做法。
go编译器会智能判断对代码进行优化和使用汇编。而我们在分析学习代码调用情况的时候需要禁止掉这些优化,避免混淆理解。
以下我们使用go build -gcflags="-N -l -S" file
来获得汇编代码。
获取一份简单的汇编代码
现在手上有一份关于range
的代码,但是我们运行之后出现了一些问题12:
package assembly
import "fmt"
func RangeClause() {
arr := []int{1, 2, 3}
var newArr []*int
for _, v := range arr {
newArr = append(newArr, &v)
}
for _, v := range newArr {
fmt.Println(*v)
}
}
结果输出了三个3。
也许我们在学习过程中见过类似的错误,然后设法(或者别人告诉我们怎么)避免错误,但是仍然百思不得其解,知其然不知其所以然。
将&v
替换成&arr[i]
package assembly
import "fmt"
func RangeClause() {
arr := []int{1, 2, 3}
var newArr []*int
for i := range arr {
newArr = append(newArr, &arr[i])
}
for _, v := range newArr {
fmt.Println(*v)
}
}
这时候获取汇编代码就可以排上用场了。
执行go build -gcflags="-N -l -S" range_clause.go
,得到下面这份输出结果:
"".RangeClause STEXT size=842 args=0x0 locals=0x158
0x0000 00000 (range_clause.go:5) TEXT "".RangeClause(SB), ABIInternal, $344-0
0x0000 00000 (range_clause.go:5) MOVQ (TLS), CX
0x0009 00009 (range_clause.go:5) LEAQ -216(SP), AX
0x0011 00017 (range_clause.go:5) CMPQ AX, 16(CX)
0x0015 00021 (range_clause.go:5) JLS 832
0x001b 00027 (range_clause.go:5) SUBQ $344, SP
0x0022 00034 (range_clause.go:5) MOVQ BP, 336(SP)
0x002a 00042 (range_clause.go:5) LEAQ 336(SP), BP
0x0032 00050 (range_clause.go:5) FUNCDATA $0, gclocals·f0a67958015464e4cc8847ce0df60843(SB)
0x0032 00050 (range_clause.go:5) FUNCDATA $1, gclocals·1be50b3ff1c6bce621b19ced5cafc212(SB)
0x0032 00050 (range_clause.go:5) FUNCDATA $2, gclocals·160a1dd0c9595e8bcf8efc4c6b948d91(SB)
0x0032 00050 (range_clause.go:5) FUNCDATA $3, "".RangeClause.stkobj(SB)
0x0032 00050 (range_clause.go:6) PCDATA $0, $1
0x0032 00050 (range_clause.go:6) PCDATA $1, $0
0x0032 00050 (range_clause.go:6) LEAQ ""..autotmp_9+120(SP), AX
0x0037 00055 (range_clause.go:6) PCDATA $1, $1
0x0037 00055 (range_clause.go:6) MOVQ AX, ""..autotmp_8+152(SP)
0x003f 00063 (range_clause.go:6) PCDATA $0, $0
0x003f 00063 (range_clause.go:6) TESTB AL, (AX)
0x0041 00065 (range_clause.go:6) MOVQ ""..stmp_0(SB), AX
0x0048 00072 (range_clause.go:6) MOVQ AX, ""..autotmp_9+120(SP)
0x004d 00077 (range_clause.go:6) MOVUPS ""..stmp_0+8(SB), X0
0x0054 00084 (range_clause.go:6) MOVUPS X0, ""..autotmp_9+128(SP)
0x005c 00092 (range_clause.go:6) PCDATA $0, $1
0x005c 00092 (range_clause.go:6) PCDATA $1, $0
0x005c 00092 (range_clause.go:6) MOVQ ""..autotmp_8+152(SP), AX
0x0064 00100 (range_clause.go:6) TESTB AL, (AX)
0x0066 00102 (range_clause.go:6) JMP 104
0x0068 00104 (range_clause.go:6) PCDATA $0, $0
0x0068 00104 (range_clause.go:6) PCDATA $1, $2
0x0068 00104 (range_clause.go:6) MOVQ AX, "".arr+240(SP)
0x0070 00112 (range_clause.go:6) MOVQ $3, "".arr+248(SP)
0x007c 00124 (range_clause.go:6) MOVQ $3, "".arr+256(SP)
0x0088 00136 (range_clause.go:7) PCDATA $1, $3
0x0088 00136 (range_clause.go:7) MOVQ $0, "".newArr+216(SP)
0x0094 00148 (range_clause.go:7) XORPS X0, X0
0x0097 00151 (range_clause.go:7) MOVUPS X0, "".newArr+224(SP)
0x009f 00159 (range_clause.go:8) PCDATA $0, $1
0x009f 00159 (range_clause.go:8) LEAQ type.int(SB), AX
0x00a6 00166 (range_clause.go:8) PCDATA $0, $0
0x00a6 00166 (range_clause.go:8) MOVQ AX, (SP)
0x00aa 00170 (range_clause.go:8) CALL runtime.newobject(SB)
0x00af 00175 (range_clause.go:8) PCDATA $0, $1
0x00af 00175 (range_clause.go:8) MOVQ 8(SP), AX
0x00b4 00180 (range_clause.go:8) PCDATA $0, $0
0x00b4 00180 (range_clause.go:8) PCDATA $1, $4
0x00b4 00180 (range_clause.go:8) MOVQ AX, "".&v+192(SP)
0x00bc 00188 (range_clause.go:8) MOVQ "".arr+256(SP), AX
0x00c4 00196 (range_clause.go:8) MOVQ "".arr+248(SP), CX
0x00cc 00204 (range_clause.go:8) PCDATA $0, $2
0x00cc 00204 (range_clause.go:8) PCDATA $1, $5
0x00cc 00204 (range_clause.go:8) MOVQ "".arr+240(SP), DX
0x00d4 00212 (range_clause.go:8) PCDATA $0, $0
0x00d4 00212 (range_clause.go:8) PCDATA $1, $6
0x00d4 00212 (range_clause.go:8) MOVQ DX, ""..autotmp_5+288(SP)
0x00dc 00220 (range_clause.go:8) MOVQ CX, ""..autotmp_5+296(SP)
0x00e4 00228 (range_clause.go:8) MOVQ AX, ""..autotmp_5+304(SP)
0x00ec 00236 (range_clause.go:8) MOVQ $0, ""..autotmp_10+112(SP)
0x00f5 00245 (range_clause.go:8) MOVQ ""..autotmp_5+296(SP), AX
0x00fd 00253 (range_clause.go:8) MOVQ AX, ""..autotmp_11+104(SP)
0x0102 00258 (range_clause.go:8) JMP 260
0x0104 00260 (range_clause.go:8) MOVQ ""..autotmp_11+104(SP), CX
0x0109 00265 (range_clause.go:8) CMPQ ""..autotmp_10+112(SP), CX
0x010e 00270 (range_clause.go:8) JLT 277
0x0110 00272 (range_clause.go:8) JMP 516
0x0115 00277 (range_clause.go:8) MOVQ ""..autotmp_10+112(SP), CX
0x011a 00282 (range_clause.go:8) SHLQ $3, CX
0x011e 00286 (range_clause.go:8) PCDATA $0, $3
0x011e 00286 (range_clause.go:8) ADDQ ""..autotmp_5+288(SP), CX
0x0126 00294 (range_clause.go:8) PCDATA $0, $0
0x0126 00294 (range_clause.go:8) MOVQ (CX), CX
0x0129 00297 (range_clause.go:8) MOVQ CX, ""..autotmp_12+96(SP)
0x012e 00302 (range_clause.go:8) PCDATA $0, $2
0x012e 00302 (range_clause.go:8) MOVQ "".&v+192(SP), DX
0x0136 00310 (range_clause.go:8) PCDATA $0, $0
0x0136 00310 (range_clause.go:8) MOVQ CX, (DX)
0x0139 00313 (range_clause.go:9) PCDATA $0, $3
0x0139 00313 (range_clause.go:9) MOVQ "".&v+192(SP), CX
0x0141 00321 (range_clause.go:9) PCDATA $0, $0
0x0141 00321 (range_clause.go:9) PCDATA $1, $7
0x0141 00321 (range_clause.go:9) MOVQ CX, ""..autotmp_13+184(SP)
0x0149 00329 (range_clause.go:9) MOVQ "".newArr+232(SP), CX
0x0151 00337 (range_clause.go:9) MOVQ "".newArr+224(SP), DX
0x0159 00345 (range_clause.go:9) PCDATA $0, $4
0x0159 00345 (range_clause.go:9) PCDATA $1, $8
0x0159 00345 (range_clause.go:9) MOVQ "".newArr+216(SP), BX
0x0161 00353 (range_clause.go:9) LEAQ 1(DX), SI
0x0165 00357 (range_clause.go:9) CMPQ SI, CX
0x0168 00360 (range_clause.go:9) JLS 364
0x016a 00362 (range_clause.go:9) JMP 446
0x016c 00364 (range_clause.go:9) PCDATA $0, $-2
0x016c 00364 (range_clause.go:9) PCDATA $1, $-2
0x016c 00364 (range_clause.go:9) JMP 366
0x016e 00366 (range_clause.go:9) PCDATA $0, $5
0x016e 00366 (range_clause.go:9) PCDATA $1, $9
0x016e 00366 (range_clause.go:9) MOVQ ""..autotmp_13+184(SP), AX
0x0176 00374 (range_clause.go:9) PCDATA $0, $6
0x0176 00374 (range_clause.go:9) LEAQ (BX)(DX*8), DI
0x017a 00378 (range_clause.go:9) PCDATA $0, $-2
0x017a 00378 (range_clause.go:9) PCDATA $1, $-2
0x017a 00378 (range_clause.go:9) CMPL runtime.writeBarrier(SB), $0
0x0181 00385 (range_clause.go:9) JEQ 389
0x0183 00387 (range_clause.go:9) JMP 439
0x0185 00389 (range_clause.go:9) MOVQ AX, (BX)(DX*8)
0x0189 00393 (range_clause.go:9) JMP 395
0x018b 00395 (range_clause.go:9) PCDATA $0, $0
0x018b 00395 (range_clause.go:9) PCDATA $1, $6
0x018b 00395 (range_clause.go:9) MOVQ BX, "".newArr+216(SP)
0x0193 00403 (range_clause.go:9) MOVQ SI, "".newArr+224(SP)
0x019b 00411 (range_clause.go:9) MOVQ CX, "".newArr+232(SP)
0x01a3 00419 (range_clause.go:9) JMP 421
0x01a5 00421 (range_clause.go:8) MOVQ ""..autotmp_10+112(SP), CX
0x01aa 00426 (range_clause.go:8) INCQ CX
0x01ad 00429 (range_clause.go:8) MOVQ CX, ""..autotmp_10+112(SP)
0x01b2 00434 (range_clause.go:8) JMP 260
0x01b7 00439 (range_clause.go:9) PCDATA $0, $-2
0x01b7 00439 (range_clause.go:9) PCDATA $1, $-2
0x01b7 00439 (range_clause.go:9) CALL runtime.gcWriteBarrier(SB)
0x01bc 00444 (range_clause.go:9) JMP 395
0x01be 00446 (range_clause.go:9) PCDATA $0, $4
0x01be 00446 (range_clause.go:9) PCDATA $1, $8
0x01be 00446 (range_clause.go:9) MOVQ DX, ""..autotmp_21+64(SP)
0x01c3 00451 (range_clause.go:9) PCDATA $0, $5
0x01c3 00451 (range_clause.go:9) LEAQ type.*int(SB), AX
0x01ca 00458 (range_clause.go:9) PCDATA $0, $4
0x01ca 00458 (range_clause.go:9) MOVQ AX, (SP)
0x01ce 00462 (range_clause.go:9) PCDATA $0, $0
0x01ce 00462 (range_clause.go:9) MOVQ BX, 8(SP)
0x01d3 00467 (range_clause.go:9) MOVQ DX, 16(SP)
0x01d8 00472 (range_clause.go:9) MOVQ CX, 24(SP)
0x01dd 00477 (range_clause.go:9) MOVQ SI, 32(SP)
0x01e2 00482 (range_clause.go:9) CALL runtime.growslice(SB)
0x01e7 00487 (range_clause.go:9) PCDATA $0, $4
0x01e7 00487 (range_clause.go:9) MOVQ 40(SP), BX
0x01ec 00492 (range_clause.go:9) MOVQ 48(SP), AX
0x01f1 00497 (range_clause.go:9) MOVQ 56(SP), CX
0x01f6 00502 (range_clause.go:9) LEAQ 1(AX), SI
0x01fa 00506 (range_clause.go:9) MOVQ ""..autotmp_21+64(SP), DX
0x01ff 00511 (range_clause.go:9) JMP 366
0x0204 00516 (range_clause.go:11) PCDATA $0, $0
0x0204 00516 (range_clause.go:11) PCDATA $1, $10
0x0204 00516 (range_clause.go:11) MOVQ "".newArr+232(SP), AX
0x020c 00524 (range_clause.go:11) MOVQ "".newArr+224(SP), CX
0x0214 00532 (range_clause.go:11) PCDATA $0, $2
0x0214 00532 (range_clause.go:11) PCDATA $1, $0
0x0214 00532 (range_clause.go:11) MOVQ "".newArr+216(SP), DX
0x021c 00540 (range_clause.go:11) PCDATA $0, $0
0x021c 00540 (range_clause.go:11) PCDATA $1, $11
0x021c 00540 (range_clause.go:11) MOVQ DX, ""..autotmp_6+264(SP)
0x0224 00548 (range_clause.go:11) MOVQ CX, ""..autotmp_6+272(SP)
0x022c 00556 (range_clause.go:11) MOVQ AX, ""..autotmp_6+280(SP)
0x0234 00564 (range_clause.go:11) MOVQ $0, ""..autotmp_14+88(SP)
0x023d 00573 (range_clause.go:11) MOVQ ""..autotmp_6+272(SP), AX
0x0245 00581 (range_clause.go:11) MOVQ AX, ""..autotmp_15+80(SP)
0x024a 00586 (range_clause.go:11) JMP 588
0x024c 00588 (range_clause.go:11) MOVQ ""..autotmp_15+80(SP), AX
0x0251 00593 (range_clause.go:11) CMPQ ""..autotmp_14+88(SP), AX
0x0256 00598 (range_clause.go:11) JLT 605
0x0258 00600 (range_clause.go:11) JMP 816
0x025d 00605 (range_clause.go:11) MOVQ ""..autotmp_14+88(SP), AX
0x0262 00610 (range_clause.go:11) SHLQ $3, AX
0x0266 00614 (range_clause.go:11) PCDATA $0, $1
0x0266 00614 (range_clause.go:11) ADDQ ""..autotmp_6+264(SP), AX
0x026e 00622 (range_clause.go:11) MOVQ (AX), AX
0x0271 00625 (range_clause.go:11) MOVQ AX, ""..autotmp_16+176(SP)
0x0279 00633 (range_clause.go:11) MOVQ AX, "".v+144(SP)
0x0281 00641 (range_clause.go:12) TESTB AL, (AX)
0x0283 00643 (range_clause.go:12) PCDATA $0, $0
0x0283 00643 (range_clause.go:12) MOVQ (AX), AX
0x0286 00646 (range_clause.go:12) MOVQ AX, ""..autotmp_17+72(SP)
0x028b 00651 (range_clause.go:12) MOVQ AX, (SP)
0x028f 00655 (range_clause.go:12) CALL runtime.convT64(SB)
0x0294 00660 (range_clause.go:12) PCDATA $0, $1
0x0294 00660 (range_clause.go:12) MOVQ 8(SP), AX
0x0299 00665 (range_clause.go:12) PCDATA $0, $0
0x0299 00665 (range_clause.go:12) PCDATA $1, $12
0x0299 00665 (range_clause.go:12) MOVQ AX, ""..autotmp_18+168(SP)
0x02a1 00673 (range_clause.go:12) PCDATA $1, $13
0x02a1 00673 (range_clause.go:12) XORPS X0, X0
0x02a4 00676 (range_clause.go:12) MOVUPS X0, ""..autotmp_7+200(SP)
0x02ac 00684 (range_clause.go:12) PCDATA $0, $1
0x02ac 00684 (range_clause.go:12) PCDATA $1, $12
0x02ac 00684 (range_clause.go:12) LEAQ ""..autotmp_7+200(SP), AX
0x02b4 00692 (range_clause.go:12) MOVQ AX, ""..autotmp_20+160(SP)
0x02bc 00700 (range_clause.go:12) TESTB AL, (AX)
0x02be 00702 (range_clause.go:12) PCDATA $0, $7
0x02be 00702 (range_clause.go:12) PCDATA $1, $11
0x02be 00702 (range_clause.go:12) MOVQ ""..autotmp_18+168(SP), CX
0x02c6 00710 (range_clause.go:12) PCDATA $0, $8
0x02c6 00710 (range_clause.go:12) LEAQ type.int(SB), DX
0x02cd 00717 (range_clause.go:12) PCDATA $0, $7
0x02cd 00717 (range_clause.go:12) MOVQ DX, ""..autotmp_7+200(SP)
0x02d5 00725 (range_clause.go:12) PCDATA $0, $1
0x02d5 00725 (range_clause.go:12) MOVQ CX, ""..autotmp_7+208(SP)
0x02dd 00733 (range_clause.go:12) TESTB AL, (AX)
0x02df 00735 (range_clause.go:12) JMP 737
0x02e1 00737 (range_clause.go:12) MOVQ AX, ""..autotmp_19+312(SP)
0x02e9 00745 (range_clause.go:12) MOVQ $1, ""..autotmp_19+320(SP)
0x02f5 00757 (range_clause.go:12) MOVQ $1, ""..autotmp_19+328(SP)
0x0301 00769 (range_clause.go:12) PCDATA $0, $0
0x0301 00769 (range_clause.go:12) MOVQ AX, (SP)
0x0305 00773 (range_clause.go:12) MOVQ $1, 8(SP)
0x030e 00782 (range_clause.go:12) MOVQ $1, 16(SP)
0x0317 00791 (range_clause.go:12) CALL fmt.Println(SB)
0x031c 00796 (range_clause.go:12) JMP 798
0x031e 00798 (range_clause.go:11) MOVQ ""..autotmp_14+88(SP), AX
0x0323 00803 (range_clause.go:11) INCQ AX
0x0326 00806 (range_clause.go:11) MOVQ AX, ""..autotmp_14+88(SP)
0x032b 00811 (range_clause.go:11) JMP 588
0x0330 00816 (<unknown line number>) PCDATA $1, $0
0x0330 00816 (<unknown line number>) MOVQ 336(SP), BP
0x0338 00824 (<unknown line number>) ADDQ $344, SP
0x033f 00831 (<unknown line number>) RET
0x0340 00832 (<unknown line number>) NOP
0x0340 00832 (range_clause.go:5) PCDATA $1, $-1
0x0340 00832 (range_clause.go:5) PCDATA $0, $-1
0x0340 00832 (range_clause.go:5) CALL runtime.morestack_noctxt(SB)
0x0345 00837 (range_clause.go:5) JMP 0
汇编的简单知识
go使用的汇编叫做plan9汇编
。最初go是在plan9系统上开发的,后来才在Linux和Mac上实现。
关于plan9汇编的入门,推荐看这个视频3:
plan9汇编入门|go夜读
其中一些汇编知识是通用的4,GoDoc也提供了go汇编的快速引导5,另外也有一部分可以参考plan9汇编手册6。
寄存器
寄存器是CPU内部用来存放数据的一些小型存储区域,用来暂时存放参与运算的数据和运算结果。
分类
这里提及的寄存器可以分为三类:
- 后缀Register的寄存器属于数据类寄存器
- 后缀为Pointer的寄存器属于指针类寄存器
- 后缀为Index的寄存器属于索引类寄存器
助记符 | 名字 | 用途 |
AX | 累加寄存器(AccumulatorRegister) | 用于存放数据,包括算术、操作数、结果和临时存放地址 |
BX | 基址寄存器(BaseRegister) | 用于存放访问存储器时的地址 |
CX | 计数寄存器(CountRegister) | 用于保存计算值,用作计数器 |
DX | 数据寄存器(DataRegister) | 用于数据传递,在寄存器间接寻址中的I/O指令中存放I/O端口的地址 |
SP | 堆栈顶指针(StackPointer) | 如果是 |
BP | 堆栈基指针(BasePointer) | 保存在进入函数前的栈顶基址 |
SB | 静态基指针(StaticBasePointer) | go汇编的伪寄存器。 |
FP | 栈帧指针(FramePointer) | go汇编的伪寄存器。引用函数的输入参数,形式是 |
SI | 源变址寄存器(SourceIndex) | 用于存放源操作数的偏移地址 |
DI | 目的寄存器(DestinationIndex) | 用于存放目的操作数的偏移地址 |
操作指令
用于指导汇编如何进行。以下指令后缀Q说明是64位上的汇编指令。
助记符 | 指令种类 | 用途 | 示例 |
MOVQ | 传送 | 数据传送 | |
LEAQ | 传送 | 地址传送 | |
|
|
| |
|
|
| |
ADDQ | 运算 | 相加并赋值 | |
SUBQ | 运算 | 相减并赋值 | 略,同上 |
IMULQ | 运算 | 无符号乘法 | 略,同上 |
IDIVQ | 运算 | 无符号除法 | |
CMPQ | 运算 | 对两数相减,比较大小 | |
CALL | 转移 | 调用函数 | |
JMP | 转移 | 无条件转移指令 | |
JLS | 转移 | 条件转移指令 | |
可以看到,表中的 |
|
|
|
标志位
助记符 | 名字 | 用途 |
OF | 溢出 | 0为无溢出 1为溢出 |
CF | 进位 | 0为最高位无进位或错位 1为有 |
PF | 奇偶 | 0表示数据最低8位中1的个数为奇数,1则表示1的个数为偶数 |
AF | 辅助进位 |
|
ZF | 零 | 0表示结果不为0 1表示结果为0 |
SF | 符号 | 0表示最高位为0 1表示最高位为1 |
这么一通信息轰炸下来,作为初学者可能已经头晕脑胀记不住了,其实是否记住这并不重要——后面分析用到了再回来查阅意思即可。
函数的栈结构
-----------------
current func arg0
----------------- <----------- FP(pseudo FP)
caller ret addr
+---------------+
| caller BP(*) |
----------------- <----------- SP(pseudo SP,实际上是当前栈帧的 BP 位置)
| Local Var0 |
-----------------
| Local Var1 |
-----------------
| Local Var2 |
----------------- -
| ........ |
-----------------
| Local VarN |
-----------------
| |
| |
| temporarily |
| unused space |
| |
| |
-----------------
| call retn |
-----------------
| call ret(n-1)|
-----------------
| .......... |
-----------------
| call ret1 |
-----------------
| call argn |
-----------------
| ..... |
-----------------
| call arg3 |
-----------------
| call arg2 |
|---------------|
| call arg1 |
----------------- <------------ hardware SP 位置
| return addr |
+---------------+
来源于No Headback7
分析汇编代码
从1+1开始
“好了,现在我们已经学会了加减乘除四则运算,接下来我们来解答一下这道微积分的题目”XD
我们先从一个简单的范例1+1
来实践一下对汇编代码的分析:
package assembly
func Add() {
a := 1 + 1
println(a)
}
汇编结果:
"".Add STEXT nosplit size=32 args=0x0 locals=0x18
0x0000 00000 (add.go:3) TEXT "".Add(SB), ABIInternal, $24-0
0x0000 00000 (add.go:3) MOVQ (TLS), CX
0x0009 00009 (add.go:3) CMPQ SP, 16(CX)
0x000d 00013 (add.go:3) JLS 77
0x000f 00015 (add.go:3) SUBQ $24, SP
0x0013 00019 (add.go:3) MOVQ BP, 16(SP)
0x0018 00024 (add.go:3) LEAQ 16(SP), BP
0x001d 00029 (add.go:3) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x001d 00029 (add.go:3) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x001d 00029 (add.go:3) FUNCDATA $2, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x001d 00029 (add.go:4) PCDATA $0, $0
0x001d 00029 (add.go:4) PCDATA $1, $0
0x001d 00029 (add.go:4) MOVQ $2, "".a+8(SP)
0x0026 00038 (add.go:5) CALL runtime.printlock(SB)
0x002b 00043 (add.go:5) MOVQ "".a+8(SP), AX
0x0030 00048 (add.go:5) MOVQ AX, (SP)
0x0034 00052 (add.go:5) CALL runtime.printint(SB)
0x0039 00057 (add.go:5) CALL runtime.printnl(SB)
0x003e 00062 (add.go:5) CALL runtime.printunlock(SB)
0x0043 00067 (add.go:6) MOVQ 16(SP), BP
0x0048 00072 (add.go:6) ADDQ $24, SP
0x004c 00076 (add.go:6) RET
0x004d 00077 (add.go:6) NOP
0x004d 00077 (add.go:3) PCDATA $1, $-1
0x004d 00077 (add.go:3) PCDATA $0, $-1
0x004d 00077 (add.go:3) CALL runtime.morestack_noctxt(SB)
0x0052 00082 (add.go:3) JMP 0
第一行是go汇编的固定开头,指定过程名字为"".Add
,args=0x0 locals=0x18
则对应第二行的$24-0
是十六进制和十进制的转化。
第二行是一个声明函数的过程。TEXT
是一个伪操作符,以过程名的内存地址("".Add(SB)
)为定义过程的参数(回想一下foo(SB)
是什么意思?),然后在栈上为过程分配内存。$24-0
其中24
表示栈帧的大小为24字节(跟函数内部变量数据类型以及个数有关,例如这里是两个整型变量,就是2x8=16字节,然后还有一个8字节的整型用来存储BP值,所以一共24个字节),0
则表示调用方传入的参数大小。ABIInternal
应该是应用程序二进制接口内部(Application Binary Interface Internal)的意思,不重要。
第三行的MOVQ (TLS), CX
,我们现在可以回头查阅一下MOVQ是干什么用的——用于数据传送。可以看出来是把一个*(TLS)*赋值给CX(计数寄存器)。但是这个(TLS)是什么呢?它实际上也是一个伪寄存器,保存了指向当前G(保存goroutine
的一种数据结构)的指针8。
第四行则是比较当前栈顶指针和G指针正偏移16字节的地址大小。
如果左边小于右边就跳到0x004d
(从十进制77转换为十六进制后的值)这个地址。我们先看看这个地址有什么内容:NOP
意思是No Operation,无操作数。看了下这里是运行到了add.go
文件的第六行,也就是一个}
,所以是没有任何操作的。往下又回到了第三行,先不管。
回到第五行,如果没有达成上面的条件判断,就不会进行内存地址跳转,而是继续执行第六行的代码。
这一行代码是将栈顶地址减去24字节的内存容量,并把结果存到SP中。根据上边的表格我们可以知道,这其实是通过对sp的运算进行栈移动操作。类似于进行了入栈(栈未动,而指向栈内存地址的指针发生了移动)。
第七行把BP
的值赋予了16(SP)
,意思是从栈顶开始第十六个字节位置开始的那个整型变量。接着第八行把16(SP)的地址赋给了BP。
第九到十三行FUNCDATA
和PCDATA
是由编译器生成的,作用是告诉GC(GarbageCollection)区分堆栈中变量的类型。$数字
表示变量属于什么类型(参数?本地?),而后面的gclocals·xxxxx(SB)
则是引用了一个隐藏的包含了GC标记的变量。注意这一行用到了·
(middle dot),用来代替go源文件中的.
,因为在汇编中此符号已经被作为标点符号来解析。这属于gc的部分,具体用途我们不清楚,但现在可以不用关注。
第十四行,把结果2赋给变量a。这里有两个点需要注意:首先$2
并不是表示上面那个FUNCDATA
创建的变量,而是1+1
的结果值,表示常数2(在plan9汇编里常数用$数字
来表示)。如果上面的代码改成了1+2
那么此处会变成$3
;"".a+8(SP)
并不是一个加法运算,而是表示距离栈顶8字节位置的那个变量a,这只是一种go汇编语法的强制规定,必须把变量名和内存地址使用+
连起来表示而已,对机器来说没有实际意义,但是方便人类阅读理解。
第十五行,源码来到了第六行,调用了runtime
包的printlock方法,根据名字可以看出这是打印前进行加锁的。
第十六和十七行的效果是把变量a放到AX寄存器中,然后把寄存器的地址赋给栈顶指针。
第十八行、十九和二十行则是打印栈顶指针的内容、打印换行符和解锁。
第二十一行把函数栈上记录的BP值还给BP,而二十二行将栈顶指针指向函数末尾。最后函数退出。
这样我们就成功分析完了一个函数方法的汇编代码。
继续a+b分析
上面我们发现两个数字相加,其实在汇编代码中直接体现为相加的结果了。所以我们把函数改成两个传入参数变量相加,看看有什么变化:
package assembly
func VariableAdd(a, b int) {
c := a + b
println(c)
}
汇编结果:
"".VariableAdd STEXT size=90 args=0x10 locals=0x18
0x0000 00000 (variable_add.go:3) TEXT "".VariableAdd(SB), ABIInternal, $24-16
0x0000 00000 (variable_add.go:3) MOVQ (TLS), CX
0x0009 00009 (variable_add.go:3) CMPQ SP, 16(CX)
0x000d 00013 (variable_add.go:3) JLS 83
0x000f 00015 (variable_add.go:3) SUBQ $24, SP
0x0013 00019 (variable_add.go:3) MOVQ BP, 16(SP)
0x0018 00024 (variable_add.go:3) LEAQ 16(SP), BP
0x001d 00029 (variable_add.go:3) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x001d 00029 (variable_add.go:3) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x001d 00029 (variable_add.go:3) FUNCDATA $2, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x001d 00029 (variable_add.go:4) PCDATA $0, $0
0x001d 00029 (variable_add.go:4) PCDATA $1, $0
0x001d 00029 (variable_add.go:4) MOVQ "".a+32(SP), AX
0x0022 00034 (variable_add.go:4) ADDQ "".b+40(SP), AX
0x0027 00039 (variable_add.go:4) MOVQ AX, "".c+8(SP)
0x002c 00044 (variable_add.go:5) CALL runtime.printlock(SB)
0x0031 00049 (variable_add.go:5) MOVQ "".c+8(SP), AX
0x0036 00054 (variable_add.go:5) MOVQ AX, (SP)
0x003a 00058 (variable_add.go:5) CALL runtime.printint(SB)
0x003f 00063 (variable_add.go:5) CALL runtime.printnl(SB)
0x0044 00068 (variable_add.go:5) CALL runtime.printunlock(SB)
0x0049 00073 (variable_add.go:6) MOVQ 16(SP), BP
0x004e 00078 (variable_add.go:6) ADDQ $24, SP
0x0052 00082 (variable_add.go:6) RET
0x0053 00083 (variable_add.go:6) NOP
0x0053 00083 (variable_add.go:3) PCDATA $1, $-1
0x0053 00083 (variable_add.go:3) PCDATA $0, $-1
0x0053 00083 (variable_add.go:3) CALL runtime.morestack_noctxt(SB)
0x0058 00088 (variable_add.go:3) JMP 0
可以看到主要的变化就是第14行到16行。
MOVQ "".a+32(SP), AX
这段代码的意思就是,把从32(SP)
那个位置开始的名为a
的变量放到AX寄存器里。下一行则对寄存器和变量b
进行相加运算并把值放到寄存器中。
另外也可以看到,相较于1+1,整个函数栈的大小从$24-0
变化为$24-16
。因为函数内部容量并未发生变化,只是添加了两个8字节整型的传入参数,因此增加了16字节的大小。
更进一步的尝试,比如把加法改成乘法、除法等,这里就不展开讨论了。读者可以自行尝试。本文并未列出全部的助记符,但是见到新出现的助记符也没什么好迷惑的,可以借助本文下方列出的参考链接以及搜索引擎来自行查明含义。
分析range
那么,经过对go汇编知识的简单了解和初步练习,现在我们可以回到对range的分析上了。
// 源码第八行
for _, v := range arr {
// 汇编结果
...
0x00af 00175 (range_clause.go:8) MOVQ 8(SP), AX
0x00b4 00180 (range_clause.go:8) MOVQ AX, "".&v+192(SP)
...
0x0104 00260 (range_clause.go:8) MOVQ ""..autotmp_11+104(SP), CX
0x0109 00265 (range_clause.go:8) CMPQ ""..autotmp_10+112(SP), CX
0x010e 00270 (range_clause.go:8) JLT 277 // 如果.autotmp_10小于CX跳转到0x0115
0x0110 00272 (range_clause.go:8) JMP 516 // 无条件跳转到0x0204
0x0115 00277 (range_clause.go:8) MOVQ ""..autotmp_10+112(SP), CX
0x011a 00282 (range_clause.go:8) SHLQ $3, CX
0x011e 00286 (range_clause.go:8) ADDQ ""..autotmp_5+288(SP), CX
0x0126 00294 (range_clause.go:8) MOVQ (CX), CX
0x0129 00297 (range_clause.go:8) MOVQ CX, ""..autotmp_12+96(SP)
0x012e 00302 (range_clause.go:8) MOVQ "".&v+192(SP), DX
0x0136 00310 (range_clause.go:8) MOVQ CX, (DX)
// 源码第九行
newArr = append(newArr, &v)
// 汇编结果
0x0139 00313 (range_clause.go:9) MOVQ "".&v+192(SP), CX
0x0141 00321 (range_clause.go:9) MOVQ CX, ""..autotmp_13+184(SP)
0x0149 00329 (range_clause.go:9) MOVQ "".newArr+232(SP), CX
0x0151 00337 (range_clause.go:9) MOVQ "".newArr+224(SP), DX
0x0159 00345 (range_clause.go:9) MOVQ "".newArr+216(SP), BX
0x0161 00353 (range_clause.go:9) LEAQ 1(DX), SI
0x0165 00357 (range_clause.go:9) CMPQ SI, CX
0x0168 00360 (range_clause.go:9) JLS 364 // 如果SI < CX跳转到0x016c
0x016a 00362 (range_clause.go:9) JMP 446 // 否则跳转到0x01be
0x016c 00364 (range_clause.go:9) JMP 366 // 无条件跳转到0x016e
0x016e 00366 (range_clause.go:9) MOVQ ""..autotmp_13+184(SP), AX
0x0176 00374 (range_clause.go:9) LEAQ (BX)(DX*8), DI
0x017a 00378 (range_clause.go:9) CMPL runtime.writeBarrier(SB), $0
0x0181 00385 (range_clause.go:9) JEQ 389 // 如果等于0就跳转到0x0185
0x0183 00387 (range_clause.go:9) JMP 439 // 否则无条件跳转到0x01b7
0x0185 00389 (range_clause.go:9) MOVQ AX, (BX)(DX*8)
0x0189 00393 (range_clause.go:9) JMP 395 // 无条件跳转到0x018b
0x018b 00395 (range_clause.go:9) MOVQ BX, "".newArr+216(SP)
0x0193 00403 (range_clause.go:9) MOVQ SI, "".newArr+224(SP)
0x019b 00411 (range_clause.go:9) MOVQ CX, "".newArr+232(SP)
0x01a3 00419 (range_clause.go:9) JMP 421 // 无条件跳转到0x01a5
0x01a5 00421 (range_clause.go:8) MOVQ ""..autotmp_10+112(SP), CX
0x01aa 00426 (range_clause.go:8) INCQ CX
0x01ad 00429 (range_clause.go:8) MOVQ CX, ""..autotmp_10+112(SP)
0x01b2 00434 (range_clause.go:8) JMP 260 // 无条件跳转到0x0104
0x01b7 00439 (range_clause.go:9) CALL runtime.gcWriteBarrier(SB)
0x01bc 00444 (range_clause.go:9) JMP 395 // 无条件跳转到0x018b
0x01be 00446 (range_clause.go:9) MOVQ DX, ""..autotmp_21+64(SP)
0x01c3 00451 (range_clause.go:9) LEAQ type.*int(SB), AX
0x01ca 00458 (range_clause.go:9) MOVQ AX, (SP)
0x01ce 00462 (range_clause.go:9) MOVQ BX, 8(SP)
0x01d3 00467 (range_clause.go:9) MOVQ DX, 16(SP)
0x01d8 00472 (range_clause.go:9) MOVQ CX, 24(SP)
0x01dd 00477 (range_clause.go:9) MOVQ SI, 32(SP)
0x01e2 00482 (range_clause.go:9) CALL runtime.growslice(SB)
0x01e7 00487 (range_clause.go:9) MOVQ 40(SP), BX
0x01ec 00492 (range_clause.go:9) MOVQ 48(SP), AX
0x01f1 00497 (range_clause.go:9) MOVQ 56(SP), CX
0x01f6 00502 (range_clause.go:9) LEAQ 1(AX), SI
0x01fa 00506 (range_clause.go:9) MOVQ ""..autotmp_21+64(SP), DX
0x01ff 00511 (range_clause.go:9) JMP 366 // 无条件跳转到0x016e
由于汇编结果上标注了对应源码文件的行数,所以我们分析的时候可以逐行分析。
问题出现在第九行,直接从第九行开始分析。第九行汇编代码做了很多跳转,这里标注了一下跳转的对应行数。
在range
未结束前,第九行代码执行完毕之后必然会跳转回第八行,执行下一轮的循环。可以看到0x01b2
这一行就是这个作用。从这里着手分析。其上两行,用到了CX
寄存器,这个寄存器通常是用来计数的,也就是说它对循环次数进行了计数。每一轮循环使用INCQ
加一,然后赋值给.autotmp_10
这个变量,也就是说保存循环次数的是.autotmp_10
。
然后我们跳回源码第八行,现在寻找对.autotmp_10
变量进行比较的代码,于是找到了0x0109
。如果循环次数大于CX,就跳转到0x0204
,也就是源码第11行,开始了另一个循环,这里暂不管。可以看到CX在上一行由.autotmp_11
赋值,可知这个变量存储了数组的长度。
接下来,我们看到汇编代码对寄存器的操作有些令人迷惑的地方,(CX)
和CX
、(DX)
等等:加了括号表示取CX的内存地址,不加则表示取值。
0x012e
一行,将"".&v+192(SP)
赋值给了DX
。往上寻找,我们可以看到0x00b4
行该变量产生的过程:8(SP)
这个变量的内存地址赋值给了"".&v+192(SP)
,而往下进行循环的过程中"".&v+192(SP)
这个变量没有再被赋值的操作,因此我们得出结论,每次循环过程中"".&v+192(SP)
一直都是同一个值,也就是说,在源码第九行中&v
一直指向同一个地址,即8(SP)
。
事情逐渐明了,因为&v
一直指向同一个地址,所以源码中的newArr
三个值都记录了同一个地址。
接下来我们可以继续追踪8(SP)
的最终值,但是这样下去太过复杂艰深(我不想写了,头疼)。我们已经知道原因,并且我们也知道v的最后一次值就是数组的最后一个数字,因此newArr
打印出来的自然就是三个3。
这样的研究并非没有意义:go的for _, v := range arr
写法容易让人误以为每次的v都是一个全新变量(因为if err := somefunc(); err == nil
就是这样的),而我们通过查看汇编代码得知了实际上v这一变量的值实际上是通过&v
指向的地址获取的,真正被重新赋值的变量另有其物(就是8(SP)
)。
扩展
在上面这段源码中,其实还有其他地方可以关注,比如arr := []int{1, 2, 3}
这个切片声明的实现过程。
从0x0032
到0x007c
正好对应源码第六行。
我们知道,一个切片实际上是由一个指针、两个整型组成的结构体9:
type slice struct {
array unsafe.Pointer
len int
cap int
}
那么在声明的时候需要赋予slice.len
和slice.cap
值——对应于在0x0070
和0x007c
,而它的底层指向数组指针slice.array
则是0x0068
完成。但是这段汇编中,匿名数组是怎么生成的我们并不知道,所以我们再写一段源码,内容是生成一个数组,然后对其进行切片操作:
package assembly
func ArraySlice() {
arr := [3]int{1, 2, 4}
sl := arr[:]
_ = sl
}
输出汇编为:
"".ArraySlice STEXT nosplit size=97 args=0x0 locals=0x38
0x0000 00000 (arr_slice.go:3) TEXT "".ArraySlice(SB), NOSPLIT|ABIInternal, $56-0
0x0000 00000 (arr_slice.go:3) SUBQ $56, SP
0x0004 00004 (arr_slice.go:3) MOVQ BP, 48(SP)
0x0009 00009 (arr_slice.go:3) LEAQ 48(SP), BP
0x000e 00014 (arr_slice.go:3) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x000e 00014 (arr_slice.go:3) FUNCDATA $1, gclocals·54241e171da8af6ae173d69da0236748(SB)
0x000e 00014 (arr_slice.go:3) FUNCDATA $2, gclocals·9fb7f0986f647f17cb53dda1484e0f7a(SB)
0x000e 00014 (arr_slice.go:4) PCDATA $0, $0
0x000e 00014 (arr_slice.go:4) PCDATA $1, $0
0x000e 00014 (arr_slice.go:4) MOVQ $0, "".arr(SP)
0x0016 00022 (arr_slice.go:4) XORPS X0, X0
0x0019 00025 (arr_slice.go:4) MOVUPS X0, "".arr+8(SP)
0x001e 00030 (arr_slice.go:4) MOVQ $1, "".arr(SP)
0x0026 00038 (arr_slice.go:4) MOVQ $2, "".arr+8(SP)
0x002f 00047 (arr_slice.go:4) MOVQ $4, "".arr+16(SP)
0x0038 00056 (arr_slice.go:5) PCDATA $0, $1
0x0038 00056 (arr_slice.go:5) LEAQ "".arr(SP), AX
0x003c 00060 (arr_slice.go:5) TESTB AL, (AX)
0x003e 00062 (arr_slice.go:5) JMP 64
0x0040 00064 (arr_slice.go:5) PCDATA $0, $0
0x0040 00064 (arr_slice.go:5) MOVQ AX, "".sl+24(SP)
0x0045 00069 (arr_slice.go:5) MOVQ $3, "".sl+32(SP)
0x004e 00078 (arr_slice.go:5) MOVQ $3, "".sl+40(SP)
0x0057 00087 (arr_slice.go:7) MOVQ 48(SP), BP
0x005c 00092 (arr_slice.go:7) ADDQ $56, SP
0x0060 00096 (arr_slice.go:7) RET
从下往上看,先看到"".sl
这段内存的三个变量被赋予了值(0x0040
~0x004e
)。而AX
由"".arr(SP)
赋值(0x0038
),值为arr所在的内存地址。
如果我们再添加一个sl2 = sl[:]
则可以看到底层数组指针依旧是由AX
赋值而成的,印证了网上所说的切片共享数组的说法(当然,通过查看源码也是可以知道的)。
- for和range的实现|Go语言的设计和实现 ↩︎
- Common Mistakes|Go ↩︎
- plan9汇编入门|go夜读 ↩︎
- Assembly Programming|Tutorialspoint ↩︎
- A Quick Guide to Go’s Assembler ↩︎
- A Manual for the Plan 9 assembler ↩︎
- plan9 汇编入门|No Headback ↩︎
- teh-cmc/go-internals ↩︎
- golang/go ↩︎
终于到函数了!因为Go汇编语言中,可以也建议通过Go语言来定义全局变量,那么剩下的也就是函数了。只有掌握了汇编函数的基本用法,才能真正算是Go汇编语言入门。本章将简单讨论Go汇编中函数的定义和用法。
基本语法
函数标识符通过TEXT
汇编指令定义,表示该行开始的指令定义在TEXT
内存段。TEXT
语句后的指令一般对应函数的实现,但是对于TEXT
指令本身来说并不关心后面是否有指令。因此TEXT
和LABEL
定义的符号是类似的,区别只是LABEL
是用于跳转标号,但是本质上他们都是通过标识符映射一个内存地址。
函数的定义的语法如下:
TEXT symbol(SB), [flags,] $framesize[-argsize]
函数的定义部分由5
个部分组成:TEXT指令
、函数名
、可选的flags标志
、函数帧大小
和可选的函数参数大小
。
其中TEXT
用于定义函数符号,函数名中当前包的路径可以省略。函数的名字后面是(SB),表示是函数名符号相对于SB伪寄存器
的偏移量,二者组合在一起最终是绝对地址。作为全局的标识符的全局变量和全局函数的名字一般都是基于SB伪寄存器的相对地址。标志部分用于指示函数的一些特殊行为,标志在textlags.h
文件中定义,常见的NOSPLIT
主要用于指示叶子函数不进行栈分裂。framesize
部分表示函数的局部变量需要多少栈空间,其中包含调用其它函数时准备调用参数的隐式栈空间。最后是可以省略的参数大小,之所以可以省略是因为编译器可以从Go语言的函数声明中推导出函数参数的大小。
我们首先从一个简单的Swap函数开始。Swap函数用于交互输入的两个参数的顺序,然后通过返回值返回交换了顺序的结果。如果用Go语言中声明Swap函数,大概这样的:
package main
//go:nosplit
func Swap(a, b int) (int, int)
下面是main包中Swap函数在汇编中两种定义方式:
// func Swap(a, b int) (int, int)
TEXT ·Swap(SB), NOSPLIT, $0-32
// func Swap(a, b int) (int, int)
TEXT ·Swap(SB), NOSPLIT, $0
下图是Swap函数几种不同写法的对比关系图:
第一种是最完整的写法:函数名部分包含了当前包的路径,同时指明了函数的参数大小为32个字节(对应参数和返回值的4个int类型)。第二种写法则比较简洁,省略了当前包的路径和参数的大小。如果有NOSPLIT标注,会禁止汇编器为汇编函数插入栈分裂的代码。NOSPLIT对应Go语言中的//go:nosplit注释。
目前可能遇到的函数标志有NOSPLIT
、WRAPPER
和NEEDCTXT
几个。其中NOSPLIT
不会生成或包含栈分裂代码,这一般用于没有任何其它函数调用的叶子函数,这样可以适当提高性能。WRAPPER
标志则表示这个是一个包装函数,在panic或runtime.caller等某些处理函数帧的地方不会增加函数帧计数。最后的NEEDCTXT
表示需要一个上下文参数,一般用于闭包函数。
需要注意的是函数也没有类型,上面定义的Swap函数签名可以下面任意一种格式:
func Swap(a, b, c int) int
func Swap(a, b, c, d int)
func Swap() (a, b, c, d int)
func Swap() (a []int, d int)
// ...
对于汇编函数来说,只要是函数的名字和参数大小一致就可以是相同的函数了。而且在Go汇编语言中,输入参数和返回值参数是没有任何的区别的。
函数参数和返回值
对于函数来说,最重要的是函数对外提供的API约定,包含函数的名称、参数和返回值。当这些都确定之后,如何精确计算参数和返回值的大小是第一个需要解决的问题。
比如有一个Swap函数的签名如下:
func Swap(a, b int) (ret0, ret1 int)
对于这个函数,我们可以轻易看出它需要4个int类型的空间,参数和返回值的大小也就是32个字节:
TEXT ·Swap(SB), $0-32
那么如何在汇编中引用这4个参数呢?为此Go汇编中引入了一个FP伪寄存器,表示函数当前帧的地址,也就是第一个参数的地址。因此我们以通过+0(FP)
、+8(FP)
、+16(FP)
和+24(FP)
来分别引用a、b、ret0和ret1
四个参数。
但是在汇编代码中,我们并不能直接以+0(FP)
的方式来使用参数。为了编写易于维护的汇编代码,Go汇编语言要求,任何通过FP伪寄存器访问的变量必和一个临时标识符前缀组合后才能有效,一般使用参数对应的变量名作为前缀
。
下图是Swap函数中参数和返回值在内存中的布局图:
下面的代码演示了如何在汇编函数中使用参数和返回值:
TEXT ·Swap(SB), $0
MOVQ a+0(FP), AX // AX = a
MOVQ b+8(FP), BX // BX = b
MOVQ BX, ret0+16(FP) // ret0 = BX
MOVQ AX, ret1+24(FP) // ret1 = AX
RET
从代码可以看出a、b、ret0和ret1的内存地址是依次递增
的,FP伪寄存器是第一个变量的开始地址
。
参数和返回值的内存布局
如果是参数和返回值类型比较复杂的情况该如何处理呢?下面我们再尝试一个更复杂的函数参数和返回值的计算。比如有以下一个函数:
func Foo(a bool, b int16) (c []byte)
函数的参数有不同的类型,而且返回值中含有更复杂的切片类型。我们该如何计算每个参数的位置和总的大小呢?
其实函数参数和返回值的大小以及对齐问题
和结构体的大小和成员对齐问题
是一致
的,函数的第一个参数
和第一个返回值
会分别进行一次地址对齐
。我们可以用诡代思路将全部的参数和返回值以同样的顺序分别放到两个结构体中,将FP伪寄存器作为唯一的一个指针参数,而每个成员的地址也就是对应原来参数的地址。
用这样的策略可以很容易计算前面的Foo函数的参数和返回值的地址和总大小。为了便于描述我们定义一个Foo_args_and_returns
临时结构体类型用于诡代原始的参数和返回值:
type Foo_args struct {
a bool
b int16
c []byte
}
type Foo_returns struct {
c []byte
}
然后将Foo原来的参数替换为结构体形式,并且只保留唯一的FP作为参数:
func Foo(FP *SomeFunc_args, FP_ret *SomeFunc_returns) {
// a = FP + offsetof(&args.a)
_ = unsafe.Offsetof(FP.a) + uintptr(FP) // a
// b = FP + offsetof(&args.b)
// argsize = sizeof(args)
argsize = unsafe.Offsetof(FP)
// c = FP + argsize + offsetof(&return.c)
_ = uintptr(FP) + argsize + unsafe.Offsetof(FP_ret.c)
// framesize = sizeof(args) + sizeof(returns)
_ = unsafe.Offsetof(FP) + unsafe.Offsetof(FP_ret)
return
}
代码完全和Foo函数参数的方式类似。唯一的差异是每个函数的偏移量,通过unsafe.Offsetof
函数自动计算生成。因为Go结构体中的每个成员已经满足了对齐要求,因此采用通用方式得到每个参数的偏移量也是满足对齐要求的。序言注意的是第一个返回值地址需要重新对齐机器字大小的倍数。
Foo函数的参数和返回值的大小和内存布局:
下面的代码演示了Foo汇编函数参数和返回值的定位:
TEXT ·Foo(SB), $0
MOVEQ a+0(FP), AX // a
MOVEQ b+2(FP), BX // b
MOVEQ c_dat+8*1(FP), CX // c.Data
MOVEQ c_len+8*2(FP), DX // c.Len
MOVEQ c_cap+8*3(FP), DI // c.Cap
RET
其中a和b参数之间出现了一个字节的空洞,b和c之间出现了4个字节的空洞。出现空洞的原因是要保证每个参数变量地址都要对齐到相应的倍数。
函数中的局部变量
从Go语言函数角度讲,局部变量是函数内明确定义的变量,同时也包含函数的参数和返回值变量。但是从Go汇编角度看,局部变量是指函数运行时,在当前函数栈帧所对应的内存内的变量,不包含函数的参数和返回值(因为访问方式有差异)。函数栈帧的空间主要由函数参数和返回值、局部变量和被调用其它函数的参数和返回值空间组成。为了便于理解,我们可以将汇编函数的局部变量类比为Go语言函数中显式定义的变量,不包含参数和返回值部分。
为了便于访问局部变量,Go汇编语言引入了伪SP寄存器,对应当前栈帧的底部
。因为在当前栈帧时栈的底部是固定不变的
,因此局部变量的相对于伪SP的偏移量也就是固定的,这可以简化局部变量的维护工作。SP真伪寄存器的区分只有一个原则:如果使用SP时有一个临时标识符前缀就是伪SP,否则就是真SP寄存器
。比如a(SP)和b+8(SP)有a和b临时前缀,这里都是伪SP,而前缀部分一般用于表示局部变量的名字。而(SP)和+8(SP)没有临时标识符作为前缀,它们都是真SP寄存器。
在X86平台,函数的调用栈是从高地址向低地址增长的,因此伪SP寄存器对应栈帧的底部其实是对应更大的地址
。当前栈的顶部对应真实存在的SP寄存器,对应当前函数栈帧的栈顶,对应更小的地址。如果整个内存用Memory数组表示,那么Memory[0(SP):end-0(SP)]就是对应当前栈帧的切片,其中开始位置是真SP寄存器,结尾部分是伪SP寄存器。真SP寄存器一般用于表示调用其它函数时的参数和返回值,真SP寄存器对应内存较低的地址,所以被访问变量的偏移量是正数;而伪SP寄存器对应高地址,对应的局部变量的偏移量都是负数。
为了便于对比,我们将前面Foo函数的参数和返回值变量改成局部变量:
func Foo() {
var c []byte
var b int16
var a bool
}
然后通过汇编语言重新实现Foo函数,并通过伪SP来定位局部变量:
TEXT ·Foo(SB), $32-0
MOVQ a-32(SP), AX // a
MOVQ b-30(SP), BX // b
MOVQ c_data-24(SP), CX // c.Data
MOVQ c_len-16(SP), DX // c.Len
MOVQ c_cap-8(SP), DI // c.Cap
RET
Foo函数有3个局部变量,但是没有调用其它的函数,因为对齐和填充的问题导致函数的栈帧大小为32个字节。因为Foo函数没有参数和返回值,因此参数和返回值大小为0个字节,当然这个部分可以省略不写。而局部变量中先定义的变量c离伪SP寄存器对应的地址最近,最后定义的变量a离伪SP寄存器最远。有两个因素导致出现这种逆序的结果:一个从Go语言函数角度理解,先定义的c变量地址要比后定义的变量的地址更大;另一个是伪SP寄存器对应栈帧的底部,而X86中栈是从高向低生长的,所以最先定义有着更大地址的c变量离栈的底部伪SP更近。
我们同样可以通过结构体来模拟局部变量的布局:
func Foo() {
var local [1]struct{
a bool
b int16
c []byte
}
var SP = &local[1];
_ = -(unsafe.Sizeof(local)-unsafe.Offsetof(local.a)) + uintptr(&SP) // a
_ = -(unsafe.Sizeof(local)-unsafe.Offsetof(local.b)) + uintptr(&SP) // b
_ = -(unsafe.Sizeof(local)-unsafe.Offsetof(local.c)) + uintptr(&SP) // c
}
我们将之前的三个局部变量挪到一个结构体中。然后构造一个SP变量对应伪SP寄存器,对应局部变量结构体的顶部。然后根据局部变量总大小和每个变量对应成员的偏移量计算相对于伪SP的距离,最终偏移量是一个负数。
通过这种方式可以处理复杂的局部变量的偏移,同时也能保证每个变量地址的对齐要求。当然,除了地址对齐外,局部变量的布局并没有顺序要求。对于汇编比较熟悉同学可以根据自己的习惯组织变量的布局。
下面是Foo函数的局部变量的大小和内存布局:
从图中可以看出Foo函数局部变量和前一个例子中参数和返回值的内存布局是完全一样的,这也是我们故意设计的结果。但是参数和返回值是通过伪FP寄存器定位的
,FP寄存器对应第一个参数的开始地址(第一个参数地址较低)
,因此每个变量的偏移量是正数。而局部变量是通过伪SP寄存器定位的
,而伪SP寄存器对应的是第一个局部变量的结束地址(第一个局部变量地址较大)
,因此每个局部变量的偏移量都是负数。
调用其它函数
常见的用Go汇编实现的函数都是叶子函数,也就是被其它函数调用的函数,但是很少调用其它函数。这主要是因为叶子函数比较简单,可以简化汇编函数的编写;同时一般性能或特性的瓶颈也处于叶子函数。但是能够调用其它函数和能够被其它函数调用同样重要,否则Go汇编就不是一个完整的汇编语言。
在前文中我们已经学习了一些汇编实现的函数参数和返回值处理的规则。那么一个显然的问题是,汇编函数的参数是从哪里来的?答案同样明显,被调用函数的参数是由调用方准备的:调用方在栈上设置好空间和数据后调用函数,被调用方在返回前将返回值放在对应的位置,函数通过RET指令返回调用方函数之后,调用方再从返回值对应的栈内存位置取出结果。Go语言函数的调用参数和返回值均是通过栈传输的,这样做的优点是函数调用栈比较清晰,缺点是函数调用有一定的性能损耗(Go编译器是通过函数内联来缓解这个问题的影响)。
为了便于展示,我们先使用Go语言来构造三个逐级调用的函数:
func main() {
printsum(1, 2)
}
func printsum(a, b int) {
var ret = sum(a, b)
println(ret)
}
func sum(a, b int) int {
return a+b
}
其中main函数通过字面值常量直接调用printsum
函数,printsum
函数输出两个整数的和。而printsum
函数内部又通过调用sum
函数计算两个数的和,并最终调用打印函数进行输出。因为printsum
既是被调用函数又是调用函数,所以它是我们要重点分析的函数。
下图展示了三个函数逐级调用时内存中函数参数和返回值的布局:
为了便于理解,我们对真实的内存布局进行了简化。要记住的是调用函数时,被调用函数的参数和返回值内存空间都必须由调用者提供。因此函数的局部变量和为调用其它函数准备的栈空间总和就确定了函数帧的大小。调用其它函数前调用方要选择保存相关寄存器到栈中,并在调用函数返回后选择要恢复的寄存器进行保存。最终通过CALL
指令调用函数的过程和调用我们熟悉的调用println
函数输出的过程类似。
Go语言中函数调用是一个复杂的问题,因为Go函数不仅仅要了解函数调用参数的布局,还会涉及到栈的跳转,栈上局部变量的生命周期管理。本节只是简单了解函数调用参数的布局规则,在后续的章节中会更详细的讨论函数的细节。
宏函数
宏函数并不是Go汇编语言所定义,而是Go汇编引入的预处理特性自带的特性。
在C语言中我们可以通过带参数的宏定义一个交换2个数的宏函数:
我们可以用类似的方式定义一个交换两个寄存器的宏:
因为汇编语言中无法定义临时变量,我们增加一个参数用于临时寄存器。下面是通过SWAP宏函数交换AX和BX寄存器的值,然后返回结果:
// func Swap(a, b int) (int, int)
TEXT ·Swap(SB), $0-32
MOVQ a+0(FP), AX // AX = a
MOVQ b+8(FP), BX // BX = b
SWAP(AX, BX, CX) // AX, BX = b, a
MOVQ AX, ret0+16(FP) // return
MOVQ BX, ret1+24(FP) //
RET
因为预处理器可以通过条件编译针对不同的平台定义宏的实现,这样可以简化平台带来的差异。
函数进阶函数调用规范
在Go汇编语言中CALL
指令用于调用函数,RET
指令用于从调用函数返回。但是CALL和RET指令并没有处理函数调用时输入参数和返回值的问题。CALL指令类似PUSH IP和JMP somefunc两个指令的组合
,首先将当前的IP指令寄存器的值压入栈中,然后通过JMP指令将要调用函数的地址写入到IP寄存器实现跳转。而RET指令则是和CALL相反的操作,基本和POP IP指令等价
,也就是将执行CALL指令时保存在SP中的返回地址重新载入到IP寄存器,实现函数的返回。
和C语言函数不同,Go语言函数的参数和返回值完全通过栈传递。下面是Go函数调用时栈的布局图:
首先是调用函数前准备的输入参数和返回值空间。然后CALL指令将首先触发返回地址入栈操作。在进入到被调用函数内之后,汇编器自动插入了BP寄存器相关的指令,因此BP寄存器和返回地址是紧挨着的。再下面就是当前函数的局部变量的空间,包含再次调用其它函数需要准备的调用参数空间。被调用的函数执行RET返回指令时,先从栈恢复BP和SP寄存器,接着取出的返回地址跳转到对应的指令执行。
高级汇编语言
Go汇编语言其实是一种高级的汇编语言。在这里高级一词并没有任何褒义或贬义的色彩,而是要强调Go汇编代码和最终真实执行的代码并不完全等价。Go汇编语言中一个指令在最终的目标代码中可能会被编译为其它等价的机器指令。Go汇编实现的函数或调用函数的指令在最终代码中也会被插入额外的指令。要彻底理解Go汇编语言就需要彻底了解汇编器到底插入了哪些指令。
为了便于分析,我们先构造一个禁止栈分裂的printnl函数。printnl函数内部都通过调用runtime.printnl函数输出换行:
TEXT ·printnl_nosplit(SB), NOSPLIT, $8
CALL runtime·printnl(SB)
RET
然后通过go tool asm -S main_amd64.s
指令查看编译后的目标代码:
"".printnl_nosplit STEXT nosplit size=29 args=0xffffffff80000000 locals=0x10
0x0000 00000 (main_amd64.s:5) TEXT "".printnl_nosplit(SB), NOSPLIT $16
0x0000 00000 (main_amd64.s:5) SUBQ $16, SP
0x0004 00004 (main_amd64.s:5) MOVQ BP, 8(SP)
0x0009 00009 (main_amd64.s:5) LEAQ 8(SP), BP
0x000e 00014 (main_amd64.s:6) CALL runtime.printnl(SB)
0x0013 00019 (main_amd64.s:7) MOVQ 8(SP), BP
0x0018 00024 (main_amd64.s:7) ADDQ $16, SP
0x001c 00028 (main_amd64.s:7) RET
输出代码中我们删除了非指令的部分。为了便于讲述,我们将上述代码重新排版,并根据缩进表示相关的功能:
TEXT "".printnl(SB), NOSPLIT, $16
SUBQ $16, SP
MOVQ BP, 8(SP)
LEAQ 8(SP), BP
CALL runtime.printnl(SB)
MOVQ 8(SP), BP
ADDQ $16, SP
RET
第一层是TEXT
指令表示函数开始,到RET指令表示函数返回。第二层是SUBQ $16, SP
指令为当前函数帧分配16字节的空间,在函数返回前通过ADDQ $16, SP
指令回收16字节的栈空间。我们谨慎猜测在第二层是为函数多分配了8个字节的空间。那么为何要多分配8个字节的空间呢?再继续查看第三层的指令:开始部分有两个指令MOVQ BP, 8(SP)和LEAQ 8(SP), BP,
首先是将BP寄存器保持到多分配的8字节栈空间,然后将8(SP)地址重新保持到了BP寄存器中;结束部分是MOVQ 8(SP)
, BP指令则是从栈中恢复之前备份的前BP寄存器的值。最里面第四次层才是我们写的代码,调用runtime.printnl
函数输出换行。
如果去掉NOSPILT标志,再重新查看生成的目标代码,会发现在函数的开头和结尾的地方又增加了新的指令。下面是经过缩进格式化的结果:
TEXT "".printnl_nosplit(SB), $16
L_BEGIN:
MOVQ (TLS), CX
CMPQ SP, 16(CX)
JLS L_MORE_STK
SUBQ $16, SP
MOVQ BP, 8(SP)
LEAQ 8(SP), BP
CALL runtime.printnl(SB)
MOVQ 8(SP), BP
ADDQ $16, SP
L_MORE_STK:
CALL runtime.morestack_noctxt(SB)
JMP L_BEGIN
RET
其中开头有三个新指令,MOVQ (TLS), CX
用于加载g结构体指针,然后第二个指令CMPQ SP, 16(CX)SP
栈指针和g结构体中stackguard0成员比较,如果比较的结果小于0则跳转到结尾的L_MORE_STK
部分。当获取到更多栈空间之后,通过JMP L_BEGIN
指令跳转到函数的开始位置重新进行栈空间的检测。
g结构体在$GOROOT/src/runtime/runtime2.go
文件定义,开头的结构成员如下:
type g struct {
// Stack parameters.
stack stack // offset known to runtime/cgo
stackguard0 uintptr // offset known to liblink
stackguard1 uintptr // offset known to liblink
...
}
第一个成员是stack类型,表示当前栈的开始和结束地址。stack的定义如下:
// Stack describes a Go execution stack.
// The bounds of the stack are exactly [lo, hi),
// with no implicit data structures on either side.
type stack struct {
lo uintptr
hi uintptr
}
在g结构体中的stackguard0成员是出现爆栈前的警戒线。stackguard0的偏移量是16个字节,因此上述代码中的CMPQ SP, 16(AX)
表示将当前的真实SP和爆栈警戒线比较,如果超出警戒线则表示需要进行栈扩容,也就是跳转到L_MORE_STK
。在L_MORE_STK
标号处,先调用runtime·morestack_noctxt
进行栈扩容,然后又跳回到函数的开始位置,此时此刻函数的栈已经调整了。然后再进行一次栈大小的检测,如果依然不足则继续扩容,直到栈足够大为止。
以上是栈的扩容,但是栈的收缩是在何时处理的呢?我们知道Go运行时会定期进行垃圾回收操作,这其中包含栈的回收工作。如果栈使用到比例小于一定到阈值,则分配一个较小到栈空间,然后将栈上面到数据移动到新的栈中,栈移动的过程和栈扩容的过程类似。
PCDATA和FUNCDATA
Go语言中有个runtime.Caller
函数可以获取当前函数的调用者列表。我们可以非常容易在运行时定位每个函数的调用位置,以及函数的调用链。因此在panic异常或用log输出信息时,可以精确定位代码的位置。
比如以下代码可以打印程序的启动流程:
func main() {
for skip := 0; ; skip++ {
pc, file, line, ok := runtime.Caller(skip)
if !ok {
break
}
p := runtime.FuncForPC(pc)
fnfile, fnline := p.FileLine(0)
fmt.Printf("skip = %d, pc = 0x%08X\n", skip, pc)
fmt.Printf(" func: file = %s, line = L%03d, name = %s, entry = 0x%08X\n", fnfile, fnline, p.Name(), p.Entry())
fmt.Printf(" call: file = %s, line = L%03d\n", file, line)
}
}
其中runtime.Caller
先获取当时的PC寄存器值,以及文件和行号。然后根据PC寄存器表示的指令位置,通过````runtime.FuncForPC```函数获取函数的基本信息。Go语言是如何实现这种特性的呢?
Go语言作为一门静态编译型语言,在执行时每个函数的地址都是固定的,函数的每条指令也是固定的。如果针对每个函数和函数的每个指令生成一个地址表格(也叫PC表格),那么在运行时我们就可以根据PC寄存器的值轻松查询到指令当时对应的函数和位置信息。而Go语言也是采用类似的策略,只不过地址表格经过裁剪,舍弃了不必要的信息。因为要在运行时获取任意一个地址的位置,必然是要有一个函数调用,因此我们只需要为函数的开始和结束位置,以及每个函数调用位置生成地址表格就可以了。同时地址是有大小顺序的,在排序后可以通过只记录增量来减少数据的大小;在查询时可以通过二分法加快查找的速度。
在汇编中有个PCDATA用于生成PC表格,PCDATA的指令用法为:PCDATA tableid, tableoffset
。PCDATA有个两个参数,第一个参数为表格的类型,第二个是表格的地址。在目前的实现中,有PCDATA_StackMapIndex
和PCDATA_InlTreeIndex
两种表格类型。两种表格的数据是类似的,应该包含了代码所在的文件路径、行号和函数的信息,只不过PCDATA_InlTreeIndex
用于内联函数的表格。
此外对于汇编函数中返回值包含指针的类型,在返回值指针被初始化之后需要执行一个GO_RESULTS_INITIALIZED
指令:
#define GO_RESULTS_INITIALIZED PCDATA $PCDATA_StackMapIndex, $1
GO_RESULTS_INITIALIZED
记录的也是PC表格的信息,表示PC指针越过某个地址之后返回值才完成被初始化的状态。
Go语言二进制文件中除了有PC表格,还有FUNC表格用于记录函数的参数、局部变量的指针信息。FUNCDATA指令和PCDATA的格式类似:FUNCDATA tableid, tableoffset
,第一个参数为表格的类型,第二个是表格的地址。目前的实现中定义了三种FUNC表格类型:FUNCDATA_ArgsPointerMaps
表示函数参数的指针信息表,FUNCDATA_LocalsPointerMaps
表示局部指针信息表,FUNCDATA_InlTree
表示被内联展开的指针信息表。通过FUNC表格,Go语言的垃圾回收器可以跟踪全部指针的生命周期,同时根据指针指向的地址是否在被移动的栈范围来确定是否要进行指针移动。
在前面递归函数的例子中,我们遇到一个NO_LOCAL_POINTERS
宏。它的定义如下:
#define FUNCDATA_ArgsPointerMaps 0 /* garbage collector blocks */
#define FUNCDATA_LocalsPointerMaps 1
#define FUNCDATA_InlTree 2
#define NO_LOCAL_POINTERS FUNCDATA $FUNCDATA_LocalsPointerMaps, runtime·no_pointers_stackmap(SB)
因此NO_LOCAL_POINTERS
宏表示的是FUNCDATA_LocalsPointerMaps
对应的局部指针表格,而runtime·no_pointers_stackmap
是一个空的指针表格,也就是表示函数没有指针类型的局部变量。
PCDATA
和FUNCDATA
的数据一般是由编译器自动生成的,手工编写并不现实。如果函数已经有Go语言声明,那么编译器可以自动输出参数和返回值的指针表格。同时所有的函数调用一般是对应CALL
指令,编译器也是可以辅助生成PCDATA
表格的。编译器唯一无法自动生成是函数局部变量的表格,因此我们一般要在汇编函数的局部变量中谨慎使用指针类型。
对于PCDATA和FUNCDATA细节感兴趣的同学可以尝试从debug/gosym包入手,参考包的实现和测试代码。
方法函数
Go语言中方法函数和全局函数非常相似,比如有以下的方法:
package main
type MyInt int
func (v MyInt) Twice() int {
return int(v)*2
}
func MyInt_Twice(v MyInt) int {
return int(v)*2
}
其中MyInt类型的Twice方法和MyInt_Twice函数的类型是完全一样的,只不过Twice在目标文件中被修饰为main.MyInt.Twice
名称。我们可以用汇编实现该方法函数:
// func (v MyInt) Twice() int
TEXT ·MyInt·Twice(SB), NOSPLIT, $0-16
MOVQ a+0(FP), AX // v
ADDQ AX, AX // AX *= 2
MOVQ AX, ret+8(FP) // return v
RET
不过这只是接收非指针类型的方法函数。现在增加一个接收参数是指针类型的Ptr方法,函数返回传入的指针:
func (p *MyInt) Ptr() *MyInt {
return p
}
在目标文件中,Ptr方法名被修饰为main.(*MyInt).Ptr
,也就是对应汇编中的·(*MyInt)·Ptr
。不过在Go汇编语言中,星号和小括弧都无法用作函数名字,也就是无法用汇编直接实现接收参数是指针类型的方法。
在最终的目标文件中的标识符名字中还有很多Go汇编语言不支持的特殊符号(比如type.string."hello"
中的双引号),这导致了无法通过手写的汇编代码实现全部的特性。或许是Go语言官方故意限制了汇编语言的特性。
递归函数: 1到n求和
递归函数是比较特殊的函数,递归函数通过调用自身并且在栈上保存状态,这可以简化很多问题的处理。Go语言中递归函数的强大之处是不用担心爆栈问题,因为栈可以根据需要进行扩容和收缩。
首先通过Go递归函数实现一个1到n的求和函数:
// sum = 1+2+...+n
// sum(100) = 5050
func sum(n int) int {
if n > 0 { return n+sum(n-1) } else { return 0 }
}
然后通过if/goto重构上面的递归函数,以便于转义为汇编版本:
func sum(n int) (result int) {
var AX = n
var BX int
if n > 0 { goto L_STEP_TO_END }
goto L_END
L_STEP_TO_END:
AX -= 1
BX = sum(AX)
AX = n // 调用函数后, AX重新恢复为n
BX += AX
return BX
L_END:
return 0
}
在改写之后,递归调用的参数需要引入局部变量,保存中间结果也需要引入局部变量。而通过栈来保存中间的调用状态正是递归函数的核心。因为输入参数也在栈上,所以我们可以通过输入参数来保存少量的状态。同时我们模拟定义了AX和BX寄存器,寄存器在使用前需要初始化,并且在函数调用后也需要重新初始化。
下面继续改造为汇编语言版本:
// func sum(n int) (result int)
TEXT ·sum(SB), NOSPLIT, $16-16
MOVQ n+0(FP), AX // n
MOVQ result+8(FP), BX // result
CMPQ AX, $0 // test n - 0
JG L_STEP_TO_END // if > 0: goto L_STEP_TO_END
JMP L_END // goto L_STEP_TO_END
L_STEP_TO_END:
SUBQ $1, AX // AX -= 1
MOVQ AX, 0(SP) // arg: n-1
CALL ·sum(SB) // call sum(n-1)
MOVQ 8(SP), BX // BX = sum(n-1)
MOVQ n+0(FP), AX // AX = n
ADDQ AX, BX // BX += AX
MOVQ BX, result+8(FP) // return BX
RET
L_END:
MOVQ $0, result+8(FP) // return 0
RET
在汇编版本函数中并没有定义局部变量,只有用于调用自身的临时栈空间。因为函数本身的参数和返回值有16个字节,因此栈帧的大小也为16字节。L_STEP_TO_END标号部分用于处理递归调用,是函数比较复杂的部分。L_END用于处理递归终结的部分。
调用sum函数的参数在0(SP)
位置,调用结束后的返回值在8(SP)
位置。在函数调用之后要需要重新为需要的寄存器注入值,因为被调用的函数内部很可能会破坏了寄存器的状态。同时调用函数的参数值也是不可信任的,输入参数值也可能在被调用函数内部被修改了。
总得来说用汇编实现递归函数和普通函数并没有什么区别,当然是在没有考虑爆栈的前提下。我们的函数应该可以对较小的n进行求和,但是当n大到一定程度,也就是栈达到一定的深度,必然会出现爆栈的问题。爆栈是C语言的特性,不应该在哪怕是Go汇编语言中出现。
Go语言的编译器在生成函数的机器代码时,会在开头插入一小段代码。因为sum函数也需要深度递归调用,因此我们删除了NOSPLIT
标志,让汇编器为我们自动生成一个栈扩容的代码:
// func sum(n int) int
TEXT ·sum(SB), $16-16
NO_LOCAL_POINTERS
// 原来的代码
除了去掉了NOSPLIT标志,我们还在函数开头增加了一个NO_LOCAL_POINTERS
语句,该语句表示函数没有局部指针变量。栈的扩容必然要涉及函数参数和局部编指针的调整,如果缺少局部指针信息将导致扩容工作无法进行。不仅仅是栈的扩容需要函数的参数和局部指针标记表格,在GC进行垃圾回收时也将需要。函数的参数和返回值的指针状态可以通过在Go语言中的函数声明中获取,函数的局部变量则需要手工指定。因为手工指定指针表格是一个非常繁琐的工作,因此一般要避免在手写汇编中出现局部指针。
喜欢深究的读者可能会有一个问题:如果进行垃圾回收或栈调整时,寄存器中的指针是如何维护的?前文说过,Go语言的函数调用是通过栈进行传递参数的,并没有使用寄存器传递参数。同时函数调用之后所有的寄存器视为失效。因此在调整和维护指针时,只需要扫描内存中的指针数据,寄存器中的数据在垃圾回收器函数返回后都需要重新加载,因此寄存器是不需要扫描的。
闭包函数
闭包函数是最强大的函数,因为闭包函数可以捕获外层局部作用域的局部变量,因此闭包函数本身就具有了状态。从理论上来说,全局的函数也是闭包函数的子集,只不过全局函数并没有捕获外层变量而已。
为了理解闭包函数如何工作,我们先构造如下的例子:
package main
func NewTwiceFunClosure(x int) func() int {
return func() int {
x *= 2
return x
}
}
func main() {
fnTwice := NewTwiceFunClosure(1)
println(fnTwice()) // 1*2 => 2
println(fnTwice()) // 2*2 => 4
println(fnTwice()) // 4*2 => 8
}
其中NewTwiceFunClosure
函数返回一个闭包函数对象,返回的闭包函数对象捕获了外层的x参数。返回的闭包函数对象在执行时,每次将捕获的外层变量乘以2之后再返回。在main
函数中,首先以1作为参数调用NewTwiceFunClosure
函数构造一个闭包函数,返回的闭包函数保存在fnTwice
闭包函数类型的变量中。然后每次调用fnTwice
闭包函数将返回翻倍后的结果,也就是:2,4,8。
上述的代码,从Go语言层面是非常容易理解的。但是闭包函数在汇编语言层面是如何工作的呢?下面我们尝试手工构造闭包函数来展示闭包的工作原理。首先是构造```FunTwiceClosure````结构体类型,用来表示闭包对象:
type FunTwiceClosure struct {
F uintptr
X int
}
func NewTwiceFunClosure(x int) func() int {
var p = &FunTwiceClosure{
F: asmFunTwiceClosureAddr(),
X: x,
}
return ptrToFunc(unsafe.Pointer(p))
}
FunTwiceClosure
结构体包含两个成员,第一个成员F表示闭包函数的函数指令的地址
,第二个成员X表示闭包捕获的外部变量
。如果闭包函数捕获了多个外部变量,那么FunTwiceClosure
结构体也要做相应的调整。然后构造FunTwiceClosure
结构体对象,其实也就是闭包函数对象。其中asmFunTwiceClosureAddr
函数用于辅助获取闭包函数的函数指令的地址,采用汇编语言实现。最后通过ptrToFunc
辅助函数将结构体指针转为闭包函数对象返回,该函数也是通过汇编语言实现。
汇编语言实现了以下三个辅助函数:
func ptrToFunc(p unsafe.Pointer) func() int
func asmFunTwiceClosureAddr() uintptr
func asmFunTwiceClosureBody() int
其中ptrToFunc用于将指针转化为func() int类型的闭包函数
,asmFunTwiceClosureAddr用于返回闭包函数机器指令的开始地址(类似全局函数的地址)
,asmFunTwiceClosureBody是闭包函数对应的全局函数的实现
。
然后用Go汇编语言实现以上三个辅助函数:
#include "textflag.h"
TEXT ·ptrToFunc(SB), NOSPLIT, $0-16
MOVQ ptr+0(FP), AX // AX = ptr
MOVQ AX, ret+8(FP) // return AX
RET
TEXT ·asmFunTwiceClosureAddr(SB), NOSPLIT, $0-8
LEAQ ·asmFunTwiceClosureBody(SB), AX // AX = ·asmFunTwiceClosureBody(SB)
MOVQ AX, ret+0(FP) // return AX
RET
TEXT ·asmFunTwiceClosureBody(SB), NOSPLIT|NEEDCTXT, $0-8
MOVQ 8(DX), AX
ADDQ AX , AX // AX *= 2
MOVQ AX , 8(DX) // ctx.X = AX
MOVQ AX , ret+0(FP) // return AX
RET
其中·ptrToFunc
和·asmFunTwiceClosureAddr
函数的实现比较简单,我们不再详细描述。最重要的是·asmFunTwiceClosureBody
函数的实现:它有一个NEEDCTXT
标志。采用NEEDCTXT
标志定义的汇编函数表示需要一个上下文环境,在AMD64环境下是通过DX寄存器
来传递这个上下文环境指针,也就是对应FunTwiceClosure
结构体的指针。函数首先从FunTwiceClosure
结构体对象取出之前捕获的X,将X乘以2之后写回内存,最后返回修改之后的X的值。
如果是在汇编语言中调用闭包函数,也需要遵循同样的流程:首先为构造闭包对象,其中保存捕获的外层变量;在调用闭包函数时首先要拿到闭包对象,用闭包对象初始化DX
,然后从闭包对象中取出函数地址并用通过CALL
指令调用。
Songzhibin
关注成功
0
0