如何调试 Go 语言源代码

1.1 调试 Go 语言

Go 语言作为开源项目,我们可以很轻松地获取它的源代码,作为一门编程语言,它有着非常复杂的项目结构和庞大的代码库,今天的 Go 语言项目中差不多有 150 万行源代码,其中包含将近 140 万行的 Go 语言,我们可以使用如下所示的命令查看 Go 语言的代码行数:

$ cloc src
    5988 text files.
    5875 unique files.
    1165 files ignored.

github.com/AlDanial/cloc v 1.78  T=6.96 s (693.7 files/s, 274805.2 lines/s)
-----------------------------------------------------------------------------------
Language                         files          blank        comment           code
-----------------------------------------------------------------------------------
Go                                4199         139910         221375        1398357
Assembly                           486          12784          19137         106699
C                                   64            718            562           4587
JSON                                12              0              0           1712
...
-----------------------------------------------------------------------------------
SUM:                              4828         154344         242395        1515787
-----------------------------------------------------------------------------------

随着 Go 语言项目的不断演进,整个代码库也会随着时间出现变化,所以上面的统计结果每天都有所不同。虽然 Go 语言项目有着巨大的代码库,但是想要调试 Go 语言并不是不可能的,只要我们掌握合适的方法并且对 Go 语言的标准库有一些了解,就可以调试 Go 语言。

1.1.1 编译源码

假设我们想要修改 Go 语言中最常用的方法 fmt.Println 的实现,例如:在真正打印字符串之前先打印任意其它的字符串,我们可以将该方法的实现修改成如下所示的代码片段,其中 println 是 Go 语言运行时提供的内置方法,它不需要依赖任何的包就可以向标准输出打印字符串:

func Println(a ...interface{}) (n int, err error) {
	println("draven")
	return Fprintln(os.Stdout, a...)
}

当我们修改了 Go 语言的源代码项目,可以使用仓库中提供的脚本来编译生成 Go 语言的二进制以及相关的工具链:

$ ./src/make.bash
Building Go cmd/dist using /usr/local/Cellar/go/1.14.2_1/libexec. (go1.14.2 darwin/amd64)
Building Go toolchain1 using /usr/local/Cellar/go/1.14.2_1/libexec.
Building Go bootstrap cmd/go (go_bootstrap) using Go toolchain1.
Building Go toolchain2 using go_bootstrap and Go toolchain1.
Building Go toolchain3 using go_bootstrap and Go toolchain2.
Building packages and commands for darwin/amd64.
---
Installed Go for darwin/amd64 in /Users/draveness/go/src/github.com/golang/go
Installed commands in /Users/draveness/go/src/github.com/golang/go/bin

./src/make.bash 脚本会编译 Go 语言的二进制、工具链以及标准库和命令并将源代码和编译好的二进制文件移动到对应的位置上。如上述代码所示,编译好的二进制会存储在 /Users/draveness/go/src/github.com/golang/go/bin 目录中,我们需要使用绝对路径来访问并使用它:

$ cat main.go
package main

import "fmt"

func main() {
	fmt.Println("Hello World")
}
$ /Users/draveness/go/src/github.com/golang/go/bin/go run main.go
draven
Hello World

我们会发现上述命令成功地调用了我们修改后的 fmt.Println 函数,而在这时如果直接使用 go run main.go,很可能会使用包管理器安装的 go 二进制,得不到期望的结果。

1.1.2 中间代码

Go 语言的应用程序在运行之前需要先编译成二进制,在编译的过程中会经过中间代码生成阶段,Go 语言编译器的中间代码具有静态单赋值(Static Single Assignment、SSA)的特性,我们会在后面介绍该中间代码的该特性,在这里我们只需要知道这是一种中间代码的表示方式。

很多 Go 语言的开发者可能都知道,我们可以使用如下所示的命令将 Go 语言的源代码编译成汇编语言,然后通过汇编语言分析程序具体的执行过程:

$ go build -gcflags -S main.go
	rel 22+4 t=8 os.(*file).close+0
"".main STEXT size=137 args=0x0 locals=0x58
	0x0000 00000 (main.go:5)	TEXT	"".main(SB), ABIInternal, $88-0
	0x0000 00000 (main.go:5)	MOVQ	(TLS), CX
	0x0009 00009 (main.go:5)	CMPQ	SP, 16(CX)
	...
	rel 5+4 t=17 TLS+0
	rel 40+4 t=16 type.string+0
	rel 52+4 t=16 ""..stmp_0+0
	rel 64+4 t=16 os.Stdout+0
	rel 71+4 t=16 go.itab.*os.File,io.Writer+0
	rel 113+4 t=8 fmt.Fprintln+0
	rel 128+4 t=8 runtime.morestack_noctxt+0

然而上述的汇编代码只是 Go 语言编译的结果,作为使用 Go 语言的开发者,我们已经能够通过上述结果分析程序的性能瓶颈,但是如果想要了解 Go 语言更详细的编译过程,我们可以通过下面的命令获取汇编指令的优化过程:

$ GOSSAFUNC=main go build main.go
# runtime
dumped SSA to /usr/local/Cellar/go/1.14.2_1/libexec/src/runtime/ssa.html
# command-line-arguments
dumped SSA to ./ssa.html

上述命令会在当前文件夹下生成一个 ssa.html 文件,我们打开这个文件后就能看到汇编代码优化的每一个步骤:

ssa-example-w4828

图 1 - 1 SSA 示例

上述 HTML 文件是可以交互的,当我们点击任意的汇编指令,它都会使用相同的颜色在 SSA 中间代码生成的不同阶段标识出相关的代码行,更方便开发者分析编译的过程。

1.1.3 小结

掌握调试和自定义 Go 语言二进制的方法可以帮助我们快速验证对 Go 语言内部实现的猜想,通过最简单粗暴的 println 函数就可以调试 Go 语言的源码和标准库;而如果想要研究源代码的详细编译成过程,我们可以使用上面提到的 SSA 中间代码深入研究 Go 语言的中间代码以及编译优化的方式,不过只要我们想了解 Go 语言的实现原理,阅读源代码是绕不开的过程。

wechat-account-qrcode

本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。