Go的汇编不是像 C/C++ 一样,对机器码的直接描述,而是兼容跨平台需求实现的半抽象化的指令集。

https://go.dev/doc/asm

汇编分析(Go 1.17)#

我们用一个简单的例子来开始汇编分析:

package main

func main() {
	add(1, 3)
}

func add(i, j int) int {
	return i + j
}

汇编结果,删去了一些无关的输出:

# go tool compile -S -l -N main.go
"".main STEXT size=54 args=0x0 locals=0x18 funcid=0x0
        0x0000 00000 (main.go:3)        TEXT    "".main(SB), ABIInternal, $24-0
        0x0000 00000 (main.go:3)        CMPQ    SP, 16(R14)
        0x0004 00004 (main.go:3)        PCDATA  $0, $-2
        0x0004 00004 (main.go:3)        JLS     47
        0x0006 00006 (main.go:3)        PCDATA  $0, $-1
        0x0006 00006 (main.go:3)        SUBQ    $24, SP
        0x000a 00010 (main.go:3)        MOVQ    BP, 16(SP)
        0x000f 00015 (main.go:3)        LEAQ    16(SP), BP
        0x0014 00020 (main.go:3)        FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0014 00020 (main.go:3)        FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0014 00020 (main.go:4)        MOVL    $1, AX
        0x0019 00025 (main.go:4)        MOVL    $3, BX
        0x001e 00030 (main.go:4)        PCDATA  $1, $0
        0x001e 00030 (main.go:4)        NOP
        0x0020 00032 (main.go:4)        CALL    "".add(SB)
        0x0025 00037 (main.go:5)        MOVQ    16(SP), BP
        0x002a 00042 (main.go:5)        ADDQ    $24, SP
        0x002e 00046 (main.go:5)        RET
        0x002f 00047 (main.go:5)        NOP
        0x002f 00047 (main.go:3)        PCDATA  $1, $-1
        0x002f 00047 (main.go:3)        PCDATA  $0, $-2
        0x002f 00047 (main.go:3)        CALL    runtime.morestack_noctxt(SB)
        0x0034 00052 (main.go:3)        PCDATA  $0, $-1
        0x0034 00052 (main.go:3)        JMP     0

"".add STEXT nosplit size=56 args=0x10 locals=0x10 funcid=0x0
        0x0000 00000 (main.go:7)        TEXT    "".add(SB), NOSPLIT|ABIInternal, $16-16
        0x0000 00000 (main.go:7)        SUBQ    $16, SP
        0x0004 00004 (main.go:7)        MOVQ    BP, 8(SP)
        0x0009 00009 (main.go:7)        LEAQ    8(SP), BP
        0x000e 00014 (main.go:7)        FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x000e 00014 (main.go:7)        FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x000e 00014 (main.go:7)        FUNCDATA        $5, "".add.arginfo1(SB)
        0x000e 00014 (main.go:7)        MOVQ    AX, "".i+24(SP)
        0x0013 00019 (main.go:7)        MOVQ    BX, "".j+32(SP)
        0x0018 00024 (main.go:7)        MOVQ    $0, "".~r2(SP)
        0x0020 00032 (main.go:8)        MOVQ    "".i+24(SP), AX
        0x0025 00037 (main.go:8)        ADDQ    "".j+32(SP), AX
        0x002a 00042 (main.go:8)        MOVQ    AX, "".~r2(SP)
        0x002e 00046 (main.go:8)        MOVQ    8(SP), BP
        0x0033 00051 (main.go:8)        ADDQ    $16, SP
        0x0037 00055 (main.go:8)        RET

FUNCDATAPCDATA是由编译器引入的,主要包含垃圾回收时使用的信息,这里略过。

先看main函数的汇编:

        0x0000 00000 (main.go:3)        TEXT    "".main(SB), ABIInternal, $24-0

首先,TEXT声明了"".main(SB)是.text代码段的一部分,并表示声明后的是函数的函数体。在链接阶段,""会替换成当前的包名,即main.add

$24-0需要从两部分解释:

  • -前面部分表示当前函数的帧大小,这个也可以从后面SUBQ $24, SP指令看出;
  • -后半部分表示调用方传入的参数的大小,位于调用者的帧上。 所以,$24-0表示main函数帧大小为24字节,调用者没有传参。==TODO: 24字节是怎么组成的==
        0x0006 00006 (main.go:3)        SUBQ    $24, SP
        0x000a 00010 (main.go:3)        MOVQ    BP, 16(SP)
        0x000f 00015 (main.go:3)        LEAQ    16(SP), BP

==TODO: 这里是干嘛用的?==

        0x0014 00020 (main.go:4)        MOVL    $1, AX
        0x0019 00025 (main.go:4)        MOVL    $3, BX
        0x001e 00030 (main.go:4)        PCDATA  $1, $0
        0x001e 00030 (main.go:4)        NOP
        0x0020 00032 (main.go:4)        CALL    "".add(SB)

接下来main函数就开始执行add函数了。可以看到,汇编将值1和3分别放入了AXBX寄存器中,在x86_64中,对应的就是raxrbx。然后用CALL指令跳转到"".add(SB)需要注意的是,这里之所以会将参数放到寄存器里,是因为Go 1.17引入了基于寄存器的调用惯例,区别于之前版本的基于堆栈的调用惯例。具体区别稍后在后文解释。

        0x0025 00037 (main.go:5)        MOVQ    16(SP), BP
        0x002a 00042 (main.go:5)        ADDQ    $24, SP
        0x002e 00046 (main.go:5)        RET
        0x002f 00047 (main.go:5)        NOP

因为main函数中没有执行其它的操作,汇编在将栈释放后,执行RET返回。

接下来,我们看看add函数的汇编:

        0x0000 00000 (main.go:7)        TEXT    "".add(SB), NOSPLIT|ABIInternal, $16-16

同样,第一行声明函数,相比于main函数,多了一个NOSPLIT参数。

$16-16表示当前函数帧大小为16字节,调用者传了16字节的参数(int + int)。

        0x0000 00000 (main.go:7)        SUBQ    $16, SP
        0x0004 00004 (main.go:7)        MOVQ    BP, 8(SP)
        0x0009 00009 (main.go:7)        LEAQ    8(SP), BP

和main函数一样。

        0x000e 00014 (main.go:7)        MOVQ    AX, "".i+24(SP)
        0x0013 00019 (main.go:7)        MOVQ    BX, "".j+32(SP)
        0x0018 00024 (main.go:7)        MOVQ    $0, "".~r2(SP)

可以看到,汇编将寄存器里面的参数值放到了caller帧中参数位置。之所以如此多此一举,猜测是为了保证兼容性。Go 1.17基于寄存器的调用惯例目前还只在amd64架构上实现。如果我们打开优化,是没有这一步的。

# go tool compile -S -l main.go
"".add STEXT nosplit size=4 args=0x10 locals=0x0 funcid=0x0
        0x0000 00000 (main.go:7)        TEXT    "".add(SB), NOSPLIT|ABIInternal, $0-16
        0x0000 00000 (main.go:7)        FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (main.go:7)        FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (main.go:7)        FUNCDATA        $5, "".add.arginfo1(SB)
        0x0000 00000 (main.go:8)        ADDQ    BX, AX
        0x0003 00003 (main.go:8)        RET

除此之外,MOVQ $0, "".~r2(SP)"".~r2(SP)初始化为0。

        0x0020 00032 (main.go:8)        MOVQ    "".i+24(SP), AX
        0x0025 00037 (main.go:8)        ADDQ    "".j+32(SP), AX
        0x002a 00042 (main.go:8)        MOVQ    AX, "".~r2(SP)

进行加法操作。

        0x002e 00046 (main.go:8)        MOVQ    8(SP), BP
        0x0033 00051 (main.go:8)        ADDQ    $16, SP
        0x0037 00055 (main.go:8)        RET

返回。

Stack-Split#

前面的分析还留了一个小问题,main函数收尾几条指令是干嘛用的? 初始状态下,runtime会给每个 goroutine 分配 2KB 的栈大小。随着函数调用,栈空间可能超过2KB。所以,在每次函数调用之前,runtime需要检查栈大小,如果超过了分配空间,就需要分配一个更大的栈(2倍大小),并将原始栈内的内容拷贝到新栈中。这个过程被称作 stack-split。

stack-split分为两个部分:

  • 函数前面的指令(prologue)检查当前goroutine是否超出了栈空间,如果是,跳到函数尾部;
  • 函数尾部的指令(epilogue)调用runtime.morestack_noctxt(SB)来触发stack-growth流程,然后跳回prologue。
		;; stack-split prologue
		0x0000 00000 (main.go:3)        CMPQ    SP, 16(R14)
        0x0004 00004 (main.go:3)        JLS     47
        ;; ...
        ;; stack-split epilogue
        0x002f 00047 (main.go:5)        NOP
        0x002f 00047 (main.go:3)        CALL    runtime.morestack_noctxt(SB)
        0x0034 00052 (main.go:3)        JMP     0

R14内存放的是指向当前g的指针:

// https://github.com/golang/go/blob/master/src/runtime/runtime2.go#L405
type g struct {
	// Stack parameters.
	// stack describes the actual stack memory: [stack.lo, stack.hi).
	// stackguard0 is the stack pointer compared in the Go stack growth prologue.
	// It is stack.lo+StackGuard normally, but can be StackPreempt to trigger a preemption.
	// stackguard1 is the stack pointer compared in the C stack growth prologue.
	// It is stack.lo+StackGuard on g0 and gsignal stacks.
	// It is ~0 on other goroutine stacks, to trigger a call to morestackc (and crash).
	stack       stack   // offset known to runtime/cgo
	stackguard0 uintptr // offset known to liblink
	stackguard1 uintptr // offset known to liblink
    /* ... */
}

stack大小为16字节,所以16(R14)指向的是stackguard0。从注释也可以看出,stackguard0用于Go栈的growth prologue。所以,prologue对比当前SP的值和stackguard0,如果小于或等于stackguard0,跳转到47,也就是epilogue。

epilogue指令就很简单了,调用runtime.morestack_noctxt(SB),跳回0。

如果每调用一次函数,都需要检查一下栈大小,这额外的开销是很恐怖的。所以,对于一些足够小的栈帧,如果不可能触发栈增长,就不需要这些额外的检查指令了。如在add函数的第一条TEXT指令中,添加了NOSPLIT,就表示编译器不需要插入stack-split先导指令。

基于栈帧的调用惯例(Go 1.17 before)#

官方的Release Note关于此次变更的描述:

Go 1.17 implements a new way of passing function arguments and results using registers instead of the stack. Benchmarks for a representative set of Go packages and programs show performance improvements of about 5%, and a typical reduction in binary size of about 2%. This is currently enabled for Linux, macOS, and Windows on the 64-bit x86 architecture (the linux/amd64, darwin/amd64, and windows/amd64 ports).

This change does not affect the functionality of any safe Go code and is designed to have no impact on most assembly code. It may affect code that violates the unsafe.Pointer rules when accessing function arguments, or that depends on undocumented behavior involving comparing function code pointers. To maintain compatibility with existing assembly functions, the compiler generates adapter functions that convert between the new register-based calling convention and the previous stack-based calling convention. These adapters are typically invisible to users, except that taking the address of a Go function in assembly code or taking the address of an assembly function in Go code using reflect.ValueOf(fn).Pointer() or unsafe.Pointer will now return the address of the adapter. Code that depends on the value of these code pointers may no longer behave as expected. Adapters also may cause a very small performance overhead in two cases: calling an assembly function indirectly from Go via a func value, and calling Go functions from assembly.

我们来看看如果用Go 1.16.7来编译,汇编是怎样的:

# go tool compile -N -S -l test.go
"".main STEXT size=71 args=0x0 locals=0x20 funcid=0x0
	0x0000 00000 (test.go:4)	TEXT	"".main(SB), ABIInternal, $32-0
	0x0000 00000 (test.go:4)	MOVQ	(TLS), CX
	0x0009 00009 (test.go:4)	CMPQ	SP, 16(CX)
	0x000d 00013 (test.go:4)	PCDATA	$0, $-2
	0x000d 00013 (test.go:4)	JLS	64
	0x000f 00015 (test.go:4)	PCDATA	$0, $-1
	0x000f 00015 (test.go:4)	SUBQ	$32, SP
	0x0013 00019 (test.go:4)	MOVQ	BP, 24(SP)
	0x0018 00024 (test.go:4)	LEAQ	24(SP), BP
	0x001d 00029 (test.go:4)	FUNCDATA	$0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
	0x001d 00029 (test.go:4)	FUNCDATA	$1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
	0x001d 00029 (test.go:5)	MOVQ	$1, (SP)
	0x0025 00037 (test.go:5)	MOVQ	$3, 8(SP)
	0x002e 00046 (test.go:5)	PCDATA	$1, $0
	0x002e 00046 (test.go:5)	CALL	"".add(SB)
	0x0033 00051 (test.go:6)	MOVQ	24(SP), BP
	0x0038 00056 (test.go:6)	ADDQ	$32, SP
	0x003c 00060 (test.go:6)	RET
	0x003d 00061 (test.go:6)	NOP
	0x003d 00061 (test.go:4)	PCDATA	$1, $-1
	0x003d 00061 (test.go:4)	PCDATA	$0, $-2
	0x003d 00061 (test.go:4)	NOP
	0x0040 00064 (test.go:4)	CALL	runtime.morestack_noctxt(SB)
	0x0045 00069 (test.go:4)	PCDATA	$0, $-1
	0x0045 00069 (test.go:4)	JMP	0

"".add STEXT nosplit size=25 args=0x18 locals=0x0 funcid=0x0
	0x0000 00000 (test.go:8)	TEXT	"".add(SB), NOSPLIT|ABIInternal, $0-24
	0x0000 00000 (test.go:8)	FUNCDATA	$0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
	0x0000 00000 (test.go:8)	FUNCDATA	$1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
	0x0000 00000 (test.go:8)	MOVQ	$0, "".~r2+24(SP)
	0x0009 00009 (test.go:9)	MOVQ	"".i+8(SP), AX
	0x000e 00014 (test.go:9)	ADDQ	"".j+16(SP), AX
	0x0013 00019 (test.go:9)	MOVQ	AX, "".~r2+24(SP)
	0x0018 00024 (test.go:9)	RET

可以看到,汇编主要有几个变化:

  • 帧大小发生了变化。main函数由24字节(1.17)增大到32字节(1.16.7),而add函数两者大小一致(1.17开启优化后也是0字节)。这也匹配官方文档中“升级到1.17后,二进制大小缩小了2%的描述”;

  • 传参值没有放到寄存器内,放到了栈上,同时,add函数的返回值也直接放在了栈上。

    	0x001d 00029 (test.go:5)	MOVQ	$1, (SP)
    	0x0025 00037 (test.go:5)	MOVQ	$3, 8(SP)
    
        0x0013 00019 (test.go:9)	MOVQ	AX, "".~r2+24(SP)
    

Go 1.17版本之前,Go采用基于栈的调用约定,即函数的参数与返回值都通过栈来传递,这种方式的优点是实现简单,不用担心底层cpu架构寄存器的差异,适合跨平台;但缺点就是牺牲了一些性能,我们都知道寄存器的访问速度要远高于内存。

多返回值的实现#

如果有多个返回值,那汇编是怎样的呢?

我们修改一下样例程序:

package main

func main() {
	add(1, 3)
}

func add(i, j int) (int, int) {
	return i + j, i - j
}

重新汇编:

"".add STEXT nosplit size=85 args=0x10 locals=0x18 funcid=0x0
	0x0000 00000 (main.go:7)	TEXT	"".add(SB), NOSPLIT|ABIInternal, $24-16
	0x0000 00000 (main.go:7)	SUBQ	$24, SP
	0x0004 00004 (main.go:7)	MOVQ	BP, 16(SP)
	0x0009 00009 (main.go:7)	LEAQ	16(SP), BP
	0x000e 00014 (main.go:7)	FUNCDATA	$0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
	0x000e 00014 (main.go:7)	FUNCDATA	$1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
	0x000e 00014 (main.go:7)	FUNCDATA	$5, "".add.arginfo1(SB)
	0x000e 00014 (main.go:7)	MOVQ	AX, "".i+32(SP)
	0x0013 00019 (main.go:7)	MOVQ	BX, "".j+40(SP)
	0x0018 00024 (main.go:7)	MOVQ	$0, "".~r2+8(SP)
	0x0021 00033 (main.go:7)	MOVQ	$0, "".~r3(SP)
	0x0029 00041 (main.go:8)	MOVQ	"".i+32(SP), CX
	0x002e 00046 (main.go:8)	ADDQ	"".j+40(SP), CX
	0x0033 00051 (main.go:8)	MOVQ	CX, "".~r2+8(SP)
	0x0038 00056 (main.go:8)	MOVQ	"".i+32(SP), BX
	0x003d 00061 (main.go:8)	SUBQ	"".j+40(SP), BX
	0x0042 00066 (main.go:8)	MOVQ	BX, "".~r3(SP)
	0x0046 00070 (main.go:8)	MOVQ	"".~r2+8(SP), AX
	0x004b 00075 (main.go:8)	MOVQ	16(SP), BP
	0x0050 00080 (main.go:8)	ADDQ	$24, SP
	0x0054 00084 (main.go:8)	RET

可以看到,第二个返回值放到了BX寄存器内。

总结#

  • 函数调用时,会从左到右扫描参数。
  • 遇到整型或指针参数,就按顺序从 RAXRBX, … 中取一个寄存器使用。
  • 遇到浮点型参数,就按顺序从 XMM0XMM1, … 中取一个寄存器使用。
  • 如果相应类型的寄存器已经用完,那么这个参数以及后续的所有参数都将通过来传递。
  • 返回值也遵循类似的规则,按顺序使用相同的寄存器列表。
  • 对于 arm 来说,规则与 amd64 类似,只不过使用的寄存器不同:整型/指针参数使用 R0-R7,浮点参数使用 F0-F7

Reference#