go-语言函数调用汇编代码分析.md 44 KB


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

  • Assembly
  • Course Project
  • Go categories:
  • - Coding
    • Programming Language date: 2021-10-30 10:54:00 ---

背景说明

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

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

探索过程

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

test1.go

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 目标文件并在屏幕上输出汇编代码。

"".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 汇编。

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 函数的返回值赋给一个变量。

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

package main

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

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

汇编代码如下。

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

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

汇编代码如下。

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

package main

var c int

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

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

汇编代码如下。

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 函数。

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

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

汇编代码如下。

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

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 时使用的优化选项。

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 编译得到的汇编代码如下。

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 编译得到的汇编代码如下。

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

package main

var c int

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

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

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

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

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

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

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

package main

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

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

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

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. 另外,在同时打开编译优化和内联函数时,编译器会自动将一些可以确定结果为常数的函数调用替换成其结果。

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

效果分析

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

无副作用函数

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

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

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
}

而如下函数不会被内联。

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 次测试程序并取平均运行时间。

#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 函数添加副作用,再次进行测试。函数代码如下。

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)

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

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

部分汇编代码如下。

        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.