Go语言内幕(5):运行时启动过程

伯乐在线 · · 9230 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

启动过程是理解 Go 语言运行时工作原理的关键。如果你想继续深入了解 Go,那么分析启动过程就非常重要。因此第五部分就着重讲解 Go 运行时,特别是 Go 程序的启动过程。这一次你会学到如下的内容:

  • Go 语言启动过程
  • 大小可变的栈是如何实现的
  • TLS  的实现机制

请注意这篇博客中会有很多汇编代码,你需要提前了解一下这方面的知识(Go 汇编器快速入门请参考这里)。让我们开始吧!

寻找入口点

首先需要找到启动 Go 程序后执行的第一个函数。为了找到这个函数,我们写了一个极其简单的 Go 应用程序:

package main

func main() {
	print(123)
}

然后,编译并链接:

go tool 6g test.go
go tool 6l test.6

这样会在当前目录下生成一个可执行文件 6.out。下一步需要用到 objdump,这是一个 Linux 系统上的工具。在 windows 或者 Mac 上,你需要找类似的工具或者直接跳过这一步。运行下面的命令:

objdump -f 6.out

你可以看到包含开始地址的输出信息:

6.out:     file format elf64-x86-64
architecture: i386:x86-64, flags 0x00000112:
EXEC_P, HAS_SYMS, D_PAGED
start address 0x000000000042f160

接下来,我们要反汇编可执行程序,再找到在开始位置处到底是什么函数:

objdump -d 6.out > disassemble.txt

现在,我们可以打开 disassemble.txt 文件并搜索 “42f160”,可以得到如下结果:

000000000042f160 <_rt0_amd64_linux>:
  42f160:	48 8d 74 24 08       		lea    0x8(%rsp),%rsi
  42f165:	48 8b 3c 24          		mov    (%rsp),%rdi
  42f169:	48 8d 05 10 00 00 00 	lea    0x10(%rip),%rax        # 42f180 
42f170: ff e0 jmpq *%rax

很好,我们找到它了。在我的这台电脑上(与 OS 以及机器的架构有关)入口点的函数为 _rt0_amd64_linux

启动顺序

现在我们需要在 Go 运行时源码中找到这个函数对应的源代码。它位于 rto_linux_arm64.s 这个文件中。如果你去看一下 Go 语言运行时包,你会发现有很多文件名前缀都和 OS 或者机器架构相关。当生成运行时包时,只有与当前系统和架构相关的文件会被选用。而其余的则会被略过。让我们来看一下 rt0_linux_arm64.s

TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8
	LEAQ	8(SP), SI // argv
	MOVQ	0(SP), DI // argc
	MOVQ	$main(SB), AX
	JMP	AX

TEXT main(SB),NOSPLIT,$-8
	MOVQ	$runtime·rt0_go(SB), AX
	JMP	AX

_rt0_amd64_linux 函数非常的简单。它只是将参数(argc 与 argv )保存到寄存器(DI 与 SI)中然后调用 main 函数。存储在栈中的参数可以通过 SP(栈指针)访问。main 函数也非常简单。它只是调用了 runtime.rt0_goruntime.rt0_go 函数就复杂一些了,所以我将其切分成几个部分,再依次讨论各部分。

第一部分是这样的:

MOVQ	DI, AX		// argc
MOVQ	SI, BX		// argv
SUBQ	$(4*8+7), SP		// 2args 2auto
ANDQ	$~15, SP
MOVQ	AX, 16(SP)
MOVQ	BX, 24(SP)

这里,我们将之前存储的命令行参数值分别放到 AX 与 BX 寄存器中。同时减小栈指针以增加两个额外的四字节变量并且将栈指针其调为 16 比特对齐。最后,将参数放回到栈中。

// create istack out of the given (operating system) stack.
// _cgo_init may update stackguard.
MOVQ	$runtime·g0(SB), DI
LEAQ	(-64*1024+104)(SP), BX
MOVQ	BX, g_stackguard0(DI)
MOVQ	BX, g_stackguard1(DI)
MOVQ	BX, (g_stack+stack_lo)(DI)
MOVQ	SP, (g_stack+stack_hi)(DI)

第二部分更加巧妙。首先,我们将全局变量 runtime.g0 的地址加载到 DI 寄存器中。这变量定义在 proc1.go 文件中,属于 runtime.g 类型。Go 为系统中每个 goroutine 创建一个此类型变量。正如你猜测的那样,runtime.g0 属于根 goroutine。然后,我们初始化描述根 goroutine 栈的各个域。stack.lo 与 stack.hi 的含义应该很清楚。它们分别是当前 goroutine 栈的开始与结束指针,但是 stackguard0 与stackguard1 是什么呢?为了搞明白两个变量,我们要先将 runtime.rto_go 函数的分析放置一边去看一下 Go 中栈增长的方式。

Go 中可变大小栈的实现

Go 语言使用可变大小的栈。每个 goroutine 开始都只有一个较小的栈,不过当已使用栈的大小达到某个阈值后栈的大小就会发生改变。显然,这里必然存在某种机制检测栈的大小是否达到阈值。事实上,在每个函数开始的时候都会执行这样的检测。为了看一下到底是怎么样工作的,让我们使用 -S 标志再编译一次我们的示例程序(这个标志会显示生成的汇编代码)。main 函数的开始处会是这样的:

"".main t=1 size=48 value=0 args=0x0 locals=0x8
	0x0000 00000 (test.go:3)	TEXT	"".main+0(SB),$8-0
	0x0000 00000 (test.go:3)	MOVQ	(TLS),CX
	0x0009 00009 (test.go:3)	CMPQ	SP,16(CX)
	0x000d 00013 (test.go:3)	JHI	,22
	0x000f 00015 (test.go:3)	CALL	,runtime.morestack_noctxt(SB)
	0x0014 00020 (test.go:3)	JMP	,0
	0x0016 00022 (test.go:3)	SUBQ	$8,SP

首先,我们从 TLS ( thread local storage) 变量中加载一个值至 CX 寄存器(我已经在前面的博客中介绍了 TLS)。这个值是一个指针,该指针指向当前 goroutine 对应的 runteim.g 结构体。然后,我们将栈指针与 runtime.g 结构体中偏移 16 字节处的值进行比较。因此我们可以知道该位置即是 stackguard0 域。

所以,这就是我们检测是否到达栈阈值的方式。如果还没有达到阈值,我们就一直调用 runtime.morestack_noctx 函数直到为栈分配足够的空间为止。stackguard1 与 stackguard0 非常相似,但是它是用在 C 的栈增长中,而不是 Go 中。runtime.morestack_noctx 内部工作的机制也是非常有意思的内容,我们稍后会讨论到这一部分。现在,我们回到启动过程。

继续 Go 启动过程

在开始启动过程前,我们先来看下面一段代码,这段代码是 runtime.rt0_go 函数中的代码:

	// find out information about the processor we're on
	MOVQ	$0, AX
	CPUID
	CMPQ	AX, $0
	JE	nocpuinfo

	// Figure out how to serialize RDTSC.
	// On Intel processors LFENCE is enough. AMD requires MFENCE.
	// Don't know about the rest, so let's do MFENCE.
	CMPL	BX, $0x756E6547  // "Genu"
	JNE	notintel
	CMPL	DX, $0x49656E69  // "ineI"
	JNE	notintel
	CMPL	CX, $0x6C65746E  // "ntel"
	JNE	notintel
	MOVB	$1, runtime·lfenceBeforeRdtsc(SB)
notintel:

	MOVQ	$1, AX
	CPUID
	MOVL	CX, runtime·cpuid_ecx(SB)
	MOVL	DX, runtime·cpuid_edx(SB)
nocpuinfo:

这一部分对于理解主要的 Go 语言概念不是非常的重要,所以我们只是简单的看一下。这段代码旨在发现系统的 CPU 类型。如果是 Intel 类型,就设置 runtime·lfenceBeforeRdtsc 变量,此变量只是在 runtime.cputicks 中使用到。这个函数根据 runtime·lfenceBeforeRdtsc 使用不同的汇编指令获得 cpu ticks 的值。最后,我们执行 CPUID 汇编指令并将结果保存到 runtime.cpuid_ecx 与 runtime.cpuid_edx 中。这些变量都会被 alg.go 用来根据计算机的架构选择合适的哈希算法。

OK,让我们继续分析另外一部分代码:

// if there is an _cgo_init, call it.
MOVQ	_cgo_init(SB), AX
TESTQ	AX, AX
JZ	needtls
// g0 already in DI
MOVQ	DI, CX	// Win64 uses CX for first parameter
MOVQ	$setg_gcc<>(SB), SI
CALL	AX

// update stackguard after _cgo_init
MOVQ	$runtime·g0(SB), CX
MOVQ	(g_stack+stack_lo)(CX), AX
ADDQ	$const__StackGuard, AX
MOVQ	AX, g_stackguard0(CX)
MOVQ	AX, g_stackguard1(CX)

CMPL	runtime·iswindows(SB), $0
JEQ ok

这段代码只有在 cgo 被允许的情况下才会执行。cgo 相关的内容我会另外讨论,我可能在后面的博客中讨论到这个主题。这儿,我们只是想明白基本的启动工作流,所以我们先跳过这一部分。

下一段代码负责设置 TLS :

needtls:
	// skip TLS setup on Plan 9
	CMPL	runtime·isplan9(SB), $1
	JEQ ok
	// skip TLS setup on Solaris
	CMPL	runtime·issolaris(SB), $1
	JEQ ok

	LEAQ	runtime·tls0(SB), DI
	CALL	runtime·settls(SB)

	// store through it, to make sure it works
	get_tls(BX)
	MOVQ	$0x123, g(BX)
	MOVQ	runtime·tls0(SB), AX
	CMPQ	AX, $0x123
	JEQ 2(PC)
	MOVL	AX, 0	// abort

我前面就一直提到 TLS 。现在是时候搞明白它到底是如何实现的了。

TLS 内部实现

如果你仔细阅读过前面的代码,很容易就会发现只有几行是真正起作用的代码:

LEAQ	runtime·tls0(SB), DI
	CALL	runtime·settls(SB)

所有其它的代码都是在你的系统不支持 TLS 时跳过 TLS 设置或者检测 TLS 是否正常工作的代码。这两行代码将 runtime.tlso 变量的地址存储到 DI 寄存器中,然后调用 runtime.settls 函数。这个函数的代码如下:

// set tls base to DI
TEXT runtime·settls(SB),NOSPLIT,$32
	ADDQ	$8, DI	// ELF wants to use -8(FS)

	MOVQ	DI, SI
	MOVQ	$0x1002, DI	// ARCH_SET_FS
	MOVQ	$158, AX	// arch_prctl
	SYSCALL
	CMPQ	AX, $0xfffffffffffff001
	JLS	2(PC)
	MOVL	$0xf1, 0xf1  // crash
	RET

从注释可以看出,这个函数执行了 arch_prctl 系统调用,并将 ARCH_SET_FS 作为参数传入。我们也可以看到,系统调用使用 FS 寄存器存储基址。在这个例子中,我们将 TLS 指向 runtime.tls0 变量。

还记得 main 开始时的汇编指令吗?

0x0000 00000 (test.go:3)	MOVQ	(TLS),CX

在前面我已经解释了这条指令将 runtime.g 结构体实例的地址加载到 CX 寄存器中。这个结构体描述了当前 goroutine,且存储到 TLS 中。现在我们明白了这条指令是如何被汇编成机器指令的了。打开之前是创建的 disasembly.txt 文件,搜索 main.main 函数,你会看到其中第一条指令为:

400c00:       64 48 8b 0c 25 f0 ff    mov    %fs:0xfffffffffffffff0,%rcx

这条指令中的冒号(%fs:0xfffffffffffffff0)表示段寻址(更多内容请参考这里)。

回到启动过程

最后,让我们看一下 runtime.rto_go 函数的最后两部分:

ok:
	// set the per-goroutine and per-mach "registers"
	get_tls(BX)
	LEAQ	runtime·g0(SB), CX
	MOVQ	CX, g(BX)
	LEAQ	runtime·m0(SB), AX

	// save m->g0 = g0
	MOVQ	CX, m_g0(AX)
	// save m0 to g0->m
	MOVQ	AX, g_m(CX)

这里,我们将 TLS 地址加载到 BX 寄存器中,并将 runtime.g0 变量的地址保存到 TLS 中。同时初始化 runtime.m0 变量。如果 runtime.g0 表示根 goroutine,那么 runtime.m0 对应于运行这个 goroutine 的系统级线程。在后面的博客中我们也许会更进一步介绍 runtime.g0 和 runtime.m0。

启动过程的最后一部分就是初始化参数并调用不同的函数,不过这又是另外的主题了。

更多关于 Golang 的内容

我们已经学习了 Go 的启动过程以及其栈实现的内部机制了。后面,我们需要分析启动过程的最后一部分。这将是下一篇博客的主题。如果你想及时看到博客更新,请关注 @altoros


有疑问加站长微信联系(非本文作者)

本文来自:伯乐在线

感谢作者:伯乐在线

查看原文:Go语言内幕(5):运行时启动过程

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

9230 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传