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语言的函数声明中推导出函数参数的大小。package main
//go:nosplit
func Swap(a, b int) (int, int)
// 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
NOSPLIT
、WRAPPER
和NEEDCTXT
几个。其中NOSPLIT
不会生成或包含栈分裂代码,这一般用于没有任何其它函数调用的叶子函数,这样可以适当提高性能。WRAPPER
标志则表示这个是一个包装函数,在panic或runtime.caller等某些处理函数帧的地方不会增加函数帧计数。最后的NEEDCTXT
表示需要一个上下文参数,一般用于闭包函数。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)
// ...
func Swap(a, b int) (ret0, ret1 int)
TEXT ·Swap(SB), $0-32
+0(FP)
、+8(FP)
、+16(FP)
和+24(FP)
来分别引用a、b、ret0和ret1
四个参数。不能直接以+0(FP)
的方式来使用参数。为了编写易于维护的汇编代码,Go汇编语言要求,任何通过FP伪寄存器访问的变量必和一个临时标识符前缀组合后才能有效,一般使用参数对应的变量名作为前缀
。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
依次递增
的,FP伪寄存器是第一个变量的开始地址
。func Foo(a bool, b int16) (c []byte)
函数参数和返回值的大小以及对齐问题
和结构体的大小和成员对齐问题
是一致
的,函数的第一个参数
和第一个返回值
会分别进行一次地址对齐
。我们可以用诡代思路将全部的参数和返回值以同样的顺序分别放到两个结构体中,将FP伪寄存器作为唯一的一个指针参数,而每个成员的地址也就是对应原来参数的地址。Foo_args_and_returns
临时结构体类型用于诡代原始的参数和返回值:type Foo_args struct {
a bool
b int16
c []byte
}
type Foo_returns struct {
c []byte
}
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
}
unsafe.Offsetof
函数自动计算生成。因为Go结构体中的每个成员已经满足了对齐要求,因此采用通用方式得到每个参数的偏移量也是满足对齐要求的。序言注意的是第一个返回值地址需要重新对齐机器字大小的倍数。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
伪SP寄存器,对应当前栈帧的底部
。因为在当前栈帧时栈的底部是固定不变的
,因此局部变量的相对于伪SP的偏移量也就是固定的,这可以简化局部变量的维护工作。SP真伪寄存器的区分只有一个原则:如果使用SP时有一个临时标识符前缀就是伪SP,否则就是真SP寄存器
。比如a(SP)和b+8(SP)有a和b临时前缀,这里都是伪SP,而前缀部分一般用于表示局部变量的名字。而(SP)和+8(SP)没有临时标识符作为前缀,它们都是真SP寄存器。函数的调用栈是从高地址向低地址增长的,因此伪SP寄存器对应栈帧的底部其实是对应更大的地址
。当前栈的顶部对应真实存在的SP寄存器,对应当前函数栈帧的栈顶,对应更小的地址。如果整个内存用Memory数组表示,那么Memory[0(SP):end-0(SP)]就是对应当前栈帧的切片,其中开始位置是真SP寄存器,结尾部分是伪SP寄存器。真SP寄存器一般用于表示调用其它函数时的参数和返回值,真SP寄存器对应内存较低的地址,所以被访问变量的偏移量是正数;而伪SP寄存器对应高地址,对应的局部变量的偏移量都是负数。func Foo() {
var c []byte
var b int16
var a bool
}
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
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
}
伪FP寄存器定位的
,FP寄存器对应第一个参数的开始地址(第一个参数地址较低)
,因此每个变量的偏移量是正数。而局部变量是通过伪SP寄存器定位的
,而伪SP寄存器对应的是第一个局部变量的结束地址(第一个局部变量地址较大)
,因此每个局部变量的偏移量都是负数。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
}
printsum
函数,printsum
函数输出两个整数的和。而printsum
函数内部又通过调用sum
函数计算两个数的和,并最终调用打印函数进行输出。因为printsum
既是被调用函数又是调用函数,所以它是我们要重点分析的函数。CALL
指令调用函数的过程和调用我们熟悉的调用println
函数输出的过程类似。#define SWAP(x, y) do{ int t = x; x = y; y = t; }while(0)
#define SWAP(x, y, t) MOVQ x, t; MOVQ y, x; MOVQ t, y
// 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
CALL
指令用于调用函数,RET
指令用于从调用函数返回。但是CALL和RET指令并没有处理函数调用时输入参数和返回值的问题。CALL指令类似PUSH IP和JMP somefunc两个指令的组合
,首先将当前的IP指令寄存器的值压入栈中,然后通过JMP指令将要调用函数的地址写入到IP寄存器实现跳转。而RET指令则是和CALL相反的操作,基本和POP IP指令等价
,也就是将执行CALL指令时保存在SP中的返回地址重新载入到IP寄存器,实现函数的返回。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
函数输出换行。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
指令跳转到函数的开始位置重新进行栈空间的检测。$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 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
}
CMPQ SP, 16(AX)
表示将当前的真实SP和爆栈警戒线比较,如果超出警戒线则表示需要进行栈扩容,也就是跳转到L_MORE_STK
。在L_MORE_STK
标号处,先调用runtime·morestack_noctxt
进行栈扩容,然后又跳回到函数的开始位置,此时此刻函数的栈已经调整了。然后再进行一次栈大小的检测,如果依然不足则继续扩容,直到栈足够大为止。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语言是如何实现这种特性的呢?: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指针越过某个地址之后返回值才完成被初始化的状态。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
表格的。编译器唯一无法自动生成是函数局部变量的表格,因此我们一般要在汇编函数的局部变量中谨慎使用指针类型。package main
type MyInt int
func (v MyInt) Twice() int {
return int(v)*2
}
func MyInt_Twice(v MyInt) int {
return int(v)*2
}
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
func (p *MyInt) Ptr() *MyInt {
return p
}
main.(*MyInt).Ptr
,也就是对应汇编中的·(*MyInt)·Ptr
。不过在Go汇编语言中,星号和小括弧都无法用作函数名字,也就是无法用汇编直接实现接收参数是指针类型的方法。type.string."hello"
中的双引号),这导致了无法通过手写的汇编代码实现全部的特性。或许是Go语言官方故意限制了汇编语言的特性。// sum = 1+2+...+n
// sum(100) = 5050
func sum(n int) int {
if n > 0 { return n+sum(n-1) } else { return 0 }
}
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
}
// 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
0(SP)
位置,调用结束后的返回值在8(SP)
位置。在函数调用之后要需要重新为需要的寄存器注入值,因为被调用的函数内部很可能会破坏了寄存器的状态。同时调用函数的参数值也是不可信任的,输入参数值也可能在被调用函数内部被修改了。NOSPLIT
标志,让汇编器为我们自动生成一个栈扩容的代码:// func sum(n int) int
TEXT ·sum(SB), $16-16
NO_LOCAL_POINTERS
// 原来的代码
NO_LOCAL_POINTERS
语句,该语句表示函数没有局部指针变量。栈的扩容必然要涉及函数参数和局部编指针的调整,如果缺少局部指针信息将导致扩容工作无法进行。不仅仅是栈的扩容需要函数的参数和局部指针标记表格,在GC进行垃圾回收时也将需要。函数的参数和返回值的指针状态可以通过在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。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是闭包函数对应的全局函数的实现
。#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
指令调用。手机扫一扫
移动阅读更方便
你可能感兴趣的文章