本文使用 golang 1.17 代码,如有任何问题,还望指出。
Golang 代码被操作系统运行起来的流程
一、编译
go 源代码首先要通过 go build 编译为可执行文件,在 linux 平台上为 ELF 格式的可执行文件,编译阶段会经过编译器、汇编器、链接器三个过程最终生成可执行文件。
- 1、编译器:.go 源码通过 go 编译器生成为 .s 的 plan9 汇编代码,Go 编译器入口是 compile/internal/gc/main.go 文件的 main 函数;
- 2、汇编器:通过 go 汇编器将编译器生成的 .s 汇编语言转换为机器代码,并写出最终的目标程序 .o 文件,src/cmd/internal/obj 包实现了go汇编器;
- 3、链接器:汇编器生成的一个个 *.o 目标文件通过链接处理得到最终的可执行程序,src/cmd/link/internal/ld 包实现了链接器;
二、运行
go 源码通过上述几个步骤生成可执行文件后,二进制文件在被操作系统加载起来运行时会经过如下几个阶段:
1、从磁盘上把可执行程序读入内存;
2、创建进程和主线程;
3、为主线程分配栈空间;
4、把由用户在命令行输入的参数拷贝到主线程的栈;
5、把主线程放入操作系统的运行队列等待被调度执起来运行;
Golang 程序启动流程分析
1、通过 gdb 调试分析程序启动流程
此处以一个简单的 go 程序通过单步调试来分析其启动过程的流程:
main.go
1 | package main |
编译该程序并使用 gdb 进行调试。使用 gdb 调试时首先在程序入口处设置一个断点,然后进行单步调试即可看到该程序启动过程中的代码执行流程。
1 | $ go build -gcflags "-N -l" -o main main.go |
通过单步调试可以看到程序入口函数在 runtime/rt0_linux_amd64.s
文件中的第 8 行,最终会执行 CALL runtime·mstart(SB)
指令后输出 “hello world” 然后程序就退出了。
启动流程流程中的函数调用如下所示:
1 | rt0_linux_amd64.s -->_rt0_amd64 --> rt0_go-->runtime·settls -->runtime·check-->runtime·args-->runtime·osinit-->runtime·schedinit-->runtime·newproc-->runtime·mstart |
2、golang 启动流程分析
上节通过gdb调试已经看到了 golang 程序在启动过程中会执行一系列的汇编指令,本节会具体分析启动程序过程中每条指令的含义,了解了这些才能明白 golang 程序在启动过程中所执行的操作。
src/runtime/rt0_linux_amd64.s
1 | #include "textflag.h" |
首先执行的第8行即 JMP _rt0_amd64
,此处在 amd64 平台下运行,_rt0_amd64
函数所在的文件为 src/runtime/asm_amd64.s
。
1 | TEXT _rt0_amd64(SB),NOSPLIT,$-8 |
_rt0_amd64
函数中将 argc 和 argv 两个参数保存到 DI 和 SI 寄存器后跳转到了 rt0_go
函数,rt0_go
函数的主要作用:
- 1、将 argc、argv 参数拷贝到主线程栈上;
- 2、初始化全局变量 g0,为 g0 在主线程栈上分配大约 64K 栈空间,并设置 g0 的stackguard0,stackguard1,stack 三个字段;
- 3、执行 CPUID 指令,探测 CPU 信息;
- 4、执行 nocpuinfo 代码块判断是否需要初始化 cgo;
- 5、执行 needtls 代码块,初始化 tls 和 m0;
- 6、执行 ok 代码块,首先将 m0 和 g0 绑定,然后调用
runtime·args
函数处理进程参数和环境变量,调用runtime·osinit
函数初始化 cpu 数量,调用runtime·schedinit
初始化调度器,调用runtime·newproc
创建第一个 goroutine 执行 main 函数,调用runtime·mstart
启动主线程,主线程会执行第一个 goroutine 来运行 main 函数,此处会阻塞住直到进程退出;
1 | TEXT runtime·rt0_go(SB),NOSPLIT|TOPFRAME,$0 |
执行完以上指令后,进程内存空间布局如下所示:
然后开始执行获取 cpu 信息的指令以及与 cgo 初始化相关的,此段代码暂时可以不用关注。
1 | // 执行CPUID指令,尝试获取CPU信息,探测 CPU 和 指令集的代码 |
下面开始执行 needtls
代码块,初始化 tls 和 m0,tls 为线程本地存储,在 golang 程序运行过程中,每个 m 都需要和一个工作线程关联,那么工作线程如何知道其关联的 m,此时就会用到线程本地存储,线程本地存储就是线程私有的全局变量,通过线程本地存储可以为每个线程初始化一个私有的全局变量 m,然后就可以在每个工作线程中都使用相同的全局变量名来访问不同的 m 结构体对象。后面会分析到其实每个工作线程 m 在刚刚被创建出来进入调度循环之前就利用线程本地存储机制为该工作线程实现了一个指向 m 结构体实例对象的私有全局变量。
在后面代码分析中,会经常看到调用 getg
函数,getg
函数会从线程本地存储中获取当前正在运行的 g,这里获取出来的 m 关联的 g0。
tls 地址会写到 m0 中,而 m0 会和 g0 绑定,所以可以直接从 tls 中获取到 g0。
1 | // 下面开始初始化tls(thread local storage,线程本地存储),设置 m0 为线程私有变量,将 m0 绑定到主线程 |
继续执行 ok 代码块,主要逻辑为:
- 将 m0 和 g0 进行绑定,启动主线程;
- 调用
runtime·osinit
函数用来初始化 cpu 数量,调度器初始化时需要知道当前系统有多少个CPU核; - 调用
runtime·schedinit
函数会初始化m0和p对象,还设置了全局变量 sched 的 maxmcount 成员为10000,限制最多可以创建10000个操作系统线程出来工作; - 调用
runtime·newproc
为main 函数创建 goroutine; - 调用
runtime·mstart
启动主线程,执行 main 函数;
1 | // 首先将 g0 地址保存在 tls 中,即 m0.tls[0] = &g0,然后将 m0 和 g0 绑定 |
此时进程内存空间布局如下所示:
查看 ELF 二进制文件结构
可以通过 readelf 命令查看 ELF 二进制文件的结构,可以看到二进制文件中代码区和数据区的内容,全局变量保存在数据区,函数保存在代码区。
1 | $ readelf -s main | grep runtime.g0 |
总结
本文主要介绍 Golang 程序启动流程中的关键代码,启动过程的主要代码是通过 Plan9 汇编编写的,如果没有做过底层相关的东西看起来还是非常吃力的,笔者对其中的一些细节也未完全搞懂,如果有兴趣可以私下讨论一些详细的实现细节,其中有一些硬编码的数字以及操作系统和硬件相关的规范理解起来相对比较困难。针对 Golang runtime 中的几大组件也会陆续写出相关的分析文章。
参考:
https://loulan.me/post/golang-boot/
https://mp.weixin.qq.com/s/W9D4Sl-6jYfcpczzdPfByQ
https://programmerall.com/article/6411655977/
https://ld246.com/article/1547651846124
https://zboya.github.io/post/go_scheduler/#mstartfn
https://blog.csdn.net/yockie/article/details/79166713