[TOC]
本节讨论程序引导流程。
首先,我们需要寻找程序的入口,通过编写下面的程序:
package main
func main() {
println("hello, world!")
}
编译:
go build -gcflags "-N -l" -ldflags=-compressdwarf=false -o main main.go
-gcflags "-N -l"
用于关闭编译器代码优化与函数内联。此外还需注意,Go 1.11 开始将调试信息压缩为 DWARF,macOS 下的 gdb 不能解释 DWARF。 因此需要使用 GDB 调试需要增加
-ldflags=-compressdwarf=false
。
通过 gdb 调试:
$ gdb main
(...)
(gdb) info files
Symbols from "/Users/changkun/dev/go-under-the-hood/demo/1-boot/main".
Local exec file:
`/Users/changkun/dev/go-under-the-hood/demo/1-boot/main', file type mach-o-x86-64.
Entry point: 0x1049e20
0x0000000001001000 - 0x000000000104dfcf is .text
(...)
(gdb) b *0x1049e20
Breakpoint 1 at 0x1049e20: file /usr/local/Cellar/go/1.11/libexec/src/runtime/rt0_darwin_amd64.s, line 8.
可以看到,程序的入口在 rt0_darwin_amd64.s
第八行,即:
TEXT _rt0_amd64_darwin(SB),NOSPLIT,$-8
JMP _rt0_amd64(SB)
进而跳转到 asm_amd64.s
:
// _rt0_amd64 是 amd64 系统上使用内部链接时候常见的引导代码。
// 这是该程序从内核到普通 -buildmode=exe 程序的入口。
// 栈保存了参数的数量以及 C 风格的 argv
TEXT _rt0_amd64(SB),NOSPLIT,$-8
MOVQ 0(SP), DI // argc
LEAQ 8(SP), SI // argv
JMP runtime·rt0_go(SB)
从汇编的 JMP
指令可以看出,程序会立即跳转到 runtime.rt0_go
,它便完成了整个
运行时的调用:
TEXT runtime·rt0_go(SB),NOSPLIT,$0
// 将参数向前复制到一个偶数栈上
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)
// 从给定(操作系统)栈中创建 istack。
// _cgo_init 可能更新 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)
// 寻找正在运行的处理器信息
MOVL $0, AX
CPUID
MOVL AX, SI
// CPU 相关的一些检测
(...)
#ifdef GOOS_darwin
// 跳过 TLS 设置 on Darwin
JMP ok
#endif
LEAQ runtime·m0+m_tls(SB), DI
CALL runtime·settls(SB)
// 使用它进行存储,确保能正常运行
get_tls(BX)
MOVQ $0x123, g(BX)
MOVQ runtime·m0+m_tls(SB), AX
CMPQ AX, $0x123
JEQ 2(PC)
CALL runtime·abort(SB)
ok:
// 程序刚刚启动,此时位于主 OS 线程
// 设置 per-goroutine 和 per-mach 寄存器
get_tls(BX)
LEAQ runtime·g0(SB), CX
MOVQ CX, g(BX)
LEAQ runtime·m0(SB), AX
// 保存 m->g0 = g0
MOVQ CX, m_g0(AX)
// 保存 m0 to g0->m
MOVQ AX, g_m(CX)
CLD // 约定 D 总是被清除
CALL runtime·check(SB)
MOVL 16(SP), AX // 复制 argc
MOVL AX, 0(SP)
MOVQ 24(SP), AX // 复制 argv
MOVQ AX, 8(SP)
CALL runtime·args(SB)
CALL runtime·osinit(SB)
CALL runtime·schedinit(SB)
// 创建一个新的 goroutine 来启动程序
MOVQ $runtime·mainPC(SB), AX // 入口
PUSHQ AX
PUSHQ $0 // 参数大小
CALL runtime·newproc(SB)
POPQ AX
POPQ AX
// 启动这个 M
CALL runtime·mstart(SB)
CALL runtime·abort(SB) // mstart 应该永不返回
RET
(...)
RET
DATA runtime·mainPC+0(SB)/8,$runtime·main(SB)
GLOBL runtime·mainPC(SB),RODATA,$8
对于 Linux 而言,这个过程基本类似,这里不再赘述。
从上面的汇编代码我们可以看出,整个准备过程按照如下顺序进行:
runtime.g0
、runtime.m0
是一组全局变量,在程序运行之初就已经存在。
除了程序参数外,会首先将 m0 与 g0 互相关联(在调度器:基本结构中讨论 M 与 G 之间的关系)。
位于
runtime/runtime1.go
runtime.check
位于进行类型检查,
基本上属于对编译器翻译工作的一个校验,确保运行时类型正确。
这里粗略展示整个函数的内容:
func check() {
var (
a int8
b uint8
(...)
)
(...)
// 校验 int8 类型 sizeof 是否为 1,下同
if unsafe.Sizeof(a) != 1 {
throw("bad a")
}
if unsafe.Sizeof(b) != 1 {
throw("bad b")
}
(...)
}
位于
runtime/runtime1.go
接下来我们看到 argc, argv
作为参数传递给 runtime.args
处理程序参数的相关事宜。
func args(c int32, v **byte) {
argc = c
argv = v
sysargs(c, v)
}
runtime.args
函数将参数指针保存到了 argc
和 argv
这两个全局变量中,供其他初始化函数使用。
而后调用了平台特定的 runtime.sysargs
。
在 darwin 平台中,只负责获取程序的 executable_path
:
位于
runtime/os_darwin.go
//go:nosplit
func argv_index(argv **byte, i int32) *byte {
return *(**byte)(add(unsafe.Pointer(argv), uintptr(i)*sys.PtrSize))
}
// 链接到 os 包
//go:linkname executablePath os.executablePath
var executablePath string
func sysargs(argc int32, argv **byte) {
// 跳过 argv, envv 与第一个字符串为路径
n := argc + 1
for argv_index(argv, n) != nil {
n++
}
executablePath = gostringnocopy(argv_index(argv, n+1))
// 移除 "executable_path=" 前缀(OS X 10.11 之后存在)
const prefix = "executable_path="
if len(executablePath) > len(prefix) && executablePath[:len(prefix)] == prefix {
executablePath = executablePath[len(prefix):]
}
}
这个参数用于设置 os
包中的 executablePath
变量。
而在 Linux 平台中,这个过程就变得复杂起来了。
与 darwin 使用 mach-o
不同,Linux 使用 ELF 格式 [4, 6]。
ELF 除了 argc, argv, envp 之外,会携带辅助向量(auxiliary vector)将某些内核级的信息 传递给用户进程。具体结构如图 1 所示。
position content size (bytes) + comment
------------------------------------------------------------------------
stack pointer -> [ argc = number of args ] 4
[ argv[0] (pointer) ] 4 (program name)
[ argv[1] (pointer) ] 4
[ argv[..] (pointer) ] 4 * x
[ argv[n - 1] (pointer) ] 4
[ argv[n] (pointer) ] 4 (= NULL)
[ envp[0] (pointer) ] 4
[ envp[1] (pointer) ] 4
[ envp[..] (pointer) ] 4
[ envp[term] (pointer) ] 4 (= NULL)
[ auxv[0] (Elf32_auxv_t) ] 8
[ auxv[1] (Elf32_auxv_t) ] 8
[ auxv[..] (Elf32_auxv_t) ] 8
[ auxv[term] (Elf32_auxv_t) ] 8 (= AT_NULL vector)
[ padding ] 0 - 16
[ argument ASCIIZ strings ] >= 0
[ environment ASCIIZ str. ] >= 0
(0xbffffffc) [ end marker ] 4 (= NULL)
(0xc0000000) < bottom of stack > 0 (virtual)
------------------------------------------------------------------------
图1:进程栈结构,引自 [5]
位于
runtime/os_linux.go
对照词表,我们能够很容易的看明白 runtime.sysargs
在 Linux amd64 下作的事情:
// physPageSize 是操作系统的物理页字节大小。内存页的映射和反映射操作必须以
// physPageSize 的整数倍完成
var physPageSize uintptr
func sysargs(argc int32, argv **byte) {
n := argc + 1
// 跳过 argv, envp 来获取 auxv
for argv_index(argv, n) != nil {
n++
}
// 跳过 NULL 分隔符
n++
// 现在 argv+n 即为 auxv
auxv := (*[1 << 28]uintptr)(add(unsafe.Pointer(argv), uintptr(n)*sys.PtrSize))
// 如果此时 auxv 读取成功,则直接返回,sysauxv 函数见下方。
if sysauxv(auxv[:]) != 0 {
return
}
// 某些情况下,我们无法获取装载器提供的 auxv,例如 Android 上的装载器
// 为一个库文件。这时退回到 /proc/self/auxv
// 使用 open 系统调用打开文件
fd := open(&procAuxv[0], 0 /* O_RDONLY */, 0)
// 若 /proc/self/auxv 打开也失败了
if fd < 0 {
// 在 Android 下,/proc/self/auxv 可能不可读取(见 #9229),因此我们再回退到
// 通过 mincore 来检测物理页的大小。
// mincore 会在地址不是系统页大小的倍数时返回 EINVAL。
const size = 256 << 10 // 需要分配的内存大小
// 此时内存管理尚未启动,直接走系统调用
// 使用 mmap 系统调用分配内存
p, err := mmap(nil, size, _PROT_READ|_PROT_WRITE, _MAP_ANON|_MAP_PRIVATE, -1, 0)
if err != 0 {
return
}
var n uintptr
for n = 4 << 10; n < size; n <<= 1 {
err := mincore(unsafe.Pointer(uintptr(p)+n), 1, &addrspace_vec[0])
if err == 0 {
// 如果没有出现错误,则说明此时的 n 是系统内存页的整数倍,我们便已第一次拿到的 n 作为 go 运行时的
// 物理页的大小
physPageSize = n
break
}
}
// 如果遍历完后仍然无法得到物理页的大小,则直接以 size 的大小作为物理页的大小。
if physPageSize == 0 {
physPageSize = size
}
// 使用 munmap 释放分配的内存,这时已经确定好系统页的大小,直接返回。
munmap(p, size)
return
}
// 打开文件成功,我们从文件中读取 auxv 信息。
var buf [128]uintptr
n = read(fd, noescape(unsafe.Pointer(&buf[0])), int32(unsafe.Sizeof(buf)))
closefd(fd)
if n < 0 {
return
}
// 即便我们无法读取整个文件,也要确保 buf 已经被终止
buf[len(buf)-2] = _AT_NULL
sysauxv(buf[:]) // 调用并确定物理页的大小,读取 vdso 表
}
func sysauxv(auxv []uintptr) int {
var i int
// 依次读取 auxv 键值对
for ; auxv[i] != _AT_NULL; i += 2 {
tag, val := auxv[i], auxv[i+1]
switch tag {
case _AT_RANDOM:
// 内核提供了一个指针,指向16字节的随机数据
startupRandomData = (*[16]byte)(unsafe.Pointer(val))[:]
case _AT_PAGESZ:
// 读取内存页的大小
physPageSize = val
// 这里其实也可能出现无法读取到物理页大小的情况,但后续再内存分配器初始化的时候还会对
// physPageSize 的大小进行检查,如果读取失败则无法运行程序,从而抛出运行时错误
}
archauxv(tag, val) // amd64 下什么也不做的空函数
vdsoauxv(tag, val) // 读取 vdso 表
}
return i / 2
}
其中涉及 mmap、mincore、munmap 等系统调用,见 [7, 8]。对于 vdso 表,已经超出我们的关注点,
这里暂不讨论 vdso 的细节,但看 runtime.vdsoauxv
函数而言,
只是去解析 _At_SYSINFO_EHDR
标签对应的值。
位于
runtime/vdso_linux.go
func vdsoauxv(tag, val uintptr) {
switch tag {
case _AT_SYSINFO_EHDR:
if val == 0 {
// 出错了
return
}
var info vdsoInfo
// 此处可能存在编译器 BUG,这里调用 noescape 的目的仅仅只是让编译器认为此处 info 不是逃逸
info1 := (*vdsoInfo)(noescape(unsafe.Pointer(&info)))
vdsoInitFromSysinfoEhdr(info1, (*elfEhdr)(unsafe.Pointer(val)))
vdsoParseSymbols(info1, vdsoFindVersion(info1, &vdsoLinuxVersion))
}
}
runtime.osinit
(runtime/os_darwin.go
)在不同平台上实现略有不同,
但所有的平台都会做的一件事情是:获得 CPU 核心数,这与调度器有关。
darwin 上由于使用的是 mach-o
格式,在此前的 runtime.sysargs
上还没有确定内存页的大小,
因而在这个函数中,还会额外使用 sysctl
完成物理页大小的查询。
var ncpu int32
// linux
func osinit() {
ncpu = getproccount()
}
// darwin
func osinit() {
ncpu = getncpu()
physPageSize = getPageSize()
}
func getPageSize() uintptr {
// 使用 sysctl 来获取 hw.pagesize.
mib := [2]uint32{_CTL_HW, _HW_PAGESIZE}
out := uint32(0)
nout := unsafe.Sizeof(out)
ret := sysctl(&mib[0], 2, (*byte)(unsafe.Pointer(&out)), &nout, nil, 0)
if ret >= 0 && int32(out) > 0 {
return uintptr(out)
}
return 0
}
darwin
从操作系统发展来看,是从 NeXTSTEP 和 FreeBSD 2.x 发展而来的后代,
macOS 系统调用的特殊之处在于它提供了两套调用接口,一个是 Mach 调用,另一个则是 POSIX 调用。
Mach 是 NeXTSTEP 遗留下来的产物,BSD 层本质上是堆 Mach 内核的一层封装。尽管用户态进程
可以直接访问 Mach 调用,但出于通用性的考虑,
这里的物理页大小获取的方式是通过 POSIX sysctl
这个系统调用进行获取 [9, 10]。
事实上 linux
与 darwin
下的系统调用如何参与到 Go 程序中去稍有不同,我们暂时不做深入讨论,留到
参与运行时的系统调用: darwin 中再统一分析。
runtime.schedinit
来进行各种初始化工作,这包括我们的内存分配器、垃圾回收与调度器的初始化,
我们在 初始化概览 中进行详细讨论。
runtime.mainPC
在数据段中被定义为 runtime.main
保存主 goroutine 入口地址:
DATA runtime·mainPC+0(SB)/8,$runtime·main(SB)
其具体过程我们放在 主 goroutine 生命周期 中详细讨论。
简单来说,runtime.newproc
则负责根据主 goroutine 入口地址创建 G 并放至 G 队列中,
这一过程我们在 调度器:初始化 中详细讨论。
runtime.mstart
开始启动调度循环,我们在 调度器:调度循环 中详细讨论。
runtime.abort
这个使用 INT 指令执行中断,最终退出程序,loop 后的无限循环永远不会被执行。
TEXT runtime·abort(SB),NOSPLIT,$0-0
INT $3
loop:
JMP loop
在整个准备过程中,对于 Go 运行时而言,下面这四个函数及其后续调用关系完整 实现了整个 Go 程序的全部运行时机制:
runtime.schedinit
runtime.newproc
runtime.mstart
runtime.main
Go 程序既不是从 main.main
直接启动,也不是从 runtime.main
直接启动。
相反,我们通过 GDB 调试寻找 Go 程序的入口地址,在 darwin/amd64
上发现实际的入口地址
位于 runtime._rt0_amd64_darwin
。随后经过一系列的跳转最终来到 runtime.rt0_go
。
而在这个过程中会完成整个 Go 程序运行时的初始化、内存分配、调度器以及垃圾回收的初始化。 进而开始由调度器转为执行主 goroutine。
- A Quick Guide to Go's Assembler
- A Manual for the Plan 9 assembler
- Debugging Go Code with GDB
- System V Application Binary Interface: AMD64 Architecture Processor Supplement
- About ELF Auxiliary Vectors
- Comparison of executable file formats
- mmap - Linux Programmer's Manual
- mincore - Linux Programmer's Manual
- sysctl - freebsd
- UNIX family tree
Go under the hood | CC-BY-NC-ND 4.0 & MIT © changkun