GoSuda

托管语言的运行时结构与 Go 二进制文件 AMD64 汇编分析

By Lee Yunjin views ...

什么是托管语言?

托管语言是指这样一类语言:与非托管语言(即只是执行程序员所编写的逻辑、基本不偏离该逻辑的语言)不同,它们在运行时中执行 GC、运行时优化、绿色线程、并发处理等功能,使用户无需进行危险的底层管理。这类语言的优点在于,开发者可以专注于业务逻辑,从而沉浸于开发之中;但另一方面,程序的实际行为有时可能与程序员的直觉不同,因此有时需要进行精细的运行时调优。首先,我们来看一下在托管语言中最忠实于极简主义哲学、且汇编最为直观的 Go 语言。

Go 语言的二进制结构

.text.data.gopclntab, .typelink 等
将要执行的机器码将要存储的数据语言运行时段

Go 语言并不会将用户所写的内容一对一地翻译为机器码,因此 .text 段中的逻辑与语言运行时段также紧密相关。此外,在 .text 段的汇编中还会添加用户并未自行编写的函数,例如 runtime.printnl()。通过这种自动化的代码插入,Go 语言帮助开发者摆脱手动管理的负担。

仅查看 Go 中的 main 函数部分

首先,编写一个简单的示例源文件 main.go,从 main 开始在 AMD64 机器上 查看。

 1package main
 2
 3func sayHello(msg string) {
 4    println(msg)
 5}
 6
 7func main() {
 8    sayHello("Hello World")
 9}
10

随后按如下方式构建。

1go build main.go
2

为了便于进行底层调试,Go 提供了 go tool。要在 go tool 中仅查看主包中 main 函数对应的汇编,需输入以下命令。

1go tool objdump -s "main\.main" ./main
2

汇编

 1TEXT main.main(SB) /home/yjlee/compare-assembly/go/main.go
 2  main.go:7             0x468f60                493b6610                CMPQ SP, 0x10(R14)
 3  main.go:7             0x468f64                762f                    JBE 0x468f95
 4  main.go:7             0x468f66                55                      PUSHQ BP
 5  main.go:7             0x468f67                4889e5                  MOVQ SP, BP
 6  main.go:7             0x468f6a                4883ec10                SUBQ $0x10, SP
 7  main.go:8             0x468f6e                90                      NOPL
 8  main.go:4             0x468f6f                e8cca3fcff              CALL runtime.printlock(SB)
 9  main.go:4             0x468f74                488d05da290100          LEAQ 0x129da(IP), AX
10  main.go:4             0x468f7b                bb0b000000              MOVL $0xb, BX
11  main.go:4             0x468f80                e83bacfcff              CALL runtime.printstring(SB)
12  main.go:4             0x468f85                e8f6a5fcff              CALL runtime.printnl(SB)
13  main.go:4             0x468f8a                e811a4fcff              CALL runtime.printunlock(SB)
14  main.go:9             0x468f8f                4883c410                ADDQ $0x10, SP
15  main.go:9             0x468f93                5d                      POPQ BP
16  main.go:9             0x468f94                c3                      RET
17  main.go:7             0x468f95                e8e6afffff              CALL runtime.morestack_noctxt.abi0(SB)
18  main.go:7             0x468f9a                ebc4                    JMP main.main(SB)
19
  • 通过 CMPQ 将当前 goroutine 控制块寄存器(R14)中的栈守卫值与当前栈指针(SP)进行比较,以检查当前 goroutine 的栈帧空间是否充足;若空间不足,则跳转(JBE)至用于栈扩展的入口点 0x468f95 地址。
  • 使用 PUSHQ BP 将先前的基址指针压入栈中。
  • 将当前栈指针(SP)复制到基址指针(BP)寄存器中,从而固定函数开始时的栈基准点。
  • 随后分配 16 字节的局部变量栈空间(SUBQ $0x10, SP),并利用 NOPL 填充虚拟指令以进行 CPU 缓存对齐。
  • 为了同步 Go 运行时内部字符串的标准输出,调用 runtime.printlock(SB) 加锁。
  • 使用 LEAQ 指令将常量字符串("Hello World")的起始地址按照 Go ABI 规范存入作为第一个参数使用的通用寄存器 AX 中。
  • 随后将表示字符串长度的值存入第二个参数寄存器 BX 中。(MOVL $0xb, BX,即十进制的 11)
  • 调用 runtime.printstring(SB),根据传入的 AX(数据地址)和 BX(长度)信息将其输出到控制台。
  • 为了处理换行,调用 runtime.printnl(SB)。
  • 输出完成后,通过 runtime.printunlock(SB) 释放锁。
  • 使用 ADDQ $0x10, SP 恢复先前分配的 16 字节栈内存。
  • 使用 POPQ BP 恢复原先的基址指针。
  • 通过 RET 将控制权返回至调用该函数的位置。
  • 如果在最初的栈检查中空间不足,则调用位于 0x468f95 地址的 runtime.morestack_noctxt.abi0(SB),以托管语言的方式动态扩展栈运行时。
  • 栈扩展完成后,重新跳转(JMP)回 main.main(SB) 的入口点。如上所示,业务逻辑的汇编相当清晰,仅附加了薄薄一层运行时管理代码。

未进行优化的情况

上述形式是 Go 编译器自动内联两个独立函数后优化的结果。然而,出于学习目的,在这种情况下我们将不内联 sayHello。为此,使用以下标志编译源文件。

1go build -gcflags="-l" main.go
2

在 shell 中输出结果后可以发现重复的汇编代码。

 1yjlee@elegant:~/compare-assembly/go$ go build -gcflags="-l" main.go
 2
 3go tool objdump -s "main\.sayHello" ./main
 4TEXT main.sayHello(SB) /home/yjlee/compare-assembly/go/main.go
 5  main.go:3             0x468f60                493b6610               CMPQ SP, 0x10(R14)
 6  main.go:3             0x468f64                7636                   JBE 0x468f9c
 7  main.go:3             0x468f66                55                     PUSHQ BP
 8  main.go:3             0x468f67                4889e5                 MOVQ SP, BP
 9  main.go:3             0x468f6a                4883ec10               SUBQ $0x10, SP
10  main.go:5             0x468f6e                4889442420             MOVQ AX, 0x20(SP)
11  main.go:5             0x468f73                48895c2428             MOVQ BX, 0x28(SP)
12  main.go:4             0x468f78                e8c3a3fcff             CALL runtime.printlock(SB)
13  main.go:4             0x468f7d                488b442420             MOVQ 0x20(SP), AX
14  main.go:4             0x468f82                488b5c2428             MOVQ 0x28(SP), BX
15  main.go:4             0x468f87                e834acfcff             CALL runtime.printstring(SB)
16  main.go:4             0x468f8c                e8efa5fcff             CALL runtime.printnl(SB)
17  main.go:4             0x468f91                e80aa4fcff             CALL runtime.printunlock(SB)
18  main.go:5             0x468f96                4883c410               ADDQ $0x10, SP
19  main.go:5             0x468f9a                5d                     POPQ BP
20  main.go:5             0x468f9b                c3                     RET
21  main.go:3             0x468f9c                4889442408             MOVQ AX, 0x8(SP)
22  main.go:3             0x468fa1                48895c2410             MOVQ BX, 0x10(SP)
23  main.go:3             0x468fa6                e8d5afffff             CALL runtime.morestack_noctxt.abi0(SB)
24  main.go:3             0x468fab                488b442408             MOVQ 0x8(SP), AX
25  main.go:3             0x468fb0                488b5c2410             MOVQ 0x10(SP), BX
26  main.go:3             0x468fb5                eba9                   JMP main.sayHello(SB)
27

取消内联后,为了按照函数调用规范保留参数(AX, BX),会插入 MOVQ 指令,将值重新加载到栈指针偏移量 0x20(SP) 等位置。由此可见,编译器优化的对象正是这些不必要的内存移动操作以及调用开销。

下一次

下一次我们将讨论 Go 语言中的 if 语句和 switch 语句。如果以后有时间,也计划分析 Go 运行时段。