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

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

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

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

寻找入口点

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

然后,编译并链接:

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

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

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

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

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

启动顺序

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

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

第一部分是这样的:

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

第二部分更加巧妙。首先,我们将全局变量 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 函数的开始处会是这样的:

首先,我们从 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 函数中的代码:

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

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

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

下一段代码负责设置 TLS :

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

TLS 内部实现

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

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

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

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

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

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

回到启动过程

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

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

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

更多关于 Golang 的内容

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

1 2 收藏 评论

关于作者:yhx

研究僧 个人主页 · 我的文章 · 17 ·    

相关文章

可能感兴趣的话题



直接登录
跳到底部
返回顶部