启动过程是理解 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 # 42f18042f170: 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_go。runtime.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。
有疑问加站长微信联系(非本文作者)