Go 语言函数调用汇编代码分析

背景说明

Go 语言是 Google 开发的一种静态强类型、编译型、并发型,并具有垃圾回收功能的编程语言,它用批判吸收的眼光,融合 C 语言、Java 等众家之长,将简洁、高效演绎得淋漓尽致。

这篇文章主要研究 Go 语言中的函数调用是如何用 x64 汇编实现的。

探索过程

首先编写一个非常简单的 Go 语言程序。

test1.go

1
2
3
4
5
6
7
8
9
package main

func sum(a, b int) int {
return a + b
}

func main() {
sum(1, 2)
}

程序中定义了 sum 函数,用于计算两个整型变量的和并返回。main 函数中调用了 sum 函数。期望通过这个程序,研究 Go 语言中函数调用的实现原理

使用 go tool compile -S test1.go,可以生成 test1.o 目标文件并在屏幕上输出汇编代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
"".sum STEXT nosplit size=4 args=0x10 locals=0x0 funcid=0x0
0x0000 00000 (test1.go:3) TEXT "".sum(SB), NOSPLIT|ABIInternal, $0-16
0x0000 00000 (test1.go:3) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (test1.go:3) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (test1.go:3) FUNCDATA $5, "".sum.arginfo1(SB)
0x0000 00000 (test1.go:4) ADDQ BX, AX
0x0003 00003 (test1.go:4) RET
0x0000 48 01 d8 c3 H...
"".main STEXT nosplit size=1 args=0x0 locals=0x0 funcid=0x0
0x0000 00000 (test1.go:7) TEXT "".main(SB), NOSPLIT|ABIInternal, $0-0
0x0000 00000 (test1.go:7) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (test1.go:7) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (test1.go:9) RET
0x0000 c3 .
go.cuinfo.packagename. SDWARFCUINFO dupok size=0
0x0000 6d 61 69 6e main
""..inittask SNOPTRDATA size=24
0x0000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0x0010 00 00 00 00 00 00 00 00 ........
go.info."".sum$abstract SDWARFABSFCN dupok size=25
0x0000 04 2e 73 75 6d 00 01 01 11 61 00 00 00 00 00 00 ..sum....a......
0x0010 11 62 00 00 00 00 00 00 00 .b.......
rel 0+0 t=23 type.int+0
rel 12+4 t=31 go.info.int+0
rel 20+4 t=31 go.info.int+0
gclocals·33cdeccccebe80329f1fdbee7f5874cb SRODATA dupok size=8
0x0000 01 00 00 00 00 00 00 00 ........
"".sum.arginfo1 SRODATA static dupok size=5
0x0000 00 08 08 08 ff .....

代码中出现了一些不属于 x64 汇编的指令。在 Go 语言的官方文档中可以查阅到一些 Go 语言专用的伪指令。TEXT 伪指令实际上定义了一个函数,比如 TEXT "".sum(SB), NOSPLIT|ABIInternal, $0-16 定义了一个叫 sum 的函数,$0-16 表示函数帧的大小为 0 字节,参数大小为 16 字节(即两个整型变量的大小)。FUNCDATA 伪指令为垃圾回收器提供信息。

使用 go tool objdump -S -gnu test1.o 命令可以更方便地查看汇编代码,因为它去除了 Go 语言专属的伪指令,并且可以显示 GNU 汇编。

1
2
3
4
5
6
7
8
TEXT "".sum(SB) gofile../home/regmsif/test/goasm/test1.go
return a + b
0x561 4801d8 ADDQ BX, AX // add %rbx,%rax
0x564 c3 RET // retq

TEXT "".main(SB) gofile../home/regmsif/test/goasm/test1.go
}
0x565 c3 RET // retq

sum 函数直接将 rbx 寄存器的值加到 rax 上并返回。可以推测 Go 语言调用函数时通常会将参数依次放在 rax、rbx 等寄存器,将返回值放在 rax 寄存器

main 函数里其实只执行了 retq 指令,也就是说并没有调用 sum 函数,这与预期不符。猜测这是因为我们没有将 sum 函数的返回值赋给其他变量,所以 Go 语言自动将函数调用优化了。尝试将 sum 函数的返回值赋给一个变量。

1
2
3
4
5
6
7
8
9
package main

func sum(a, b int) int {
return a + b
}

func main() {
c := sum(1, 2)
}

然而这份代码无法通过编译。编译错误提示 test2.go:8:2: c declared but not used。这是因为 Go 语言不允许定义不会被使用的变量。既然一定要使用,那就继续用这个变量调用一次 sum 函数。

test2.go

1
2
3
4
5
6
7
8
9
10
package main

func sum(a, b int) int {
return a + b
}

func main() {
c := sum(1, 2)
sum(c, 3)
}

汇编代码如下。

1
2
3
4
5
6
7
8
TEXT "".sum(SB) gofile../home/regmsif/test/goasm/test2.go
return a + b
0x561 4801d8 ADDQ BX, AX // add %rbx,%rax
0x564 c3 RET // retq

TEXT "".main(SB) gofile../home/regmsif/test/goasm/test2.go
}
0x565 c3 RET // retq

发现仍然没有调用 sum 函数。猜测这是因为 Go 语言编译时会构建一棵函数调用的依赖关系树。如果一个函数不被任何其他操作依赖,则这个函数不会被调用。为了验证这一猜想,尝试编写一个具有较为复杂的嵌套关系的代码。

test3.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main

func sum(a, b int) int {
return a + b
}

func mul(a, b int) int {
return a * b
}

func main() {
if sum(1, 2) == 0 {
if mul(3, 4) == 0 {
sum(5, 6)
}
if sum(7, 8) == 0 && mul(9, 10) == 0 {
mul(11, 12)
}
} else {
if mul(13, 14) == 0 || sum(15, 16) == 0 {
if sum(17, 18) == 0 {
mul(19, 20)
}
}
}
}

汇编代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
TEXT "".sum(SB) gofile../home/regmsif/test/goasm/test3.go
return a + b
0x795 4801d8 ADDQ BX, AX // add %rbx,%rax
0x798 c3 RET // retq

TEXT "".mul(SB) gofile../home/regmsif/test/goasm/test3.go
return a * b
0x799 480fafc3 IMULQ BX, AX // imul %rbx,%rax
0x79d c3 RET // retq

TEXT "".main(SB) gofile../home/regmsif/test/goasm/test3.go
}
0x79e c3 RET // retq

可以发现,即使嵌套了很多层,编译器依然判定这些代码都不需要。那么,究竟什么样的函数才是必须执行的呢?被忽略的函数都是给定一些参数,返回一个值,不会对外界产生影响,也就是说,它们没有副作用。从另一方面想,正是因为这些函数没有副作用,所以在它们的返回值被忽略时,它们也可以被忽略。而有副作用的函数会对外界产生影响,如果忽略它们,可能影响程序的结果。

尝试为 sum 函数添加副作用。

test4.go

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

var c int

func sum(a, b int) int {
c = a + b
return c
}

func main() {
sum(1, 2)
sum(c, 3)
}

汇编代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
TEXT "".sum(SB) gofile../home/regmsif/test/goasm/test4.go
c = a + b
0x5d4 4801d8 ADDQ BX, AX // add %rbx,%rax
0x5d7 48890500000000 MOVQ AX, 0(IP) // mov %rax,(%rip) [3:7]R_PCREL:"".c
return c
0x5de c3 RET // retq

TEXT "".main(SB) gofile../home/regmsif/test/goasm/test4.go
sum(1, 2)
0x5df 48c7050000000003000000 MOVQ $0x3, 0(IP) // movq $0x3,(%rip) [3:7]R_PCREL:"".c+-4
sum(c, 3)
0x5ea 90 NOPL // nop
c = a + b
0x5eb 48c7050000000006000000 MOVQ $0x6, 0(IP) // movq $0x6,(%rip) [3:7]R_PCREL:"".c+-4
}
0x5f6 c3 RET // retq

main 函数里出现了新的指令 movq $0x3,(%rip),它表示将 3 放到一个相对于 rip 的地址(即变量 c 的地址)上。这个相对地址在代码中为 0,但这并不意味着会把数据放在下一条指令的位置,因为编译器在链接之前还不知道这个数据会被安排在哪,所以 0 相当于一个占位符,地址的具体值将在链接时填充。

通过 go build test4.go 生成可执行文件,再查看汇编代码中的 main 函数。

1
2
3
4
5
6
7
8
9
TEXT main.main(SB) /home/regmsif/test/goasm/test4.go
sum(1, 2)
0x4553e0 48c705754b090003000000 MOVQ $0x3, main.c(SB) // movq $0x3,0x94b75(%rip)
sum(c, 3)
0x4553eb 90 NOPL // nop
c = a + b
0x4553ec 48c705694b090006000000 MOVQ $0x6, main.c(SB) // movq $0x6,0x94b69(%rip)
}
0x4553f7 c3 RET // retq

可以发现指令变为 movq $0x3,0x94b75(%rip)。然而,在生成的可执行文件中并没有发现 sum 函数。这可能是因为编译器自动计算了函数的返回值并作为常量(3 和 6)写在汇编代码中。还有个问题是不清楚为什么要在第一条 movq 指令和第二条 movq 指令之间插入一条 nop 指令。猜测是为了解决 CPU 流水线竞争。

尝试将 sum 函数放在循环中,观察是否会自动计算。

test5.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

var c int

func sum(a, b int) int {
c = a + b
return c
}

func main() {
for i := 1; i <= 100; i++ {
sum(i, i+1)
}
}

汇编代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
TEXT "".sum(SB) gofile../home/regmsif/test/goasm/test5.go
c = a + b
0x5bd 4801d8 ADDQ BX, AX // add %rbx,%rax
0x5c0 48890500000000 MOVQ AX, 0(IP) // mov %rax,(%rip) [3:7]R_PCREL:"".c
return c
0x5c7 c3 RET // retq

TEXT "".main(SB) gofile../home/regmsif/test/goasm/test5.go
func main() {
0x5c8 b801000000 MOVL $0x1, AX // mov $0x1,%eax
for i := 1; i <= 100; i++ {
0x5cd eb19 JMP 0x5e8 // jmp 0x5e8
sum(i, i+1)
0x5cf 90 NOPL // nop
c = a + b
0x5d0 488d0c00 LEAQ 0(AX)(AX*1), CX // lea (%rax,%rax,1),%rcx
0x5d4 488d4901 LEAQ 0x1(CX), CX // lea 0x1(%rcx),%rcx
0x5d8 48890d00000000 MOVQ CX, 0(IP) // mov %rcx,(%rip) [3:7]R_PCREL:"".c
for i := 1; i <= 100; i++ {
0x5df 48ffc0 INCQ AX // inc %rax
0x5e2 660f1f440000 NOPW 0(AX)(AX*1) // nopw (%rax,%rax,1)
0x5e8 4883f864 CMPQ $0x64, AX // cmp $0x64,%rax
0x5ec 7ee1 JLE 0x5cf // jle 0x5cf
}
0x5ee c3 RET // retq

编译器并没有自动计算最终结果。不过,它也没有调用 sum 函数。实际上,它直接使用了 lea 指令来计算两个数之和。在这个例子里需要计算 i + (i + 1),第一条 lea 指令 lea (%rax,%rax,1),%rcx 计算了 i + 1 * i,第二条 lea 指令 lea 0x1(%rcx),%rcx 把前一条指令的结果加 1。

尝试将 sum 函数里的 c = a + b 改成其他的式子(比如 c = a/5 + b*123 + 4),汇编代码如下。

test6.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
TEXT "".sum(SB) gofile../home/regmsif/test/goasm/test6.go
c = a/5 + b*123 + 4
0x5bd 4889c1 MOVQ AX, CX // mov %rax,%rcx
0x5c0 48b8cdcccccccccccccc MOVQ $0xcccccccccccccccd, AX // mov $-0x3333333333333333,%rax
0x5ca 48f7e9 IMULQ CX // imul %rcx
0x5cd 4801ca ADDQ CX, DX // add %rcx,%rdx
0x5d0 48c1fa02 SARQ $0x2, DX // sar $0x2,%rdx
0x5d4 48c1f93f SARQ $0x3f, CX // sar $0x3f,%rcx
0x5d8 4829ca SUBQ CX, DX // sub %rcx,%rdx
0x5db 486bcb7b IMULQ $0x7b, BX, CX // imul $0x7b,%rbx,%rcx
0x5df 488d0411 LEAQ 0(CX)(DX*1), AX // lea (%rcx,%rdx,1),%rax
0x5e3 488d4004 LEAQ 0x4(AX), AX // lea 0x4(%rax),%rax
0x5e7 48890500000000 MOVQ AX, 0(IP) // mov %rax,(%rip) [3:7]R_PCREL:"".c
return c
0x5ee c3 RET // retq

TEXT "".main(SB) gofile../home/regmsif/test/goasm/test6.go
func main() {
0x5ef b801000000 MOVL $0x1, AX // mov $0x1,%eax
for i := 1; i <= 100; i++ {
0x5f4 eb2f JMP 0x625 // jmp 0x625
sum(i, i+1)
0x5f6 90 NOPL // nop
for i := 1; i <= 100; i++ {
0x5f7 4889c1 MOVQ AX, CX // mov %rax,%rcx
c = a/5 + b*123 + 4
0x5fa 48b8cdcccccccccccccc MOVQ $0xcccccccccccccccd, AX // mov $-0x3333333333333333,%rax
0x604 48f7e9 IMULQ CX // imul %rcx
0x607 4801ca ADDQ CX, DX // add %rcx,%rdx
0x60a 48c1fa02 SARQ $0x2, DX // sar $0x2,%rdx
0x60e 486bd97b IMULQ $0x7b, CX, BX // imul $0x7b,%rcx,%rbx
0x612 488d141a LEAQ 0(DX)(BX*1), DX // lea (%rdx,%rbx,1),%rdx
0x616 488d527f LEAQ 0x7f(DX), DX // lea 0x7f(%rdx),%rdx
0x61a 48891500000000 MOVQ DX, 0(IP) // mov %rdx,(%rip) [3:7]R_PCREL:"".c
for i := 1; i <= 100; i++ {
0x621 488d4101 LEAQ 0x1(CX), AX // lea 0x1(%rcx),%rax
0x625 4883f864 CMPQ $0x64, AX // cmp $0x64,%rax
0x629 7ecb JLE 0x5f6 // jle 0x5f6
}
0x62b c3 RET // retq

可以发现不管是在 sum 函数中还是在 main 函数中,编译器都对这个式子的计算做了优化,但仔细观察可知这是两个不同的式子:sum 函数里最后一步计算为 lea 0x4(%rax),%rax,对应的式子就是 a/5 + b*123 + 4;而 main 函数里最后一步计算为 lea 0x7f(%rdx),%rdx,对应的式子其实是 i/5 + i*123 + 127 = i/5 + (i+1)*123 + 4。也就是说,编译器似乎自动理解了 sum 函数要做的工作,并把工作的内容嵌入了调用 sum 函数的地方。

以上这些都与传统意义上对函数调用的认知不同。一般来说,函数调用的步骤是先设置参数,接着跳转到函数的起始地址,最后通过 retq 指令返回,并取返回值。然而,这几个 Go 语言的程序都没有真正“调用” sum 函数。它们要么是无视了 sum 函数,要么是将 sum 函数以某种方式嵌入了调用它的地方。

那么有什么办法可以让 Go 语言的程序“真正”调用函数呢?其实,Go 语言所做的事情就是一类编译优化。可以通过 go tool compile -m test1.go 命令查看编译 test1.go 时使用的优化选项。

1
2
3
test1.go:3:6: can inline sum
test1.go:7:6: can inline main
test1.go:8:5: inlining call to sum

显然,编译器使用了内联函数。调用函数时,编译器直接将函数内联嵌入调用的地方,而不通过 callq 指令跳转。

为了关闭内联函数,在编译时添加 -l 选项。用 go tool compile -l -m test1.go 编译得到的汇编代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
TEXT "".sum(SB) gofile../home/regmsif/test/goasm/test1.go
return a + b
0x551 4801d8 ADDQ BX, AX // add %rbx,%rax
0x554 c3 RET // retq

TEXT "".main(SB) gofile../home/regmsif/test/goasm/test1.go
func main() {
0x555 493b6610 CMPQ 0x10(R14), SP // cmp 0x10(%r14),%rsp
0x559 7629 JBE 0x584 // jbe 0x584
0x55b 4883ec18 SUBQ $0x18, SP // sub $0x18,%rsp
0x55f 48896c2410 MOVQ BP, 0x10(SP) // mov %rbp,0x10(%rsp)
0x564 488d6c2410 LEAQ 0x10(SP), BP // lea 0x10(%rsp),%rbp
sum(1, 2)
0x569 b801000000 MOVL $0x1, AX // mov $0x1,%eax
0x56e bb02000000 MOVL $0x2, BX // mov $0x2,%ebx
0x573 6690 NOPW // data16 nop
0x575 e800000000 CALL 0x57a // callq 0x57a [1:5]R_CALL:"".sum
}
0x57a 488b6c2410 MOVQ 0x10(SP), BP // mov 0x10(%rsp),%rbp
0x57f 4883c418 ADDQ $0x18, SP // add $0x18,%rsp
0x583 c3 RET // retq
func main() {
0x584 e800000000 CALL 0x589 // callq 0x589 [1:5]R_CALL:runtime.morestack_noctxt
0x589 ebca JMP "".main(SB) // jmp 0x555

这个汇编对我们来说就很熟悉了。调用函数之前,用 mov $0x1,%eaxmov $0x2,%ebx 指令设置参数,用 callq 0x57a 指令调用 sum 函数,用 add %rbx,%rax 指令计算结果并保存在 rax 寄存器,最后用 retq 指令返回。

这意味着之前有关副作用的猜想并不准确。在这个程序中,sum 函数没有副作用,但它仍会被调用。事实上,如果在编译 test3.go 时关闭内联函数,将发现它们每次都会被调用。

Go 的编译器还有一个 -N 选项,可以关闭编译 test1.go 时的优化。用 go tool compile -N -m test1.go 编译得到的汇编代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
TEXT "".sum(SB) gofile../home/regmsif/test/goasm/test1.go
func sum(a, b int) int {
0x561 4883ec10 SUBQ $0x10, SP // sub $0x10,%rsp
0x565 48896c2408 MOVQ BP, 0x8(SP) // mov %rbp,0x8(%rsp)
0x56a 488d6c2408 LEAQ 0x8(SP), BP // lea 0x8(%rsp),%rbp
0x56f 4889442418 MOVQ AX, 0x18(SP) // mov %rax,0x18(%rsp)
0x574 48895c2420 MOVQ BX, 0x20(SP) // mov %rbx,0x20(%rsp)
0x579 48c7042400000000 MOVQ $0x0, 0(SP) // movq $0x0,(%rsp)
return a + b
0x581 488b442418 MOVQ 0x18(SP), AX // mov 0x18(%rsp),%rax
0x586 4803442420 ADDQ 0x20(SP), AX // add 0x20(%rsp),%rax
0x58b 48890424 MOVQ AX, 0(SP) // mov %rax,(%rsp)
0x58f 488b6c2408 MOVQ 0x8(SP), BP // mov 0x8(%rsp),%rbp
0x594 4883c410 ADDQ $0x10, SP // add $0x10,%rsp
0x598 c3 RET // retq

TEXT "".main(SB) gofile../home/regmsif/test/goasm/test1.go
func main() {
0x599 4883ec20 SUBQ $0x20, SP // sub $0x20,%rsp
0x59d 48896c2418 MOVQ BP, 0x18(SP) // mov %rbp,0x18(%rsp)
0x5a2 488d6c2418 LEAQ 0x18(SP), BP // lea 0x18(%rsp),%rbp
sum(1, 2)
0x5a7 48c744241001000000 MOVQ $0x1, 0x10(SP) // movq $0x1,0x10(%rsp)
0x5b0 48c744240802000000 MOVQ $0x2, 0x8(SP) // movq $0x2,0x8(%rsp)
return a + b
0x5b9 488b442410 MOVQ 0x10(SP), AX // mov 0x10(%rsp),%rax
0x5be 4883c002 ADDQ $0x2, AX // add $0x2,%rax
sum(1, 2)
0x5c2 48890424 MOVQ AX, 0(SP) // mov %rax,(%rsp)
0x5c6 eb00 JMP 0x5c8 // jmp 0x5c8
}
0x5c8 488b6c2418 MOVQ 0x18(SP), BP // mov 0x18(%rsp),%rbp
0x5cd 4883c420 ADDQ $0x20, SP // add $0x20,%rsp
0x5d1 c3 RET // retq

可以发现在函数内部多了很多代码(原来只有一条 add %rbx,%rax)。这些代码是用于创建函数栈帧、保存寄存器值的。在这个程序里,main 函数也没有直接调用 sum 函数,而是将 sum 函数的代码嵌入调用的地方(将 %rax%rbx 替换成了 $0x1$0x2)。这意味着编译器是将编译优化与内联函数分开的。它们可以同时打开,同时关闭,也可以只打开其中一个。对于 _test1.go_,打开或关闭这两个选项的效果可以总结为如下表格。

关闭编译优化 打开编译优化
关闭内联函数 函数内部创建栈帧并保存寄存器
调用函数时,用 rax 和 rbx 传参,用 rax 取返回值
函数内部直接执行 add %rbx,%rax
调用函数时,用 rax 和 rbx 传参,用 rax 取返回值
打开内联函数 函数内部创建栈帧并保存寄存器
调用函数时,将函数嵌入调用的地方
函数内部直接执行 add %rbx,%rax
没有调用函数

到这里,为什么同时打开编译优化和内联函数时不会调用 sum 函数的问题就很明朗了。打开编译优化意味着 sum 函数不需要对栈进行操作,直接执行 add %rbx,%rax;打开内联函数意味着这条语句会被直接嵌入调用 sum 函数的地方,并且 %rax%rbx 会被替换成 $0x1$0x2,即 add $0x1,$0x2。但这并不是一条合法的汇编指令,因为 add 指令的第二个参数必须为寄存器或内存。于是,这条指令并不会产生任何效果,即被忽略了。

这是不是意味着 sum 函数就没有用呢?尝试将 sum 函数作为 if 语句的条件,观察是否会生效。

test7.go

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

var c int

func sum(a, b int) int {
return a + b
}

func main() {
if sum(1, 2) == 3 {
c = 4
}
}

汇编代码如下(打开编译优化和内联函数)。

1
2
3
4
5
6
7
8
9
10
TEXT "".sum(SB) gofile../home/regmsif/test/goasm/test7.go
return a + b
0x5a6 4801d8 ADDQ BX, AX // add %rbx,%rax
0x5a9 c3 RET // retq

TEXT "".main(SB) gofile../home/regmsif/test/goasm/test7.go
c = 4
0x5aa 48c7050000000004000000 MOVQ $0x4, 0(IP) // movq $0x4,(%rip) [3:7]R_PCREL:"".c+-4
}
0x5b5 c3 RET // retq

main 函数里只有 movq $0x4,(%rip)retq,也就是说 c = 4 被直接执行了,并没有先做判断。这可能是因为编译器将 add $0x1,$0x2 看成常数 3,将它与 3 的比较结果看成布尔常数 true,所以忽略了判断直接执行内部语句。

为了验证这一猜想,尝试将条件改为 true,汇编代码不变,符合预期。如果将条件改为 sum(1, 2) != 3,则 main 函数里只有 retq,因为比较结果为布尔常数 false,符合预期。如果关闭内联函数(或编译优化),则会先调用(或嵌入)函数进行判断,符合预期。

尝试在一个函数内部调用另一个函数。

test8.go

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

func mul(a, b int) int {
return a * b
}

func sum(a, b int) int {
return a + mul(a, b)
}

func main() {
sum(1, 2)
}

汇编代码如下(关闭编译优化,打开内联函数)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
TEXT "".mul(SB) gofile../home/regmsif/test/goasm/test8.go
func mul(a, b int) int {
0x795 4883ec10 SUBQ $0x10, SP // sub $0x10,%rsp
0x799 48896c2408 MOVQ BP, 0x8(SP) // mov %rbp,0x8(%rsp)
0x79e 488d6c2408 LEAQ 0x8(SP), BP // lea 0x8(%rsp),%rbp
0x7a3 4889442418 MOVQ AX, 0x18(SP) // mov %rax,0x18(%rsp)
0x7a8 48895c2420 MOVQ BX, 0x20(SP) // mov %rbx,0x20(%rsp)
0x7ad 48c7042400000000 MOVQ $0x0, 0(SP) // movq $0x0,(%rsp)
return a * b
0x7b5 488b442420 MOVQ 0x20(SP), AX // mov 0x20(%rsp),%rax
0x7ba 488b4c2418 MOVQ 0x18(SP), CX // mov 0x18(%rsp),%rcx
0x7bf 480fafc1 IMULQ CX, AX // imul %rcx,%rax
0x7c3 48890424 MOVQ AX, 0(SP) // mov %rax,(%rsp)
0x7c7 488b6c2408 MOVQ 0x8(SP), BP // mov 0x8(%rsp),%rbp
0x7cc 4883c410 ADDQ $0x10, SP // add $0x10,%rsp
0x7d0 c3 RET // retq

TEXT "".sum(SB) gofile../home/regmsif/test/goasm/test8.go
func sum(a, b int) int {
0x7d1 4883ec28 SUBQ $0x28, SP // sub $0x28,%rsp
0x7d5 48896c2420 MOVQ BP, 0x20(SP) // mov %rbp,0x20(%rsp)
0x7da 488d6c2420 LEAQ 0x20(SP), BP // lea 0x20(%rsp),%rbp
0x7df 4889442430 MOVQ AX, 0x30(SP) // mov %rax,0x30(%rsp)
0x7e4 48895c2438 MOVQ BX, 0x38(SP) // mov %rbx,0x38(%rsp)
0x7e9 48c7042400000000 MOVQ $0x0, 0(SP) // movq $0x0,(%rsp)
return a + mul(a, b)
0x7f1 488b4c2430 MOVQ 0x30(SP), CX // mov 0x30(%rsp),%rcx
0x7f6 48894c2418 MOVQ CX, 0x18(SP) // mov %rcx,0x18(%rsp)
0x7fb 488b4c2438 MOVQ 0x38(SP), CX // mov 0x38(%rsp),%rcx
0x800 48894c2410 MOVQ CX, 0x10(SP) // mov %rcx,0x10(%rsp)
return a * b
0x805 488b542418 MOVQ 0x18(SP), DX // mov 0x18(%rsp),%rdx
0x80a 480fafd1 IMULQ CX, DX // imul %rcx,%rdx
return a + mul(a, b)
0x80e 4889542408 MOVQ DX, 0x8(SP) // mov %rdx,0x8(%rsp)
0x813 eb00 JMP 0x815 // jmp 0x815
0x815 488b4c2430 MOVQ 0x30(SP), CX // mov 0x30(%rsp),%rcx
0x81a 488d0411 LEAQ 0(CX)(DX*1), AX // lea (%rcx,%rdx,1),%rax
0x81e 48890424 MOVQ AX, 0(SP) // mov %rax,(%rsp)
0x822 488b6c2420 MOVQ 0x20(SP), BP // mov 0x20(%rsp),%rbp
0x827 4883c428 ADDQ $0x28, SP // add $0x28,%rsp
0x82b c3 RET // retq

TEXT "".main(SB) gofile../home/regmsif/test/goasm/test8.go
func main() {
0x82c 4883ec38 SUBQ $0x38, SP // sub $0x38,%rsp
0x830 48896c2430 MOVQ BP, 0x30(SP) // mov %rbp,0x30(%rsp)
0x835 488d6c2430 LEAQ 0x30(SP), BP // lea 0x30(%rsp),%rbp
sum(1, 2)
0x83a 48c744242801000000 MOVQ $0x1, 0x28(SP) // movq $0x1,0x28(%rsp)
0x843 48c744241802000000 MOVQ $0x2, 0x18(SP) // movq $0x2,0x18(%rsp)
return a + mul(a, b)
0x84c 488b442428 MOVQ 0x28(SP), AX // mov 0x28(%rsp),%rax
0x851 4889442420 MOVQ AX, 0x20(SP) // mov %rax,0x20(%rsp)
0x856 488b442418 MOVQ 0x18(SP), AX // mov 0x18(%rsp),%rax
0x85b 4889442410 MOVQ AX, 0x10(SP) // mov %rax,0x10(%rsp)
return a * b
0x860 488b4c2420 MOVQ 0x20(SP), CX // mov 0x20(%rsp),%rcx
0x865 480fafc8 IMULQ AX, CX // imul %rax,%rcx
return a + mul(a, b)
0x869 48890c24 MOVQ CX, 0(SP) // mov %rcx,(%rsp)
0x86d eb00 JMP 0x86f // jmp 0x86f
0x86f 488b442428 MOVQ 0x28(SP), AX // mov 0x28(%rsp),%rax
0x874 4801c8 ADDQ CX, AX // add %rcx,%rax
sum(1, 2)
0x877 4889442408 MOVQ AX, 0x8(SP) // mov %rax,0x8(%rsp)
0x87c eb00 JMP 0x87e // jmp 0x87e
}
0x87e 488b6c2430 MOVQ 0x30(SP), BP // mov 0x30(%rsp),%rbp
0x883 4883c438 ADDQ $0x38, SP // add $0x38,%rsp
0x887 c3 RET // retq

其效果相当于先将 mul 函数嵌入 sum 函数中调用的地方,接着将 sum 函数嵌入 main 函数中调用的地方。

尝试在一个函数内部调用自身。

test9.go

1
2
3
4
5
6
7
8
9
package main

func sum(a, b int) int {
return sum(a, b)
}

func main() {
sum(1, 2)
}

汇编代码如下(打开编译优化和内联函数)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
TEXT "".sum(SB) gofile../home/regmsif/test/goasm/test9.go
func sum(a, b int) int {
0x5fb 493b6610 CMPQ 0x10(R14), SP // cmp 0x10(%r14),%rsp
0x5ff 761d JBE 0x61e // jbe 0x61e
0x601 4883ec18 SUBQ $0x18, SP // sub $0x18,%rsp
0x605 48896c2410 MOVQ BP, 0x10(SP) // mov %rbp,0x10(%rsp)
0x60a 488d6c2410 LEAQ 0x10(SP), BP // lea 0x10(%rsp),%rbp
return sum(a, b)
0x60f e800000000 CALL 0x614 // callq 0x614 [1:5]R_CALL:"".sum
0x614 488b6c2410 MOVQ 0x10(SP), BP // mov 0x10(%rsp),%rbp
0x619 4883c418 ADDQ $0x18, SP // add $0x18,%rsp
0x61d c3 RET // retq
func sum(a, b int) int {
0x61e 4889442408 MOVQ AX, 0x8(SP) // mov %rax,0x8(%rsp)
0x623 48895c2410 MOVQ BX, 0x10(SP) // mov %rbx,0x10(%rsp)
0x628 e800000000 CALL 0x62d // callq 0x62d [1:5]R_CALL:runtime.morestack_noctxt
0x62d 488b442408 MOVQ 0x8(SP), AX // mov 0x8(%rsp),%rax
0x632 488b5c2410 MOVQ 0x10(SP), BX // mov 0x10(%rsp),%rbx
0x637 ebc2 JMP "".sum(SB) // jmp 0x5fb

TEXT "".main(SB) gofile../home/regmsif/test/goasm/test9.go
func main() {
0x639 493b6610 CMPQ 0x10(R14), SP // cmp 0x10(%r14),%rsp
0x63d 7629 JBE 0x668 // jbe 0x668
0x63f 4883ec18 SUBQ $0x18, SP // sub $0x18,%rsp
0x643 48896c2410 MOVQ BP, 0x10(SP) // mov %rbp,0x10(%rsp)
0x648 488d6c2410 LEAQ 0x10(SP), BP // lea 0x10(%rsp),%rbp
sum(1, 2)
0x64d b801000000 MOVL $0x1, AX // mov $0x1,%eax
0x652 bb02000000 MOVL $0x2, BX // mov $0x2,%ebx
0x657 6690 NOPW // data16 nop
0x659 e800000000 CALL 0x65e // callq 0x65e [1:5]R_CALL:"".sum
}
0x65e 488b6c2410 MOVQ 0x10(SP), BP // mov 0x10(%rsp),%rbp
0x663 4883c418 ADDQ $0x18, SP // add $0x18,%rsp
0x667 c3 RET // retq
func main() {
0x668 e800000000 CALL 0x66d // callq 0x66d [1:5]R_CALL:runtime.morestack_noctxt
0x66d ebca JMP "".main(SB) // jmp 0x639

虽然打开了内联函数,但编译器认为 sum 函数不可内联,因此使用传统的函数调用方法。这是因为内联函数的代码必须是确定的,而递归调用的深度是不确定的。

通过以上这些实验,基本了解了 Go 语言对于函数调用的实现。总结一下就是:

  1. Go 语言编译器默认会打开编译优化和内联函数;
  2. 编译优化将尽可能减少函数调用前后的栈操作,减少内存访问,从而加快速度;
  3. 内联函数则直接将函数嵌入调用的地方,直接避免了函数调用,因此也减少了栈操作和内存访问,从而加快速度;
  4. 虽然它们目的一样,但编译优化在加快速度的同时可以减少可执行文件的大小(函数内部代码减少了),而内联函数则有可能增加可执行文件的大小(每个函数调用都会被替换成函数代码);
  5. 另外,在同时打开编译优化和内联函数时,编译器会自动将一些可以确定结果为常数的函数调用替换成其结果。

当然,编译器也不会将所有可以内联的函数都进行内联,因为如果函数代码较长且需要在多个位置调用,打开内联函数的可执行文件大小将远远大于关闭内联函数的可执行文件。

效果分析

在这一部分,将对比同一份代码在不同编译选项下的目标文件大小以及运行速度。

无副作用函数

首先比较目标文件大小。由于编译器不会内联过长的函数,因此需要找一个尽可能长但又会被内联的函数,使对比更加明显。

经过测试后发现,如下函数会被内联。

1
2
3
4
5
6
7
8
9
10
11
func sum(a, b int) int {
c := a + b*1
d := b - c/2
e := c*d + 3
f := d/e - 4
g := e + f*5
h := f - g/6
i := g*h + 7
j := h/i - 8
return j
}

而如下函数不会被内联。

1
2
3
4
5
6
7
8
9
10
11
12
func sum(a, b int) int {
c := a + b*1
d := b - c/2
e := c*d + 3
f := d/e - 4
g := e + f*5
h := f - g/6
i := g*h + 7
j := h/i - 8
k := i + j*9 // add a line here
return k
}

另外,编译器也不会内联调用的位置过多的函数。测试后发现 1240 个不同位置的 sum 函数调用会被内联,1250 个不同位置的 sum 函数调用不会被内联。

因此,测试程序 test10.go 为在 1240 个不同的位置调用第一个 sum 函数。生成的目标文件大小(单位:B)如下。

关闭编译优化 打开编译优化
关闭内联函数 55988 (test10.0.o) 55914 (test10.2.o)
打开内联函数 596280 (test10.1.o) 2778 (test10.3.o)

对于 test10.3.o,编译器自动忽略了所有函数调用,main 函数里只有 retq,所以大小最小。

对于 test10.2.o,每个函数调用需要两条 mov 指令和一条 callq 指令(有些还需要一条 nop 指令),因此相比 test10.3.o 要大很多。

对于 test10.0.o,函数调用部分与 test.10.2.o 相同,但由于关闭了编译优化,函数内部的代码更多了,因此相比 test10.2.o 稍大一点。

对于 test10.1.o,每个函数调用都被替换成函数代码,所以相比其他目标文件又大了很多。

以上结果符合预期。

为了比较运行速度,需要足够多的调用次数。这可以用循环实现。因此,测试程序 test11.go 为循环调用第一个 sum 函数 100000000 次。编写如下 C 语言程序,用于运行 100 次测试程序并取平均运行时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h>

int main()
{
char cmd[100];
fgets(cmd, 100, stdin); // get command

struct timeval start, end;
long long total_time = 0, cnt = 100; // run 100 times
for (int i = 1; i <= cnt; i++)
{
gettimeofday(&start, NULL);
system(cmd); // run command
gettimeofday(&end, NULL);
total_time += (end.tv_sec - start.tv_sec) * 1000000 + (end.tv_usec - start.tv_usec); // calculate total time
printf("%d-th time is %f s\n", i, (end.tv_sec - start.tv_sec) + (end.tv_usec - start.tv_usec) / 1000000.);
}
printf("average time is %f s\n", total_time / 1000000. / cnt);
return 0;
}

测试程序的平均运行时间(单位:s)如下。

关闭编译优化 打开编译优化
关闭内联函数 3.197516 (test11.0) 2.539210 (test11.2)
打开内联函数 2.996570 (test11.1) 0.034671 (test11.3)

对于 test11.3,编译器忽略了函数调用,所以循环内部没有语句,相当于循环变量从 1 累加到 100000000,自然速度很快。

对于 test11.2,循环里每次会用两条 mov 指令和一条 callq 指令调用函数,虽然函数是被优化过的,但还是需要一定时间,所以速度比 test11.3 慢很多。

对于 test11.1,循环里直接嵌入了函数,不需要函数调用,但因为函数没有被优化,所以总体上比 test11.2 慢一些。

对于 test11.0,循环里需要调用函数,而且函数没有被优化,所以速度是最慢的。

以上结果符合预期。

有副作用函数

为 sum 函数添加副作用,再次进行测试。函数代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
var c, d, e, f, g, h, i, j int

func sum(a, b int) int {
c = a + b*1
d = b - c/2
e = c*d + 3
f = d/e - 4
g = e + f*5
h = f - g/6
i = g*h + 7
j = h/i - 8
return j
}

测试文件为 test12.go 。生成的目标文件大小(单位:B)如下。

关闭编译优化 打开编译优化
关闭内联函数 56576 (test12.0.o) 56538 (test12.2.o)
打开内联函数 774078 (test12.1.o) 435408 (test12.3.o)

打开内联函数的目标文件大小远大于关闭内联函数的目标文件,因为每个函数调用都会被替换成函数代码。就算同时打开编译优化和内联函数,因为 sum 函数有副作用(会读写内存),所以这些代码不会被编译器忽略。

打开编译优化的目标文件大小小于关闭编译优化的目标文件,因为优化后函数内部的代码变少了。在打开内联函数的情况下,每个函数调用被替换后的代码都变少了,而且实际上编译器自动计算了要写入内存的值,每次赋值只需要一条 movq 指令,所以目标文件大小的减少更明显。

测试文件为 test13.go 。测试程序的平均运行时间(单位:s)如下。

关闭编译优化 打开编译优化
关闭内联函数 3.106696 (test13.0) 2.611334 (test13.2)
打开内联函数 2.903649 (test13.1) 0.260347 (test13.3)

除了同时打开编译优化和内联函数的情况,其他结果都与使用无副作用函数类似,因为有副作用函数与无副作用函数的指令数相差不大。

同时打开编译优化和内联函数时,无副作用函数会被忽略,而有副作用函数不能被忽略,仍需要写内存,所以比无副作用函数慢很多。但由于函数参数是常数,编译器会自动计算每个内存地址应该写入什么值,所以比有副作用函数的其他情况快很多(它们每次都需要重新计算)。

部分汇编代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
      sum(1, 2)
0x384cc 90 NOPL // nop
c = a + b*1
0x384cd 48c7050000000003000000 MOVQ $0x3, 0(IP) // movq $0x3,(%rip) [3:7]R_PCREL:"".c+-4
d = b - c/2
0x384d8 48c7050000000001000000 MOVQ $0x1, 0(IP) // movq $0x1,(%rip) [3:7]R_PCREL:"".d+-4
e = c*d + 3
0x384e3 48c7050000000006000000 MOVQ $0x6, 0(IP) // movq $0x6,(%rip) [3:7]R_PCREL:"".e+-4
f = d/e - 4
0x384ee 48c70500000000fcffffff MOVQ $-0x4, 0(IP) // movq $-0x4,(%rip) [3:7]R_PCREL:"".f+-4
g = e + f*5
0x384f9 48c70500000000f2ffffff MOVQ $-0xe, 0(IP) // movq $-0xe,(%rip) [3:7]R_PCREL:"".g+-4
h = f - g/6
0x38504 48c70500000000feffffff MOVQ $-0x2, 0(IP) // movq $-0x2,(%rip) [3:7]R_PCREL:"".h+-4
i = g*h + 7
0x3850f 48c7050000000023000000 MOVQ $0x23, 0(IP) // movq $0x23,(%rip) [3:7]R_PCREL:"".i+-4
j = h/i - 8
0x3851a 48c70500000000f8ffffff MOVQ $-0x8, 0(IP) // movq $-0x8,(%rip) [3:7]R_PCREL:"".j+-4

以上结果符合预期。

参考文献

  1. A Quick Guide to Go’s Assembler, https://golang.org/doc/asm.
  2. Git repositories on go, arch.go, https://go.googlesource.com/go/+/master/src/cmd/asm/internal/arch/arch.go.
  3. x64 Architecture, https://docs.microsoft.com/zh-cn/windows-hardware/drivers/debugger/x64-architecture.

Go 语言函数调用汇编代码分析
https://regmsif.cf/2021/10/30/coding/go-语言函数调用汇编代码分析/
作者
RegMs If
发布于
2021年10月30日
许可协议