本文翻译自 https://github.com/teh-cmc/go-internals/tree/master/chapter1_assembly_primer

先看一个简单的 code

// go tool compile -N -l -S once.go
// go build -gcflags -S once.go

package main 

//go:noinline
func add(a, b int32) (int32, bool) { return a + b, true }

func main() { add(10, 32) }

生成汇编

GOOS=linux GOARCH=amd64 go tool compile -S x.go

在我的机器 Ubuntu kernel 5.4.0, Go version go1.18.3 amd64 上出来的结果与原文中还是有些差异的,但为了文章通顺,下面还是用的原文的结果。

0x0000 TEXT		"".add(SB), NOSPLIT, $0-16
  0x0000 FUNCDATA	$0, gclocals·f207267fbf96a0178e8758c6e3e0ce28(SB)
  0x0000 FUNCDATA	$1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
  0x0000 MOVL		"".b+12(SP), AX
  0x0004 MOVL		"".a+8(SP), CX
  0x0008 ADDL		CX, AX
  0x000a MOVL		AX, "".~r2+16(SP)
  0x000e MOVB		$1, "".~r3+20(SP)
  0x0013 RET

0x0000 TEXT		"".main(SB), $24-0
  ;; ...omitted stack-split prologue...
  0x000f SUBQ		$24, SP
  0x0013 MOVQ		BP, 16(SP)
  0x0018 LEAQ		16(SP), BP
  0x001d FUNCDATA	$0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
  0x001d FUNCDATA	$1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
  0x001d MOVQ		$137438953482, AX
  0x0027 MOVQ		AX, (SP)
  0x002b PCDATA		$0, $0
  0x002b CALL		"".add(SB)
  0x0030 MOVQ		16(SP), BP
  0x0035 ADDQ		$24, SP
  0x0039 RET
  ;; ...omitted stack-split epilogue...

Add 函数

0x0000 TEXT "".add(SB), NOSPLIT, $0-16
  • 0x0000 : 表示当前指令相对于函数的偏移量
  • TEXT “”.add : 定义函数,在链接阶段会被链接器替换为 main.add
  • (SB): SB 是 static base pointer,代表程序地址空间的起始地址。"".add(SB) 代表 add 在程序地址空间的一个固定 offset。比如像下面这样
$ objdump -j .text -t binary | grep 'main.add'
000000000044d980 g     F .text	000000000000000f main.add

FP 和 SB 的作用可以参见 《Go 语言汇编 一》

  • NOSPLIT: 告诉编译器不要插入 stack split,这是 Go 运行时决定是否需要对函数栈进行扩容。
  • $0-16 : $0 表示 add 函数在 stack frame 上没有分配局部变量,所以是 0。 $16 表示 caller 传入的函数参数时 16 字节。注意:在我自己机器上,这个值是 8,并且 add 的两个参数是由 AX 和 BX 传递的。

PCDATA 和 FUNCDATA

Go 语言作为一门静态编译型语言,在执行时每个函数的地址都是固定的,函数的每条指令也是固定的。如果针对每个函数和函数的每个指令生成一个地址表格(也叫 PC 表格),那么在运行时我们就可以根据 PC 寄存器的值轻松查询到指令当时对应的函数和位置信息。

PCDATA 用于生成 PC 表格,PCDATA 的指令用法为:PCDATA tableid, tableoffset 。PCDATA 有个两个参数,第一个参数为表格的类型,第二个是表格的地址,PCDATA_StackMapIndexPCDATA_InlTreeIndex

FUNC 表格用于记录函数的参数、局部变量的指针信息。FUNCDATA 指令和 PCDATA 的格式类似:FUNCDATA tableid, tableoffset ,第一个参数为表格的类型,第二个是表格的地址。

#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)

RET 取出存在 0(SP) 的返回地址,然后返回主函数。

下面把分散的注释放到一起

;; Declare global function symbol "".add (actually main.add once linked)
;; Do not insert stack-split preamble
;; 0 bytes of stack-frame, 16 bytes of arguments passed in
;; func add(a, b int32) (int32, bool)
0x0000 TEXT	"".add(SB), NOSPLIT, $0-16
  ;; ...omitted FUNCDATA stuff...
  0x0000 MOVL	"".b+12(SP), AX	    ;; move second Long-word (4B) argument from caller's stack-frame into AX
  0x0004 MOVL	"".a+8(SP), CX	    ;; move first Long-word (4B) argument from caller's stack-frame into CX
  0x0008 ADDL	CX, AX		    ;; compute AX=CX+AX
  0x000a MOVL	AX, "".~r2+16(SP)   ;; move addition result (AX) into caller's stack-frame
  0x000e MOVB	$1, "".~r3+20(SP)   ;; move `true` boolean (constant) into caller's stack-frame
  0x0013 RET			    ;; jump to return address stored at 0(SP)

程序的虚拟地址空间如下所示

   |    +-------------------------+ <-- 32(SP)              
   |    |                         |                         
 G |    |                         |                         
 R |    |                         |                         
 O |    | main.main's saved       |                         
 W |    |     frame-pointer (BP)  |                         
 S |    |-------------------------| <-- 24(SP)              
   |    |      [alignment]        |                         
 D |    | "".~r3 (bool) = 1/true  | <-- 21(SP)              
 O |    |-------------------------| <-- 20(SP)              
 W |    |                         |                         
 N |    | "".~r2 (int32) = 42     |                         
 W |    |-------------------------| <-- 16(SP)              
 A |    |                         |                         
 R |    | "".b (int32) = 32       |                         
 D |    |-------------------------| <-- 12(SP)              
 S |    |                         |                         
   |    | "".a (int32) = 10       |                         
   |    |-------------------------| <-- 8(SP)               
   |    |                         |                         
   |    |                         |                         
   |    |                         |                         
 \ | /  | return address to       |                         
  \|/   |     main.main + 0x30    |                         
   -    +-------------------------+ <-- 0(SP) (TOP OF STACK)

(diagram made with https://textik.com)

main 函数

在回顾一下 main 函数的汇编

  0x0000 TEXT		"".main(SB), $24-0
  ;; ...omitted stack-split prologue...
  0x000f SUBQ		$24, SP
  0x0013 MOVQ		BP, 16(SP)
  0x0018 LEAQ		16(SP), BP
  ;; ...omitted FUNCDATA stuff...
  0x001d MOVQ		$137438953482, AX
  0x0027 MOVQ		AX, (SP)
  ;; ...omitted PCDATA stuff...
  0x002b CALL		"".add(SB)
  0x0030 MOVQ		16(SP), BP
  0x0035 ADDQ		$24, SP
  0x0039 RET
  ;; ...omitted stack-split epilogue...

在 call 函数之前,所有的参数通过栈传递。

0x000f SUBQ     $24, SP // 栈从高地址往低地址增长,因此是 sub
0x0013 MOVQ     BP, 16(SP) // 把 BP 的值存到 16(SP) 
0x0018 LEAQ     16(SP), BP

其中 24 字节是怎么算出来的呢 ?

  • 8 bytes 16(SP)-24(SP),用于存当前 frame-pointer BP 值,to allow for stack-unwinding and facilitate debugging
  • 1+3 bytes 12(SP)-16(SP),用于第二个 bool 返回值,3 字节用于对齐,没有实际用处。
  • 4 bytes 8(SP)-12(SP), 第一个返回值。
  • 4 bytes 4(SP)-8(SP),用于第二个参数。
  • 4 bytes 0(SP)-4(SP) 用于第一个参数。

LEAQ 指令的作用是计算frame pointer 的新地址,并把它存到 BP。

继续往下看:

0x001d MOVQ     $137438953482, AX
0x0027 MOVQ     AX, (SP)

这里的常量 137438953482 是怎么来的呢? 其实它是 a,b 两个 4 字节参数合成一个 8 字节数字的结果。

$ echo 'obase=2;137438953482' | bc
10000000000000000000000000000000001010
\____/\______________________________/
   32                              10

MOVQ 是 Quad Word, b 字节。 这里也算是也个编译器优化的结果吧,导致人很难看懂。

接下来就是 CALL add 函数了。 注意:CALL 会把 8 字节的返回地址 push 到栈上,所以在 add 函数內部使用 SP 的时候,他已经默认增加了 8 字节的 offset。比如 "".a 不在 0(SP) 而是在 8(SP)。

最后三条语句

0x0030 MOVQ     16(SP), BP
0x0035 ADDQ     $24, SP
0x0039 RET
  1. 首先展开 frame pointer (似乎在这个程序里 BP 的值并没有什么用 ? )
  2. 恢复栈,加上之前减去的 24 。
  3. 返回。

goroutine 和 栈 split

Goroutine 有自己的栈空间,初始时分配了 2KB,当不够用时 runtime 会自动重新 2 倍的空间,这个过程就叫做 stack split。 联想到我们之前看到的 NOSPLIT 汇编代码,可以猜到,这是编译器告诉 runtime 这个函数不需要 stack split。

看以下代码,注意注释部分的 prologue 和 epilogue

  0x0000 TEXT	"".main(SB), $24-0
  ;; stack-split prologue
  0x0000 MOVQ	(TLS), CX
  0x0009 CMPQ	SP, 16(CX)
  0x000d JLS	58

  0x000f SUBQ	$24, SP
  0x0013 MOVQ	BP, 16(SP)
  0x0018 LEAQ	16(SP), BP
  ;; ...omitted FUNCDATA stuff...
  0x001d MOVQ	$137438953482, AX
  0x0027 MOVQ	AX, (SP)
  ;; ...omitted PCDATA stuff...
  0x002b CALL	"".add(SB)
  0x0030 MOVQ	16(SP), BP
  0x0035 ADDQ	$24, SP
  0x0039 RET

  ;; stack-split epilogue
  0x003a NOP
  ;; ...omitted PCDATA stuff...
  0x003a CALL	runtime.morestack_noctxt(SB)
  0x003f JMP	0
  1. 当进入函数时,首先检查是否需要扩展栈空间。
  2. 如果需要,则跳到 epilogue 部分,调用 runtime 扩展栈,然后 JMP 0 返回函数开始的地方。

Prologue

0x0000 MOVQ	(TLS), CX   ;; store current *g in CX
0x0009 CMPQ	SP, 16(CX)  ;; compare SP and g.stackguard0
0x000d JLS	58	    ;; jumps to 0x3a if SP <= g.stackguard0

TLS 是一个由 runtime 维护的虚拟寄存器,它指向当前的 g, g 是管理所有 goroutine 状态的一个数据结构。下面是 g 的结构体:

type g struct {
	stack       stack   // 16 bytes
	// 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.
	stackguard0 uintptr
	stackguard1 uintptr

	// ...omitted dozens of fields...
}

可见,汇编中的 16(CX) 指的就是 g.stackguard0 ,它的值是由 runtime 维护的。当 SP 的值小于 stackguard0 时,说明栈已经超出了 stackgurad0,所以需要 JLS 到 58 行进行扩容。

Epilogue 部分的三行代码很清晰,就不解释了。

最后附上同样 go code 在我的机器上生成的汇编代码

"".add STEXT nosplit size=8 args=0x8 locals=0x0 funcid=0x0 align=0x0
        0x0000 00000 (x.go:4)   TEXT    "".add(SB), NOSPLIT|ABIInternal, $0-8
        0x0000 00000 (x.go:4)   FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (x.go:4)   FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (x.go:4)   FUNCDATA        $5, "".add.arginfo1(SB)
        0x0000 00000 (x.go:4)   FUNCDATA        $6, "".add.argliveinfo(SB)
        0x0000 00000 (x.go:4)   PCDATA  $3, $1
        0x0000 00000 (x.go:4)   ADDL    BX, AX
        0x0002 00002 (x.go:4)   MOVL    $1, BX
        0x0007 00007 (x.go:4)   RET
        0x0000 01 d8 bb 01 00 00 00 c3                          ........
"".main STEXT size=54 args=0x0 locals=0x10 funcid=0x0 align=0x0
        0x0000 00000 (x.go:6)   TEXT    "".main(SB), ABIInternal, $16-0
        0x0000 00000 (x.go:6)   CMPQ    SP, 16(R14)
        0x0004 00004 (x.go:6)   PCDATA  $0, $-2
        0x0004 00004 (x.go:6)   JLS     47
        0x0006 00006 (x.go:6)   PCDATA  $0, $-1
        0x0006 00006 (x.go:6)   SUBQ    $16, SP
        0x000a 00010 (x.go:6)   MOVQ    BP, 8(SP)
        0x000f 00015 (x.go:6)   LEAQ    8(SP), BP
        0x0014 00020 (x.go:6)   FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0014 00020 (x.go:6)   FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0014 00020 (x.go:6)   MOVL    $10, AX
        0x0019 00025 (x.go:6)   MOVL    $32, BX
        0x001e 00030 (x.go:6)   PCDATA  $1, $0
        0x001e 00030 (x.go:6)   NOP
        0x0020 00032 (x.go:6)   CALL    "".add(SB)
        0x0025 00037 (x.go:6)   MOVQ    8(SP), BP
        0x002a 00042 (x.go:6)   ADDQ    $16, SP
        0x002e 00046 (x.go:6)   RET
        0x002f 00047 (x.go:6)   NOP
        0x002f 00047 (x.go:6)   PCDATA  $1, $-1
        0x002f 00047 (x.go:6)   PCDATA  $0, $-2
        0x002f 00047 (x.go:6)   CALL    runtime.morestack_noctxt(SB)
        0x0034 00052 (x.go:6)   PCDATA  $0, $-1
        0x0034 00052 (x.go:6)   JMP     0
        0x0000 49 3b 66 10 76 29 48 83 ec 10 48 89 6c 24 08 48  I;f.v)H...H.l$.H
        0x0010 8d 6c 24 08 b8 0a 00 00 00 bb 20 00 00 00 66 90  .l$....... ...f.
        0x0020 e8 00 00 00 00 48 8b 6c 24 08 48 83 c4 10 c3 e8  .....H.l$.H.....
        0x0030 00 00 00 00 eb ca                                ......
        rel 33+4 t=7 "".add+0
        rel 48+4 t=7 runtime.morestack_noctxt+0

参考资料