mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
3602 字
10 分钟
Go 程序的启动
2022-05-05

一、Go 语言历史与设计哲学#

Go 语言(又称 Golang)由 Robert Griesemer、Rob Pike 和 Ken Thompson 于 2007 年在 Google 开始设计,并于 2009 年正式开源。它的诞生源于对大型软件工程项目中编译速度慢、依赖管理混乱、并发编程复杂等痛点的深刻反思。

Go 的设计哲学可以概括为以下几个核心原则:

  • 简洁(Simplicity):语法精简,关键字仅 25 个,摒弃了类继承、异常等复杂特性,通过组合和接口实现代码复用
  • 显式(Explicitness):错误处理不使用异常机制,而是通过返回值显式传递,让控制流一目了然
  • 并发原生(Concurrency):goroutine 和 channel 作为一等公民内置于语言之中,基于 CSP(Communicating Sequential Processes)模型实现轻量级并发
  • 工程实用(Pragmatism):内置垃圾回收、格式化工具(gofmt)、测试框架、模块管理,开箱即用
  • 快速编译(Fast Compilation):依赖分析算法高效,大型项目也能在秒级完成编译

Ken Thompson 曾说:“Go 语言的设计目标是让程序员在大型代码库中保持高效。” 这一理念贯穿于 Go 从语法设计到工具链的方方面面。

Info

本文作为系列的第一篇,将从最底层的视角出发,追踪一个 Go 程序从操作系统加载到用户 main 函数执行的完整过程。

二、安装与环境配置#

在深入源码之前,确保你的开发环境已正确配置。Go 的安装非常简单:

安装 Go#

Go 官方下载页面 获取最新稳定版,或使用包管理器安装:

# macOS
brew install go
# Ubuntu/Debian
sudo apt install golang-go
# 或直接下载二进制包(推荐)
wget https://go.dev/dl/go1.25.0.linux-amd64.tar.gz
sudo tar -C /usr/local -xzf go1.25.0.linux-amd64.tar.gz

配置环境变量:

# ~/.bashrc 或 ~/.zshrc
export GOROOT=/usr/local/go
export GOPATH=$HOME/go
export PATH=$PATH:$GOROOT/bin:$GOPATH/bin

验证安装:

go version
# go version go1.25.0 linux/amd64

调试工具配置#

为使 gdb 支持调试 Go 程序,需要配置 gdbinit:

echo add-auto-load-safe-path $(go env GOROOT)/src/runtime/runtime-gdb.py >> ~/.gdbinit

后续将使用 gdb 追踪 Go 程序的启动过程,因此这一步不可或缺。

三、Hello World 程序剖析#

在 Golang 中,编写的代码仅仅是用户层面的代码,在真正执行时,runtime 还会进行大量初始化工作。本文将深入探究 Go 程序的真正入口,以及启动时会执行哪些初始化操作。

首先,在 workdir 目录中创建一个简单的 Hello World 程序:

package main
import "fmt"
func main() {
fmt.Println("Hello World")
}

这段看似简单的代码背后隐藏着复杂的启动流程。当执行编译和运行时,Go runtime 会完成以下工作:

  1. 操作系统加载可执行文件,跳转到入口地址
  2. 初始化 g0(系统 goroutine)和 m0(系统线程)
  3. 设置 TLS(线程本地存储)
  4. 初始化调度器、内存管理器、垃圾回收器
  5. 创建主 goroutine 并调度执行用户 main 函数

下面将逐步追踪每一个阶段。

四、编译与链接过程#

可以使用 go build -v -x main.go 命令进行编译,观察编译的详细过程(编译器内部流程详见 Go 编译器与工具链):

~ go build -v -x main.go
WORK=/tmp/go-build3785097308
command-line-arguments
mkdir -p $WORK/b001/
cat >/tmp/go-build3785097308/b001/importcfg << 'EOF' # internal
# import config
packagefile fmt=/root/.cache/go-build/5a/5a85274bbefd6702b95fe9edb05543e0787d85e1a7dd4a638459c0d94279347e-d
packagefile runtime=/root/.cache/go-build/47/47475b234b211c63b7b5248c5ffe186380c67b3a013d5e0a9bfc38ff287322ce-d
EOF
cd /root/blog-site/content/posts/golang
/usr/local/go/pkg/tool/linux_amd64/compile -o $WORK/b001/_pkg_.a -trimpath "$WORK/b001=>" -p main -complete -buildid 9Q9L-dT1lPKryT9M6MuW/9Q9L-dT1lPKryT9M6MuW -goversion go1.20.5 -c=4 -nolocalimports -importcfg $WORK/b001/importcfg -pack ./main.go
/usr/local/go/pkg/tool/linux_amd64/buildid -w $WORK/b001/_pkg_.a # internal
cp $WORK/b001/_pkg_.a /root/.cache/go-build/65/651f9497c1bf6bdb269656b49fdcc35dc17f28757633725d905c9a12834b70d8-d # internal
cat >/tmp/go-build3785097308/b001/importcfg.link << 'EOF' # internal
packagefile command-line-arguments=/tmp/go-build3785097308/b001/_pkg_.a
packagefile fmt=/root/.cache/go-build/5a/5a85274bbefd6702b95fe9edb05543e0787d85e1a7dd4a638459c0d94279347e-d
packagefile runtime=/root/.cache/go-build/47/47475b234b211c63b7b5248c5ffe186380c67b3a013d5e0a9bfc38ff287322ce-d
packagefile errors=/root/.cache/go-build/16/1614d1e860779bdd7e63b9ace048ed5c7a97b04fbf64c21e3d5d9ed474dc6127-d
packagefile internal/fmtsort=/root/.cache/go-build/38/387acd18f92d205195c39a0cd19fccf97740aca13e8ff884e8380ee620a42625-d
packagefile io=/root/.cache/go-build/db/db976f4d0ab0631112d3df9d84973615392ec449f518ab2fdd80f012add5e0ea-d
packagefile math=/root/.cache/go-build/9b/9b5cfacac4b76a1cd5bffe8ddf1205bd3311b3351271ef0478df6b24a330aac5-d
packagefile os=/root/.cache/go-build/54/543e805161b3797fca3138c85e870dae0b983b5820a9d1d27dfb32787dfb17a8-d
packagefile reflect=/root/.cache/go-build/ba/ba60b6d4a1e062811c877f7525496d90904df31d574ccddcafd18ab0765fd3e6-d
packagefile sort=/root/.cache/go-build/9c/9c22541614fe877a88f3a267fb9a282f736945c545901a4ab92f6c87295594f0-d
packagefile strconv=/root/.cache/go-build/61/61af2248c1b10fa37e5d7da74ce63bc2f3fc4b85d71c8a8d2472c6841263dbbc-d
packagefile sync=/root/.cache/go-build/af/af0c9e835f263130088c123bb71d9f528ead461fe081b137543a23a2e6880e0c-d
packagefile unicode/utf8=/root/.cache/go-build/55/55c78141439967c497ef1d8bcc1f724dd5b1fab6131ce80d6bc7ab4d338ac783-d
packagefile internal/abi=/root/.cache/go-build/b9/b9fe5f7abd9676969136c0fda934dee047633039cae77c70d282251609443bce-d
packagefile internal/bytealg=/root/.cache/go-build/b8/b87cc310d783a3a27ab80f0165651d417a52848d12f7adf3003d305eb87f3158-d
packagefile internal/coverage/rtcov=/root/.cache/go-build/bc/bc4d12e895fb104d560e6b4192e952d2829d8194cf8a274c5f104c35d80b9774-d
packagefile internal/cpu=/root/.cache/go-build/ae/ae7645e84cb76a1bf351f5ab1b823a97e845dfbdea48a8c3bd64010d853b20ee-d
packagefile internal/goarch=/root/.cache/go-build/e3/e3dc83e2820ca7ed5dbc544f0181cfd8829e3357e99876f40e5454c7abd3d7f7-d
packagefile internal/goexperiment=/root/.cache/go-build/ac/acdb750114f8d21a951104fb87a789b829c55e1269d8e2d5ede8e8109dceb514-d
packagefile internal/goos=/root/.cache/go-build/13/13c70de12e7d1cf949413b66cf278a25a60205c9fe2a48884df4cc75bf099dce-d
packagefile runtime/internal/atomic=/root/.cache/go-build/02/021d982fd31ceb193751ff630bd0a0708326e65bad78c10d35fe284dc2ddb667-d
packagefile runtime/internal/math=/root/.cache/go-build/e9/e9c1b787dae856726480d898196fec4bae10539b338bbb8373d00fe395fbb426-d
packagefile runtime/internal/sys=/root/.cache/go-build/f4/f4b5f90f461eb546868335e492567ea93fafc7616321907072ff597df87dd8bc-d
packagefile runtime/internal/syscall=/root/.cache/go-build/ba/baf2d32d6593f77b395fed92cf640776faf6fb971842ce63916341639d6d80e1-d
packagefile internal/reflectlite=/root/.cache/go-build/01/012b08e178a6c8e813d3c48a5a52d73e938eeec3182f79894f2ba716aae9412c-d
packagefile math/bits=/root/.cache/go-build/cb/cb87d7fb3a3d8b81084908cf252115ec4d0345b313c07eb3ee9929df39ac53f0-d
packagefile internal/itoa=/root/.cache/go-build/2e/2e1ffdf15257231907ed22bea794e71342406c8066cf719ed7c3c1a295926451-d
packagefile internal/poll=/root/.cache/go-build/dd/ddfa9bcc31f046a25a975333486f4ecddec59fb396184cbb6d2761492fc79e18-d
packagefile internal/safefilepath=/root/.cache/go-build/f1/f186c3bc25497814c5681b3bbbd5b8e4bbcf455c65dc4a833e9446db8ac538a3-d
packagefile internal/syscall/execenv=/root/.cache/go-build/8a/8a956c2b317f1c0d69bc02cbb6c6afb4fa7db43990bb723820e49c4949747892-d
packagefile internal/syscall/unix=/root/.cache/go-build/0d/0dd2c0be76cd7c72277a5b8391db4040f85182d4204ac8d43ae635730dc7fa67-d
packagefile internal/testlog=/root/.cache/go-build/34/34811ed3b55f7e3febc65f3d800e76be4adc05cfedea8fa873ace7b3092a6c07-d
packagefile io/fs=/root/.cache/go-build/31/3138290bc0a2bc085e7839ef4ab7ba901be8b10750897a7e671a23a48a8c1e41-d
packagefile sync/atomic=/root/.cache/go-build/5b/5bdd0d873301df7a24b744f11f78b678e44a684b6b4cd83829136aa69cac73f0-d
packagefile syscall=/root/.cache/go-build/3a/3a6c5bb22516cd974a1b3a14c01500d92d73d33785b098d6a79de53586937925-d
packagefile time=/root/.cache/go-build/7f/7f201b0f381f3a1af27c0a073b9f62698ab1d771b35a678e97f662bbbf1d30e0-d
packagefile internal/unsafeheader=/root/.cache/go-build/a5/a5d0da800ef285eedd4592c5c0343f7ca289a8e9611c69d90f42fae894fe4580-d
packagefile unicode=/root/.cache/go-build/2b/2be17f8a5ea974fc4bc4e294eb8d59a605226bb7b8ffa829b8417425bb792ff4-d
packagefile internal/race=/root/.cache/go-build/09/090603aeb000493ecd31846063b3fc479da8f548f6c94ecd6524ee0d3eee540f-d
packagefile internal/oserror=/root/.cache/go-build/25/25e65d202f732dc1c662bddad3dbbc0f733872a3033bf105f7734e562010d3f4-d
packagefile path=/root/.cache/go-build/2e/2ef225fea25de35eeec1b70589fa74f75a89f9aedf763fc4e507e81ee24e9e63-d
modinfo "0w\xaf\f\x92t\b\x02A\xe1\xc1\a\xe6\xd6\x18\xe6path\tcommand-line-arguments\nbuild\t-buildmode=exe\nbuild\t-compiler=gc\nbuild\tCGO_ENABLED=1\nbuild\tCGO_CFLAGS=\nbuild\tCGO_CPPFLAGS=\nbuild\tCGO_CXXFLAGS=\nbuild\tCGO_LDFLAGS=\nbuild\tGOARCH=amd64\nbuild\tGOOS=linux\nbuild\tGOAMD64=v1\n\xf92C1\x86\x18 r\x00\x82B\x10A\x16\xd8\xf2"
EOF
mkdir -p $WORK/b001/exe/
cd .
/usr/local/go/pkg/tool/linux_amd64/link -o $WORK/b001/exe/a.out -importcfg $WORK/b001/importcfg.link -buildmode=exe -buildid=GQ-8mEEisevO5bzTxqty/9Q9L-dT1lPKryT9M6MuW/QuqmoVROIoCDLHhaE7Hb/GQ-8mEEisevO5bzTxqty -extld=gcc $WORK/b001/_pkg_.a
/usr/local/go/pkg/tool/linux_amd64/buildid -w $WORK/b001/exe/a.out # internal
mv $WORK/b001/exe/a.out main
rm -r $WORK/b001/

从编译输出可以看到,即使是 “Hello World” 这样的简单程序,Go 也会链接数十个包。其中 runtime 是最核心的包——它包含了调度器、内存管理器、垃圾回收器等基础设施。

Go 程序编译与执行流#

下面的 Mermaid 图展示了 Go 源代码从编译到执行的完整流程:

flowchart LR A["main.go<br/>源代码"] --> B["compile<br/>词法分析/语法分析"] B --> C["SSA 中间表示<br/>优化/降级"] C --> D["_pkg_.a<br/>目标文件"] D --> E["link<br/>链接器"] E --> F["ELF 可执行文件<br/>入口: rt0_linux_amd64.s"] F --> G["OS 加载器<br/>加载到内存"] G --> H["runtime 初始化<br/>schedinit"] H --> I["runtime.main<br/>主 goroutine"] I --> J["main.main<br/>用户代码"]

这个流程揭示了 Go 程序的编译和启动过程:

  • compile 阶段:源代码经过词法分析、语法分析生成 AST,再转换为 SSA(Static Single Assignment)中间表示进行优化,最终生成目标文件
  • link 阶段:链接器将所有目标文件合并为最终的可执行文件,设置入口地址为 rt0_linux_amd64.s 中的 _rt0_amd64_linux
  • 执行阶段:操作系统加载可执行文件,从入口地址开始执行 runtime 初始化代码,最终调用用户的 main 函数

通过 gdb 追踪入口#

接下来即可开始调试:

gdb main
(gdb) info files
Symbols from "/root/blog-site/content/posts/golang/main".
Local exec file:
`/root/blog-site/content/posts/golang/main', file type elf64-x86-64.
Entry point: 0x45f300
0x0000000000401000 - 0x0000000000481807 is .text
0x0000000000482000 - 0x00000000004b920d is .rodata
0x00000000004b93a0 - 0x00000000004b98ac is .typelink
0x00000000004b98c0 - 0x00000000004b9918 is .itablink
0x00000000004b9918 - 0x00000000004b9918 is .gosymtab
0x00000000004b9920 - 0x0000000000513f08 is .gopclntab
0x0000000000514000 - 0x0000000000514130 is .go.buildinfo
0x0000000000514140 - 0x0000000000524740 is .noptrdata
0x0000000000524740 - 0x000000000052bed0 is .data
0x000000000052bee0 - 0x0000000000559e60 is .bss
0x0000000000559e60 - 0x000000000055d830 is .noptrbss
0x0000000000400f9c - 0x0000000000401000 is .note.go.buildid

从上述信息可以看出可执行文件加载到内存后的入口点以及数据分段的位置。其中关键段说明:

段名用途
.text代码段,存放编译后的机器指令
.rodata只读数据段,存放字符串常量等
.typelink类型链接信息,用于反射和类型断言
.itablink接口方法表链接
.gosymtabGo 符号表(已废弃,保留兼容性)
.gopclntabPC-Line 表,用于栈追踪和调试
.go.buildinfo构建信息(Go 版本、模块依赖等)
.data / .bss全局变量和未初始化数据

可以在入口点处设置断点,gdb 会自动显示断点位置的文件路径,随后通过 r(un)s(tep) 指令进行运行和单步调试:

(gdb) b *0x45f300
Breakpoint 1 at 0x45f300: file /usr/local/go/src/runtime/rt0_linux_amd64.s, line 8.
(gdb) r
Starting program: /root/main
Breakpoint 1, _rt0_amd64_linux () at /usr/local/go/src/runtime/rt0_linux_amd64.s:8
8 JMP _rt0_amd64(SB)
(gdb) s
_rt0_amd64 () at /usr/local/go/src/runtime/asm_amd64.s:16
16 MOVQ 0(SP), DI // argc

根据上述步骤,可以追踪 Go 程序从真正的入口到达用户编写的 main 函数的完整过程。

五、程序入口:从 rt0 到 runtime.main#

根据 gdb 的调试信息,程序入口位于 rt0_linux_amd64.s

源码参考: runtime/rt0_linux_amd64.s

#include "textflag.h"
TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8
JMP _rt0_amd64(SB)
TEXT _rt0_amd64_linux_lib(SB),NOSPLIT,$0
JMP _rt0_amd64_lib(SB)

该文件直接跳转到 runtime/asm_amd64.s 中的 _rt0_amd64 函数。由于该函数较长,我们分段阅读:

源码参考: runtime/asm_amd64.s

// 声明数据地址 runtime·mainPC+0(SB),大小为 4,值为 $runtime·main(SB)
// 注:SB 表示静态基指针(Static Base Pointer),它是 Go 中的伪寄存器,用于表示全局数据和只读数据段的基地址(使用时根据符号所在段确定自己的值)
// 它的值是在程序加载到内存时由操作系统的程序加载器设置的
DATA runtime·mainPC+0(SB)/4,$runtime·main(SB)
// 将 runtime·mainPC(SB) 声明为全局符号
GLOBL runtime·mainPC(SB),RODATA,$4
// _rt0_amd64 is common startup code for most amd64 systems when using
// internal linking. This is the entry point for the program from the
// kernel for an ordinary -buildmode=exe program. The stack holds the
// number of arguments and the C-style argv.
TEXT _rt0_amd64(SB),NOSPLIT,$-8
MOVQ 0(SP), DI // argc
LEAQ 8(SP), SI // argv
JMP runtime·rt0_go(SB)
TEXT runtime·rt0_go(SB),NOSPLIT|TOPFRAME,$0
// copy arguments forward on an even stack
MOVQ DI, AX // argc
MOVQ SI, BX // argv
// 为局部变量和参数分配空间,将 argc 和 argv 分别后移到 24(SP) 和 32(SP)
// 因为不同架构的入口函数参数数量可能不一致,比如在 x86-64 架构中传入的参数通常就包括:
//argc(命令行参数数量),argv(命令行参数数组指针),envp(环境变量数组指针),auxv(辅助向量,用于传递系统特定的信息)
SUBQ $(5*8), SP // 3args 2auto
// 操作堆栈前,确保栈指针是16字节对齐的,以避免性能下降或未定义行为。
// 因为有些指令和操作都要求栈操作在16字节边界上进行。
ANDQ $~15, SP
MOVQ AX, 24(SP)
MOVQ BX, 32(SP)

第一段较为简单,仅将栈指针进行 16 字节对齐,并将 argcargv 参数后移。

初始化 g0 栈空间#

// 初始化 g0
// create istack out of the given (operating system) stack.
// _cgo_init may update stackguard.
MOVQ $runtime·g0(SB), DI
// BX=SP-64*1024+104
LEAQ (-64*1024+104)(SP), BX
// g0.stackguard0=SP-64*1024+104
MOVQ BX, g_stackguard0(DI)
// g0.stackguard1=SP-64*1024+104
MOVQ BX, g_stackguard1(DI)
// g0.stack.lo=SP-64*1024+104,表示当前栈的底部
MOVQ BX, (g_stack+stack_lo)(DI)
// g0.stack.hi=SP,表示当前栈的顶部
MOVQ SP, (g_stack+stack_hi)(DI)

该段主要为 g0 开辟空间,并初始化 g0 中与栈边界相关的变量。关于 g0m0 的详细结构及其在调度中的作用,详见 GMP 并发调度模型

CPU 信息检测#

// find out information about the processor we're on
MOVL $0, AX
CPUID
CMPL AX, $0
JE nocpuinfo
CMPL BX, $0x756E6547 // "Genu"
JNE notintel
CMPL DX, $0x49656E69 // "ineI"
JNE notintel
CMPL CX, $0x6C65746E // "ntel"
JNE notintel
MOVB $1, runtime·isIntel(SB)
notintel:
// Load EAX=1 cpuid flags
MOVL $1, AX
CPUID
MOVL AX, runtime·processorVersionInfo(SB)

通过 CPUID 指令获取处理器额外信息,并赋值到 runtime.isIntelruntime.processorVersionInfo 变量。

CGO 初始化#

nocpuinfo:
// if there is an _cgo_init, call it.
MOVQ _cgo_init(SB), AX
TESTQ AX, AX
JZ needtls
// arg 1: g0, already in DI
MOVQ $setg_gcc<>(SB), SI // arg 2: setg_gcc
MOVQ $0, DX // arg 3, 4: not used when using platform's TLS
MOVQ $0, CX
#ifdef GOOS_android
MOVQ $runtime·tls_g(SB), DX // arg 3: &tls_g
// arg 4: TLS base, stored in slot 0 (Android's TLS_SLOT_SELF).
// Compensate for tls_g (+16).
MOVQ -16(TLS), CX
#endif
#ifdef GOOS_windows
MOVQ $runtime·tls_g(SB), DX // arg 3: &tls_g
// Adjust for the Win64 calling convention.
MOVQ CX, R9 // arg 4
MOVQ DX, R8 // arg 3
MOVQ SI, DX // arg 2
MOVQ DI, CX // arg 1
#endif
CALL AX
// update stackguard after _cgo_init
MOVQ $runtime·g0(SB), CX
MOVQ (g_stack+stack_lo)(CX), AX
ADDQ $const__StackGuard, AX
// runtime.go.g_stackguard0 = runtime.go.g_stack.g_stack_lo + $const__StackGuard,该值用于检查 goroutine 栈的下溢
MOVQ AX, g_stackguard0(CX)
// runtime.go.g_stackguard1 = runtime.go.g_stack.g_stack_lo + $const__StackGuard,该值用于检查 goroutine 栈的上溢
MOVQ AX, g_stackguard1(CX)
#ifndef GOOS_windows
JMP ok
#endif

这一段是关于 CGO 的初始化,如果 _cgo_init(SB) 为 nil,则进行跳过,直接开始下一阶段,即 TLS 的初始化;否则根据平台约定传递参数进行 _cgo_init 的调用;调用后再更新 g0 中的堆栈检查边界

TLS 初始化与 g0/m0 绑定#

#ifndef GOOS_windows
JMP ok
#endif
needtls:
#ifdef GOOS_plan9
// skip TLS setup on Plan 9
JMP ok
#endif
#ifdef GOOS_solaris
// skip TLS setup on Solaris
JMP ok
#endif
#ifdef GOOS_illumos
// skip TLS setup on illumos
JMP ok
#endif
#ifdef GOOS_darwin
// skip TLS setup on Darwin
JMP ok
#endif
#ifdef GOOS_openbsd
// skip TLS setup on OpenBSD
JMP ok
#endif
#ifdef GOOS_windows
CALL runtime·wintls(SB)
#endif
// 将 runtime.m0.m_tls 的地址赋值给 DI
LEAQ runtime·m0+m_tls(SB), DI
// 设置当前线程的 TLS
CALL runtime·settls(SB)
// store through it, to make sure it works
// 调用 get_tls 函数,它将当前线程的 TLS 数据指针存储在 BX 寄存器中
get_tls(BX)
// 通过 TLS 数据指针,将值 0x123 存储在 g 变量中,以验证 TLS 是否工作正常
MOVQ $0x123, g(BX)
// 将 runtime.m0.m_tls 字段的值加载到 AX 寄存器中
MOVQ runtime·m0+m_tls(SB), AX
// 验证值是否是 $0x123
CMPQ AX, $0x123
// 如果值等于 $123,也就是 TLS 工作正常,则跳过下一条指令
JEQ 2(PC)
// TLS 异常,程序中止
CALL runtime·abort(SB)
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)
CLD // convention is D is always left cleared

该段代码根据操作系统决定是否跳过 TLS 初始化,目前存在三种情况:

  • 若为 Windows,则会调用 runtime.wintls 设置 Windows 线程的 TLS,再通过 runtime·settls 设置和使用 TLS
  • 若为 Plan9/Solaris/Illumos/Darwin/OpenBSD,则会跳过 TLS 初始化
  • 其他情况则直接通过 runtime·settls 设置和使用 TLS

TLS 初始化并验证成功后,将 g0 赋值到 TLS 的 g 变量和 runtime.m0.m_g0 上,同时将 m0 赋值到 g0m 上。此时 g0m0 相互绑定。

CPU 特性检查#

// Check GOAMD64 reqirements
// We need to do this after setting up TLS, so that
// we can report an error if there is a failure. See issue 49586.
#ifdef NEED_FEATURES_CX
MOVL $0, AX
CPUID
CMPL AX, $0
JE bad_cpu
MOVL $1, AX
CPUID
ANDL $NEED_FEATURES_CX, CX
CMPL CX, $NEED_FEATURES_CX
JNE bad_cpu
#endif
#ifdef NEED_MAX_CPUID
MOVL $0x80000000, AX
CPUID
CMPL AX, $NEED_MAX_CPUID
JL bad_cpu
#endif
#ifdef NEED_EXT_FEATURES_BX
MOVL $7, AX
MOVL $0, CX
CPUID
ANDL $NEED_EXT_FEATURES_BX, BX
CMPL BX, $NEED_EXT_FEATURES_BX
JNE bad_cpu
#endif
#ifdef NEED_EXT_FEATURES_CX
MOVL $0x80000001, AX
CPUID
ANDL $NEED_EXT_FEATURES_CX, CX
CMPL CX, $NEED_EXT_FEATURES_CX
JNE bad_cpu
#endif
#ifdef NEED_OS_SUPPORT_AX
XORL CX, CX
XGETBV
ANDL $NEED_OS_SUPPORT_AX, AX
CMPL AX, $NEED_OS_SUPPORT_AX
JNE bad_cpu
#endif
#ifdef NEED_DARWIN_SUPPORT
MOVQ $commpage64_version, BX
CMPW (BX), $13 // cpu_capabilities64 undefined in versions < 13
JL bad_cpu
MOVQ $commpage64_cpu_capabilities64, BX
MOVQ (BX), BX
MOVQ $NEED_DARWIN_SUPPORT, CX
ANDQ CX, BX
CMPQ BX, CX
JNE bad_cpu
#endif
bad_cpu: // show that the program requires a certain microarchitecture level.
MOVQ $2, 0(SP)
MOVQ $bad_cpu_msg<>(SB), AX
MOVQ AX, 8(SP)
MOVQ $84, 16(SP)
CALL runtime·write(SB)
MOVQ $1, 0(SP)
CALL runtime·exit(SB)
CALL runtime·abort(SB)
RET
// Prevent dead-code elimination of debugCallV2, which is
// intended to be called by debuggers.
MOVQ $runtime·debugCallV2<ABIInternal>(SB), AX
RET

该段代码的主要目的是检查当前 CPU 和操作系统是否满足运行 Go 程序的要求,并初始化 Go 运行时环境。若检测到不兼容情况,将显示错误消息并终止程序。

六、runtime 初始化调用链#

最后一段代码回到程序入口的主线:

// 执行运行时检查,以确保环境符合 Go 运行时的要求
CALL runtime·check(SB)
// 将之前后移的 argc 和 argv 复制到栈顶
MOVL 24(SP), AX // copy argc
MOVL AX, 0(SP)
MOVQ 32(SP), AX // copy argv
MOVQ AX, 8(SP)
// 调用 runtime·args 函数,这个函数处理命令行参数和环境变量,将它们设置为 Go 程序可以使用的格式
CALL runtime·args(SB)
// 调用 runtime·osinit 函数,这个函数初始化操作系统相关的功能,如信号处理
CALL runtime·osinit(SB)
// 调用 runtime·schedinit 函数,这个函数初始化 Go 运行时的调度器
CALL runtime·schedinit(SB)
// create a new goroutine to start program
MOVQ $runtime·mainPC(SB), AX // entry
PUSHQ AX
// 调用 runtime·newproc 函数,创建一个新的 goroutine 来执行 main 函数
CALL runtime·newproc(SB)
POPQ AX
// start this M
// 调用 runtime·mstart 函数,开始执行 Go 运行时的 M(机器),即启动 Go 调度器
CALL runtime·mstart(SB)
// mstart 函数返回,程序中止
CALL runtime·abort(SB) // mstart should never return
RET

在上述 runtime·rt0_go 函数中,我忽略了一些系统校验和初始化过程。可以看到该函数依次调用了以下函数:

  • runtime.args:传递系统参数
  • runtime.osinit:进行操作系统相关的初始化(目前仅获取 CPU 核心数和物理页大小,赋值给全局变量供后续核心组件使用)
  • runtime.schedinit:初始化命令行参数、环境变量、GC、栈空间、内存管理、所有 P 实例、HASH 算法等。其中内存管理初始化(mallocinit)详见 Go 内存管理深度解析,GC 初始化(gcinit)详见 Go GC 机制深度解析
  • runtime.newproc:创建一个新的 goroutine,参数为 $runtime·mainPC(SB),即 runtime.main 函数
  • runtime.mstart:启动系统线程 m,调度器开始循环调度。GMP 调度模型的工作原理详见 GMP 并发调度模型
  • runtime.abort:中止程序,卸载运行时

runtime.args:系统参数传递#

源码参考: runtime/runtime.go

var (
argc int32
argv **byte
)
func args(c int32, v **byte) {
argc = c
argv = v
// argc(命令行参数数量),argv(命令行参数数组指针),envp(环境变量数组指针),auxv(辅助向量,用于传递系统特定的信息)
sysargs(c, v)
}

该函数将 argcargv 复制到全局变量中,供后续函数使用,并通过 sysargs 函数解析 envpauxv 等系统参数。

runtime.osinit:操作系统级初始化#

源码参考: runtime/os_linux.go

func osinit() {
// 获取 CPU 核心数,存储到 ncpu 全局变量中
ncpu = getproccount()
// 获取物理内存页大小,存储到 physHugePageSize 全局变量中
physHugePageSize = getHugePageSize()
// 如果开启了 cgo 则删除信号集中的 32 33 34,避免潜在的死锁问题
if iscgo {
// #42494 glibc and musl reserve some signals for
// internal use and require they not be blocked by
// the rest of a normal C runtime. When the go runtime
// blocks...unblocks signals, temporarily, the blocked
// interval of time is generally very short. As such,
// these expectations of *libc code are mostly met by
// the combined go+cgo system of threads. However,
// when go causes a thread to exit, via a return from
// mstart(), the combined runtime can deadlock if
// these signals are blocked. Thus, don't block these
// signals when exiting threads.
// - glibc: SIGCANCEL (32), SIGSETXID (33)
// - musl: SIGTIMER (32), SIGCANCEL (33), SIGSYNCCALL (34)
sigdelset(&sigsetAllExiting, 32)
sigdelset(&sigsetAllExiting, 33)
sigdelset(&sigsetAllExiting, 34)
}
// 空实现
osArchInit()
}

runtime.schedinit:调度器与 runtime 全面初始化#

源码参考: runtime/proc.go

这是启动过程中最关键的函数之一,负责初始化 Go runtime 的所有核心组件:

// The bootstrap sequence is:
//
// call osinit
// call schedinit
// make & queue new G
// call runtime·mstart
//
// The new G calls runtime·main.
func schedinit() {
// 初始化调度器需要用到的各种锁
lockInit(&sched.lock, lockRankSched)
lockInit(&sched.sysmonlock, lockRankSysmon)
lockInit(&sched.deferlock, lockRankDefer)
lockInit(&sched.sudoglock, lockRankSudog)
lockInit(&deadlock, lockRankDeadlock)
lockInit(&paniclk, lockRankPanic)
lockInit(&allglock, lockRankAllg)
lockInit(&allpLock, lockRankAllp)
lockInit(&reflectOffs.lock, lockRankReflectOffs)
lockInit(&finlock, lockRankFin)
lockInit(&trace.bufLock, lockRankTraceBuf)
lockInit(&trace.stringsLock, lockRankTraceStrings)
lockInit(&trace.lock, lockRankTrace)
lockInit(&cpuprof.lock, lockRankCpuprof)
lockInit(&trace.stackTab.lock, lockRankTraceStackTab)
// Enforce that this lock is always a leaf lock.
// All of this lock's critical sections should be
// extremely short.
lockInit(&memstats.heapStats.noPLock, lockRankLeafRank)
// raceinit must be the first call to race detector.
// In particular, it must be done before mallocinit below calls racemapshadow.
gp := getg()
if raceenabled {
gp.racectx, raceprocctx0 = raceinit()
}
// 最大线程数量,写死为 10000
sched.maxmcount = 10000
// The world starts stopped.
worldStopped()
moduledataverify()
stackinit()
mallocinit()
godebug := getGodebugEarly()
initPageTrace(godebug) // must run after mallocinit but before anything allocates
cpuinit(godebug) // must run before alginit
alginit() // maps, hash, fastrand must not be used before this call
fastrandinit() // must run before mcommoninit
mcommoninit(gp.m, -1)
modulesinit() // provides activeModules
typelinksinit() // uses maps, activeModules
itabsinit() // uses activeModules
stkobjinit() // must run before GC starts
sigsave(&gp.m.sigmask)
initSigmask = gp.m.sigmask
goargs()
goenvs()
secure()
parsedebugvars()
// 初始化垃圾收集器
gcinit()
// if disableMemoryProfiling is set, update MemProfileRate to 0 to turn off memprofile.
// Note: parsedebugvars may update MemProfileRate, but when disableMemoryProfiling is
// set to true by the linker, it means that nothing is consuming the profile, it is
// safe to set MemProfileRate to 0.
if disableMemoryProfiling {
MemProfileRate = 0
}
lock(&sched.lock)
sched.lastpoll.Store(nanotime())
procs := ncpu
// 设置 P 的数量
if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
procs = n
}
if procresize(procs) != nil {
throw("unknown runnable goroutine during bootstrap")
}
unlock(&sched.lock)
// World is effectively started now, as P's can run.
worldStarted()
// For cgocheck > 1, we turn on the write barrier at all times
// and check all pointer writes. We can't do this until after
// procresize because the write barrier needs a P.
if debug.cgocheck > 1 {
writeBarrier.cgo = true
writeBarrier.enabled = true
for _, pp := range allp {
pp.wbBuf.reset()
}
}
if buildVersion == "" {
// Condition should never trigger. This code just serves
// to ensure runtime·buildVersion is kept in the resulting binary.
buildVersion = "unknown"
}
if len(modinfo) == 1 {
// Condition should never trigger. This code just serves
// to ensure runtime·modinfo is kept in the resulting binary.
modinfo = ""
}
}

其中,由于 getg 的实现与系统架构和指令集紧密相关,涉及 TLS(线程本地存储)和 goroutine 的管理,因此其源码需要在 compile 包中通过 OpGetG 关键字搜索查阅。此处不再展开。

schedinit 初始化顺序一览#

schedinit 中的初始化顺序至关重要,每个步骤都有明确的依赖关系:

// 关键初始化顺序(简化)
stackinit() // 栈空间分配器 — 依赖无
mallocinit() // 内存分配器 — 依赖 stackinit
cpuinit() // CPU 特性检测 — 依赖 mallocinit
alginit() // 哈希算法初始化 — 依赖 cpuinit
fastrandinit() // 随机数生成器 — 依赖 alginit
mcommoninit() // M 初始化 — 依赖 fastrandinit
gcinit() // GC 初始化 — 依赖 mallocinit
procresize() // P 数量调整 — 依赖 gcinit

源码参考: runtime/proc.go — schedinit

七、runtime.main:用户代码的起点#

源码参考: runtime/proc.go — main

接下来我们主要查看 runtime.main:

//go:linkname main_main main.main
func main_main()
// The main goroutine.
func main() {
mp := getg().m
// Racectx of m0->g0 is used only as the parent of the main goroutine.
// It must not be used for anything else.
mp.g0.racectx = 0
// Max stack size is 1 GB on 64-bit, 250 MB on 32-bit.
// Using decimal instead of binary GB and MB because
// they look nicer in the stack overflow failure message.
if goarch.PtrSize == 8 {
maxstacksize = 1000000000
} else {
maxstacksize = 250000000
}
// An upper limit for max stack size. Used to avoid random crashes
// after calling SetMaxStack and trying to allocate a stack that is too big,
// since stackalloc works with 32-bit sizes.
maxstackceiling = 2 * maxstacksize
// Allow newproc to start new Ms.
mainStarted = true
if GOARCH != "wasm" { // no threads on wasm yet, so no sysmon
systemstack(func() {
newm(sysmon, nil, -1)
})
}
// Lock the main goroutine onto this, the main OS thread,
// during initialization. Most programs won't care, but a few
// do require certain calls to be made by the main thread.
// Those can arrange for main.main to run in the main thread
// by calling runtime.LockOSThread during initialization
// to preserve the lock.
lockOSThread()
if mp != &m0 {
throw("runtime.main not on m0")
}
// Record when the world started.
// Must be before doInit for tracing init.
runtimeInitTime = nanotime()
if runtimeInitTime == 0 {
throw("nanotime returning zero")
}
if debug.inittrace != 0 {
inittrace.id = getg().goid
inittrace.active = true
}
doInit(&runtime_inittask) // Must be before defer.
// Defer unlock so that runtime.Goexit during init does the unlock too.
needUnlock := true
defer func() {
if needUnlock {
unlockOSThread()
}
}()
gcenable()
main_init_done = make(chan bool)
if iscgo {
if _cgo_thread_start == nil {
throw("_cgo_thread_start missing")
}
if GOOS != "windows" {
if _cgo_setenv == nil {
throw("_cgo_setenv missing")
}
if _cgo_unsetenv == nil {
throw("_cgo_unsetenv missing")
}
}
if _cgo_notify_runtime_init_done == nil {
throw("_cgo_notify_runtime_init_done missing")
}
// Start the template thread in case we enter Go from
// a C-created thread and need to create a new thread.
startTemplateThread()
cgocall(_cgo_notify_runtime_init_done, nil)
}
doInit(&main_inittask)
// Disable init tracing after main init done to avoid overhead
// of collecting statistics in malloc and newproc
inittrace.active = false
close(main_init_done)
needUnlock = false
unlockOSThread()
if isarchive || islibrary {
// A program compiled with -buildmode=c-archive or c-shared
// has a main, but it is not executed.
return
}
fn := main_main // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtime
fn()
if raceenabled {
runExitHooks(0) // run hooks now, since racefini does not return
racefini()
}
// Make racy client program work: if panicking on
// another goroutine at the same time as main returns,
// let the other goroutine finish printing the panic trace.
// Once it does, it will exit. See issues 3934 and 20018.
if runningPanicDefers.Load() != 0 {
// Running deferred functions should not take long.
for c := 0; c < 1000; c++ {
if runningPanicDefers.Load() == 0 {
break
}
Gosched()
}
}
if panicking.Load() != 0 {
gopark(nil, nil, waitReasonPanicWait, traceEvGoStop, 1)
}
runExitHooks(0)
exit(0)
for {
var x *int32
*x = 0
}
}

注意,以上函数体中的 main_main 即是用户编写的 main 函数,它通过 go:linkname main_main main.main 指令链接到用户编写的 main 函数。

runtime.main 的执行流程#

runtime.main 函数的执行过程可以总结为以下几个关键步骤:

  1. 设置栈大小限制:64 位系统上限 1GB,32 位系统上限 250MB
  2. 启动 sysmon 系统监控:独立线程,负责抢占调度、netpoll 轮询、GC 触发等
  3. 执行 runtime init 函数doInit(&runtime_inittask) 运行 runtime 包中的 init() 函数
  4. 启动 GCgcenable() 开启并发垃圾回收
  5. 执行用户 init 函数doInit(&main_inittask) 运行用户包中的所有 init() 函数
  6. 调用用户 mainmain_main() 即用户编写的 main() 函数
  7. 退出exit(0) 终止进程,最后的 for 循环确保进程不会返回到 runtime

八、g0 与 m0:启动阶段的特殊角色#

runtime.main 中我们还看到了 g0m0 这两个比较特殊的对象。g0 是每个 M 的系统 goroutine,它不执行用户代码,只负责调度和栈管理;m0 是程序启动时的初始 M。

g0m0 在程序启动阶段的绑定过程已在上一节的汇编代码中展示:TLS 初始化完成后,通过以下指令完成绑定:

// 将 g0 存入 TLS
get_tls(BX)
LEAQ runtime·g0(SB), CX
MOVQ CX, g(BX)
// m0.g0 = g0
LEAQ runtime·m0(SB), AX
MOVQ CX, m_g0(AX)
// g0.m = m0
MOVQ AX, g_m(CX)

源码参考: runtime/runtime2.go — g0/m0 结构体定义

九、Go 程序启动全景图#

下面的 Mermaid 图展示了 Go 程序从操作系统加载到用户 main 执行的完整启动流程:

flowchart TD A["OS 加载 ELF<br/>Entry: _rt0_amd64_linux"] --> B["_rt0_amd64<br/>获取 argc/argv"] B --> C["rt0_go<br/>栈对齐 + 参数保存"] C --> D["初始化 g0 栈<br/>64KB 系统栈"] D --> E["CPUID 检测<br/>isIntel / processorVersionInfo"] E --> F{"CGO?"} F -->|"Yes"| G["_cgo_init<br/>初始化 CGO 运行时"] F -->|"No"| H["TLS 初始化<br/>settls"] G --> H H --> I["g0 ↔ m0 绑定<br/>g0.m = m0, m0.g0 = g0"] I --> J["CPU 特性检查<br/>GOAMD64 验证"] J --> K["runtime.check<br/>运行时自检"] K --> L["runtime.args<br/>解析命令行参数"] L --> M["runtime.osinit<br/>获取 ncpu / physHugePageSize"] M --> N["runtime.schedinit<br/>调度器全面初始化"] N --> O["runtime.newproc<br/>创建主 goroutine"] O --> P["runtime.mstart<br/>启动调度循环"] P --> Q["runtime.main<br/>主 goroutine 执行"] Q --> R["doInit runtime_inittask<br/>runtime init 函数"] R --> S["gcenable<br/>启动 GC"] S --> T["doInit main_inittask<br/>用户 init 函数"] T --> U["main.main<br/>用户代码"]

这个全景图清晰地展示了 Go 程序启动的三个阶段:

  1. 汇编入口阶段_rt0_amd64_linuxrt0_go):栈初始化、TLS 设置、g0/m0 绑定
  2. runtime 初始化阶段runtime.checkruntime.schedinit):内存分配器、调度器、GC 等核心组件初始化
  3. 用户代码阶段runtime.mainmain.main):init 函数执行、GC 启用、最终调用用户 main

十、常见问题#

Q1:Go 程序的入口函数是 main.main 吗?#

不是。Go 程序的真正入口是 _rt0_amd64_linux(Linux amd64 平台),经过汇编初始化、runtime 初始化(runtime.schedinit)后,才由 runtime.main 调用用户定义的 main.mainmain.main 只是用户代码的入口,而非程序入口。

Q2:g0 和 m0 是什么?为什么需要它们?#

g0 是每个 M(操作系统线程)的”系统栈”goroutine,拥有 64KB 栈空间,用于执行调度器代码、栈增长、GC 等不能在用户栈上执行的操作。m0 是 Go runtime 启动时创建的第一个 M,与 g0 绑定后负责完成初始化流程。它们是 runtime 调度的基础设施,用户代码不会直接接触。

Q3:runtime.schedinit 初始化了哪些核心组件?#

schedinit 几乎初始化了 runtime 的所有核心子系统:内存分配器(mallocinit)、调度器(schedinit 内部的 P 初始化)、GC(gcinit)、栈管理(stackinit)、信号处理(initsig)等。它是 Go 程序从”裸机”状态进入”可调度”状态的。

Q4:init 函数的执行顺序是什么?#

Go 规定了 init 函数的执行顺序:先执行被导入包的 init,再执行导入者的 init;同一文件内多个 init 按声明顺序执行。在启动流程中,runtime.main 先执行 runtime_inittask(runtime 包的 init),再启用 GC,最后执行 main_inittask(用户包的 init)。

Q5:为什么 Go 程序启动需要 STW(Stop The World)?#

启动过程中的 STW 主要出现在 runtime 初始化阶段,确保在调度器、内存分配器等核心组件尚未就绪时,不会有其他 goroutine 并发运行导致数据竞争。一旦 runtime.mstart 启动调度循环,程序就进入并发执行阶段。

小结#

  1. Go 程序入口不是 main.main,而是平台相关的汇编入口 _rt0_amd64_linux,经过 rt0_goruntime.schedinitruntime.main 才到达用户代码
  2. 启动分为三个阶段:汇编入口阶段(栈初始化、TLS 设置、g0/m0 绑定)、runtime 初始化阶段(内存分配器、调度器、GC 初始化)、用户代码阶段(init 执行、main 调用)
  3. g0 是调度器的系统栈,所有调度决策、栈增长、GC 操作都在 g0 上执行,与用户 goroutine 隔离
  4. schedinit 是 runtime 的”总装车间”,一次性初始化内存、调度、GC、信号等所有核心子系统
  5. init 函数有严格执行顺序:依赖包先于导入包,runtime init 先于用户 init,GC 在用户 init 之前启用

理解 Go 程序的启动流程,是深入理解 runtime 调度、内存管理和 GC 机制的基础。下一篇文章深入 GMP 调度模型,看看 goroutine 是如何被高效调度的。

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

Go 程序的启动
https://blog.souloss.com/posts/golang/go-start/
作者
Souloss
发布于
2022-05-05
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时