Go Bootstrap Process

June 10, 2019

Go Bootstrap Process

一、环境准备

1、 Go版本

$ go version
go version go1.12.4 darwin/amd64

2、准备代码

package main
import (
	"fmt"
)
func main() {
	fmt.Println("hello world")
}

3、调试工具

调试工具我使用的是dlv,对golang支持最好的调试工具,比gdb都好,先按官方文档安装上。编译上述代码go build main.go,得到一个可执行文件,执行dlv exec ./main就进入了调试空间。

$ dlv exec main
Type 'help' for list of commands.
(dlv) 

输入help就可以看到帮助文档,打断点以及执行下一步都是通过命令进行的,不知道就可以随时进行查看。执行上述的命令之后,程序还没有真正的启动。

4、dlv常用的命令 更具体的命令使用参考官方文档。

args :函数的参数
break (alias: b) :打一个断点
breakpoints (alias: bp) :打印已经添加的断点信息。
clear : 通过断点编号删除断点
continue (alias: c) :运行到有断点的地方
locals :打印本地变量
next (alias: n) :运行到下一行
print (alias: p) :可以指定变量进行打印查看,特别对于引用类型变量最有用。
regs :打印当前cpu寄存器的值。
restart (alias: r) :重启进程,重新开始调试.
stack (alias: bt) :打印堆栈信息
step (alias: s) :单步执行,会进入函数内部
step-instruction (alias: si)  : cpu的单个指令执行
thread (alias: tr) :可以切换到指定的线程
threads :打印所有线程的信息
goroutine :显示或者切换当前的go
goroutines : 列出程序的所有go

二、进入调试

1、打断点

第一步先打断点,对于第一次才开始调试的人来说,最简单的就是打main函数的断点了.

$ (dlv) b main.main
Breakpoint 1 set at 0x1093038 for main.main() ./main.go:27
(dlv) bp
Breakpoint 1 at 0x1093038 for main.main() ./main.go:27 (0)

2、运行到断点出

bbp是缩写,对应为打断点和打印所有的断点信息。打了断点之后就可以执行c这个命令了。

$ (dlv) continue
> main.main() ./main.go:27 (hits goroutine(1):1 total:1) (PC: 0x1093038)
=>  27:	func main() {
}

3、打印堆栈信息

执行过后就会出现源代码,并且断点指向了main函数上,此时我们通过stack命令就可以看到这个main函数所在的goroutine栈了。

$ (dlv) stack
0  0x0000000001093038 in main.main
   at ./main.go:27
1  0x00000000010297ec in runtime.main
   at ./golang_work/go/src/runtime/proc.go:200
2  0x0000000001051011 in runtime.goexit
   at ./golang_work/go/src/runtime/asm_amd64.s:1337

如上就是显示了一个goroutine的堆栈,堆栈中的信息就保证了调用过程,所以我们就可以去分析go启动过程了。 我们要从栈底往上看,所以我们首先去分析一下asm_amd64.s这个汇编代码。现在是逆向分析调用栈,后续的分析就是正向的过程。

三、分析启动过程

1、分析asm_amd64.s这个文件

这里面的内容很多,我们不能全部看完,只能找一下重点,开始的两个函数都指向了JMP runtime·rt0_go(SB)这条汇编代码,我们就去查看rt0_go这个函数。

runtime/asm_amd64.s 
TEXT runtime·rt0_go(SB),NOSPLIT,$0
	CALL	runtime·args(SB)
	CALL	runtime·osinit(SB)
	CALL	runtime·schedinit(SB)
	CALL	runtime·newproc(SB)
	CALL	runtime·mstart(SB)
	CALL	runtime·abort(SB)	// mstart should 

文件中的大部分内容都去掉了,我只留下了CALL 指令的部分,call调用的就是runtime包中的源代码,所以我们通过方法名就可以进行搜索了,并且是在可以运行之后的部分,所以上面就是go进程启动的过程。下面我们边打断点边查看每个函数都做了写什么内容。

按顺序总结下runtime.rt0_go里几件重要的事:

  • 检查运行平台的CPU,设置好程序运行需要相关标志。
  • TLS的初始化。
  • runtime.args、runtime.osinit、runtime.schedinit 三个方法做好程序运行需要的各种变量与调度器。
  • runtime.newproc创建新的goroutine用于绑定用户写的main方法。
  • runtime.mstart开始goroutine的调度。

2、按顺序打上断点

(dlv) b runtime.args
(dlv) b runtime.osinit
(dlv) b runtime.schedinit
(dlv) b runtime.newproc
(dlv) b runtime.mstart
(dlv) b runtime.abort

Breakpoint 2 at 0x10360ff for runtime.args() ./go/src/runtime/runtime1.go:60 (0)
Breakpoint 3 at 0x102604f for runtime.osinit() ./go/src/runtime/os_darwin.go:79 (0)
Breakpoint 4 at 0x102a843 for runtime.schedinit() ./go/src/runtime/proc.go:526 (0)
Breakpoint 5 at 0x1030f90 for runtime.newproc() ./go/src/runtime/proc.go:3239 (0)
Breakpoint 6 at 0x102c410 for runtime.mstart() ./go/src/runtime/proc.go:1153 (0)
Breakpoint 7 at 0x10509a0 for runtime.abort() ./go/src/runtime/asm_amd64.s:837 (0)

3、runtime.args

打好断点之后,可以执行如下命令找到源码所在位置.

$ (dlv) s
> runtime.args() 
./go/src/runtime/runtime1.go:60 (hits total:1) (PC: 0x10360ff)
=>  60:	func args(c int32, v **byte) {}

我们找到源码,并且验证了启动过程第一步就是这个

runtime/runtime1.go +60
func args(c int32, v **byte) {
	argc = c
	argv = v
	sysargs(c, v)
}
runtime/os_darwin.go +341
//go:linkname executablePath os.executablePath
var executablePath string

func sysargs(argc int32, argv **byte) {
	// skip over argv, envv and the first string will be the path
	n := argc + 1
	for argv_index(argv, n) != nil {
		n++
	}
	executablePath = gostringnocopy(argv_index(argv, n+1))

	// strip "executable_path=" prefix if available, it's added after OS X 10.11.
	const prefix = "executable_path="
	if len(executablePath) > len(prefix) && executablePath[:len(prefix)] == prefix {
		executablePath = executablePath[len(prefix):]
	}
}

这个方法只做了一件事,就是把二进制文件的绝对路径找出来,并存在os.executablePath里。

按照本文的测试工程:os.executablePath=$GOPATH/test/main

4、runtime.osinit

再接着运行就会到下一个断点,也就是runtime.osinit()这个函数。 osinit这个函数是根据操作系统的不同来选择不同的执行函数的,我的是mac,所以执行的是os_darwin.go里面的函数,同时还有os_linux.go里面也有同样的函数。

runtime/os_darwin.go +79
func osinit() {
	ncpu = getncpu()
	physPageSize = getPageSize()
}

这个函数主要做来两件事:

  • 第一是获取当前电脑的cpu个数,并赋值给runtime/runtime2.go中的ncpu变量。
  • 第二是设置runtime/malloc.go中的physPageSize变量,这个变量是用于分配内存的,操作系统分配内存或者回收内存都是按这个数字的整数倍来操作的。我获取的是4096这个数字,所以是4k字节。

5、runtime.schedinit

再接着运行到下一个断点,就是runtime.schedinit这个函数。

runtime/proc.go +526
func schedinit(){
    //从TLS 中获取g
	_g_ := getg()
	// 是否开启竞争检测,启动方法go run -race main.go
	if raceenabled {
		_g_.racectx, raceprocctx0 = raceinit()
	}
	//运行创建的最大线程数
	sched.maxmcount = 10000
	// 初始化一系列函数所在的PC计数器,用于traceback
	tracebackinit()
	//...
	moduledataverify()
	//栈初始化,初始化了一个栈池,可以直接取,还有一个大栈的全局池.
	//golang 使用的是动态栈,初始是2k大小,后面增长,但是golang使用的不是分离栈,是连续栈,就是栈空间是连续的,分配的时候如果之前的空间不连续了,就拷贝一份到新的空间去。
	stackinit()
	mallocinit()
	mcommoninit(_g_.m)
	cpuinit()       // must run before alginit
	alginit()       // maps must not be used before this call
	modulesinit()   // provides activeModules
	typelinksinit() // uses maps, activeModules
	itabsinit()     // uses activeModules

	msigsave(_g_.m)
	initSigmask = _g_.m.sigmask
    // 启动参数初始化
	goargs()
	//
	goenvs()
	// 解析debug变量,GODEBUG,GOTRACEBACK这些
	parsedebugvars()
	// gc 初始化
	gcinit()
    
	sched.lastpoll = uint64(nanotime())
	procs := ncpu
	if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
		procs = n
	}
	if procresize(procs) != nil {
		throw("unknown runnable goroutine during bootstrap")
	}

	// For cgocheck > 1, we turn on the write barrier at all times
	// and check all pointer writes. We can't do this until after
	// procresize because the write barrier needs a P.
	if debug.cgocheck > 1 {
		writeBarrier.cgo = true
		writeBarrier.enabled = true
		for _, p := range allp {
			p.wbBuf.reset()
		}
	}

	if buildVersion == "" {
		// Condition should never trigger. This code just serves
		// to ensure runtime·buildVersion is kept in the resulting binary.
		buildVersion = "unknown"
	}
}
  • mallocinit():这个函数是内存分配器初始化。

  • mcommoninit(mp *m):初始化m0这个线程

  • cpuinit():cpu初始化

  • alginit():初始化AES,HASH算法

6、runtime.newproc()

再接着运行到下一个断点,就是runtime.newproc这个函数。

runtime/proc.go +3239
func newproc(siz int32, fn *funcval) {
	argp := add(unsafe.Pointer(&fn), sys.PtrSize)
	gp := getg()
	pc := getcallerpc()
	systemstack(func() {
		newproc1(fn, (*uint8)(argp), siz, gp, pc)
	})
}

7、runtime.mstart()

再接着运行到下一个断点,就是runtime.mstart这个函数。 mstart方法主要的执行路径是:

mstart -> mstart1 -> schedule -> execute

  • mstart做一些栈相关的检查,然后就调用mstart1。
  • mstart1先做一些初始化与M相关的工作,例如是信号栈和信号处理函数的初始化。最后调用schedule。
  • schedule逻辑是这四个方法里最复杂的。简单来说,就是要找出一个可运行的G,不管是从P本地的G队列、全局调度器的G队列、GC - worker、因IO阻塞的G、甚至从别的P里偷。然后传给execute运行。
  • execute对传进来的G设置好相关的状态后,就加载G自身记录着的PC、SP等寄存器信息,恢复现场继续执行。

参考


LRF 记录学习、生活的点滴