Go 程序的启动流程【1/2】

Go 程序的启动流程

本文将以一个简单的 HelloWorld 程序为例,探究 Go 程序的启动流程

go 复制代码
package main

func main() {
    _ = "Hello World"
}

入口

我们先通过 go build . 将代码编译成可执行文件,众所周知,我们在一个 shell 中执行可执行文件时,shell 进程会启动一个子进程,并在子进程中通过 execve 系统调用启动加载器,加载器会读取可执行文件头部信息中的入口点(entry point) 字段,该字段告诉加载器应该从什么地方开始执行该可执行文件,这便是我们要找的程序入口。

不同环境的可执行文件格式不同,查看入口点的方法也不同,以最常见的 Linux 系统的 TLF 格式为例,

先将代码交叉编译成 TLS 格式

sh 复制代码
GOOS=linux GOARCH=amd64 go build -gcflags="-N -l" -o main

使用 objdump -f 便可以得到入口点地址为 0x0000000000453860

sh 复制代码
➭ objdump -f main                                       

main:	file format elf64-x86-64
architecture: x86_64
start address: 0x0000000000453860

使用 -t 列出符号表并检索入口地址就可以得到 go 程序的入口地址 _rt0_amd64_linux

sh 复制代码
➭ objdump -t main | grep 453860
0000000000453860 g     F .text	0000000000000005 _rt0_amd64_linux

如果你是 Mac 系统,可执行文件格式为 Mach-O 想要查看这种文件的入口点信息,可以使用 otool 工具查看,Mach-O 中,这个信息被放在 Load Command 中,使用命令:

sh 复制代码
➭ otool -l main | awk '/entryoff/ { printf "0x%x\n", $2 }'
0x4fe80

得到入口点偏移地址为 0x4fe80 同样检索符号表可以得到入口函数 _rt0_arm64_darwin

sh 复制代码
➭ otool -t -V helloworld | grep -C 1 "4fe80"
__rt0_arm64_darwin:
000000010004fe80        adrp    x2, 0 ; 0x10004f000
000000010004fe84        add     x2, x2, #0x160

不管是 AMD 的 _rt0_amd64_linux 还是 ARM 的 _rt0_arm64_darwin 在简单的移动 argc 和 argv 之后,都会跳转到 rt0_go 函数,这就是引导 Go 程序的入口点,它主要干以下几件事:

  1. 初始化 TLS
  2. 初始化 g0 和 m0 并相互绑定
  3. 初始化命令行参数,程序执行路径
  4. 操作系统初始化
  5. 调度器初始化
  6. 启动新的 groutine 执行 main 函数

后续内容以 ARM 架构的 Mac 系统为例

初始化 TLS

s 复制代码
TEXT runtime·rt0_go(SB),NOSPLIT|TOPFRAME,$0
	SUB	$32, RSP
	MOVW	R0, 8(RSP) // argc
	MOVD	R1, 16(RSP) // argv

#ifdef TLS_darwin
	// Initialize TLS.
	MOVD	ZR, g // clear g, make sure it's not junk.
	SUB	$32, RSP
	MRS_TPIDR_R0
	AND	$~7, R0
	MOVD	R0, 16(RSP)             // arg2: TLS base
	MOVD	$runtime·tls_g(SB), R2
	MOVD	R2, 8(RSP)              // arg1: &tlsg
	BL	·tlsinit(SB)
	ADD	$32, RSP
#endif

rt0_go 中会先将寄存器中的 argc 和 argv 移动到栈上,之后进入初始化 TLS 的过程。

TLS(Thread Local Storage,线程本地存储)被用来保存那些只对本线程可见的全局变量,go 会将正在系统线程上运行的 groutine 保存在 TLS 中,在实现上,一般会在内存中开辟一块专门的区域用来保存 TLS 数据,然后使用某个 CPU 寄存器保存这块内存的起始地址(AMD 架构下一般使用段寄存器 FD, 而 ARM 有一个专门的 TPIDR 寄存器来保存),线程被操作系统调度时,会保存和恢复寄存器的值,这样在任何情况下,根据寄存器中的起始地址,我们一定可以拿到专属于该线程的全局数据。

在 c++ 中,可以使用 __thread 将变量声明为 "Pre-Thread" 的

POSIX 提供了一组 API^1^ 来操作 TLS:

cpp 复制代码
// 存储 每线程 的变量
int pthread_setspecific(pthread_key_t key, const void *value);

// 获取 每线程 的变量
void *pthread_getspecific(pthread_key_t key);

// 生成一个 每线程 变量的 key
int pthread_key_create(pthread_key_t *key, void (*destructor)(void*));

它的内部结构类似一个 uintptr 的数组,每一位都保存一个指向真实数据地址的指针,而根据 pthread_key 则可以为宜定位到该数组中的某一位。

接下来我们逐行分析这段汇编:

s 复制代码
MOVD ZR g

这一行的作用是将 g 清空,避免脏数据。

  • ZR 是一个 62 bit 零寄存器的代称,它总是返回 0。

  • g 是 go 官方对 ARM 架构下 R10 寄存器的一个代称,可能因为他们总是使用 R10 来保存 g 的指针,因此在 go 的汇编中,直接使用 R10 是无效的,只能使用 g 指代.^1^

这一句翻译一下就是清空 R10 寄存器。

s 复制代码
SUB	$32, RSP

栈指针 RSP 向下移 32 字节,为后面的函数调用申请栈空间。

s 复制代码
MRS_TPIDR_R0
AND	$~7, R0

这实际上是一个宏,定义在 tls_arm64.h 中, 作用是读 TPIDR 寄存器的值到 R0, TPIDR 寄存器中保存的就是 TLS 的基地址:

h 复制代码
#define MRS_TPIDR_R0 WORD $0xd53bd040 // MRS TPIDR_EL0, R0

下面的 AND 是 ARM 的特殊规定,要求基地址低 3 位必须为 0,

s 复制代码
MOVD	R0, 16(RSP)             // arg2: TLS base
MOVD	$runtime·tls_g(SB), R2
MOVD	R2, 8(RSP)              // arg1: &tls
BL	·tlsinit(SB)

这四行的作用是调用 tlsinit 函数为 tls_g 赋值。

go 复制代码
func tlsinit(tlsg *uintptr, tlsbase *[_PTHREAD_KEYS_MAX]uintptr) {}

这个函数接受两个参数:

  • tlsg: tlsg 的指针,来自 runtime·tls_g(SB) 这是一个全局的变量
  • tlsbase: 从寄存器中取出的 TLS 基地址

它会调用 POSIX API 在 TLS 中临时保存一个魔法值,之后遍历 TLS 空间找到这个保存这个魔法值的槽的偏移,并将其赋值给 runtime·tls_g

上面说过,POSIX 的 TLS 实现类似于一个数组,这里 runtime·tls_g 中保存的就是 g 在那个数组中的地址偏移,这样寄存器中的 TLS 基地址加上这里的地址偏移就可以定位到 g 的地址了。

s 复制代码
ADD	$32, RSP

最后函数调用结束,释放栈。

总结一下

相对而言 ARM 初始化 TLS 的过程是比较简单的,总体就做了一件事:为 runtime·tls_g 赋值

这个全局变量保存了 g 在 tls 中的偏移,根据 tls 基地址加上偏移,我们就可以定位到保存在 TLS 中的 g 了

初始化 g0 m0

我们知道 g 是 go 对 groutine 的抽象,m 是对系统线程的抽象。

而 m0 描述的就是主线程,运行中主线程上的第一个 groutine 就是 g0.

m0 和 g0 是两个全局变量,引导时会使用汇编为这两个变量中的一些字段赋初值:

go 复制代码
// runtime/proc.go
var (
	m0           m
	g0           g
)
s 复制代码
MOVD	$runtime·g0(SB), g
MOVD	RSP, R7
MOVD	$(-64*1024)(R7), R0
MOVD	R0, g_stackguard0(g)
MOVD	R0, g_stackguard1(g)
MOVD	R0, (g_stack+stack_lo)(g)
MOVD	R7, (g_stack+stack_hi)(g)

这里是在初始化 g0 的栈帧:

go 复制代码
type stack struct {
	lo uintptr // gorutine 栈低地址
	hi uintptr // groutine 栈高地址
}

type g struct {
	stack       stack   // 标识栈的边界
	stackguard0 uintptr // 同于栈增长
	stackguard1 uintptr // TODO
}

执行完上述代码后,程序的栈空间应该如下:

txt 复制代码
        high addr
      ┌───────────┐
      │           │
      │           │
      │           │
      ├───────────┤
      │  argv 8   │
      ├───────────┤
RSP   │  argc 8   │
─────►├───────────┤◄─────
  ▲   │           │    g0.stack.ho
  │   │           │
  │   │           │
  │   │           │
  │   │           │
  │   │           │
  │   │           │
64kb  │  g0 stack │
  │   │           │
  │   │           │
  │   │           │
  │   │           │
  │   │           │
  │   │           │
  │   │           │     g0.stackguard1
  ▼   │           │     g0.stackguard0
─────►├───────────┤◄─────
R7    │           │     g0.stack.lo
      │           │
      │           │
      │           │
      │           │
      └───────────┘
         low addr

初始化栈后会调用 save_g 正式将 g0 的地址存入 TLS:

s 复制代码
nocgo:
	BL	runtime·save_g(SB)
s 复制代码
TEXT runtime·save_g(SB),NOSPLIT,$0
	MRS_TPIDR_R0
	MOVD	runtime·tls_g(SB), R27
	MOVD	g, (R0)(R27)

和上面一样 R0 是从寄存器中获得的 TLS 基地址,R27 是来自 tls_g 的 TLS 偏移,通过 R0+R27 的间接寻址得到的就是 g0 在 TLS 中的位置

接着会调整 stackguard0 和 stackguard1 以插入栈保护区(StackGuard 默认是 928 字节)

s 复制代码
MOVD	(g_stack+stack_lo)(g), R0
ADD	$const__StackGuard, R0
MOVD	R0, g_stackguard0(g)
MOVD	R0, g_stackguard1(g)

接下来会绑定 g0 和 m0:

s 复制代码
	MOVD	$runtime·m0(SB), R0
	MOVD	g, m_g0(R0) // m0.g0 = g0
	MOVD	R0, g_m(g)  // g0.m = m0
go 复制代码
type m struct {
	g0      *g
}

m 的 g0 字段保存了当前正在我上面执行的是哪个 groutine

而 g 结构中也有一个 m 字段用来反向查找当前 groutine 在哪个 m 上执行

总结

这一段主要做了三件事:

  1. 为 g0 申请栈空间,32 KB,其中有 928 字节保护区
  2. 将 g0 地址保存到 TLS 中
  3. 将 m0 和 g0 双向绑定

这时的内存布局为:

txt 复制代码
        high addr
      ┌───────────┐
      │           │
      │           │
      │           │
      ├───────────┤
      │  argv 8   │
      ├───────────┤
RSP   │  argc 8   │
─────►├───────────┤◄─────
  ▲   │           │    g0.stack.ho
  │   │           │
  │   │           │
  │   │           │
  │   │           │
  │   │           │
  │   │           │
64kb  │  g0 stack │
  │   │           │
  │   │           │
  │   │           │
  │   │           │
  │   │           │       g0.stackguard0
  │   │───────────│◄───── g0.stackguard1
  │   │ 928 byte  │     
  ▼   │           │  栈保护区
─────►├───────────┤◄─────
R7    │           │     g0.stack.lo
      │           │
      │           │
      │           │
      │           │
      └───────────┘
         low addr

args/osinit

TLS 初始化结束后会调用 args 对命令行参数进行初始化:

s 复制代码
MOVW	8(RSP), R0	// copy argc
MOVW	R0, -8(RSP)
MOVD	16(RSP), R0		// copy argv
MOVD	R0, 0(RSP)
BL	runtime·args(SB)

BL	runtime·osinit(SB)
BL	runtime·schedinit(SB)
go 复制代码
var (
	argc int32
	argv **byte
)

//go:linkname executablePath os.executablePath
var executablePath string

func args(c int32, v **byte) {
	argc = c
	argv = v
	sysargs(c, v)
}

值得注意的是这一步只是调用 sysargs 初始化了 executablePath 全局变量,它保存的是当前可执行文件的绝对路径,在代码中,你可以使用 os.Executable() 获取到该值。

argv 参数在程序中可以使用 os.Argv 获取,返回的是一个 []string 不过这一步并没有初始化这个值,它与 os.GetEnv() 所返回的环境变量的值一起会在调度器初始化时通过 goargsgoenvs 函数初始化。

实际上命令行参数 argv 和环境变量 envv 都是按顺序保存在栈上的(v **byte)这一步将它赋值给了全局变量 argv, 它的结构如下:

txt 复制代码
┌───────────────┬───┬────────────────────┬───┬───────────────────────────┬───┐
│               │ n │                    │ n │                           │ n │
│               │ u │                    │ u │                           │ u │
│   argv        │ l │     envv           │ l │    executablePath         │ l │
│               │ l │                    │ l │                           │ l │
│               │   │                    │   │                           │   │
└───────────────┴───┴────────────────────┴───┴───────────────────────────┴───┘

程序参数被初始化后,就会通过 osinit 初始化 cpu 数量和物理内存页大小,这两个变量在初始化内存时会被经常用到。

go 复制代码
func osinit() {
	ncpu = getncpu()
	physPageSize = getPageSize()
}

schedinit

在获取了 CPU 和 内存的必要参数后,Go 开始调用 schedinit 初始化包括 内存分配器,Goroutine 调度器,垃圾回收器等语言层面的组件。

函数开始会对用到的锁调用 lockInit,但除非你设置 GOEXPERIMENT=staticlockranking 开启 static lock ranking 功能,否则他们都是空函数,不会做任何事情;static lock ranking 的作用是在运行时 mutex 出现死锁时报告错误,具体可以参考提交 comment 信息.

这里大部分是一些初始化函数,用来初始化堆栈分配,gc 等模块的关键数据结构,值得注意的有下面几个地方:

  1. 这里设置了 sched.maxmcount=1000

  2. mcommoninit 中将当前 m 即 m0 的 id 设置成了 -1

    go 复制代码
     mcommoninit(_g_.m, -1)
  3. 并且为其新初始化了一个 g 挂在 gsignal 上,实际上这个 groutine 是专门用来处理操作系统信号的,有意思的是这个 g 的栈空间是 32k 大于普通 g 的 8k

    go 复制代码
     func mpreinit(mp *m) {
     	mp.gsignal = malg(32 * 1024) // OS X wants >= 8K
     	mp.gsignal.m = mp
     }
  4. mcommoninit 还将当前 m 插入了全局 m 链表 allm 的表头,事实上每个新建的 m 都会被加入到这个链表的头部,每个 m 有一个 alllink 字段指向该链表(注意:现在还在初始化,allm 本身还是 nil,所以这里直接将 m0 原子地赋值给了 allm)

    go 复制代码
     mp.alllink = allm
     atomicstorep(unsafe.Pointer(&allm), unsafe.Pointer(mp))
  5. 调用 goargsgoenvs 初始化命令后参数和环境变量,我们在上一节中已经讲过了

  6. procresize 中根据 GOMAXPROCS 和 cpu 数量初始化所有 p:

    go 复制代码
     procs := ncpu
     if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
     	procs = n
     }
     if procresize(procs) != nil {
     	throw("unknown runnable goroutine during bootstrap")
     }

    这里会根据 GOMAXPROCS 和 cpu 数量 直接将 nprocs 个 p 全部初始化好并放入 allp 列表中并将 m0 和第 0 个 p(我们称为 p0)绑定:

    go 复制代码
     _g_.m.p = 0
     p := allp[0]
     p.m = 0
     p.status = _Pidle
     acquirep(p)
    
     func acquirep(_p_ *p) {
     	wirep(_p_)
     	// ...
     }
    
     func wirep(_p_ *p) {
     	_g_ := getg()
     	// ...
     	_g_.m.p.set(_p_)
     	_p_.m.set(_g_.m)
     	_p_.status = _Prunning
     }

    可以留意一下 p 的状态流转:

    在刚被初始化好还没有与 m 绑定时,状态是 _Pidle 绑定后,状态流转为 _Prunning

    最后,所有空闲的 p(现在是 除 p0 外的所有p)放入 _Pidle 列表:

    go 复制代码
     var runnablePs *p
     for i := nprocs - 1; i >= 0; i-- {
     	p := allp[i]
     	if _g_.m.p.ptr() == p {
     		continue
     	}
     	p.status = _Pidle
     	if runqempty(p) {
     		pidleput(p) // 加入 _Pidle list 
     	} else {
     		p.m.set(mget())
     		p.link.set(runnablePs)
     		runnablePs = p
     	}
     }

总结一下,这一步主要是初始化了一些关键数据结构的字段,除此之外最重要的就是将 m0 加入了全局 m 链表,并切初始化了所有 p 并实现了 m0 和 p0 的绑定,至此 g0 m0 p0 全部如下图绑定和初始化结束,下一步就是启动新的 groutine 去执行 main 函数了:

newproc

s 复制代码
// create a new goroutine to start program
MOVD	$runtime·mainPC(SB), R0		// entry
SUB	$16, RSP
MOVD	R0, 8(RSP) // arg
MOVD	$0, 0(RSP) // dummy LR
BL	runtime·newproc(SB)
ADD	$16, RSP

这一步的作用是通过 newproc 创建一个新的 goroutine 去执行 main 函数, 它接受一个函数指针作为参数:

go 复制代码
func newproc(fn *funcval) {
	gp := getg() // 这里是 g0
	pc := getcallerpc()
	systemstack(func() {
		newg := newproc1(fn, gp, pc)

		pp := getg().m.p.ptr() // p0
		runqput(pp, newg, true)

		if mainStarted {
			wakep()
		}
	})
}

systemstack 用于切换到系统栈执行,有关系统栈,signal 和用户栈可以参考下面 细节/stack 一节,我们这里本来就在系统栈上.

newproc1 创建一个新的 g

细节

为了逻辑通顺,上面有些细节没有介绍,放在了这里

stack

https://go.dev/src/runtime/HACKING

Every non-dead G has a user stack associated with it, which is what user Go code executes on. User stacks start small (e.g., 2K) and grow or shrink dynamically.

每个非死亡的 G(goroutine)都关联有一个用户栈(user stack),这是用户Go代码执行的地方。用户栈起始时很小(例如2KB),并且可以动态增长或缩小。

Every M has a system stack associated with it (also known as the M's "g0" stack because it's implemented as a stub G) and, on Unix platforms, a signal stack (also known as the M's "gsignal" stack). System and signal stacks cannot grow, but are large enough to execute runtime and cgo code (8K in a pure Go binary; system-allocated in a cgo binary).

每个M(OS线程)都关联有一个系统栈(system stack),也被称为M的"g0"栈,因为它是作为一个存根G(stub G)实现的。在Unix平台上,M还有一个信号栈(signal stack),也被称为M的"gsignal"栈。系统栈和信号栈不能增长,但足够大以执行运行时和cgo代码(在纯Go二进制文件中为8KB;在cgo二进制文件中由系统分配)。

Runtime code often temporarily switches to the system stack using systemstack, mcall, or asmcgocall to perform tasks that must not be preempted, that must not grow the user stack, or that switch user goroutines. Code running on the system stack is implicitly non-preemptible and the garbage collector does not scan system stacks. While running on the system stack, the current user stack is not used for execution.

运行时代码经常使用 systemstack、mcall或asmcgocall临时切换到系统栈来执行不能被抢占的任务,或者不能增长用户栈的任务,或者需要切换用户goroutine的任务。在系统栈上运行的代码隐式地不可抢占,垃圾回收器不会扫描系统栈。在系统栈上运行时,当前的用户栈不用于执行。

文档描述的很清楚,在处理一些特殊任务时,需要切换到系统栈,这里我们简单看一下 systemstack 的实现,这是一个汇编函数:

参考

记录

  1. getg 是一个伪指令 https://groups.google.com/g/golang-nuts/c/KgPOzaMylHo/m/zX0GosvnAQAJ

  2. 所有的 m 通过 m.alllink 连了一个链表,有一个 allm 指针指向这个链表的头部,每次创建新的 m 都会插入到链表头部


  1. https://pubs.opengroup.org/onlinepubs/009695399/functions/pthread_key_create.html ↩︎ ↩︎
相关推荐
Lizhihao_2 分钟前
JAVA-队列
java·开发语言
远望清一色20 分钟前
基于MATLAB边缘检测博文
开发语言·算法·matlab
何曾参静谧28 分钟前
「Py」Python基础篇 之 Python都可以做哪些自动化?
开发语言·python·自动化
Prejudices32 分钟前
C++如何调用Python脚本
开发语言·c++·python
我狠狠地刷刷刷刷刷1 小时前
中文分词模拟器
开发语言·python·算法
wyh要好好学习1 小时前
C# WPF 记录DataGrid的表头顺序,下次打开界面时应用到表格中
开发语言·c#·wpf
AitTech1 小时前
C#实现:电脑系统信息的全面获取与监控
开发语言·c#
qing_0406031 小时前
C++——多态
开发语言·c++·多态
孙同学_1 小时前
【C++】—掌握STL vector 类:“Vector简介:动态数组的高效应用”
开发语言·c++
froginwe111 小时前
XML 编辑器:功能、选择与使用技巧
开发语言